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:
poduck
2025-11-28 01:11:53 -05:00
parent a7c756a8ec
commit b10426fbdb
52 changed files with 4259 additions and 356 deletions

175
smoothschedule/CLAUDE.md Normal file
View 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

View File

@@ -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(

View File

@@ -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),
),
]

View File

@@ -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/'),
),
]

View File

@@ -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)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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,
),
),
]

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View File

@@ -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)

View File

@@ -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"

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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)