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>
272 lines
7.7 KiB
Python
272 lines
7.7 KiB
Python
"""
|
|
Smooth Schedule Core Models
|
|
Multi-tenancy, Domain management, and Permission Grant system
|
|
"""
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django_tenants.models import TenantMixin, DomainMixin
|
|
from datetime import timedelta
|
|
|
|
|
|
class Tenant(TenantMixin):
|
|
"""
|
|
Tenant model for multi-schema architecture.
|
|
Each tenant gets their own PostgreSQL schema for complete data isolation.
|
|
"""
|
|
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(
|
|
max_length=50,
|
|
choices=[
|
|
('FREE', 'Free Trial'),
|
|
('STARTER', 'Starter'),
|
|
('PROFESSIONAL', 'Professional'),
|
|
('ENTERPRISE', 'Enterprise'),
|
|
],
|
|
default='FREE'
|
|
)
|
|
|
|
# Feature flags
|
|
max_users = models.IntegerField(default=5)
|
|
max_resources = models.IntegerField(default=10)
|
|
|
|
# Metadata
|
|
contact_email = models.EmailField(blank=True)
|
|
phone = models.CharField(max_length=20, blank=True)
|
|
|
|
# Auto-created fields from TenantMixin:
|
|
# - schema_name (unique, indexed)
|
|
# - auto_create_schema
|
|
# - auto_drop_schema
|
|
|
|
class Meta:
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Domain(DomainMixin):
|
|
"""
|
|
Domain model for tenant routing.
|
|
Supports both subdomain (tenant.chronoflow.com) and custom domains.
|
|
"""
|
|
# Inherited from DomainMixin:
|
|
# - domain (unique, primary key)
|
|
# - tenant (ForeignKey to Tenant)
|
|
# - is_primary (boolean)
|
|
|
|
# Route53 integration fields for custom domains
|
|
is_custom_domain = models.BooleanField(
|
|
default=False,
|
|
help_text="True if this is a custom domain (not a subdomain of smoothschedule.com)"
|
|
)
|
|
|
|
route53_zone_id = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text="AWS Route53 Hosted Zone ID for this custom domain"
|
|
)
|
|
|
|
route53_record_set_id = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text="Route53 Record Set ID for DNS verification"
|
|
)
|
|
|
|
# SSL certificate management (for future AWS ACM integration)
|
|
ssl_certificate_arn = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text="AWS ACM Certificate ARN for HTTPS"
|
|
)
|
|
|
|
verified_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="When the custom domain was verified via DNS"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ['domain']
|
|
|
|
def __str__(self):
|
|
domain_type = "Custom" if self.is_custom_domain else "Subdomain"
|
|
return f"{self.domain} ({domain_type})"
|
|
|
|
def is_verified(self):
|
|
"""Check if custom domain is verified"""
|
|
if not self.is_custom_domain:
|
|
return True # Subdomains are always verified
|
|
return self.verified_at is not None
|
|
|
|
|
|
class PermissionGrant(models.Model):
|
|
"""
|
|
Time-limited permission grants (30-minute window).
|
|
Used for temporary elevated access without permanently changing user permissions.
|
|
|
|
Example use cases:
|
|
- Support agent needs temporary access to tenant data
|
|
- Sales demo requiring elevated permissions
|
|
- Cross-tenant operations during migrations
|
|
"""
|
|
grantor = models.ForeignKey(
|
|
'users.User',
|
|
on_delete=models.CASCADE,
|
|
related_name='granted_permissions',
|
|
help_text="User who granted the permission"
|
|
)
|
|
|
|
grantee = models.ForeignKey(
|
|
'users.User',
|
|
on_delete=models.CASCADE,
|
|
related_name='received_permissions',
|
|
help_text="User who received the permission"
|
|
)
|
|
|
|
action = models.CharField(
|
|
max_length=100,
|
|
help_text="Specific action or permission being granted (e.g., 'view_billing', 'edit_settings')"
|
|
)
|
|
|
|
reason = models.TextField(
|
|
blank=True,
|
|
help_text="Justification for granting this permission (audit trail)"
|
|
)
|
|
|
|
granted_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
expires_at = models.DateTimeField(
|
|
help_text="When this permission grant expires"
|
|
)
|
|
|
|
revoked_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="If manually revoked before expiration"
|
|
)
|
|
|
|
# Audit metadata
|
|
ip_address = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="IP address where grant was created"
|
|
)
|
|
|
|
user_agent = models.TextField(
|
|
blank=True,
|
|
help_text="Browser/client user agent"
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ['-granted_at']
|
|
indexes = [
|
|
models.Index(fields=['grantee', 'expires_at']),
|
|
models.Index(fields=['grantor', 'granted_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.grantor} → {self.grantee}: {self.action} (expires {self.expires_at})"
|
|
|
|
def is_active(self):
|
|
"""
|
|
Check if this permission grant is currently active.
|
|
Returns False if expired or revoked.
|
|
"""
|
|
now = timezone.now()
|
|
|
|
# Check if revoked
|
|
if self.revoked_at is not None:
|
|
return False
|
|
|
|
# Check if expired
|
|
if now >= self.expires_at:
|
|
return False
|
|
|
|
return True
|
|
|
|
def revoke(self):
|
|
"""Manually revoke this permission grant"""
|
|
if self.revoked_at is None:
|
|
self.revoked_at = timezone.now()
|
|
self.save(update_fields=['revoked_at'])
|
|
|
|
def time_remaining(self):
|
|
"""Get timedelta of remaining active time (or None if expired/revoked)"""
|
|
if not self.is_active():
|
|
return None
|
|
|
|
now = timezone.now()
|
|
return self.expires_at - now
|
|
|
|
@classmethod
|
|
def create_grant(cls, grantor, grantee, action, reason="", duration_minutes=30, ip_address=None, user_agent=""):
|
|
"""
|
|
Factory method to create a time-limited permission grant.
|
|
|
|
Args:
|
|
grantor: User granting the permission
|
|
grantee: User receiving the permission
|
|
action: Permission action string
|
|
reason: Justification for audit trail
|
|
duration_minutes: How long the grant is valid (default 30)
|
|
ip_address: IP address of the request
|
|
user_agent: Browser user agent
|
|
|
|
Returns:
|
|
PermissionGrant instance
|
|
"""
|
|
now = timezone.now()
|
|
expires_at = now + timedelta(minutes=duration_minutes)
|
|
|
|
return cls.objects.create(
|
|
grantor=grantor,
|
|
grantee=grantee,
|
|
action=action,
|
|
reason=reason,
|
|
expires_at=expires_at,
|
|
ip_address=ip_address,
|
|
user_agent=user_agent
|
|
)
|
|
|
|
|
|
class TierLimit(models.Model):
|
|
"""
|
|
Defines resource limits for each subscription tier.
|
|
Used by HasQuota permission to enforce hard blocks.
|
|
"""
|
|
tier = models.CharField(
|
|
max_length=50,
|
|
choices=[
|
|
('FREE', 'Free Trial'),
|
|
('STARTER', 'Starter'),
|
|
('PROFESSIONAL', 'Professional'),
|
|
('ENTERPRISE', 'Enterprise'),
|
|
]
|
|
)
|
|
|
|
feature_code = models.CharField(
|
|
max_length=100,
|
|
help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')"
|
|
)
|
|
|
|
limit = models.IntegerField(
|
|
default=0,
|
|
help_text="Maximum allowed count for this feature"
|
|
)
|
|
|
|
class Meta:
|
|
unique_together = ['tier', 'feature_code']
|
|
ordering = ['tier', 'feature_code']
|
|
|
|
def __str__(self):
|
|
return f"{self.tier} - {self.feature_code}: {self.limit}"
|
|
|