999 lines
40 KiB
Python
999 lines
40 KiB
Python
"""
|
|
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
|
|
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
|
|
from simple_history.models import HistoricalRecords
|
|
|
|
|
|
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(SoftDeletableModel):
|
|
"""
|
|
Account/Organization model for multi-account support.
|
|
"""
|
|
STATUS_CHOICES = [
|
|
('active', 'Active'),
|
|
('suspended', 'Suspended'),
|
|
('trial', 'Trial'),
|
|
('cancelled', 'Cancelled'),
|
|
('pending_payment', 'Pending Payment'),
|
|
]
|
|
|
|
PAYMENT_METHOD_CHOICES = [
|
|
('stripe', 'Stripe'),
|
|
('paypal', 'PayPal'),
|
|
('bank_transfer', 'Bank Transfer'),
|
|
]
|
|
|
|
name = models.CharField(max_length=255)
|
|
slug = models.SlugField(unique=True, max_length=255)
|
|
owner = models.ForeignKey(
|
|
'igny8_core_auth.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
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)], help_text="Plan credits (reset on renewal)")
|
|
bonus_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Purchased/bonus credits (never expire, never reset)")
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
|
payment_method = models.CharField(
|
|
max_length=30,
|
|
choices=PAYMENT_METHOD_CHOICES,
|
|
default='stripe',
|
|
help_text='Payment method used for this account'
|
|
)
|
|
deletion_retention_days = models.PositiveIntegerField(
|
|
default=14,
|
|
validators=[MinValueValidator(1), MaxValueValidator(365)],
|
|
help_text="Retention window (days) before soft-deleted items are purged",
|
|
)
|
|
|
|
# Billing information
|
|
billing_email = models.EmailField(blank=True, null=True, help_text="Email for billing notifications")
|
|
billing_address_line1 = models.CharField(max_length=255, blank=True, help_text="Street address")
|
|
billing_address_line2 = models.CharField(max_length=255, blank=True, help_text="Apt, suite, etc.")
|
|
billing_city = models.CharField(max_length=100, blank=True)
|
|
billing_state = models.CharField(max_length=100, blank=True, help_text="State/Province/Region")
|
|
billing_postal_code = models.CharField(max_length=20, blank=True)
|
|
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
|
|
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
|
|
|
# Account timezone (single source of truth for all users/sites)
|
|
account_timezone = models.CharField(max_length=64, default='UTC', help_text="IANA timezone name")
|
|
timezone_mode = models.CharField(
|
|
max_length=20,
|
|
choices=[('country', 'Country'), ('manual', 'Manual')],
|
|
default='country',
|
|
help_text="Timezone selection mode"
|
|
)
|
|
timezone_offset = models.CharField(max_length=10, blank=True, default='', help_text="Optional UTC offset label")
|
|
|
|
# Monthly usage tracking (reset on billing cycle)
|
|
usage_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used this month")
|
|
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
|
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
# History tracking
|
|
history = HistoricalRecords()
|
|
|
|
class Meta:
|
|
db_table = 'igny8_tenants'
|
|
verbose_name = 'Account'
|
|
verbose_name_plural = 'Accounts'
|
|
indexes = [
|
|
models.Index(fields=['slug']),
|
|
models.Index(fields=['status']),
|
|
]
|
|
|
|
objects = SoftDeleteManager()
|
|
all_objects = models.Manager()
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
@property
|
|
def total_credits(self):
|
|
"""Total available credits (plan + bonus). Use this for balance checks."""
|
|
return self.credits + self.bonus_credits
|
|
|
|
@property
|
|
def default_payment_method(self):
|
|
"""Get default payment method from AccountPaymentMethod table"""
|
|
try:
|
|
from igny8_core.business.billing.models import AccountPaymentMethod
|
|
method = AccountPaymentMethod.objects.filter(
|
|
account=self,
|
|
is_default=True,
|
|
is_enabled=True
|
|
).first()
|
|
return method.type if method else self.payment_method
|
|
except Exception:
|
|
# Fallback to field if table doesn't exist or error
|
|
return self.payment_method
|
|
|
|
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']
|
|
|
|
def soft_delete(self, user=None, reason=None, retention_days=None, cascade=True):
|
|
"""
|
|
Soft delete the account and optionally cascade to all related objects.
|
|
Args:
|
|
user: User performing the deletion
|
|
reason: Reason for deletion
|
|
retention_days: Days before permanent deletion
|
|
cascade: If True, also soft-delete related objects that support soft delete,
|
|
and hard-delete objects that don't support soft delete
|
|
"""
|
|
if self.is_system_account():
|
|
from django.core.exceptions import PermissionDenied
|
|
raise PermissionDenied("System account cannot be deleted.")
|
|
|
|
if cascade:
|
|
self._cascade_delete_related(user=user, reason=reason, retention_days=retention_days, hard_delete=False)
|
|
|
|
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
|
|
|
def _cascade_delete_related(self, user=None, reason=None, retention_days=None, hard_delete=False):
|
|
"""
|
|
Delete all related objects when account is deleted.
|
|
For soft delete: soft-deletes objects with SoftDeletableModel, hard-deletes others
|
|
For hard delete: hard-deletes everything
|
|
"""
|
|
from igny8_core.common.soft_delete import SoftDeletableModel
|
|
|
|
# List of related objects to delete (in order to avoid FK constraint issues)
|
|
# Related names from Account reverse relations
|
|
related_names = [
|
|
# Content & Planning related (delete first due to dependencies)
|
|
'contentclustermap_set',
|
|
'contentattribute_set',
|
|
'contenttaxonomy_set',
|
|
'content_set',
|
|
'images_set',
|
|
'contentideas_set',
|
|
'tasks_set',
|
|
'keywords_set',
|
|
'clusters_set',
|
|
'strategy_set',
|
|
# Automation
|
|
'automation_runs',
|
|
'automation_configs',
|
|
# Publishing & Integration
|
|
'syncevent_set',
|
|
'publishingsettings_set',
|
|
'publishingrecord_set',
|
|
'deploymentrecord_set',
|
|
'siteintegration_set',
|
|
# Notifications & Optimization
|
|
'notification_set',
|
|
'optimizationtask_set',
|
|
# AI & Settings
|
|
'aitasklog_set',
|
|
'aiprompt_set',
|
|
'aisettings_set',
|
|
'authorprofile_set',
|
|
# Billing (preserve invoices/payments for audit, delete others)
|
|
'planlimitusage_set',
|
|
'creditusagelog_set',
|
|
'credittransaction_set',
|
|
'accountpaymentmethod_set',
|
|
'payment_set',
|
|
'invoice_set',
|
|
# Settings
|
|
'modulesettings_set',
|
|
'moduleenablesettings_set',
|
|
'integrationsettings_set',
|
|
'user_settings',
|
|
'accountsettings_set',
|
|
# Core (last due to dependencies)
|
|
'sector_set',
|
|
'site_set',
|
|
# Users (delete after sites to avoid FK issues, owner is SET_NULL)
|
|
'users',
|
|
# Subscription (OneToOne)
|
|
'subscription',
|
|
]
|
|
|
|
for related_name in related_names:
|
|
try:
|
|
related = getattr(self, related_name, None)
|
|
if related is None:
|
|
continue
|
|
|
|
# Handle OneToOne fields (subscription)
|
|
if hasattr(related, 'pk'):
|
|
# It's a single object (OneToOneField)
|
|
if hard_delete:
|
|
related.hard_delete() if hasattr(related, 'hard_delete') else related.delete()
|
|
elif isinstance(related, SoftDeletableModel):
|
|
related.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
|
else:
|
|
# Non-soft-deletable single object - hard delete
|
|
related.delete()
|
|
else:
|
|
# It's a RelatedManager (ForeignKey)
|
|
queryset = related.all()
|
|
if queryset.exists():
|
|
if hard_delete:
|
|
# Hard delete all
|
|
if hasattr(queryset, 'hard_delete'):
|
|
queryset.hard_delete()
|
|
else:
|
|
for obj in queryset:
|
|
if hasattr(obj, 'hard_delete'):
|
|
obj.hard_delete()
|
|
else:
|
|
obj.delete()
|
|
else:
|
|
# Soft delete if supported, otherwise hard delete
|
|
model = queryset.model
|
|
if issubclass(model, SoftDeletableModel):
|
|
for obj in queryset:
|
|
obj.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
|
else:
|
|
queryset.delete()
|
|
except Exception as e:
|
|
# Log but don't fail - some relations may not exist
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(f"Failed to delete related {related_name} for account {self.pk}: {e}")
|
|
|
|
def hard_delete_with_cascade(self, using=None, keep_parents=False):
|
|
"""
|
|
Permanently delete the account and ALL related objects.
|
|
This bypasses soft-delete and removes everything from the database.
|
|
USE WITH CAUTION - this cannot be undone!
|
|
"""
|
|
if self.is_system_account():
|
|
from django.core.exceptions import PermissionDenied
|
|
raise PermissionDenied("System account cannot be deleted.")
|
|
|
|
# Clear owner reference first to avoid FK constraint issues
|
|
# (owner is SET_NULL but we're deleting the user who is the owner)
|
|
if self.owner:
|
|
self.owner = None
|
|
self.save(update_fields=['owner'])
|
|
|
|
# Cascade hard-delete all related objects first
|
|
self._cascade_delete_related(hard_delete=True)
|
|
|
|
# Finally hard-delete the account itself
|
|
return super().hard_delete(using=using, keep_parents=keep_parents)
|
|
|
|
def delete(self, using=None, keep_parents=False):
|
|
return self.soft_delete()
|
|
|
|
|
|
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)
|
|
original_price = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
null=True,
|
|
blank=True,
|
|
help_text="Original price (before discount) - shows as crossed out price. Leave empty if no discount."
|
|
)
|
|
billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly')
|
|
annual_discount_percent = models.IntegerField(
|
|
default=15,
|
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
|
help_text="Annual subscription discount percentage (default 15%)"
|
|
)
|
|
is_featured = models.BooleanField(default=False, help_text="Highlight this plan as popular/recommended")
|
|
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)
|
|
is_internal = models.BooleanField(default=False, help_text="Internal-only plan (Free/Internal) - hidden from public plan listings")
|
|
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")
|
|
|
|
# Hard Limits (Persistent - user manages within limit)
|
|
max_keywords = models.IntegerField(
|
|
default=1000,
|
|
validators=[MinValueValidator(1)],
|
|
help_text="Maximum total keywords allowed (hard limit)"
|
|
)
|
|
|
|
# Monthly Limits (Reset on billing cycle)
|
|
max_ahrefs_queries = models.IntegerField(
|
|
default=0,
|
|
validators=[MinValueValidator(0)],
|
|
help_text="Monthly Ahrefs keyword research queries (0 = disabled)"
|
|
)
|
|
|
|
# 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 supporting multiple payment methods.
|
|
"""
|
|
STATUS_CHOICES = [
|
|
('active', 'Active'),
|
|
('past_due', 'Past Due'),
|
|
('canceled', 'Canceled'),
|
|
('trialing', 'Trialing'),
|
|
('pending_payment', 'Pending Payment'),
|
|
]
|
|
|
|
PAYMENT_METHOD_CHOICES = [
|
|
('stripe', 'Stripe'),
|
|
('paypal', 'PayPal'),
|
|
('bank_transfer', 'Bank Transfer'),
|
|
]
|
|
|
|
account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id')
|
|
plan = models.ForeignKey(
|
|
'igny8_core_auth.Plan',
|
|
on_delete=models.PROTECT,
|
|
related_name='subscriptions',
|
|
help_text='Subscription plan (tracks historical plan even if account changes plan)'
|
|
)
|
|
stripe_subscription_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
db_index=True,
|
|
help_text='Stripe subscription ID (when using Stripe)'
|
|
)
|
|
external_payment_id = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
help_text='External payment reference (bank transfer ref, PayPal transaction ID)'
|
|
)
|
|
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)
|
|
|
|
@property
|
|
def payment_method(self):
|
|
"""Get payment method from account's default payment method"""
|
|
if hasattr(self.account, 'default_payment_method'):
|
|
return self.account.default_payment_method
|
|
# Fallback to account.payment_method field if property doesn't exist yet
|
|
return getattr(self.account, 'payment_method', 'stripe')
|
|
|
|
class Meta:
|
|
db_table = 'igny8_subscriptions'
|
|
indexes = [
|
|
models.Index(fields=['status']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.account.name} - {self.status}"
|
|
|
|
|
|
|
|
class Site(SoftDeletableModel, 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',
|
|
help_text="Industry this site belongs to (required for sector creation)"
|
|
)
|
|
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"
|
|
)
|
|
|
|
objects = SoftDeleteManager()
|
|
all_objects = models.Manager()
|
|
|
|
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()
|
|
|
|
def soft_delete(self, user=None, reason=None, retention_days=None, cascade=True):
|
|
"""
|
|
Soft delete site and optionally cascade to all related objects.
|
|
|
|
Args:
|
|
user: User performing the deletion
|
|
reason: Reason for deletion
|
|
retention_days: Days to retain before permanent deletion
|
|
cascade: If True, cascade soft-delete to all related objects
|
|
"""
|
|
if cascade:
|
|
self._cascade_delete_related(user=user, reason=reason, retention_days=retention_days, hard_delete=False)
|
|
|
|
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
|
|
|
def _cascade_delete_related(self, user=None, reason=None, retention_days=None, hard_delete=False):
|
|
"""
|
|
Delete all related objects when site is deleted.
|
|
For soft delete: soft-deletes objects with SoftDeletableModel, hard-deletes others
|
|
For hard delete: hard-deletes everything
|
|
"""
|
|
from igny8_core.common.soft_delete import SoftDeletableModel
|
|
|
|
# List of related objects to delete (in order to avoid FK constraint issues)
|
|
related_names = [
|
|
# Content & Planning related (delete first due to dependencies)
|
|
'contentclustermap_set',
|
|
'contentattribute_set',
|
|
'contenttaxonomy_set',
|
|
'content_set',
|
|
'images_set',
|
|
'contentideas_set',
|
|
'tasks_set',
|
|
'keywords_set',
|
|
'clusters_set',
|
|
# Automation
|
|
'automation_runs',
|
|
'automation_config',
|
|
# Publishing & Integration
|
|
'sync_events',
|
|
'publishing_settings',
|
|
'publishingrecord_set',
|
|
'deploymentrecord_set',
|
|
'integrations',
|
|
# Notifications
|
|
'notifications',
|
|
# Settings & AI
|
|
# Core
|
|
'sectors',
|
|
'user_access',
|
|
]
|
|
|
|
for related_name in related_names:
|
|
try:
|
|
related = getattr(self, related_name, None)
|
|
if related is None:
|
|
continue
|
|
|
|
# Handle OneToOne fields
|
|
if hasattr(related, 'pk'):
|
|
# It's a single object (OneToOneField)
|
|
if hard_delete:
|
|
related.hard_delete() if hasattr(related, 'hard_delete') else related.delete()
|
|
elif isinstance(related, SoftDeletableModel):
|
|
related.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
|
else:
|
|
# Non-soft-deletable single object - hard delete
|
|
related.delete()
|
|
else:
|
|
# It's a RelatedManager (ForeignKey)
|
|
queryset = related.all()
|
|
if queryset.exists():
|
|
if hard_delete:
|
|
# Hard delete all
|
|
if hasattr(queryset, 'hard_delete'):
|
|
queryset.hard_delete()
|
|
else:
|
|
for obj in queryset:
|
|
if hasattr(obj, 'hard_delete'):
|
|
obj.hard_delete()
|
|
else:
|
|
obj.delete()
|
|
else:
|
|
# Soft delete if supported, otherwise hard delete
|
|
model = queryset.model
|
|
if issubclass(model, SoftDeletableModel):
|
|
for obj in queryset:
|
|
obj.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
|
else:
|
|
queryset.delete()
|
|
except Exception as e:
|
|
# Log but don't fail - some relations may not exist
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning(f"Failed to delete related {related_name} for site {self.pk}: {e}")
|
|
|
|
def hard_delete_with_cascade(self, using=None, keep_parents=False):
|
|
"""
|
|
Permanently delete the site and ALL related objects.
|
|
This bypasses soft-delete and removes everything from the database.
|
|
USE WITH CAUTION - this cannot be undone!
|
|
"""
|
|
# Cascade hard-delete all related objects first
|
|
self._cascade_delete_related(hard_delete=True)
|
|
|
|
# Finally hard-delete the site itself
|
|
return super().hard_delete(using=using, keep_parents=keep_parents)
|
|
|
|
|
|
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.
|
|
"""
|
|
COUNTRY_CHOICES = [
|
|
('US', 'United States'),
|
|
('CA', 'Canada'),
|
|
('GB', 'United Kingdom'),
|
|
('AE', 'United Arab Emirates'),
|
|
('AU', 'Australia'),
|
|
('IN', 'India'),
|
|
('PK', 'Pakistan'),
|
|
]
|
|
|
|
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)'
|
|
)
|
|
country = models.CharField(max_length=2, choices=COUNTRY_CHOICES, default='US', help_text='Target country for this keyword')
|
|
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 = 'Global Keywords Database'
|
|
indexes = [
|
|
models.Index(fields=['keyword']),
|
|
models.Index(fields=['industry', 'sector']),
|
|
models.Index(fields=['industry', 'sector', 'is_active']),
|
|
models.Index(fields=['country']),
|
|
]
|
|
ordering = ['keyword']
|
|
|
|
def __str__(self):
|
|
return f"{self.keyword} ({self.industry.name} - {self.sector.name})"
|
|
|
|
|
|
class Sector(SoftDeletableModel, 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)
|
|
|
|
objects = SoftDeleteManager()
|
|
all_objects = models.Manager()
|
|
|
|
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)
|
|
phone = models.CharField(max_length=30, blank=True, default='')
|
|
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."""
|
|
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."""
|
|
try:
|
|
if not self.account:
|
|
return Site.objects.none()
|
|
|
|
base_sites = Site.objects.filter(account=self.account)
|
|
|
|
if self.role in ['owner', 'admin', 'developer'] or self.is_superuser or self.is_system_account_user():
|
|
return base_sites
|
|
|
|
# Other users can only access sites explicitly granted via SiteUserAccess
|
|
return base_sites.filter(user_access__user=self).distinct()
|
|
except (AttributeError, Exception):
|
|
# If account access fails (e.g., column mismatch), return empty queryset
|
|
return Site.objects.none()
|
|
|