Files
smoothschedule/smoothschedule/platform_admin/models.py
poduck 023ea7f020 feat(contracts): Add contracts permission to subscription tiers
- Add contracts_enabled field to SubscriptionPlan model
- Add contracts toggle to plan create/edit modal in platform settings
- Hide contracts menu item for tenants without contracts permission
- Protect /contracts routes with canUse('contracts') check
- Add HasContractsPermission to contracts API ViewSets
- Add contracts to PlanPermissions interface and feature definitions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-05 23:28:51 -05:00

729 lines
22 KiB
Python

"""
Platform Admin Models
Models for platform-level operations like tenant invitations
"""
import secrets
from datetime import timedelta
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.core.cache import cache
class PlatformSettings(models.Model):
"""
Singleton model for platform-wide settings.
Settings like Stripe keys can be overridden via environment variables.
"""
# Stripe settings (can be overridden or set via admin)
stripe_secret_key = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe secret key (overrides env if set)"
)
stripe_publishable_key = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe publishable key (overrides env if set)"
)
stripe_webhook_secret = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe webhook secret (overrides env if set)"
)
# Stripe validation info
stripe_account_id = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe account ID (set after validation)"
)
stripe_account_name = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe account name (set after validation)"
)
stripe_keys_validated_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the Stripe keys were last validated"
)
stripe_validation_error = models.TextField(
blank=True,
default='',
help_text="Last validation error, if any"
)
# OAuth settings - stored as JSON for flexibility
oauth_settings = models.JSONField(
default=dict,
blank=True,
help_text="OAuth provider settings (Google, Apple, etc.)"
)
# Example oauth_settings structure:
# {
# "allow_registration": true,
# "google": {"enabled": true, "client_id": "...", "client_secret": "..."},
# "apple": {"enabled": false, "client_id": "", ...},
# ...
# }
# Email settings
email_check_interval_minutes = models.PositiveIntegerField(
default=5,
help_text="How often to check for new incoming emails (in minutes)"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'platform_admin'
verbose_name = 'Platform Settings'
verbose_name_plural = 'Platform Settings'
def __str__(self):
return "Platform Settings"
def save(self, *args, **kwargs):
# Ensure only one instance exists
self.pk = 1
super().save(*args, **kwargs)
# Clear cache
cache.delete('platform_settings')
def delete(self, *args, **kwargs):
# Prevent deletion
pass
@classmethod
def get_instance(cls):
"""Get or create the singleton instance"""
# Try cache first
cached = cache.get('platform_settings')
if cached:
return cached
instance, _ = cls.objects.get_or_create(pk=1)
cache.set('platform_settings', instance, timeout=300) # 5 min cache
return instance
def get_stripe_secret_key(self):
"""Get Stripe secret key (database or env)"""
from django.conf import settings
if self.stripe_secret_key:
return self.stripe_secret_key
return getattr(settings, 'STRIPE_SECRET_KEY', '')
def get_stripe_publishable_key(self):
"""Get Stripe publishable key (database or env)"""
from django.conf import settings
if self.stripe_publishable_key:
return self.stripe_publishable_key
return getattr(settings, 'STRIPE_PUBLISHABLE_KEY', '')
def get_stripe_webhook_secret(self):
"""Get Stripe webhook secret (database or env)"""
from django.conf import settings
if self.stripe_webhook_secret:
return self.stripe_webhook_secret
return getattr(settings, 'STRIPE_WEBHOOK_SECRET', '')
def has_stripe_keys(self):
"""Check if Stripe keys are configured"""
return bool(self.get_stripe_secret_key() and self.get_stripe_publishable_key())
def stripe_keys_from_env(self):
"""Check if Stripe keys come from environment"""
from django.conf import settings
return (
not self.stripe_secret_key and
bool(getattr(settings, 'STRIPE_SECRET_KEY', ''))
)
def mask_key(self, key, visible_chars=8):
"""Mask a key, showing only first/last chars"""
if not key:
return ''
if len(key) <= visible_chars * 2:
return '*' * len(key)
return f"{key[:visible_chars]}...{key[-4:]}"
class SubscriptionPlan(models.Model):
"""
Subscription plans that can be assigned to tenants.
Links to Stripe products/prices for billing.
"""
class PlanType(models.TextChoices):
BASE = 'base', _('Base Plan')
ADDON = 'addon', _('Add-on')
name = models.CharField(max_length=100)
description = models.TextField(blank=True, default='')
plan_type = models.CharField(
max_length=10,
choices=PlanType.choices,
default=PlanType.BASE
)
# Stripe integration
stripe_product_id = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe Product ID"
)
stripe_price_id = models.CharField(
max_length=255,
blank=True,
default='',
help_text="Stripe Price ID (for monthly billing)"
)
# Pricing
price_monthly = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
help_text="Monthly price in dollars"
)
price_yearly = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
help_text="Yearly price in dollars"
)
# Business tier this plan corresponds to (empty for addons)
business_tier = models.CharField(
max_length=50,
choices=[
('', 'N/A (Add-on)'),
('Free', 'Free'),
('Starter', 'Starter'),
('Professional', 'Professional'),
('Business', 'Business'),
('Enterprise', 'Enterprise'),
],
blank=True,
default=''
)
# Features included (stored as JSON array of strings)
features = models.JSONField(
default=list,
blank=True,
help_text="List of feature descriptions"
)
# Platform permissions (what features this plan grants)
permissions = models.JSONField(
default=dict,
blank=True,
help_text="Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)"
)
# Feature limits (what capabilities this plan has)
limits = models.JSONField(
default=dict,
blank=True,
help_text="Feature limits and capabilities to grant (e.g. max_users, max_resources)"
)
# Transaction fees for payment processing
transaction_fee_percent = models.DecimalField(
max_digits=5,
decimal_places=2,
default=2.9,
help_text="Transaction fee percentage"
)
transaction_fee_fixed = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0.30,
help_text="Fixed transaction fee in dollars"
)
# SMS & Communication Settings
sms_enabled = models.BooleanField(
default=False,
help_text="Whether SMS reminders are available for this tier"
)
sms_price_per_message_cents = models.IntegerField(
default=3, # $0.03
help_text="Price per SMS message in cents"
)
# Masked Calling Settings
masked_calling_enabled = models.BooleanField(
default=False,
help_text="Whether masked calling is available for this tier"
)
masked_calling_price_per_minute_cents = models.IntegerField(
default=5, # $0.05
help_text="Price per voice minute in cents"
)
# Proxy Phone Number Settings
proxy_number_enabled = models.BooleanField(
default=False,
help_text="Whether tenants can have dedicated proxy numbers"
)
proxy_number_monthly_fee_cents = models.IntegerField(
default=200, # $2.00
help_text="Monthly fee per proxy number in cents"
)
# Contracts Feature
contracts_enabled = models.BooleanField(
default=False,
help_text="Whether tenants can use the contracts feature"
)
# Default Credit Settings (for new tenants on this tier)
default_auto_reload_enabled = models.BooleanField(
default=False,
help_text="Whether auto-reload is enabled by default for new tenants"
)
default_auto_reload_threshold_cents = models.IntegerField(
default=1000, # $10
help_text="Default auto-reload threshold in cents"
)
default_auto_reload_amount_cents = models.IntegerField(
default=2500, # $25
help_text="Default auto-reload amount in cents"
)
# Visibility
is_active = models.BooleanField(default=True)
is_public = models.BooleanField(
default=True,
help_text="Whether this plan is visible on public pricing page"
)
is_most_popular = models.BooleanField(
default=False,
help_text="Whether to highlight this plan as the most popular choice"
)
show_price = models.BooleanField(
default=True,
help_text="Whether to display the price on the marketing site (disable for 'Contact Us')"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'platform_admin'
ordering = ['price_monthly', 'name']
def __str__(self):
return f"{self.name} ({self.get_plan_type_display()})"
class TenantInvitation(models.Model):
"""
Invitation for new business owners to create their tenant.
Allows platform admins to pre-configure custom limits and permissions.
Flow:
1. Platform admin creates invitation with email and custom settings
2. System sends email with unique token link
3. Invitee clicks link, completes onboarding wizard
4. Tenant, domain, and owner user are created with pre-configured settings
"""
class Status(models.TextChoices):
PENDING = 'PENDING', _('Pending')
ACCEPTED = 'ACCEPTED', _('Accepted')
EXPIRED = 'EXPIRED', _('Expired')
CANCELLED = 'CANCELLED', _('Cancelled')
# Invitation target
email = models.EmailField(help_text="Email address to send invitation to")
token = models.CharField(max_length=64, unique=True)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING
)
# Pre-configured business settings (owner can modify during onboarding)
suggested_business_name = models.CharField(
max_length=100,
blank=True,
help_text="Suggested business name (owner can change during onboarding)"
)
# Subscription settings
subscription_tier = models.CharField(
max_length=50,
choices=[
('FREE', 'Free Trial'),
('STARTER', 'Starter'),
('PROFESSIONAL', 'Professional'),
('ENTERPRISE', 'Enterprise'),
],
default='PROFESSIONAL'
)
# Custom limits (null = use tier defaults)
custom_max_users = models.IntegerField(
null=True,
blank=True,
help_text="Custom max users limit (null = use tier default)"
)
custom_max_resources = models.IntegerField(
null=True,
blank=True,
help_text="Custom max resources limit (null = use tier default)"
)
# Platform permissions (what features this tenant can access)
# These are special permissions not available in normal tier packages
permissions = models.JSONField(
default=dict,
blank=True,
help_text="Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)"
)
# Example permissions structure:
# {
# "can_manage_oauth_credentials": true,
# "can_accept_payments": true,
# "can_use_custom_domain": true,
# "can_white_label": false,
# "can_api_access": true,
# }
# Feature limits (what capabilities this tenant has)
limits = models.JSONField(
default=dict,
blank=True,
help_text="Feature limits and capabilities to grant"
)
# Example limits structure:
# {
# "can_add_video_conferencing": true,
# "max_event_types": 10, # null = unlimited
# "max_calendars_connected": 5, # null = unlimited
# "can_connect_to_api": true,
# "can_book_repeated_events": true,
# "can_require_2fa": true,
# "can_download_logs": false,
# "can_delete_data": false,
# "can_use_masked_phone_numbers": false,
# "can_use_pos": false,
# "can_use_mobile_app": true,
# }
# Personal message to include in email
personal_message = models.TextField(
blank=True,
help_text="Optional personal message to include in the invitation email"
)
# Metadata
invited_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='tenant_invitations_sent',
help_text="Platform admin who sent the invitation"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
accepted_at = models.DateTimeField(null=True, blank=True)
# Links to created resources (after acceptance)
created_tenant = models.ForeignKey(
'core.Tenant',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='invitation',
help_text="Tenant created when invitation was accepted"
)
created_user = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='tenant_invitation_accepted',
help_text="Owner user created when invitation was accepted"
)
class Meta:
app_label = 'platform_admin'
ordering = ['-created_at']
indexes = [
models.Index(fields=['token']),
models.Index(fields=['email', 'status']),
models.Index(fields=['status', 'expires_at']),
]
def __str__(self):
return f"Tenant invitation for {self.email} ({self.get_status_display()})"
def save(self, *args, **kwargs):
if not self.token:
self.token = secrets.token_urlsafe(32)
if not self.expires_at:
# Default expiration: 7 days
self.expires_at = timezone.now() + timedelta(days=7)
super().save(*args, **kwargs)
def is_valid(self):
"""Check if invitation can still be accepted"""
if self.status != self.Status.PENDING:
return False
if timezone.now() > self.expires_at:
return False
return True
def accept(self, tenant, user):
"""Mark invitation as accepted and link to created resources"""
self.status = self.Status.ACCEPTED
self.accepted_at = timezone.now()
self.created_tenant = tenant
self.created_user = user
self.save()
def cancel(self):
"""Cancel a pending invitation"""
if self.status == self.Status.PENDING:
self.status = self.Status.CANCELLED
self.save()
def get_effective_max_users(self):
"""Get max users (custom or tier default)"""
if self.custom_max_users is not None:
return self.custom_max_users
# Tier defaults
tier_defaults = {
'FREE': 2,
'STARTER': 5,
'PROFESSIONAL': 15,
'ENTERPRISE': 50,
}
return tier_defaults.get(self.subscription_tier, 5)
def get_effective_max_resources(self):
"""Get max resources (custom or tier default)"""
if self.custom_max_resources is not None:
return self.custom_max_resources
# Tier defaults
tier_defaults = {
'FREE': 3,
'STARTER': 10,
'PROFESSIONAL': 30,
'ENTERPRISE': 100,
}
return tier_defaults.get(self.subscription_tier, 10)
@classmethod
def create_invitation(cls, email, invited_by, subscription_tier='PROFESSIONAL',
suggested_business_name='', custom_max_users=None,
custom_max_resources=None, permissions=None, limits=None,
personal_message=''):
"""
Create a new tenant invitation, cancelling any existing pending invitations
for the same email.
"""
# Cancel existing pending invitations for this email
cls.objects.filter(
email=email,
status=cls.Status.PENDING
).update(status=cls.Status.CANCELLED)
# Create new invitation
return cls.objects.create(
email=email,
invited_by=invited_by,
subscription_tier=subscription_tier,
suggested_business_name=suggested_business_name,
custom_max_users=custom_max_users,
custom_max_resources=custom_max_resources,
permissions=permissions or {},
limits=limits or {},
personal_message=personal_message,
)
class PlatformEmailAddress(models.Model):
"""
Platform-managed email addresses hosted on mail.talova.net.
These are managed directly via SSH/Docker commands on the mail server.
Unlike TicketEmailAddress which supports arbitrary IMAP/SMTP servers,
this model is specifically for platform email addresses managed on our
dedicated mail server.
"""
class Domain(models.TextChoices):
SMOOTHSCHEDULE = 'smoothschedule.com', 'smoothschedule.com'
TALOVA = 'talova.net', 'talova.net'
# Display information
display_name = models.CharField(
max_length=100,
help_text="Display name (e.g., 'Support', 'Billing', 'Sales')"
)
local_part = models.CharField(
max_length=64,
help_text="Local part of email address (before @)"
)
domain = models.CharField(
max_length=50,
choices=Domain.choices,
default=Domain.SMOOTHSCHEDULE,
help_text="Email domain"
)
color = models.CharField(
max_length=7,
default='#3b82f6',
help_text="Hex color code for visual identification"
)
sender_name = models.CharField(
max_length=100,
blank=True,
default='',
help_text="Name to show in From header (e.g., 'SmoothSchedule Support'). If blank, uses display_name."
)
assigned_user = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='platform_email_addresses',
help_text="User associated with this email. If set, their name is used as sender name."
)
# Account credentials (stored securely, synced to mail server)
password = models.CharField(
max_length=255,
help_text="Password for the email account (stored encrypted, synced to mail server)"
)
# Status
is_active = models.BooleanField(
default=True,
help_text="Whether this email address is active"
)
is_default = models.BooleanField(
default=False,
help_text="Default email for platform support"
)
# Mail server sync status
mail_server_synced = models.BooleanField(
default=False,
help_text="Whether the account exists on the mail server"
)
last_sync_error = models.TextField(
blank=True,
default='',
help_text="Last sync error message, if any"
)
last_synced_at = models.DateTimeField(
null=True,
blank=True,
help_text="When this account was last synced to the mail server"
)
# Usage tracking
emails_processed_count = models.IntegerField(
default=0,
help_text="Total number of emails processed for this address"
)
last_check_at = models.DateTimeField(
null=True,
blank=True,
help_text="When emails were last checked for this address"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'platform_admin'
ordering = ['-is_default', 'display_name']
unique_together = [['local_part', 'domain']]
verbose_name = 'Platform Email Address'
verbose_name_plural = 'Platform Email Addresses'
def __str__(self):
return f"{self.display_name} <{self.email_address}>"
@property
def email_address(self):
"""Full email address."""
return f"{self.local_part}@{self.domain}"
@property
def effective_sender_name(self):
"""
Name to use in From header.
Priority: assigned_user's full name > sender_name > display_name
"""
if self.assigned_user:
user_name = self.assigned_user.get_full_name()
if user_name:
return user_name
if self.sender_name:
return self.sender_name
return self.display_name
def save(self, *args, **kwargs):
# Ensure only one default
if self.is_default:
PlatformEmailAddress.objects.filter(
is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
# Pre-configured mail server settings
MAIL_SERVER_HOST = 'mail.talova.net'
IMAP_HOST = 'mail.talova.net'
IMAP_PORT = 993
IMAP_USE_SSL = True
SMTP_HOST = 'mail.talova.net'
SMTP_PORT = 587
SMTP_USE_TLS = True
SMTP_USE_SSL = False
def get_imap_settings(self):
"""Get IMAP connection settings."""
return {
'host': self.IMAP_HOST,
'port': self.IMAP_PORT,
'use_ssl': self.IMAP_USE_SSL,
'username': self.email_address,
'password': self.password,
'folder': 'INBOX',
}
def get_smtp_settings(self):
"""Get SMTP connection settings."""
return {
'host': self.SMTP_HOST,
'port': self.SMTP_PORT,
'use_tls': self.SMTP_USE_TLS,
'use_ssl': self.SMTP_USE_SSL,
'username': self.email_address,
'password': self.password,
}