""" Multi-Account and Authentication Models """ from django.db import models from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ from django.core.validators import MinValueValidator, MaxValueValidator class AccountBaseModel(models.Model): """ Abstract base model for all account-isolated models. All models should inherit from this to ensure account isolation. """ account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='%(class)s_set', db_index=True, db_column='tenant_id') created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = True indexes = [ models.Index(fields=['account', 'created_at']), ] class SiteSectorBaseModel(AccountBaseModel): """ Abstract base model for models that belong to a Site and Sector. Provides automatic filtering by site/sector based on user access. Models like Keywords and Clusters should inherit from this. """ site = models.ForeignKey('igny8_core_auth.Site', on_delete=models.CASCADE, related_name='%(class)s_set', db_index=True) sector = models.ForeignKey('igny8_core_auth.Sector', on_delete=models.CASCADE, related_name='%(class)s_set', db_index=True) class Meta: abstract = True indexes = [ models.Index(fields=['account', 'site', 'sector']), models.Index(fields=['site', 'sector']), ] def save(self, *args, **kwargs): """Ensure site and sector belong to same account.""" # Set account from site if self.site: self.account = self.site.account # Ensure sector belongs to site if self.sector and self.sector.site != self.site: from django.core.exceptions import ValidationError raise ValidationError("Sector must belong to the same site") super().save(*args, **kwargs) class Account(models.Model): """ Account/Organization model for multi-account support. """ STATUS_CHOICES = [ ('active', 'Active'), ('suspended', 'Suspended'), ('trial', 'Trial'), ('cancelled', 'Cancelled'), ] name = models.CharField(max_length=255) slug = models.SlugField(unique=True, max_length=255) owner = models.ForeignKey('igny8_core_auth.User', on_delete=models.PROTECT, related_name='owned_accounts') stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts') credits = models.IntegerField(default=0, validators=[MinValueValidator(0)]) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'igny8_tenants' verbose_name = 'Account' verbose_name_plural = 'Accounts' indexes = [ models.Index(fields=['slug']), models.Index(fields=['status']), ] def __str__(self): return self.name def is_system_account(self): """Check if this account is a system account with highest access level.""" # System accounts bypass all filtering restrictions return self.slug in ['aws-admin', 'default-account', 'default'] class Plan(models.Model): """ Subscription plan model - Phase 0: Credit-only system. Plans define credits, billing, and account management limits only. """ BILLING_CYCLE_CHOICES = [ ('monthly', 'Monthly'), ('annual', 'Annual'), ] # Plan Info name = models.CharField(max_length=255) slug = models.SlugField(unique=True, max_length=255) price = models.DecimalField(max_digits=10, decimal_places=2) billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly') features = models.JSONField(default=list, blank=True, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])") is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) # Account Management Limits (kept - not operation limits) max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account") max_sites = models.IntegerField( default=1, validators=[MinValueValidator(1)], help_text="Maximum number of sites allowed" ) max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors") max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles") # Billing & Credits (Phase 0: Credit-only system) included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included") extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit") allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?") auto_credit_topup_threshold = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(0)], help_text="Auto top-up trigger point (optional)") auto_credit_topup_amount = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="How many credits to auto-buy") # Stripe Integration stripe_product_id = models.CharField(max_length=255, blank=True, null=True, help_text="For Stripe plan sync") stripe_price_id = models.CharField(max_length=255, blank=True, null=True, help_text="Monthly price ID for Stripe") # Legacy field for backward compatibility credits_per_month = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="DEPRECATED: Use included_credits instead") class Meta: db_table = 'igny8_plans' ordering = ['price'] def __str__(self): return self.name def clean(self): """Validate plan limits.""" from django.core.exceptions import ValidationError if self.max_sites < 1: raise ValidationError("max_sites must be >= 1") if self.included_credits < 0: raise ValidationError("included_credits must be >= 0") def get_effective_credits_per_month(self): """Get effective credits per month (use included_credits if set, otherwise credits_per_month for backward compatibility).""" return self.included_credits if self.included_credits > 0 else self.credits_per_month class Subscription(models.Model): """ Account subscription model linking to Stripe. """ STATUS_CHOICES = [ ('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('trialing', 'Trialing'), ] account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id') stripe_subscription_id = models.CharField(max_length=255, unique=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES) current_period_start = models.DateTimeField() current_period_end = models.DateTimeField() cancel_at_period_end = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'igny8_subscriptions' indexes = [ models.Index(fields=['status']), ] def __str__(self): return f"{self.account.name} - {self.status}" class Site(AccountBaseModel): """ Site model - Each account can have multiple sites based on their plan. Each site belongs to ONE industry and can have 1-5 sectors from that industry. """ STATUS_CHOICES = [ ('active', 'Active'), ('inactive', 'Inactive'), ('suspended', 'Suspended'), ] name = models.CharField(max_length=255) slug = models.SlugField(max_length=255) domain = models.URLField(blank=True, null=True, help_text="Primary domain URL") description = models.TextField(blank=True, null=True) industry = models.ForeignKey( 'igny8_core_auth.Industry', on_delete=models.PROTECT, related_name='sites', null=True, blank=True, help_text="Industry this site belongs to" ) is_active = models.BooleanField(default=True, db_index=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # WordPress integration fields (legacy - use SiteIntegration instead) wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL (legacy - use SiteIntegration)") wp_username = models.CharField(max_length=255, blank=True, null=True) wp_app_password = models.CharField(max_length=255, blank=True, null=True) wp_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API key for WordPress integration via IGNY8 WP Bridge plugin") # Site type and hosting (Phase 6) SITE_TYPE_CHOICES = [ ('marketing', 'Marketing Site'), ('ecommerce', 'Ecommerce Site'), ('blog', 'Blog'), ('portfolio', 'Portfolio'), ('corporate', 'Corporate'), ] HOSTING_TYPE_CHOICES = [ ('igny8_sites', 'IGNY8 Sites'), ('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('multi', 'Multi-Destination'), ] site_type = models.CharField( max_length=50, choices=SITE_TYPE_CHOICES, default='marketing', db_index=True, help_text="Type of site" ) hosting_type = models.CharField( max_length=50, choices=HOSTING_TYPE_CHOICES, default='igny8_sites', db_index=True, help_text="Target hosting platform" ) # SEO metadata (Phase 7) seo_metadata = models.JSONField( default=dict, blank=True, help_text="SEO metadata: meta tags, Open Graph, Schema.org" ) class Meta: db_table = 'igny8_sites' unique_together = [['account', 'slug']] # Slug unique per account ordering = ['-created_at'] # Order by creation date for consistent pagination indexes = [ models.Index(fields=['account', 'is_active']), models.Index(fields=['account', 'status']), models.Index(fields=['industry']), models.Index(fields=['site_type']), models.Index(fields=['hosting_type']), ] def __str__(self): return f"{self.account.name} - {self.name}" def get_active_sectors_count(self): """Get count of active sectors for this site.""" return self.sectors.filter(is_active=True).count() def get_max_sectors_limit(self): """Get the maximum sectors allowed for this site based on plan, defaulting to 5 if not set.""" try: if self.account and self.account.plan and self.account.plan.max_industries is not None: return self.account.plan.max_industries except (AttributeError, Exception): pass # Default limit: 5 sectors per site return 5 def can_add_sector(self): """Check if site can add another sector based on plan limits.""" return self.get_active_sectors_count() < self.get_max_sectors_limit() class Industry(models.Model): """ Industry model - Global industry templates. These are predefined industry definitions that sites can reference. """ name = models.CharField(max_length=255, unique=True) slug = models.SlugField(unique=True, max_length=255, db_index=True) description = models.TextField(blank=True, null=True) is_active = models.BooleanField(default=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'igny8_industries' ordering = ['name'] verbose_name = 'Industry' verbose_name_plural = 'Industries' indexes = [ models.Index(fields=['slug']), models.Index(fields=['is_active']), ] def __str__(self): return self.name class IndustrySector(models.Model): """ Industry Sector model - Sector templates within industries. These define the available sectors for each industry. """ industry = models.ForeignKey('igny8_core_auth.Industry', on_delete=models.CASCADE, related_name='sectors') name = models.CharField(max_length=255) slug = models.SlugField(max_length=255, db_index=True) description = models.TextField(blank=True, null=True) suggested_keywords = models.JSONField(default=list, help_text='List of suggested keywords for this sector template') is_active = models.BooleanField(default=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'igny8_industry_sectors' unique_together = [['industry', 'slug']] # Slug unique per industry verbose_name = 'Industry Sector' verbose_name_plural = 'Industry Sectors' indexes = [ models.Index(fields=['industry', 'is_active']), models.Index(fields=['slug']), ] ordering = ['industry', 'name'] def __str__(self): return f"{self.industry.name} - {self.name}" class SeedKeyword(models.Model): """ Global, permanent keyword suggestions scoped by industry + sector. These are canonical keywords that can be imported into account-specific Keywords. Non-deletable global reference data. """ INTENT_CHOICES = [ ('informational', 'Informational'), ('navigational', 'Navigational'), ('commercial', 'Commercial'), ('transactional', 'Transactional'), ] keyword = models.CharField(max_length=255, db_index=True) industry = models.ForeignKey('igny8_core_auth.Industry', on_delete=models.CASCADE, related_name='seed_keywords') sector = models.ForeignKey('igny8_core_auth.IndustrySector', on_delete=models.CASCADE, related_name='seed_keywords') volume = models.IntegerField(default=0, help_text='Search volume estimate') difficulty = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(100)], help_text='Keyword difficulty (0-100)' ) intent = models.CharField(max_length=50, choices=INTENT_CHOICES, default='informational') is_active = models.BooleanField(default=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'igny8_seed_keywords' unique_together = [['keyword', 'industry', 'sector']] verbose_name = 'Seed Keyword' verbose_name_plural = 'Seed Keywords' indexes = [ models.Index(fields=['keyword']), models.Index(fields=['industry', 'sector']), models.Index(fields=['industry', 'sector', 'is_active']), models.Index(fields=['intent']), ] ordering = ['keyword'] def __str__(self): return f"{self.keyword} ({self.industry.name} - {self.sector.name})" class Sector(AccountBaseModel): """ Sector model - Each site can have 1-5 sectors. Sectors are site-specific instances that reference an IndustrySector template. Sectors contain keywords and clusters. """ STATUS_CHOICES = [ ('active', 'Active'), ('inactive', 'Inactive'), ] site = models.ForeignKey('igny8_core_auth.Site', on_delete=models.CASCADE, related_name='sectors') industry_sector = models.ForeignKey( 'igny8_core_auth.IndustrySector', on_delete=models.PROTECT, related_name='site_sectors', null=True, blank=True, help_text="Reference to the industry sector template" ) name = models.CharField(max_length=255) slug = models.SlugField(max_length=255) description = models.TextField(blank=True, null=True) is_active = models.BooleanField(default=True, db_index=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'igny8_sectors' unique_together = [['site', 'slug']] # Slug unique per site indexes = [ models.Index(fields=['site', 'is_active']), models.Index(fields=['account', 'site']), models.Index(fields=['industry_sector']), ] def __str__(self): return f"{self.site.name} - {self.name}" @property def industry(self): """Get the industry for this sector.""" return self.industry_sector.industry if self.industry_sector else None def save(self, *args, **kwargs): """Ensure site belongs to same account, validate sector limit, and industry match.""" # Set account from site if self.site: self.account = self.site.account # Validate that sector's industry_sector belongs to site's industry if self.site and self.site.industry and self.industry_sector: if self.industry_sector.industry != self.site.industry: from django.core.exceptions import ValidationError raise ValidationError( f"Sector must belong to site's industry ({self.site.industry.name}). " f"Selected sector belongs to {self.industry_sector.industry.name}." ) super().save(*args, **kwargs) # Validate sector limit based on plan - only for new active sectors if self.is_active: max_sectors = self.site.get_max_sectors_limit() if self.site.get_active_sectors_count() > max_sectors: from django.core.exceptions import ValidationError raise ValidationError(f"Maximum {max_sectors} sectors allowed per site for this plan") class SiteUserAccess(models.Model): """ Many-to-many relationship between Users and Sites. Controls which users can access which sites. Owners and Admins have access to all sites automatically. """ user = models.ForeignKey('igny8_core_auth.User', on_delete=models.CASCADE, related_name='site_access') site = models.ForeignKey('igny8_core_auth.Site', on_delete=models.CASCADE, related_name='user_access') granted_at = models.DateTimeField(auto_now_add=True) granted_by = models.ForeignKey( 'igny8_core_auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='granted_site_accesses' ) class Meta: db_table = 'igny8_site_user_access' unique_together = [['user', 'site']] verbose_name = 'Site User Access' verbose_name_plural = 'Site User Access' indexes = [ models.Index(fields=['user', 'site']), ] def __str__(self): return f"{self.user.email} -> {self.site.name}" class PasswordResetToken(models.Model): """Password reset token model for password reset flow""" user = models.ForeignKey('igny8_core_auth.User', on_delete=models.CASCADE, related_name='password_reset_tokens') token = models.CharField(max_length=255, unique=True, db_index=True) expires_at = models.DateTimeField() used = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'igny8_password_reset_tokens' indexes = [ models.Index(fields=['token']), models.Index(fields=['user', 'used']), models.Index(fields=['expires_at']), ] ordering = ['-created_at'] def __str__(self): return f"Password reset token for {self.user.email}" def is_valid(self): """Check if token is valid (not used and not expired)""" from django.utils import timezone return not self.used and self.expires_at > timezone.now() class User(AbstractUser): """ Custom user model with account relationship and role support. """ ROLE_CHOICES = [ ('developer', 'Developer / Super Admin'), ('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot'), ] account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='users', null=True, blank=True, db_column='tenant_id') role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer') email = models.EmailField(_('email address'), unique=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['username'] class Meta: db_table = 'igny8_users' indexes = [ models.Index(fields=['account', 'role']), models.Index(fields=['email']), ] def __str__(self): return self.email def has_role(self, *roles): """Check if user has any of the specified roles.""" return self.role in roles def is_owner_or_admin(self): """Check if user is owner or admin.""" return self.role in ['owner', 'admin'] def is_developer(self): """Check if user is a developer/super admin with full access.""" return self.role == 'developer' or self.is_superuser def is_admin_or_developer(self): """Check if user is admin or developer with override privileges.""" # ADMIN/DEV OVERRIDE: Both admin and developer roles bypass account/site/sector restrictions return self.role in ['admin', 'developer'] or self.is_superuser def is_system_account_user(self): """Check if user belongs to a system account with highest access level.""" try: return self.account and self.account.is_system_account() except (AttributeError, Exception): # If account access fails (e.g., column mismatch), return False return False def get_accessible_sites(self): """Get all sites the user can access.""" # System account users can access all sites across all accounts if self.is_system_account_user(): return Site.objects.filter(is_active=True).distinct() # Developers/super admins can access all sites across all accounts # ADMIN/DEV OVERRIDE: Admins also bypass account restrictions (see is_admin_or_developer) if self.is_developer(): return Site.objects.filter(is_active=True).distinct() try: if not self.account: return Site.objects.none() # Owners and admins can access all sites in their account if self.role in ['owner', 'admin']: return Site.objects.filter(account=self.account, is_active=True) # Other users can only access sites explicitly granted via SiteUserAccess return Site.objects.filter( account=self.account, is_active=True, user_access__user=self ).distinct() except (AttributeError, Exception): # If account access fails (e.g., column mismatch), return empty queryset return Site.objects.none()