Add scheduler improvements, API endpoints, and month calendar view

Backend:
- Add /api/customers/ endpoint (CustomerViewSet, CustomerSerializer)
- Add /api/services/ endpoint with Service model and migrations
- Add Resource.type field (STAFF, ROOM, EQUIPMENT) with migration
- Fix EventSerializer to return resource_id, customer_id, service_id
- Add date range filtering to EventViewSet (start_date, end_date params)
- Add create_demo_appointments management command
- Set default brand colors in business API

Frontend:
- Add calendar grid view for month mode in OwnerScheduler
- Fix sidebar navigation active link contrast (bg-white/10)
- Add default primaryColor/secondaryColor fallbacks in useBusiness
- Disable WebSocket (backend not implemented) to stop reconnect loop
- Fix Resource.type.toLowerCase() error by adding type to backend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-27 20:09:04 -05:00
parent 38c43d3f27
commit 373257469b
38 changed files with 977 additions and 2111 deletions

View File

@@ -10,7 +10,7 @@ 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
from smoothschedule.users.api_views import current_user_view, logout_view, send_verification_email, verify_email
from schedule.api_views import current_business_view
urlpatterns = [
@@ -37,6 +37,8 @@ urlpatterns += [
path("api/auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
path("api/auth/me/", current_user_view, name="current_user"),
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"),
# Business API
path("api/business/current/", current_business_view, name="current_business"),
# API Docs

View File

@@ -40,8 +40,8 @@ def current_business_view(request):
'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': None,
'secondary_color': None,
'primary_color': '#3B82F6', # Blue-500 default
'secondary_color': '#1E40AF', # Blue-800 default
'logo_url': None,
'whitelabel_enabled': False,
'resources_can_reschedule': False,

View File

@@ -0,0 +1,167 @@
"""
Management command to create demo appointments for the current month.
"""
import random
from datetime import datetime, timedelta
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.utils import timezone
from schedule.models import Event, Resource, Service, Participant
from smoothschedule.users.models import User
class Command(BaseCommand):
help = 'Create demo appointments spanning the current month'
def add_arguments(self, parser):
parser.add_argument(
'--clear',
action='store_true',
help='Clear existing events before creating new ones',
)
parser.add_argument(
'--count',
type=int,
default=50,
help='Number of appointments to create (default: 50)',
)
def handle(self, *args, **options):
if options['clear']:
deleted_count = Event.objects.all().delete()[0]
self.stdout.write(f'Deleted {deleted_count} existing events')
# Ensure we have some resources
resources = list(Resource.objects.filter(is_active=True))
if not resources:
self.stdout.write('Creating demo resources...')
resources = [
Resource.objects.create(name='Sarah Johnson', type='STAFF', description='Senior Stylist'),
Resource.objects.create(name='Mike Chen', type='STAFF', description='Barber'),
Resource.objects.create(name='Room A', type='ROOM', description='Main treatment room'),
Resource.objects.create(name='Room B', type='ROOM', description='Private consultation'),
]
self.stdout.write(f'Created {len(resources)} resources')
# Ensure we have some services
services = list(Service.objects.filter(is_active=True))
if not services:
self.stdout.write('Creating demo services...')
services = [
Service.objects.create(name='Haircut', duration=30, price=Decimal('35.00')),
Service.objects.create(name='Hair Coloring', duration=90, price=Decimal('120.00')),
Service.objects.create(name='Beard Trim', duration=15, price=Decimal('15.00')),
Service.objects.create(name='Full Styling', duration=60, price=Decimal('75.00')),
Service.objects.create(name='Consultation', duration=30, price=Decimal('0.00')),
]
self.stdout.write(f'Created {len(services)} services')
# Ensure we have customer users
customers = list(User.objects.filter(role=User.Role.CUSTOMER))
if not customers:
self.stdout.write('Creating demo customers...')
customer_data = [
('alice', 'Alice Williams', 'alice@example.com'),
('bob', 'Bob Martinez', 'bob@example.com'),
('carol', 'Carol Davis', 'carol@example.com'),
('david', 'David Lee', 'david@example.com'),
('emma', 'Emma Thompson', 'emma@example.com'),
('frank', 'Frank Wilson', 'frank@example.com'),
('grace', 'Grace Kim', 'grace@example.com'),
('henry', 'Henry Brown', 'henry@example.com'),
('ivy', 'Ivy Chen', 'ivy@example.com'),
('jack', 'Jack Taylor', 'jack@example.com'),
]
for username, full_name, email in customer_data:
first_name, last_name = full_name.split(' ', 1)
user = User.objects.create_user(
username=username,
email=email,
password='test123',
first_name=first_name,
last_name=last_name,
role=User.Role.CUSTOMER,
)
customers.append(user)
self.stdout.write(f'Created {len(customer_data)} customer users')
statuses = ['SCHEDULED', 'SCHEDULED', 'SCHEDULED', 'CONFIRMED', 'CONFIRMED', 'COMPLETED']
# Get the current month range
now = timezone.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if now.month == 12:
end_of_month = start_of_month.replace(year=now.year + 1, month=1)
else:
end_of_month = start_of_month.replace(month=now.month + 1)
count = options['count']
created = 0
# Get content types for participants
resource_ct = ContentType.objects.get_for_model(Resource)
user_ct = ContentType.objects.get_for_model(User)
self.stdout.write(f'Creating {count} appointments for {start_of_month.strftime("%B %Y")}...')
for _ in range(count):
# Random day in the month
days_in_month = (end_of_month - start_of_month).days
random_day = random.randint(0, days_in_month - 1)
appointment_date = start_of_month + timedelta(days=random_day)
# Random time between 8am and 6pm
hour = random.randint(8, 17)
minute = random.choice([0, 15, 30, 45])
start_time = appointment_date.replace(hour=hour, minute=minute)
# Skip if in the past and marked as scheduled
status = random.choice(statuses)
if start_time < now and status == 'SCHEDULED':
status = 'COMPLETED'
# Random service and duration
service = random.choice(services)
duration = service.duration
# Random resource
resource = random.choice(resources)
# Random customer
customer = random.choice(customers)
# Create the event
end_time = start_time + timedelta(minutes=duration)
event = Event.objects.create(
title=f'{customer.full_name} - {service.name}',
start_time=start_time,
end_time=end_time,
status=status,
notes=f'Service: {service.name}',
)
# Create participant for the resource
Participant.objects.create(
event=event,
role=Participant.Role.RESOURCE,
content_type=resource_ct,
object_id=resource.id,
)
# Create participant for the customer
Participant.objects.create(
event=event,
role=Participant.Role.CUSTOMER,
content_type=user_ct,
object_id=customer.id,
)
created += 1
self.stdout.write(
self.style.SUCCESS(f'Successfully created {created} demo appointments with resource and customer links')
)

View File

@@ -0,0 +1,34 @@
# Generated manually
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Service',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('duration', models.PositiveIntegerField(default=60, help_text='Duration in minutes')),
('price', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.AddIndex(
model_name='service',
index=models.Index(fields=['is_active', 'name'], name='schedule_se_is_acti_idx'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0002_add_service_model'),
]
operations = [
migrations.AddField(
model_name='resource',
name='type',
field=models.CharField(
choices=[('STAFF', 'Staff Member'), ('ROOM', 'Room'), ('EQUIPMENT', 'Equipment')],
default='STAFF',
max_length=20
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-28 01:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('schedule', '0003_add_resource_type'),
]
operations = [
migrations.RenameIndex(
model_name='service',
new_name='schedule_se_is_acti_8c055e_idx',
old_name='schedule_se_is_acti_idx',
),
]

View File

@@ -3,18 +3,56 @@ from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator
from django.utils import timezone
from decimal import Decimal
class Service(models.Model):
"""
A service offered by the business (e.g., Haircut, Massage, Consultation).
"""
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
duration = models.PositiveIntegerField(
help_text="Duration in minutes",
default=60
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00')
)
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'])]
def __str__(self):
return f"{self.name} ({self.duration} min - ${self.price})"
class Resource(models.Model):
"""
A bookable resource with configurable concurrency.
Concurrency Modes:
- max_concurrent_events = 1: Strict blocking (Dentist Chair, Private Room)
- max_concurrent_events > 1: Limited overlap (Waiting Room with N seats)
- max_concurrent_events = 0: Infinite capacity (Virtual Resource/Category)
"""
class Type(models.TextChoices):
STAFF = 'STAFF', 'Staff Member'
ROOM = 'ROOM', 'Room'
EQUIPMENT = 'EQUIPMENT', 'Equipment'
name = models.CharField(max_length=200)
type = models.CharField(
max_length=20,
choices=Type.choices,
default=Type.STAFF
)
description = models.TextField(blank=True)
max_concurrent_events = models.PositiveIntegerField(
default=1,

View File

@@ -3,18 +3,84 @@ 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
from .models import Resource, Event, Participant, Service
from .services import AvailabilityService
from smoothschedule.users.models import User
class CustomerSerializer(serializers.ModelSerializer):
"""Serializer for Customer (User with role=CUSTOMER)"""
name = serializers.SerializerMethodField()
total_spend = serializers.SerializerMethodField()
last_visit = serializers.SerializerMethodField()
status = serializers.SerializerMethodField()
avatar_url = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
user_id = serializers.IntegerField(source='id', read_only=True)
city = serializers.SerializerMethodField()
state = serializers.SerializerMethodField()
zip = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
'id', 'name', 'email', 'phone', 'city', 'state', 'zip',
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
'user_id',
]
read_only_fields = ['id', 'email']
def get_name(self, obj):
return obj.full_name
def get_total_spend(self, obj):
# TODO: Calculate from payments when implemented
return 0
def get_last_visit(self, obj):
# TODO: Get from last appointment when implemented
return None
def get_status(self, obj):
return 'Active' if obj.is_active else 'Inactive'
def get_avatar_url(self, obj):
return None # TODO: Implement avatar
def get_tags(self, obj):
return [] # TODO: Implement customer tags
def get_city(self, obj):
return '' # TODO: Add address fields to User model
def get_state(self, obj):
return ''
def get_zip(self, obj):
return ''
class ServiceSerializer(serializers.ModelSerializer):
"""Serializer for Service model"""
duration_minutes = serializers.IntegerField(source='duration', read_only=True)
class Meta:
model = Service
fields = [
'id', 'name', 'description', 'duration', 'duration_minutes',
'price', 'is_active', 'created_at', 'updated_at',
]
read_only_fields = ['created_at', 'updated_at']
class ResourceSerializer(serializers.ModelSerializer):
"""Serializer for Resource model"""
capacity_description = serializers.SerializerMethodField()
class Meta:
model = Resource
fields = [
'id', 'name', 'description', 'max_concurrent_events',
'id', 'name', 'type', 'description', 'max_concurrent_events',
'buffer_duration', 'is_active', 'capacity_description',
'created_at', 'updated_at',
]
@@ -58,9 +124,9 @@ class EventSerializer(serializers.ModelSerializer):
duration_minutes = serializers.SerializerMethodField()
# Simplified fields for frontend compatibility
resource = serializers.SerializerMethodField()
customer = serializers.SerializerMethodField()
service = serializers.SerializerMethodField()
resource_id = serializers.SerializerMethodField()
customer_id = serializers.SerializerMethodField()
service_id = serializers.SerializerMethodField()
customer_name = serializers.SerializerMethodField()
service_name = serializers.SerializerMethodField()
is_paid = serializers.SerializerMethodField()
@@ -84,7 +150,7 @@ class EventSerializer(serializers.ModelSerializer):
fields = [
'id', 'title', 'start_time', 'end_time', 'status', 'notes',
'duration_minutes', 'participants', 'resource_ids', 'staff_ids',
'resource', 'customer', 'service', 'customer_name', 'service_name', 'is_paid',
'resource_id', 'customer_id', 'service_id', 'customer_name', 'service_name', 'is_paid',
'created_at', 'updated_at', 'created_by',
]
read_only_fields = ['created_at', 'updated_at', 'created_by']
@@ -92,25 +158,35 @@ class EventSerializer(serializers.ModelSerializer):
def get_duration_minutes(self, obj):
return int(obj.duration.total_seconds() / 60)
def get_resource(self, obj):
def get_resource_id(self, obj):
"""Get first resource ID from participants"""
resource_participant = obj.participants.filter(role='RESOURCE').first()
return resource_participant.object_id if resource_participant else None
def get_customer(self, obj):
"""Get customer ID - placeholder for now"""
return 1 # TODO: Implement actual customer logic
def get_customer_id(self, obj):
"""Get customer ID from participants"""
customer_participant = obj.participants.filter(role='CUSTOMER').first()
return customer_participant.object_id if customer_participant else None
def get_service(self, obj):
def get_service_id(self, obj):
"""Get service ID - placeholder for now"""
return 1 # TODO: Implement actual service logic
# TODO: Add service link to Event model or participants
return 1
def get_customer_name(self, obj):
"""Get customer name from title for now"""
return obj.title
"""Get customer name from participant"""
customer_participant = obj.participants.filter(role='CUSTOMER').first()
if customer_participant and customer_participant.content_object:
user = customer_participant.content_object
return user.full_name if hasattr(user, 'full_name') else str(user)
# Fallback to title
return obj.title.split(' - ')[0] if ' - ' in obj.title else obj.title
def get_service_name(self, obj):
"""Get service name - placeholder"""
"""Get service name from title"""
# Extract from title format "Customer Name - Service Name"
if ' - ' in obj.title:
return obj.title.split(' - ')[-1]
return "Service"
def get_is_paid(self, obj):

View File

@@ -3,7 +3,7 @@ Schedule App URLs
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ResourceViewSet, EventViewSet, ParticipantViewSet
from .views import ResourceViewSet, EventViewSet, ParticipantViewSet, CustomerViewSet, ServiceViewSet
# Create router and register viewsets
router = DefaultRouter()
@@ -11,6 +11,8 @@ 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')
# URL patterns
urlpatterns = [

View File

@@ -7,8 +7,10 @@ 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
from .serializers import ResourceSerializer, EventSerializer, ParticipantSerializer, CustomerSerializer, ServiceSerializer
from .models import Service
from core.permissions import HasQuota
from smoothschedule.users.models import User
class ResourceViewSet(viewsets.ModelViewSet):
@@ -52,17 +54,46 @@ class EventViewSet(viewsets.ModelViewSet):
- EventSerializer.validate() automatically checks resource availability
- If resource capacity exceeded, returns 400 Bad Request
- See schedule/services.py AvailabilityService for logic
Query Parameters:
- start_date: Filter events starting on or after this date (ISO format)
- end_date: Filter events starting before this date (ISO format)
- status: Filter by event status
"""
queryset = Event.objects.all()
serializer_class = EventSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
filterset_fields = ['status', 'start_time', 'end_time']
filterset_fields = ['status']
search_fields = ['title', 'notes']
ordering_fields = ['start_time', 'end_time', 'created_at']
ordering = ['start_time']
def get_queryset(self):
"""
Filter events by date range if start_date and end_date are provided.
"""
queryset = Event.objects.all()
# Filter by date range
start_date = self.request.query_params.get('start_date')
end_date = self.request.query_params.get('end_date')
if start_date:
from django.utils.dateparse import parse_datetime
start_dt = parse_datetime(start_date)
if start_dt:
queryset = queryset.filter(start_time__gte=start_dt)
if end_date:
from django.utils.dateparse import parse_datetime
end_dt = parse_datetime(end_date)
if end_dt:
queryset = queryset.filter(start_time__lt=end_dt)
return queryset
def perform_create(self, serializer):
"""
Create event with automatic availability validation.
@@ -90,14 +121,93 @@ class EventViewSet(viewsets.ModelViewSet):
class ParticipantViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Event Participants.
Allows adding/removing participants (Resources, Staff, Customers)
to/from events via the GenericForeignKey pattern.
"""
queryset = Participant.objects.all()
serializer_class = ParticipantSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['event', 'role', 'content_type']
ordering_fields = ['created_at']
ordering = ['-created_at']
class CustomerViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Customers.
Customers are Users with role=CUSTOMER belonging to the current tenant.
"""
serializer_class = CustomerSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
filterset_fields = ['is_active']
search_fields = ['email', 'first_name', 'last_name']
ordering_fields = ['email', 'created_at']
ordering = ['email']
def get_queryset(self):
"""
Return customers for the current tenant.
Customers are Users with role=CUSTOMER.
For now, return all customers. When authentication is enabled,
filter by the user's tenant.
"""
queryset = User.objects.filter(role=User.Role.CUSTOMER)
# 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 status filter if provided
status_filter = self.request.query_params.get('status')
if status_filter:
if status_filter == 'Active':
queryset = queryset.filter(is_active=True)
elif status_filter == 'Inactive':
queryset = queryset.filter(is_active=False)
# Apply search filter if provided
search = self.request.query_params.get('search')
if search:
from django.db.models import Q
queryset = queryset.filter(
Q(email__icontains=search) |
Q(first_name__icontains=search) |
Q(last_name__icontains=search)
)
return queryset
class ServiceViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing Services.
Services are the offerings a business provides (e.g., Haircut, Massage).
"""
queryset = Service.objects.filter(is_active=True)
serializer_class = ServiceSerializer
# TODO: Re-enable authentication for production
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
filterset_fields = ['is_active']
search_fields = ['name', 'description']
ordering_fields = ['name', 'price', 'duration', 'created_at']
ordering = ['name']
def get_queryset(self):
"""Return services, optionally including inactive ones."""
queryset = Service.objects.all()
# By default only show active services
show_inactive = self.request.query_params.get('show_inactive', 'false')
if show_inactive.lower() != 'true':
queryset = queryset.filter(is_active=True)
return queryset

View File

@@ -1,12 +1,16 @@
"""
API views for user authentication
"""
import secrets
from django.core.mail import send_mail
from django.conf import settings
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from .models import User
from .models import User, EmailVerificationToken
@api_view(['GET'])
@@ -31,14 +35,27 @@ def current_user_view(request):
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())
user_data = {
'id': user.id,
'username': user.username,
'email': user.email,
'name': user.full_name,
'role': user.role.lower(),
'role': frontend_role,
'avatar_url': None, # TODO: Implement avatar
'email_verified': False, # TODO: Implement email verification
'email_verified': user.email_verified,
'is_staff': user.is_staff,
'is_superuser': user.is_superuser,
'business': user.tenant_id,
@@ -59,3 +76,87 @@ def logout_view(request):
from django.contrib.auth import logout
logout(request)
return Response({"detail": "Successfully logged out."}, status=status.HTTP_200_OK)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def send_verification_email(request):
"""
Send email verification link
POST /api/auth/email/verify/send/
"""
user = request.user
if user.email_verified:
return Response({"detail": "Email already verified."}, status=status.HTTP_400_BAD_REQUEST)
# Create token
token = EmailVerificationToken.create_for_user(user)
# Build verification URL
# Use the frontend URL for verification
port = ':5173' if settings.DEBUG else ''
subdomain = ''
if user.tenant:
primary_domain = user.tenant.domains.filter(is_primary=True).first()
if primary_domain:
subdomain = primary_domain.domain.split('.')[0] + '.'
verify_url = f"http://{subdomain}lvh.me{port}/#/verify-email?token={token.token}"
# Send email (goes to console in development)
subject = "Verify your email - Smooth Schedule"
message = f"""Hi {user.full_name},
Please click the link below to verify your email address:
{verify_url}
This link will expire in 24 hours.
If you did not request this, please ignore this email.
Thanks,
The Smooth Schedule Team
"""
send_mail(
subject,
message,
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
[user.email],
fail_silently=False,
)
return Response({"detail": "Verification email sent."}, status=status.HTTP_200_OK)
@api_view(['POST'])
@permission_classes([AllowAny])
def verify_email(request):
"""
Verify email with token
POST /api/auth/email/verify/
"""
token_str = request.data.get('token')
if not token_str:
return Response({"error": "Token is required."}, status=status.HTTP_400_BAD_REQUEST)
try:
token = EmailVerificationToken.objects.get(token=token_str)
except EmailVerificationToken.DoesNotExist:
return Response({"error": "Invalid token."}, status=status.HTTP_400_BAD_REQUEST)
if not token.is_valid():
return Response({"error": "Token has expired or already been used."}, status=status.HTTP_400_BAD_REQUEST)
# Mark token as used
token.used = True
token.save()
# Mark user email as verified
token.user.email_verified = True
token.user.save(update_fields=['email_verified'])
return Response({"detail": "Email verified successfully."}, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.8 on 2025-11-28 00:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0002_alter_user_options_remove_user_name_user_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='email_verified',
field=models.BooleanField(default=False, help_text='Whether user has verified their email address'),
),
migrations.CreateModel(
name='EmailVerificationToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('used', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -2,8 +2,11 @@
Smooth Schedule Custom User Model
Implements strict role hierarchy and multi-tenant user management
"""
import secrets
from datetime import timedelta
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -53,6 +56,10 @@ class User(AbstractUser):
default=False,
help_text="True for sales demo accounts - can be masqueraded by Platform Sales"
)
email_verified = models.BooleanField(
default=False,
help_text="Whether user has verified their email address"
)
# Additional profile fields
phone = models.CharField(max_length=20, blank=True)
@@ -157,5 +164,34 @@ class User(AbstractUser):
# Tenant users must have a tenant
if self.is_tenant_user() and not self.tenant:
raise ValueError(f"Users with role {self.role} must be assigned to a tenant")
super().save(*args, **kwargs)
class EmailVerificationToken(models.Model):
"""Token for email verification"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_tokens')
token = models.CharField(max_length=64, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
class Meta:
ordering = ['-created_at']
def save(self, *args, **kwargs):
if not self.token:
self.token = secrets.token_urlsafe(32)
if not self.expires_at:
self.expires_at = timezone.now() + timedelta(hours=24)
super().save(*args, **kwargs)
def is_valid(self):
return not self.used and timezone.now() < self.expires_at
@classmethod
def create_for_user(cls, user):
# Invalidate old tokens
cls.objects.filter(user=user, used=False).update(used=True)
# Create new token
return cls.objects.create(user=user)