Compare commits
5 Commits
8440ac945a
...
67ce2c433c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67ce2c433c | ||
|
|
1391374d45 | ||
|
|
b9e90e6f46 | ||
|
|
1af79cc019 | ||
|
|
156cc2676d |
52
CLAUDE.md
52
CLAUDE.md
@@ -178,25 +178,51 @@ toUTC(date) // For API requests
|
||||
formatForDisplay(utcString, businessTimezone) // For displaying
|
||||
```
|
||||
|
||||
## Key Django Apps
|
||||
## 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` | `smoothschedule/core/` | Shared mixins, permissions, middleware |
|
||||
| `payments` | `smoothschedule/payments/` | Stripe integration, subscriptions |
|
||||
| `platform_admin` | `smoothschedule/platform_admin/` | Platform administration |
|
||||
| `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/core/mixins.py`. Use these to avoid code duplication.
|
||||
Located in `smoothschedule/smoothschedule/identity/core/mixins.py`. Use these to avoid code duplication.
|
||||
|
||||
### Permission Classes
|
||||
|
||||
```python
|
||||
from core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
|
||||
from smoothschedule.identity.core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
|
||||
|
||||
class MyViewSet(ModelViewSet):
|
||||
# Block write operations for staff (GET allowed)
|
||||
@@ -236,7 +262,7 @@ docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from smoothschedule.users.models import User
|
||||
from smoothschedule.identity.users.models import User
|
||||
|
||||
# Find the staff member
|
||||
staff = User.objects.get(email='john@example.com')
|
||||
@@ -263,7 +289,7 @@ Then grant via: `staff.permissions['can_manage_equipment'] = True`
|
||||
### QuerySet Mixins
|
||||
|
||||
```python
|
||||
from core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
|
||||
from smoothschedule.identity.core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
|
||||
|
||||
# For tenant-scoped models (automatic django-tenants filtering)
|
||||
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
|
||||
@@ -282,7 +308,7 @@ class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
|
||||
### Feature Permission Mixins
|
||||
|
||||
```python
|
||||
from core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
|
||||
from smoothschedule.identity.core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
|
||||
|
||||
# Checks can_use_plugins feature on list/retrieve/create
|
||||
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
|
||||
@@ -297,7 +323,7 @@ class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin
|
||||
|
||||
```python
|
||||
from rest_framework.views import APIView
|
||||
from core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||
from smoothschedule.identity.core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||
|
||||
# Optional tenant - use self.get_tenant()
|
||||
class MyView(TenantAPIView, APIView):
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
│ ├── 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/
|
||||
├── 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
|
||||
│ └── 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,15 +31,12 @@ 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',
|
||||
'corsheaders',
|
||||
'drf_spectacular',
|
||||
'channels', # WebSockets
|
||||
'channels', # WebSockets
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.mfa',
|
||||
@@ -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,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
@@ -10,13 +10,13 @@ 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 core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||
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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -1532,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
|
||||
|
||||
@@ -1653,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
|
||||
|
||||
@@ -1726,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
|
||||
@@ -1841,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
|
||||
|
||||
@@ -1904,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
|
||||
|
||||
@@ -1989,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:
|
||||
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
Unit tests for Communication Credits models.
|
||||
|
||||
These tests use mocks to avoid database dependencies and run quickly.
|
||||
They test model methods, properties, and business logic.
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock, call
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
from django.utils import timezone
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCommunicationCreditsModel:
|
||||
"""Tests for CommunicationCredits model."""
|
||||
|
||||
def test_str_representation(self):
|
||||
"""Test string representation shows tenant and balance."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.name = "Test Business"
|
||||
|
||||
# Create instance and set attributes directly
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.tenant = mock_tenant
|
||||
credits.balance_cents = 1500
|
||||
|
||||
# Call the real __str__ method
|
||||
result = CommunicationCredits.__str__(credits)
|
||||
|
||||
assert result == "Test Business - $15.00"
|
||||
|
||||
def test_balance_property_converts_cents_to_dollars(self):
|
||||
"""Test balance property returns dollars."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 2500
|
||||
|
||||
# Call the real property getter
|
||||
result = CommunicationCredits.balance.fget(credits)
|
||||
|
||||
assert result == 25.0
|
||||
|
||||
def test_balance_property_handles_zero(self):
|
||||
"""Test balance property handles zero balance."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 0
|
||||
|
||||
result = CommunicationCredits.balance.fget(credits)
|
||||
|
||||
assert result == 0.0
|
||||
|
||||
def test_auto_reload_threshold_property(self):
|
||||
"""Test auto reload threshold converts cents to dollars."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.auto_reload_threshold_cents = 1000
|
||||
|
||||
result = CommunicationCredits.auto_reload_threshold.fget(credits)
|
||||
|
||||
assert result == 10.0
|
||||
|
||||
def test_auto_reload_amount_property(self):
|
||||
"""Test auto reload amount converts cents to dollars."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.auto_reload_amount_cents = 2500
|
||||
|
||||
result = CommunicationCredits.auto_reload_amount.fget(credits)
|
||||
|
||||
assert result == 25.0
|
||||
|
||||
def test_deduct_success_returns_transaction(self):
|
||||
"""Test deduct with sufficient balance returns transaction."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 5000
|
||||
credits.total_spent_cents = 0
|
||||
credits.save = Mock()
|
||||
credits._check_thresholds = Mock()
|
||||
|
||||
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||
mock_transaction = Mock(id=1)
|
||||
mock_tx.objects.create.return_value = mock_transaction
|
||||
|
||||
# Call the real deduct method
|
||||
result = CommunicationCredits.deduct(
|
||||
credits,
|
||||
amount_cents=1000,
|
||||
description="Test charge",
|
||||
reference_type="sms",
|
||||
reference_id="SM123"
|
||||
)
|
||||
|
||||
# Verify balance updated
|
||||
assert credits.balance_cents == 4000
|
||||
assert credits.total_spent_cents == 1000
|
||||
|
||||
# Verify save called
|
||||
credits.save.assert_called_once_with(
|
||||
update_fields=['balance_cents', 'total_spent_cents', 'updated_at']
|
||||
)
|
||||
|
||||
# Verify transaction created
|
||||
mock_tx.objects.create.assert_called_once_with(
|
||||
credits=credits,
|
||||
amount_cents=-1000,
|
||||
balance_after_cents=4000,
|
||||
transaction_type='usage',
|
||||
description="Test charge",
|
||||
reference_type="sms",
|
||||
reference_id="SM123"
|
||||
)
|
||||
|
||||
# Verify thresholds checked
|
||||
credits._check_thresholds.assert_called_once()
|
||||
|
||||
assert result == mock_transaction
|
||||
|
||||
def test_deduct_insufficient_balance_returns_none(self):
|
||||
"""Test deduct with insufficient balance returns None."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 500
|
||||
credits.save = Mock()
|
||||
|
||||
result = CommunicationCredits.deduct(
|
||||
credits,
|
||||
amount_cents=1000,
|
||||
description="Test charge"
|
||||
)
|
||||
|
||||
# Verify no changes made
|
||||
assert credits.balance_cents == 500
|
||||
credits.save.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
def test_deduct_handles_none_references(self):
|
||||
"""Test deduct handles None reference_type and reference_id."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 5000
|
||||
credits.total_spent_cents = 0
|
||||
credits.save = Mock()
|
||||
credits._check_thresholds = Mock()
|
||||
|
||||
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||
mock_tx.objects.create.return_value = Mock(id=1)
|
||||
|
||||
CommunicationCredits.deduct(
|
||||
credits,
|
||||
amount_cents=1000,
|
||||
description="Test"
|
||||
)
|
||||
|
||||
# Verify empty strings used for None values
|
||||
call_kwargs = mock_tx.objects.create.call_args[1]
|
||||
assert call_kwargs['reference_type'] == ''
|
||||
assert call_kwargs['reference_id'] == ''
|
||||
|
||||
def test_add_credits_updates_balance_and_total(self):
|
||||
"""Test add_credits updates balance and total loaded."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 1000
|
||||
credits.total_loaded_cents = 5000
|
||||
credits.low_balance_warning_sent = True
|
||||
credits.save = Mock()
|
||||
|
||||
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||
CommunicationCredits.add_credits(
|
||||
credits,
|
||||
amount_cents=2500,
|
||||
transaction_type='manual',
|
||||
stripe_charge_id='ch_123',
|
||||
description="Top-up"
|
||||
)
|
||||
|
||||
# Verify balance and totals updated
|
||||
assert credits.balance_cents == 3500
|
||||
assert credits.total_loaded_cents == 7500
|
||||
assert credits.low_balance_warning_sent is False
|
||||
|
||||
# Verify save called
|
||||
credits.save.assert_called_once()
|
||||
|
||||
# Verify transaction created
|
||||
mock_tx.objects.create.assert_called_once_with(
|
||||
credits=credits,
|
||||
amount_cents=2500,
|
||||
balance_after_cents=3500,
|
||||
transaction_type='manual',
|
||||
description="Top-up",
|
||||
stripe_charge_id='ch_123'
|
||||
)
|
||||
|
||||
def test_add_credits_uses_default_description(self):
|
||||
"""Test add_credits uses default description when not provided."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 1000
|
||||
credits.total_loaded_cents = 0
|
||||
credits.low_balance_warning_sent = False
|
||||
credits.save = Mock()
|
||||
|
||||
with patch('smoothschedule.communication.credits.models.CreditTransaction') as mock_tx:
|
||||
CommunicationCredits.add_credits(
|
||||
credits,
|
||||
amount_cents=2500,
|
||||
transaction_type='auto_reload'
|
||||
)
|
||||
|
||||
call_kwargs = mock_tx.objects.create.call_args[1]
|
||||
assert call_kwargs['description'] == "Credits added (auto_reload)"
|
||||
|
||||
def test_check_thresholds_sends_warning_when_below_threshold(self):
|
||||
"""Test _check_thresholds sends warning when below threshold."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 400
|
||||
credits.low_balance_warning_cents = 500
|
||||
credits.low_balance_warning_sent = False
|
||||
credits.auto_reload_enabled = False # Disable auto-reload for this test
|
||||
credits._send_low_balance_warning = Mock()
|
||||
credits._trigger_auto_reload = Mock()
|
||||
|
||||
CommunicationCredits._check_thresholds(credits)
|
||||
|
||||
credits._send_low_balance_warning.assert_called_once()
|
||||
|
||||
def test_check_thresholds_skips_warning_if_already_sent(self):
|
||||
"""Test _check_thresholds skips warning if already sent."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 400
|
||||
credits.low_balance_warning_cents = 500
|
||||
credits.low_balance_warning_sent = True
|
||||
credits.auto_reload_enabled = False # Disable auto-reload for this test
|
||||
credits._send_low_balance_warning = Mock()
|
||||
|
||||
CommunicationCredits._check_thresholds(credits)
|
||||
|
||||
credits._send_low_balance_warning.assert_not_called()
|
||||
|
||||
def test_check_thresholds_triggers_auto_reload_when_enabled(self):
|
||||
"""Test _check_thresholds triggers auto reload when conditions met."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 800
|
||||
credits.low_balance_warning_cents = 100
|
||||
credits.low_balance_warning_sent = False
|
||||
credits.auto_reload_enabled = True
|
||||
credits.auto_reload_threshold_cents = 1000
|
||||
credits.stripe_payment_method_id = 'pm_123'
|
||||
credits._send_low_balance_warning = Mock()
|
||||
credits._trigger_auto_reload = Mock()
|
||||
|
||||
CommunicationCredits._check_thresholds(credits)
|
||||
|
||||
credits._trigger_auto_reload.assert_called_once()
|
||||
|
||||
def test_check_thresholds_skips_auto_reload_when_disabled(self):
|
||||
"""Test _check_thresholds skips auto reload when disabled."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 800
|
||||
credits.low_balance_warning_cents = 100
|
||||
credits.auto_reload_enabled = False
|
||||
credits.auto_reload_threshold_cents = 1000
|
||||
credits.stripe_payment_method_id = 'pm_123'
|
||||
credits._trigger_auto_reload = Mock()
|
||||
|
||||
CommunicationCredits._check_thresholds(credits)
|
||||
|
||||
credits._trigger_auto_reload.assert_not_called()
|
||||
|
||||
def test_check_thresholds_skips_auto_reload_without_payment_method(self):
|
||||
"""Test _check_thresholds skips auto reload without payment method."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.balance_cents = 800
|
||||
credits.low_balance_warning_cents = 100
|
||||
credits.auto_reload_enabled = True
|
||||
credits.auto_reload_threshold_cents = 1000
|
||||
credits.stripe_payment_method_id = ''
|
||||
credits._trigger_auto_reload = Mock()
|
||||
|
||||
CommunicationCredits._check_thresholds(credits)
|
||||
|
||||
credits._trigger_auto_reload.assert_not_called()
|
||||
|
||||
def test_send_low_balance_warning_triggers_task(self):
|
||||
"""Test _send_low_balance_warning triggers Celery task."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock(spec=CommunicationCredits)
|
||||
credits.id = 42
|
||||
credits.save = Mock()
|
||||
|
||||
with patch('smoothschedule.communication.credits.tasks.send_low_balance_warning') as mock_task, \
|
||||
patch('smoothschedule.communication.credits.models.timezone.now') as mock_now:
|
||||
|
||||
mock_now.return_value = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||
|
||||
CommunicationCredits._send_low_balance_warning(credits)
|
||||
|
||||
# Verify task triggered
|
||||
mock_task.delay.assert_called_once_with(42)
|
||||
|
||||
# Verify save called with correct update_fields
|
||||
credits.save.assert_called_once_with(
|
||||
update_fields=['low_balance_warning_sent', 'low_balance_warning_sent_at']
|
||||
)
|
||||
|
||||
def test_trigger_auto_reload_triggers_task(self):
|
||||
"""Test _trigger_auto_reload triggers Celery task."""
|
||||
from smoothschedule.communication.credits.models import CommunicationCredits
|
||||
|
||||
credits = Mock() # Don't use spec here as we only need the id
|
||||
credits.id = 42
|
||||
|
||||
with patch('smoothschedule.communication.credits.tasks.process_auto_reload') as mock_task:
|
||||
CommunicationCredits._trigger_auto_reload(credits)
|
||||
mock_task.delay.assert_called_once_with(42)
|
||||
|
||||
|
||||
class TestCreditTransactionModel:
|
||||
"""Tests for CreditTransaction model."""
|
||||
|
||||
def test_str_representation_positive_amount(self):
|
||||
"""Test string representation for credit (positive amount)."""
|
||||
from smoothschedule.communication.credits.models import CreditTransaction
|
||||
|
||||
transaction = Mock(spec=CreditTransaction)
|
||||
transaction.amount_cents = 2500
|
||||
transaction.description = "Manual top-up"
|
||||
|
||||
result = CreditTransaction.__str__(transaction)
|
||||
|
||||
assert result == "+$25.00 - Manual top-up"
|
||||
|
||||
def test_str_representation_negative_amount(self):
|
||||
"""Test string representation for debit (negative amount)."""
|
||||
from smoothschedule.communication.credits.models import CreditTransaction
|
||||
|
||||
transaction = Mock(spec=CreditTransaction)
|
||||
transaction.amount_cents = -1500
|
||||
transaction.description = "SMS charge"
|
||||
|
||||
result = CreditTransaction.__str__(transaction)
|
||||
|
||||
# Negative amounts display as "$-15.00" because the sign is part of the number
|
||||
assert result == "$-15.00 - SMS charge"
|
||||
|
||||
def test_str_representation_zero_amount(self):
|
||||
"""Test string representation for zero amount."""
|
||||
from smoothschedule.communication.credits.models import CreditTransaction
|
||||
|
||||
transaction = Mock(spec=CreditTransaction)
|
||||
transaction.amount_cents = 0
|
||||
transaction.description = "Test"
|
||||
|
||||
result = CreditTransaction.__str__(transaction)
|
||||
|
||||
# Zero is neither positive nor negative, so no sign
|
||||
assert result == "$0.00 - Test"
|
||||
|
||||
def test_amount_property_converts_cents_to_dollars(self):
|
||||
"""Test amount property returns dollars."""
|
||||
from smoothschedule.communication.credits.models import CreditTransaction
|
||||
|
||||
transaction = Mock(spec=CreditTransaction)
|
||||
transaction.amount_cents = 3500
|
||||
|
||||
result = CreditTransaction.amount.fget(transaction)
|
||||
|
||||
assert result == 35.0
|
||||
|
||||
def test_amount_property_handles_negative(self):
|
||||
"""Test amount property handles negative amounts."""
|
||||
from smoothschedule.communication.credits.models import CreditTransaction
|
||||
|
||||
transaction = Mock(spec=CreditTransaction)
|
||||
transaction.amount_cents = -1250
|
||||
|
||||
result = CreditTransaction.amount.fget(transaction)
|
||||
|
||||
assert result == -12.5
|
||||
|
||||
def test_transaction_type_choices(self):
|
||||
"""Test TransactionType choices are defined correctly."""
|
||||
from smoothschedule.communication.credits.models import CreditTransaction
|
||||
|
||||
assert CreditTransaction.TransactionType.MANUAL == 'manual'
|
||||
assert CreditTransaction.TransactionType.AUTO_RELOAD == 'auto_reload'
|
||||
assert CreditTransaction.TransactionType.USAGE == 'usage'
|
||||
assert CreditTransaction.TransactionType.REFUND == 'refund'
|
||||
assert CreditTransaction.TransactionType.ADJUSTMENT == 'adjustment'
|
||||
assert CreditTransaction.TransactionType.PROMO == 'promo'
|
||||
|
||||
def test_transaction_type_labels(self):
|
||||
"""Test TransactionType labels are human-readable."""
|
||||
from smoothschedule.communication.credits.models import CreditTransaction
|
||||
|
||||
choices_dict = dict(CreditTransaction.TransactionType.choices)
|
||||
assert choices_dict['manual'] == 'Manual Top-up'
|
||||
assert choices_dict['auto_reload'] == 'Auto Reload'
|
||||
assert choices_dict['usage'] == 'Usage'
|
||||
assert choices_dict['refund'] == 'Refund'
|
||||
assert choices_dict['adjustment'] == 'Adjustment'
|
||||
assert choices_dict['promo'] == 'Promotional Credit'
|
||||
|
||||
|
||||
class TestProxyPhoneNumberModel:
|
||||
"""Tests for ProxyPhoneNumber model."""
|
||||
|
||||
def test_str_representation_without_tenant(self):
|
||||
"""Test string representation for unassigned number."""
|
||||
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||
|
||||
number = Mock(spec=ProxyPhoneNumber)
|
||||
number.phone_number = '+15551234567'
|
||||
number.assigned_tenant = None
|
||||
|
||||
result = ProxyPhoneNumber.__str__(number)
|
||||
|
||||
assert result == "+15551234567"
|
||||
|
||||
def test_str_representation_with_tenant(self):
|
||||
"""Test string representation for assigned number."""
|
||||
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.name = "Test Business"
|
||||
|
||||
number = Mock(spec=ProxyPhoneNumber)
|
||||
number.phone_number = '+15551234567'
|
||||
number.assigned_tenant = mock_tenant
|
||||
|
||||
result = ProxyPhoneNumber.__str__(number)
|
||||
|
||||
assert result == "+15551234567 (Test Business)"
|
||||
|
||||
def test_assign_to_tenant_success(self):
|
||||
"""Test assign_to_tenant with valid permissions."""
|
||||
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.has_feature.return_value = True
|
||||
|
||||
number = Mock() # Don't use spec to allow attribute assignment
|
||||
number.save = Mock()
|
||||
|
||||
with patch('django.utils.timezone.now') as mock_now:
|
||||
mock_now.return_value = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||
|
||||
ProxyPhoneNumber.assign_to_tenant(number, mock_tenant)
|
||||
|
||||
# Verify save called with correct update_fields
|
||||
number.save.assert_called_once_with(
|
||||
update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at']
|
||||
)
|
||||
|
||||
def test_assign_to_tenant_without_permission(self):
|
||||
"""Test assign_to_tenant raises PermissionDenied without feature."""
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.has_feature.return_value = False
|
||||
|
||||
number = Mock(spec=ProxyPhoneNumber)
|
||||
|
||||
with pytest.raises(PermissionDenied) as exc_info:
|
||||
ProxyPhoneNumber.assign_to_tenant(number, mock_tenant)
|
||||
|
||||
assert "Masked Calling" in str(exc_info.value)
|
||||
assert "upgrade" in str(exc_info.value).lower()
|
||||
|
||||
def test_release_clears_assignment(self):
|
||||
"""Test release returns number to pool."""
|
||||
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||
|
||||
number = Mock() # Don't use spec to allow attribute assignment
|
||||
number.save = Mock()
|
||||
|
||||
ProxyPhoneNumber.release(number)
|
||||
|
||||
# Verify save called with correct update_fields
|
||||
number.save.assert_called_once_with(
|
||||
update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at']
|
||||
)
|
||||
|
||||
def test_status_choices(self):
|
||||
"""Test Status choices are defined correctly."""
|
||||
from smoothschedule.communication.credits.models import ProxyPhoneNumber
|
||||
|
||||
assert ProxyPhoneNumber.Status.AVAILABLE == 'available'
|
||||
assert ProxyPhoneNumber.Status.ASSIGNED == 'assigned'
|
||||
assert ProxyPhoneNumber.Status.RESERVED == 'reserved'
|
||||
assert ProxyPhoneNumber.Status.INACTIVE == 'inactive'
|
||||
|
||||
|
||||
class TestMaskedSessionModel:
|
||||
"""Tests for MaskedSession model."""
|
||||
|
||||
def test_str_representation(self):
|
||||
"""Test string representation shows session info."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.id = 42
|
||||
session.customer_phone = '+15551111111'
|
||||
session.staff_phone = '+15552222222'
|
||||
|
||||
result = MaskedSession.__str__(session)
|
||||
|
||||
assert result == "Session 42: +15551111111 <-> +15552222222"
|
||||
|
||||
def test_is_active_returns_true_for_active_unexpired_session(self):
|
||||
"""Test is_active returns True when status active and not expired."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||
future_time = now + timedelta(hours=1)
|
||||
|
||||
session = Mock() # Don't use spec to allow simpler attribute access
|
||||
session.status = MaskedSession.Status.ACTIVE # Use the actual enum
|
||||
session.Status = MaskedSession.Status # Make Status available on the instance
|
||||
session.expires_at = future_time
|
||||
|
||||
with patch('smoothschedule.communication.credits.models.timezone.now') as mock_now:
|
||||
mock_now.return_value = now
|
||||
result = MaskedSession.is_active(session)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_is_active_returns_false_for_closed_session(self):
|
||||
"""Test is_active returns False when status is closed."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
future_time = timezone.now() + timedelta(hours=1)
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.status = MaskedSession.Status.CLOSED
|
||||
session.expires_at = future_time
|
||||
|
||||
result = MaskedSession.is_active(session)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_is_active_returns_false_for_expired_session(self):
|
||||
"""Test is_active returns False when session is expired."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||
past_time = now - timedelta(hours=1)
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.status = MaskedSession.Status.ACTIVE
|
||||
session.expires_at = past_time
|
||||
|
||||
with patch('django.utils.timezone.now') as mock_now:
|
||||
mock_now.return_value = now
|
||||
result = MaskedSession.is_active(session)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_close_updates_status_and_timestamp(self):
|
||||
"""Test close sets status and closed_at."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
mock_proxy_number = Mock()
|
||||
mock_proxy_number.status = 'available'
|
||||
|
||||
session = Mock() # Don't use spec to allow attribute assignment
|
||||
session.proxy_number = mock_proxy_number
|
||||
session.save = Mock()
|
||||
|
||||
with patch('smoothschedule.communication.credits.models.timezone.now') as mock_now:
|
||||
mock_now.return_value = datetime(2024, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc)
|
||||
|
||||
MaskedSession.close(session)
|
||||
|
||||
# Verify save called with correct update_fields
|
||||
session.save.assert_called_once_with(
|
||||
update_fields=['status', 'closed_at', 'updated_at']
|
||||
)
|
||||
|
||||
def test_close_releases_reserved_proxy_number(self):
|
||||
"""Test close releases proxy number if it was reserved."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession, ProxyPhoneNumber
|
||||
|
||||
mock_proxy_number = Mock()
|
||||
mock_proxy_number.status = ProxyPhoneNumber.Status.RESERVED
|
||||
mock_proxy_number.save = Mock()
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.proxy_number = mock_proxy_number
|
||||
session.save = Mock()
|
||||
|
||||
with patch('django.utils.timezone.now'):
|
||||
MaskedSession.close(session)
|
||||
|
||||
# Verify proxy number released
|
||||
assert mock_proxy_number.status == ProxyPhoneNumber.Status.AVAILABLE
|
||||
mock_proxy_number.save.assert_called_once()
|
||||
|
||||
def test_close_does_not_release_assigned_proxy_number(self):
|
||||
"""Test close does not release assigned proxy number."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession, ProxyPhoneNumber
|
||||
|
||||
mock_proxy_number = Mock()
|
||||
mock_proxy_number.status = ProxyPhoneNumber.Status.ASSIGNED
|
||||
mock_proxy_number.save = Mock()
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.proxy_number = mock_proxy_number
|
||||
session.save = Mock()
|
||||
|
||||
with patch('django.utils.timezone.now'):
|
||||
MaskedSession.close(session)
|
||||
|
||||
# Verify proxy number NOT changed
|
||||
assert mock_proxy_number.status == ProxyPhoneNumber.Status.ASSIGNED
|
||||
mock_proxy_number.save.assert_not_called()
|
||||
|
||||
def test_get_destination_for_caller_customer_to_staff(self):
|
||||
"""Test get_destination_for_caller routes customer to staff."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.customer_phone = '+15551111111'
|
||||
session.staff_phone = '+15552222222'
|
||||
|
||||
result = MaskedSession.get_destination_for_caller(session, '+15551111111')
|
||||
|
||||
assert result == '+15552222222'
|
||||
|
||||
def test_get_destination_for_caller_staff_to_customer(self):
|
||||
"""Test get_destination_for_caller routes staff to customer."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.customer_phone = '+15551111111'
|
||||
session.staff_phone = '+15552222222'
|
||||
|
||||
result = MaskedSession.get_destination_for_caller(session, '+15552222222')
|
||||
|
||||
assert result == '+15551111111'
|
||||
|
||||
def test_get_destination_for_caller_unknown_caller(self):
|
||||
"""Test get_destination_for_caller returns None for unknown caller."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.customer_phone = '+15551111111'
|
||||
session.staff_phone = '+15552222222'
|
||||
|
||||
result = MaskedSession.get_destination_for_caller(session, '+15559999999')
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_get_destination_for_caller_normalizes_phone_numbers(self):
|
||||
"""Test get_destination_for_caller normalizes phone formats."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.customer_phone = '+15551111111'
|
||||
session.staff_phone = '+15552222222'
|
||||
|
||||
# Test with spaces
|
||||
result = MaskedSession.get_destination_for_caller(session, '+1 555 111 1111')
|
||||
assert result == '+15552222222'
|
||||
|
||||
# Test with dashes
|
||||
result = MaskedSession.get_destination_for_caller(session, '+1-555-111-1111')
|
||||
assert result == '+15552222222'
|
||||
|
||||
# Test without country code
|
||||
result = MaskedSession.get_destination_for_caller(session, '5551111111')
|
||||
assert result == '+15552222222'
|
||||
|
||||
def test_get_destination_for_caller_handles_partial_matches(self):
|
||||
"""Test get_destination_for_caller handles partial number matches."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
session = Mock(spec=MaskedSession)
|
||||
session.customer_phone = '5551111111'
|
||||
session.staff_phone = '5552222222'
|
||||
|
||||
# Full number with country code should still match
|
||||
result = MaskedSession.get_destination_for_caller(session, '+15551111111')
|
||||
assert result == '5552222222'
|
||||
|
||||
def test_status_choices(self):
|
||||
"""Test Status choices are defined correctly."""
|
||||
from smoothschedule.communication.credits.models import MaskedSession
|
||||
|
||||
assert MaskedSession.Status.ACTIVE == 'active'
|
||||
assert MaskedSession.Status.CLOSED == 'closed'
|
||||
assert MaskedSession.Status.EXPIRED == 'expired'
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,5 @@ from django.apps import AppConfig
|
||||
|
||||
class CommunicationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'communication'
|
||||
name = 'smoothschedule.communication.messaging'
|
||||
label = 'communication'
|
||||
@@ -83,6 +83,7 @@ class CommunicationSession(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'communication'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['is_active', 'created_at']),
|
||||
@@ -57,7 +57,7 @@ class TwilioService:
|
||||
PermissionError: If tenant doesn't have masked calling feature
|
||||
"""
|
||||
from django.db import connection
|
||||
from core.models import Tenant
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
# Check feature permission
|
||||
@@ -3,7 +3,7 @@ from django.apps import AppConfig
|
||||
|
||||
class FieldMobileConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'smoothschedule.field_mobile'
|
||||
name = 'smoothschedule.communication.mobile'
|
||||
label = 'field_mobile'
|
||||
verbose_name = 'Field Mobile App'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user