Compare commits
9 Commits
feature/do
...
67ce2c433c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ce2c433c | ||
|
|
1391374d45 | ||
|
|
8440ac945a | ||
|
|
f4332153f4 | ||
|
|
b9e90e6f46 | ||
|
|
1af79cc019 | ||
|
|
156cc2676d | ||
|
|
897a336d0b | ||
|
|
410b46a896 |
287
CLAUDE.md
287
CLAUDE.md
@@ -61,14 +61,293 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
|
||||
| `frontend/src/api/client.ts` | Axios API client |
|
||||
| `frontend/src/types.ts` | TypeScript interfaces |
|
||||
| `frontend/src/i18n/locales/en.json` | Translations |
|
||||
| `frontend/src/utils/dateUtils.ts` | Date formatting utilities |
|
||||
|
||||
## Key Django Apps
|
||||
## Timezone Architecture (CRITICAL)
|
||||
|
||||
All date/time handling follows this architecture to ensure consistency across timezones.
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Database**: All times stored in UTC
|
||||
2. **API Communication**: Always use UTC (both directions)
|
||||
3. **API Responses**: Include `business_timezone` field
|
||||
4. **Frontend Display**: Convert UTC based on `business_timezone`
|
||||
- If `business_timezone` is set → display in that timezone
|
||||
- If `business_timezone` is null/blank → display in user's local timezone
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
FRONTEND (User in Eastern Time selects "Dec 8, 2:00 PM")
|
||||
↓
|
||||
Convert to UTC: "2024-12-08T19:00:00Z"
|
||||
↓
|
||||
Send to API (always UTC)
|
||||
↓
|
||||
DATABASE (stores UTC)
|
||||
↓
|
||||
API RESPONSE:
|
||||
{
|
||||
"start_time": "2024-12-08T19:00:00Z", // Always UTC
|
||||
"business_timezone": "America/Denver" // IANA timezone (or null for local)
|
||||
}
|
||||
↓
|
||||
FRONTEND CONVERTS:
|
||||
- If business_timezone set: UTC → Mountain Time → "Dec 8, 12:00 PM MST"
|
||||
- If business_timezone null: UTC → User local → "Dec 8, 2:00 PM EST"
|
||||
```
|
||||
|
||||
### Frontend Helper Functions
|
||||
|
||||
Located in `frontend/src/utils/dateUtils.ts`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
toUTC,
|
||||
fromUTC,
|
||||
formatForDisplay,
|
||||
formatDateForDisplay,
|
||||
getDisplayTimezone,
|
||||
} from '../utils/dateUtils';
|
||||
|
||||
// SENDING TO API - Always convert to UTC
|
||||
const apiPayload = {
|
||||
start_time: toUTC(selectedDateTime), // "2024-12-08T19:00:00Z"
|
||||
};
|
||||
|
||||
// RECEIVING FROM API - Convert for display
|
||||
const displayTime = formatForDisplay(
|
||||
response.start_time, // UTC from API
|
||||
response.business_timezone // "America/Denver" or null
|
||||
);
|
||||
// Result: "Dec 8, 2024 12:00 PM" (in business or local timezone)
|
||||
|
||||
// DATE-ONLY fields (time blocks)
|
||||
const displayDate = formatDateForDisplay(
|
||||
response.start_date,
|
||||
response.business_timezone
|
||||
);
|
||||
```
|
||||
|
||||
### API Response Requirements
|
||||
|
||||
All endpoints returning date/time data MUST include:
|
||||
|
||||
```python
|
||||
# In serializers or views
|
||||
{
|
||||
"start_time": "2024-12-08T19:00:00Z",
|
||||
"business_timezone": business.timezone, # "America/Denver" or None
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Serializer Mixin
|
||||
|
||||
Use `TimezoneSerializerMixin` from `core/mixins.py` to automatically add the timezone field:
|
||||
|
||||
```python
|
||||
from core.mixins import TimezoneSerializerMixin
|
||||
|
||||
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = [
|
||||
'id', 'start_time', 'end_time',
|
||||
# ... other fields ...
|
||||
'business_timezone', # Provided by mixin
|
||||
]
|
||||
read_only_fields = ['business_timezone']
|
||||
```
|
||||
|
||||
The mixin automatically retrieves the timezone from the tenant context.
|
||||
- Returns the IANA timezone string if set (e.g., "America/Denver")
|
||||
- Returns `null` if not set (frontend uses user's local timezone)
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
||||
```typescript
|
||||
// BAD - Uses browser local time, not UTC
|
||||
date.toISOString().split('T')[0]
|
||||
|
||||
// BAD - Doesn't respect business timezone setting
|
||||
new Date(utcString).toLocaleString()
|
||||
|
||||
// GOOD - Use helper functions
|
||||
toUTC(date) // For API requests
|
||||
formatForDisplay(utcString, businessTimezone) // For displaying
|
||||
```
|
||||
|
||||
## Django App Organization (Domain-Based)
|
||||
|
||||
Apps are organized into domain packages under `smoothschedule/smoothschedule/`:
|
||||
|
||||
### Identity Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
|
||||
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
|
||||
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
|
||||
| `core` | `identity/core/` | Tenant, Domain, PermissionGrant, middleware, mixins |
|
||||
| `users` | `identity/users/` | User model, authentication, MFA |
|
||||
|
||||
### Scheduling Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `schedule` | `scheduling/schedule/` | Resources, Events, Services, Participants |
|
||||
| `contracts` | `scheduling/contracts/` | Contract/e-signature system |
|
||||
| `analytics` | `scheduling/analytics/` | Business analytics and reporting |
|
||||
|
||||
### Communication Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `notifications` | `communication/notifications/` | Notification system |
|
||||
| `credits` | `communication/credits/` | SMS/calling credits |
|
||||
| `mobile` | `communication/mobile/` | Field employee mobile app |
|
||||
| `messaging` | `communication/messaging/` | Email templates and messaging |
|
||||
|
||||
### Commerce Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `payments` | `commerce/payments/` | Stripe Connect payments bridge |
|
||||
| `tickets` | `commerce/tickets/` | Support ticket system |
|
||||
|
||||
### Platform Domain
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `admin` | `platform/admin/` | Platform administration, subscriptions |
|
||||
| `api` | `platform/api/` | Public API v1 for third-party integrations |
|
||||
|
||||
## Core Mixins & Base Classes
|
||||
|
||||
Located in `smoothschedule/smoothschedule/identity/core/mixins.py`. Use these to avoid code duplication.
|
||||
|
||||
### Permission Classes
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
|
||||
|
||||
class MyViewSet(ModelViewSet):
|
||||
# Block write operations for staff (GET allowed)
|
||||
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
|
||||
|
||||
# Block ALL operations for staff
|
||||
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||
|
||||
# Block list/create/update/delete but allow retrieve
|
||||
permission_classes = [IsAuthenticated, DenyStaffListPermission]
|
||||
```
|
||||
|
||||
#### Per-User Permission Overrides
|
||||
|
||||
Staff permissions can be overridden on a per-user basis using the `user.permissions` JSONField.
|
||||
Permission keys are auto-derived from the view's basename or model name:
|
||||
|
||||
| Permission Class | Auto-derived Key | Example |
|
||||
|-----------------|------------------|---------|
|
||||
| `DenyStaffWritePermission` | `can_write_{basename}` | `can_write_resources` |
|
||||
| `DenyStaffAllAccessPermission` | `can_access_{basename}` | `can_access_services` |
|
||||
| `DenyStaffListPermission` | `can_list_{basename}` or `can_access_{basename}` | `can_list_customers` |
|
||||
|
||||
**Current ViewSet permission keys:**
|
||||
|
||||
| ViewSet | Permission Class | Override Key |
|
||||
|---------|-----------------|--------------|
|
||||
| `ResourceViewSet` | `DenyStaffAllAccessPermission` | `can_access_resources` |
|
||||
| `ServiceViewSet` | `DenyStaffAllAccessPermission` | `can_access_services` |
|
||||
| `CustomerViewSet` | `DenyStaffListPermission` | `can_list_customers` or `can_access_customers` |
|
||||
| `ScheduledTaskViewSet` | `DenyStaffAllAccessPermission` | `can_access_scheduled-tasks` |
|
||||
|
||||
**Granting a specific staff member access:**
|
||||
```bash
|
||||
# Open Django shell
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
# Find the staff member
|
||||
staff = User.objects.get(email='john@example.com')
|
||||
|
||||
# Grant read access to resources
|
||||
staff.permissions['can_access_resources'] = True
|
||||
staff.save()
|
||||
|
||||
# Or grant list access to customers (but not full CRUD)
|
||||
staff.permissions['can_list_customers'] = True
|
||||
staff.save()
|
||||
```
|
||||
|
||||
**Custom permission keys (optional):**
|
||||
```python
|
||||
class ResourceViewSet(ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||
# Override the auto-derived key
|
||||
staff_access_permission_key = 'can_manage_equipment'
|
||||
```
|
||||
|
||||
Then grant via: `staff.permissions['can_manage_equipment'] = True`
|
||||
|
||||
### QuerySet Mixins
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
|
||||
|
||||
# For tenant-scoped models (automatic django-tenants filtering)
|
||||
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
|
||||
queryset = Resource.objects.all()
|
||||
deny_staff_queryset = True # Optional: also filter staff at queryset level
|
||||
|
||||
def filter_queryset_for_tenant(self, queryset):
|
||||
# Override for custom filtering
|
||||
return queryset.filter(is_active=True)
|
||||
|
||||
# For User model (shared schema, needs explicit tenant filter)
|
||||
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
|
||||
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
||||
```
|
||||
|
||||
### Feature Permission Mixins
|
||||
|
||||
```python
|
||||
from smoothschedule.identity.core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
|
||||
|
||||
# Checks can_use_plugins feature on list/retrieve/create
|
||||
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
|
||||
pass
|
||||
|
||||
# Checks both can_use_plugins AND can_use_tasks
|
||||
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, ModelViewSet):
|
||||
pass
|
||||
```
|
||||
|
||||
### Base API Views (for non-ViewSet views)
|
||||
|
||||
```python
|
||||
from rest_framework.views import APIView
|
||||
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||
|
||||
# Optional tenant - use self.get_tenant()
|
||||
class MyView(TenantAPIView, APIView):
|
||||
def get(self, request):
|
||||
tenant = self.get_tenant() # May be None
|
||||
return self.success_response({'data': 'value'})
|
||||
# or: return self.error_response('Something went wrong', status_code=400)
|
||||
|
||||
# Required tenant - self.tenant always available
|
||||
class MyTenantView(TenantRequiredAPIView, APIView):
|
||||
def get(self, request):
|
||||
# self.tenant is guaranteed to exist (returns 400 if missing)
|
||||
return Response({'name': self.tenant.name})
|
||||
```
|
||||
|
||||
### Helper Methods Available
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `self.get_tenant()` | Get tenant from request (may be None) |
|
||||
| `self.get_tenant_or_error()` | Returns (tenant, error_response) tuple |
|
||||
| `self.error_response(msg, status_code)` | Standard error response |
|
||||
| `self.success_response(data, status_code)` | Standard success response |
|
||||
| `self.check_feature(key, name)` | Check feature permission, returns error or None |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
||||
383
PLAN_APP_REORGANIZATION.md
Normal file
383
PLAN_APP_REORGANIZATION.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Django App Reorganization Plan - Option C (Domain-Based)
|
||||
|
||||
## Overview
|
||||
|
||||
Reorganize Django apps from their current scattered locations into a clean domain-based structure within `smoothschedule/smoothschedule/`.
|
||||
|
||||
**Branch:** `refactor/organize-django-apps`
|
||||
**Risk Level:** Medium-High (migration history must be preserved)
|
||||
**Estimated Parallel Agents:** 6-8
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Current App Locations (Inconsistent)
|
||||
|
||||
| App | Current Location | Registered As |
|
||||
|-----|-----------------|---------------|
|
||||
| core | `smoothschedule/core/` | `"core"` |
|
||||
| schedule | `smoothschedule/schedule/` | `"schedule"` |
|
||||
| payments | `smoothschedule/payments/` | `"payments"` |
|
||||
| platform_admin | `smoothschedule/platform_admin/` | `"platform_admin.apps.PlatformAdminConfig"` |
|
||||
| analytics | `smoothschedule/analytics/` | `"analytics"` |
|
||||
| notifications | `smoothschedule/notifications/` | `"notifications"` |
|
||||
| tickets | `smoothschedule/tickets/` | `"tickets"` |
|
||||
| contracts | `smoothschedule/contracts/` | **NOT REGISTERED** |
|
||||
| communication | `smoothschedule/communication/` | **NOT REGISTERED** |
|
||||
| users | `smoothschedule/smoothschedule/users/` | `"smoothschedule.users"` |
|
||||
| comms_credits | `smoothschedule/smoothschedule/comms_credits/` | `"smoothschedule.comms_credits"` |
|
||||
| field_mobile | `smoothschedule/smoothschedule/field_mobile/` | `"smoothschedule.field_mobile"` |
|
||||
| public_api | `smoothschedule/smoothschedule/public_api/` | `"smoothschedule.public_api"` |
|
||||
|
||||
### Migration Counts by App
|
||||
|
||||
| App | Migrations | Complexity |
|
||||
|-----|------------|------------|
|
||||
| core | 22 | High (Tenant model) |
|
||||
| schedule | 30 | High (main business logic) |
|
||||
| payments | 1 | Low |
|
||||
| platform_admin | 12 | Medium |
|
||||
| users | 10 | Medium |
|
||||
| tickets | 13 | Medium |
|
||||
| contracts | 1 | Low |
|
||||
| notifications | 1 | Low |
|
||||
| comms_credits | 2 | Low |
|
||||
| field_mobile | 1 | Low |
|
||||
| public_api | 3 | Low |
|
||||
| analytics | 0 | None |
|
||||
| communication | 1 | Low |
|
||||
|
||||
---
|
||||
|
||||
## Target Structure (Option C - Domain-Based)
|
||||
|
||||
```
|
||||
smoothschedule/smoothschedule/
|
||||
├── __init__.py
|
||||
│
|
||||
├── identity/ # User & Tenant Management
|
||||
│ ├── __init__.py
|
||||
│ ├── core/ # Multi-tenancy, permissions, OAuth
|
||||
│ │ └── (moved from smoothschedule/core/)
|
||||
│ └── users/ # User model, auth, invitations
|
||||
│ └── (keep at current location, just move parent)
|
||||
│
|
||||
├── scheduling/ # Core Business Logic
|
||||
│ ├── __init__.py
|
||||
│ ├── schedule/ # Resources, Events, Services, Plugins
|
||||
│ │ └── (moved from smoothschedule/schedule/)
|
||||
│ ├── contracts/ # E-signatures, legal documents
|
||||
│ │ └── (moved from smoothschedule/contracts/)
|
||||
│ └── analytics/ # Reporting, dashboards
|
||||
│ └── (moved from smoothschedule/analytics/)
|
||||
│
|
||||
├── communication/ # Messaging & Notifications
|
||||
│ ├── __init__.py
|
||||
│ ├── notifications/ # In-app notifications
|
||||
│ │ └── (moved from smoothschedule/notifications/)
|
||||
│ ├── credits/ # SMS/voice credits (renamed from comms_credits)
|
||||
│ │ └── (moved from smoothschedule/smoothschedule/comms_credits/)
|
||||
│ ├── mobile/ # Field employee app (renamed from field_mobile)
|
||||
│ │ └── (moved from smoothschedule/smoothschedule/field_mobile/)
|
||||
│ └── messaging/ # Twilio conversations (renamed from communication)
|
||||
│ └── (moved from smoothschedule/communication/)
|
||||
│
|
||||
├── commerce/ # Payments & Support
|
||||
│ ├── __init__.py
|
||||
│ ├── payments/ # Stripe Connect, transactions
|
||||
│ │ └── (moved from smoothschedule/payments/)
|
||||
│ └── tickets/ # Support tickets, email integration
|
||||
│ └── (moved from smoothschedule/tickets/)
|
||||
│
|
||||
└── platform/ # Platform Administration
|
||||
├── __init__.py
|
||||
├── admin/ # Platform settings, subscriptions (renamed)
|
||||
│ └── (moved from smoothschedule/platform_admin/)
|
||||
└── api/ # Public API v1 (renamed from public_api)
|
||||
└── (moved from smoothschedule/smoothschedule/public_api/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
### 1. Migration History Preservation
|
||||
|
||||
Django migrations contain the app label in their `dependencies` and `app_label` references. We MUST:
|
||||
|
||||
- **Keep `app_label` unchanged** in each app's `Meta` class
|
||||
- Update `AppConfig.name` to the new dotted path
|
||||
- Django will use the `app_label` (not the path) for migration tracking
|
||||
|
||||
### 2. Foreign Key String References
|
||||
|
||||
Models use string references like `'users.User'` and `'core.Tenant'`. These reference `app_label`, not the module path, so they remain valid.
|
||||
|
||||
### 3. Import Path Updates
|
||||
|
||||
All imports across the codebase must be updated:
|
||||
- `from core.models import Tenant` → `from smoothschedule.identity.core.models import Tenant`
|
||||
- `from schedule.models import Event` → `from smoothschedule.scheduling.schedule.models import Event`
|
||||
|
||||
### 4. URL Configuration
|
||||
|
||||
`config/urls.py` imports views directly - all import paths must be updated.
|
||||
|
||||
### 5. Settings Files
|
||||
|
||||
- `config/settings/base.py` - `LOCAL_APPS`
|
||||
- `config/settings/multitenancy.py` - `SHARED_APPS`, `TENANT_APPS`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Preparation (Serial)
|
||||
|
||||
**Agent 1: Setup & Verification**
|
||||
1. Create all domain package directories with `__init__.py` files
|
||||
2. Verify Docker is running and database is accessible
|
||||
3. Run existing tests to establish baseline
|
||||
4. Create backup of current migration state
|
||||
|
||||
```bash
|
||||
# Create domain packages
|
||||
mkdir -p smoothschedule/smoothschedule/identity
|
||||
mkdir -p smoothschedule/smoothschedule/scheduling
|
||||
mkdir -p smoothschedule/smoothschedule/communication
|
||||
mkdir -p smoothschedule/smoothschedule/commerce
|
||||
mkdir -p smoothschedule/smoothschedule/platform
|
||||
|
||||
# Create __init__.py files
|
||||
touch smoothschedule/smoothschedule/identity/__init__.py
|
||||
touch smoothschedule/smoothschedule/scheduling/__init__.py
|
||||
touch smoothschedule/smoothschedule/communication/__init__.py
|
||||
touch smoothschedule/smoothschedule/commerce/__init__.py
|
||||
touch smoothschedule/smoothschedule/platform/__init__.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Move Apps (Parallel - 5 Agents)
|
||||
|
||||
Each agent handles one domain. For each app move:
|
||||
|
||||
1. **Move directory** to new location
|
||||
2. **Update `apps.py`** - change `name` to new dotted path, keep `label` same
|
||||
3. **Update internal imports** within the app
|
||||
4. **Add explicit `app_label`** to all model Meta classes (if not present)
|
||||
|
||||
#### Agent 2: Identity Domain
|
||||
Move and update:
|
||||
- `smoothschedule/core/` → `smoothschedule/smoothschedule/identity/core/`
|
||||
- `smoothschedule/smoothschedule/users/` → `smoothschedule/smoothschedule/identity/users/`
|
||||
|
||||
**apps.py changes:**
|
||||
```python
|
||||
# identity/core/apps.py
|
||||
class CoreConfig(AppConfig):
|
||||
name = "smoothschedule.identity.core" # NEW
|
||||
label = "core" # KEEP SAME
|
||||
verbose_name = "Core"
|
||||
|
||||
# identity/users/apps.py
|
||||
class UsersConfig(AppConfig):
|
||||
name = "smoothschedule.identity.users" # NEW
|
||||
label = "users" # KEEP SAME
|
||||
```
|
||||
|
||||
#### Agent 3: Scheduling Domain
|
||||
Move and update:
|
||||
- `smoothschedule/schedule/` → `smoothschedule/smoothschedule/scheduling/schedule/`
|
||||
- `smoothschedule/contracts/` → `smoothschedule/smoothschedule/scheduling/contracts/`
|
||||
- `smoothschedule/analytics/` → `smoothschedule/smoothschedule/scheduling/analytics/`
|
||||
|
||||
#### Agent 4: Communication Domain
|
||||
Move and update:
|
||||
- `smoothschedule/notifications/` → `smoothschedule/smoothschedule/communication/notifications/`
|
||||
- `smoothschedule/smoothschedule/comms_credits/` → `smoothschedule/smoothschedule/communication/credits/`
|
||||
- `smoothschedule/smoothschedule/field_mobile/` → `smoothschedule/smoothschedule/communication/mobile/`
|
||||
- `smoothschedule/communication/` → `smoothschedule/smoothschedule/communication/messaging/`
|
||||
|
||||
**Note:** Rename apps for clarity:
|
||||
- `comms_credits` label stays same, path changes
|
||||
- `field_mobile` label stays same, path changes
|
||||
- `communication` label stays same, path changes
|
||||
|
||||
#### Agent 5: Commerce Domain
|
||||
Move and update:
|
||||
- `smoothschedule/payments/` → `smoothschedule/smoothschedule/commerce/payments/`
|
||||
- `smoothschedule/tickets/` → `smoothschedule/smoothschedule/commerce/tickets/`
|
||||
|
||||
#### Agent 6: Platform Domain
|
||||
Move and update:
|
||||
- `smoothschedule/platform_admin/` → `smoothschedule/smoothschedule/platform/admin/`
|
||||
- `smoothschedule/smoothschedule/public_api/` → `smoothschedule/smoothschedule/platform/api/`
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Update Settings (Serial)
|
||||
|
||||
**Agent 7: Settings Configuration**
|
||||
|
||||
Update `config/settings/base.py`:
|
||||
```python
|
||||
LOCAL_APPS = [
|
||||
# Identity
|
||||
"smoothschedule.identity.users",
|
||||
"smoothschedule.identity.core",
|
||||
|
||||
# Scheduling
|
||||
"smoothschedule.scheduling.schedule",
|
||||
"smoothschedule.scheduling.contracts",
|
||||
"smoothschedule.scheduling.analytics",
|
||||
|
||||
# Communication
|
||||
"smoothschedule.communication.notifications",
|
||||
"smoothschedule.communication.credits",
|
||||
"smoothschedule.communication.mobile",
|
||||
"smoothschedule.communication.messaging",
|
||||
|
||||
# Commerce
|
||||
"smoothschedule.commerce.payments",
|
||||
"smoothschedule.commerce.tickets",
|
||||
|
||||
# Platform
|
||||
"smoothschedule.platform.admin",
|
||||
"smoothschedule.platform.api",
|
||||
]
|
||||
```
|
||||
|
||||
Update `config/settings/multitenancy.py`:
|
||||
```python
|
||||
SHARED_APPS = [
|
||||
'django_tenants',
|
||||
'smoothschedule.identity.core',
|
||||
'smoothschedule.platform.admin',
|
||||
# ... rest of shared apps with new paths
|
||||
]
|
||||
|
||||
TENANT_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'smoothschedule.scheduling.schedule',
|
||||
'smoothschedule.commerce.payments',
|
||||
'smoothschedule.scheduling.contracts',
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Update All Import Paths (Parallel - Multiple Agents)
|
||||
|
||||
**This is the largest task.** Each agent handles specific import patterns:
|
||||
|
||||
#### Agent 8: Core Imports
|
||||
Find and replace across entire codebase:
|
||||
- `from core.models import` → `from smoothschedule.identity.core.models import`
|
||||
- `from core.` → `from smoothschedule.identity.core.`
|
||||
- `import core` → `import smoothschedule.identity.core as core`
|
||||
|
||||
#### Agent 9: Schedule Imports
|
||||
- `from schedule.models import` → `from smoothschedule.scheduling.schedule.models import`
|
||||
- `from schedule.` → `from smoothschedule.scheduling.schedule.`
|
||||
|
||||
#### Agent 10: Users/Auth Imports
|
||||
- `from smoothschedule.users.` → `from smoothschedule.identity.users.`
|
||||
- `from users.` → `from smoothschedule.identity.users.`
|
||||
|
||||
#### Agent 11: Other App Imports
|
||||
Handle remaining apps:
|
||||
- payments, tickets, notifications, contracts, analytics
|
||||
- platform_admin, public_api, comms_credits, field_mobile, communication
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: URL Configuration Updates (Serial)
|
||||
|
||||
**Agent 12: URL Updates**
|
||||
|
||||
Update `config/urls.py` with new import paths:
|
||||
```python
|
||||
# Old
|
||||
from schedule.views import ResourceViewSet, EventViewSet
|
||||
from core.api_views import business_current
|
||||
|
||||
# New
|
||||
from smoothschedule.scheduling.schedule.views import ResourceViewSet, EventViewSet
|
||||
from smoothschedule.identity.core.api_views import business_current
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Cleanup & Verification (Serial)
|
||||
|
||||
**Agent 13: Cleanup**
|
||||
1. Remove old empty directories at top level
|
||||
2. Remove deprecated `smoothschedule/smoothschedule/schedule/` directory
|
||||
3. Update `CLAUDE.md` documentation
|
||||
4. Update any remaining references
|
||||
|
||||
**Agent 14: Verification**
|
||||
1. Run `docker compose exec django python manage.py check`
|
||||
2. Run `docker compose exec django python manage.py makemigrations --check`
|
||||
3. Run `docker compose exec django python manage.py migrate --check`
|
||||
4. Run test suite
|
||||
5. Manual smoke test of key endpoints
|
||||
|
||||
---
|
||||
|
||||
## App Label Mapping Reference
|
||||
|
||||
| Old Import Path | New Import Path | app_label (unchanged) |
|
||||
|----------------|-----------------|----------------------|
|
||||
| `core` | `smoothschedule.identity.core` | `core` |
|
||||
| `smoothschedule.users` | `smoothschedule.identity.users` | `users` |
|
||||
| `schedule` | `smoothschedule.scheduling.schedule` | `schedule` |
|
||||
| `contracts` | `smoothschedule.scheduling.contracts` | `contracts` |
|
||||
| `analytics` | `smoothschedule.scheduling.analytics` | `analytics` |
|
||||
| `notifications` | `smoothschedule.communication.notifications` | `notifications` |
|
||||
| `smoothschedule.comms_credits` | `smoothschedule.communication.credits` | `comms_credits` |
|
||||
| `smoothschedule.field_mobile` | `smoothschedule.communication.mobile` | `field_mobile` |
|
||||
| `communication` | `smoothschedule.communication.messaging` | `communication` |
|
||||
| `payments` | `smoothschedule.commerce.payments` | `payments` |
|
||||
| `tickets` | `smoothschedule.commerce.tickets` | `tickets` |
|
||||
| `platform_admin` | `smoothschedule.platform.admin` | `platform_admin` |
|
||||
| `smoothschedule.public_api` | `smoothschedule.platform.api` | `public_api` |
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues are encountered:
|
||||
|
||||
1. **Git Reset:** `git checkout main` and delete branch
|
||||
2. **Database:** No migration changes, database remains intact
|
||||
3. **Docker:** Rebuild containers if needed
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All apps moved to domain-based structure
|
||||
- [ ] `python manage.py check` passes
|
||||
- [ ] `python manage.py makemigrations --check` shows no changes
|
||||
- [ ] All existing tests pass
|
||||
- [ ] Frontend can communicate with API
|
||||
- [ ] Mobile app can communicate with API
|
||||
- [ ] CLAUDE.md updated with new structure
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
```
|
||||
Phase 1 (Serial): Agent 1 - Setup
|
||||
Phase 2 (Parallel): Agents 2-6 - Move apps by domain
|
||||
Phase 3 (Serial): Agent 7 - Update settings
|
||||
Phase 4 (Parallel): Agents 8-11 - Update imports
|
||||
Phase 5 (Serial): Agent 12 - URL updates
|
||||
Phase 6 (Serial): Agents 13-14 - Cleanup & verify
|
||||
```
|
||||
|
||||
**Total Agents:** 14 (8 can run in parallel at peak)
|
||||
@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
||||
|
||||
// Import pages
|
||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
|
||||
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
||||
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||
@@ -667,7 +668,7 @@ const AppContent: React.FC = () => {
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
||||
/>
|
||||
{/* Staff Schedule - vertical timeline view */}
|
||||
<Route
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface User {
|
||||
permissions?: Record<string, boolean>;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
can_edit_schedule?: boolean;
|
||||
linked_resource_id?: number;
|
||||
quota_overages?: QuotaOverage[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from '../api/client';
|
||||
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
|
||||
import { formatLocalDate } from '../utils/dateUtils';
|
||||
|
||||
interface ScheduledTask {
|
||||
id: string;
|
||||
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
|
||||
setScheduleMode('onetime');
|
||||
if (task.run_at) {
|
||||
const date = new Date(task.run_at);
|
||||
setRunAtDate(date.toISOString().split('T')[0]);
|
||||
setRunAtDate(formatLocalDate(date));
|
||||
setRunAtTime(date.toTimeString().slice(0, 5));
|
||||
}
|
||||
} else if (task.schedule_type === 'INTERVAL') {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare } from 'lucide-react';
|
||||
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare, Clock } from 'lucide-react';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadNotificationCount,
|
||||
@@ -56,6 +56,14 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
||||
}
|
||||
}
|
||||
|
||||
// Handle time-off request notifications - navigate to time blocks page
|
||||
// Includes both new requests and modified requests that need re-approval
|
||||
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
|
||||
navigate('/time-blocks');
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to target if available
|
||||
if (notification.target_url) {
|
||||
navigate(notification.target_url);
|
||||
@@ -71,8 +79,13 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
||||
clearAllMutation.mutate();
|
||||
};
|
||||
|
||||
const getNotificationIcon = (targetType: string | null) => {
|
||||
switch (targetType) {
|
||||
const getNotificationIcon = (notification: Notification) => {
|
||||
// Check for time-off request type in data (new or modified)
|
||||
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
|
||||
return <Clock size={16} className="text-amber-500" />;
|
||||
}
|
||||
|
||||
switch (notification.target_type) {
|
||||
case 'ticket':
|
||||
return <Ticket size={16} className="text-blue-500" />;
|
||||
case 'event':
|
||||
@@ -171,7 +184,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{getNotificationIcon(notification.target_type)}
|
||||
{getNotificationIcon(notification)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>
|
||||
|
||||
@@ -87,6 +87,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
defaultValue: true,
|
||||
roles: ['staff'],
|
||||
},
|
||||
{
|
||||
key: 'can_self_approve_time_off',
|
||||
labelKey: 'staff.canSelfApproveTimeOff',
|
||||
labelDefault: 'Can self-approve time off',
|
||||
hintKey: 'staff.canSelfApproveTimeOffHint',
|
||||
hintDefault: 'Add time off without requiring manager/owner approval',
|
||||
defaultValue: false,
|
||||
roles: ['staff'],
|
||||
},
|
||||
// Shared permissions (both manager and staff)
|
||||
{
|
||||
key: 'can_access_tickets',
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Resource,
|
||||
TimeBlockListItem,
|
||||
} from '../../types';
|
||||
import { formatLocalDate } from '../../utils/dateUtils';
|
||||
|
||||
// Preset block types
|
||||
const PRESETS = [
|
||||
@@ -155,6 +156,10 @@ interface TimeBlockCreatorModalProps {
|
||||
holidays: Holiday[];
|
||||
resources: Resource[];
|
||||
isResourceLevel?: boolean;
|
||||
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
|
||||
staffMode?: boolean;
|
||||
/** Pre-selected resource ID for staff mode */
|
||||
staffResourceId?: string | number | null;
|
||||
}
|
||||
|
||||
type Step = 'preset' | 'details' | 'schedule' | 'review';
|
||||
@@ -168,6 +173,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
holidays,
|
||||
resources,
|
||||
isResourceLevel: initialIsResourceLevel = false,
|
||||
staffMode = false,
|
||||
staffResourceId = null,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
|
||||
@@ -177,7 +184,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
// Form state
|
||||
const [title, setTitle] = useState(editingBlock?.title || '');
|
||||
const [description, setDescription] = useState(editingBlock?.description || '');
|
||||
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || 'HARD');
|
||||
// In staff mode, default to SOFT blocks (time-off requests)
|
||||
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || (staffMode ? 'SOFT' : 'HARD'));
|
||||
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
|
||||
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
|
||||
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
|
||||
@@ -270,7 +278,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setAllDay(true);
|
||||
setStartTime('09:00');
|
||||
setEndTime('17:00');
|
||||
setResourceId(null);
|
||||
// In staff mode, pre-select the staff's resource
|
||||
setResourceId(staffMode && staffResourceId ? String(staffResourceId) : null);
|
||||
setSelectedDates([]);
|
||||
setDaysOfWeek([]);
|
||||
setDaysOfMonth([]);
|
||||
@@ -279,10 +288,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setHolidayCodes([]);
|
||||
setRecurrenceStart('');
|
||||
setRecurrenceEnd('');
|
||||
setIsResourceLevel(initialIsResourceLevel);
|
||||
// In staff mode, always resource-level
|
||||
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel]);
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
|
||||
|
||||
// Apply preset configuration
|
||||
const applyPreset = (presetId: string) => {
|
||||
@@ -293,7 +303,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setTitle(preset.config.title);
|
||||
setRecurrenceType(preset.config.recurrence_type);
|
||||
setAllDay(preset.config.all_day);
|
||||
setBlockType(preset.config.block_type);
|
||||
// In staff mode, always use SOFT blocks regardless of preset
|
||||
setBlockType(staffMode ? 'SOFT' : preset.config.block_type);
|
||||
|
||||
if (preset.config.start_time) setStartTime(preset.config.start_time);
|
||||
if (preset.config.end_time) setEndTime(preset.config.end_time);
|
||||
@@ -367,12 +378,15 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// In staff mode, always use the staff's resource ID
|
||||
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
|
||||
|
||||
const baseData: any = {
|
||||
description: description || undefined,
|
||||
block_type: blockType,
|
||||
recurrence_type: recurrenceType,
|
||||
all_day: allDay,
|
||||
resource: isResourceLevel ? resourceId : null,
|
||||
resource: isResourceLevel ? effectiveResourceId : null,
|
||||
};
|
||||
|
||||
if (!allDay) {
|
||||
@@ -405,8 +419,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
if (recurrenceType === 'NONE') {
|
||||
if (selectedDates.length > 0) {
|
||||
const sorted = [...selectedDates].sort((a, b) => a.getTime() - b.getTime());
|
||||
data.start_date = sorted[0].toISOString().split('T')[0];
|
||||
data.end_date = sorted[sorted.length - 1].toISOString().split('T')[0];
|
||||
data.start_date = formatLocalDate(sorted[0]);
|
||||
data.end_date = formatLocalDate(sorted[sorted.length - 1]);
|
||||
}
|
||||
} else if (recurrenceType === 'WEEKLY') {
|
||||
data.recurrence_pattern = { days_of_week: daysOfWeek };
|
||||
@@ -425,7 +439,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
return true;
|
||||
case 'details':
|
||||
if (!title.trim()) return false;
|
||||
if (isResourceLevel && !resourceId) return false;
|
||||
// In staff mode, resource is auto-selected; otherwise check if selected
|
||||
if (isResourceLevel && !staffMode && !resourceId) return false;
|
||||
return true;
|
||||
case 'schedule':
|
||||
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
|
||||
@@ -556,7 +571,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
{/* Step 2: Details */}
|
||||
{step === 'details' && (
|
||||
<div className="space-y-6">
|
||||
{/* Block Level Selector */}
|
||||
{/* Block Level Selector - Hidden in staff mode */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Level
|
||||
@@ -613,6 +629,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
@@ -642,8 +659,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource (if resource-level) */}
|
||||
{isResourceLevel && (
|
||||
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
|
||||
{isResourceLevel && !staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Resource
|
||||
@@ -661,7 +678,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block Type */}
|
||||
{/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Type
|
||||
@@ -707,6 +725,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Day Toggle & Time */}
|
||||
<div>
|
||||
@@ -1188,11 +1207,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{isResourceLevel && resourceId && (
|
||||
{isResourceLevel && (resourceId || staffResourceId) && (
|
||||
<div className="flex justify-between py-2">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
|
||||
<dd className="font-medium text-gray-900 dark:text-white">
|
||||
{resources.find(r => r.id === resourceId)?.name || resourceId}
|
||||
{resources.find(r => String(r.id) === String(staffMode ? staffResourceId : resourceId))?.name || (staffMode ? staffResourceId : resourceId)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
|
||||
import { BlockedDate, TimeBlockListItem } from '../../types';
|
||||
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
|
||||
import { formatLocalDate } from '../../utils/dateUtils';
|
||||
|
||||
interface YearlyBlockCalendarProps {
|
||||
resourceId?: string;
|
||||
@@ -134,7 +135,7 @@ const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
|
||||
return <div key={`empty-${i}`} className="aspect-square" />;
|
||||
}
|
||||
|
||||
const dateKey = day.toISOString().split('T')[0];
|
||||
const dateKey = formatLocalDate(day);
|
||||
const blocks = blockedDateMap.get(dateKey) || [];
|
||||
const hasBlocks = blocks.length > 0;
|
||||
const isToday = new Date().toDateString() === day.toDateString();
|
||||
|
||||
@@ -158,8 +158,9 @@ export const useMyBlocks = () => {
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
resource_id: String(data.resource_id),
|
||||
resource_id: data.resource_id ? String(data.resource_id) : null,
|
||||
resource_name: data.resource_name,
|
||||
can_self_approve: data.can_self_approve,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -248,6 +249,75 @@ export const useToggleTimeBlock = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Time Block Approval Hooks
|
||||
// =============================================================================
|
||||
|
||||
export interface PendingReviewsResponse {
|
||||
count: number;
|
||||
pending_blocks: TimeBlockListItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch pending time block reviews (for managers/owners)
|
||||
*/
|
||||
export const usePendingReviews = () => {
|
||||
return useQuery<PendingReviewsResponse>({
|
||||
queryKey: ['time-block-pending-reviews'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/time-blocks/pending_reviews/');
|
||||
return {
|
||||
count: data.count,
|
||||
pending_blocks: data.pending_blocks.map((b: any) => ({
|
||||
...b,
|
||||
id: String(b.id),
|
||||
resource: b.resource ? String(b.resource) : null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to approve a time block
|
||||
*/
|
||||
export const useApproveTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
|
||||
const { data } = await apiClient.post(`/time-blocks/${id}/approve/`, { notes });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to deny a time block
|
||||
*/
|
||||
export const useDenyTimeBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
|
||||
const { data } = await apiClient.post(`/time-blocks/${id}/deny/`, { notes });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check for conflicts before creating a time block
|
||||
*/
|
||||
|
||||
@@ -491,7 +491,39 @@
|
||||
"reactivateAccount": "Reactivate Account",
|
||||
"deactivateHint": "Prevent this user from logging in while keeping their data",
|
||||
"reactivateHint": "Allow this user to log in again",
|
||||
"deactivate": "Deactivate"
|
||||
"deactivate": "Deactivate",
|
||||
"canSelfApproveTimeOff": "Can self-approve time off",
|
||||
"canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval"
|
||||
},
|
||||
"staffDashboard": {
|
||||
"welcomeTitle": "Welcome, {{name}}!",
|
||||
"weekOverview": "Here's your week at a glance",
|
||||
"noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.",
|
||||
"currentAppointment": "Current Appointment",
|
||||
"nextAppointment": "Next Appointment",
|
||||
"viewSchedule": "View Schedule",
|
||||
"todayAppointments": "Today",
|
||||
"thisWeek": "This Week",
|
||||
"completed": "Completed",
|
||||
"hoursWorked": "Hours Worked",
|
||||
"appointmentsLabel": "appointments",
|
||||
"totalAppointments": "total appointments",
|
||||
"completionRate": "completion rate",
|
||||
"thisWeekLabel": "this week",
|
||||
"upcomingAppointments": "Upcoming",
|
||||
"noUpcoming": "No upcoming appointments",
|
||||
"weeklyOverview": "This Week",
|
||||
"appointments": "Appointments",
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"scheduled": "Scheduled",
|
||||
"inProgress": "In Progress",
|
||||
"cancelled": "Cancelled",
|
||||
"noShows": "No-Shows",
|
||||
"viewMySchedule": "View My Schedule",
|
||||
"viewScheduleDesc": "See your daily appointments and manage your time",
|
||||
"manageAvailability": "Manage Availability",
|
||||
"availabilityDesc": "Set your working hours and time off"
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Support Tickets",
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
* My Availability Page
|
||||
*
|
||||
* Staff-facing page to view and manage their own time blocks.
|
||||
* Uses the same UI as TimeBlocks but locked to the staff's own resource.
|
||||
* Shows business-level blocks (read-only) and personal blocks (editable).
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
TimeBlockListItem,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
RecurrencePattern,
|
||||
User,
|
||||
} from '../types';
|
||||
import {
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
useDeleteTimeBlock,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
CreateTimeBlockData,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import Portal from '../components/Portal';
|
||||
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
|
||||
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
|
||||
import {
|
||||
Calendar,
|
||||
Building2,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CalendarDays,
|
||||
@@ -42,8 +41,13 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
HourglassIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
type AvailabilityTab = 'blocks' | 'calendar';
|
||||
|
||||
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
|
||||
NONE: 'One-time',
|
||||
WEEKLY: 'Weekly',
|
||||
@@ -57,43 +61,6 @@ const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
||||
SOFT: 'Soft Block',
|
||||
};
|
||||
|
||||
const DAY_ABBREVS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
interface TimeBlockFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
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;
|
||||
}
|
||||
|
||||
const defaultFormData: TimeBlockFormData = {
|
||||
title: '',
|
||||
description: '',
|
||||
block_type: 'SOFT',
|
||||
recurrence_type: 'NONE',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
all_day: true,
|
||||
start_time: '09:00',
|
||||
end_time: '17:00',
|
||||
recurrence_pattern: {},
|
||||
recurrence_start: '',
|
||||
recurrence_end: '',
|
||||
};
|
||||
|
||||
interface MyAvailabilityProps {
|
||||
user?: User;
|
||||
}
|
||||
@@ -103,9 +70,9 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
const contextUser = useOutletContext<{ user?: User }>()?.user;
|
||||
const user = props.user || contextUser;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<AvailabilityTab>('blocks');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [formData, setFormData] = useState<TimeBlockFormData>(defaultFormData);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Fetch data
|
||||
@@ -118,105 +85,20 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
|
||||
// Check if user can create hard blocks
|
||||
const canCreateHardBlocks = user?.permissions?.can_create_hard_blocks ?? false;
|
||||
|
||||
// Modal handlers
|
||||
const openCreateModal = () => {
|
||||
setEditingBlock(null);
|
||||
setFormData(defaultFormData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (block: TimeBlockListItem) => {
|
||||
setEditingBlock(block);
|
||||
setFormData({
|
||||
title: block.title,
|
||||
description: '',
|
||||
block_type: block.block_type,
|
||||
recurrence_type: block.recurrence_type,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
all_day: true,
|
||||
start_time: '09:00',
|
||||
end_time: '17:00',
|
||||
recurrence_pattern: {},
|
||||
recurrence_start: '',
|
||||
recurrence_end: '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingBlock(null);
|
||||
setFormData(defaultFormData);
|
||||
};
|
||||
|
||||
// Form handlers
|
||||
const handleFormChange = (field: keyof TimeBlockFormData, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePatternChange = (field: keyof RecurrencePattern, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
recurrence_pattern: { ...prev.recurrence_pattern, [field]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDayOfWeekToggle = (day: number) => {
|
||||
const current = formData.recurrence_pattern.days_of_week || [];
|
||||
const newDays = current.includes(day)
|
||||
? current.filter((d) => d !== day)
|
||||
: [...current, day].sort();
|
||||
handlePatternChange('days_of_week', newDays);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!myBlocksData?.resource_id) {
|
||||
console.error('No resource linked to user');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateTimeBlockData = {
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
resource: myBlocksData.resource_id,
|
||||
block_type: formData.block_type,
|
||||
recurrence_type: formData.recurrence_type,
|
||||
all_day: formData.all_day,
|
||||
};
|
||||
|
||||
// Add type-specific fields
|
||||
if (formData.recurrence_type === 'NONE') {
|
||||
payload.start_date = formData.start_date;
|
||||
payload.end_date = formData.end_date || formData.start_date;
|
||||
}
|
||||
|
||||
if (!formData.all_day) {
|
||||
payload.start_time = formData.start_time;
|
||||
payload.end_time = formData.end_time;
|
||||
}
|
||||
|
||||
if (formData.recurrence_type !== 'NONE') {
|
||||
payload.recurrence_pattern = formData.recurrence_pattern;
|
||||
if (formData.recurrence_start) payload.recurrence_start = formData.recurrence_start;
|
||||
if (formData.recurrence_end) payload.recurrence_end = formData.recurrence_end;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingBlock) {
|
||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: payload });
|
||||
} else {
|
||||
await createBlock.mutateAsync(payload);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -264,6 +146,35 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</span>
|
||||
);
|
||||
|
||||
// Render approval status badge
|
||||
const renderApprovalBadge = (status: string | undefined) => {
|
||||
if (!status || status === 'APPROVED') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<CheckCircle size={12} className="mr-1" />
|
||||
{t('myAvailability.approved', 'Approved')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'PENDING') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<HourglassIcon size={12} className="mr-1" />
|
||||
{t('myAvailability.pending', 'Pending Review')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'DENIED') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
<XCircle size={12} className="mr-1" />
|
||||
{t('myAvailability.denied', 'Denied')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle no linked resource
|
||||
if (!isLoading && !myBlocksData?.resource_id) {
|
||||
return (
|
||||
@@ -290,6 +201,12 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Create a mock resource for the modal
|
||||
const staffResource = myBlocksData?.resource_id ? {
|
||||
id: myBlocksData.resource_id,
|
||||
name: myBlocksData.resource_name || 'My Resource',
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
@@ -299,77 +216,115 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
{t('myAvailability.title', 'My Availability')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{myBlocksData?.resource_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon size={14} />
|
||||
{myBlocksData.resource_name}
|
||||
</span>
|
||||
)}
|
||||
{t('myAvailability.subtitle', 'Manage your time off and unavailability')}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={openCreateModal} className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('myAvailability.addBlock', 'Block Time')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Approval Required Banner */}
|
||||
{myBlocksData?.can_self_approve === false && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-amber-900 dark:text-amber-100">
|
||||
{t('myAvailability.approvalRequired', 'Approval Required')}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
{t('myAvailability.approvalRequiredInfo', 'Your time off requests require manager or owner approval. New blocks will show as "Pending Review" until approved.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Blocks Info Banner */}
|
||||
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 size={20} className="text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-900 dark:text-blue-100">
|
||||
{t('myAvailability.businessBlocks', 'Business Closures')}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone:')}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{myBlocksData.business_blocks.map((block) => (
|
||||
<span
|
||||
key={block.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-800/50 text-blue-800 dark:text-blue-200 rounded text-sm"
|
||||
>
|
||||
{block.title}
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-1" aria-label="Availability tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('blocks')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'blocks'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<UserIcon size={18} />
|
||||
{t('myAvailability.myBlocksTab', 'My Time Blocks')}
|
||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length > 0 && (
|
||||
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
|
||||
{myBlocksData.my_blocks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'calendar'
|
||||
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays size={18} />
|
||||
{t('myAvailability.calendarTab', 'Yearly View')}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Business Blocks (Read-only) */}
|
||||
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
{t('myAvailability.businessBlocks', 'Business Closures')}
|
||||
</h2>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 flex items-center gap-2">
|
||||
<Info size={16} />
|
||||
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone.')}
|
||||
<div className="space-y-4">
|
||||
{activeTab === 'blocks' && (
|
||||
<>
|
||||
{/* Resource Info Banner */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-300 flex items-center gap-2">
|
||||
<UserIcon size={16} />
|
||||
{t('myAvailability.resourceInfo', 'Managing blocks for:')}
|
||||
<span className="font-semibold">{myBlocksData?.resource_name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData.business_blocks.map((block) => (
|
||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{block.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
{block.pattern_display && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.pattern_display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Blocks (Editable) */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<UserIcon size={20} />
|
||||
{t('myAvailability.myBlocks', 'My Time Blocks')}
|
||||
</h2>
|
||||
|
||||
{/* My Blocks List */}
|
||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
@@ -379,7 +334,10 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
|
||||
</p>
|
||||
<button onClick={openCreateModal} className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('myAvailability.addFirstBlock', 'Add First Block')}
|
||||
</button>
|
||||
@@ -398,6 +356,9 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.patternCol', 'Pattern')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.statusCol', 'Status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('myAvailability.actionsCol', 'Actions')}
|
||||
</th>
|
||||
@@ -405,11 +366,18 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData?.my_blocks.map((block) => (
|
||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${block.is_active ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400 line-through'}`}>
|
||||
{block.title}
|
||||
</span>
|
||||
{!block.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
@@ -424,6 +392,16 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-1">
|
||||
{renderApprovalBadge((block as any).approval_status)}
|
||||
{(block as any).review_notes && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
"{(block as any).review_notes}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
@@ -455,9 +433,10 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Yearly Calendar View */}
|
||||
{activeTab === 'calendar' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<YearlyBlockCalendar
|
||||
resourceId={myBlocksData?.resource_id}
|
||||
@@ -470,276 +449,38 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{isModalOpen && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{editingBlock
|
||||
? t('myAvailability.editBlock', 'Edit Time Block')
|
||||
: t('myAvailability.createBlock', 'Block Time Off')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.title', 'Title')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleFormChange('title', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
placeholder="e.g., Vacation, Lunch Break, Doctor Appointment"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.description', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
rows={2}
|
||||
placeholder="Optional reason"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Block Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.blockType', 'Block Type')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="block_type"
|
||||
value="SOFT"
|
||||
checked={formData.block_type === 'SOFT'}
|
||||
onChange={() => handleFormChange('block_type', 'SOFT')}
|
||||
className="text-brand-500"
|
||||
/>
|
||||
<AlertCircle size={16} className="text-yellow-500" />
|
||||
<span className="text-sm">Soft Block</span>
|
||||
<span className="text-xs text-gray-500">(shows warning, can be overridden)</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 ${canCreateHardBlocks ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="block_type"
|
||||
value="HARD"
|
||||
checked={formData.block_type === 'HARD'}
|
||||
onChange={() => canCreateHardBlocks && handleFormChange('block_type', 'HARD')}
|
||||
className="text-brand-500"
|
||||
disabled={!canCreateHardBlocks}
|
||||
/>
|
||||
<Ban size={16} className="text-red-500" />
|
||||
<span className="text-sm">Hard Block</span>
|
||||
<span className="text-xs text-gray-500">(prevents booking)</span>
|
||||
{!canCreateHardBlocks && (
|
||||
<span className="text-xs text-red-500">(requires permission)</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.recurrenceType', 'Recurrence')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.recurrence_type}
|
||||
onChange={(e) => handleFormChange('recurrence_type', e.target.value as RecurrenceType)}
|
||||
className="input-primary w-full"
|
||||
>
|
||||
<option value="NONE">One-time (specific date/range)</option>
|
||||
<option value="WEEKLY">Weekly (e.g., every Monday)</option>
|
||||
<option value="MONTHLY">Monthly (e.g., 1st of month)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recurrence Pattern - NONE */}
|
||||
{formData.recurrence_type === 'NONE' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Start Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => handleFormChange('start_date', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => handleFormChange('end_date', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
min={formData.start_date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence Pattern - WEEKLY */}
|
||||
{formData.recurrence_type === 'WEEKLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Days of Week *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAY_ABBREVS.map((day, index) => {
|
||||
const isSelected = (formData.recurrence_pattern.days_of_week || []).includes(index);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => handleDayOfWeekToggle(index)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence Pattern - MONTHLY */}
|
||||
{formData.recurrence_type === 'MONTHLY' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Days of Month *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
|
||||
const isSelected = (formData.recurrence_pattern.days_of_month || []).includes(day);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = formData.recurrence_pattern.days_of_month || [];
|
||||
const newDays = current.includes(day)
|
||||
? current.filter((d) => d !== day)
|
||||
: [...current, day].sort((a, b) => a - b);
|
||||
handlePatternChange('days_of_month', newDays);
|
||||
{/* Create/Edit Modal - Using TimeBlockCreatorModal in staff mode */}
|
||||
<TimeBlockCreatorModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
if (editingBlock) {
|
||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: data });
|
||||
} else {
|
||||
// Handle array of blocks (multiple holidays)
|
||||
const blocks = Array.isArray(data) ? data : [data];
|
||||
for (const block of blocks) {
|
||||
await createBlock.mutateAsync(block);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save time block:', error);
|
||||
}
|
||||
}}
|
||||
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Day Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="all_day"
|
||||
checked={formData.all_day}
|
||||
onChange={(e) => handleFormChange('all_day', e.target.checked)}
|
||||
className="rounded text-brand-500"
|
||||
isSubmitting={createBlock.isPending || updateBlock.isPending}
|
||||
editingBlock={editingBlock}
|
||||
holidays={holidays}
|
||||
resources={staffResource ? [staffResource as any] : []}
|
||||
isResourceLevel={true}
|
||||
staffMode={true}
|
||||
staffResourceId={myBlocksData?.resource_id}
|
||||
/>
|
||||
<label htmlFor="all_day" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('myAvailability.form.allDay', 'All day')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Time Range (if not all day) */}
|
||||
{!formData.all_day && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.start_time}
|
||||
onChange={(e) => handleFormChange('start_time', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
End Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.end_time}
|
||||
onChange={(e) => handleFormChange('end_time', e.target.value)}
|
||||
className="input-primary w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" onClick={closeModal} className="btn-secondary">
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={createBlock.isPending || updateBlock.isPending}
|
||||
>
|
||||
{(createBlock.isPending || updateBlock.isPending) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.saving', 'Saving...')}
|
||||
</span>
|
||||
) : editingBlock ? (
|
||||
t('common.save', 'Save Changes')
|
||||
) : (
|
||||
t('myAvailability.create', 'Block Time')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmId && (
|
||||
@@ -760,12 +501,15 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setDeleteConfirmId(null)} className="btn-secondary">
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="btn-danger"
|
||||
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
disabled={deleteBlock.isPending}
|
||||
>
|
||||
{deleteBlock.isPending ? (
|
||||
|
||||
@@ -14,6 +14,7 @@ import Portal from '../components/Portal';
|
||||
import EventAutomations from '../components/EventAutomations';
|
||||
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
|
||||
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
|
||||
import { formatLocalDate } from '../utils/dateUtils';
|
||||
|
||||
// Time settings
|
||||
const START_HOUR = 0; // Midnight
|
||||
@@ -87,8 +88,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
|
||||
// Fetch blocked dates for the calendar overlay
|
||||
const blockedDatesParams = useMemo(() => ({
|
||||
start_date: dateRange.startDate.toISOString().split('T')[0],
|
||||
end_date: dateRange.endDate.toISOString().split('T')[0],
|
||||
start_date: formatLocalDate(dateRange.startDate),
|
||||
end_date: formatLocalDate(dateRange.endDate),
|
||||
include_business: true,
|
||||
}), [dateRange]);
|
||||
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);
|
||||
|
||||
627
frontend/src/pages/StaffDashboard.tsx
Normal file
627
frontend/src/pages/StaffDashboard.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
format,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
isWithinInterval,
|
||||
parseISO,
|
||||
differenceInMinutes,
|
||||
isBefore,
|
||||
isAfter,
|
||||
} from 'date-fns';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
TrendingUp,
|
||||
CalendarDays,
|
||||
CalendarOff,
|
||||
ArrowRight,
|
||||
PlayCircle,
|
||||
} from 'lucide-react';
|
||||
import apiClient from '../api/client';
|
||||
import { User as UserType } from '../types';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface StaffDashboardProps {
|
||||
user: UserType;
|
||||
}
|
||||
|
||||
interface Appointment {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
customer_name?: string;
|
||||
service_name?: string;
|
||||
}
|
||||
|
||||
const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
const { t } = useTranslation();
|
||||
const userResourceId = user.linked_resource_id ?? null;
|
||||
|
||||
// Fetch this week's appointments for statistics
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
|
||||
const { data: weekAppointments = [], isLoading } = useQuery({
|
||||
queryKey: ['staff-week-appointments', userResourceId, format(weekStart, 'yyyy-MM-dd')],
|
||||
queryFn: async () => {
|
||||
if (!userResourceId) return [];
|
||||
|
||||
const response = await apiClient.get('/appointments/', {
|
||||
params: {
|
||||
resource: userResourceId,
|
||||
start_date: weekStart.toISOString(),
|
||||
end_date: weekEnd.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.map((apt: any) => ({
|
||||
id: apt.id,
|
||||
title: apt.title || apt.service_name || 'Appointment',
|
||||
start_time: apt.start_time,
|
||||
end_time: apt.end_time,
|
||||
status: apt.status,
|
||||
notes: apt.notes,
|
||||
customer_name: apt.customer_name,
|
||||
service_name: apt.service_name,
|
||||
}));
|
||||
},
|
||||
enabled: !!userResourceId,
|
||||
});
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const todayStart = startOfDay(now);
|
||||
const todayEnd = endOfDay(now);
|
||||
|
||||
const todayAppointments = weekAppointments.filter((apt) =>
|
||||
isWithinInterval(parseISO(apt.start_time), { start: todayStart, end: todayEnd })
|
||||
);
|
||||
|
||||
const completed = weekAppointments.filter(
|
||||
(apt) => apt.status === 'COMPLETED' || apt.status === 'PAID'
|
||||
).length;
|
||||
|
||||
const cancelled = weekAppointments.filter(
|
||||
(apt) => apt.status === 'CANCELLED' || apt.status === 'CANCELED'
|
||||
).length;
|
||||
|
||||
const noShows = weekAppointments.filter(
|
||||
(apt) => apt.status === 'NOSHOW' || apt.status === 'NO_SHOW'
|
||||
).length;
|
||||
|
||||
const scheduled = weekAppointments.filter(
|
||||
(apt) =>
|
||||
apt.status === 'SCHEDULED' ||
|
||||
apt.status === 'CONFIRMED' ||
|
||||
apt.status === 'PENDING'
|
||||
).length;
|
||||
|
||||
const inProgress = weekAppointments.filter(
|
||||
(apt) => apt.status === 'IN_PROGRESS'
|
||||
).length;
|
||||
|
||||
// Calculate total hours worked this week
|
||||
const totalMinutes = weekAppointments
|
||||
.filter((apt) => apt.status === 'COMPLETED' || apt.status === 'PAID')
|
||||
.reduce((acc, apt) => {
|
||||
const start = parseISO(apt.start_time);
|
||||
const end = parseISO(apt.end_time);
|
||||
return acc + differenceInMinutes(end, start);
|
||||
}, 0);
|
||||
|
||||
const hoursWorked = Math.round(totalMinutes / 60 * 10) / 10;
|
||||
|
||||
return {
|
||||
todayCount: todayAppointments.length,
|
||||
weekTotal: weekAppointments.length,
|
||||
completed,
|
||||
cancelled,
|
||||
noShows,
|
||||
scheduled,
|
||||
inProgress,
|
||||
hoursWorked,
|
||||
completionRate: weekAppointments.length > 0
|
||||
? Math.round((completed / weekAppointments.length) * 100)
|
||||
: 0,
|
||||
};
|
||||
}, [weekAppointments]);
|
||||
|
||||
// Get current or next appointment
|
||||
const currentOrNextAppointment = useMemo(() => {
|
||||
const now = new Date();
|
||||
|
||||
// First check for in-progress
|
||||
const inProgress = weekAppointments.find((apt) => apt.status === 'IN_PROGRESS');
|
||||
if (inProgress) {
|
||||
return { type: 'current', appointment: inProgress };
|
||||
}
|
||||
|
||||
// Find next upcoming appointment
|
||||
const upcoming = weekAppointments
|
||||
.filter(
|
||||
(apt) =>
|
||||
(apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED' || apt.status === 'PENDING') &&
|
||||
isAfter(parseISO(apt.start_time), now)
|
||||
)
|
||||
.sort((a, b) => parseISO(a.start_time).getTime() - parseISO(b.start_time).getTime());
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
return { type: 'next', appointment: upcoming[0] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [weekAppointments]);
|
||||
|
||||
// Get upcoming appointments (next 3 days)
|
||||
const upcomingAppointments = useMemo(() => {
|
||||
const now = new Date();
|
||||
const threeDaysLater = addDays(now, 3);
|
||||
|
||||
return weekAppointments
|
||||
.filter(
|
||||
(apt) =>
|
||||
(apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED' || apt.status === 'PENDING') &&
|
||||
isAfter(parseISO(apt.start_time), now) &&
|
||||
isBefore(parseISO(apt.start_time), threeDaysLater)
|
||||
)
|
||||
.sort((a, b) => parseISO(a.start_time).getTime() - parseISO(b.start_time).getTime())
|
||||
.slice(0, 5);
|
||||
}, [weekAppointments]);
|
||||
|
||||
// Weekly chart data
|
||||
const weeklyChartData = useMemo(() => {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const dayMap: Record<string, number> = {};
|
||||
|
||||
days.forEach((day) => {
|
||||
dayMap[day] = 0;
|
||||
});
|
||||
|
||||
weekAppointments.forEach((apt) => {
|
||||
const date = parseISO(apt.start_time);
|
||||
const dayIndex = (date.getDay() + 6) % 7; // Convert to Mon=0, Sun=6
|
||||
const dayName = days[dayIndex];
|
||||
dayMap[dayName]++;
|
||||
});
|
||||
|
||||
return days.map((day) => ({ name: day, appointments: dayMap[day] }));
|
||||
}, [weekAppointments]);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'SCHEDULED':
|
||||
case 'CONFIRMED':
|
||||
case 'PENDING':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||
case 'COMPLETED':
|
||||
case 'PAID':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
case 'CANCELLED':
|
||||
case 'CANCELED':
|
||||
case 'NOSHOW':
|
||||
case 'NO_SHOW':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const formatAppointmentDate = (dateStr: string) => {
|
||||
const date = parseISO(dateStr);
|
||||
if (isToday(date)) return t('staffDashboard.today', 'Today');
|
||||
if (isTomorrow(date)) return t('staffDashboard.tomorrow', 'Tomorrow');
|
||||
return format(date, 'EEE, MMM d');
|
||||
};
|
||||
|
||||
// Show message if no resource is linked
|
||||
if (!userResourceId) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<Calendar size={64} className="mx-auto text-gray-300 dark:text-gray-600 mb-6" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{t('staffDashboard.welcomeTitle', 'Welcome, {{name}}!', { name: user.name })}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||
{t(
|
||||
'staffDashboard.noResourceLinked',
|
||||
'Your account is not linked to a resource yet. Please contact your manager to set up your schedule.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 animate-pulse"
|
||||
>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 bg-gray-50 dark:bg-gray-900 min-h-full">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.welcomeTitle', 'Welcome, {{name}}!', { name: user.name })}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.weekOverview', "Here's your week at a glance")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current/Next Appointment Banner */}
|
||||
{currentOrNextAppointment && (
|
||||
<div
|
||||
className={`p-4 rounded-xl border-l-4 ${
|
||||
currentOrNextAppointment.type === 'current'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`p-3 rounded-lg ${
|
||||
currentOrNextAppointment.type === 'current'
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-600'
|
||||
: 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{currentOrNextAppointment.type === 'current' ? (
|
||||
<PlayCircle size={24} />
|
||||
) : (
|
||||
<Clock size={24} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{currentOrNextAppointment.type === 'current'
|
||||
? t('staffDashboard.currentAppointment', 'Current Appointment')
|
||||
: t('staffDashboard.nextAppointment', 'Next Appointment')}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{currentOrNextAppointment.appointment.service_name ||
|
||||
currentOrNextAppointment.appointment.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||
{currentOrNextAppointment.appointment.customer_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={14} />
|
||||
{currentOrNextAppointment.appointment.customer_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{format(parseISO(currentOrNextAppointment.appointment.start_time), 'h:mm a')} -{' '}
|
||||
{format(parseISO(currentOrNextAppointment.appointment.end_time), 'h:mm a')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/my-schedule"
|
||||
className="px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{t('staffDashboard.viewSchedule', 'View Schedule')}
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Today's Appointments */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<Calendar size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.todayAppointments', 'Today')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.todayCount}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.appointmentsLabel', 'appointments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* This Week Total */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/40 rounded-lg">
|
||||
<CalendarDays size={18} className="text-purple-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.thisWeek', 'This Week')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.weekTotal}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.totalAppointments', 'total appointments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Completed */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-lg">
|
||||
<CheckCircle size={18} className="text-green-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.completed', 'Completed')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.completed}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{stats.completionRate}% {t('staffDashboard.completionRate', 'completion rate')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hours Worked */}
|
||||
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-orange-100 dark:bg-orange-900/40 rounded-lg">
|
||||
<Clock size={18} className="text-orange-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.hoursWorked', 'Hours Worked')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{stats.hoursWorked}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staffDashboard.thisWeekLabel', 'this week')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Upcoming Appointments */}
|
||||
<div className="lg:col-span-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.upcomingAppointments', 'Upcoming')}
|
||||
</h2>
|
||||
<Link
|
||||
to="/my-schedule"
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
{t('common.viewAll', 'View All')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{upcomingAppointments.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Calendar size={40} className="mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.noUpcoming', 'No upcoming appointments')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingAppointments.map((apt) => (
|
||||
<div
|
||||
key={apt.id}
|
||||
className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{apt.service_name || apt.title}
|
||||
</h4>
|
||||
{apt.customer_name && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-0.5">
|
||||
<User size={10} />
|
||||
{apt.customer_name}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{formatAppointmentDate(apt.start_time)} at{' '}
|
||||
{format(parseISO(apt.start_time), 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${getStatusColor(
|
||||
apt.status
|
||||
)}`}
|
||||
>
|
||||
{apt.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weekly Chart */}
|
||||
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('staffDashboard.weeklyOverview', 'This Week')}
|
||||
</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={weeklyChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
backgroundColor: '#1F2937',
|
||||
color: '#F3F4F6',
|
||||
}}
|
||||
formatter={(value: number) => [value, t('staffDashboard.appointments', 'Appointments')]}
|
||||
/>
|
||||
<Bar dataKey="appointments" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Breakdown */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<Calendar size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.scheduled}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.scheduled', 'Scheduled')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900/40 rounded-lg">
|
||||
<TrendingUp size={18} className="text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.inProgress}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.inProgress', 'In Progress')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
|
||||
<XCircle size={18} className="text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.cancelled}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.cancelled', 'Cancelled')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<User size={18} className="text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.noShows}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.noShows', 'No-Shows')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link
|
||||
to="/my-schedule"
|
||||
className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-400 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-brand-100 dark:bg-brand-900/40 rounded-lg group-hover:bg-brand-200 dark:group-hover:bg-brand-800/40 transition-colors">
|
||||
<CalendarDays size={24} className="text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.viewMySchedule', 'View My Schedule')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.viewScheduleDesc', 'See your daily appointments and manage your time')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight size={20} className="text-gray-400 group-hover:text-brand-500 ml-auto transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/my-availability"
|
||||
className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-400 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-green-100 dark:bg-green-900/40 rounded-lg group-hover:bg-green-200 dark:group-hover:bg-green-800/40 transition-colors">
|
||||
<CalendarOff size={24} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('staffDashboard.manageAvailability', 'Manage Availability')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('staffDashboard.availabilityDesc', 'Set your working hours and time off')}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight size={20} className="text-gray-400 group-hover:text-green-500 ml-auto transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffDashboard;
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
useDeleteTimeBlock,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
usePendingReviews,
|
||||
useApproveTimeBlock,
|
||||
useDenyTimeBlock,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import Portal from '../components/Portal';
|
||||
@@ -38,6 +41,12 @@ import {
|
||||
AlertCircle,
|
||||
Power,
|
||||
PowerOff,
|
||||
HourglassIcon,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
type TimeBlockTab = 'business' | 'resource' | 'calendar';
|
||||
@@ -61,6 +70,9 @@ const TimeBlocks: React.FC = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [isPendingReviewsExpanded, setIsPendingReviewsExpanded] = useState(true);
|
||||
const [reviewingBlock, setReviewingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [reviewNotes, setReviewNotes] = useState('');
|
||||
|
||||
// Fetch data (include inactive blocks so users can re-enable them)
|
||||
const {
|
||||
@@ -75,12 +87,15 @@ const TimeBlocks: React.FC = () => {
|
||||
|
||||
const { data: holidays = [] } = useHolidays('US');
|
||||
const { data: resources = [] } = useResources();
|
||||
const { data: pendingReviews } = usePendingReviews();
|
||||
|
||||
// Mutations
|
||||
const createBlock = useCreateTimeBlock();
|
||||
const updateBlock = useUpdateTimeBlock();
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
const approveBlock = useApproveTimeBlock();
|
||||
const denyBlock = useDenyTimeBlock();
|
||||
|
||||
// Current blocks based on tab
|
||||
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
|
||||
@@ -130,6 +145,26 @@ const TimeBlocks: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
try {
|
||||
await approveBlock.mutateAsync({ id, notes: reviewNotes });
|
||||
setReviewingBlock(null);
|
||||
setReviewNotes('');
|
||||
} catch (error) {
|
||||
console.error('Failed to approve time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async (id: string) => {
|
||||
try {
|
||||
await denyBlock.mutateAsync({ id, notes: reviewNotes });
|
||||
setReviewingBlock(null);
|
||||
setReviewNotes('');
|
||||
} catch (error) {
|
||||
console.error('Failed to deny time block:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Render block type badge
|
||||
const renderBlockTypeBadge = (type: BlockType) => (
|
||||
<span
|
||||
@@ -179,6 +214,97 @@ const TimeBlocks: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pending Reviews Section */}
|
||||
{pendingReviews && pendingReviews.count > 0 && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setIsPendingReviewsExpanded(!isPendingReviewsExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-800/50 rounded-lg">
|
||||
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
|
||||
{t('timeBlocks.pendingReviews', 'Pending Time Off Requests')}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
{t('timeBlocks.pendingReviewsCount', '{{count}} request(s) need your review', { count: pendingReviews.count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isPendingReviewsExpanded ? (
|
||||
<ChevronUp size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<ChevronDown size={20} className="text-amber-600 dark:text-amber-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isPendingReviewsExpanded && (
|
||||
<div className="border-t border-amber-200 dark:border-amber-800">
|
||||
<div className="divide-y divide-amber-200 dark:divide-amber-800">
|
||||
{pendingReviews.pending_blocks.map((block) => (
|
||||
<div
|
||||
key={block.id}
|
||||
className="p-4 hover:bg-amber-100/50 dark:hover:bg-amber-900/30 cursor-pointer transition-colors"
|
||||
onClick={() => setReviewingBlock(block)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{block.title}
|
||||
</span>
|
||||
{renderBlockTypeBadge(block.block_type)}
|
||||
{renderRecurrenceBadge(block.recurrence_type)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{block.resource_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User size={14} />
|
||||
{block.resource_name}
|
||||
</span>
|
||||
)}
|
||||
{block.created_by_name && (
|
||||
<span>Requested by {block.created_by_name}</span>
|
||||
)}
|
||||
{block.pattern_display && (
|
||||
<span>{block.pattern_display}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleApprove(block.id);
|
||||
}}
|
||||
className="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
|
||||
title={t('timeBlocks.approve', 'Approve')}
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setReviewingBlock(block);
|
||||
}}
|
||||
className="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
title={t('timeBlocks.deny', 'Deny')}
|
||||
>
|
||||
<XCircle size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-1" aria-label="Time block tabs">
|
||||
@@ -548,6 +674,205 @@ const TimeBlocks: React.FC = () => {
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{/* Review Modal */}
|
||||
{reviewingBlock && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<HourglassIcon size={24} className="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('timeBlocks.reviewRequest', 'Review Time Off Request')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('timeBlocks.reviewRequestDesc', 'Approve or deny this time off request')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block Details */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.titleCol', 'Title')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.title}</span>
|
||||
</div>
|
||||
{reviewingBlock.resource_name && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.resource', 'Resource')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.resource_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.typeCol', 'Type')}</span>
|
||||
{renderBlockTypeBadge(reviewingBlock.block_type)}
|
||||
</div>
|
||||
|
||||
{/* Schedule Details Section */}
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-2">
|
||||
{t('timeBlocks.scheduleDetails', 'Schedule Details')}
|
||||
</span>
|
||||
|
||||
{/* Recurrence Type */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.patternCol', 'Pattern')}</span>
|
||||
{renderRecurrenceBadge(reviewingBlock.recurrence_type)}
|
||||
</div>
|
||||
|
||||
{/* One-time block dates */}
|
||||
{reviewingBlock.recurrence_type === 'NONE' && reviewingBlock.start_date && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.dates', 'Date(s)')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.start_date === reviewingBlock.end_date ? (
|
||||
new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
|
||||
) : (
|
||||
<>
|
||||
{new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
{' - '}
|
||||
{new Date((reviewingBlock.end_date || reviewingBlock.start_date) + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly: Days of week */}
|
||||
{reviewingBlock.recurrence_type === 'WEEKLY' && reviewingBlock.recurrence_pattern?.days_of_week && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfWeek', 'Days')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.recurrence_pattern.days_of_week
|
||||
.map(d => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d])
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly: Days of month */}
|
||||
{reviewingBlock.recurrence_type === 'MONTHLY' && reviewingBlock.recurrence_pattern?.days_of_month && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfMonth', 'Days of Month')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.recurrence_pattern.days_of_month.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yearly: Month and day */}
|
||||
{reviewingBlock.recurrence_type === 'YEARLY' && reviewingBlock.recurrence_pattern?.month && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.yearlyDate', 'Annual Date')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][reviewingBlock.recurrence_pattern.month - 1]} {reviewingBlock.recurrence_pattern.day}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurrence period (start/end) for recurring blocks */}
|
||||
{reviewingBlock.recurrence_type !== 'NONE' && (reviewingBlock.recurrence_start || reviewingBlock.recurrence_end) && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.effectivePeriod', 'Effective Period')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.recurrence_start ? (
|
||||
new Date(reviewingBlock.recurrence_start + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
) : 'No start date'}
|
||||
{' - '}
|
||||
{reviewingBlock.recurrence_end ? (
|
||||
new Date(reviewingBlock.recurrence_end + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
) : 'Ongoing'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time range if not all-day */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.timeRange', 'Time')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{reviewingBlock.all_day !== false ? (
|
||||
t('timeBlocks.allDay', 'All Day')
|
||||
) : (
|
||||
<>
|
||||
{reviewingBlock.start_time?.slice(0, 5)} - {reviewingBlock.end_time?.slice(0, 5)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewingBlock.description && (
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 block mb-1">{t('timeBlocks.description', 'Description')}</span>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{reviewingBlock.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{reviewingBlock.created_by_name && (
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.requestedBy', 'Requested by')}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.created_by_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<MessageSquare size={14} className="inline mr-1" />
|
||||
{t('timeBlocks.reviewNotes', 'Notes (optional)')}
|
||||
</label>
|
||||
<textarea
|
||||
value={reviewNotes}
|
||||
onChange={(e) => setReviewNotes(e.target.value)}
|
||||
placeholder={t('timeBlocks.reviewNotesPlaceholder', 'Add a note for the requester...')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setReviewingBlock(null);
|
||||
setReviewNotes('');
|
||||
}}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeny(reviewingBlock.id)}
|
||||
disabled={denyBlock.isPending}
|
||||
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{denyBlock.isPending ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<XCircle size={18} />
|
||||
)}
|
||||
{t('timeBlocks.deny', 'Deny')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(reviewingBlock.id)}
|
||||
disabled={approveBlock.isPending}
|
||||
className="px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{approveBlock.isPending ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
) : (
|
||||
<CheckCircle size={18} />
|
||||
)}
|
||||
{t('timeBlocks.approve', 'Approve')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,28 +33,134 @@ const GeneralSettings: React.FC = () => {
|
||||
setFormState(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Common timezones grouped by region
|
||||
const commonTimezones = [
|
||||
{ value: 'America/New_York', label: 'Eastern Time (New York)' },
|
||||
{ value: 'America/Chicago', label: 'Central Time (Chicago)' },
|
||||
{ value: 'America/Denver', label: 'Mountain Time (Denver)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)' },
|
||||
{ value: 'America/Anchorage', label: 'Alaska Time' },
|
||||
{ value: 'Pacific/Honolulu', label: 'Hawaii Time' },
|
||||
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
|
||||
{ value: 'America/Toronto', label: 'Eastern Time (Toronto)' },
|
||||
{ value: 'America/Vancouver', label: 'Pacific Time (Vancouver)' },
|
||||
{ value: 'Europe/London', label: 'London (GMT/BST)' },
|
||||
{ value: 'Europe/Paris', label: 'Central European Time' },
|
||||
{ value: 'Europe/Berlin', label: 'Berlin' },
|
||||
{ value: 'Asia/Tokyo', label: 'Japan Time' },
|
||||
{ value: 'Asia/Shanghai', label: 'China Time' },
|
||||
{ value: 'Asia/Singapore', label: 'Singapore Time' },
|
||||
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
|
||||
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
|
||||
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)' },
|
||||
{ value: 'Pacific/Auckland', label: 'New Zealand Time' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
// IANA timezones grouped by region
|
||||
const timezoneGroups = [
|
||||
{
|
||||
label: 'United States',
|
||||
timezones: [
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'Pacific/Honolulu',
|
||||
'America/Phoenix',
|
||||
'America/Detroit',
|
||||
'America/Indiana/Indianapolis',
|
||||
'America/Boise',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Canada',
|
||||
timezones: [
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'America/Edmonton',
|
||||
'America/Winnipeg',
|
||||
'America/Halifax',
|
||||
'America/St_Johns',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Mexico & Central America',
|
||||
timezones: [
|
||||
'America/Mexico_City',
|
||||
'America/Tijuana',
|
||||
'America/Cancun',
|
||||
'America/Guatemala',
|
||||
'America/Costa_Rica',
|
||||
'America/Panama',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'South America',
|
||||
timezones: [
|
||||
'America/Sao_Paulo',
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Santiago',
|
||||
'America/Bogota',
|
||||
'America/Lima',
|
||||
'America/Caracas',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Europe',
|
||||
timezones: [
|
||||
'Europe/London',
|
||||
'Europe/Dublin',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Madrid',
|
||||
'Europe/Rome',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Brussels',
|
||||
'Europe/Vienna',
|
||||
'Europe/Zurich',
|
||||
'Europe/Stockholm',
|
||||
'Europe/Oslo',
|
||||
'Europe/Copenhagen',
|
||||
'Europe/Helsinki',
|
||||
'Europe/Athens',
|
||||
'Europe/Bucharest',
|
||||
'Europe/Warsaw',
|
||||
'Europe/Prague',
|
||||
'Europe/Moscow',
|
||||
'Europe/Istanbul',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Asia',
|
||||
timezones: [
|
||||
'Asia/Tokyo',
|
||||
'Asia/Seoul',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Hong_Kong',
|
||||
'Asia/Taipei',
|
||||
'Asia/Singapore',
|
||||
'Asia/Kuala_Lumpur',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Ho_Chi_Minh',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Manila',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Mumbai',
|
||||
'Asia/Dubai',
|
||||
'Asia/Riyadh',
|
||||
'Asia/Jerusalem',
|
||||
'Asia/Karachi',
|
||||
'Asia/Dhaka',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Australia & Pacific',
|
||||
timezones: [
|
||||
'Australia/Sydney',
|
||||
'Australia/Melbourne',
|
||||
'Australia/Brisbane',
|
||||
'Australia/Perth',
|
||||
'Australia/Adelaide',
|
||||
'Australia/Darwin',
|
||||
'Pacific/Auckland',
|
||||
'Pacific/Fiji',
|
||||
'Pacific/Guam',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Africa',
|
||||
timezones: [
|
||||
'Africa/Johannesburg',
|
||||
'Africa/Cairo',
|
||||
'Africa/Lagos',
|
||||
'Africa/Nairobi',
|
||||
'Africa/Casablanca',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Other',
|
||||
timezones: [
|
||||
'UTC',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -146,11 +252,15 @@ const GeneralSettings: React.FC = () => {
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{commonTimezones.map(tz => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
{timezoneGroups.map(group => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.timezones.map(tz => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.timezone.businessTimezoneHint', 'The timezone where your business operates.')}
|
||||
|
||||
@@ -599,6 +599,8 @@ export interface TimeBlock {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type ApprovalStatus = 'APPROVED' | 'PENDING' | 'DENIED';
|
||||
|
||||
export interface TimeBlockListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -619,6 +621,12 @@ export interface TimeBlockListItem {
|
||||
pattern_display?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
approval_status?: ApprovalStatus;
|
||||
reviewed_by?: number;
|
||||
reviewed_by_name?: string;
|
||||
reviewed_at?: string;
|
||||
review_notes?: string;
|
||||
created_by_name?: string;
|
||||
}
|
||||
|
||||
export interface BlockedDate {
|
||||
@@ -648,6 +656,7 @@ export interface TimeBlockConflictCheck {
|
||||
export interface MyBlocksResponse {
|
||||
business_blocks: TimeBlockListItem[];
|
||||
my_blocks: TimeBlockListItem[];
|
||||
resource_id: string;
|
||||
resource_name: string;
|
||||
resource_id: string | null;
|
||||
resource_name: string | null;
|
||||
can_self_approve: boolean;
|
||||
}
|
||||
393
frontend/src/utils/dateUtils.ts
Normal file
393
frontend/src/utils/dateUtils.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Date/Time Utility Functions
|
||||
*
|
||||
* TIMEZONE ARCHITECTURE:
|
||||
* - Database: All times stored in UTC
|
||||
* - API Communication: Always UTC (both directions)
|
||||
* - Frontend Display: Convert based on business_timezone setting
|
||||
* - If business_timezone is set: Display in that timezone
|
||||
* - If business_timezone is blank/null: Display in user's local timezone
|
||||
*
|
||||
* See CLAUDE.md for full architecture documentation.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// SENDING TO API - Convert to UTC
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert a local Date to UTC ISO string for API requests.
|
||||
* Use this when sending datetime values to the API.
|
||||
*
|
||||
* @example
|
||||
* const payload = { start_time: toUTC(selectedDate) };
|
||||
* // Returns: "2024-12-08T19:00:00.000Z"
|
||||
*/
|
||||
export const toUTC = (date: Date): string => {
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a date and time (in display timezone) to UTC ISO string.
|
||||
* Use when the user selects a time that should be interpreted in a specific timezone.
|
||||
*
|
||||
* @param date - The date portion
|
||||
* @param time - Time string "HH:MM"
|
||||
* @param timezone - IANA timezone the user is selecting in (business or local)
|
||||
*
|
||||
* @example
|
||||
* // User in Eastern selects 2pm, but display mode is "business" (Mountain)
|
||||
* // This means they selected 2pm Mountain time
|
||||
* toUTCFromTimezone(date, "14:00", "America/Denver")
|
||||
*/
|
||||
export const toUTCFromTimezone = (
|
||||
date: Date,
|
||||
time: string,
|
||||
timezone: string
|
||||
): string => {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const dateStr = formatLocalDate(date);
|
||||
|
||||
// Create a date string that we'll parse in the target timezone
|
||||
const dateTimeStr = `${dateStr}T${time}:00`;
|
||||
|
||||
// Use Intl to get the UTC offset for this timezone at this date/time
|
||||
const targetDate = new Date(dateTimeStr);
|
||||
const utcDate = convertTimezoneToUTC(targetDate, timezone);
|
||||
|
||||
return utcDate.toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a Date object from a specific timezone to UTC.
|
||||
* The input Date's time values are interpreted as being in the given timezone.
|
||||
*/
|
||||
export const convertTimezoneToUTC = (date: Date, timezone: string): Date => {
|
||||
// Get the date/time components as they appear (treating as target timezone)
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const day = date.getDate();
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
const seconds = date.getSeconds();
|
||||
|
||||
// Create a formatter for the target timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Find the UTC time that displays as our target time in the target timezone
|
||||
// We do this by creating a UTC date and adjusting based on offset
|
||||
const tempDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds));
|
||||
|
||||
// Get what this UTC time displays as in the target timezone
|
||||
const parts = formatter.formatToParts(tempDate);
|
||||
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
|
||||
|
||||
const displayedHour = getPart('hour');
|
||||
const displayedMinute = getPart('minute');
|
||||
const displayedDay = getPart('day');
|
||||
|
||||
// Calculate the offset in minutes
|
||||
const displayedMinutes = displayedDay * 24 * 60 + displayedHour * 60 + displayedMinute;
|
||||
const targetMinutes = day * 24 * 60 + hours * 60 + minutes;
|
||||
const offsetMinutes = displayedMinutes - targetMinutes;
|
||||
|
||||
// Adjust the UTC time by the offset
|
||||
return new Date(tempDate.getTime() - offsetMinutes * 60 * 1000);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RECEIVING FROM API - Convert from UTC for Display
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert a UTC datetime string from API to a Date object in the display timezone.
|
||||
*
|
||||
* @param utcString - ISO string from API (always UTC)
|
||||
* @param businessTimezone - IANA timezone of the business (null = use local)
|
||||
*/
|
||||
export const fromUTC = (
|
||||
utcString: string,
|
||||
businessTimezone?: string | null
|
||||
): Date => {
|
||||
const utcDate = new Date(utcString);
|
||||
const targetTimezone = getDisplayTimezone(businessTimezone);
|
||||
return convertUTCToTimezone(utcDate, targetTimezone);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a UTC Date to display in a specific timezone.
|
||||
* Returns a Date object with values adjusted for the target timezone.
|
||||
*/
|
||||
export const convertUTCToTimezone = (utcDate: Date, timezone: string): Date => {
|
||||
// Format the UTC date in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(utcDate);
|
||||
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
|
||||
|
||||
// Create a new Date with the timezone-adjusted values
|
||||
// Note: This Date object's internal UTC value won't match, but the displayed values will be correct
|
||||
return new Date(
|
||||
getPart('year'),
|
||||
getPart('month') - 1,
|
||||
getPart('day'),
|
||||
getPart('hour'),
|
||||
getPart('minute'),
|
||||
getPart('second')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the timezone to use for display.
|
||||
* If businessTimezone is set, use it. Otherwise use user's local timezone.
|
||||
*/
|
||||
export const getDisplayTimezone = (businessTimezone?: string | null): string => {
|
||||
if (businessTimezone) {
|
||||
return businessTimezone;
|
||||
}
|
||||
// No business timezone set - use browser's local timezone
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the user's local timezone (browser timezone).
|
||||
*/
|
||||
export const getUserTimezone = (): string => {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// FORMATTING FOR DISPLAY
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format a UTC datetime string for display, respecting timezone settings.
|
||||
*
|
||||
* @param utcString - ISO string from API
|
||||
* @param businessTimezone - IANA timezone of the business (null = use local)
|
||||
* @param options - Intl.DateTimeFormat options for customizing output
|
||||
*
|
||||
* @example
|
||||
* formatForDisplay("2024-12-08T19:00:00Z", "America/Denver")
|
||||
* // Returns: "Dec 8, 2024, 12:00 PM" (Mountain Time)
|
||||
*
|
||||
* formatForDisplay("2024-12-08T19:00:00Z", null)
|
||||
* // Returns time in user's local timezone
|
||||
*/
|
||||
export const formatForDisplay = (
|
||||
utcString: string,
|
||||
businessTimezone?: string | null,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const utcDate = new Date(utcString);
|
||||
const timezone = getDisplayTimezone(businessTimezone);
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
return utcDate.toLocaleString('en-US', defaultOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format just the time portion for display.
|
||||
*
|
||||
* @example
|
||||
* formatTimeForDisplay("2024-12-08T19:00:00Z", "America/Denver")
|
||||
* // Returns: "12:00 PM"
|
||||
*/
|
||||
export const formatTimeForDisplay = (
|
||||
utcString: string,
|
||||
businessTimezone?: string | null
|
||||
): string => {
|
||||
const utcDate = new Date(utcString);
|
||||
const timezone = getDisplayTimezone(businessTimezone);
|
||||
|
||||
return utcDate.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Format just the date portion for display.
|
||||
*
|
||||
* @example
|
||||
* formatDateForDisplay("2024-12-08T19:00:00Z", "America/Denver")
|
||||
* // Returns: "Dec 8, 2024"
|
||||
*/
|
||||
export const formatDateForDisplay = (
|
||||
utcString: string,
|
||||
businessTimezone?: string | null,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string => {
|
||||
const utcDate = new Date(utcString);
|
||||
const timezone = getDisplayTimezone(businessTimezone);
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...options,
|
||||
};
|
||||
|
||||
return utcDate.toLocaleDateString('en-US', defaultOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a datetime for datetime-local input, in the display timezone.
|
||||
* Returns: "YYYY-MM-DDTHH:MM"
|
||||
*/
|
||||
export const formatForDateTimeInput = (
|
||||
utcString: string,
|
||||
businessTimezone?: string | null
|
||||
): string => {
|
||||
const displayDate = fromUTC(utcString, businessTimezone);
|
||||
return formatLocalDateTime(displayDate);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// DATE-ONLY HELPERS (for fields like time block start_date/end_date)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format a Date object as YYYY-MM-DD string.
|
||||
* Uses the Date's local values (not UTC).
|
||||
*
|
||||
* For date-only fields, use this when you have a Date object
|
||||
* representing a calendar date selection.
|
||||
*/
|
||||
export const formatLocalDate = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a YYYY-MM-DD string as a local date (at midnight local time).
|
||||
*/
|
||||
export const parseLocalDate = (dateString: string): Date => {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a Date as YYYY-MM-DDTHH:MM for datetime-local inputs.
|
||||
*/
|
||||
export const formatLocalDateTime = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get today's date as YYYY-MM-DD in a specific timezone.
|
||||
* Useful for determining "today" in the business timezone.
|
||||
*/
|
||||
export const getTodayInTimezone = (timezone: string): string => {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return formatter.format(now); // Returns YYYY-MM-DD format
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a date string (YYYY-MM-DD) is today in the given timezone.
|
||||
*/
|
||||
export const isToday = (dateString: string, timezone: string): boolean => {
|
||||
return dateString === getTodayInTimezone(timezone);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if two dates are the same day (ignoring time).
|
||||
*/
|
||||
export const isSameDay = (date1: Date, date2: Date): boolean => {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the start of a day (midnight local time).
|
||||
*/
|
||||
export const startOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the end of a day (23:59:59.999 local time).
|
||||
*/
|
||||
export const endOfDay = (date: Date): Date => {
|
||||
const result = new Date(date);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get timezone abbreviation for display (e.g., "MST", "EST").
|
||||
*/
|
||||
export const getTimezoneAbbreviation = (
|
||||
timezone: string,
|
||||
date: Date = new Date()
|
||||
): string => {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
return parts.find(p => p.type === 'timeZoneName')?.value || timezone;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format timezone for display (e.g., "Mountain Time (MST)").
|
||||
*/
|
||||
export const formatTimezoneDisplay = (timezone: string): string => {
|
||||
const abbr = getTimezoneAbbreviation(timezone);
|
||||
const cityName = timezone.split('/').pop()?.replace(/_/g, ' ') || timezone;
|
||||
return `${cityName} (${abbr})`;
|
||||
};
|
||||
@@ -60,7 +60,6 @@ export interface StatusHistoryItem {
|
||||
export interface JobDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: JobStatus;
|
||||
@@ -70,23 +69,29 @@ export interface JobDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
phone_masked?: string;
|
||||
phone_masked: string | null;
|
||||
} | null;
|
||||
address: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
service: {
|
||||
id: number;
|
||||
name: string;
|
||||
duration_minutes: number;
|
||||
duration: number;
|
||||
price: string | null;
|
||||
} | null;
|
||||
notes: string | null;
|
||||
available_transitions: JobStatus[];
|
||||
allowed_transitions: JobStatus[];
|
||||
is_tracking_location: boolean;
|
||||
can_track_location: boolean;
|
||||
has_active_call_session: boolean;
|
||||
status_history?: StatusHistoryItem[];
|
||||
latest_location?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timestamp: string;
|
||||
accuracy: number | null;
|
||||
} | null;
|
||||
deposit_amount?: string | null;
|
||||
final_price?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
can_edit_schedule?: boolean;
|
||||
}
|
||||
|
||||
@@ -125,10 +130,8 @@ export interface LocationPoint {
|
||||
|
||||
export interface RouteResponse {
|
||||
job_id: number;
|
||||
status: JobStatus;
|
||||
is_tracking: boolean;
|
||||
route: LocationPoint[];
|
||||
latest_location: LocationPoint | null;
|
||||
point_count: number;
|
||||
}
|
||||
|
||||
export interface CallResponse {
|
||||
@@ -151,11 +154,16 @@ export interface SMSResponse {
|
||||
export interface CallHistoryItem {
|
||||
id: number;
|
||||
call_type: 'OUTBOUND_CALL' | 'INBOUND_CALL' | 'OUTBOUND_SMS' | 'INBOUND_SMS';
|
||||
type_display: string;
|
||||
direction: 'outbound' | 'inbound';
|
||||
duration_seconds: number | null;
|
||||
direction_display: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
sms_body: string | null;
|
||||
status_display: string;
|
||||
duration_seconds: number | null;
|
||||
initiated_at: string;
|
||||
answered_at: string | null;
|
||||
ended_at: string | null;
|
||||
employee_name: string | null;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
|
||||
@@ -289,7 +289,7 @@ docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
from django.test import TestCase, RequestFactory
|
||||
from rest_framework.test import APITestCase
|
||||
from core.models import Tenant
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
class CalendarSyncTests(APITestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -95,23 +95,38 @@ smoothschedule/
|
||||
│ └── traefik/
|
||||
```
|
||||
|
||||
### Django Apps
|
||||
### Django Apps (Domain-Based Organization)
|
||||
|
||||
```
|
||||
smoothschedule/smoothschedule/
|
||||
├── users/ # User management, authentication
|
||||
├── identity/ # Identity Domain
|
||||
│ ├── core/ # Tenant, Domain, middleware, mixins
|
||||
│ │ ├── models.py # Tenant, Domain, PermissionGrant
|
||||
│ │ ├── middleware.py # TenantHeader, Sandbox, Masquerade
|
||||
│ │ └── mixins.py # Base classes for views/viewsets
|
||||
│ └── users/ # User management, authentication
|
||||
│ ├── models.py # User model with roles
|
||||
│ ├── api_views.py # Auth endpoints, user API
|
||||
│ └── migrations/
|
||||
├── schedule/ # Core scheduling functionality
|
||||
│ ├── models.py # Resource, Event, Service, Participant
|
||||
│ ├── serializers.py # DRF serializers
|
||||
│ ├── views.py # ViewSets for API
|
||||
│ ├── services.py # AvailabilityService
|
||||
│ └── migrations/
|
||||
├── tenants/ # Multi-tenancy (Business/Tenant models)
|
||||
│ ├── models.py # Tenant, Domain models
|
||||
│ └── migrations/
|
||||
│ ├── api_views.py # Auth endpoints
|
||||
│ └── mfa_api_views.py # MFA endpoints
|
||||
├── scheduling/ # Scheduling Domain
|
||||
│ ├── schedule/ # Core scheduling functionality
|
||||
│ │ ├── models.py # Resource, Event, Service, Participant
|
||||
│ │ ├── serializers.py # DRF serializers
|
||||
│ │ ├── views.py # ViewSets for API
|
||||
│ │ └── services.py # AvailabilityService
|
||||
│ ├── contracts/ # E-signature system
|
||||
│ └── analytics/ # Business analytics
|
||||
├── communication/ # Communication Domain
|
||||
│ ├── notifications/ # Notification system
|
||||
│ ├── credits/ # SMS/calling credits
|
||||
│ ├── mobile/ # Field employee mobile app
|
||||
│ └── messaging/ # Email templates
|
||||
├── commerce/ # Commerce Domain
|
||||
│ ├── payments/ # Stripe Connect integration
|
||||
│ └── tickets/ # Support tickets
|
||||
└── platform/ # Platform Domain
|
||||
├── admin/ # Platform administration
|
||||
└── api/ # Public API v1
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
@@ -160,16 +175,239 @@ You're trying to run Python directly instead of through Docker. Use `docker comp
|
||||
|
||||
## Key Models
|
||||
|
||||
### Resource (schedule/models.py)
|
||||
### Resource (scheduling/schedule/models.py)
|
||||
- `name`, `type` (STAFF/ROOM/EQUIPMENT)
|
||||
- `max_concurrent_events` - concurrency limit (1=exclusive, >1=multilane, 0=unlimited)
|
||||
- `saved_lane_count` - remembers lane count when multilane disabled
|
||||
- `buffer_duration` - time between events
|
||||
|
||||
### Event (schedule/models.py)
|
||||
### Event (scheduling/schedule/models.py)
|
||||
- `title`, `start_time`, `end_time`, `status`
|
||||
- Links to resources/customers via `Participant` model
|
||||
|
||||
### User (users/models.py)
|
||||
### User (identity/users/models.py)
|
||||
- Roles: `superuser`, `platform_manager`, `platform_support`, `owner`, `manager`, `staff`, `resource`, `customer`
|
||||
- `business_subdomain` - which tenant they belong to
|
||||
|
||||
### Tenant (identity/core/models.py)
|
||||
- `name`, `subdomain`, `schema_name`
|
||||
- Multi-tenancy via django-tenants
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Testing Philosophy
|
||||
|
||||
Follow the **Testing Pyramid**:
|
||||
```
|
||||
/\
|
||||
/E2E\ <- Few (real browser, full stack) - AVOID
|
||||
/------\
|
||||
/Integr- \ <- Some (real DB, verify ORM/queries)
|
||||
/---ation--\
|
||||
/ Unit \ <- Many (mocked, fast, isolated) - PREFER
|
||||
/--------------\
|
||||
```
|
||||
|
||||
**Why:** Django-tenants requires full PostgreSQL migrations per test, making DB-based tests extremely slow (~30-60 seconds each). Unit tests with mocks run in milliseconds.
|
||||
|
||||
### Unit Tests (Preferred)
|
||||
|
||||
Write fast, isolated unit tests using mocks. **Do NOT use `@pytest.mark.django_db`** for unit tests.
|
||||
|
||||
```python
|
||||
# GOOD: Fast unit test with mocks
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
def test_event_service_creates_event():
|
||||
# Arrange - mock dependencies
|
||||
mock_repo = Mock()
|
||||
mock_repo.save.return_value = Mock(id=1, title="Test Event")
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.can_use_feature.return_value = True
|
||||
|
||||
service = EventService(repository=mock_repo, tenant=mock_tenant)
|
||||
|
||||
# Act
|
||||
result = service.create_event(title="Test Event", start_time=datetime.now())
|
||||
|
||||
# Assert
|
||||
mock_repo.save.assert_called_once()
|
||||
assert result.title == "Test Event"
|
||||
|
||||
|
||||
def test_event_service_denies_without_permission():
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.can_use_feature.return_value = False
|
||||
|
||||
service = EventService(repository=Mock(), tenant=mock_tenant)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
service.create_event(title="Test")
|
||||
```
|
||||
|
||||
```python
|
||||
# BAD: Slow test hitting real database
|
||||
@pytest.mark.django_db
|
||||
def test_event_creation(tenant, user):
|
||||
# This takes 30+ seconds due to tenant schema setup!
|
||||
event = Event.objects.create(tenant=tenant, title="Test")
|
||||
assert event.id is not None
|
||||
```
|
||||
|
||||
### Testing Serializers
|
||||
|
||||
```python
|
||||
def test_event_serializer_validates_end_after_start():
|
||||
data = {
|
||||
'title': 'Test',
|
||||
'start_time': '2024-01-01T10:00:00Z',
|
||||
'end_time': '2024-01-01T09:00:00Z', # Before start!
|
||||
}
|
||||
serializer = EventSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert 'end_time' in serializer.errors
|
||||
```
|
||||
|
||||
### Testing Views/ViewSets
|
||||
|
||||
Use `APIRequestFactory` with mocked authentication:
|
||||
|
||||
```python
|
||||
from rest_framework.test import APIRequestFactory
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
def test_resource_list_returns_filtered_resources():
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get('/api/resources/')
|
||||
|
||||
# Mock the user and tenant
|
||||
request.user = Mock(is_authenticated=True, role='manager')
|
||||
request.tenant = Mock(id=1)
|
||||
|
||||
# Mock the queryset
|
||||
with patch.object(ResourceViewSet, 'get_queryset') as mock_qs:
|
||||
mock_qs.return_value = [Mock(id=1, name='Room A')]
|
||||
|
||||
view = ResourceViewSet.as_view({'get': 'list'})
|
||||
response = view(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
### Integration Tests (Use Sparingly)
|
||||
|
||||
Only use `@pytest.mark.django_db` when you MUST verify:
|
||||
- Complex ORM queries with joins/aggregations
|
||||
- Database constraints and triggers
|
||||
- Migration correctness
|
||||
|
||||
```python
|
||||
# Integration test - use only when necessary
|
||||
@pytest.mark.django_db
|
||||
class TestEventQueryIntegration:
|
||||
"""Verify complex queries work correctly with real DB."""
|
||||
|
||||
def test_overlapping_events_query(self, tenant_with_events):
|
||||
# This tests the actual SQL query behavior
|
||||
overlapping = Event.objects.filter_overlapping(
|
||||
start=datetime(2024, 1, 1, 10),
|
||||
end=datetime(2024, 1, 1, 11)
|
||||
)
|
||||
assert overlapping.count() == 2
|
||||
```
|
||||
|
||||
### Writing Testable Code
|
||||
|
||||
Design code for testability using dependency injection:
|
||||
|
||||
```python
|
||||
# BAD: Hard to test - direct imports and global state
|
||||
class EventService:
|
||||
def create_event(self, data):
|
||||
tenant = get_current_tenant() # Global state!
|
||||
event = Event.objects.create(**data) # Direct ORM call
|
||||
send_notification(event) # Direct import
|
||||
return event
|
||||
|
||||
# GOOD: Easy to test - dependencies injected
|
||||
class EventService:
|
||||
def __init__(self, repository, tenant, notifier):
|
||||
self.repository = repository
|
||||
self.tenant = tenant
|
||||
self.notifier = notifier
|
||||
|
||||
def create_event(self, data):
|
||||
if not self.tenant.can_use_feature('events'):
|
||||
raise PermissionDenied()
|
||||
event = self.repository.save(Event(**data))
|
||||
self.notifier.send(event)
|
||||
return event
|
||||
```
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
Abstract database access for easier mocking:
|
||||
|
||||
```python
|
||||
# In repositories.py
|
||||
class EventRepository:
|
||||
def save(self, event):
|
||||
event.save()
|
||||
return event
|
||||
|
||||
def get_by_id(self, event_id):
|
||||
return Event.objects.get(id=event_id)
|
||||
|
||||
def filter_by_date_range(self, start, end):
|
||||
return Event.objects.filter(start_time__gte=start, end_time__lte=end)
|
||||
|
||||
# In tests - mock the repository
|
||||
def test_something():
|
||||
mock_repo = Mock(spec=EventRepository)
|
||||
mock_repo.get_by_id.return_value = Mock(id=1, title="Test")
|
||||
# ... test with mock_repo
|
||||
```
|
||||
|
||||
### Test File Structure
|
||||
|
||||
Each app should have:
|
||||
```
|
||||
app/
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_services.py # Unit tests for business logic
|
||||
│ ├── test_serializers.py # Serializer validation tests
|
||||
│ ├── test_views.py # View/ViewSet tests (mocked)
|
||||
│ └── test_integration.py # DB tests (sparingly)
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
docker compose -f docker-compose.local.yml exec django pytest
|
||||
|
||||
# Run specific test file
|
||||
docker compose -f docker-compose.local.yml exec django pytest smoothschedule/scheduling/schedule/tests/test_services.py
|
||||
|
||||
# Run with coverage
|
||||
docker compose -f docker-compose.local.yml exec django coverage run -m pytest
|
||||
docker compose -f docker-compose.local.yml exec django coverage report
|
||||
|
||||
# Run only unit tests (fast)
|
||||
docker compose -f docker-compose.local.yml exec django pytest -m "not django_db"
|
||||
|
||||
# Run only integration tests
|
||||
docker compose -f docker-compose.local.yml exec django pytest -m django_db
|
||||
```
|
||||
|
||||
### Key Testing Rules
|
||||
|
||||
1. **Default to mocks** - Only hit DB when testing query logic
|
||||
2. **One assertion focus** - Each test verifies one behavior
|
||||
3. **Descriptive names** - `test_create_event_raises_when_tenant_lacks_permission`
|
||||
4. **Arrange-Act-Assert** - Clear test structure
|
||||
5. **No test interdependence** - Tests must run in any order
|
||||
6. **Fast feedback** - If a test takes >1 second, it probably shouldn't hit the DB
|
||||
|
||||
@@ -159,7 +159,7 @@ docker-compose -f docker-compose.local.yml run --rm django python manage.py migr
|
||||
In Django shell or admin, create users with different roles:
|
||||
|
||||
```python
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
from core.models import Tenant
|
||||
|
||||
# Get the tenant
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
"""
|
||||
Analytics API Tests
|
||||
|
||||
Tests for permission gating and endpoint functionality.
|
||||
"""
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.authtoken.models import Token
|
||||
from datetime import timedelta
|
||||
|
||||
from core.models import Tenant
|
||||
from schedule.models import Event, Resource, Service
|
||||
from platform_admin.models import SubscriptionPlan
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAnalyticsPermissions:
|
||||
"""Test permission gating for analytics endpoints"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup test data"""
|
||||
self.client = APIClient()
|
||||
|
||||
# Create a tenant
|
||||
self.tenant = Tenant.objects.create(
|
||||
name="Test Business",
|
||||
schema_name="test_business"
|
||||
)
|
||||
|
||||
# Create a user for this tenant
|
||||
self.user = User.objects.create_user(
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=self.tenant
|
||||
)
|
||||
|
||||
# Create auth token
|
||||
self.token = Token.objects.create(user=self.user)
|
||||
|
||||
# Create subscription plan with advanced_analytics permission
|
||||
self.plan_with_analytics = SubscriptionPlan.objects.create(
|
||||
name="Professional",
|
||||
business_tier="PROFESSIONAL",
|
||||
permissions={"advanced_analytics": True}
|
||||
)
|
||||
|
||||
# Create subscription plan WITHOUT advanced_analytics permission
|
||||
self.plan_without_analytics = SubscriptionPlan.objects.create(
|
||||
name="Starter",
|
||||
business_tier="STARTER",
|
||||
permissions={}
|
||||
)
|
||||
|
||||
def test_analytics_requires_authentication(self):
|
||||
"""Test that analytics endpoints require authentication"""
|
||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
||||
assert response.status_code == 401
|
||||
assert "Authentication credentials were not provided" in str(response.data)
|
||||
|
||||
def test_analytics_denied_without_permission(self):
|
||||
"""Test that analytics is denied without advanced_analytics permission"""
|
||||
# Assign plan without permission
|
||||
self.tenant.subscription_plan = self.plan_without_analytics
|
||||
self.tenant.save()
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Advanced Analytics" in str(response.data)
|
||||
assert "upgrade your subscription" in str(response.data).lower()
|
||||
|
||||
def test_analytics_allowed_with_permission(self):
|
||||
"""Test that analytics is allowed with advanced_analytics permission"""
|
||||
# Assign plan with permission
|
||||
self.tenant.subscription_plan = self.plan_with_analytics
|
||||
self.tenant.save()
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "total_appointments_this_month" in response.data
|
||||
|
||||
def test_dashboard_endpoint_structure(self):
|
||||
"""Test dashboard endpoint returns correct data structure"""
|
||||
# Setup permission
|
||||
self.tenant.subscription_plan = self.plan_with_analytics
|
||||
self.tenant.save()
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check required fields
|
||||
required_fields = [
|
||||
'total_appointments_this_month',
|
||||
'total_appointments_all_time',
|
||||
'active_resources_count',
|
||||
'active_services_count',
|
||||
'upcoming_appointments_count',
|
||||
'average_appointment_duration_minutes',
|
||||
'peak_booking_day',
|
||||
'peak_booking_hour',
|
||||
'period'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in response.data, f"Missing field: {field}"
|
||||
|
||||
def test_appointments_endpoint_with_filters(self):
|
||||
"""Test appointments endpoint with query parameters"""
|
||||
self.tenant.subscription_plan = self.plan_with_analytics
|
||||
self.tenant.save()
|
||||
|
||||
# Create test service and resource
|
||||
service = Service.objects.create(
|
||||
name="Haircut",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
resource = Resource.objects.create(
|
||||
name="Chair 1",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
# Create a test appointment
|
||||
now = timezone.now()
|
||||
Event.objects.create(
|
||||
title="Test Appointment",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status="confirmed",
|
||||
service=service,
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
||||
|
||||
# Test without filters
|
||||
response = self.client.get("/api/analytics/analytics/appointments/")
|
||||
assert response.status_code == 200
|
||||
assert response.data['total'] >= 1
|
||||
|
||||
# Test with days filter
|
||||
response = self.client.get("/api/analytics/analytics/appointments/?days=7")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test with service filter
|
||||
response = self.client.get(f"/api/analytics/analytics/appointments/?service_id={service.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_revenue_requires_payments_permission(self):
|
||||
"""Test that revenue analytics requires both permissions"""
|
||||
self.tenant.subscription_plan = self.plan_with_analytics
|
||||
self.tenant.save()
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
||||
response = self.client.get("/api/analytics/analytics/revenue/")
|
||||
|
||||
# Should be denied because tenant doesn't have can_accept_payments
|
||||
assert response.status_code == 403
|
||||
assert "Payment analytics not available" in str(response.data)
|
||||
|
||||
def test_multiple_permission_check(self):
|
||||
"""Test that both IsAuthenticated and HasFeaturePermission are checked"""
|
||||
self.tenant.subscription_plan = self.plan_with_analytics
|
||||
self.tenant.save()
|
||||
|
||||
# No auth token = 401
|
||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
||||
assert response.status_code == 401
|
||||
|
||||
# With auth but no permission = 403
|
||||
self.tenant.subscription_plan = self.plan_without_analytics
|
||||
self.tenant.save()
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAnalyticsData:
|
||||
"""Test analytics data calculation"""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup test data"""
|
||||
self.client = APIClient()
|
||||
|
||||
self.tenant = Tenant.objects.create(
|
||||
name="Test Business",
|
||||
schema_name="test_business"
|
||||
)
|
||||
|
||||
self.user = User.objects.create_user(
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
role=User.Role.TENANT_OWNER,
|
||||
tenant=self.tenant
|
||||
)
|
||||
|
||||
self.token = Token.objects.create(user=self.user)
|
||||
|
||||
self.plan = SubscriptionPlan.objects.create(
|
||||
name="Professional",
|
||||
business_tier="PROFESSIONAL",
|
||||
permissions={"advanced_analytics": True}
|
||||
)
|
||||
|
||||
self.tenant.subscription_plan = self.plan
|
||||
self.tenant.save()
|
||||
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
|
||||
|
||||
def test_dashboard_counts_appointments_correctly(self):
|
||||
"""Test that dashboard counts appointments accurately"""
|
||||
now = timezone.now()
|
||||
|
||||
# Create appointments in current month
|
||||
for i in range(5):
|
||||
Event.objects.create(
|
||||
title=f"Appointment {i}",
|
||||
start_time=now + timedelta(hours=i),
|
||||
end_time=now + timedelta(hours=i+1),
|
||||
status="confirmed",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
# Create appointment in previous month
|
||||
last_month = now - timedelta(days=40)
|
||||
Event.objects.create(
|
||||
title="Old Appointment",
|
||||
start_time=last_month,
|
||||
end_time=last_month + timedelta(hours=1),
|
||||
status="confirmed",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
response = self.client.get("/api/analytics/analytics/dashboard/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data['total_appointments_this_month'] == 5
|
||||
assert response.data['total_appointments_all_time'] == 6
|
||||
|
||||
def test_appointments_counts_by_status(self):
|
||||
"""Test that appointments are counted by status"""
|
||||
now = timezone.now()
|
||||
|
||||
# Create appointments with different statuses
|
||||
Event.objects.create(
|
||||
title="Confirmed",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status="confirmed",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
Event.objects.create(
|
||||
title="Cancelled",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status="cancelled",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
Event.objects.create(
|
||||
title="No Show",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status="no_show",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
response = self.client.get("/api/analytics/analytics/appointments/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data['by_status']['confirmed'] == 1
|
||||
assert response.data['by_status']['cancelled'] == 1
|
||||
assert response.data['by_status']['no_show'] == 1
|
||||
assert response.data['total'] == 3
|
||||
|
||||
def test_cancellation_rate_calculation(self):
|
||||
"""Test cancellation rate is calculated correctly"""
|
||||
now = timezone.now()
|
||||
|
||||
# Create 100 total appointments: 80 confirmed, 20 cancelled
|
||||
for i in range(80):
|
||||
Event.objects.create(
|
||||
title=f"Confirmed {i}",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status="confirmed",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
for i in range(20):
|
||||
Event.objects.create(
|
||||
title=f"Cancelled {i}",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status="cancelled",
|
||||
business=self.tenant
|
||||
)
|
||||
|
||||
response = self.client.get("/api/analytics/analytics/appointments/")
|
||||
|
||||
assert response.status_code == 200
|
||||
# 20 cancelled / 100 total = 20%
|
||||
assert response.data['cancellation_rate_percent'] == 20.0
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -2,7 +2,7 @@ from django.conf import settings
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework.routers import SimpleRouter
|
||||
|
||||
from smoothschedule.users.api.views import UserViewSet
|
||||
from smoothschedule.identity.users.api.views import UserViewSet
|
||||
|
||||
router = DefaultRouter() if settings.DEBUG else SimpleRouter()
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ django_asgi_app = get_asgi_application()
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
|
||||
from tickets import routing as tickets_routing
|
||||
from schedule import routing as schedule_routing
|
||||
from tickets.middleware import TokenAuthMiddleware
|
||||
from smoothschedule.commerce.tickets import routing as tickets_routing
|
||||
from smoothschedule.scheduling.schedule import routing as schedule_routing
|
||||
from smoothschedule.commerce.tickets.middleware import TokenAuthMiddleware
|
||||
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
|
||||
@@ -97,17 +97,28 @@ THIRD_PARTY_APPS = [
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
"smoothschedule.users",
|
||||
"core",
|
||||
"schedule",
|
||||
"analytics",
|
||||
"payments",
|
||||
"platform_admin.apps.PlatformAdminConfig",
|
||||
"notifications", # New: Generic notification app
|
||||
"tickets", # New: Support tickets app
|
||||
"smoothschedule.comms_credits", # Communication credits and SMS/calling
|
||||
"smoothschedule.field_mobile", # Field employee mobile app
|
||||
# Your stuff: custom apps go here
|
||||
# Identity Domain
|
||||
"smoothschedule.identity.users",
|
||||
"smoothschedule.identity.core",
|
||||
|
||||
# Scheduling Domain
|
||||
"smoothschedule.scheduling.schedule",
|
||||
"smoothschedule.scheduling.contracts",
|
||||
"smoothschedule.scheduling.analytics",
|
||||
|
||||
# Communication Domain
|
||||
"smoothschedule.communication.notifications",
|
||||
"smoothschedule.communication.credits", # SMS/calling credits (was comms_credits)
|
||||
"smoothschedule.communication.mobile", # Field employee app (was field_mobile)
|
||||
"smoothschedule.communication.messaging", # Twilio conversations (was communication)
|
||||
|
||||
# Commerce Domain
|
||||
"smoothschedule.commerce.payments",
|
||||
"smoothschedule.commerce.tickets",
|
||||
|
||||
# Platform Domain
|
||||
"smoothschedule.platform.admin", # Platform settings (was platform_admin)
|
||||
"smoothschedule.platform.api", # Public API v1 (was public_api)
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
@@ -183,7 +194,7 @@ TEMPLATES = [
|
||||
"django.template.context_processors.media",
|
||||
"django.template.context_processors.static",
|
||||
"django.template.context_processors.tz",
|
||||
"smoothschedule.users.context_processors.allauth_settings",
|
||||
"smoothschedule.identity.users.context_processors.allauth_settings",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -318,13 +329,13 @@ ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_ADAPTER = "smoothschedule.users.adapters.AccountAdapter"
|
||||
ACCOUNT_ADAPTER = "smoothschedule.identity.users.adapters.AccountAdapter"
|
||||
# https://docs.allauth.org/en/latest/account/forms.html
|
||||
ACCOUNT_FORMS = {"signup": "smoothschedule.users.forms.UserSignupForm"}
|
||||
ACCOUNT_FORMS = {"signup": "smoothschedule.identity.users.forms.UserSignupForm"}
|
||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||
SOCIALACCOUNT_ADAPTER = "smoothschedule.users.adapters.SocialAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "smoothschedule.identity.users.adapters.SocialAccountAdapter"
|
||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||
SOCIALACCOUNT_FORMS = {"signup": "smoothschedule.users.forms.UserSocialSignupForm"}
|
||||
SOCIALACCOUNT_FORMS = {"signup": "smoothschedule.identity.users.forms.UserSocialSignupForm"}
|
||||
|
||||
# django-rest-framework
|
||||
# -------------------------------------------------------------------------------
|
||||
|
||||
@@ -13,10 +13,16 @@ from .base import INSTALLED_APPS, MIDDLEWARE, DATABASES, LOGGING, env
|
||||
# Shared apps - Available to all tenants (stored in 'public' schema)
|
||||
SHARED_APPS = [
|
||||
'django_tenants', # Must be first
|
||||
'core', # Core models (Tenant, Domain, PermissionGrant)
|
||||
'platform_admin.apps.PlatformAdminConfig', # Platform management (TenantInvitation, etc.)
|
||||
|
||||
# Django built-ins (must be in shared
|
||||
# Identity Domain (shared)
|
||||
'smoothschedule.identity.core', # Core models (Tenant, Domain, PermissionGrant)
|
||||
'smoothschedule.identity.users', # Users app (shared across tenants)
|
||||
|
||||
# Platform Domain (shared)
|
||||
'smoothschedule.platform.admin', # Platform management (TenantInvitation, etc.)
|
||||
'smoothschedule.platform.api', # Public API v1 for third-party integrations
|
||||
|
||||
# Django built-ins (must be in shared)
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.sessions',
|
||||
@@ -25,9 +31,6 @@ SHARED_APPS = [
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.admin',
|
||||
|
||||
# Users app (shared across tenants)
|
||||
'smoothschedule.users',
|
||||
|
||||
# Third-party apps that should be shared
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
@@ -45,23 +48,26 @@ SHARED_APPS = [
|
||||
'crispy_bootstrap5',
|
||||
'csp',
|
||||
'djstripe', # Stripe integration
|
||||
'tickets', # Ticket system - shared for platform support access
|
||||
'notifications', # Notification system - shared for platform to notify tenants
|
||||
'smoothschedule.public_api', # Public API v1 for third-party integrations
|
||||
'smoothschedule.comms_credits', # Communication credits (SMS/calling) - shared for billing
|
||||
'smoothschedule.field_mobile', # Field employee mobile app - shared for location tracking
|
||||
|
||||
# Commerce Domain (shared for platform support)
|
||||
'smoothschedule.commerce.tickets', # Ticket system - shared for platform support access
|
||||
|
||||
# Communication Domain (shared)
|
||||
'smoothschedule.communication.notifications', # Notification system - shared for platform
|
||||
'smoothschedule.communication.credits', # Communication credits (SMS/calling) - shared for billing
|
||||
'smoothschedule.communication.mobile', # Field employee mobile app - shared for location tracking
|
||||
]
|
||||
|
||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||
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',
|
||||
# 'analytics',
|
||||
|
||||
# Scheduling Domain (tenant-isolated)
|
||||
'smoothschedule.scheduling.schedule', # Resource scheduling with configurable concurrency
|
||||
'smoothschedule.scheduling.contracts', # Contract/e-signature system
|
||||
|
||||
# Commerce Domain (tenant-isolated)
|
||||
'smoothschedule.commerce.payments', # Stripe Connect payments bridge
|
||||
]
|
||||
|
||||
|
||||
@@ -96,7 +102,7 @@ MIDDLEWARE = [
|
||||
|
||||
# 1. Tenant resolution
|
||||
'django_tenants.middleware.main.TenantMainMiddleware',
|
||||
'core.middleware.TenantHeaderMiddleware', # Support tenant switching via header
|
||||
'smoothschedule.identity.core.middleware.TenantHeaderMiddleware', # Support tenant switching via header
|
||||
|
||||
# 2. Security middleware
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
@@ -108,7 +114,7 @@ MIDDLEWARE = [
|
||||
|
||||
# 4. Sandbox mode - switches to sandbox schema if requested
|
||||
# MUST come after TenantMainMiddleware and SessionMiddleware
|
||||
'core.middleware.SandboxModeMiddleware',
|
||||
'smoothschedule.identity.core.middleware.SandboxModeMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -120,7 +126,7 @@ MIDDLEWARE = [
|
||||
'hijack.middleware.HijackUserMiddleware',
|
||||
|
||||
# 6. MASQUERADE AUDIT - MUST come AFTER HijackUserMiddleware
|
||||
'core.middleware.MasqueradeAuditMiddleware',
|
||||
'smoothschedule.identity.core.middleware.MasqueradeAuditMiddleware',
|
||||
|
||||
# 7. Messages, Clickjacking, and Allauth
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
@@ -176,7 +182,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
# HIJACK (MASQUERADING) CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
HIJACK_AUTHORIZATION_CHECK = 'core.permissions.can_hijack'
|
||||
HIJACK_AUTHORIZATION_CHECK = 'smoothschedule.identity.core.permissions.can_hijack'
|
||||
HIJACK_DISPLAY_ADMIN_BUTTON = True
|
||||
HIJACK_USE_BOOTSTRAP = True
|
||||
HIJACK_ALLOW_GET_REQUESTS = False # Security: require POST
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
With these settings, tests run faster.
|
||||
"""
|
||||
|
||||
from .base import * # noqa: F403
|
||||
from .base import TEMPLATES
|
||||
from .base import env
|
||||
from .multitenancy import * # noqa: F403
|
||||
from .multitenancy import TEMPLATES, env
|
||||
|
||||
# GENERAL
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -19,6 +18,8 @@ TEST_RUNNER = "django.test.runner.DiscoverRunner"
|
||||
# PASSWORDS
|
||||
# ------------------------------------------------------------------------------
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
|
||||
# Use fast password hasher for tests (bcrypt is intentionally slow)
|
||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
|
||||
# EMAIL
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -35,3 +36,27 @@ TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore[index]
|
||||
MEDIA_URL = "http://media.testserver/"
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# CHANNELS
|
||||
# ------------------------------------------------------------------------------
|
||||
# Use in-memory channel layer for tests (no Redis needed)
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer"
|
||||
}
|
||||
}
|
||||
|
||||
# CELERY
|
||||
# ------------------------------------------------------------------------------
|
||||
# Run tasks synchronously in tests
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# CACHES
|
||||
# ------------------------------------------------------------------------------
|
||||
# Use local memory cache for tests
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,33 +10,33 @@ from drf_spectacular.views import SpectacularAPIView
|
||||
from drf_spectacular.views import SpectacularSwaggerView
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
from smoothschedule.users.api_views import (
|
||||
from smoothschedule.identity.users.api_views import (
|
||||
login_view, current_user_view, logout_view, send_verification_email, verify_email,
|
||||
hijack_acquire_view, hijack_release_view,
|
||||
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
||||
invitation_details_view, accept_invitation_view, decline_invitation_view,
|
||||
check_subdomain_view, signup_view
|
||||
)
|
||||
from smoothschedule.users.mfa_api_views import (
|
||||
from smoothschedule.identity.users.mfa_api_views import (
|
||||
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
|
||||
setup_totp, verify_totp_setup, generate_backup_codes, backup_codes_status,
|
||||
disable_mfa, mfa_login_send_code, mfa_login_verify,
|
||||
list_trusted_devices, revoke_trusted_device, revoke_all_trusted_devices
|
||||
)
|
||||
from schedule.api_views import (
|
||||
from smoothschedule.scheduling.schedule.api_views import (
|
||||
current_business_view, update_business_view,
|
||||
oauth_settings_view, oauth_credentials_view,
|
||||
custom_domains_view, custom_domain_detail_view,
|
||||
custom_domain_verify_view, custom_domain_set_primary_view,
|
||||
sandbox_status_view, sandbox_toggle_view, sandbox_reset_view
|
||||
)
|
||||
from core.email_autoconfig import (
|
||||
from smoothschedule.identity.core.email_autoconfig import (
|
||||
MozillaAutoconfigView,
|
||||
MicrosoftAutodiscoverView,
|
||||
AppleConfigProfileView,
|
||||
WellKnownAutoconfigView,
|
||||
)
|
||||
from core.api_views import (
|
||||
from smoothschedule.identity.core.api_views import (
|
||||
quota_status_view,
|
||||
quota_resources_view,
|
||||
quota_archive_view,
|
||||
@@ -48,7 +48,7 @@ urlpatterns = [
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
path(settings.ADMIN_URL, admin.site.urls),
|
||||
# User management
|
||||
path("users/", include("smoothschedule.users.urls", namespace="users")),
|
||||
path("users/", include("smoothschedule.identity.users.urls", namespace="users")),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
# Django Hijack (masquerade) - for admin interface
|
||||
path("hijack/", include("hijack.urls")),
|
||||
@@ -78,28 +78,28 @@ urlpatterns += [
|
||||
# Stripe Webhooks (dj-stripe built-in handler)
|
||||
path("stripe/", include("djstripe.urls", namespace="djstripe")),
|
||||
# Public API v1 (for third-party integrations)
|
||||
path("v1/", include("smoothschedule.public_api.urls", namespace="public_api")),
|
||||
path("v1/", include("smoothschedule.platform.api.urls", namespace="public_api")),
|
||||
# Schedule API (internal)
|
||||
path("", include("schedule.urls")),
|
||||
path("", include("smoothschedule.scheduling.schedule.urls")),
|
||||
# Analytics API
|
||||
path("", include("analytics.urls")),
|
||||
path("", include("smoothschedule.scheduling.analytics.urls")),
|
||||
# Payments API
|
||||
path("payments/", include("payments.urls")),
|
||||
path("payments/", include("smoothschedule.commerce.payments.urls")),
|
||||
# Contracts API
|
||||
path("contracts/", include("contracts.urls")),
|
||||
path("contracts/", include("smoothschedule.scheduling.contracts.urls")),
|
||||
# Communication Credits API
|
||||
path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
|
||||
path("communication-credits/", include("smoothschedule.communication.credits.urls", namespace="comms_credits")),
|
||||
# Field Mobile API (for field employee mobile app)
|
||||
path("mobile/", include("smoothschedule.field_mobile.urls", namespace="field_mobile")),
|
||||
path("mobile/", include("smoothschedule.communication.mobile.urls", namespace="field_mobile")),
|
||||
# Tickets API
|
||||
path("tickets/", include("tickets.urls")),
|
||||
path("tickets/", include("smoothschedule.commerce.tickets.urls")),
|
||||
# Notifications API
|
||||
path("notifications/", include("notifications.urls")),
|
||||
path("notifications/", include("smoothschedule.communication.notifications.urls")),
|
||||
# Platform API
|
||||
path("platform/", include("platform_admin.urls", namespace="platform")),
|
||||
path("platform/", include("smoothschedule.platform.admin.urls", namespace="platform")),
|
||||
# OAuth Email Integration API
|
||||
path("oauth/", include("core.oauth_urls", namespace="oauth")),
|
||||
path("auth/oauth/", include("core.oauth_urls", namespace="auth_oauth")),
|
||||
path("oauth/", include("smoothschedule.identity.core.oauth_urls", namespace="oauth")),
|
||||
path("auth/oauth/", include("smoothschedule.identity.core.oauth_urls", namespace="auth_oauth")),
|
||||
# Auth API
|
||||
path("auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
|
||||
path("auth/signup/check-subdomain/", check_subdomain_view, name="check_subdomain"),
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -6,7 +6,7 @@ import django
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
django.setup()
|
||||
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
# Create or get a superuser with platform admin role
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Create a default tenant for local development
|
||||
"""
|
||||
from core.models import Tenant, Domain
|
||||
from smoothschedule.identity.core.models import Tenant, Domain
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django_tenants.utils import tenant_context
|
||||
from core.models import Tenant
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,404 +0,0 @@
|
||||
"""
|
||||
Signals for the schedule app.
|
||||
|
||||
Handles:
|
||||
1. Auto-attaching plugins from GlobalEventPlugin rules when events are created
|
||||
2. Rescheduling Celery tasks when events are modified (time/duration changes)
|
||||
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
|
||||
4. Cancelling tasks when Events are deleted or cancelled
|
||||
5. Broadcasting real-time updates via WebSocket for calendar sync
|
||||
"""
|
||||
import logging
|
||||
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Broadcasting Helpers
|
||||
# ============================================================================
|
||||
|
||||
def broadcast_event_change_sync(event, update_type, changed_fields=None, old_status=None):
|
||||
"""
|
||||
Synchronous wrapper to broadcast event changes via WebSocket.
|
||||
|
||||
Uses async_to_sync to call the async broadcast function from signals.
|
||||
"""
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if not channel_layer:
|
||||
logger.debug("No channel layer configured, skipping WebSocket broadcast")
|
||||
return
|
||||
|
||||
try:
|
||||
from .consumers import broadcast_event_update
|
||||
async_to_sync(broadcast_event_update)(
|
||||
event,
|
||||
update_type=update_type,
|
||||
changed_fields=changed_fields,
|
||||
old_status=old_status
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to broadcast event change: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender='schedule.Event')
|
||||
def auto_attach_global_plugins(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When a new event is created, automatically attach all active GlobalEventPlugin rules.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
from .models import GlobalEventPlugin, EventPlugin
|
||||
|
||||
# Get all active global rules
|
||||
global_rules = GlobalEventPlugin.objects.filter(is_active=True)
|
||||
|
||||
attached_count = 0
|
||||
for rule in global_rules:
|
||||
event_plugin = rule.apply_to_event(instance)
|
||||
if event_plugin:
|
||||
attached_count += 1
|
||||
logger.info(
|
||||
f"Auto-attached plugin '{rule.plugin_installation}' to event '{instance}' "
|
||||
f"via global rule (trigger={rule.trigger}, offset={rule.offset_minutes})"
|
||||
)
|
||||
|
||||
if attached_count > 0:
|
||||
logger.info(f"Auto-attached {attached_count} plugins to new event '{instance}'")
|
||||
|
||||
|
||||
@receiver(pre_save, sender='schedule.Event')
|
||||
def track_event_changes(sender, instance, **kwargs):
|
||||
"""
|
||||
Track changes to event timing before save.
|
||||
Store old values on the instance for post_save comparison.
|
||||
"""
|
||||
if instance.pk:
|
||||
try:
|
||||
from .models import Event
|
||||
old_instance = Event.objects.get(pk=instance.pk)
|
||||
instance._old_start_time = old_instance.start_time
|
||||
instance._old_end_time = old_instance.end_time
|
||||
except sender.DoesNotExist:
|
||||
instance._old_start_time = None
|
||||
instance._old_end_time = None
|
||||
else:
|
||||
instance._old_start_time = None
|
||||
instance._old_end_time = None
|
||||
|
||||
|
||||
@receiver(post_save, sender='schedule.Event')
|
||||
def reschedule_event_plugins_on_change(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When an event's timing changes, update any scheduled Celery tasks for its plugins.
|
||||
This handles both time changes and duration changes (via end_time).
|
||||
"""
|
||||
if created:
|
||||
# New events don't have existing tasks to reschedule
|
||||
return
|
||||
|
||||
old_start = getattr(instance, '_old_start_time', None)
|
||||
old_end = getattr(instance, '_old_end_time', None)
|
||||
|
||||
if old_start is None and old_end is None:
|
||||
return
|
||||
|
||||
# Check if timing actually changed
|
||||
start_changed = old_start and old_start != instance.start_time
|
||||
end_changed = old_end and old_end != instance.end_time
|
||||
|
||||
if not start_changed and not end_changed:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Event '{instance}' timing changed. "
|
||||
f"Start: {old_start} -> {instance.start_time}, "
|
||||
f"End: {old_end} -> {instance.end_time}"
|
||||
)
|
||||
|
||||
# Reschedule all active time-based event plugins
|
||||
reschedule_event_celery_tasks(instance, start_changed, end_changed)
|
||||
|
||||
|
||||
def reschedule_event_celery_tasks(event, start_changed=True, end_changed=True):
|
||||
"""
|
||||
Reschedule Celery tasks for an event's plugins when timing changes.
|
||||
|
||||
Args:
|
||||
event: The Event instance
|
||||
start_changed: Whether start_time changed
|
||||
end_changed: Whether end_time changed
|
||||
"""
|
||||
from .models import EventPlugin
|
||||
|
||||
# Get all active, time-based plugins for this event
|
||||
time_based_triggers = ['before_start', 'at_start', 'after_start', 'after_end']
|
||||
|
||||
plugins_to_update = event.event_plugins.filter(
|
||||
is_active=True,
|
||||
trigger__in=time_based_triggers
|
||||
)
|
||||
|
||||
for event_plugin in plugins_to_update:
|
||||
# Only reschedule if the relevant time changed
|
||||
affects_start = event_plugin.trigger in ['before_start', 'at_start', 'after_start']
|
||||
affects_end = event_plugin.trigger == 'after_end'
|
||||
|
||||
if (affects_start and start_changed) or (affects_end and end_changed):
|
||||
new_execution_time = event_plugin.get_execution_time()
|
||||
if new_execution_time:
|
||||
logger.info(
|
||||
f"Rescheduling plugin '{event_plugin.plugin_installation}' for event '{event}' "
|
||||
f"to new execution time: {new_execution_time}"
|
||||
)
|
||||
# TODO: Integrate with Celery beat to reschedule the actual task
|
||||
# For now, we log the intent. The actual Celery integration
|
||||
# will be handled by the task execution system.
|
||||
schedule_event_plugin_task(event_plugin, new_execution_time)
|
||||
|
||||
|
||||
def schedule_event_plugin_task(event_plugin, execution_time):
|
||||
"""
|
||||
Schedule a Celery task for an event plugin at a specific time.
|
||||
|
||||
This function handles creating or updating Celery beat entries
|
||||
for time-based event plugin execution.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
# Don't schedule tasks in the past
|
||||
if execution_time < timezone.now():
|
||||
logger.debug(
|
||||
f"Skipping scheduling for event plugin {event_plugin.id} - "
|
||||
f"execution time {execution_time} is in the past"
|
||||
)
|
||||
return
|
||||
|
||||
# Get or create the Celery task entry
|
||||
# Using django-celery-beat's PeriodicTask model if available
|
||||
try:
|
||||
from django_celery_beat.models import PeriodicTask, ClockedSchedule
|
||||
|
||||
# Create a clocked schedule for the specific execution time
|
||||
clocked_schedule, _ = ClockedSchedule.objects.get_or_create(
|
||||
clocked_time=execution_time
|
||||
)
|
||||
|
||||
# Task name is unique per event-plugin combination
|
||||
task_name = f"event_plugin_{event_plugin.id}"
|
||||
|
||||
import json
|
||||
|
||||
# Create or update the periodic task
|
||||
task, created = PeriodicTask.objects.update_or_create(
|
||||
name=task_name,
|
||||
defaults={
|
||||
'task': 'schedule.tasks.execute_event_plugin',
|
||||
'clocked': clocked_schedule,
|
||||
'one_off': True, # Run only once
|
||||
'enabled': event_plugin.is_active,
|
||||
'kwargs': json.dumps({
|
||||
'event_plugin_id': event_plugin.id,
|
||||
'event_id': event_plugin.event_id,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
logger.info(f"{action} Celery task '{task_name}' for execution at {execution_time}")
|
||||
|
||||
except ImportError:
|
||||
# django-celery-beat not installed, fall back to simple delay
|
||||
logger.warning(
|
||||
"django-celery-beat not installed. "
|
||||
"Event plugin scheduling will use basic Celery delay."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to schedule event plugin task: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender='schedule.GlobalEventPlugin')
|
||||
def apply_global_plugin_to_existing_events(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When a new GlobalEventPlugin rule is created, apply it to all existing events
|
||||
if apply_to_existing is True.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
if not instance.is_active:
|
||||
return
|
||||
|
||||
if not instance.apply_to_existing:
|
||||
logger.info(
|
||||
f"Global plugin rule '{instance}' will only apply to future events"
|
||||
)
|
||||
return
|
||||
|
||||
count = instance.apply_to_all_events()
|
||||
logger.info(
|
||||
f"Applied global plugin rule '{instance}' to {count} existing events"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EventPlugin Scheduling Signals
|
||||
# ============================================================================
|
||||
|
||||
@receiver(post_save, sender='schedule.EventPlugin')
|
||||
def schedule_event_plugin_on_create(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When an EventPlugin is created or updated, schedule its Celery task
|
||||
if it has a time-based trigger.
|
||||
"""
|
||||
# Only schedule time-based triggers
|
||||
time_based_triggers = ['before_start', 'at_start', 'after_start', 'after_end']
|
||||
|
||||
if instance.trigger not in time_based_triggers:
|
||||
return
|
||||
|
||||
if not instance.is_active:
|
||||
# If deactivated, cancel any existing task
|
||||
from .tasks import cancel_event_plugin_task
|
||||
cancel_event_plugin_task(instance.id)
|
||||
return
|
||||
|
||||
execution_time = instance.get_execution_time()
|
||||
if execution_time:
|
||||
schedule_event_plugin_task(instance, execution_time)
|
||||
|
||||
|
||||
@receiver(pre_save, sender='schedule.EventPlugin')
|
||||
def track_event_plugin_active_change(sender, instance, **kwargs):
|
||||
"""
|
||||
Track if is_active changed so we can cancel tasks when deactivated.
|
||||
"""
|
||||
if instance.pk:
|
||||
try:
|
||||
from .models import EventPlugin
|
||||
old_instance = EventPlugin.objects.get(pk=instance.pk)
|
||||
instance._was_active = old_instance.is_active
|
||||
except sender.DoesNotExist:
|
||||
instance._was_active = None
|
||||
else:
|
||||
instance._was_active = None
|
||||
|
||||
|
||||
@receiver(post_delete, sender='schedule.EventPlugin')
|
||||
def cancel_event_plugin_on_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When an EventPlugin is deleted, cancel its scheduled Celery task.
|
||||
"""
|
||||
from .tasks import cancel_event_plugin_task
|
||||
cancel_event_plugin_task(instance.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Event Deletion/Cancellation Signals
|
||||
# ============================================================================
|
||||
|
||||
@receiver(pre_delete, sender='schedule.Event')
|
||||
def cancel_event_tasks_on_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When an Event is deleted, cancel all its scheduled plugin tasks.
|
||||
"""
|
||||
from .tasks import cancel_event_tasks
|
||||
cancel_event_tasks(instance.id)
|
||||
|
||||
|
||||
@receiver(pre_save, sender='schedule.Event')
|
||||
def track_event_status_change(sender, instance, **kwargs):
|
||||
"""
|
||||
Track status changes to detect cancellation.
|
||||
"""
|
||||
if instance.pk:
|
||||
try:
|
||||
from .models import Event
|
||||
old_instance = Event.objects.get(pk=instance.pk)
|
||||
instance._old_status = old_instance.status
|
||||
except sender.DoesNotExist:
|
||||
instance._old_status = None
|
||||
else:
|
||||
instance._old_status = None
|
||||
|
||||
|
||||
@receiver(post_save, sender='schedule.Event')
|
||||
def cancel_event_tasks_on_cancel(sender, instance, created, **kwargs):
|
||||
"""
|
||||
When an Event is cancelled, cancel all its scheduled plugin tasks.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
|
||||
from .models import Event
|
||||
|
||||
old_status = getattr(instance, '_old_status', None)
|
||||
|
||||
# If status changed to cancelled, cancel all tasks
|
||||
if old_status != Event.Status.CANCELED and instance.status == Event.Status.CANCELED:
|
||||
from .tasks import cancel_event_tasks
|
||||
logger.info(f"Event '{instance}' was cancelled, cancelling all plugin tasks")
|
||||
cancel_event_tasks(instance.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Broadcasting Signals
|
||||
# ============================================================================
|
||||
|
||||
@receiver(post_save, sender='schedule.Event')
|
||||
def broadcast_event_save(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Broadcast event creation/update via WebSocket for real-time calendar sync.
|
||||
"""
|
||||
old_status = getattr(instance, '_old_status', None)
|
||||
old_start = getattr(instance, '_old_start_time', None)
|
||||
old_end = getattr(instance, '_old_end_time', None)
|
||||
|
||||
if created:
|
||||
# New event created
|
||||
broadcast_event_change_sync(instance, 'event_created')
|
||||
logger.info(f"Broadcast event_created for event {instance.id}")
|
||||
|
||||
elif old_status and old_status != instance.status:
|
||||
# Status changed
|
||||
broadcast_event_change_sync(
|
||||
instance,
|
||||
'event_status_changed',
|
||||
old_status=old_status
|
||||
)
|
||||
logger.info(
|
||||
f"Broadcast event_status_changed for event {instance.id}: "
|
||||
f"{old_status} -> {instance.status}"
|
||||
)
|
||||
|
||||
else:
|
||||
# Other update - determine what changed
|
||||
changed_fields = []
|
||||
|
||||
if old_start and old_start != instance.start_time:
|
||||
changed_fields.append('start_time')
|
||||
if old_end and old_end != instance.end_time:
|
||||
changed_fields.append('end_time')
|
||||
|
||||
# Always broadcast updates for changes
|
||||
broadcast_event_change_sync(
|
||||
instance,
|
||||
'event_updated',
|
||||
changed_fields=changed_fields if changed_fields else None
|
||||
)
|
||||
logger.info(f"Broadcast event_updated for event {instance.id}")
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='schedule.Event')
|
||||
def broadcast_event_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Broadcast event deletion via WebSocket.
|
||||
"""
|
||||
# Store the event data before deletion for broadcasting
|
||||
broadcast_event_change_sync(instance, 'event_deleted')
|
||||
logger.info(f"Broadcast event_deleted for event {instance.id}")
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Tests for Data Export API
|
||||
|
||||
Run with:
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py test schedule.test_export
|
||||
"""
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from core.models import Tenant, Domain
|
||||
from schedule.models import Event, Resource, Service
|
||||
from smoothschedule.users.models import User as CustomUser
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class DataExportAPITestCase(TestCase):
|
||||
"""Test suite for data export API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
# Create tenant with export permission
|
||||
self.tenant = Tenant.objects.create(
|
||||
name="Test Business",
|
||||
schema_name="test_business",
|
||||
can_export_data=True, # Enable export permission
|
||||
)
|
||||
|
||||
# Create domain for tenant
|
||||
self.domain = Domain.objects.create(
|
||||
tenant=self.tenant,
|
||||
domain="test.lvh.me",
|
||||
is_primary=True
|
||||
)
|
||||
|
||||
# Create test user (owner)
|
||||
self.user = CustomUser.objects.create_user(
|
||||
username="testowner",
|
||||
email="owner@test.com",
|
||||
password="testpass123",
|
||||
role=CustomUser.Role.TENANT_OWNER,
|
||||
tenant=self.tenant
|
||||
)
|
||||
|
||||
# Create test customer
|
||||
self.customer = CustomUser.objects.create_user(
|
||||
username="customer1",
|
||||
email="customer@test.com",
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
role=CustomUser.Role.CUSTOMER,
|
||||
tenant=self.tenant
|
||||
)
|
||||
|
||||
# Create test resource
|
||||
self.resource = Resource.objects.create(
|
||||
name="Test Resource",
|
||||
type=Resource.Type.STAFF,
|
||||
max_concurrent_events=1
|
||||
)
|
||||
|
||||
# Create test service
|
||||
self.service = Service.objects.create(
|
||||
name="Test Service",
|
||||
description="Test service description",
|
||||
duration=60,
|
||||
price=50.00
|
||||
)
|
||||
|
||||
# Create test event
|
||||
now = timezone.now()
|
||||
self.event = Event.objects.create(
|
||||
title="Test Appointment",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status=Event.Status.SCHEDULED,
|
||||
notes="Test notes",
|
||||
created_by=self.user
|
||||
)
|
||||
|
||||
# Set up authenticated client
|
||||
self.client = Client()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_appointments_export_json(self):
|
||||
"""Test exporting appointments in JSON format"""
|
||||
response = self.client.get('/export/appointments/?format=json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
# Check response structure
|
||||
data = response.json()
|
||||
self.assertIn('count', data)
|
||||
self.assertIn('data', data)
|
||||
self.assertIn('exported_at', data)
|
||||
self.assertIn('filters', data)
|
||||
|
||||
# Verify data
|
||||
self.assertEqual(data['count'], 1)
|
||||
self.assertEqual(len(data['data']), 1)
|
||||
|
||||
appointment = data['data'][0]
|
||||
self.assertEqual(appointment['title'], 'Test Appointment')
|
||||
self.assertEqual(appointment['status'], 'SCHEDULED')
|
||||
|
||||
def test_appointments_export_csv(self):
|
||||
"""Test exporting appointments in CSV format"""
|
||||
response = self.client.get('/export/appointments/?format=csv')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('text/csv', response['Content-Type'])
|
||||
self.assertIn('attachment', response['Content-Disposition'])
|
||||
|
||||
# Check CSV content
|
||||
content = response.content.decode('utf-8')
|
||||
self.assertIn('id,title,start_time', content)
|
||||
self.assertIn('Test Appointment', content)
|
||||
|
||||
def test_customers_export_json(self):
|
||||
"""Test exporting customers in JSON format"""
|
||||
response = self.client.get('/export/customers/?format=json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertEqual(data['count'], 1)
|
||||
customer = data['data'][0]
|
||||
self.assertEqual(customer['email'], 'customer@test.com')
|
||||
self.assertEqual(customer['first_name'], 'John')
|
||||
self.assertEqual(customer['last_name'], 'Doe')
|
||||
|
||||
def test_customers_export_csv(self):
|
||||
"""Test exporting customers in CSV format"""
|
||||
response = self.client.get('/export/customers/?format=csv')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('text/csv', response['Content-Type'])
|
||||
|
||||
content = response.content.decode('utf-8')
|
||||
self.assertIn('customer@test.com', content)
|
||||
self.assertIn('John', content)
|
||||
|
||||
def test_resources_export_json(self):
|
||||
"""Test exporting resources in JSON format"""
|
||||
response = self.client.get('/export/resources/?format=json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertEqual(data['count'], 1)
|
||||
resource = data['data'][0]
|
||||
self.assertEqual(resource['name'], 'Test Resource')
|
||||
self.assertEqual(resource['type'], 'STAFF')
|
||||
|
||||
def test_services_export_json(self):
|
||||
"""Test exporting services in JSON format"""
|
||||
response = self.client.get('/export/services/?format=json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
|
||||
self.assertEqual(data['count'], 1)
|
||||
service = data['data'][0]
|
||||
self.assertEqual(service['name'], 'Test Service')
|
||||
self.assertEqual(service['duration'], 60)
|
||||
self.assertEqual(service['price'], '50.00')
|
||||
|
||||
def test_date_range_filter(self):
|
||||
"""Test filtering appointments by date range"""
|
||||
# Create appointment in the past
|
||||
past_time = timezone.now() - timedelta(days=30)
|
||||
Event.objects.create(
|
||||
title="Past Appointment",
|
||||
start_time=past_time,
|
||||
end_time=past_time + timedelta(hours=1),
|
||||
status=Event.Status.COMPLETED,
|
||||
created_by=self.user
|
||||
)
|
||||
|
||||
# Filter for recent appointments only
|
||||
start_date = (timezone.now() - timedelta(days=7)).isoformat()
|
||||
response = self.client.get(f'/export/appointments/?format=json&start_date={start_date}')
|
||||
|
||||
data = response.json()
|
||||
# Should only get the recent appointment, not the past one
|
||||
self.assertEqual(data['count'], 1)
|
||||
self.assertEqual(data['data'][0]['title'], 'Test Appointment')
|
||||
|
||||
def test_no_permission_denied(self):
|
||||
"""Test that export fails when tenant doesn't have permission"""
|
||||
# Disable export permission
|
||||
self.tenant.can_export_data = False
|
||||
self.tenant.save()
|
||||
|
||||
response = self.client.get('/export/appointments/?format=json')
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn('not available', response.json()['detail'])
|
||||
|
||||
def test_unauthenticated_denied(self):
|
||||
"""Test that unauthenticated requests are denied"""
|
||||
client = Client() # Not authenticated
|
||||
response = client.get('/export/appointments/?format=json')
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertIn('Authentication', response.json()['detail'])
|
||||
|
||||
def test_active_filter(self):
|
||||
"""Test filtering by active status"""
|
||||
# Create inactive service
|
||||
Service.objects.create(
|
||||
name="Inactive Service",
|
||||
duration=30,
|
||||
price=25.00,
|
||||
is_active=False
|
||||
)
|
||||
|
||||
# Export only active services
|
||||
response = self.client.get('/export/services/?format=json&is_active=true')
|
||||
data = response.json()
|
||||
|
||||
# Should only get the active service
|
||||
self.assertEqual(data['count'], 1)
|
||||
self.assertEqual(data['data'][0]['name'], 'Test Service')
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,380 +0,0 @@
|
||||
"""
|
||||
Tests for Calendar Sync Feature Permission
|
||||
|
||||
Tests the can_use_calendar_sync permission checking throughout the calendar sync system.
|
||||
Includes tests for:
|
||||
- Permission denied when feature is disabled
|
||||
- Permission granted when feature is enabled
|
||||
- OAuth view permission checks
|
||||
- Calendar sync view permission checks
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
from rest_framework import status
|
||||
from core.models import Tenant, OAuthCredential
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
|
||||
class CalendarSyncPermissionTests(APITestCase):
|
||||
"""
|
||||
Test suite for calendar sync feature permissions.
|
||||
|
||||
Verifies that the can_use_calendar_sync permission is properly enforced
|
||||
across all calendar sync operations.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
# Create a tenant without calendar sync enabled
|
||||
self.tenant = Tenant.objects.create(
|
||||
schema_name='test_tenant',
|
||||
name='Test Tenant',
|
||||
can_use_calendar_sync=False
|
||||
)
|
||||
|
||||
# Create a user in this tenant
|
||||
self.user = User.objects.create_user(
|
||||
email='user@test.com',
|
||||
password='testpass123',
|
||||
tenant=self.tenant
|
||||
)
|
||||
|
||||
# Initialize API client
|
||||
self.client = APIClient()
|
||||
|
||||
def test_calendar_status_without_permission(self):
|
||||
"""
|
||||
Test that users without can_use_calendar_sync cannot access calendar status.
|
||||
|
||||
Expected: 403 Forbidden with upgrade message
|
||||
"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/calendar/status/')
|
||||
|
||||
# Should be able to check status (it's informational)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.data['can_use_calendar_sync'])
|
||||
self.assertEqual(response.data['total_connected'], 0)
|
||||
|
||||
def test_calendar_list_without_permission(self):
|
||||
"""
|
||||
Test that users without can_use_calendar_sync cannot list calendars.
|
||||
|
||||
Expected: 403 Forbidden
|
||||
"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/calendar/list/')
|
||||
|
||||
# Should return 403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn('upgrade', response.data['error'].lower())
|
||||
|
||||
def test_calendar_sync_without_permission(self):
|
||||
"""
|
||||
Test that users without can_use_calendar_sync cannot sync calendars.
|
||||
|
||||
Expected: 403 Forbidden
|
||||
"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post(
|
||||
'/api/calendar/sync/',
|
||||
{'credential_id': 1},
|
||||
format='json'
|
||||
)
|
||||
|
||||
# Should return 403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn('upgrade', response.data['error'].lower())
|
||||
|
||||
def test_calendar_disconnect_without_permission(self):
|
||||
"""
|
||||
Test that users without can_use_calendar_sync cannot disconnect calendars.
|
||||
|
||||
Expected: 403 Forbidden
|
||||
"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.delete(
|
||||
'/api/calendar/disconnect/',
|
||||
{'credential_id': 1},
|
||||
format='json'
|
||||
)
|
||||
|
||||
# Should return 403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn('upgrade', response.data['error'].lower())
|
||||
|
||||
def test_oauth_calendar_initiate_without_permission(self):
|
||||
"""
|
||||
Test that OAuth calendar initiation checks permission.
|
||||
|
||||
Expected: 403 Forbidden when trying to initiate calendar OAuth
|
||||
"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post(
|
||||
'/api/oauth/google/initiate/',
|
||||
{'purpose': 'calendar'},
|
||||
format='json'
|
||||
)
|
||||
|
||||
# Should return 403 Forbidden for calendar purpose
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertIn('Calendar Sync', response.data['error'])
|
||||
|
||||
def test_oauth_email_initiate_without_permission(self):
|
||||
"""
|
||||
Test that OAuth email initiation does NOT require calendar sync permission.
|
||||
|
||||
Note: Email integration may have different permission checks,
|
||||
this test documents that calendar and email are separate.
|
||||
"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Email purpose should be allowed without calendar sync permission
|
||||
# (assuming different permission for email)
|
||||
response = self.client.post(
|
||||
'/api/oauth/google/initiate/',
|
||||
{'purpose': 'email'},
|
||||
format='json'
|
||||
)
|
||||
|
||||
# Should not be blocked by calendar sync permission
|
||||
# (Response may be 400 if OAuth not configured, but not 403 for this reason)
|
||||
self.assertNotEqual(response.status_code, 403)
|
||||
|
||||
def test_calendar_list_with_permission(self):
|
||||
"""
|
||||
Test that users WITH can_use_calendar_sync can list calendars.
|
||||
|
||||
Expected: 200 OK with empty calendar list
|
||||
"""
|
||||
# Enable calendar sync for tenant
|
||||
self.tenant.can_use_calendar_sync = True
|
||||
self.tenant.save()
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/calendar/list/')
|
||||
|
||||
# Should return 200 OK
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(response.data['calendars'], [])
|
||||
|
||||
def test_calendar_with_connected_credential(self):
|
||||
"""
|
||||
Test calendar list with an actual OAuth credential.
|
||||
|
||||
Expected: 200 OK with credential in the list
|
||||
"""
|
||||
# Enable calendar sync
|
||||
self.tenant.can_use_calendar_sync = True
|
||||
self.tenant.save()
|
||||
|
||||
# Create a calendar OAuth credential
|
||||
credential = OAuthCredential.objects.create(
|
||||
tenant=self.tenant,
|
||||
provider='google',
|
||||
purpose='calendar',
|
||||
email='user@gmail.com',
|
||||
access_token='fake_token_123',
|
||||
refresh_token='fake_refresh_123',
|
||||
is_valid=True,
|
||||
authorized_by=self.user,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/calendar/list/')
|
||||
|
||||
# Should return 200 OK with the credential
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertEqual(len(response.data['calendars']), 1)
|
||||
|
||||
calendar = response.data['calendars'][0]
|
||||
self.assertEqual(calendar['email'], 'user@gmail.com')
|
||||
self.assertEqual(calendar['provider'], 'Google')
|
||||
self.assertTrue(calendar['is_valid'])
|
||||
|
||||
def test_calendar_status_with_permission(self):
|
||||
"""
|
||||
Test calendar status check when permission is granted.
|
||||
|
||||
Expected: 200 OK with feature enabled
|
||||
"""
|
||||
# Enable calendar sync
|
||||
self.tenant.can_use_calendar_sync = True
|
||||
self.tenant.save()
|
||||
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get('/api/calendar/status/')
|
||||
|
||||
# Should return 200 OK with feature enabled
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertTrue(response.data['can_use_calendar_sync'])
|
||||
self.assertTrue(response.data['feature_enabled'])
|
||||
|
||||
def test_unauthenticated_calendar_access(self):
|
||||
"""
|
||||
Test that unauthenticated users cannot access calendar endpoints.
|
||||
|
||||
Expected: 401 Unauthorized
|
||||
"""
|
||||
# Don't authenticate
|
||||
|
||||
response = self.client.get('/api/calendar/list/')
|
||||
|
||||
# Should return 401 Unauthorized
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_tenant_has_feature_method(self):
|
||||
"""
|
||||
Test the Tenant.has_feature() method for calendar sync.
|
||||
|
||||
Expected: Method returns correct boolean based on field
|
||||
"""
|
||||
# Initially disabled
|
||||
self.assertFalse(self.tenant.has_feature('can_use_calendar_sync'))
|
||||
|
||||
# Enable it
|
||||
self.tenant.can_use_calendar_sync = True
|
||||
self.tenant.save()
|
||||
|
||||
# Check again
|
||||
self.assertTrue(self.tenant.has_feature('can_use_calendar_sync'))
|
||||
|
||||
|
||||
class CalendarSyncIntegrationTests(APITestCase):
|
||||
"""
|
||||
Integration tests for calendar sync with permission checks.
|
||||
|
||||
Tests realistic workflows of connecting and syncing calendars.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
# Create a tenant WITH calendar sync enabled
|
||||
self.tenant = Tenant.objects.create(
|
||||
schema_name='pro_tenant',
|
||||
name='Professional Tenant',
|
||||
can_use_calendar_sync=True # Premium feature enabled
|
||||
)
|
||||
|
||||
# Create a user
|
||||
self.user = User.objects.create_user(
|
||||
email='pro@example.com',
|
||||
password='testpass123',
|
||||
tenant=self.tenant
|
||||
)
|
||||
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_full_calendar_workflow(self):
|
||||
"""
|
||||
Test complete workflow: Check status -> List -> Add -> Sync -> Remove
|
||||
|
||||
Expected: All steps succeed with permission checks passing
|
||||
"""
|
||||
# Step 1: Check status
|
||||
response = self.client.get('/api/calendar/status/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['can_use_calendar_sync'])
|
||||
|
||||
# Step 2: List calendars (empty initially)
|
||||
response = self.client.get('/api/calendar/list/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data['calendars']), 0)
|
||||
|
||||
# Step 3: Create credential (simulating OAuth completion)
|
||||
credential = OAuthCredential.objects.create(
|
||||
tenant=self.tenant,
|
||||
provider='google',
|
||||
purpose='calendar',
|
||||
email='calendar@gmail.com',
|
||||
access_token='token_123',
|
||||
is_valid=True,
|
||||
authorized_by=self.user,
|
||||
)
|
||||
|
||||
# Step 4: List again (should see the credential)
|
||||
response = self.client.get('/api/calendar/list/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data['calendars']), 1)
|
||||
|
||||
# Step 5: Sync from the calendar
|
||||
response = self.client.post(
|
||||
'/api/calendar/sync/',
|
||||
{
|
||||
'credential_id': credential.id,
|
||||
'calendar_id': 'primary',
|
||||
'start_date': '2025-01-01',
|
||||
'end_date': '2025-12-31',
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
|
||||
# Step 6: Disconnect the calendar
|
||||
response = self.client.delete(
|
||||
'/api/calendar/disconnect/',
|
||||
{'credential_id': credential.id},
|
||||
format='json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
|
||||
# Step 7: Verify it's deleted
|
||||
response = self.client.get('/api/calendar/list/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data['calendars']), 0)
|
||||
|
||||
|
||||
class TenantPermissionModelTests(TestCase):
|
||||
"""
|
||||
Unit tests for the Tenant model's calendar sync permission field.
|
||||
"""
|
||||
|
||||
def test_tenant_can_use_calendar_sync_default(self):
|
||||
"""Test that can_use_calendar_sync defaults to False"""
|
||||
tenant = Tenant.objects.create(
|
||||
schema_name='test',
|
||||
name='Test'
|
||||
)
|
||||
|
||||
self.assertFalse(tenant.can_use_calendar_sync)
|
||||
|
||||
def test_tenant_can_use_calendar_sync_enable(self):
|
||||
"""Test enabling calendar sync on a tenant"""
|
||||
tenant = Tenant.objects.create(
|
||||
schema_name='test',
|
||||
name='Test',
|
||||
can_use_calendar_sync=False
|
||||
)
|
||||
|
||||
tenant.can_use_calendar_sync = True
|
||||
tenant.save()
|
||||
|
||||
refreshed = Tenant.objects.get(pk=tenant.pk)
|
||||
self.assertTrue(refreshed.can_use_calendar_sync)
|
||||
|
||||
def test_has_feature_with_other_permissions(self):
|
||||
"""Test that has_feature correctly checks other permissions too"""
|
||||
tenant = Tenant.objects.create(
|
||||
schema_name='test',
|
||||
name='Test',
|
||||
can_use_calendar_sync=True,
|
||||
can_use_webhooks=False,
|
||||
)
|
||||
|
||||
self.assertTrue(tenant.has_feature('can_use_calendar_sync'))
|
||||
self.assertFalse(tenant.has_feature('can_use_webhooks'))
|
||||
@@ -2,7 +2,7 @@
|
||||
Script to ensure production domain exists in the database.
|
||||
Run with: python manage.py shell < scripts/ensure_production_domain.py
|
||||
"""
|
||||
from core.models import Tenant, Domain
|
||||
from smoothschedule.identity.core.models import Tenant, Domain
|
||||
from django.conf import settings
|
||||
|
||||
def ensure_production_domain():
|
||||
|
||||
@@ -3,4 +3,5 @@ from django.apps import AppConfig
|
||||
|
||||
class PaymentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'payments'
|
||||
name = 'smoothschedule.commerce.payments'
|
||||
label = 'payments'
|
||||
@@ -92,6 +92,7 @@ class TransactionLink(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'payments'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'created_at']),
|
||||
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Unit tests for StripeService.
|
||||
|
||||
Tests the Stripe Connect integration logic with mocks to avoid API calls.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import pytest
|
||||
|
||||
from smoothschedule.commerce.payments.services import StripeService, get_stripe_service_for_tenant
|
||||
|
||||
|
||||
class TestStripeServiceInit:
|
||||
"""Test StripeService initialization."""
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_init_sets_api_key(self, mock_stripe):
|
||||
"""Test that initialization sets the Stripe API key."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
# Act
|
||||
with patch('smoothschedule.commerce.payments.services.settings') as mock_settings:
|
||||
mock_settings.STRIPE_SECRET_KEY = 'sk_test_xxx'
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Assert
|
||||
assert service.tenant == mock_tenant
|
||||
assert mock_stripe.api_key == 'sk_test_xxx'
|
||||
|
||||
|
||||
class TestStripeServiceFactory:
|
||||
"""Test the factory function."""
|
||||
|
||||
def test_factory_raises_without_connect_id(self):
|
||||
"""Test that factory raises error if tenant has no Stripe Connect ID."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.name = 'Test Business'
|
||||
mock_tenant.stripe_connect_id = None
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_stripe_service_for_tenant(mock_tenant)
|
||||
|
||||
assert "does not have a Stripe Connect account" in str(exc_info.value)
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_factory_returns_service_with_connect_id(self, mock_stripe):
|
||||
"""Test that factory returns StripeService when tenant has Connect ID."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
# Act
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = get_stripe_service_for_tenant(mock_tenant)
|
||||
|
||||
# Assert
|
||||
assert isinstance(service, StripeService)
|
||||
assert service.tenant == mock_tenant
|
||||
|
||||
|
||||
class TestCreatePaymentIntent:
|
||||
"""Test payment intent creation."""
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.TransactionLink')
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_create_payment_intent_uses_stripe_account(
|
||||
self, mock_stripe, mock_transaction_link
|
||||
):
|
||||
"""Test that payment intent uses stripe_account header."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
mock_tenant.id = 1
|
||||
mock_tenant.name = 'Test Business'
|
||||
mock_tenant.currency = 'USD'
|
||||
|
||||
mock_event = Mock()
|
||||
mock_event.id = 100
|
||||
mock_event.title = 'Test Appointment'
|
||||
|
||||
mock_pi = Mock()
|
||||
mock_pi.id = 'pi_test123'
|
||||
mock_pi.currency = 'usd'
|
||||
mock_stripe.PaymentIntent.create.return_value = mock_pi
|
||||
|
||||
mock_tx = Mock()
|
||||
mock_transaction_link.objects.create.return_value = mock_tx
|
||||
mock_transaction_link.Status.PENDING = 'PENDING'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
amount = Decimal('100.00')
|
||||
pi, tx = service.create_payment_intent(mock_event, amount)
|
||||
|
||||
# Assert
|
||||
mock_stripe.PaymentIntent.create.assert_called_once()
|
||||
call_kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
|
||||
|
||||
# CRITICAL: Verify stripe_account header is set
|
||||
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||
|
||||
# Verify amount in cents
|
||||
assert call_kwargs['amount'] == 10000 # $100 = 10000 cents
|
||||
|
||||
# Verify application fee is calculated (5% default)
|
||||
assert call_kwargs['application_fee_amount'] == 500 # 5% of 10000
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.TransactionLink')
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_create_payment_intent_custom_fee(
|
||||
self, mock_stripe, mock_transaction_link
|
||||
):
|
||||
"""Test payment intent with custom application fee."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
mock_tenant.id = 1
|
||||
mock_tenant.name = 'Test Business'
|
||||
mock_tenant.currency = 'USD'
|
||||
|
||||
mock_event = Mock()
|
||||
mock_event.id = 100
|
||||
mock_event.title = 'Test Appointment'
|
||||
|
||||
mock_pi = Mock()
|
||||
mock_pi.id = 'pi_test123'
|
||||
mock_pi.currency = 'usd'
|
||||
mock_stripe.PaymentIntent.create.return_value = mock_pi
|
||||
|
||||
mock_tx = Mock()
|
||||
mock_transaction_link.objects.create.return_value = mock_tx
|
||||
mock_transaction_link.Status.PENDING = 'PENDING'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act - 10% fee instead of default 5%
|
||||
amount = Decimal('100.00')
|
||||
pi, tx = service.create_payment_intent(
|
||||
mock_event, amount, application_fee_percent=Decimal('10.0')
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
|
||||
assert call_kwargs['application_fee_amount'] == 1000 # 10% of 10000
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.TransactionLink')
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_create_payment_intent_includes_metadata(
|
||||
self, mock_stripe, mock_transaction_link
|
||||
):
|
||||
"""Test that payment intent includes proper metadata."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
mock_tenant.id = 42
|
||||
mock_tenant.name = 'My Business'
|
||||
mock_tenant.currency = 'EUR'
|
||||
|
||||
mock_event = Mock()
|
||||
mock_event.id = 99
|
||||
mock_event.title = 'Premium Service'
|
||||
|
||||
mock_pi = Mock()
|
||||
mock_pi.id = 'pi_test123'
|
||||
mock_pi.currency = 'eur'
|
||||
mock_stripe.PaymentIntent.create.return_value = mock_pi
|
||||
|
||||
mock_tx = Mock()
|
||||
mock_transaction_link.objects.create.return_value = mock_tx
|
||||
mock_transaction_link.Status.PENDING = 'PENDING'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
pi, tx = service.create_payment_intent(
|
||||
mock_event, Decimal('50.00'), locale='es'
|
||||
)
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
|
||||
metadata = call_kwargs['metadata']
|
||||
|
||||
assert metadata['event_id'] == 99
|
||||
assert metadata['event_title'] == 'Premium Service'
|
||||
assert metadata['tenant_id'] == 42
|
||||
assert metadata['tenant_name'] == 'My Business'
|
||||
assert metadata['locale'] == 'es'
|
||||
|
||||
|
||||
class TestRefundPayment:
|
||||
"""Test refund functionality."""
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_refund_uses_stripe_account(self, mock_stripe):
|
||||
"""Test that refund uses stripe_account header."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
service.refund_payment('pi_test123')
|
||||
|
||||
# Assert
|
||||
mock_stripe.Refund.create.assert_called_once()
|
||||
call_kwargs = mock_stripe.Refund.create.call_args.kwargs
|
||||
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||
assert call_kwargs['payment_intent'] == 'pi_test123'
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_partial_refund_converts_amount(self, mock_stripe):
|
||||
"""Test that partial refund amount is converted to cents."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act - $25 partial refund
|
||||
service.refund_payment('pi_test123', amount=Decimal('25.00'))
|
||||
|
||||
# Assert
|
||||
call_kwargs = mock_stripe.Refund.create.call_args.kwargs
|
||||
assert call_kwargs['amount'] == 2500 # Converted to cents
|
||||
|
||||
|
||||
class TestPaymentMethods:
|
||||
"""Test payment method operations."""
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_list_payment_methods_uses_stripe_account(self, mock_stripe):
|
||||
"""Test that listing payment methods uses stripe_account header."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
service.list_payment_methods('cus_test123')
|
||||
|
||||
# Assert
|
||||
mock_stripe.PaymentMethod.list.assert_called_once()
|
||||
call_kwargs = mock_stripe.PaymentMethod.list.call_args.kwargs
|
||||
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||
assert call_kwargs['customer'] == 'cus_test123'
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_detach_payment_method_uses_stripe_account(self, mock_stripe):
|
||||
"""Test that detaching payment method uses stripe_account header."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
service.detach_payment_method('pm_test123')
|
||||
|
||||
# Assert
|
||||
mock_stripe.PaymentMethod.detach.assert_called_once_with(
|
||||
'pm_test123',
|
||||
stripe_account='acct_test123'
|
||||
)
|
||||
|
||||
|
||||
class TestCustomerOperations:
|
||||
"""Test customer creation and retrieval."""
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_create_customer_on_connected_account(self, mock_stripe):
|
||||
"""Test that new customers are created on connected account."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
mock_tenant.id = 1
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.email = 'test@example.com'
|
||||
mock_user.full_name = 'John Doe'
|
||||
mock_user.username = 'johndoe'
|
||||
mock_user.id = 42
|
||||
mock_user.stripe_customer_id = None
|
||||
|
||||
mock_customer = Mock()
|
||||
mock_customer.id = 'cus_new123'
|
||||
mock_stripe.Customer.create.return_value = mock_customer
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
customer_id = service.create_or_get_customer(mock_user)
|
||||
|
||||
# Assert
|
||||
assert customer_id == 'cus_new123'
|
||||
mock_stripe.Customer.create.assert_called_once()
|
||||
call_kwargs = mock_stripe.Customer.create.call_args.kwargs
|
||||
assert call_kwargs['stripe_account'] == 'acct_test123'
|
||||
assert call_kwargs['email'] == 'test@example.com'
|
||||
|
||||
# Verify user was updated
|
||||
mock_user.save.assert_called_once()
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_get_existing_customer(self, mock_stripe):
|
||||
"""Test that existing customer ID is returned without creating new."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.stripe_customer_id = 'cus_existing123'
|
||||
|
||||
mock_customer = Mock()
|
||||
mock_stripe.Customer.retrieve.return_value = mock_customer
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
customer_id = service.create_or_get_customer(mock_user)
|
||||
|
||||
# Assert
|
||||
assert customer_id == 'cus_existing123'
|
||||
mock_stripe.Customer.create.assert_not_called()
|
||||
|
||||
|
||||
class TestTerminalOperations:
|
||||
"""Test Stripe Terminal operations."""
|
||||
|
||||
@patch('smoothschedule.commerce.payments.services.stripe')
|
||||
def test_get_terminal_token_uses_stripe_account(self, mock_stripe):
|
||||
"""Test that terminal token uses stripe_account header."""
|
||||
# Arrange
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.stripe_connect_id = 'acct_test123'
|
||||
|
||||
with patch('smoothschedule.commerce.payments.services.settings'):
|
||||
service = StripeService(mock_tenant)
|
||||
|
||||
# Act
|
||||
service.get_terminal_token()
|
||||
|
||||
# Assert
|
||||
mock_stripe.terminal.ConnectionToken.create.assert_called_once_with(
|
||||
stripe_account='acct_test123'
|
||||
)
|
||||
2113
smoothschedule/smoothschedule/commerce/payments/tests/test_views.py
Normal file
2113
smoothschedule/smoothschedule/commerce/payments/tests/test_views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,10 +78,10 @@ urlpatterns = [
|
||||
|
||||
# Payment operations (existing)
|
||||
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
||||
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
|
||||
path('refunds/', RefundPaymentView.as_view(), name='create-refund'),
|
||||
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'), # UNUSED_ENDPOINT: For Stripe Terminal POS hardware
|
||||
path('refunds/', RefundPaymentView.as_view(), name='create-refund'), # UNUSED_ENDPOINT: Refunds done via transactions/{id}/refund
|
||||
|
||||
# Customer billing endpoints
|
||||
# Customer billing endpoints (used by customer portal)
|
||||
path('customer/billing/', CustomerBillingView.as_view(), name='customer-billing'),
|
||||
path('customer/payment-methods/', CustomerPaymentMethodsView.as_view(), name='customer-payment-methods'),
|
||||
path('customer/setup-intent/', CustomerSetupIntentView.as_view(), name='customer-setup-intent'),
|
||||
@@ -89,6 +89,6 @@ urlpatterns = [
|
||||
path('customer/payment-methods/<str:payment_method_id>/default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'),
|
||||
|
||||
# Variable pricing / final charge endpoints
|
||||
path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'),
|
||||
path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'),
|
||||
path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'), # UNUSED_ENDPOINT: For setting final price on variable-priced services
|
||||
path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'), # UNUSED_ENDPOINT: Get pricing info for variable-priced events
|
||||
]
|
||||
@@ -10,19 +10,33 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework import status
|
||||
from core.permissions import HasFeaturePermission
|
||||
from smoothschedule.identity.core.permissions import HasFeaturePermission
|
||||
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||
from decimal import Decimal
|
||||
from .services import get_stripe_service_for_tenant
|
||||
from .models import TransactionLink
|
||||
from schedule.models import Event
|
||||
from platform_admin.models import SubscriptionPlan
|
||||
from smoothschedule.scheduling.schedule.models import Event
|
||||
from smoothschedule.platform.admin.models import SubscriptionPlan
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def mask_key(key):
|
||||
"""Mask a key showing only first 7 and last 4 characters."""
|
||||
if not key:
|
||||
return ''
|
||||
if len(key) <= 12:
|
||||
return '*' * len(key)
|
||||
return key[:7] + '*' * (len(key) - 11) + key[-4:]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Payment Configuration Status
|
||||
# ============================================================================
|
||||
|
||||
class PaymentConfigStatusView(APIView):
|
||||
class PaymentConfigStatusView(TenantRequiredAPIView, APIView):
|
||||
"""
|
||||
Get unified payment configuration status.
|
||||
|
||||
@@ -38,7 +52,7 @@ class PaymentConfigStatusView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
tenant = request.tenant
|
||||
tenant = self.tenant
|
||||
|
||||
# Build API keys info if configured
|
||||
api_keys = None
|
||||
@@ -46,8 +60,8 @@ class PaymentConfigStatusView(APIView):
|
||||
api_keys = {
|
||||
'id': tenant.id,
|
||||
'status': tenant.stripe_api_key_status,
|
||||
'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
|
||||
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
|
||||
'secret_key_masked': mask_key(tenant.stripe_secret_key),
|
||||
'publishable_key_masked': mask_key(tenant.stripe_publishable_key),
|
||||
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
|
||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||
@@ -98,14 +112,6 @@ class PaymentConfigStatusView(APIView):
|
||||
'connect_account': connect_account,
|
||||
})
|
||||
|
||||
def _mask_key(self, key):
|
||||
"""Mask a key showing only first 7 and last 4 characters."""
|
||||
if not key:
|
||||
return ''
|
||||
if len(key) <= 12:
|
||||
return '*' * len(key)
|
||||
return key[:7] + '*' * (len(key) - 11) + key[-4:]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subscription Plans & Add-ons
|
||||
@@ -511,7 +517,7 @@ class ReactivateSubscriptionView(APIView):
|
||||
# API Keys Endpoints (Free Tier)
|
||||
# ============================================================================
|
||||
|
||||
class ApiKeysView(APIView):
|
||||
class ApiKeysView(TenantRequiredAPIView, APIView):
|
||||
"""
|
||||
Manage Stripe API keys for direct integration (free tier).
|
||||
|
||||
@@ -522,7 +528,7 @@ class ApiKeysView(APIView):
|
||||
|
||||
def get(self, request):
|
||||
"""Get current API key configuration."""
|
||||
tenant = request.tenant
|
||||
tenant = self.tenant
|
||||
|
||||
if not tenant.stripe_secret_key:
|
||||
return Response({
|
||||
@@ -534,8 +540,8 @@ class ApiKeysView(APIView):
|
||||
'configured': True,
|
||||
'id': tenant.id,
|
||||
'status': tenant.stripe_api_key_status,
|
||||
'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
|
||||
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
|
||||
'secret_key_masked': mask_key(tenant.stripe_secret_key),
|
||||
'publishable_key_masked': mask_key(tenant.stripe_publishable_key),
|
||||
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
|
||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||
@@ -548,22 +554,16 @@ class ApiKeysView(APIView):
|
||||
publishable_key = request.data.get('publishable_key', '').strip()
|
||||
|
||||
if not secret_key or not publishable_key:
|
||||
return Response(
|
||||
{'error': 'Both secret_key and publishable_key are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.error_response('Both secret_key and publishable_key are required')
|
||||
|
||||
# Validate keys against Stripe
|
||||
validation = self._validate_keys(secret_key, publishable_key)
|
||||
validation = validate_stripe_keys(secret_key, publishable_key)
|
||||
|
||||
if not validation['valid']:
|
||||
return Response(
|
||||
{'error': validation.get('error', 'Invalid API keys')},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.error_response(validation.get('error', 'Invalid API keys'))
|
||||
|
||||
# Save keys to tenant
|
||||
tenant = request.tenant
|
||||
tenant = self.tenant
|
||||
tenant.stripe_secret_key = secret_key
|
||||
tenant.stripe_publishable_key = publishable_key
|
||||
tenant.stripe_api_key_status = 'active'
|
||||
@@ -577,16 +577,17 @@ class ApiKeysView(APIView):
|
||||
return Response({
|
||||
'id': tenant.id,
|
||||
'status': 'active',
|
||||
'secret_key_masked': self._mask_key(secret_key),
|
||||
'publishable_key_masked': self._mask_key(publishable_key),
|
||||
'secret_key_masked': mask_key(secret_key),
|
||||
'publishable_key_masked': mask_key(publishable_key),
|
||||
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat(),
|
||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||
'validation_error': '',
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
def _validate_keys(self, secret_key, publishable_key):
|
||||
"""Validate Stripe API keys."""
|
||||
|
||||
def validate_stripe_keys(secret_key, publishable_key):
|
||||
"""Validate Stripe API keys. Returns dict with 'valid' key and validation info."""
|
||||
try:
|
||||
# Test the secret key by retrieving account info
|
||||
stripe.api_key = secret_key
|
||||
@@ -613,16 +614,8 @@ class ApiKeysView(APIView):
|
||||
# Reset to platform key
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
def _mask_key(self, key):
|
||||
"""Mask a key showing only first 7 and last 4 characters."""
|
||||
if not key:
|
||||
return ''
|
||||
if len(key) <= 12:
|
||||
return '*' * len(key)
|
||||
return key[:7] + '*' * (len(key) - 11) + key[-4:]
|
||||
|
||||
|
||||
class ApiKeysValidateView(APIView):
|
||||
class ApiKeysValidateView(TenantAPIView, APIView):
|
||||
"""
|
||||
Validate API keys without saving.
|
||||
|
||||
@@ -641,30 +634,8 @@ class ApiKeysValidateView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
stripe.api_key = secret_key
|
||||
account = stripe.Account.retrieve()
|
||||
|
||||
if not publishable_key.startswith('pk_'):
|
||||
return Response({
|
||||
'valid': False,
|
||||
'error': 'Invalid publishable key format'
|
||||
})
|
||||
|
||||
is_test = secret_key.startswith('sk_test_')
|
||||
|
||||
return Response({
|
||||
'valid': True,
|
||||
'account_id': account.id,
|
||||
'account_name': account.get('business_profile', {}).get('name', '') or account.get('email', ''),
|
||||
'environment': 'test' if is_test else 'live',
|
||||
})
|
||||
except stripe.error.AuthenticationError:
|
||||
return Response({'valid': False, 'error': 'Invalid secret key'})
|
||||
except stripe.error.StripeError as e:
|
||||
return Response({'valid': False, 'error': str(e)})
|
||||
finally:
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
validation = validate_stripe_keys(secret_key, publishable_key)
|
||||
return Response(validation)
|
||||
|
||||
|
||||
class ApiKeysRevalidateView(APIView):
|
||||
@@ -1561,8 +1532,8 @@ class CustomerBillingView(APIView):
|
||||
def get(self, request):
|
||||
"""Get customer billing data."""
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from schedule.models import Participant
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.scheduling.schedule.models import Participant
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
user = request.user
|
||||
|
||||
@@ -1682,7 +1653,7 @@ class CustomerPaymentMethodsView(APIView):
|
||||
|
||||
def get(self, request):
|
||||
"""Get customer's saved payment methods from Stripe."""
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
user = request.user
|
||||
|
||||
@@ -1755,7 +1726,7 @@ class CustomerSetupIntentView(APIView):
|
||||
"""Create a SetupIntent for the customer."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
user = request.user
|
||||
tenant = request.tenant
|
||||
@@ -1870,7 +1841,7 @@ class CustomerPaymentMethodDeleteView(APIView):
|
||||
|
||||
def delete(self, request, payment_method_id):
|
||||
"""Delete a payment method."""
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
user = request.user
|
||||
|
||||
@@ -1933,7 +1904,7 @@ class CustomerPaymentMethodDefaultView(APIView):
|
||||
|
||||
def post(self, request, payment_method_id):
|
||||
"""Set payment method as default."""
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
user = request.user
|
||||
|
||||
@@ -2018,8 +1989,8 @@ class SetFinalPriceView(APIView):
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from schedule.models import Participant
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.scheduling.schedule.models import Participant
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
final_price = request.data.get('final_price')
|
||||
charge_now = request.data.get('charge_now', True)
|
||||
@@ -7,7 +7,7 @@ from django.dispatch import receiver
|
||||
from djstripe import signals
|
||||
from django.utils import timezone
|
||||
from .models import TransactionLink
|
||||
from schedule.models import Event
|
||||
from smoothschedule.scheduling.schedule.models import Event
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -3,7 +3,8 @@ from django.apps import AppConfig
|
||||
|
||||
class TicketsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'tickets'
|
||||
name = 'smoothschedule.commerce.tickets'
|
||||
label = 'tickets'
|
||||
|
||||
def ready(self):
|
||||
import tickets.signals # noqa
|
||||
import smoothschedule.commerce.tickets.signals # noqa
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
from .models import Ticket, TicketComment
|
||||
from .serializers import TicketSerializer, TicketCommentSerializer # Import your serializers
|
||||
|
||||
@@ -31,7 +31,7 @@ def get_default_platform_email():
|
||||
Returns None if no default is configured.
|
||||
"""
|
||||
try:
|
||||
from platform_admin.models import PlatformEmailAddress
|
||||
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||
return PlatformEmailAddress.objects.filter(
|
||||
is_default=True,
|
||||
is_active=True,
|
||||
@@ -75,7 +75,7 @@ class TicketEmailService:
|
||||
Returns None if template not found.
|
||||
"""
|
||||
try:
|
||||
from schedule.models import EmailTemplate
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
return EmailTemplate.objects.filter(
|
||||
name=template_name,
|
||||
scope=EmailTemplate.Scope.BUSINESS
|
||||
@@ -37,7 +37,7 @@ from .models import (
|
||||
TicketEmailAddress,
|
||||
IncomingTicketEmail
|
||||
)
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -713,7 +713,7 @@ class PlatformEmailReceiver:
|
||||
|
||||
def __init__(self, email_address):
|
||||
"""Initialize with a PlatformEmailAddress instance."""
|
||||
from platform_admin.models import PlatformEmailAddress
|
||||
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||
self.email_address = email_address
|
||||
self.connection = None
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone
|
||||
from core.models import Tenant
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
|
||||
class Ticket(models.Model):
|
||||
@@ -160,6 +160,7 @@ class Ticket(models.Model):
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'tickets'
|
||||
ordering = ['-priority', '-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'status']),
|
||||
@@ -247,6 +248,7 @@ class TicketTemplate(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'tickets'
|
||||
ordering = ['ticket_type', 'name']
|
||||
|
||||
def __str__(self):
|
||||
@@ -285,6 +287,7 @@ class CannedResponse(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'tickets'
|
||||
ordering = ['-use_count', 'title']
|
||||
|
||||
def __str__(self):
|
||||
@@ -349,6 +352,7 @@ class TicketComment(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'tickets'
|
||||
ordering = ['created_at']
|
||||
|
||||
@property
|
||||
@@ -495,6 +499,7 @@ class IncomingTicketEmail(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'tickets'
|
||||
ordering = ['-received_at']
|
||||
indexes = [
|
||||
models.Index(fields=['message_id']),
|
||||
@@ -640,6 +645,7 @@ class TicketEmailAddress(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'tickets'
|
||||
ordering = ['-is_default', 'display_name']
|
||||
unique_together = [['tenant', 'email_address']]
|
||||
indexes = [
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
||||
from smoothschedule.users.models import User
|
||||
from core.models import Tenant
|
||||
from smoothschedule.identity.users.models import User
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
|
||||
class TicketCommentSerializer(serializers.ModelSerializer):
|
||||
author_email = serializers.ReadOnlyField(source='author.email')
|
||||
@@ -8,7 +8,7 @@ from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
from .models import Ticket, TicketComment
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,7 +25,7 @@ def is_notifications_available():
|
||||
global _notifications_available
|
||||
if _notifications_available is None:
|
||||
try:
|
||||
from notifications.models import Notification
|
||||
from smoothschedule.communication.notifications.models import Notification
|
||||
# Check if the table exists by doing a simple query
|
||||
Notification.objects.exists()
|
||||
_notifications_available = True
|
||||
@@ -60,7 +60,7 @@ def create_notification(recipient, actor, verb, action_object, target, data):
|
||||
return
|
||||
|
||||
try:
|
||||
from notifications.models import Notification
|
||||
from smoothschedule.communication.notifications.models import Notification
|
||||
Notification.objects.create(
|
||||
recipient=recipient,
|
||||
actor=actor,
|
||||
@@ -33,7 +33,7 @@ def fetch_incoming_emails(self):
|
||||
"""
|
||||
from .email_receiver import TicketEmailReceiver, PlatformEmailReceiver
|
||||
from .models import TicketEmailAddress
|
||||
from platform_admin.models import PlatformEmailAddress
|
||||
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||
|
||||
total_processed = 0
|
||||
results = []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Unit tests for Ticket serializers.
|
||||
|
||||
Tests serializer validation logic without hitting the database.
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from rest_framework.test import APIRequestFactory
|
||||
import pytest
|
||||
|
||||
from smoothschedule.commerce.tickets.serializers import (
|
||||
TicketSerializer,
|
||||
TicketListSerializer,
|
||||
TicketCommentSerializer,
|
||||
TicketTemplateSerializer,
|
||||
CannedResponseSerializer,
|
||||
TicketEmailAddressSerializer,
|
||||
)
|
||||
|
||||
|
||||
class TestTicketSerializerValidation:
|
||||
"""Test TicketSerializer validation logic."""
|
||||
|
||||
def test_read_only_fields_not_writable(self):
|
||||
"""Test that read-only fields are not included in writable fields."""
|
||||
serializer = TicketSerializer()
|
||||
writable_fields = [
|
||||
f for f in serializer.fields
|
||||
if not serializer.fields[f].read_only
|
||||
]
|
||||
|
||||
# These should NOT be writable
|
||||
assert 'id' not in writable_fields
|
||||
assert 'creator' not in writable_fields
|
||||
assert 'creator_email' not in writable_fields
|
||||
assert 'is_overdue' not in writable_fields
|
||||
assert 'created_at' not in writable_fields
|
||||
assert 'comments' not in writable_fields
|
||||
|
||||
def test_writable_fields_present(self):
|
||||
"""Test that writable fields are present."""
|
||||
serializer = TicketSerializer()
|
||||
writable_fields = [
|
||||
f for f in serializer.fields
|
||||
if not serializer.fields[f].read_only
|
||||
]
|
||||
|
||||
# These should be writable
|
||||
assert 'subject' in writable_fields
|
||||
assert 'description' in writable_fields
|
||||
assert 'priority' in writable_fields
|
||||
assert 'status' in writable_fields
|
||||
assert 'assignee' in writable_fields
|
||||
|
||||
|
||||
class TestTicketSerializerCreate:
|
||||
"""Test TicketSerializer create logic."""
|
||||
|
||||
def test_create_sets_creator_from_request(self):
|
||||
"""Test that create sets creator from authenticated user."""
|
||||
# Arrange
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/tickets/')
|
||||
request.user = Mock(is_authenticated=True, tenant=Mock(id=1))
|
||||
|
||||
serializer = TicketSerializer(context={'request': request})
|
||||
|
||||
# Patch the Ticket model
|
||||
with patch.object(TicketSerializer, 'create') as mock_create:
|
||||
mock_create.return_value = Mock()
|
||||
|
||||
validated_data = {
|
||||
'subject': 'Test Ticket',
|
||||
'description': 'Test description',
|
||||
'ticket_type': 'PLATFORM',
|
||||
}
|
||||
|
||||
# The actual create method should set creator
|
||||
# This test verifies the serializer has the context it needs
|
||||
assert serializer.context['request'].user.is_authenticated
|
||||
|
||||
def test_create_requires_tenant_for_non_platform_tickets(self):
|
||||
"""Test that non-platform tickets require tenant."""
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/tickets/')
|
||||
# User without tenant
|
||||
request.user = Mock(is_authenticated=True, tenant=None)
|
||||
|
||||
serializer = TicketSerializer(
|
||||
data={
|
||||
'subject': 'Test Ticket',
|
||||
'description': 'Test description',
|
||||
'ticket_type': 'SUPPORT', # Non-platform ticket
|
||||
},
|
||||
context={'request': request}
|
||||
)
|
||||
|
||||
# Validation should pass at field level but fail in create
|
||||
# (The actual validation happens in create method)
|
||||
assert 'subject' in serializer.fields
|
||||
|
||||
|
||||
class TestTicketSerializerUpdate:
|
||||
"""Test TicketSerializer update logic."""
|
||||
|
||||
def test_update_removes_tenant_from_data(self):
|
||||
"""Test that update prevents changing tenant."""
|
||||
# Arrange
|
||||
mock_instance = Mock()
|
||||
mock_instance.tenant = Mock(id=1)
|
||||
mock_instance.creator = Mock(id=1)
|
||||
|
||||
factory = APIRequestFactory()
|
||||
request = factory.patch('/tickets/1/')
|
||||
request.user = Mock(is_authenticated=True)
|
||||
|
||||
serializer = TicketSerializer(
|
||||
instance=mock_instance,
|
||||
context={'request': request}
|
||||
)
|
||||
|
||||
# The update method should strip tenant and creator
|
||||
with patch.object(TicketSerializer, 'update') as mock_update:
|
||||
validated_data = {
|
||||
'subject': 'Updated Subject',
|
||||
'tenant': Mock(id=2), # Trying to change tenant
|
||||
'creator': Mock(id=2), # Trying to change creator
|
||||
}
|
||||
|
||||
# Simulate calling update
|
||||
# In real usage, tenant and creator would be stripped
|
||||
assert 'subject' in serializer.fields
|
||||
|
||||
|
||||
class TestTicketListSerializer:
|
||||
"""Test TicketListSerializer."""
|
||||
|
||||
def test_excludes_comments(self):
|
||||
"""Test that list serializer excludes comments for performance."""
|
||||
serializer = TicketListSerializer()
|
||||
assert 'comments' not in serializer.fields
|
||||
|
||||
|
||||
class TestTicketCommentSerializer:
|
||||
"""Test TicketCommentSerializer."""
|
||||
|
||||
def test_all_fields_read_only_except_comment_text(self):
|
||||
"""Test that most fields are read-only."""
|
||||
serializer = TicketCommentSerializer()
|
||||
|
||||
# comment_text should be writable
|
||||
assert not serializer.fields['comment_text'].read_only
|
||||
|
||||
# These should be read-only
|
||||
assert serializer.fields['id'].read_only
|
||||
assert serializer.fields['ticket'].read_only
|
||||
assert serializer.fields['author'].read_only
|
||||
assert serializer.fields['created_at'].read_only
|
||||
|
||||
|
||||
class TestTicketTemplateSerializer:
|
||||
"""Test TicketTemplateSerializer."""
|
||||
|
||||
def test_read_only_fields(self):
|
||||
"""Test that correct fields are read-only."""
|
||||
serializer = TicketTemplateSerializer()
|
||||
|
||||
assert serializer.fields['id'].read_only
|
||||
assert serializer.fields['created_at'].read_only
|
||||
|
||||
def test_writable_fields(self):
|
||||
"""Test that correct fields are writable."""
|
||||
serializer = TicketTemplateSerializer()
|
||||
writable = [f for f in serializer.fields if not serializer.fields[f].read_only]
|
||||
|
||||
assert 'name' in writable
|
||||
assert 'description' in writable
|
||||
assert 'ticket_type' in writable
|
||||
assert 'subject_template' in writable
|
||||
|
||||
|
||||
class TestCannedResponseSerializer:
|
||||
"""Test CannedResponseSerializer."""
|
||||
|
||||
def test_read_only_fields(self):
|
||||
"""Test that correct fields are read-only."""
|
||||
serializer = CannedResponseSerializer()
|
||||
|
||||
assert serializer.fields['id'].read_only
|
||||
assert serializer.fields['use_count'].read_only
|
||||
assert serializer.fields['created_by'].read_only
|
||||
assert serializer.fields['created_at'].read_only
|
||||
|
||||
def test_writable_fields(self):
|
||||
"""Test that correct fields are writable."""
|
||||
serializer = CannedResponseSerializer()
|
||||
writable = [f for f in serializer.fields if not serializer.fields[f].read_only]
|
||||
|
||||
assert 'title' in writable
|
||||
assert 'content' in writable
|
||||
assert 'category' in writable
|
||||
assert 'is_active' in writable
|
||||
|
||||
|
||||
class TestTicketEmailAddressSerializer:
|
||||
"""Test TicketEmailAddressSerializer."""
|
||||
|
||||
def test_password_fields_write_only(self):
|
||||
"""Test that password fields are write-only."""
|
||||
serializer = TicketEmailAddressSerializer()
|
||||
|
||||
# Passwords should be write-only (not exposed in responses)
|
||||
assert serializer.fields['imap_password'].write_only
|
||||
assert serializer.fields['smtp_password'].write_only
|
||||
|
||||
def test_computed_fields_read_only(self):
|
||||
"""Test that computed fields are read-only."""
|
||||
serializer = TicketEmailAddressSerializer()
|
||||
|
||||
assert serializer.fields['is_imap_configured'].read_only
|
||||
assert serializer.fields['is_smtp_configured'].read_only
|
||||
assert serializer.fields['is_fully_configured'].read_only
|
||||
|
||||
def test_writable_configuration_fields(self):
|
||||
"""Test that configuration fields are writable."""
|
||||
serializer = TicketEmailAddressSerializer()
|
||||
writable = [f for f in serializer.fields if not serializer.fields[f].read_only]
|
||||
|
||||
assert 'display_name' in writable
|
||||
assert 'email_address' in writable
|
||||
assert 'imap_host' in writable
|
||||
assert 'imap_port' in writable
|
||||
assert 'smtp_host' in writable
|
||||
assert 'smtp_port' in writable
|
||||
@@ -0,0 +1,940 @@
|
||||
"""
|
||||
Unit tests for ticket signals.
|
||||
|
||||
Tests signal handlers, helper functions, and notification logic without database access.
|
||||
Following the testing pyramid: fast, isolated unit tests using mocks.
|
||||
"""
|
||||
import logging
|
||||
from unittest.mock import Mock, patch, MagicMock, call
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from smoothschedule.commerce.tickets.models import Ticket, TicketComment
|
||||
from smoothschedule.commerce.tickets import signals
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
|
||||
class TestIsNotificationsAvailable:
|
||||
"""Test the is_notifications_available() helper function."""
|
||||
|
||||
def test_returns_cached_true_value(self):
|
||||
"""Notification availability check should return cached True value."""
|
||||
# Set the cache to True
|
||||
signals._notifications_available = True
|
||||
|
||||
result = signals.is_notifications_available()
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_returns_cached_false_value(self):
|
||||
"""Notification availability check should return cached False value."""
|
||||
signals._notifications_available = False
|
||||
|
||||
result = signals.is_notifications_available()
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_function_is_callable(self):
|
||||
"""Should be a callable function."""
|
||||
assert callable(signals.is_notifications_available)
|
||||
|
||||
|
||||
class TestSendWebsocketNotification:
|
||||
"""Test the send_websocket_notification() helper function."""
|
||||
|
||||
def test_sends_notification_successfully(self):
|
||||
"""Should send websocket notification via channel layer."""
|
||||
mock_channel_layer = MagicMock()
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_channel_layer', return_value=mock_channel_layer):
|
||||
with patch('smoothschedule.commerce.tickets.signals.async_to_sync') as mock_async:
|
||||
signals.send_websocket_notification(
|
||||
"user_123",
|
||||
{"type": "test", "message": "Hello"}
|
||||
)
|
||||
|
||||
mock_async.assert_called_once()
|
||||
# Verify the correct arguments were passed
|
||||
call_args = mock_async.call_args[0]
|
||||
assert call_args[0] == mock_channel_layer.group_send
|
||||
|
||||
def test_handles_missing_channel_layer(self, caplog):
|
||||
"""Should log warning when channel layer is not configured."""
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_channel_layer', return_value=None):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
signals.send_websocket_notification("user_123", {"type": "test"})
|
||||
|
||||
assert "Channel layer not configured" in caplog.text
|
||||
|
||||
def test_handles_exception_gracefully(self, caplog):
|
||||
"""Should log error and not raise when websocket send fails."""
|
||||
mock_channel_layer = MagicMock()
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_channel_layer', return_value=mock_channel_layer):
|
||||
with patch('smoothschedule.commerce.tickets.signals.async_to_sync', side_effect=Exception("Connection error")):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Should not raise
|
||||
signals.send_websocket_notification("user_123", {"type": "test"})
|
||||
|
||||
assert "Failed to send WebSocket notification" in caplog.text
|
||||
assert "user_123" in caplog.text
|
||||
|
||||
|
||||
class TestCreateNotification:
|
||||
"""Test the create_notification() helper function."""
|
||||
|
||||
def test_skips_when_notifications_unavailable(self, caplog):
|
||||
"""Should skip notification creation when app is unavailable."""
|
||||
with patch('smoothschedule.commerce.tickets.signals.is_notifications_available', return_value=False):
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
signals.create_notification(
|
||||
recipient=Mock(),
|
||||
actor=Mock(),
|
||||
verb="test",
|
||||
action_object=Mock(),
|
||||
target=Mock(),
|
||||
data={}
|
||||
)
|
||||
|
||||
assert "notifications app not available" in caplog.text
|
||||
|
||||
|
||||
class TestGetPlatformSupportTeam:
|
||||
"""Test the get_platform_support_team() helper function."""
|
||||
|
||||
def test_returns_platform_team_members(self):
|
||||
"""Should return users with platform roles."""
|
||||
mock_queryset = Mock()
|
||||
mock_filtered = Mock()
|
||||
mock_queryset.filter.return_value = mock_filtered
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||
result = signals.get_platform_support_team()
|
||||
|
||||
# Verify correct filter was applied
|
||||
mock_queryset.filter.assert_called_once_with(
|
||||
role__in=[User.Role.PLATFORM_SUPPORT, User.Role.PLATFORM_MANAGER, User.Role.SUPERUSER],
|
||||
is_active=True
|
||||
)
|
||||
assert result == mock_filtered
|
||||
|
||||
def test_handles_exception_gracefully(self, caplog):
|
||||
"""Should return empty queryset and log error on exception."""
|
||||
mock_queryset = Mock()
|
||||
mock_queryset.filter.side_effect = Exception("DB error")
|
||||
mock_queryset.none.return_value = Mock()
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
result = signals.get_platform_support_team()
|
||||
|
||||
assert "Failed to fetch platform support team" in caplog.text
|
||||
mock_queryset.none.assert_called_once()
|
||||
|
||||
|
||||
class TestGetTenantManagers:
|
||||
"""Test the get_tenant_managers() helper function."""
|
||||
|
||||
def test_returns_tenant_managers(self):
|
||||
"""Should return owners and managers for a tenant."""
|
||||
mock_tenant = Mock(id=1)
|
||||
mock_queryset = Mock()
|
||||
mock_filtered = Mock()
|
||||
mock_queryset.filter.return_value = mock_filtered
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||
result = signals.get_tenant_managers(mock_tenant)
|
||||
|
||||
mock_queryset.filter.assert_called_once_with(
|
||||
tenant=mock_tenant,
|
||||
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
||||
is_active=True
|
||||
)
|
||||
assert result == mock_filtered
|
||||
|
||||
def test_returns_empty_queryset_when_no_tenant(self):
|
||||
"""Should return empty queryset when tenant is None."""
|
||||
mock_queryset = Mock()
|
||||
mock_queryset.none.return_value = Mock()
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||
result = signals.get_tenant_managers(None)
|
||||
|
||||
mock_queryset.none.assert_called_once()
|
||||
|
||||
def test_handles_exception_gracefully(self, caplog):
|
||||
"""Should return empty queryset and log error on exception."""
|
||||
mock_tenant = Mock(id=1)
|
||||
mock_queryset = Mock()
|
||||
mock_queryset.filter.side_effect = Exception("DB error")
|
||||
mock_queryset.none.return_value = Mock()
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.User.objects', mock_queryset):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
result = signals.get_tenant_managers(mock_tenant)
|
||||
|
||||
assert "Failed to fetch tenant managers" in caplog.text
|
||||
mock_queryset.none.assert_called_once()
|
||||
|
||||
|
||||
class TestTicketPreSaveHandler:
|
||||
"""Test the ticket_pre_save_handler signal receiver."""
|
||||
|
||||
def test_ignores_new_tickets(self):
|
||||
"""Should not store state for new tickets (no pk)."""
|
||||
mock_ticket = Mock(pk=None)
|
||||
|
||||
signals._ticket_pre_save_state.clear()
|
||||
|
||||
signals.ticket_pre_save_handler(sender=Ticket, instance=mock_ticket)
|
||||
|
||||
assert len(signals._ticket_pre_save_state) == 0
|
||||
|
||||
def test_handles_does_not_exist_gracefully(self):
|
||||
"""Should handle DoesNotExist exception gracefully."""
|
||||
mock_ticket = Mock(pk=999)
|
||||
|
||||
signals._ticket_pre_save_state.clear()
|
||||
|
||||
with patch.object(Ticket.objects, 'get', side_effect=Ticket.DoesNotExist):
|
||||
# Should not raise
|
||||
signals.ticket_pre_save_handler(sender=Ticket, instance=mock_ticket)
|
||||
|
||||
assert 999 not in signals._ticket_pre_save_state
|
||||
|
||||
|
||||
class TestTicketNotificationHandler:
|
||||
"""Test the ticket_notification_handler signal receiver."""
|
||||
|
||||
def test_calls_handle_ticket_creation_for_new_tickets(self):
|
||||
"""Should delegate to _handle_ticket_creation for created tickets."""
|
||||
mock_ticket = Mock(id=1)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation') as mock_handle:
|
||||
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=True)
|
||||
|
||||
mock_handle.assert_called_once_with(mock_ticket)
|
||||
|
||||
def test_calls_handle_ticket_update_for_existing_tickets(self):
|
||||
"""Should delegate to _handle_ticket_update for updated tickets."""
|
||||
mock_ticket = Mock(id=1)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_update') as mock_handle:
|
||||
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=False)
|
||||
|
||||
mock_handle.assert_called_once_with(mock_ticket)
|
||||
|
||||
def test_handles_exception_gracefully(self, caplog):
|
||||
"""Should log error and not raise on exception."""
|
||||
mock_ticket = Mock(id=1)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation', side_effect=Exception("Error")):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Should not raise
|
||||
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=True)
|
||||
|
||||
assert "Error in ticket_notification_handler" in caplog.text
|
||||
assert "ticket 1" in caplog.text
|
||||
|
||||
|
||||
class TestSendTicketEmailNotification:
|
||||
"""Test the _send_ticket_email_notification helper function."""
|
||||
|
||||
@override_settings(TICKET_EMAIL_NOTIFICATIONS_ENABLED=False)
|
||||
def test_skips_when_disabled_in_settings(self):
|
||||
"""Should not send emails when disabled in settings."""
|
||||
mock_ticket = Mock(id=1)
|
||||
|
||||
# The function should return early without importing
|
||||
signals._send_ticket_email_notification('assigned', mock_ticket)
|
||||
# No exception means it returned early
|
||||
|
||||
def test_handles_exception(self, caplog):
|
||||
"""Should log error on exception."""
|
||||
mock_ticket = Mock(id=1)
|
||||
|
||||
with patch.object(signals, '_send_ticket_email_notification') as mock_send:
|
||||
# Test the error logging by calling the original and mocking the import to fail
|
||||
pass # The original function handles exceptions internally
|
||||
|
||||
|
||||
class TestSendCommentEmailNotification:
|
||||
"""Test the _send_comment_email_notification helper function."""
|
||||
|
||||
@override_settings(TICKET_EMAIL_NOTIFICATIONS_ENABLED=False)
|
||||
def test_skips_when_disabled_in_settings(self):
|
||||
"""Should not send emails when disabled in settings."""
|
||||
mock_ticket = Mock(id=1)
|
||||
mock_comment = Mock(is_internal=False)
|
||||
|
||||
# Should return early without error
|
||||
signals._send_comment_email_notification(mock_ticket, mock_comment)
|
||||
|
||||
def test_skips_internal_comments(self):
|
||||
"""Should not send emails for internal comments."""
|
||||
mock_ticket = Mock(id=1)
|
||||
mock_comment = Mock(is_internal=True)
|
||||
|
||||
# Should return early without error
|
||||
signals._send_comment_email_notification(mock_ticket, mock_comment)
|
||||
|
||||
|
||||
class TestHandleTicketCreation:
|
||||
"""Test the _handle_ticket_creation helper function."""
|
||||
|
||||
def test_sends_assigned_email_when_assignee_exists(self):
|
||||
"""Should send assignment email when ticket created with assignee."""
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
assignee_id=5,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(full_name="John Doe"),
|
||||
tenant=Mock(id=1),
|
||||
subject="Test",
|
||||
priority="high",
|
||||
category="bug"
|
||||
)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||
signals._handle_ticket_creation(mock_ticket)
|
||||
|
||||
mock_email.assert_called_once_with('assigned', mock_ticket)
|
||||
|
||||
def test_notifies_platform_team_for_platform_tickets(self):
|
||||
"""Should notify platform support team for platform tickets."""
|
||||
mock_creator = Mock(full_name="John Doe")
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
assignee_id=None,
|
||||
ticket_type=Ticket.TicketType.PLATFORM,
|
||||
creator=mock_creator,
|
||||
subject="Platform Issue",
|
||||
priority="high",
|
||||
category="bug"
|
||||
)
|
||||
mock_support = Mock(id=10)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', return_value=[mock_support]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||
signals._handle_ticket_creation(mock_ticket)
|
||||
|
||||
# Verify notification created
|
||||
mock_notify.assert_called_once_with(
|
||||
recipient=mock_support,
|
||||
actor=mock_creator,
|
||||
verb=f"New platform support ticket #1: 'Platform Issue'",
|
||||
action_object=mock_ticket,
|
||||
target=mock_ticket,
|
||||
data={
|
||||
'ticket_id': 1,
|
||||
'subject': "Platform Issue",
|
||||
'priority': "high",
|
||||
'category': "bug"
|
||||
}
|
||||
)
|
||||
|
||||
# Verify websocket sent
|
||||
mock_ws.assert_called_once()
|
||||
|
||||
def test_notifies_tenant_managers_for_customer_tickets(self):
|
||||
"""Should notify tenant managers for customer tickets."""
|
||||
mock_creator = Mock(full_name="Customer")
|
||||
mock_tenant = Mock(id=1)
|
||||
mock_ticket = Mock(
|
||||
id=2,
|
||||
assignee_id=None,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=mock_creator,
|
||||
tenant=mock_tenant,
|
||||
subject="Help needed",
|
||||
priority="normal",
|
||||
category="question"
|
||||
)
|
||||
mock_ticket.get_ticket_type_display.return_value = "Customer"
|
||||
mock_manager = Mock(id=20)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[mock_manager]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||
signals._handle_ticket_creation(mock_ticket)
|
||||
|
||||
# Verify notification created
|
||||
mock_notify.assert_called_once()
|
||||
call_kwargs = mock_notify.call_args[1]
|
||||
assert call_kwargs['recipient'] == mock_manager
|
||||
assert "customer ticket" in call_kwargs['verb'].lower()
|
||||
|
||||
def test_handles_creator_without_full_name(self):
|
||||
"""Should use 'Someone' when creator has no full_name."""
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
assignee_id=None,
|
||||
ticket_type=Ticket.TicketType.PLATFORM,
|
||||
creator=None,
|
||||
subject="Test",
|
||||
priority="high",
|
||||
category="bug"
|
||||
)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', return_value=[]):
|
||||
# Should not raise
|
||||
signals._handle_ticket_creation(mock_ticket)
|
||||
|
||||
def test_handles_exception_gracefully(self, caplog):
|
||||
"""Should log error and not raise on exception."""
|
||||
mock_ticket = Mock(id=1)
|
||||
mock_ticket.ticket_type = Ticket.TicketType.PLATFORM
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', side_effect=Exception("Error")):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Should not raise
|
||||
signals._handle_ticket_creation(mock_ticket)
|
||||
|
||||
assert "Error handling ticket creation" in caplog.text
|
||||
|
||||
|
||||
class TestHandleTicketUpdate:
|
||||
"""Test the _handle_ticket_update helper function."""
|
||||
|
||||
def test_sends_assigned_email_when_assignee_changes(self):
|
||||
"""Should send assignment email when assignee changes."""
|
||||
mock_ticket = Mock(
|
||||
pk=1,
|
||||
id=1,
|
||||
assignee_id=5,
|
||||
assignee=Mock(id=5),
|
||||
status=Ticket.Status.OPEN,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(id=2),
|
||||
tenant=Mock(id=1),
|
||||
subject="Test"
|
||||
)
|
||||
|
||||
# Set pre-save state with different assignee
|
||||
signals._ticket_pre_save_state[1] = {
|
||||
'assignee_id': 3,
|
||||
'status': Ticket.Status.OPEN
|
||||
}
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
mock_email.assert_called_with('assigned', mock_ticket)
|
||||
|
||||
def test_sends_resolved_email_when_status_becomes_resolved(self):
|
||||
"""Should send resolved email when status changes to RESOLVED."""
|
||||
mock_ticket = Mock(
|
||||
pk=1,
|
||||
id=1,
|
||||
assignee_id=None,
|
||||
assignee=None,
|
||||
status=Ticket.Status.RESOLVED,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(id=2),
|
||||
tenant=Mock(id=1),
|
||||
subject="Test"
|
||||
)
|
||||
|
||||
signals._ticket_pre_save_state[1] = {
|
||||
'assignee_id': None,
|
||||
'status': Ticket.Status.OPEN
|
||||
}
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
mock_email.assert_called_with('resolved', mock_ticket)
|
||||
|
||||
def test_sends_resolved_email_when_status_becomes_closed(self):
|
||||
"""Should send resolved email when status changes to CLOSED."""
|
||||
mock_ticket = Mock(
|
||||
pk=1,
|
||||
id=1,
|
||||
assignee_id=None,
|
||||
assignee=None,
|
||||
status=Ticket.Status.CLOSED,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(id=2),
|
||||
tenant=Mock(id=1),
|
||||
subject="Test"
|
||||
)
|
||||
|
||||
signals._ticket_pre_save_state[1] = {
|
||||
'assignee_id': None,
|
||||
'status': Ticket.Status.OPEN
|
||||
}
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
mock_email.assert_called_with('resolved', mock_ticket)
|
||||
|
||||
def test_sends_status_changed_email_for_other_changes(self):
|
||||
"""Should send status changed email for non-resolved status changes."""
|
||||
mock_ticket = Mock(
|
||||
pk=1,
|
||||
id=1,
|
||||
assignee_id=None,
|
||||
assignee=None,
|
||||
status=Ticket.Status.IN_PROGRESS,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(id=2),
|
||||
tenant=Mock(id=1),
|
||||
subject="Test"
|
||||
)
|
||||
|
||||
signals._ticket_pre_save_state[1] = {
|
||||
'assignee_id': None,
|
||||
'status': Ticket.Status.OPEN
|
||||
}
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_ticket_email_notification') as mock_email:
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
mock_email.assert_called_with(
|
||||
'status_changed',
|
||||
mock_ticket,
|
||||
old_status=Ticket.Status.OPEN
|
||||
)
|
||||
|
||||
def test_sends_websocket_to_platform_team_for_platform_tickets(self):
|
||||
"""Should send websocket notifications to platform team."""
|
||||
mock_ticket = Mock(
|
||||
pk=1,
|
||||
id=1,
|
||||
assignee_id=None,
|
||||
assignee=None,
|
||||
status=Ticket.Status.OPEN,
|
||||
ticket_type=Ticket.TicketType.PLATFORM,
|
||||
creator=Mock(id=2),
|
||||
tenant=None,
|
||||
subject="Platform Issue",
|
||||
priority="high"
|
||||
)
|
||||
mock_support = Mock(id=10)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_platform_support_team', return_value=[mock_support]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
# Should send to platform team member
|
||||
calls = mock_ws.call_args_list
|
||||
assert any("user_10" in str(call) for call in calls)
|
||||
|
||||
def test_sends_websocket_to_tenant_managers(self):
|
||||
"""Should send websocket notifications to tenant managers."""
|
||||
mock_ticket = Mock(
|
||||
pk=1,
|
||||
id=1,
|
||||
assignee_id=None,
|
||||
assignee=None,
|
||||
status=Ticket.Status.OPEN,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(id=2),
|
||||
tenant=Mock(id=1),
|
||||
subject="Customer Issue",
|
||||
priority="normal"
|
||||
)
|
||||
mock_manager = Mock(id=20)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[mock_manager]):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
# Should send to tenant manager
|
||||
calls = mock_ws.call_args_list
|
||||
assert any("user_20" in str(call) for call in calls)
|
||||
|
||||
def test_notifies_assignee(self):
|
||||
"""Should create notification and send websocket to assignee."""
|
||||
mock_assignee = Mock(id=5)
|
||||
mock_ticket = Mock(
|
||||
pk=1,
|
||||
id=1,
|
||||
assignee_id=5,
|
||||
assignee=mock_assignee,
|
||||
status=Ticket.Status.OPEN,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(id=2),
|
||||
tenant=Mock(id=1),
|
||||
subject="Test"
|
||||
)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
# Verify notification created for assignee
|
||||
mock_notify.assert_called_once()
|
||||
call_kwargs = mock_notify.call_args[1]
|
||||
assert call_kwargs['recipient'] == mock_assignee
|
||||
|
||||
# Verify websocket sent to assignee
|
||||
calls = mock_ws.call_args_list
|
||||
assert any("user_5" in str(call) for call in calls)
|
||||
|
||||
def test_handles_no_pre_save_state(self):
|
||||
"""Should handle update when no pre-save state exists."""
|
||||
mock_ticket = Mock(
|
||||
pk=999,
|
||||
id=999,
|
||||
assignee_id=None,
|
||||
assignee=None,
|
||||
status=Ticket.Status.OPEN,
|
||||
ticket_type=Ticket.TicketType.CUSTOMER,
|
||||
creator=Mock(id=2),
|
||||
tenant=Mock(id=1),
|
||||
subject="Test",
|
||||
priority="normal"
|
||||
)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.get_tenant_managers', return_value=[]):
|
||||
# Should not raise
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
def test_handles_exception_gracefully(self, caplog):
|
||||
"""Should log error and not raise on exception."""
|
||||
mock_ticket = Mock(pk=1, id=1)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification', side_effect=Exception("Error")):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Should not raise
|
||||
signals._handle_ticket_update(mock_ticket)
|
||||
|
||||
assert "Error handling ticket update" in caplog.text
|
||||
|
||||
|
||||
class TestCommentNotificationHandler:
|
||||
"""Test the comment_notification_handler signal receiver."""
|
||||
|
||||
def test_ignores_comment_updates(self):
|
||||
"""Should not process comment updates, only creation."""
|
||||
mock_comment = Mock(id=1)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification') as mock_email:
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=False)
|
||||
|
||||
mock_email.assert_not_called()
|
||||
|
||||
def test_sends_email_notification_on_creation(self):
|
||||
"""Should send email notification when comment is created."""
|
||||
mock_ticket = Mock(id=1, creator=Mock(id=2), first_response_at=None)
|
||||
mock_author = Mock(id=3, full_name="Support Agent")
|
||||
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_author)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification') as mock_email:
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
mock_email.assert_called_once_with(mock_ticket, mock_comment)
|
||||
|
||||
def test_sets_first_response_at_when_not_creator(self, caplog):
|
||||
"""Should set first_response_at when comment is from non-creator."""
|
||||
mock_creator = Mock(id=2)
|
||||
mock_author = Mock(id=3, full_name="Support")
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_creator,
|
||||
first_response_at=None,
|
||||
save=Mock()
|
||||
)
|
||||
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_author)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.timezone.now', return_value=Mock()):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
with caplog.at_level(logging.INFO):
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Verify first_response_at was set
|
||||
assert mock_ticket.first_response_at is not None
|
||||
mock_ticket.save.assert_called_once_with(update_fields=['first_response_at'])
|
||||
assert "Set first_response_at for ticket 1" in caplog.text
|
||||
|
||||
def test_does_not_set_first_response_at_for_creator_comment(self):
|
||||
"""Should not set first_response_at when creator comments."""
|
||||
mock_creator = Mock(id=2)
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_creator,
|
||||
first_response_at=None,
|
||||
save=Mock()
|
||||
)
|
||||
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_creator)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Verify first_response_at was NOT set
|
||||
mock_ticket.save.assert_not_called()
|
||||
|
||||
def test_does_not_overwrite_existing_first_response_at(self):
|
||||
"""Should not overwrite first_response_at if already set."""
|
||||
mock_creator = Mock(id=2)
|
||||
mock_author = Mock(id=3)
|
||||
existing_time = Mock()
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_creator,
|
||||
first_response_at=existing_time,
|
||||
save=Mock()
|
||||
)
|
||||
mock_comment = Mock(id=1, ticket=mock_ticket, author=mock_author)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Verify first_response_at was NOT changed
|
||||
assert mock_ticket.first_response_at == existing_time
|
||||
mock_ticket.save.assert_not_called()
|
||||
|
||||
def test_notifies_ticket_creator(self):
|
||||
"""Should notify ticket creator about new comment."""
|
||||
mock_creator = Mock(id=2)
|
||||
mock_author = Mock(id=3, full_name="Support Agent")
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_creator,
|
||||
assignee=None,
|
||||
first_response_at=Mock(),
|
||||
subject="Help"
|
||||
)
|
||||
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_author)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Verify notification created for creator
|
||||
mock_notify.assert_called()
|
||||
call_kwargs = mock_notify.call_args[1]
|
||||
assert call_kwargs['recipient'] == mock_creator
|
||||
assert "New comment on your ticket" in call_kwargs['verb']
|
||||
|
||||
# Verify websocket sent to creator
|
||||
calls = mock_ws.call_args_list
|
||||
assert any("user_2" in str(call) for call in calls)
|
||||
|
||||
def test_does_not_notify_creator_if_they_are_author(self):
|
||||
"""Should not notify creator if they authored the comment."""
|
||||
mock_creator = Mock(id=2)
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_creator,
|
||||
assignee=None,
|
||||
first_response_at=Mock(),
|
||||
subject="Help"
|
||||
)
|
||||
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_creator)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Verify notification NOT created
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
def test_notifies_assignee(self):
|
||||
"""Should notify assignee about new comment."""
|
||||
mock_creator = Mock(id=2)
|
||||
mock_assignee = Mock(id=5)
|
||||
mock_author = Mock(id=3, full_name="Customer")
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_creator,
|
||||
assignee=mock_assignee,
|
||||
first_response_at=Mock(),
|
||||
subject="Issue"
|
||||
)
|
||||
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_author)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification') as mock_ws:
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Should be called twice: once for creator, once for assignee
|
||||
assert mock_notify.call_count == 2
|
||||
|
||||
# Verify assignee notification
|
||||
calls = [call[1] for call in mock_notify.call_args_list]
|
||||
assignee_call = [c for c in calls if c['recipient'] == mock_assignee][0]
|
||||
assert "you are assigned to" in assignee_call['verb']
|
||||
|
||||
def test_does_not_notify_assignee_if_they_are_author(self):
|
||||
"""Should not notify assignee if they authored the comment."""
|
||||
mock_creator = Mock(id=2)
|
||||
mock_assignee = Mock(id=5)
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_creator,
|
||||
assignee=mock_assignee,
|
||||
first_response_at=Mock(),
|
||||
subject="Issue"
|
||||
)
|
||||
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_assignee)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Should only notify creator (not assignee)
|
||||
assert mock_notify.call_count == 1
|
||||
call_kwargs = mock_notify.call_args[1]
|
||||
assert call_kwargs['recipient'] == mock_creator
|
||||
|
||||
def test_does_not_notify_assignee_if_they_are_also_creator(self):
|
||||
"""Should not notify assignee if they are also the creator."""
|
||||
mock_user = Mock(id=2) # Same user is both creator and assignee
|
||||
mock_author = Mock(id=3)
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=mock_user,
|
||||
assignee=mock_user,
|
||||
first_response_at=Mock(),
|
||||
subject="Issue"
|
||||
)
|
||||
mock_comment = Mock(id=10, ticket=mock_ticket, author=mock_author)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification') as mock_notify:
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
# Should only notify once (as creator, not as assignee)
|
||||
assert mock_notify.call_count == 1
|
||||
|
||||
def test_handles_exception_gracefully(self, caplog):
|
||||
"""Should log error and not raise on exception."""
|
||||
mock_comment = Mock(id=1)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification', side_effect=Exception("Error")):
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Should not raise
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
assert "Error in comment_notification_handler" in caplog.text
|
||||
assert "comment 1" in caplog.text
|
||||
|
||||
def test_handles_author_without_full_name(self):
|
||||
"""Should use 'Someone' when author has no full_name."""
|
||||
mock_ticket = Mock(
|
||||
id=1,
|
||||
creator=Mock(id=2),
|
||||
assignee=None,
|
||||
first_response_at=Mock(),
|
||||
subject="Test"
|
||||
)
|
||||
mock_comment = Mock(id=10, ticket=mock_ticket, author=None)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._send_comment_email_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.create_notification'):
|
||||
with patch('smoothschedule.commerce.tickets.signals.send_websocket_notification'):
|
||||
# Should not raise
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=mock_comment, created=True)
|
||||
|
||||
|
||||
class TestSignalRegistration:
|
||||
"""Test that signals are properly registered."""
|
||||
|
||||
def test_ticket_pre_save_handler_is_registered(self):
|
||||
"""Should verify ticket_pre_save_handler is connected to pre_save signal."""
|
||||
assert callable(signals.ticket_pre_save_handler)
|
||||
|
||||
# Verify it accepts the correct parameters by calling it
|
||||
mock_ticket = Mock(pk=None)
|
||||
try:
|
||||
signals.ticket_pre_save_handler(sender=Ticket, instance=mock_ticket)
|
||||
assert True
|
||||
except TypeError as e:
|
||||
pytest.fail(f"Handler has incorrect signature: {e}")
|
||||
|
||||
def test_ticket_notification_handler_is_registered(self):
|
||||
"""Should verify ticket_notification_handler is connected to post_save signal."""
|
||||
assert callable(signals.ticket_notification_handler)
|
||||
|
||||
# Verify it accepts the correct parameters
|
||||
mock_ticket = Mock(id=1, ticket_type=Ticket.TicketType.CUSTOMER)
|
||||
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation'):
|
||||
try:
|
||||
signals.ticket_notification_handler(sender=Ticket, instance=mock_ticket, created=True)
|
||||
assert True
|
||||
except TypeError as e:
|
||||
pytest.fail(f"Handler has incorrect signature: {e}")
|
||||
|
||||
def test_comment_notification_handler_is_registered(self):
|
||||
"""Should verify comment_notification_handler is connected to post_save signal."""
|
||||
assert callable(signals.comment_notification_handler)
|
||||
|
||||
# Verify it accepts the correct parameters
|
||||
try:
|
||||
signals.comment_notification_handler(sender=TicketComment, instance=Mock(id=1), created=False)
|
||||
assert True
|
||||
except TypeError as e:
|
||||
pytest.fail(f"Handler has incorrect signature: {e}")
|
||||
|
||||
|
||||
class TestSignalHandlerSignatures:
|
||||
"""Test that signal handlers have correct signatures."""
|
||||
|
||||
def test_ticket_pre_save_handler_signature(self):
|
||||
"""Should accept correct parameters for pre_save signal."""
|
||||
# Should not raise TypeError
|
||||
signals.ticket_pre_save_handler(
|
||||
sender=Ticket,
|
||||
instance=Mock(pk=None),
|
||||
raw=False,
|
||||
using='default',
|
||||
update_fields=None
|
||||
)
|
||||
|
||||
def test_ticket_notification_handler_signature(self):
|
||||
"""Should accept correct parameters for post_save signal."""
|
||||
mock_ticket = Mock(id=1, ticket_type=Ticket.TicketType.CUSTOMER)
|
||||
|
||||
with patch('smoothschedule.commerce.tickets.signals._handle_ticket_creation'):
|
||||
# Should not raise TypeError
|
||||
signals.ticket_notification_handler(
|
||||
sender=Ticket,
|
||||
instance=mock_ticket,
|
||||
created=True,
|
||||
raw=False,
|
||||
using='default',
|
||||
update_fields=None
|
||||
)
|
||||
|
||||
def test_comment_notification_handler_signature(self):
|
||||
"""Should accept correct parameters for post_save signal."""
|
||||
# Should not raise TypeError (testing signature, not functionality)
|
||||
signals.comment_notification_handler(
|
||||
sender=TicketComment,
|
||||
instance=Mock(id=1),
|
||||
created=False,
|
||||
raw=False,
|
||||
using='default',
|
||||
update_fields=None
|
||||
)
|
||||
1199
smoothschedule/smoothschedule/commerce/tickets/tests/test_views.py
Normal file
1199
smoothschedule/smoothschedule/commerce/tickets/tests/test_views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ from rest_framework.views import APIView
|
||||
from django.db.models import Q
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
|
||||
from core.models import Tenant
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
from smoothschedule.identity.users.models import User
|
||||
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
||||
from .serializers import (
|
||||
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
|
||||
@@ -804,7 +804,7 @@ class TicketEmailAddressViewSet(viewsets.ModelViewSet):
|
||||
# Business users see only their own email addresses
|
||||
if hasattr(user, 'tenant') and user.tenant:
|
||||
# Only owners and managers can view/manage email addresses
|
||||
if user.role in [User.Role.OWNER, User.Role.MANAGER]:
|
||||
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||
return TicketEmailAddress.objects.filter(tenant=user.tenant)
|
||||
|
||||
return TicketEmailAddress.objects.none()
|
||||
@@ -941,7 +941,7 @@ class RefreshTicketEmailsView(APIView):
|
||||
)
|
||||
|
||||
from .email_receiver import PlatformEmailReceiver
|
||||
from platform_admin.models import PlatformEmailAddress
|
||||
from smoothschedule.platform.admin.models import PlatformEmailAddress
|
||||
|
||||
results = []
|
||||
total_processed = 0
|
||||
@@ -3,5 +3,6 @@ from django.apps import AppConfig
|
||||
|
||||
class CommsCreditsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'smoothschedule.comms_credits'
|
||||
name = 'smoothschedule.communication.credits'
|
||||
label = 'comms_credits'
|
||||
verbose_name = 'Communication Credits'
|
||||
@@ -109,6 +109,7 @@ class CommunicationCredits(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'comms_credits'
|
||||
verbose_name = 'Communication Credits'
|
||||
verbose_name_plural = 'Communication Credits'
|
||||
|
||||
@@ -210,7 +211,7 @@ class CommunicationCredits(models.Model):
|
||||
|
||||
def _send_low_balance_warning(self):
|
||||
"""Send low balance warning email."""
|
||||
from smoothschedule.comms_credits.tasks import send_low_balance_warning
|
||||
from smoothschedule.communication.credits.tasks import send_low_balance_warning
|
||||
send_low_balance_warning.delay(self.id)
|
||||
|
||||
self.low_balance_warning_sent = True
|
||||
@@ -219,7 +220,7 @@ class CommunicationCredits(models.Model):
|
||||
|
||||
def _trigger_auto_reload(self):
|
||||
"""Trigger auto-reload of credits."""
|
||||
from smoothschedule.comms_credits.tasks import process_auto_reload
|
||||
from smoothschedule.communication.credits.tasks import process_auto_reload
|
||||
process_auto_reload.delay(self.id)
|
||||
|
||||
|
||||
@@ -291,6 +292,7 @@ class CreditTransaction(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'comms_credits'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['credits', '-created_at']),
|
||||
@@ -383,6 +385,7 @@ class ProxyPhoneNumber(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'comms_credits'
|
||||
ordering = ['phone_number']
|
||||
verbose_name = 'Proxy Phone Number'
|
||||
verbose_name_plural = 'Proxy Phone Numbers'
|
||||
@@ -495,6 +498,7 @@ class MaskedSession(models.Model):
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'comms_credits'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'status']),
|
||||
@@ -20,7 +20,7 @@ def sync_twilio_usage_all_tenants():
|
||||
2. Calculate charges with markup
|
||||
3. Deduct from tenant credits
|
||||
"""
|
||||
from core.models import Tenant
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
|
||||
tenants = Tenant.objects.exclude(twilio_subaccount_sid='')
|
||||
|
||||
@@ -46,7 +46,7 @@ def sync_twilio_usage_for_tenant(tenant_id):
|
||||
|
||||
Fetches usage from Twilio API and deducts from credits.
|
||||
"""
|
||||
from core.models import Tenant
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
from .models import CommunicationCredits
|
||||
|
||||
try:
|
||||
@@ -219,7 +219,7 @@ def process_auto_reload(credits_id):
|
||||
|
||||
try:
|
||||
# Get Stripe API key from platform settings
|
||||
from platform_admin.models import PlatformSettings
|
||||
from smoothschedule.platform.admin.models import PlatformSettings
|
||||
platform_settings = PlatformSettings.get_instance()
|
||||
stripe.api_key = platform_settings.get_stripe_secret_key()
|
||||
|
||||
@@ -373,7 +373,7 @@ def create_twilio_subaccount(tenant_id):
|
||||
|
||||
Called when SMS/calling is first enabled for a tenant.
|
||||
"""
|
||||
from core.models import Tenant
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
from twilio.rest import Client
|
||||
|
||||
try:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user