asdasd
This commit is contained in:
174
tenant-temp/backend/igny8_core/auth/middleware.py
Normal file
174
tenant-temp/backend/igny8_core/auth/middleware.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Multi-Account Middleware
|
||||
Extracts account from JWT token and injects into request context
|
||||
"""
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.http import JsonResponse
|
||||
from django.contrib.auth import logout
|
||||
from rest_framework import status
|
||||
|
||||
try:
|
||||
import jwt
|
||||
JWT_AVAILABLE = True
|
||||
except ImportError:
|
||||
JWT_AVAILABLE = False
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class AccountContextMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Middleware that extracts account information from JWT token
|
||||
and adds it to request context for account isolation.
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Extract account from JWT token in Authorization header or session."""
|
||||
# Skip for admin and auth endpoints
|
||||
if request.path.startswith('/admin/') or request.path.startswith('/api/v1/auth/'):
|
||||
return None
|
||||
|
||||
# First, try to get user from Django session (cookie-based auth)
|
||||
# This handles cases where frontend uses credentials: 'include' with session cookies
|
||||
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||
# User is authenticated via session - refresh from DB to get latest account/plan data
|
||||
# This ensures changes to account/plan are reflected immediately without re-login
|
||||
try:
|
||||
from .models import User as UserModel
|
||||
# Refresh user from DB with account and plan relationships to get latest data
|
||||
# This is important so account/plan changes are reflected immediately
|
||||
user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
||||
# Update request.user with fresh data
|
||||
request.user = user
|
||||
# Get account from refreshed user
|
||||
user_account = getattr(user, 'account', None)
|
||||
validation_error = self._validate_account_and_plan(request, user)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
request.account = getattr(user, 'account', None)
|
||||
return None
|
||||
except (AttributeError, UserModel.DoesNotExist, Exception):
|
||||
# If refresh fails, fallback to cached account
|
||||
try:
|
||||
user_account = getattr(request.user, 'account', None)
|
||||
if user_account:
|
||||
validation_error = self._validate_account_and_plan(request, request.user)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
request.account = user_account
|
||||
return None
|
||||
except (AttributeError, Exception):
|
||||
pass
|
||||
# If account access fails (e.g., column mismatch), set to None
|
||||
request.account = None
|
||||
return None
|
||||
|
||||
# Get token from Authorization header (JWT auth - for future implementation)
|
||||
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
# No JWT token - if session auth didn't work, set account to None
|
||||
# But don't set request.user to None - it might be set by Django's auth middleware
|
||||
if not hasattr(request, 'account'):
|
||||
request.account = None
|
||||
return None
|
||||
|
||||
token = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None
|
||||
if not token:
|
||||
if not hasattr(request, 'account'):
|
||||
request.account = None
|
||||
return None
|
||||
|
||||
try:
|
||||
if not JWT_AVAILABLE:
|
||||
# JWT library not installed yet - skip for now
|
||||
request.account = None
|
||||
return None
|
||||
|
||||
# Decode JWT token with signature verification
|
||||
# Use JWT_SECRET_KEY from settings (falls back to SECRET_KEY if not set)
|
||||
jwt_secret = getattr(settings, 'JWT_SECRET_KEY', getattr(settings, 'SECRET_KEY', None))
|
||||
if not jwt_secret:
|
||||
raise ValueError("JWT_SECRET_KEY or SECRET_KEY must be set in settings")
|
||||
|
||||
decoded = jwt.decode(token, jwt_secret, algorithms=[getattr(settings, 'JWT_ALGORITHM', 'HS256')])
|
||||
|
||||
# Extract user and account info from token
|
||||
user_id = decoded.get('user_id')
|
||||
account_id = decoded.get('account_id')
|
||||
|
||||
if user_id:
|
||||
from .models import User, Account
|
||||
try:
|
||||
# Get user from DB (but don't set request.user - let DRF authentication handle that)
|
||||
# Only set request.account for account context
|
||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||
validation_error = self._validate_account_and_plan(request, user)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
if account_id:
|
||||
# Verify account still exists
|
||||
try:
|
||||
account = Account.objects.get(id=account_id)
|
||||
request.account = account
|
||||
except Account.DoesNotExist:
|
||||
# Account from token doesn't exist - don't fallback, set to None
|
||||
request.account = None
|
||||
else:
|
||||
# No account_id in token - set to None (don't fallback to user.account)
|
||||
request.account = None
|
||||
except (User.DoesNotExist, Account.DoesNotExist):
|
||||
request.account = None
|
||||
else:
|
||||
request.account = None
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
request.account = None
|
||||
except Exception:
|
||||
# Fail silently for now - allow unauthenticated access
|
||||
request.account = None
|
||||
|
||||
return None
|
||||
|
||||
def _validate_account_and_plan(self, request, user):
|
||||
"""
|
||||
Ensure the authenticated user has an account and an active plan.
|
||||
If not, logout the user (for session auth) and block the request.
|
||||
"""
|
||||
try:
|
||||
account = getattr(user, 'account', None)
|
||||
except Exception:
|
||||
account = None
|
||||
|
||||
if not account:
|
||||
return self._deny_request(
|
||||
request,
|
||||
error='Account not configured for this user. Please contact support.',
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
plan = getattr(account, 'plan', None)
|
||||
if plan is None or getattr(plan, 'is_active', False) is False:
|
||||
return self._deny_request(
|
||||
request,
|
||||
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _deny_request(self, request, error, status_code):
|
||||
"""Logout session users (if any) and return a consistent JSON error."""
|
||||
try:
|
||||
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||
logout(request)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
'success': False,
|
||||
'error': error,
|
||||
},
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
634
tenant-temp/backend/igny8_core/auth/models.py
Normal file
634
tenant-temp/backend/igny8_core/auth/models.py
Normal file
@@ -0,0 +1,634 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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'),
|
||||
]
|
||||
|
||||
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)])
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
|
||||
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")
|
||||
|
||||
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']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
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']
|
||||
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||
if self.is_system_account():
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("System account cannot be deleted.")
|
||||
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
|
||||
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)
|
||||
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(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',
|
||||
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"
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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 = 'Global Keywords Database'
|
||||
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(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)
|
||||
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, is_active=True)
|
||||
|
||||
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()
|
||||
|
||||
77
tenant-temp/backend/igny8_core/auth/permissions.py
Normal file
77
tenant-temp/backend/igny8_core/auth/permissions.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Role-Based Access Control (RBAC) Permissions
|
||||
"""
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class IsOwnerOrAdmin(permissions.BasePermission):
|
||||
"""Allow access only to owners and admins."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if getattr(user, "is_superuser", False):
|
||||
return True
|
||||
return user.role in ['owner', 'admin', 'developer']
|
||||
|
||||
|
||||
class IsEditorOrAbove(permissions.BasePermission):
|
||||
"""Allow access to editors, admins, and owners."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if getattr(user, "is_superuser", False):
|
||||
return True
|
||||
return user.role in ['owner', 'admin', 'editor', 'developer']
|
||||
|
||||
|
||||
class IsViewerOrAbove(permissions.BasePermission):
|
||||
"""Allow access to all authenticated users."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class AccountPermission(permissions.BasePermission):
|
||||
"""Ensure user belongs to the account being accessed."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# System bots can access all accounts
|
||||
if request.user.role == 'system_bot':
|
||||
return True
|
||||
|
||||
# Users must have an account
|
||||
user_account = getattr(request.user, 'account', None)
|
||||
if not user_account:
|
||||
return False
|
||||
|
||||
# For now, allow access if user has account (will be refined with object-level checks)
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# System bots can access all
|
||||
if request.user.role == 'system_bot':
|
||||
return True
|
||||
|
||||
# Check if object has account and it matches user's account
|
||||
obj_account = getattr(obj, 'account', None)
|
||||
user_account = getattr(request.user, 'account', None)
|
||||
if obj_account:
|
||||
return obj_account == user_account
|
||||
|
||||
# If no account on object, allow (for non-account models)
|
||||
return True
|
||||
|
||||
|
||||
1523
tenant-temp/backend/igny8_core/auth/views.py
Normal file
1523
tenant-temp/backend/igny8_core/auth/views.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user