Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2
smoothschedule/platform_admin/__init__.py
Normal file
2
smoothschedule/platform_admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Platform admin app for managing tenants and platform-level operations."""
|
||||
default_app_config = 'platform_admin.apps.PlatformAdminConfig'
|
||||
7
smoothschedule/platform_admin/apps.py
Normal file
7
smoothschedule/platform_admin/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PlatformAdminConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'platform_admin'
|
||||
verbose_name = 'Platform Management'
|
||||
43
smoothschedule/platform_admin/permissions.py
Normal file
43
smoothschedule/platform_admin/permissions.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Platform Permissions
|
||||
Custom permission classes for platform-level operations
|
||||
"""
|
||||
from rest_framework import permissions
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
|
||||
class IsPlatformAdmin(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that only allows platform admins (superuser, platform_manager)
|
||||
"""
|
||||
message = "Platform admin access required."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Allow superusers and platform managers
|
||||
return request.user.role in [
|
||||
User.Role.SUPERUSER,
|
||||
User.Role.PLATFORM_MANAGER
|
||||
]
|
||||
|
||||
|
||||
class IsPlatformUser(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that allows any platform-level user
|
||||
(superuser, platform_manager, platform_support, platform_sales)
|
||||
"""
|
||||
message = "Platform user access required."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Allow any platform-level role
|
||||
return request.user.role in [
|
||||
User.Role.SUPERUSER,
|
||||
User.Role.PLATFORM_MANAGER,
|
||||
User.Role.PLATFORM_SUPPORT,
|
||||
User.Role.PLATFORM_SALES,
|
||||
]
|
||||
111
smoothschedule/platform_admin/serializers.py
Normal file
111
smoothschedule/platform_admin/serializers.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Platform Serializers
|
||||
Serializers for platform-level operations (viewing tenants, users, metrics)
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from core.models import Tenant, Domain
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
|
||||
class TenantSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Tenant (Business) listing"""
|
||||
subdomain = serializers.SerializerMethodField()
|
||||
tier = serializers.CharField(source='subscription_tier')
|
||||
user_count = serializers.SerializerMethodField()
|
||||
owner = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
fields = [
|
||||
'id', 'name', 'subdomain', 'tier', 'is_active',
|
||||
'created_on', 'user_count', 'owner', 'max_users',
|
||||
'max_resources', 'contact_email', 'phone'
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_subdomain(self, obj):
|
||||
"""Get primary subdomain for this tenant"""
|
||||
primary_domain = obj.domains.filter(is_primary=True, is_custom_domain=False).first()
|
||||
if primary_domain:
|
||||
# Extract subdomain from domain (e.g., 'business1.lvh.me' -> 'business1')
|
||||
return primary_domain.domain.split('.')[0]
|
||||
return obj.schema_name
|
||||
|
||||
def get_user_count(self, obj):
|
||||
"""Get count of users in this tenant's schema"""
|
||||
# This requires querying the tenant schema
|
||||
# For now, return 0 - we can optimize this with annotations
|
||||
return 0
|
||||
|
||||
def get_owner(self, obj):
|
||||
"""Get the tenant owner user"""
|
||||
# Query public schema for users with this tenant as their business
|
||||
try:
|
||||
owner = User.objects.filter(
|
||||
role=User.Role.TENANT_OWNER,
|
||||
# Note: We need to add a tenant reference to User model
|
||||
).first()
|
||||
|
||||
if owner:
|
||||
return {
|
||||
'id': owner.id,
|
||||
'username': owner.username,
|
||||
'full_name': owner.full_name,
|
||||
'email': owner.email,
|
||||
'role': owner.role,
|
||||
}
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class PlatformUserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for User listing (platform view)"""
|
||||
business = serializers.SerializerMethodField()
|
||||
business_name = serializers.SerializerMethodField()
|
||||
business_subdomain = serializers.SerializerMethodField()
|
||||
full_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'email', 'username', 'first_name', 'last_name', 'full_name', 'role',
|
||||
'is_active', 'is_staff', 'is_superuser',
|
||||
'business', 'business_name', 'business_subdomain',
|
||||
'date_joined', 'last_login'
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_full_name(self, obj):
|
||||
"""Get user's full name"""
|
||||
return obj.full_name
|
||||
|
||||
def get_business(self, obj):
|
||||
"""Get tenant ID if user belongs to a tenant"""
|
||||
if obj.tenant:
|
||||
return obj.tenant.id
|
||||
return None
|
||||
|
||||
def get_business_name(self, obj):
|
||||
"""Get tenant name if user belongs to a tenant"""
|
||||
if obj.tenant:
|
||||
return obj.tenant.name
|
||||
return None
|
||||
|
||||
def get_business_subdomain(self, obj):
|
||||
"""Get tenant subdomain if user belongs to a tenant"""
|
||||
if obj.tenant:
|
||||
primary_domain = obj.tenant.domains.filter(is_primary=True, is_custom_domain=False).first()
|
||||
if primary_domain:
|
||||
return primary_domain.domain.split('.')[0]
|
||||
return obj.tenant.schema_name
|
||||
return None
|
||||
|
||||
|
||||
class PlatformMetricsSerializer(serializers.Serializer):
|
||||
"""Serializer for platform dashboard metrics"""
|
||||
total_tenants = serializers.IntegerField()
|
||||
active_tenants = serializers.IntegerField()
|
||||
total_users = serializers.IntegerField()
|
||||
mrr = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
growth_rate = serializers.FloatField()
|
||||
16
smoothschedule/platform_admin/urls.py
Normal file
16
smoothschedule/platform_admin/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Platform URL Configuration
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import TenantViewSet, PlatformUserViewSet
|
||||
|
||||
app_name = 'platform'
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'businesses', TenantViewSet, basename='business')
|
||||
router.register(r'users', PlatformUserViewSet, basename='user')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
77
smoothschedule/platform_admin/views.py
Normal file
77
smoothschedule/platform_admin/views.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Platform Views
|
||||
API views for platform-level operations
|
||||
"""
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Count
|
||||
|
||||
from core.models import Tenant
|
||||
from smoothschedule.users.models import User
|
||||
from .serializers import TenantSerializer, PlatformUserSerializer, PlatformMetricsSerializer
|
||||
from .permissions import IsPlatformAdmin, IsPlatformUser
|
||||
|
||||
|
||||
class TenantViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing tenants (businesses).
|
||||
Platform admins only.
|
||||
"""
|
||||
queryset = Tenant.objects.all().order_by('-created_on')
|
||||
serializer_class = TenantSerializer
|
||||
permission_classes = [IsAuthenticated, IsPlatformAdmin]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optionally filter by active status"""
|
||||
queryset = super().get_queryset()
|
||||
is_active = self.request.query_params.get('is_active')
|
||||
if is_active is not None:
|
||||
queryset = queryset.filter(is_active=is_active.lower() == 'true')
|
||||
return queryset
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def metrics(self, request):
|
||||
"""Get platform-wide tenant metrics"""
|
||||
total_tenants = Tenant.objects.count()
|
||||
active_tenants = Tenant.objects.filter(is_active=True).count()
|
||||
|
||||
metrics = {
|
||||
'total_tenants': total_tenants,
|
||||
'active_tenants': active_tenants,
|
||||
'total_users': User.objects.count(),
|
||||
'mrr': 0, # TODO: Calculate from billing
|
||||
'growth_rate': 0.0, # TODO: Calculate growth
|
||||
}
|
||||
|
||||
serializer = PlatformMetricsSerializer(metrics)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class PlatformUserViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing all users across the platform.
|
||||
Platform admins only.
|
||||
"""
|
||||
queryset = User.objects.all().order_by('-date_joined')
|
||||
serializer_class = PlatformUserSerializer
|
||||
permission_classes = [IsAuthenticated, IsPlatformAdmin]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optionally filter by business or role"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by role
|
||||
role = self.request.query_params.get('role')
|
||||
if role:
|
||||
queryset = queryset.filter(role=role)
|
||||
|
||||
# Filter by active status
|
||||
is_active = self.request.query_params.get('is_active')
|
||||
if is_active is not None:
|
||||
queryset = queryset.filter(is_active=is_active.lower() == 'true')
|
||||
|
||||
# TODO: Filter by business when we add tenant reference to User
|
||||
|
||||
return queryset
|
||||
Reference in New Issue
Block a user