feat: Add photo galleries to services, resource types management, and UI improvements
Major features: - Add drag-and-drop photo gallery to Service create/edit modals - Add Resource Types management section to Settings (CRUD for custom types) - Add edit icon consistency to Resources table (pencil icon in actions) - Improve Services page with drag-to-reorder and customer preview mockup Backend changes: - Add photos JSONField to Service model with migration - Add ResourceType model with category (STAFF/OTHER), description fields - Add ResourceTypeViewSet with CRUD operations - Add service reorder endpoint for display order Frontend changes: - Services page: two-column layout, drag-reorder, photo upload - Settings page: Resource Types tab with full CRUD modal - Resources page: Edit icon in actions column instead of row click - Sidebar: Payments link visibility based on role and paymentsEnabled - Update types.ts with Service.photos and ResourceTypeDefinition Note: Removed photos from ResourceType (kept only for Service) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
175
smoothschedule/CLAUDE.md
Normal file
175
smoothschedule/CLAUDE.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# SmoothSchedule Backend Development Guide
|
||||
|
||||
## Docker-Based Development
|
||||
|
||||
**IMPORTANT:** This project runs in Docker containers. Do NOT try to run Django commands directly on the host machine - they will fail due to missing environment variables and database connection.
|
||||
|
||||
### Running Django Commands
|
||||
|
||||
Always use Docker Compose to execute commands:
|
||||
|
||||
```bash
|
||||
# Navigate to the smoothschedule directory first
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
|
||||
# Run migrations for a specific app
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate schedule
|
||||
|
||||
# Make migrations
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py makemigrations
|
||||
|
||||
# Create superuser
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
|
||||
|
||||
# Run management commands
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py <command>
|
||||
|
||||
# Access Django shell
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||
|
||||
# Run tests
|
||||
docker compose -f docker-compose.local.yml exec django pytest
|
||||
```
|
||||
|
||||
### Multi-Tenant Migrations
|
||||
|
||||
This is a multi-tenant app using django-tenants. To run migrations on a specific tenant schema:
|
||||
|
||||
```bash
|
||||
# Run on a specific tenant (e.g., 'demo')
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py tenant_command migrate --schema=demo
|
||||
|
||||
# Run on public schema
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate_schemas --shared
|
||||
```
|
||||
|
||||
### Docker Services
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yml logs -f django
|
||||
|
||||
# Restart Django after code changes (usually auto-reloads)
|
||||
docker compose -f docker-compose.local.yml restart django
|
||||
|
||||
# Rebuild after dependency changes
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Critical Configuration Files
|
||||
|
||||
```
|
||||
smoothschedule/
|
||||
├── docker-compose.local.yml # Local development Docker config
|
||||
├── docker-compose.production.yml # Production Docker config
|
||||
├── .envs/
|
||||
│ ├── .local/
|
||||
│ │ ├── .django # Django env vars (SECRET_KEY, DEBUG, etc.)
|
||||
│ │ └── .postgres # Database credentials
|
||||
│ └── .production/
|
||||
│ ├── .django
|
||||
│ └── .postgres
|
||||
├── config/
|
||||
│ ├── settings/
|
||||
│ │ ├── base.py # Base settings (shared)
|
||||
│ │ ├── local.py # Local dev settings (imports multitenancy.py)
|
||||
│ │ ├── production.py # Production settings
|
||||
│ │ ├── multitenancy.py # Multi-tenant configuration
|
||||
│ │ └── test.py # Test settings
|
||||
│ └── urls.py # Main URL configuration
|
||||
├── compose/
|
||||
│ ├── local/django/
|
||||
│ │ ├── Dockerfile # Local Django container
|
||||
│ │ └── start # Startup script
|
||||
│ └── production/
|
||||
│ ├── django/
|
||||
│ ├── postgres/
|
||||
│ └── traefik/
|
||||
```
|
||||
|
||||
### Django Apps
|
||||
|
||||
```
|
||||
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/
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
Base URL: `http://lvh.me:8000/api/`
|
||||
|
||||
- `/api/resources/` - Resource CRUD
|
||||
- `/api/events/` - Event/Appointment CRUD
|
||||
- `/api/services/` - Service CRUD
|
||||
- `/api/customers/` - Customer listing
|
||||
- `/api/auth/login/` - Authentication
|
||||
- `/api/auth/logout/` - Logout
|
||||
- `/api/users/me/` - Current user info
|
||||
- `/api/business/` - Business settings
|
||||
|
||||
## Local Development URLs
|
||||
|
||||
- **Backend API:** `http://lvh.me:8000`
|
||||
- **Frontend:** `http://demo.lvh.me:5173` (business subdomain)
|
||||
- **Platform Frontend:** `http://platform.lvh.me:5173`
|
||||
|
||||
Note: `lvh.me` resolves to `127.0.0.1` and allows subdomain-based multi-tenancy with cookies.
|
||||
|
||||
## Database
|
||||
|
||||
- **Type:** PostgreSQL with django-tenants
|
||||
- **Public Schema:** Shared tables (tenants, domains, platform users)
|
||||
- **Tenant Schemas:** Per-business data (resources, events, customers)
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 500 Error with No CORS Headers
|
||||
When Django crashes (500 error), CORS headers aren't sent. Check Django logs:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs django --tail=100
|
||||
```
|
||||
|
||||
### Missing Column/Table Errors
|
||||
Run migrations:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
```
|
||||
|
||||
### ModuleNotFoundError / ImportError
|
||||
You're trying to run Python directly instead of through Docker. Use `docker compose exec`.
|
||||
|
||||
## Key Models
|
||||
|
||||
### Resource (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)
|
||||
- `title`, `start_time`, `end_time`, `status`
|
||||
- Links to resources/customers via `Participant` model
|
||||
|
||||
### User (users/models.py)
|
||||
- Roles: `superuser`, `platform_manager`, `platform_support`, `owner`, `manager`, `staff`, `resource`, `customer`
|
||||
- `business_subdomain` - which tenant they belong to
|
||||
@@ -10,8 +10,11 @@ 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 current_user_view, logout_view, send_verification_email, verify_email
|
||||
from schedule.api_views import current_business_view
|
||||
from smoothschedule.users.api_views import (
|
||||
current_user_view, logout_view, send_verification_email, verify_email,
|
||||
hijack_acquire_view, hijack_release_view
|
||||
)
|
||||
from schedule.api_views import current_business_view, update_business_view
|
||||
|
||||
urlpatterns = [
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
@@ -19,6 +22,8 @@ urlpatterns = [
|
||||
# User management
|
||||
path("users/", include("smoothschedule.users.urls", namespace="users")),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
# Django Hijack (masquerade) - for admin interface
|
||||
path("hijack/", include("hijack.urls")),
|
||||
# Your stuff: custom urls includes go here
|
||||
# ...
|
||||
# Media files
|
||||
@@ -39,8 +44,12 @@ urlpatterns += [
|
||||
path("api/auth/logout/", logout_view, name="logout"),
|
||||
path("api/auth/email/verify/send/", send_verification_email, name="send_verification_email"),
|
||||
path("api/auth/email/verify/", verify_email, name="verify_email"),
|
||||
# Hijack (masquerade) API
|
||||
path("api/auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
||||
path("api/auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
||||
# Business API
|
||||
path("api/business/current/", current_business_view, name="current_business"),
|
||||
path("api/business/current/update/", update_business_view, name="update_business"),
|
||||
# API Docs
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
||||
path(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_tierlimit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='logo',
|
||||
field=models.ImageField(blank=True, help_text='Business logo (recommended: 500x500px square or 500x200px wide)', null=True, upload_to='tenant_logos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='logo_display_mode',
|
||||
field=models.CharField(choices=[('logo-only', 'Logo Only'), ('text-only', 'Text Only'), ('logo-and-text', 'Logo and Text')], default='text-only', help_text='How to display branding in sidebar and headers', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='primary_color',
|
||||
field=models.CharField(default='#2563eb', help_text='Primary brand color (hex format, e.g., #2563eb)', max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='secondary_color',
|
||||
field=models.CharField(default='#0ea5e9', help_text='Secondary brand color (hex format, e.g., #0ea5e9)', max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_tenant_logo_tenant_logo_display_mode_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='email_logo',
|
||||
field=models.ImageField(blank=True, help_text='Email logo (recommended: 600x200px wide, PNG with transparent background)', null=True, upload_to='tenant_email_logos/'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tenant',
|
||||
name='logo',
|
||||
field=models.ImageField(blank=True, help_text='Website logo (recommended: 500x500px square or 500x200px wide, PNG with transparent background)', null=True, upload_to='tenant_logos/'),
|
||||
),
|
||||
]
|
||||
@@ -15,7 +15,7 @@ class Tenant(TenantMixin):
|
||||
"""
|
||||
name = models.CharField(max_length=100)
|
||||
created_on = models.DateField(auto_now_add=True)
|
||||
|
||||
|
||||
# Subscription & billing
|
||||
is_active = models.BooleanField(default=True)
|
||||
subscription_tier = models.CharField(
|
||||
@@ -28,11 +28,45 @@ class Tenant(TenantMixin):
|
||||
],
|
||||
default='FREE'
|
||||
)
|
||||
|
||||
|
||||
# Feature flags
|
||||
max_users = models.IntegerField(default=5)
|
||||
max_resources = models.IntegerField(default=10)
|
||||
|
||||
|
||||
# Branding
|
||||
logo = models.ImageField(
|
||||
upload_to='tenant_logos/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Website logo (recommended: 500x500px square or 500x200px wide, PNG with transparent background)"
|
||||
)
|
||||
email_logo = models.ImageField(
|
||||
upload_to='tenant_email_logos/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Email logo (recommended: 600x200px wide, PNG with transparent background)"
|
||||
)
|
||||
logo_display_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('logo-only', 'Logo Only'),
|
||||
('text-only', 'Text Only'),
|
||||
('logo-and-text', 'Logo and Text'),
|
||||
],
|
||||
default='text-only',
|
||||
help_text="How to display branding in sidebar and headers"
|
||||
)
|
||||
primary_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#2563eb',
|
||||
help_text="Primary brand color (hex format, e.g., #2563eb)"
|
||||
)
|
||||
secondary_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#0ea5e9',
|
||||
help_text="Secondary brand color (hex format, e.g., #0ea5e9)"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
contact_email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
|
||||
@@ -33,7 +33,7 @@ def can_hijack(hijacker, hijacked):
|
||||
- Always validate tenant boundaries for tenant-scoped roles
|
||||
- Log all hijack attempts (success and failure) for audit
|
||||
"""
|
||||
from users.models import User
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
# Safety check: can't hijack yourself
|
||||
if hijacker.id == hijacked.id:
|
||||
@@ -60,16 +60,17 @@ def can_hijack(hijacker, hijacked):
|
||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||
return hijacked.is_temporary
|
||||
|
||||
# Rule 4: TENANT_OWNER can hijack staff within their own tenant
|
||||
# Rule 4: TENANT_OWNER can hijack managers, staff, and customers within their own tenant
|
||||
if hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Must be in same tenant
|
||||
if not hijacker.tenant or not hijacked.tenant:
|
||||
return False
|
||||
if hijacker.tenant.id != hijacked.tenant.id:
|
||||
return False
|
||||
|
||||
# Can only hijack staff and customers, not other owners/managers
|
||||
|
||||
# Can hijack managers, staff, and customers (not other owners)
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
@@ -112,7 +113,7 @@ def get_hijackable_users(hijacker):
|
||||
Returns:
|
||||
QuerySet: Users that can be hijacked by this user
|
||||
"""
|
||||
from users.models import User
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
# Start with all users except self
|
||||
qs = User.objects.exclude(id=hijacker.id)
|
||||
@@ -136,13 +137,13 @@ def get_hijackable_users(hijacker):
|
||||
return qs.filter(is_temporary=True)
|
||||
|
||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Only staff in same tenant
|
||||
# Managers, staff, and customers in same tenant
|
||||
if not hijacker.tenant:
|
||||
return qs.none()
|
||||
|
||||
|
||||
return qs.filter(
|
||||
tenant=hijacker.tenant,
|
||||
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
role__in=[User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
API views for business/tenant management
|
||||
"""
|
||||
import base64
|
||||
import uuid
|
||||
from django.core.files.base import ContentFile
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -39,10 +42,119 @@ def current_business_view(request):
|
||||
'tier': tenant.subscription_tier,
|
||||
'status': 'active' if tenant.is_active else 'inactive',
|
||||
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
||||
# Optional fields with defaults
|
||||
'primary_color': '#3B82F6', # Blue-500 default
|
||||
'secondary_color': '#1E40AF', # Blue-800 default
|
||||
'logo_url': None,
|
||||
# Branding fields from Tenant model
|
||||
'primary_color': tenant.primary_color,
|
||||
'secondary_color': tenant.secondary_color,
|
||||
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
||||
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
||||
'logo_display_mode': tenant.logo_display_mode,
|
||||
# Other optional fields with defaults
|
||||
'whitelabel_enabled': False,
|
||||
'resources_can_reschedule': False,
|
||||
'require_payment_method_to_book': False,
|
||||
'cancellation_window_hours': 24,
|
||||
'late_cancellation_fee_percent': 0,
|
||||
'initial_setup_complete': False,
|
||||
'website_pages': {},
|
||||
'customer_dashboard_content': [],
|
||||
}
|
||||
|
||||
return Response(business_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@api_view(['PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_business_view(request):
|
||||
"""
|
||||
Update business (tenant) settings for the authenticated user
|
||||
PATCH /api/business/current/update/
|
||||
|
||||
Only business owners can update settings
|
||||
"""
|
||||
user = request.user
|
||||
tenant = user.tenant
|
||||
|
||||
# Platform users don't have a tenant
|
||||
if not tenant:
|
||||
return Response({'error': 'No business found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Only owners can update business settings
|
||||
if user.role.lower() != 'tenant_owner':
|
||||
return Response({'error': 'Only business owners can update settings'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Update fields if provided in request
|
||||
if 'name' in request.data:
|
||||
tenant.name = request.data['name']
|
||||
|
||||
if 'primary_color' in request.data:
|
||||
tenant.primary_color = request.data['primary_color']
|
||||
|
||||
if 'secondary_color' in request.data:
|
||||
tenant.secondary_color = request.data['secondary_color']
|
||||
|
||||
if 'logo_display_mode' in request.data:
|
||||
tenant.logo_display_mode = request.data['logo_display_mode']
|
||||
|
||||
# Handle logo uploads (base64 data URLs)
|
||||
if 'logo_url' in request.data:
|
||||
logo_data = request.data['logo_url']
|
||||
if logo_data and logo_data.startswith('data:image'):
|
||||
# Extract base64 data and file extension
|
||||
format_str, imgstr = logo_data.split(';base64,')
|
||||
ext = format_str.split('/')[-1]
|
||||
# Decode base64 and create Django file
|
||||
data = ContentFile(base64.b64decode(imgstr), name=f'logo_{uuid.uuid4()}.{ext}')
|
||||
# Delete old logo if exists
|
||||
if tenant.logo:
|
||||
tenant.logo.delete(save=False)
|
||||
tenant.logo = data
|
||||
elif logo_data is None or logo_data == '':
|
||||
# Remove logo if set to None or empty string
|
||||
if tenant.logo:
|
||||
tenant.logo.delete(save=False)
|
||||
tenant.logo = None
|
||||
|
||||
if 'email_logo_url' in request.data:
|
||||
email_logo_data = request.data['email_logo_url']
|
||||
if email_logo_data and email_logo_data.startswith('data:image'):
|
||||
# Extract base64 data and file extension
|
||||
format_str, imgstr = email_logo_data.split(';base64,')
|
||||
ext = format_str.split('/')[-1]
|
||||
# Decode base64 and create Django file
|
||||
data = ContentFile(base64.b64decode(imgstr), name=f'email_logo_{uuid.uuid4()}.{ext}')
|
||||
# Delete old email logo if exists
|
||||
if tenant.email_logo:
|
||||
tenant.email_logo.delete(save=False)
|
||||
tenant.email_logo = data
|
||||
elif email_logo_data is None or email_logo_data == '':
|
||||
# Remove email logo if set to None or empty string
|
||||
if tenant.email_logo:
|
||||
tenant.email_logo.delete(save=False)
|
||||
tenant.email_logo = None
|
||||
|
||||
# Save the tenant
|
||||
tenant.save()
|
||||
|
||||
# Return updated business data
|
||||
subdomain = None
|
||||
primary_domain = tenant.domains.filter(is_primary=True).first()
|
||||
if primary_domain:
|
||||
domain_parts = primary_domain.domain.split('.')
|
||||
if len(domain_parts) > 0:
|
||||
subdomain = domain_parts[0]
|
||||
|
||||
business_data = {
|
||||
'id': tenant.id,
|
||||
'name': tenant.name,
|
||||
'subdomain': subdomain or tenant.schema_name,
|
||||
'tier': tenant.subscription_tier,
|
||||
'status': 'active' if tenant.is_active else 'inactive',
|
||||
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
||||
'primary_color': tenant.primary_color,
|
||||
'secondary_color': tenant.secondary_color,
|
||||
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
||||
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
||||
'logo_display_mode': tenant.logo_display_mode,
|
||||
'whitelabel_enabled': False,
|
||||
'resources_can_reschedule': False,
|
||||
'require_payment_method_to_book': False,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0004_rename_schedule_se_is_acti_idx_schedule_se_is_acti_8c055e_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='saved_lane_count',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text='Remembered lane count when multilane is disabled',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 02:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0005_resource_saved_lane_count'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, help_text='Link to User account for STAFF type resources', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_resources', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0006_add_user_to_resource'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='resource',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('STAFF', 'Staff Member'), ('ROOM', 'Room'), ('EQUIPMENT', 'Equipment')], default='STAFF', help_text='DEPRECATED: Use resource_type instead', max_length=20),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ResourceType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="User-facing name like 'Stylist' or 'Treatment Room'", max_length=100)),
|
||||
('category', models.CharField(choices=[('STAFF', 'Staff'), ('OTHER', 'Other')], default='OTHER', help_text='STAFF types require staff assignment, OTHER types do not', max_length=10)),
|
||||
('is_default', models.BooleanField(default=False, help_text="Default types cannot be deleted (e.g., 'Staff')")),
|
||||
('icon_name', models.CharField(blank=True, help_text='Optional icon identifier', max_length=50)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
'indexes': [models.Index(fields=['category', 'name'], name='schedule_re_categor_3040dd_idx')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='resource_type',
|
||||
field=models.ForeignKey(blank=True, help_text='Custom resource type definition', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='resources', to='schedule.resourcetype'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_default_resource_types(apps, schema_editor):
|
||||
"""Create default resource types for all tenants"""
|
||||
ResourceType = apps.get_model('schedule', 'ResourceType')
|
||||
|
||||
# Create default types if they don't exist
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Staff',
|
||||
defaults={
|
||||
'category': 'STAFF',
|
||||
'is_default': True,
|
||||
'icon_name': 'user'
|
||||
}
|
||||
)
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Room',
|
||||
defaults={
|
||||
'category': 'OTHER',
|
||||
'is_default': True,
|
||||
'icon_name': 'home'
|
||||
}
|
||||
)
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Equipment',
|
||||
defaults={
|
||||
'category': 'OTHER',
|
||||
'is_default': True,
|
||||
'icon_name': 'wrench'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reverse_default_resource_types(apps, schema_editor):
|
||||
"""Remove default resource types (for rollback)"""
|
||||
ResourceType = apps.get_model('schedule', 'ResourceType')
|
||||
ResourceType.objects.filter(is_default=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0007_alter_resource_type_resourcetype_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_default_resource_types, reverse_default_resource_types),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 05:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0008_create_default_resource_types'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='service',
|
||||
options={'ordering': ['display_order', 'name']},
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='service',
|
||||
name='schedule_se_is_acti_8c055e_idx',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='display_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order in which services appear in menus (lower = first)'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='service',
|
||||
index=models.Index(fields=['is_active', 'display_order'], name='schedule_se_is_acti_d33934_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 05:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0009_add_service_display_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resourcetype',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Description of this resource type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resourcetype',
|
||||
name='photos',
|
||||
field=models.JSONField(blank=True, default=list, help_text='List of photo URLs in display order'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 06:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0010_add_resourcetype_description_photos'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='photos',
|
||||
field=models.JSONField(blank=True, default=list, help_text='List of photo URLs in display order'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 06:09
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0011_add_photos_to_service'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='resourcetype',
|
||||
name='photos',
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class Service(models.Model):
|
||||
@@ -21,18 +22,75 @@ class Service(models.Model):
|
||||
decimal_places=2,
|
||||
default=Decimal('0.00')
|
||||
)
|
||||
display_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Order in which services appear in menus (lower = first)"
|
||||
)
|
||||
photos = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of photo URLs in display order"
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
indexes = [models.Index(fields=['is_active', 'name'])]
|
||||
ordering = ['display_order', 'name']
|
||||
indexes = [models.Index(fields=['is_active', 'display_order'])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.duration} min - ${self.price})"
|
||||
|
||||
|
||||
class ResourceType(models.Model):
|
||||
"""
|
||||
Custom resource type definitions (e.g., "Hair Stylist", "Massage Room").
|
||||
Businesses can create their own types instead of using hardcoded STAFF/ROOM/EQUIPMENT.
|
||||
"""
|
||||
class Category(models.TextChoices):
|
||||
STAFF = 'STAFF', 'Staff' # Requires staff member assignment
|
||||
OTHER = 'OTHER', 'Other' # No staff assignment needed
|
||||
|
||||
name = models.CharField(max_length=100, help_text="User-facing name like 'Stylist' or 'Treatment Room'")
|
||||
description = models.TextField(blank=True, help_text="Description of this resource type")
|
||||
category = models.CharField(
|
||||
max_length=10,
|
||||
choices=Category.choices,
|
||||
default=Category.OTHER,
|
||||
help_text="STAFF types require staff assignment, OTHER types do not"
|
||||
)
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Default types cannot be deleted (e.g., 'Staff')"
|
||||
)
|
||||
icon_name = models.CharField(max_length=50, blank=True, help_text="Optional icon identifier")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
indexes = [models.Index(fields=['category', 'name'])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
|
||||
def clean(self):
|
||||
"""Prevent deletion of default types"""
|
||||
if self.is_default and self.pk:
|
||||
# Check if being deleted
|
||||
if not ResourceType.objects.filter(pk=self.pk).exists():
|
||||
raise ValidationError("Cannot delete default resource types.")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Prevent deletion of default types and types in use"""
|
||||
if self.is_default:
|
||||
raise ValidationError("Cannot delete default resource types.")
|
||||
if self.resources.exists():
|
||||
raise ValidationError(f"Cannot delete resource type '{self.name}' because it is in use by {self.resources.count()} resource(s).")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class Resource(models.Model):
|
||||
"""
|
||||
A bookable resource with configurable concurrency.
|
||||
@@ -48,10 +106,32 @@ class Resource(models.Model):
|
||||
EQUIPMENT = 'EQUIPMENT', 'Equipment'
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
|
||||
# NEW: Custom resource type (preferred)
|
||||
resource_type = models.ForeignKey(
|
||||
ResourceType,
|
||||
on_delete=models.PROTECT, # Cannot delete type if resources use it
|
||||
related_name='resources',
|
||||
null=True, # For migration compatibility
|
||||
blank=True,
|
||||
help_text="Custom resource type definition"
|
||||
)
|
||||
|
||||
# LEGACY: Hardcoded type (deprecated, kept for backwards compatibility)
|
||||
type = models.CharField(
|
||||
max_length=20,
|
||||
choices=Type.choices,
|
||||
default=Type.STAFF
|
||||
default=Type.STAFF,
|
||||
help_text="DEPRECATED: Use resource_type instead"
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='staff_resources',
|
||||
help_text="Link to User account for STAFF type resources"
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
max_concurrent_events = models.PositiveIntegerField(
|
||||
@@ -63,6 +143,11 @@ class Resource(models.Model):
|
||||
default=timezone.timedelta(0),
|
||||
help_text="Time buffer before/after events. Buffers consume capacity."
|
||||
)
|
||||
saved_lane_count = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Remembered lane count when multilane is disabled"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
@@ -3,11 +3,38 @@ DRF Serializers for Schedule App with Availability Validation
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from .models import Resource, Event, Participant, Service
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from .models import Resource, Event, Participant, Service, ResourceType
|
||||
from .services import AvailabilityService
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
|
||||
class ResourceTypeSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for custom resource types"""
|
||||
|
||||
class Meta:
|
||||
model = ResourceType
|
||||
fields = ['id', 'name', 'description', 'category', 'is_default', 'icon_name', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'is_default']
|
||||
|
||||
def validate(self, attrs):
|
||||
# If updating, check if trying to modify is_default
|
||||
if self.instance and self.instance.is_default:
|
||||
if 'name' in attrs and attrs['name'] != self.instance.name:
|
||||
# Allow renaming default types
|
||||
pass
|
||||
return attrs
|
||||
|
||||
def delete(self, instance):
|
||||
"""Validate before deletion"""
|
||||
if instance.is_default:
|
||||
raise serializers.ValidationError("Cannot delete default resource types.")
|
||||
if instance.resources.exists():
|
||||
raise serializers.ValidationError(
|
||||
f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)."
|
||||
)
|
||||
|
||||
|
||||
class CustomerSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Customer (User with role=CUSTOMER)"""
|
||||
name = serializers.SerializerMethodField()
|
||||
@@ -20,13 +47,14 @@ class CustomerSerializer(serializers.ModelSerializer):
|
||||
city = serializers.SerializerMethodField()
|
||||
state = serializers.SerializerMethodField()
|
||||
zip = serializers.SerializerMethodField()
|
||||
user_data = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'name', 'email', 'phone', 'city', 'state', 'zip',
|
||||
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
||||
'user_id',
|
||||
'user_id', 'user_data',
|
||||
]
|
||||
read_only_fields = ['id', 'email']
|
||||
|
||||
@@ -59,6 +87,41 @@ class CustomerSerializer(serializers.ModelSerializer):
|
||||
def get_zip(self, obj):
|
||||
return ''
|
||||
|
||||
def get_user_data(self, obj):
|
||||
"""Return user data needed for masquerading"""
|
||||
return {
|
||||
'id': obj.id,
|
||||
'username': obj.username,
|
||||
'name': obj.full_name,
|
||||
'email': obj.email,
|
||||
'role': 'customer',
|
||||
}
|
||||
|
||||
|
||||
class StaffSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Staff members (Users with staff roles)"""
|
||||
name = serializers.SerializerMethodField()
|
||||
role = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'username', 'name', 'email', 'phone', 'role',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_name(self, obj):
|
||||
return obj.full_name
|
||||
|
||||
def get_role(self, obj):
|
||||
# Map database roles to frontend roles
|
||||
role_mapping = {
|
||||
'TENANT_OWNER': 'owner',
|
||||
'TENANT_MANAGER': 'manager',
|
||||
'TENANT_STAFF': 'staff',
|
||||
}
|
||||
return role_mapping.get(obj.role, obj.role.lower())
|
||||
|
||||
|
||||
class ServiceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Service model"""
|
||||
@@ -68,7 +131,7 @@ class ServiceSerializer(serializers.ModelSerializer):
|
||||
model = Service
|
||||
fields = [
|
||||
'id', 'name', 'description', 'duration', 'duration_minutes',
|
||||
'price', 'is_active', 'created_at', 'updated_at',
|
||||
'price', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
@@ -76,16 +139,25 @@ class ServiceSerializer(serializers.ModelSerializer):
|
||||
class ResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Resource model"""
|
||||
capacity_description = serializers.SerializerMethodField()
|
||||
user_id = serializers.IntegerField(source='user.id', read_only=True, allow_null=True)
|
||||
user_name = serializers.CharField(source='user.full_name', read_only=True, allow_null=True)
|
||||
user = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
write_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = [
|
||||
'id', 'name', 'type', 'description', 'max_concurrent_events',
|
||||
'id', 'name', 'type', 'user', 'user_id', 'user_name',
|
||||
'description', 'max_concurrent_events',
|
||||
'buffer_duration', 'is_active', 'capacity_description',
|
||||
'saved_lane_count', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_capacity_description(self, obj):
|
||||
if obj.max_concurrent_events == 0:
|
||||
return "Unlimited capacity"
|
||||
|
||||
@@ -3,16 +3,21 @@ Schedule App URLs
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ResourceViewSet, EventViewSet, ParticipantViewSet, CustomerViewSet, ServiceViewSet
|
||||
from .views import (
|
||||
ResourceViewSet, EventViewSet, ParticipantViewSet,
|
||||
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet
|
||||
)
|
||||
|
||||
# Create router and register viewsets
|
||||
router = DefaultRouter()
|
||||
router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
|
||||
router.register(r'resources', ResourceViewSet, basename='resource')
|
||||
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
|
||||
router.register(r'events', EventViewSet, basename='event')
|
||||
router.register(r'participants', ParticipantViewSet, basename='participant')
|
||||
router.register(r'customers', CustomerViewSet, basename='customer')
|
||||
router.register(r'services', ServiceViewSet, basename='service')
|
||||
router.register(r'staff', StaffViewSet, basename='staff')
|
||||
|
||||
# URL patterns
|
||||
urlpatterns = [
|
||||
|
||||
@@ -6,13 +6,60 @@ API endpoints for Resources and Events with quota enforcement.
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from .models import Resource, Event, Participant
|
||||
from .serializers import ResourceSerializer, EventSerializer, ParticipantSerializer, CustomerSerializer, ServiceSerializer
|
||||
from rest_framework.decorators import action
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from .models import Resource, Event, Participant, ResourceType
|
||||
from .serializers import (
|
||||
ResourceSerializer, EventSerializer, ParticipantSerializer,
|
||||
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer
|
||||
)
|
||||
from .models import Service
|
||||
from core.permissions import HasQuota
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
|
||||
class ResourceTypeViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing custom Resource Types.
|
||||
|
||||
Permissions:
|
||||
- Must be authenticated
|
||||
- Only owners/managers can create/update/delete
|
||||
|
||||
Functionality:
|
||||
- List all resource types
|
||||
- Create new custom types
|
||||
- Update existing types (except is_default flag)
|
||||
- Delete types (only if not default and not in use)
|
||||
"""
|
||||
queryset = ResourceType.objects.all()
|
||||
serializer_class = ResourceTypeSerializer
|
||||
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
|
||||
ordering = ['name']
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Override destroy to add validation"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if default
|
||||
if instance.is_default:
|
||||
return Response(
|
||||
{'error': 'Cannot delete default resource types.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if in use
|
||||
if instance.resources.exists():
|
||||
return Response(
|
||||
{
|
||||
'error': f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ResourceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing Resources.
|
||||
@@ -198,8 +245,8 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
|
||||
filterset_fields = ['is_active']
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'price', 'duration', 'created_at']
|
||||
ordering = ['name']
|
||||
ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at']
|
||||
ordering = ['display_order', 'name']
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return services, optionally including inactive ones."""
|
||||
@@ -211,3 +258,71 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
queryset = queryset.filter(is_active=True)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def reorder(self, request):
|
||||
"""
|
||||
Bulk update service display order.
|
||||
|
||||
Expects: { "order": [1, 3, 2, 5, 4] }
|
||||
Where the list contains service IDs in the desired display order.
|
||||
"""
|
||||
order = request.data.get('order', [])
|
||||
|
||||
if not isinstance(order, list):
|
||||
return Response(
|
||||
{'error': 'order must be a list of service IDs'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Update display_order for each service
|
||||
for index, service_id in enumerate(order):
|
||||
Service.objects.filter(id=service_id).update(display_order=index)
|
||||
|
||||
return Response({'status': 'ok', 'updated': len(order)})
|
||||
|
||||
|
||||
class StaffViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API endpoint for listing staff members (Users who can be assigned to resources).
|
||||
|
||||
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||
This endpoint is read-only for assigning staff to resources.
|
||||
"""
|
||||
serializer_class = StaffSerializer
|
||||
# TODO: Re-enable authentication for production
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
search_fields = ['email', 'first_name', 'last_name']
|
||||
ordering_fields = ['email', 'first_name', 'last_name']
|
||||
ordering = ['first_name', 'last_name']
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return staff members for the current tenant.
|
||||
|
||||
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||
"""
|
||||
from django.db.models import Q
|
||||
|
||||
queryset = User.objects.filter(
|
||||
Q(role=User.Role.TENANT_OWNER) |
|
||||
Q(role=User.Role.TENANT_MANAGER) |
|
||||
Q(role=User.Role.TENANT_STAFF)
|
||||
).filter(is_active=True)
|
||||
|
||||
# Filter by tenant if user is authenticated and has a tenant
|
||||
# TODO: Re-enable this when authentication is enabled
|
||||
# if self.request.user.is_authenticated and self.request.user.tenant:
|
||||
# queryset = queryset.filter(tenant=self.request.user.tenant)
|
||||
|
||||
# Apply search filter if provided
|
||||
search = self.request.query_params.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(email__icontains=search) |
|
||||
Q(first_name__icontains=search) |
|
||||
Q(last_name__icontains=search)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -5,12 +5,15 @@ import secrets
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from .models import User, EmailVerificationToken
|
||||
from core.permissions import can_hijack
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@@ -160,3 +163,169 @@ def verify_email(request):
|
||||
token.user.save(update_fields=['email_verified'])
|
||||
|
||||
return Response({"detail": "Email verified successfully."}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def _get_user_data(user):
|
||||
"""Helper to get user data for API responses."""
|
||||
# Get business info if user has a tenant
|
||||
business_name = None
|
||||
business_subdomain = None
|
||||
if user.tenant:
|
||||
business_name = user.tenant.name
|
||||
primary_domain = user.tenant.domains.filter(is_primary=True).first()
|
||||
if primary_domain:
|
||||
business_subdomain = primary_domain.domain.split('.')[0]
|
||||
else:
|
||||
business_subdomain = user.tenant.schema_name
|
||||
|
||||
# Map database roles to frontend roles
|
||||
role_mapping = {
|
||||
'superuser': 'superuser',
|
||||
'platform_manager': 'platform_manager',
|
||||
'platform_sales': 'platform_sales',
|
||||
'platform_support': 'platform_support',
|
||||
'tenant_owner': 'owner',
|
||||
'tenant_manager': 'manager',
|
||||
'tenant_staff': 'staff',
|
||||
'customer': 'customer',
|
||||
}
|
||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'name': user.full_name,
|
||||
'role': frontend_role,
|
||||
'avatar_url': None,
|
||||
'email_verified': user.email_verified,
|
||||
'is_staff': user.is_staff,
|
||||
'is_superuser': user.is_superuser,
|
||||
'business': user.tenant_id,
|
||||
'business_name': business_name,
|
||||
'business_subdomain': business_subdomain,
|
||||
}
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def hijack_acquire_view(request):
|
||||
"""
|
||||
Masquerade as another user (hijack).
|
||||
POST /api/auth/hijack/acquire/
|
||||
|
||||
Body: { "user_pk": <user_id> }
|
||||
|
||||
Returns new auth token for the hijacked user along with the hijack history.
|
||||
"""
|
||||
# Debug logging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Hijack API called. User authenticated: {request.user.is_authenticated}, User: {request.user}")
|
||||
|
||||
user_pk = request.data.get('user_pk')
|
||||
if not user_pk:
|
||||
return Response({"error": "user_pk is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
hijacker = request.user
|
||||
hijacked = get_object_or_404(User, pk=user_pk)
|
||||
|
||||
logger.warning(f"Hijack attempt: hijacker={hijacker.email} (role={hijacker.role}), hijacked={hijacked.email} (role={hijacked.role})")
|
||||
|
||||
# Check permission
|
||||
can_hijack_result = can_hijack(hijacker, hijacked)
|
||||
logger.warning(f"can_hijack result: {can_hijack_result}")
|
||||
if not can_hijack_result:
|
||||
logger.warning(f"Hijack DENIED: {hijacker.email} -> {hijacked.email}")
|
||||
return Response(
|
||||
{"error": f"You do not have permission to masquerade as this user."},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get or build hijack history from request
|
||||
hijack_history = request.data.get('hijack_history', [])
|
||||
logger.warning(f"hijack_history length: {len(hijack_history)}")
|
||||
|
||||
# Don't allow hijacking while already hijacked (max depth 1)
|
||||
if len(hijack_history) > 0:
|
||||
logger.warning("Hijack denied - already masquerading")
|
||||
return Response(
|
||||
{"error": "Cannot start a new masquerade session while already masquerading. Please exit your current session first."},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
logger.warning("Passed all checks, proceeding with hijack...")
|
||||
|
||||
# Add current user (hijacker) to the history stack
|
||||
hijacker_business_subdomain = None
|
||||
if hijacker.tenant:
|
||||
primary_domain = hijacker.tenant.domains.filter(is_primary=True).first()
|
||||
if primary_domain:
|
||||
hijacker_business_subdomain = primary_domain.domain.split('.')[0]
|
||||
else:
|
||||
hijacker_business_subdomain = hijacker.tenant.schema_name
|
||||
|
||||
role_mapping = {
|
||||
'superuser': 'superuser',
|
||||
'platform_manager': 'platform_manager',
|
||||
'platform_sales': 'platform_sales',
|
||||
'platform_support': 'platform_support',
|
||||
'tenant_owner': 'owner',
|
||||
'tenant_manager': 'manager',
|
||||
'tenant_staff': 'staff',
|
||||
'customer': 'customer',
|
||||
}
|
||||
|
||||
new_history = [{
|
||||
'user_id': hijacker.id,
|
||||
'username': hijacker.username,
|
||||
'role': role_mapping.get(hijacker.role.lower(), hijacker.role.lower()),
|
||||
'business_id': hijacker.tenant_id,
|
||||
'business_subdomain': hijacker_business_subdomain,
|
||||
}]
|
||||
|
||||
# Create or get token for hijacked user
|
||||
Token.objects.filter(user=hijacked).delete() # Delete old token
|
||||
token = Token.objects.create(user=hijacked)
|
||||
|
||||
return Response({
|
||||
'access': token.key,
|
||||
'refresh': token.key, # For API compatibility (we don't use refresh tokens with DRF Token auth)
|
||||
'user': _get_user_data(hijacked),
|
||||
'masquerade_stack': new_history,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def hijack_release_view(request):
|
||||
"""
|
||||
Stop masquerading and return to previous user.
|
||||
POST /api/auth/hijack/release/
|
||||
|
||||
Body: { "masquerade_stack": [...] }
|
||||
|
||||
Returns auth token for the original user.
|
||||
"""
|
||||
masquerade_stack = request.data.get('masquerade_stack', [])
|
||||
|
||||
if not masquerade_stack:
|
||||
return Response(
|
||||
{"error": "No masquerade session to stop. masquerade_stack is empty."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get the original user from the stack
|
||||
original_user_entry = masquerade_stack.pop()
|
||||
original_user = get_object_or_404(User, pk=original_user_entry['user_id'])
|
||||
|
||||
# Create or get token for original user
|
||||
Token.objects.filter(user=original_user).delete() # Delete old token
|
||||
token = Token.objects.create(user=original_user)
|
||||
|
||||
return Response({
|
||||
'access': token.key,
|
||||
'refresh': token.key,
|
||||
'user': _get_user_data(original_user),
|
||||
'masquerade_stack': masquerade_stack, # Return remaining stack (should be empty now)
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user