Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
"""
IGNY8 Authentication & Multi-Tenancy Module
"""

View File

@@ -0,0 +1,272 @@
"""
Admin interface for auth models
"""
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from igny8_core.admin.base import AccountAdminMixin
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
"""Plan admin - Global, no account filtering needed"""
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'included_credits', 'is_active']
list_filter = ['is_active', 'billing_cycle']
search_fields = ['name', 'slug']
readonly_fields = ['created_at']
fieldsets = (
('Plan Info', {
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
}),
('User / Site Limits', {
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
}),
('Planner Limits', {
'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits')
}),
('Writer Limits', {
'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits')
}),
('Image Limits', {
'fields': ('monthly_image_count', 'monthly_image_ai_credits', 'max_images_per_task', 'image_model_choices')
}),
('AI Controls', {
'fields': ('daily_ai_request_limit', 'monthly_ai_credit_limit')
}),
('Billing & Credits', {
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
}),
('Stripe Integration', {
'fields': ('stripe_product_id', 'stripe_price_id')
}),
)
@admin.register(Account)
class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['name', 'slug', 'owner', 'plan', 'status', 'credits', 'created_at']
list_filter = ['status', 'plan']
search_fields = ['name', 'slug']
readonly_fields = ['created_at', 'updated_at']
def get_queryset(self, request):
"""Override to filter by account for non-superusers"""
qs = super().get_queryset(request)
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return qs
# Owners can see their own accounts
if hasattr(request.user, 'role') and request.user.role == 'owner':
return qs.filter(owner=request.user)
# Admins can see their account
try:
user_account = getattr(request.user, 'account', None)
if user_account:
return qs.filter(id=user_account.id)
except (AttributeError, Exception):
# If account access fails (e.g., column mismatch), return empty
pass
return qs.none()
@admin.register(Subscription)
class SubscriptionAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['account', 'status', 'current_period_start', 'current_period_end']
list_filter = ['status']
search_fields = ['account__name', 'stripe_subscription_id']
readonly_fields = ['created_at', 'updated_at']
@admin.register(PasswordResetToken)
class PasswordResetTokenAdmin(admin.ModelAdmin):
list_display = ['user', 'token', 'used', 'expires_at', 'created_at']
list_filter = ['used', 'expires_at', 'created_at']
search_fields = ['user__email', 'token']
readonly_fields = ['created_at', 'token']
def get_queryset(self, request):
"""Filter by account for non-superusers"""
qs = super().get_queryset(request)
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return qs
user_account = getattr(request.user, 'account', None)
if user_account:
return qs.filter(user__account=user_account)
return qs.none()
class SectorInline(admin.TabularInline):
"""Inline admin for sectors within Site admin."""
model = Sector
extra = 0
fields = ['industry_sector', 'name', 'slug', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count']
readonly_fields = ['get_keywords_count', 'get_clusters_count']
def get_keywords_count(self, obj):
if obj.pk:
return getattr(obj, 'keywords_set', obj.keywords_set).count()
return 0
get_keywords_count.short_description = 'Keywords'
def get_clusters_count(self, obj):
if obj.pk:
return getattr(obj, 'clusters_set', obj.clusters_set).count()
return 0
get_clusters_count.short_description = 'Clusters'
@admin.register(Site)
class SiteAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_sectors_count']
list_filter = ['status', 'is_active', 'account', 'industry']
search_fields = ['name', 'slug', 'domain', 'industry__name']
readonly_fields = ['created_at', 'updated_at']
inlines = [SectorInline]
def get_sectors_count(self, obj):
try:
return obj.get_active_sectors_count()
except:
return 0
get_sectors_count.short_description = 'Active Sectors'
def get_industry_display(self, obj):
"""Safely get industry name"""
try:
return obj.industry.name if obj.industry else '-'
except:
return '-'
get_industry_display.short_description = 'Industry'
@admin.register(Sector)
class SectorAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count']
list_filter = ['status', 'is_active', 'site', 'industry_sector__industry']
search_fields = ['name', 'slug', 'site__name', 'industry_sector__name']
readonly_fields = ['created_at', 'updated_at']
def get_industry(self, obj):
"""Safely get industry name"""
try:
if obj.industry_sector and obj.industry_sector.industry:
return obj.industry_sector.industry.name
except:
pass
return '-'
get_industry.short_description = 'Industry'
def get_keywords_count(self, obj):
"""Safely get keywords count"""
try:
if obj.pk:
return getattr(obj, 'keywords_set', obj.keywords_set).count()
except:
pass
return 0
get_keywords_count.short_description = 'Keywords'
def get_clusters_count(self, obj):
"""Safely get clusters count"""
try:
if obj.pk:
return getattr(obj, 'clusters_set', obj.clusters_set).count()
except:
pass
return 0
get_clusters_count.short_description = 'Clusters'
@admin.register(SiteUserAccess)
class SiteUserAccessAdmin(admin.ModelAdmin):
list_display = ['user', 'site', 'granted_at', 'granted_by']
list_filter = ['granted_at']
search_fields = ['user__email', 'site__name']
readonly_fields = ['granted_at']
class IndustrySectorInline(admin.TabularInline):
"""Inline admin for industry sectors within Industry admin."""
model = IndustrySector
extra = 0
fields = ['name', 'slug', 'description', 'is_active']
readonly_fields = []
@admin.register(Industry)
class IndustryAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'is_active', 'get_sectors_count', 'created_at']
list_filter = ['is_active']
search_fields = ['name', 'slug', 'description']
readonly_fields = ['created_at', 'updated_at']
inlines = [IndustrySectorInline]
def get_sectors_count(self, obj):
return obj.sectors.filter(is_active=True).count()
get_sectors_count.short_description = 'Active Sectors'
@admin.register(IndustrySector)
class IndustrySectorAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'industry', 'is_active']
list_filter = ['is_active', 'industry']
search_fields = ['name', 'slug', 'description']
readonly_fields = ['created_at', 'updated_at']
@admin.register(SeedKeyword)
class SeedKeywordAdmin(admin.ModelAdmin):
"""SeedKeyword admin - Global reference data, no account filtering"""
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'intent', 'is_active', 'created_at']
list_filter = ['is_active', 'industry', 'sector', 'intent']
search_fields = ['keyword']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Keyword Info', {
'fields': ('keyword', 'industry', 'sector', 'is_active')
}),
('SEO Metrics', {
'fields': ('volume', 'difficulty', 'intent')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
@admin.register(User)
class UserAdmin(BaseUserAdmin):
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at']
list_filter = ['role', 'account', 'is_active', 'is_staff']
search_fields = ['email', 'username']
readonly_fields = ['created_at', 'updated_at']
fieldsets = BaseUserAdmin.fieldsets + (
('IGNY8 Info', {'fields': ('account', 'role')}),
('Timestamps', {'fields': ('created_at', 'updated_at')}),
)
add_fieldsets = BaseUserAdmin.add_fieldsets + (
('IGNY8 Info', {'fields': ('account', 'role')}),
)
def get_queryset(self, request):
"""Filter users by account for non-superusers"""
qs = super().get_queryset(request)
if request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer()):
return qs
user_account = getattr(request.user, 'account', None)
if user_account:
return qs.filter(account=user_account)
return qs.none()
def get_account_display(self, obj):
"""Safely get account name"""
try:
account = getattr(obj, 'account', None)
return account.name if account else '-'
except:
return '-'
get_account_display.short_description = 'Account'

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class Igny8CoreAuthConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.auth'
label = 'igny8_core_auth' # Custom label to avoid conflict with django.contrib.auth

View File

@@ -0,0 +1,2 @@
# Management module

View File

@@ -0,0 +1,2 @@
# Management commands module

View File

@@ -0,0 +1,297 @@
"""
Django management command to delete accounts and reassign their data to AWS Admin account
Usage: python manage.py cleanup_accounts
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from igny8_core.auth.models import Account, User, Site, Sector
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
from igny8_core.modules.writer.models import Tasks, Images, Content
from igny8_core.modules.billing.models import CreditTransaction, CreditUsageLog
from igny8_core.modules.system.models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
from igny8_core.modules.system.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings
class Command(BaseCommand):
help = 'Delete specified accounts and reassign all their data to AWS Admin account'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without actually doing it',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN MODE - No changes will be made'))
# Get AWS Admin account
try:
aws_admin_account = Account.objects.get(slug='aws-admin')
self.stdout.write(self.style.SUCCESS(f'✅ Found AWS Admin account: {aws_admin_account.name} (ID: {aws_admin_account.id})'))
except Account.DoesNotExist:
self.stdout.write(self.style.ERROR('❌ AWS Admin account not found. Please create it first.'))
return
# Get AWS Admin user (developer role)
aws_admin_user = User.objects.filter(
account=aws_admin_account,
role='developer'
).first()
if not aws_admin_user:
# Try to get any user in aws-admin account
aws_admin_user = User.objects.filter(account=aws_admin_account).first()
if not aws_admin_user:
self.stdout.write(self.style.ERROR('❌ No user found in AWS Admin account. Please create a user first.'))
return
self.stdout.write(self.style.SUCCESS(f'✅ Using AWS Admin user: {aws_admin_user.email} (ID: {aws_admin_user.id})'))
# Accounts to delete (from the image)
accounts_to_delete = [
'test-user',
'test-account-test-user',
'default',
'salman-sadiq',
]
accounts_found = []
accounts_not_found = []
for slug in accounts_to_delete:
try:
account = Account.objects.get(slug=slug)
accounts_found.append(account)
self.stdout.write(f' - Found account: {account.name} (slug: {slug}, ID: {account.id})')
except Account.DoesNotExist:
accounts_not_found.append(slug)
self.stdout.write(self.style.WARNING(f' - Account not found: {slug}'))
if accounts_not_found:
self.stdout.write(self.style.WARNING(f'\n⚠️ {len(accounts_not_found)} account(s) not found: {", ".join(accounts_not_found)}'))
if not accounts_found:
self.stdout.write(self.style.ERROR('❌ No accounts found to delete.'))
return
self.stdout.write(f'\n📊 Summary:')
self.stdout.write(f' - AWS Admin account: {aws_admin_account.name} (ID: {aws_admin_account.id})')
self.stdout.write(f' - Accounts to delete: {len(accounts_found)}')
if dry_run:
self.stdout.write(self.style.WARNING('\n🔍 DRY RUN - Counting items to reassign:'))
else:
self.stdout.write(self.style.WARNING('\n⚠️ Starting reassignment and deletion...'))
with transaction.atomic():
total_reassigned = 0
for account in accounts_found:
self.stdout.write(f'\n📦 Processing account: {account.name} (ID: {account.id})')
# Count and reassign Sites
sites_count = Site.objects.filter(account=account).count()
if sites_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {sites_count} site(s)')
else:
Site.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {sites_count} site(s)'))
total_reassigned += sites_count
# Count and reassign Sectors
sectors_count = Sector.objects.filter(account=account).count()
if sectors_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {sectors_count} sector(s)')
else:
Sector.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {sectors_count} sector(s)'))
total_reassigned += sectors_count
# Count and reassign Keywords
keywords_count = Keywords.objects.filter(account=account).count()
if keywords_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {keywords_count} keyword(s)')
else:
Keywords.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {keywords_count} keyword(s)'))
total_reassigned += keywords_count
# Count and reassign Clusters
clusters_count = Clusters.objects.filter(account=account).count()
if clusters_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {clusters_count} cluster(s)')
else:
Clusters.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {clusters_count} cluster(s)'))
total_reassigned += clusters_count
# Count and reassign ContentIdeas
ideas_count = ContentIdeas.objects.filter(account=account).count()
if ideas_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {ideas_count} content idea(s)')
else:
ContentIdeas.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {ideas_count} content idea(s)'))
total_reassigned += ideas_count
# Count and reassign Tasks
tasks_count = Tasks.objects.filter(account=account).count()
if tasks_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {tasks_count} task(s)')
else:
Tasks.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {tasks_count} task(s)'))
total_reassigned += tasks_count
# Count and reassign Images
images_count = Images.objects.filter(account=account).count()
if images_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {images_count} image(s)')
else:
Images.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {images_count} image(s)'))
total_reassigned += images_count
# Count and reassign Content
content_count = Content.objects.filter(account=account).count()
if content_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {content_count} content record(s)')
else:
Content.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {content_count} content record(s)'))
total_reassigned += content_count
# Count and reassign CreditTransactions
transactions_count = CreditTransaction.objects.filter(account=account).count()
if transactions_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {transactions_count} credit transaction(s)')
else:
CreditTransaction.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {transactions_count} credit transaction(s)'))
total_reassigned += transactions_count
# Count and reassign CreditUsageLog
usage_logs_count = CreditUsageLog.objects.filter(account=account).count()
if usage_logs_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {usage_logs_count} usage log(s)')
else:
CreditUsageLog.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {usage_logs_count} usage log(s)'))
total_reassigned += usage_logs_count
# Count and reassign AIPrompt
prompts_count = AIPrompt.objects.filter(account=account).count()
if prompts_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {prompts_count} AI prompt(s)')
else:
AIPrompt.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {prompts_count} AI prompt(s)'))
total_reassigned += prompts_count
# Count and reassign IntegrationSettings
integrations_count = IntegrationSettings.objects.filter(account=account).count()
if integrations_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {integrations_count} integration setting(s)')
else:
IntegrationSettings.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {integrations_count} integration setting(s)'))
total_reassigned += integrations_count
# Count and reassign AuthorProfile
profiles_count = AuthorProfile.objects.filter(account=account).count()
if profiles_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {profiles_count} author profile(s)')
else:
AuthorProfile.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {profiles_count} author profile(s)'))
total_reassigned += profiles_count
# Count and reassign Strategy
strategies_count = Strategy.objects.filter(account=account).count()
if strategies_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {strategies_count} strategy(ies)')
else:
Strategy.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {strategies_count} strategy(ies)'))
total_reassigned += strategies_count
# Count and reassign AccountSettings
account_settings_count = AccountSettings.objects.filter(account=account).count()
if account_settings_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {account_settings_count} account setting(s)')
else:
AccountSettings.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {account_settings_count} account setting(s)'))
total_reassigned += account_settings_count
# Count and reassign ModuleSettings
module_settings_count = ModuleSettings.objects.filter(account=account).count()
if module_settings_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {module_settings_count} module setting(s)')
else:
ModuleSettings.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {module_settings_count} module setting(s)'))
total_reassigned += module_settings_count
# Count and reassign AISettings
ai_settings_count = AISettings.objects.filter(account=account).count()
if ai_settings_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {ai_settings_count} AI setting(s)')
else:
AISettings.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {ai_settings_count} AI setting(s)'))
total_reassigned += ai_settings_count
# Reassign Users to AWS Admin account
users_count = User.objects.filter(account=account).count()
if users_count > 0:
if dry_run:
self.stdout.write(f' - Would reassign {users_count} user(s) to AWS Admin account')
else:
User.objects.filter(account=account).update(account=aws_admin_account)
self.stdout.write(self.style.SUCCESS(f' ✅ Reassigned {users_count} user(s) to AWS Admin account'))
total_reassigned += users_count
# Delete the account
if not dry_run:
account_name = account.name
account_id = account.id
account.delete()
self.stdout.write(self.style.SUCCESS(f' ✅ Deleted account: {account_name} (ID: {account_id})'))
else:
self.stdout.write(f' - Would delete account: {account.name} (ID: {account.id})')
if dry_run:
self.stdout.write(self.style.WARNING(f'\n🔍 DRY RUN COMPLETE'))
self.stdout.write(f' - Total items that would be reassigned: {total_reassigned}')
self.stdout.write(f' - Accounts that would be deleted: {len(accounts_found)}')
self.stdout.write(self.style.WARNING('\n⚠️ Run without --dry-run to actually perform the operation'))
else:
self.stdout.write(self.style.SUCCESS(f'\n✅ CLEANUP COMPLETE'))
self.stdout.write(f' - Total items reassigned: {total_reassigned}')
self.stdout.write(f' - Accounts deleted: {len(accounts_found)}')
self.stdout.write(self.style.SUCCESS(f'\n✅ All data has been reassigned to AWS Admin account: {aws_admin_account.name}'))

View File

@@ -0,0 +1,134 @@
"""
Django management command to create aws-admin account and move developer/super admin users to it
Usage: python manage.py create_aws_admin_account
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from django.utils.text import slugify
from igny8_core.auth.models import Account, User, Plan
class Command(BaseCommand):
help = 'Create aws-admin account and move developer/super admin users to it'
def handle(self, *args, **options):
with transaction.atomic():
# Step 1: Get or create an Enterprise plan for the aws-admin account
# System accounts should have unlimited access via Enterprise plan
plan, created = Plan.objects.get_or_create(
slug='enterprise',
defaults={
'name': 'Enterprise Plan',
'price': 0.00,
'billing_cycle': 'monthly',
'max_users': 999999,
'max_sites': 999999,
'max_keywords': 999999,
'max_clusters': 999999,
'max_content_ideas': 999999,
'monthly_word_count_limit': 999999999,
'daily_content_tasks': 999999,
'daily_ai_requests': 999999,
'daily_ai_request_limit': 999999,
'monthly_ai_credit_limit': 999999,
'monthly_image_count': 999999,
'daily_image_generation_limit': 999999,
'monthly_cluster_ai_credits': 999999,
'monthly_content_ai_credits': 999999,
'monthly_image_ai_credits': 999999,
'included_credits': 999999,
'is_active': True,
'features': ['ai_writer', 'image_gen', 'auto_publish', 'custom_prompts', 'unlimited'],
}
)
if created:
self.stdout.write(self.style.SUCCESS(f'✅ Created Enterprise plan: {plan.name}'))
else:
self.stdout.write(f'Using existing Enterprise plan: {plan.name}')
# Step 2: Get the first superuser or developer to be the owner
# If no superuser exists, we'll need to create one or use the first user
owner = User.objects.filter(
Q(is_superuser=True) | Q(role='developer')
).first()
if not owner:
# Try to get any user
owner = User.objects.first()
if not owner:
self.stdout.write(self.style.ERROR('No users found. Please create a user first.'))
return
self.stdout.write(self.style.WARNING(f'No superuser/developer found. Using first user as owner: {owner.username}'))
else:
self.stdout.write(f'Using owner: {owner.username} (ID: {owner.id})')
# Step 3: Create or get aws-admin account
account, created = Account.objects.get_or_create(
slug='aws-admin',
defaults={
'name': 'AWS Admin',
'owner': owner,
'plan': plan,
'credits': 999999,
'status': 'active',
}
)
# Always ensure aws-admin account uses Enterprise plan (update if needed)
if account.plan != plan:
old_plan_name = account.plan.name if account.plan else 'None'
account.plan = plan
account.save()
self.stdout.write(self.style.SUCCESS(f'✅ Updated account plan from "{old_plan_name}" to "{plan.name}"'))
if created:
self.stdout.write(self.style.SUCCESS(f'✅ Created account: {account.name} (slug: {account.slug})'))
else:
self.stdout.write(f'Account "{account.name}" already exists, using existing one.')
# Step 4: Find all developer and super admin users
developer_users = User.objects.filter(
Q(role='developer') | Q(is_superuser=True)
).distinct()
moved_count = 0
skipped_count = 0
for user in developer_users:
if user.account == account:
skipped_count += 1
self.stdout.write(f' - User {user.username} ({user.email}) already in aws-admin account, skipping...')
continue
old_account = user.account
user.account = account
user.save()
moved_count += 1
old_account_name = old_account.name if old_account else 'None'
self.stdout.write(
self.style.SUCCESS(
f' ✅ Moved user {user.username} ({user.email}) from "{old_account_name}" to "{account.name}"'
)
)
# Summary
self.stdout.write('')
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(self.style.SUCCESS('Summary:'))
self.stdout.write(f' - Account: {account.name} (slug: {account.slug})')
self.stdout.write(f' - Users moved: {moved_count}')
self.stdout.write(f' - Users already in account: {skipped_count}')
self.stdout.write(f' - Total developer/super admin users: {developer_users.count()}')
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write('')
self.stdout.write(
self.style.SUCCESS(
'✅ All developer and super admin users have been moved to the aws-admin account.'
)
)
self.stdout.write(
' These users now have access to all data across all accounts without filtering.'
)

View File

@@ -0,0 +1,135 @@
"""
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 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)
if user_account:
request.account = user_account
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:
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
request.user = 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:
# Refresh user from DB with account and plan relationships to get latest data
# This ensures changes to account/plan are reflected immediately without re-login
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
request.user = user
if account_id:
# Verify account still exists and matches user
account = Account.objects.get(id=account_id)
# If user's account changed, use the new one from user object
if user.account and user.account.id != account_id:
request.account = user.account
else:
request.account = account
else:
try:
user_account = getattr(user, 'account', None)
if user_account:
request.account = user_account
else:
request.account = None
except (AttributeError, Exception):
# If account access fails (e.g., column mismatch), set to None
request.account = None
except (User.DoesNotExist, Account.DoesNotExist):
request.account = None
request.user = None
else:
request.account = None
request.user = None
except jwt.InvalidTokenError:
request.account = None
request.user = None
except Exception:
# Fail silently for now - allow unauthenticated access
request.account = None
request.user = None
return None

View File

@@ -0,0 +1,205 @@
# Generated by Django 5.2.7 on 2025-11-02 21:42
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='Plan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('credits_per_month', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('max_sites', models.IntegerField(default=1, help_text='Maximum number of sites allowed (1-10)', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])),
('features', models.JSONField(default=dict, help_text='Plan features as JSON')),
('stripe_price_id', models.CharField(blank=True, max_length=255, null=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'igny8_plans',
'ordering': ['price'],
},
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20)),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'db_table': 'igny8_users',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Tenant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('stripe_customer_id', models.CharField(blank=True, max_length=255, null=True)),
('credits', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('status', models.CharField(choices=[('active', 'Active'), ('suspended', 'Suspended'), ('trial', 'Trial'), ('cancelled', 'Cancelled')], default='trial', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='owned_tenants', to=settings.AUTH_USER_MODEL)),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tenants', to='igny8_core_auth.plan')),
],
options={
'db_table': 'igny8_tenants',
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_subscription_id', models.CharField(max_length=255, unique=True)),
('status', models.CharField(choices=[('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('trialing', 'Trialing')], max_length=20)),
('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)),
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_subscriptions',
},
),
migrations.CreateModel(
name='Site',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('domain', models.URLField(blank=True, help_text='Primary domain URL', null=True)),
('description', models.TextField(blank=True, null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('suspended', 'Suspended')], default='active', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('wp_url', models.URLField(blank=True, help_text='WordPress site URL', null=True)),
('wp_username', models.CharField(blank=True, max_length=255, null=True)),
('wp_app_password', models.CharField(blank=True, max_length=255, null=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_sites',
},
),
migrations.CreateModel(
name='Sector',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('description', models.TextField(blank=True, null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], default='active', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.site')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_sectors',
},
),
migrations.AddField(
model_name='user',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='igny8_core_auth.tenant'),
),
migrations.CreateModel(
name='SiteUserAccess',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('granted_at', models.DateTimeField(auto_now_add=True)),
('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='granted_site_accesses', to=settings.AUTH_USER_MODEL)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_access', to='igny8_core_auth.site')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='site_access', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'igny8_site_user_access',
'indexes': [models.Index(fields=['user', 'site'], name='igny8_site__user_id_61951e_idx')],
'unique_together': {('user', 'site')},
},
),
migrations.AddIndex(
model_name='tenant',
index=models.Index(fields=['slug'], name='igny8_tenan_slug_f25e97_idx'),
),
migrations.AddIndex(
model_name='tenant',
index=models.Index(fields=['status'], name='igny8_tenan_status_5dc02a_idx'),
),
migrations.AddIndex(
model_name='subscription',
index=models.Index(fields=['status'], name='igny8_subsc_status_2fa897_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['tenant', 'is_active'], name='igny8_sites_tenant__e0f31d_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['tenant', 'status'], name='igny8_sites_tenant__a20275_idx'),
),
migrations.AlterUniqueTogether(
name='site',
unique_together={('tenant', 'slug')},
),
migrations.AddIndex(
model_name='sector',
index=models.Index(fields=['site', 'is_active'], name='igny8_secto_site_id_76b3c7_idx'),
),
migrations.AddIndex(
model_name='sector',
index=models.Index(fields=['tenant', 'site'], name='igny8_secto_tenant__af54ae_idx'),
),
migrations.AlterUniqueTogether(
name='sector',
unique_together={('site', 'slug')},
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['tenant', 'role'], name='igny8_users_tenant__0ab02b_idx'),
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['email'], name='igny8_users_email_fd61ff_idx'),
),
]

View File

@@ -0,0 +1,13 @@
# Generated by Django 5.2.7 on 2025-11-02 22:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0001_initial'),
]
operations = [
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-03 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0002_add_developer_role'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.CharField(choices=[('developer', 'Developer / Super Admin'), ('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20),
),
]

View File

@@ -0,0 +1,75 @@
# Generated migration for Industry and IndustrySector models
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0003_alter_user_role'),
]
operations = [
migrations.CreateModel(
name='Industry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('slug', models.SlugField(db_index=True, max_length=255, unique=True)),
('description', models.TextField(blank=True, null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'igny8_industries',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='IndustrySector',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(db_index=True, max_length=255)),
('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(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.industry')),
],
options={
'db_table': 'igny8_industry_sectors',
'ordering': ['industry', 'name'],
'unique_together': {('industry', 'slug')},
},
),
migrations.AddField(
model_name='sector',
name='industry_sector',
field=models.ForeignKey(blank=True, help_text='Reference to the industry sector template', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='site_sectors', to='igny8_core_auth.industrysector'),
),
migrations.AddIndex(
model_name='industry',
index=models.Index(fields=['slug'], name='igny8_indu_slug_idx'),
),
migrations.AddIndex(
model_name='industry',
index=models.Index(fields=['is_active'], name='igny8_indu_is_acti_idx'),
),
migrations.AddIndex(
model_name='industrysector',
index=models.Index(fields=['industry', 'is_active'], name='igny8_indu_industr_idx'),
),
migrations.AddIndex(
model_name='industrysector',
index=models.Index(fields=['slug'], name='igny8_indu_slug_1_idx'),
),
migrations.AddIndex(
model_name='sector',
index=models.Index(fields=['industry_sector'], name='igny8_sect_industr_idx'),
),
]

View File

@@ -0,0 +1,31 @@
# Migration to add industry field to Site model
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0004_add_industry_models'),
]
operations = [
migrations.AddField(
model_name='site',
name='industry',
field=models.ForeignKey(
blank=True,
help_text='Industry this site belongs to',
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='sites',
to='igny8_core_auth.industry'
),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['industry'], name='igny8_site_industr_idx'),
),
]

View File

@@ -0,0 +1,151 @@
"""Add extended plan configuration fields"""
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0006_add_industry_to_site'),
]
operations = [
migrations.AddField(
model_name='plan',
name='ai_cost_per_request',
field=models.JSONField(default=dict, help_text="Cost per request type (e.g., {'cluster': 2, 'idea': 3, 'content': 5, 'image': 1})"),
),
migrations.AddField(
model_name='plan',
name='allow_credit_topup',
field=models.BooleanField(default=True, help_text='Can user purchase more credits?'),
),
migrations.AddField(
model_name='plan',
name='billing_cycle',
field=models.CharField(choices=[('monthly', 'Monthly'), ('annual', 'Annual')], default='monthly', max_length=20),
),
migrations.AddField(
model_name='plan',
name='daily_ai_request_limit',
field=models.IntegerField(default=100, help_text='Global daily AI request cap', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_ai_requests',
field=models.IntegerField(default=50, help_text='Total AI executions (content + idea + image) allowed per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_cluster_limit',
field=models.IntegerField(default=10, help_text='Max clusters that can be created per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_content_tasks',
field=models.IntegerField(default=10, help_text='Max number of content tasks (blogs) per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_keyword_import_limit',
field=models.IntegerField(default=100, help_text='SeedKeywords import limit per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='extra_credit_price',
field=models.DecimalField(decimal_places=2, default=Decimal('0.01'), help_text='Price per additional credit', max_digits=10),
),
migrations.AddField(
model_name='plan',
name='image_model_choices',
field=models.JSONField(default=list, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])"),
),
migrations.AddField(
model_name='plan',
name='included_credits',
field=models.IntegerField(default=0, help_text='Monthly credits included', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_author_profiles',
field=models.IntegerField(default=5, help_text='Limit for saved writing styles', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_clusters',
field=models.IntegerField(default=100, help_text='Total clusters allowed (global)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_images_per_task',
field=models.IntegerField(default=4, help_text='Max images per content task', validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_industries',
field=models.IntegerField(blank=True, default=None, help_text='Optional limit for industries/sectors', null=True, validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_keywords',
field=models.IntegerField(default=1000, help_text='Total keywords allowed (global limit)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_users',
field=models.IntegerField(default=1, help_text='Total users allowed per account', validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='monthly_ai_credit_limit',
field=models.IntegerField(default=500, help_text='Unified credit ceiling per month (all AI functions)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_cluster_ai_credits',
field=models.IntegerField(default=50, help_text='AI credits allocated for clustering', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_content_ai_credits',
field=models.IntegerField(default=200, help_text='AI credit pool for content generation', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_image_ai_credits',
field=models.IntegerField(default=100, help_text='AI credit pool for image generation', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_image_count',
field=models.IntegerField(default=100, help_text='Max images per month', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_word_count_limit',
field=models.IntegerField(default=50000, help_text='Monthly word limit (for generated content)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='auto_credit_topup_threshold',
field=models.IntegerField(blank=True, default=None, help_text='Auto top-up trigger point (optional)', null=True, validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='auto_credit_topup_amount',
field=models.IntegerField(blank=True, default=None, help_text='How many credits to auto-buy', null=True, validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='stripe_product_id',
field=models.CharField(blank=True, help_text='For Stripe plan sync', max_length=255, null=True),
),
migrations.AlterField(
model_name='plan',
name='features',
field=models.JSONField(default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])"),
),
]

View File

@@ -0,0 +1,108 @@
# Generated by Django 5.2.8 on 2025-11-07 10:06
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0007_expand_plan_limits'),
]
operations = [
migrations.CreateModel(
name='PasswordResetToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(db_index=True, max_length=255, unique=True)),
('expires_at', models.DateTimeField()),
('used', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'igny8_password_reset_tokens',
'ordering': ['-created_at'],
},
),
migrations.AlterModelOptions(
name='industry',
options={'ordering': ['name'], 'verbose_name': 'Industry', 'verbose_name_plural': 'Industries'},
),
migrations.AlterModelOptions(
name='industrysector',
options={'ordering': ['industry', 'name'], 'verbose_name': 'Industry Sector', 'verbose_name_plural': 'Industry Sectors'},
),
migrations.AlterModelOptions(
name='site',
options={'ordering': ['-created_at']},
),
migrations.AlterModelOptions(
name='siteuseraccess',
options={'verbose_name': 'Site User Access', 'verbose_name_plural': 'Site User Access'},
),
migrations.RenameIndex(
model_name='industry',
new_name='igny8_indus_slug_2f8769_idx',
old_name='igny8_indu_slug_idx',
),
migrations.RenameIndex(
model_name='industry',
new_name='igny8_indus_is_acti_146d41_idx',
old_name='igny8_indu_is_acti_idx',
),
migrations.RenameIndex(
model_name='industrysector',
new_name='igny8_indus_industr_00b524_idx',
old_name='igny8_indu_industr_idx',
),
migrations.RenameIndex(
model_name='industrysector',
new_name='igny8_indus_slug_101d63_idx',
old_name='igny8_indu_slug_1_idx',
),
migrations.RenameIndex(
model_name='sector',
new_name='igny8_secto_industr_1cf990_idx',
old_name='igny8_sect_industr_idx',
),
migrations.RenameIndex(
model_name='site',
new_name='igny8_sites_industr_66e004_idx',
old_name='igny8_site_industr_idx',
),
migrations.AlterField(
model_name='plan',
name='credits_per_month',
field=models.IntegerField(default=0, help_text='DEPRECATED: Use included_credits instead', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='plan',
name='extra_credit_price',
field=models.DecimalField(decimal_places=2, default=0.01, help_text='Price per additional credit', max_digits=10),
),
migrations.AlterField(
model_name='plan',
name='stripe_price_id',
field=models.CharField(blank=True, help_text='Monthly price ID for Stripe', max_length=255, null=True),
),
migrations.AddField(
model_name='passwordresettoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_reset_tokens', to=settings.AUTH_USER_MODEL),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['token'], name='igny8_passw_token_0eaf0c_idx'),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['user', 'used'], name='igny8_passw_user_id_320c02_idx'),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['expires_at'], name='igny8_passw_expires_c9aa03_idx'),
),
]

View File

@@ -0,0 +1,80 @@
from django.db import migrations
def forward_fix_admin_log_fk(apps, schema_editor):
if schema_editor.connection.vendor != "postgresql":
return
schema_editor.execute(
"""
ALTER TABLE django_admin_log
DROP CONSTRAINT IF EXISTS django_admin_log_user_id_c564eba6_fk_auth_user_id;
"""
)
schema_editor.execute(
"""
UPDATE django_admin_log
SET user_id = sub.new_user_id
FROM (
SELECT id AS new_user_id
FROM igny8_users
ORDER BY id
LIMIT 1
) AS sub
WHERE django_admin_log.user_id NOT IN (
SELECT id FROM igny8_users
);
"""
)
schema_editor.execute(
"""
ALTER TABLE django_admin_log
ADD CONSTRAINT django_admin_log_user_id_c564eba6_fk_igny8_users_id
FOREIGN KEY (user_id) REFERENCES igny8_users(id) DEFERRABLE INITIALLY DEFERRED;
"""
)
def reverse_fix_admin_log_fk(apps, schema_editor):
if schema_editor.connection.vendor != "postgresql":
return
schema_editor.execute(
"""
ALTER TABLE django_admin_log
DROP CONSTRAINT IF EXISTS django_admin_log_user_id_c564eba6_fk_igny8_users_id;
"""
)
schema_editor.execute(
"""
UPDATE django_admin_log
SET user_id = sub.old_user_id
FROM (
SELECT id AS old_user_id
FROM auth_user
ORDER BY id
LIMIT 1
) AS sub
WHERE django_admin_log.user_id NOT IN (
SELECT id FROM auth_user
);
"""
)
schema_editor.execute(
"""
ALTER TABLE django_admin_log
ADD CONSTRAINT django_admin_log_user_id_c564eba6_fk_auth_user_id
FOREIGN KEY (user_id) REFERENCES auth_user(id) DEFERRABLE INITIALLY DEFERRED;
"""
)
class Migration(migrations.Migration):
dependencies = [
("igny8_core_auth", "0008_passwordresettoken_alter_industry_options_and_more"),
]
operations = [
migrations.RunPython(forward_fix_admin_log_fk, reverse_fix_admin_log_fk),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.8 on 2025-11-07 11:34
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0009_fix_admin_log_user_fk'),
]
operations = [
migrations.CreateModel(
name='SeedKeyword',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('keyword', models.CharField(db_index=True, max_length=255)),
('volume', models.IntegerField(default=0, help_text='Search volume estimate')),
('difficulty', models.IntegerField(default=0, help_text='Keyword difficulty (0-100)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
('intent', models.CharField(choices=[('informational', 'Informational'), ('navigational', 'Navigational'), ('commercial', 'Commercial'), ('transactional', 'Transactional')], default='informational', max_length=50)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industry')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industrysector')),
],
options={
'verbose_name': 'Seed Keyword',
'verbose_name_plural': 'Seed Keywords',
'db_table': 'igny8_seed_keywords',
'ordering': ['keyword'],
'indexes': [models.Index(fields=['keyword'], name='igny8_seed__keyword_efa089_idx'), models.Index(fields=['industry', 'sector'], name='igny8_seed__industr_c41841_idx'), models.Index(fields=['industry', 'sector', 'is_active'], name='igny8_seed__industr_da0030_idx'), models.Index(fields=['intent'], name='igny8_seed__intent_15020d_idx')],
'unique_together': {('keyword', 'industry', 'sector')},
},
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-11-07 11:45
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0010_add_seed_keyword'),
]
operations = [
migrations.AddField(
model_name='plan',
name='daily_image_generation_limit',
field=models.IntegerField(default=25, help_text='Max images that can be generated per day', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_content_ideas',
field=models.IntegerField(default=300, help_text='Total content ideas allowed (global limit)', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='plan',
name='max_sites',
field=models.IntegerField(default=1, help_text='Maximum number of sites allowed', validators=[django.core.validators.MinValueValidator(1)]),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2025-11-07 11:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0011_add_plan_fields_and_fix_constraints'),
]
operations = [
migrations.AlterField(
model_name='plan',
name='ai_cost_per_request',
field=models.JSONField(blank=True, default=dict, help_text="Cost per request type (e.g., {'cluster': 2, 'idea': 3, 'content': 5, 'image': 1})"),
),
migrations.AlterField(
model_name='plan',
name='features',
field=models.JSONField(blank=True, default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])"),
),
migrations.AlterField(
model_name='plan',
name='image_model_choices',
field=models.JSONField(blank=True, default=list, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])"),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2025-11-07 12:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0012_allow_blank_json_fields'),
]
operations = [
migrations.RemoveField(
model_name='plan',
name='ai_cost_per_request',
),
]

View File

@@ -0,0 +1,589 @@
"""
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 with comprehensive limits and features.
Plans define limits for users, sites, content generation, AI usage, and billing.
"""
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)
# User / Site / Scope 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")
# Planner Limits
max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)")
max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)")
max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)")
daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day")
daily_keyword_import_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="SeedKeywords import limit per day")
monthly_cluster_ai_credits = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="AI credits allocated for clustering")
# Writer Limits
daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day")
daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day")
monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)")
monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation")
# Image Generation Limits
monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month")
daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day")
monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation")
max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task")
image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])")
# AI Request Controls
daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap")
monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)")
# Billing & Add-ons
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
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL")
wp_username = models.CharField(max_length=255, blank=True, null=True)
wp_app_password = models.CharField(max_length=255, blank=True, null=True)
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']),
]
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()

View 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

View File

@@ -0,0 +1,394 @@
"""
Authentication Serializers
"""
from rest_framework import serializers
from django.contrib.auth.password_validation import validate_password
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword
class PlanSerializer(serializers.ModelSerializer):
class Meta:
model = Plan
fields = [
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas',
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
'included_credits', 'image_model_choices', 'credits_per_month'
]
class SubscriptionSerializer(serializers.ModelSerializer):
"""Serializer for Subscription model."""
account_name = serializers.CharField(source='account.name', read_only=True)
account_slug = serializers.CharField(source='account.slug', read_only=True)
class Meta:
model = Subscription
fields = [
'id', 'account', 'account_name', 'account_slug',
'stripe_subscription_id', 'status',
'current_period_start', 'current_period_end',
'cancel_at_period_end',
'created_at', 'updated_at'
]
read_only_fields = ['created_at', 'updated_at']
class AccountSerializer(serializers.ModelSerializer):
plan = PlanSerializer(read_only=True)
plan_id = serializers.PrimaryKeyRelatedField(queryset=Plan.objects.filter(is_active=True), write_only=True, source='plan', required=False)
subscription = SubscriptionSerializer(read_only=True, allow_null=True)
def validate_plan_id(self, value):
"""Validate plan_id is provided during creation."""
if self.instance is None and not value:
raise serializers.ValidationError("plan_id is required when creating an account.")
return value
class Meta:
model = Account
fields = ['id', 'name', 'slug', 'owner', 'plan', 'plan_id', 'credits', 'status', 'subscription', 'created_at']
read_only_fields = ['owner', 'created_at']
class SiteSerializer(serializers.ModelSerializer):
"""Serializer for Site model."""
sectors_count = serializers.SerializerMethodField()
active_sectors_count = serializers.SerializerMethodField()
selected_sectors = serializers.SerializerMethodField()
can_add_sectors = serializers.SerializerMethodField()
industry_name = serializers.CharField(source='industry.name', read_only=True)
industry_slug = serializers.CharField(source='industry.slug', read_only=True)
# Override domain field to use CharField instead of URLField to avoid premature validation
domain = serializers.CharField(required=False, allow_blank=True, allow_null=True)
class Meta:
model = Site
fields = [
'id', 'name', 'slug', 'domain', 'description',
'industry', 'industry_name', 'industry_slug',
'is_active', 'status', 'wp_url', 'wp_username',
'sectors_count', 'active_sectors_count', 'selected_sectors',
'can_add_sectors',
'created_at', 'updated_at'
]
read_only_fields = ['created_at', 'updated_at', 'account']
def __init__(self, *args, **kwargs):
"""Allow partial updates for PATCH requests."""
super().__init__(*args, **kwargs)
# Make slug optional - it will be auto-generated from name if not provided
if 'slug' in self.fields:
self.fields['slug'].required = False
# For partial updates (PATCH), make name optional
if self.partial:
if 'name' in self.fields:
self.fields['name'].required = False
def validate_domain(self, value):
"""Ensure domain has https:// protocol.
- If domain has https://, keep it as is
- If domain has http://, replace with https://
- If domain has no protocol, add https://
- Validates that the final URL is valid
"""
if not value:
return value
value = value.strip()
# If it already starts with https://, keep it as is
if value.startswith('https://'):
normalized = value
# If it starts with http://, replace with https://
elif value.startswith('http://'):
normalized = value.replace('http://', 'https://', 1)
# Otherwise, add https://
else:
normalized = f'https://{value}'
# Validate that the normalized URL is a valid URL format
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
validator = URLValidator()
try:
validator(normalized)
except ValidationError:
raise serializers.ValidationError("Enter a valid URL or domain name.")
return normalized
def validate(self, attrs):
"""Auto-generate slug from name if not provided."""
# Auto-generate slug from name if slug is not provided
if 'slug' not in attrs or not attrs.get('slug'):
if 'name' in attrs and attrs['name']:
from django.utils.text import slugify
attrs['slug'] = slugify(attrs['name'])
return attrs
def get_sectors_count(self, obj):
"""Get total sectors count."""
return obj.sectors.count()
def get_active_sectors_count(self, obj):
"""Get active sectors count."""
return obj.sectors.filter(is_active=True).count()
def get_selected_sectors(self, obj):
"""Get list of selected sector IDs."""
return list(obj.sectors.filter(is_active=True).values_list('id', flat=True))
def get_can_add_sectors(self, obj):
"""Check if site can add more sectors (max 5)."""
return obj.can_add_sector()
class IndustrySectorSerializer(serializers.ModelSerializer):
"""Serializer for IndustrySector model."""
class Meta:
model = IndustrySector
fields = [
'id', 'industry', 'name', 'slug', 'description',
'is_active',
'created_at', 'updated_at'
]
read_only_fields = ['created_at', 'updated_at', 'id', 'industry']
class IndustrySerializer(serializers.ModelSerializer):
"""Serializer for Industry model."""
sectors = IndustrySectorSerializer(many=True, read_only=True)
sectors_count = serializers.SerializerMethodField()
class Meta:
model = Industry
fields = [
'id', 'name', 'slug', 'description', 'is_active',
'sectors', 'sectors_count',
'created_at', 'updated_at'
]
read_only_fields = ['created_at', 'updated_at']
def get_sectors_count(self, obj):
"""Get active sectors count."""
return obj.sectors.filter(is_active=True).count()
class SectorSerializer(serializers.ModelSerializer):
"""Serializer for Sector model."""
site_name = serializers.CharField(source='site.name', read_only=True)
industry_sector_name = serializers.CharField(source='industry_sector.name', read_only=True)
industry_sector_slug = serializers.CharField(source='industry_sector.slug', read_only=True)
industry_name = serializers.SerializerMethodField()
industry_slug = serializers.SerializerMethodField()
keywords_count = serializers.SerializerMethodField()
clusters_count = serializers.SerializerMethodField()
class Meta:
model = Sector
fields = [
'id', 'site', 'site_name', 'industry_sector', 'industry_sector_name',
'industry_sector_slug', 'industry_name', 'industry_slug',
'name', 'slug', 'description',
'is_active', 'status', 'keywords_count', 'clusters_count',
'created_at', 'updated_at'
]
read_only_fields = ['created_at', 'updated_at', 'account']
def get_industry_name(self, obj):
"""Get industry name from industry_sector."""
return obj.industry_sector.industry.name if obj.industry_sector else None
def get_industry_slug(self, obj):
"""Get industry slug from industry_sector."""
return obj.industry_sector.industry.slug if obj.industry_sector else None
def get_keywords_count(self, obj):
"""Get keywords count in this sector."""
# Using the related name from Keywords model
return getattr(obj, 'keywords_set', obj.keywords_set).count()
def get_clusters_count(self, obj):
"""Get clusters count in this sector."""
# Using the related name from Clusters model
return getattr(obj, 'clusters_set', obj.clusters_set).count()
class SiteUserAccessSerializer(serializers.ModelSerializer):
"""Serializer for SiteUserAccess model."""
user_email = serializers.CharField(source='user.email', read_only=True)
user_name = serializers.CharField(source='user.username', read_only=True)
site_name = serializers.CharField(source='site.name', read_only=True)
class Meta:
model = SiteUserAccess
fields = ['id', 'user', 'user_email', 'user_name', 'site', 'site_name', 'granted_at', 'granted_by']
read_only_fields = ['granted_at']
class UserSerializer(serializers.ModelSerializer):
account = AccountSerializer(read_only=True)
accessible_sites = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['id', 'username', 'email', 'role', 'account', 'accessible_sites', 'created_at']
read_only_fields = ['created_at']
def get_accessible_sites(self, obj):
"""Get list of sites user can access."""
sites = obj.get_accessible_sites()
return SiteSerializer(sites, many=True).data
class RegisterSerializer(serializers.Serializer):
"""Serializer for user registration."""
email = serializers.EmailField()
username = serializers.CharField(max_length=150, required=False)
password = serializers.CharField(write_only=True, validators=[validate_password])
password_confirm = serializers.CharField(write_only=True)
first_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
last_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
account_name = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True, default=None)
plan_id = serializers.PrimaryKeyRelatedField(
queryset=Plan.objects.filter(is_active=True),
required=False,
allow_null=True,
default=None
)
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError({"password": "Passwords do not match"})
# Convert empty strings to None for optional fields
if 'account_name' in attrs and attrs.get('account_name') == '':
attrs['account_name'] = None
if 'plan_id' in attrs and attrs.get('plan_id') == '':
attrs['plan_id'] = None
return attrs
def create(self, validated_data):
from django.db import transaction
with transaction.atomic():
# Get or assign free plan
plan = validated_data.get('plan_id')
if not plan:
# Auto-assign free plan
try:
plan = Plan.objects.get(slug='free', is_active=True)
except Plan.DoesNotExist:
# Fallback: get first active plan ordered by price (cheapest)
plan = Plan.objects.filter(is_active=True).order_by('price').first()
if not plan:
raise serializers.ValidationError({"plan": "No active plans available"})
# Generate account name if not provided
account_name = validated_data.get('account_name')
if not account_name:
first_name = validated_data.get('first_name', '')
last_name = validated_data.get('last_name', '')
if first_name or last_name:
account_name = f"{first_name} {last_name}".strip() or validated_data['email'].split('@')[0]
else:
account_name = validated_data['email'].split('@')[0]
# Generate username if not provided
username = validated_data.get('username')
if not username:
username = validated_data['email'].split('@')[0]
# Ensure username is unique
base_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
# Create user first without account (User.account is nullable)
user = User.objects.create_user(
username=username,
email=validated_data['email'],
password=validated_data['password'],
first_name=validated_data.get('first_name', ''),
last_name=validated_data.get('last_name', ''),
account=None, # Will be set after account creation
role='owner'
)
# Now create account with user as owner
account = Account.objects.create(
name=account_name,
slug=account_name.lower().replace(' ', '-').replace('_', '-')[:50],
owner=user,
plan=plan
)
# Update user to reference the new account
user.account = account
user.save()
return user
class LoginSerializer(serializers.Serializer):
"""Serializer for user login."""
email = serializers.EmailField()
password = serializers.CharField(write_only=True)
class ChangePasswordSerializer(serializers.Serializer):
"""Serializer for password change."""
old_password = serializers.CharField(write_only=True)
new_password = serializers.CharField(write_only=True, validators=[validate_password])
new_password_confirm = serializers.CharField(write_only=True)
def validate(self, attrs):
if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError({"new_password": "Passwords do not match"})
return attrs
class RefreshTokenSerializer(serializers.Serializer):
"""Serializer for token refresh."""
refresh = serializers.CharField(required=True)
class RequestPasswordResetSerializer(serializers.Serializer):
"""Serializer for password reset request."""
email = serializers.EmailField(required=True)
class ResetPasswordSerializer(serializers.Serializer):
"""Serializer for password reset."""
token = serializers.CharField(required=True)
new_password = serializers.CharField(write_only=True, validators=[validate_password])
new_password_confirm = serializers.CharField(write_only=True)
def validate(self, attrs):
if attrs['new_password'] != attrs['new_password_confirm']:
raise serializers.ValidationError({"new_password": "Passwords do not match"})
return attrs
class SeedKeywordSerializer(serializers.ModelSerializer):
"""Serializer for SeedKeyword model."""
industry_name = serializers.CharField(source='industry.name', read_only=True)
industry_slug = serializers.CharField(source='industry.slug', read_only=True)
sector_name = serializers.CharField(source='sector.name', read_only=True)
sector_slug = serializers.CharField(source='sector.slug', read_only=True)
intent_display = serializers.CharField(source='get_intent_display', read_only=True)
class Meta:
model = SeedKeyword
fields = [
'id', 'keyword', 'industry', 'industry_name', 'industry_slug',
'sector', 'sector_name', 'sector_slug',
'volume', 'difficulty', 'intent', 'intent_display',
'is_active', 'created_at', 'updated_at'
]
read_only_fields = ['created_at', 'updated_at']

View File

@@ -0,0 +1,177 @@
"""
Authentication URL Configuration
"""
from django.urls import path, include
from django.views.decorators.csrf import csrf_exempt
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, permissions
from .views import (
GroupsViewSet, UsersViewSet, AccountsViewSet, SubscriptionsViewSet,
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
IndustryViewSet, SeedKeywordViewSet, AuthViewSet
)
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
from .models import User
router = DefaultRouter()
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
router.register(r'groups', GroupsViewSet, basename='group')
router.register(r'users', UsersViewSet, basename='user')
router.register(r'accounts', AccountsViewSet, basename='account')
router.register(r'subscriptions', SubscriptionsViewSet, basename='subscription')
router.register(r'site-access', SiteUserAccessViewSet, basename='site-access')
# Supporting viewsets
router.register(r'plans', PlanViewSet, basename='plan')
router.register(r'sites', SiteViewSet, basename='site')
router.register(r'sectors', SectorViewSet, basename='sector')
router.register(r'industries', IndustryViewSet, basename='industry')
router.register(r'seed-keywords', SeedKeywordViewSet, basename='seed-keyword')
router.register(r'auth', AuthViewSet, basename='auth')
class RegisterView(APIView):
"""Registration endpoint."""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
user_serializer = UserSerializer(user)
return Response({
'success': True,
'message': 'Registration successful',
'user': user_serializer.data
}, status=status.HTTP_201_CREATED)
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
class LoginView(APIView):
"""Login endpoint."""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = LoginSerializer(data=request.data)
if serializer.is_valid():
email = serializer.validated_data['email']
password = serializer.validated_data['password']
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
return Response({
'success': False,
'message': 'Invalid credentials'
}, status=status.HTTP_401_UNAUTHORIZED)
if user.check_password(password):
# Log the user in (create session for session authentication)
from django.contrib.auth import login
login(request, user)
# Get account from user
account = getattr(user, 'account', None)
# Generate JWT tokens
from .utils import generate_access_token, generate_refresh_token, get_token_expiry
access_token = generate_access_token(user, account)
refresh_token = generate_refresh_token(user, account)
access_expires_at = get_token_expiry('access')
refresh_expires_at = get_token_expiry('refresh')
# Serialize user data safely, handling missing account relationship
try:
user_serializer = UserSerializer(user)
user_data = user_serializer.data
except Exception as e:
# Fallback if serializer fails (e.g., missing account_id column)
user_data = {
'id': user.id,
'username': user.username,
'email': user.email,
'role': user.role,
'account': None,
'accessible_sites': [],
}
return Response({
'success': True,
'message': 'Login successful',
'user': user_data,
'tokens': {
'access': access_token,
'refresh': refresh_token,
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
}
})
return Response({
'success': False,
'message': 'Invalid credentials'
}, status=status.HTTP_401_UNAUTHORIZED)
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
class ChangePasswordView(APIView):
"""Change password endpoint."""
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
user = request.user
if not user.check_password(serializer.validated_data['old_password']):
return Response({
'success': False,
'message': 'Current password is incorrect'
}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(serializer.validated_data['new_password'])
user.save()
return Response({
'success': True,
'message': 'Password changed successfully'
})
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
class MeView(APIView):
"""Get current user information."""
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
# Refresh user from DB to get latest account/plan data
# This ensures account/plan changes are reflected immediately
from .models import User as UserModel
user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id)
serializer = UserSerializer(user)
return Response({
'success': True,
'user': serializer.data
})
urlpatterns = [
path('', include(router.urls)),
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
path('me/', MeView.as_view(), name='auth-me'),
]

View File

@@ -0,0 +1,130 @@
"""
JWT Token Utilities for Authentication
"""
import jwt
from datetime import datetime, timedelta
from django.conf import settings
from django.utils import timezone
def get_jwt_secret_key():
"""Get JWT secret key from settings or fallback to Django SECRET_KEY"""
return getattr(settings, 'JWT_SECRET_KEY', settings.SECRET_KEY)
def get_jwt_algorithm():
"""Get JWT algorithm from settings"""
return getattr(settings, 'JWT_ALGORITHM', 'HS256')
def get_access_token_expiry():
"""Get access token expiry time from settings"""
return getattr(settings, 'JWT_ACCESS_TOKEN_EXPIRY', timedelta(minutes=15))
def get_refresh_token_expiry():
"""Get refresh token expiry time from settings"""
return getattr(settings, 'JWT_REFRESH_TOKEN_EXPIRY', timedelta(days=7))
def generate_access_token(user, account=None):
"""
Generate JWT access token for user
Args:
user: User instance
account: Account instance (optional, will use user.account if not provided)
Returns:
str: JWT access token
"""
if account is None:
account = getattr(user, 'account', None)
now = timezone.now()
expiry = now + get_access_token_expiry()
payload = {
'user_id': user.id,
'account_id': account.id if account else None,
'email': user.email,
'exp': int(expiry.timestamp()),
'iat': int(now.timestamp()),
'type': 'access',
}
token = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
return token
def generate_refresh_token(user, account=None):
"""
Generate JWT refresh token for user
Args:
user: User instance
account: Account instance (optional, will use user.account if not provided)
Returns:
str: JWT refresh token
"""
if account is None:
account = getattr(user, 'account', None)
now = timezone.now()
expiry = now + get_refresh_token_expiry()
payload = {
'user_id': user.id,
'account_id': account.id if account else None,
'exp': int(expiry.timestamp()),
'iat': int(now.timestamp()),
'type': 'refresh',
}
token = jwt.encode(payload, get_jwt_secret_key(), algorithm=get_jwt_algorithm())
return token
def decode_token(token):
"""
Decode and validate JWT token
Args:
token: JWT token string
Returns:
dict: Decoded token payload
Raises:
jwt.InvalidTokenError: If token is invalid or expired
"""
try:
payload = jwt.decode(
token,
get_jwt_secret_key(),
algorithms=[get_jwt_algorithm()],
options={"verify_signature": True, "verify_exp": True}
)
return payload
except jwt.ExpiredSignatureError:
raise jwt.InvalidTokenError("Token has expired")
except jwt.InvalidTokenError:
raise
def get_token_expiry(token_type='access'):
"""
Get token expiry datetime
Args:
token_type: 'access' or 'refresh'
Returns:
datetime: Expiry datetime
"""
now = timezone.now()
if token_type == 'refresh':
return now + get_refresh_token_expiry()
return now + get_access_token_expiry()

View File

@@ -0,0 +1,952 @@
"""
Authentication Views - Structured as: Groups, Users, Accounts, Subscriptions, Site User Access
"""
from rest_framework import viewsets, status, permissions, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from django.contrib.auth import authenticate
from django.utils import timezone
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend
from igny8_core.api.base import AccountModelViewSet
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword
from .serializers import (
UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer,
RegisterSerializer, LoginSerializer, ChangePasswordSerializer,
SiteSerializer, SectorSerializer, SiteUserAccessSerializer,
IndustrySerializer, IndustrySectorSerializer, SeedKeywordSerializer,
RefreshTokenSerializer, RequestPasswordResetSerializer, ResetPasswordSerializer
)
from .permissions import IsOwnerOrAdmin, IsEditorOrAbove
from .utils import generate_access_token, generate_refresh_token, get_token_expiry, decode_token
from .models import PasswordResetToken
import jwt
# ============================================================================
# 1. GROUPS - Define user roles and permissions across the system
# ============================================================================
class GroupsViewSet(viewsets.ViewSet):
"""
ViewSet for managing user roles and permissions (Groups).
Groups are defined by the User.ROLE_CHOICES.
"""
permission_classes = [IsOwnerOrAdmin]
def list(self, request):
"""List all available roles/groups."""
roles = [
{
'id': 'developer',
'name': 'Developer / Super Admin',
'description': 'Full access across all accounts (bypasses all filters)',
'permissions': ['full_access', 'bypass_filters', 'all_modules']
},
{
'id': 'owner',
'name': 'Owner',
'description': 'Full account access, billing, automation',
'permissions': ['account_management', 'billing', 'automation', 'all_sites']
},
{
'id': 'admin',
'name': 'Admin',
'description': 'Manage content modules, view billing (no edit)',
'permissions': ['content_management', 'view_billing', 'all_sites']
},
{
'id': 'editor',
'name': 'Editor',
'description': 'Generate AI content, manage clusters/tasks',
'permissions': ['ai_content', 'manage_clusters', 'manage_tasks', 'assigned_sites']
},
{
'id': 'viewer',
'name': 'Viewer',
'description': 'Read-only dashboards',
'permissions': ['read_only', 'assigned_sites']
},
{
'id': 'system_bot',
'name': 'System Bot',
'description': 'System automation user',
'permissions': ['automation_only']
}
]
return Response({
'success': True,
'groups': roles
})
@action(detail=False, methods=['get'], url_path='permissions')
def permissions(self, request):
"""Get permissions for a specific role."""
role = request.query_params.get('role')
if not role:
return Response({'error': 'role parameter is required'}, status=status.HTTP_400_BAD_REQUEST)
role_permissions = {
'developer': ['full_access', 'bypass_filters', 'all_modules', 'all_accounts'],
'owner': ['account_management', 'billing', 'automation', 'all_sites', 'user_management'],
'admin': ['content_management', 'view_billing', 'all_sites', 'user_management'],
'editor': ['ai_content', 'manage_clusters', 'manage_tasks', 'assigned_sites'],
'viewer': ['read_only', 'assigned_sites'],
'system_bot': ['automation_only']
}
permissions_list = role_permissions.get(role, [])
return Response({
'success': True,
'role': role,
'permissions': permissions_list
})
# ============================================================================
# 2. USERS - Manage global user records and credentials
# ============================================================================
class UsersViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing global user records and credentials.
Users are global, but belong to accounts.
"""
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsOwnerOrAdmin]
def get_queryset(self):
"""Return users based on access level."""
user = self.request.user
if not user or not user.is_authenticated:
return User.objects.none()
# Developers can see all users
if user.is_developer():
return User.objects.all()
# Owners/Admins can see users in their account
if user.role in ['owner', 'admin'] and user.account:
return User.objects.filter(account=user.account)
# Others can only see themselves
return User.objects.filter(id=user.id)
@action(detail=False, methods=['post'])
def create_user(self, request):
"""Create a new user (separate from registration)."""
from django.contrib.auth.password_validation import validate_password
email = request.data.get('email')
username = request.data.get('username')
password = request.data.get('password')
role = request.data.get('role', 'viewer')
account_id = request.data.get('account_id')
if not email or not username or not password:
return Response({
'error': 'email, username, and password are required'
}, status=status.HTTP_400_BAD_REQUEST)
# Validate password
try:
validate_password(password)
except Exception as e:
return Response({
'error': str(e)
}, status=status.HTTP_400_BAD_REQUEST)
# Get account
account = None
if account_id:
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
return Response({
'error': f'Account with id {account_id} does not exist'
}, status=status.HTTP_400_BAD_REQUEST)
else:
# Use current user's account
if request.user.account:
account = request.user.account
# Create user
try:
user = User.objects.create_user(
username=username,
email=email,
password=password,
role=role,
account=account
)
serializer = UserSerializer(user)
return Response({
'success': True,
'user': serializer.data
}, status=status.HTTP_201_CREATED)
except Exception as e:
return Response({
'error': str(e)
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'])
def update_role(self, request, pk=None):
"""Update user role."""
user = self.get_object()
new_role = request.data.get('role')
if not new_role:
return Response({
'error': 'role is required'
}, status=status.HTTP_400_BAD_REQUEST)
if new_role not in [choice[0] for choice in User.ROLE_CHOICES]:
return Response({
'error': f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}'
}, status=status.HTTP_400_BAD_REQUEST)
user.role = new_role
user.save()
serializer = UserSerializer(user)
return Response({
'success': True,
'user': serializer.data
})
# ============================================================================
# 3. ACCOUNTS - Register each unique organization/user space
# ============================================================================
class AccountsViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing accounts (unique organization/user spaces).
"""
queryset = Account.objects.all()
serializer_class = AccountSerializer
permission_classes = [IsOwnerOrAdmin]
def get_queryset(self):
"""Return accounts based on access level."""
user = self.request.user
if not user or not user.is_authenticated:
return Account.objects.none()
# Developers can see all accounts
if user.is_developer():
return Account.objects.all()
# Owners can see their own accounts
if user.role == 'owner':
return Account.objects.filter(owner=user)
# Admins can see their account
if user.role == 'admin' and user.account:
return Account.objects.filter(id=user.account.id)
return Account.objects.none()
def perform_create(self, serializer):
"""Create account with owner."""
user = self.request.user
# plan_id is mapped to plan in serializer (source='plan')
plan = serializer.validated_data.get('plan')
if not plan:
from rest_framework.exceptions import ValidationError
raise ValidationError("plan_id is required")
# Set owner to current user if not provided
owner = serializer.validated_data.get('owner')
if not owner:
owner = user
account = serializer.save(plan=plan, owner=owner)
return account
# ============================================================================
# 4. SUBSCRIPTIONS - Control plan level, limits, and billing per account
# ============================================================================
class SubscriptionsViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing subscriptions (plan level, limits, billing per account).
"""
queryset = Subscription.objects.all()
permission_classes = [IsOwnerOrAdmin]
def get_queryset(self):
"""Return subscriptions based on access level."""
user = self.request.user
if not user or not user.is_authenticated:
return Subscription.objects.none()
# Developers can see all subscriptions
if user.is_developer():
return Subscription.objects.all()
# Owners/Admins can see subscriptions for their account
if user.role in ['owner', 'admin'] and user.account:
return Subscription.objects.filter(account=user.account)
return Subscription.objects.none()
def get_serializer_class(self):
"""Return appropriate serializer."""
return SubscriptionSerializer
@action(detail=False, methods=['get'], url_path='by-account/(?P<account_id>[^/.]+)')
def by_account(self, request, account_id=None):
"""Get subscription for a specific account."""
try:
subscription = Subscription.objects.get(account_id=account_id)
serializer = self.get_serializer(subscription)
return Response({
'success': True,
'subscription': serializer.data
})
except Subscription.DoesNotExist:
return Response({
'error': 'Subscription not found for this account'
}, status=status.HTTP_404_NOT_FOUND)
# ============================================================================
# 5. SITE USER ACCESS - Assign users access to specific sites within account
# ============================================================================
class SiteUserAccessViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing Site-User access permissions.
Assign users access to specific sites within their account.
"""
serializer_class = SiteUserAccessSerializer
permission_classes = [IsOwnerOrAdmin]
def get_queryset(self):
"""Return access records for sites in user's account."""
user = self.request.user
if not user or not user.is_authenticated:
return SiteUserAccess.objects.none()
# Developers can see all access records
if user.is_developer():
return SiteUserAccess.objects.all()
if not user.account:
return SiteUserAccess.objects.none()
# Return access records for sites in user's account
return SiteUserAccess.objects.filter(site__account=user.account)
def perform_create(self, serializer):
"""Create site user access with granted_by."""
user = self.request.user
serializer.save(granted_by=user)
# ============================================================================
# SUPPORTING VIEWSETS (Sites, Sectors, Industries, Plans, Auth)
# ============================================================================
class PlanViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for listing active subscription plans."""
queryset = Plan.objects.filter(is_active=True)
serializer_class = PlanSerializer
permission_classes = [permissions.AllowAny]
class SiteViewSet(AccountModelViewSet):
"""ViewSet for managing Sites."""
serializer_class = SiteSerializer
permission_classes = [IsEditorOrAbove]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
def get_permissions(self):
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
if self.action == 'create':
return [permissions.IsAuthenticated()]
return [IsEditorOrAbove()]
def get_queryset(self):
"""Return sites accessible to the current user."""
user = self.request.user
if not user or not user.is_authenticated:
return Site.objects.none()
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sites
if user.is_admin_or_developer():
return Site.objects.all().distinct()
# Get account from user
account = getattr(user, 'account', None)
if not account:
return Site.objects.none()
if user.role in ['owner', 'admin']:
return Site.objects.filter(account=account)
return Site.objects.filter(
account=account,
user_access__user=user
).distinct()
def perform_create(self, serializer):
"""Create site with account."""
account = getattr(self.request, 'account', None)
if not account:
user = self.request.user
if user and user.is_authenticated:
account = getattr(user, 'account', None)
# Multiple sites can be active simultaneously - no constraint
serializer.save(account=account)
def perform_update(self, serializer):
"""Update site."""
account = getattr(self.request, 'account', None)
if not account:
account = getattr(serializer.instance, 'account', None)
# Multiple sites can be active simultaneously - no constraint
serializer.save()
@action(detail=True, methods=['get'])
def sectors(self, request, pk=None):
"""Get all sectors for this site."""
site = self.get_object()
sectors = site.sectors.filter(is_active=True)
serializer = SectorSerializer(sectors, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='set_active')
def set_active(self, request, pk=None):
"""Set this site as active (multiple sites can be active simultaneously)."""
site = self.get_object()
# Simply activate this site - no need to deactivate others
site.is_active = True
site.status = 'active'
site.save()
serializer = self.get_serializer(site)
return Response({
'success': True,
'message': f'Site "{site.name}" is now active',
'site': serializer.data
})
@action(detail=True, methods=['post'], url_path='select_sectors')
def select_sectors(self, request, pk=None):
"""Select industry and sectors for this site."""
import logging
logger = logging.getLogger(__name__)
try:
site = self.get_object()
except Exception as e:
logger.error(f"Error getting site object: {str(e)}", exc_info=True)
return Response({
'error': f'Site not found: {str(e)}'
}, status=status.HTTP_404_NOT_FOUND)
sector_slugs = request.data.get('sector_slugs', [])
industry_slug = request.data.get('industry_slug')
if not industry_slug:
return Response({
'error': 'Industry slug is required'
}, status=status.HTTP_400_BAD_REQUEST)
try:
industry = Industry.objects.get(slug=industry_slug, is_active=True)
except Industry.DoesNotExist:
return Response({
'error': f'Industry with slug "{industry_slug}" not found'
}, status=status.HTTP_400_BAD_REQUEST)
site.industry = industry
site.save()
if not sector_slugs:
return Response({
'success': True,
'message': f'Industry "{industry.name}" set for site. No sectors selected.',
'site': SiteSerializer(site).data,
'sectors': []
})
# Get plan's max_industries limit (if set), otherwise default to 5
max_sectors = site.get_max_sectors_limit()
if len(sector_slugs) > max_sectors:
return Response({
'error': f'Maximum {max_sectors} sectors allowed per site for this plan'
}, status=status.HTTP_400_BAD_REQUEST)
created_sectors = []
updated_sectors = []
existing_sector_slugs = set(sector_slugs)
site.sectors.exclude(slug__in=existing_sector_slugs).update(is_active=False)
industry_sectors_map = {}
for sector_slug in sector_slugs:
industry_sector = IndustrySector.objects.filter(
industry=industry,
slug=sector_slug,
is_active=True
).first()
if not industry_sector:
return Response({
'error': f'Sector "{sector_slug}" not found in industry "{industry.name}"'
}, status=status.HTTP_400_BAD_REQUEST)
industry_sectors_map[sector_slug] = industry_sector
for sector_slug, industry_sector in industry_sectors_map.items():
try:
# Check if site has account before proceeding
if not site.account:
logger.error(f"Site {site.id} has no account assigned")
return Response({
'error': f'Site "{site.name}" has no account assigned. Please contact support.'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Create or get sector - account will be set automatically in save() method
# But we need to pass it in defaults for get_or_create to work
sector, created = Sector.objects.get_or_create(
site=site,
slug=sector_slug,
defaults={
'industry_sector': industry_sector,
'name': industry_sector.name,
'description': industry_sector.description or '',
'is_active': True,
'status': 'active',
'account': site.account # Pass the account object, not the ID
}
)
if not created:
# Update existing sector
sector.industry_sector = industry_sector
sector.name = industry_sector.name
sector.description = industry_sector.description or ''
sector.is_active = True
sector.status = 'active'
# Ensure account is set (save() will also set it, but be explicit)
if not sector.account:
sector.account = site.account
sector.save()
updated_sectors.append(sector)
else:
created_sectors.append(sector)
except Exception as e:
logger.error(f"Error creating/updating sector {sector_slug}: {str(e)}", exc_info=True)
return Response({
'error': f'Failed to create/update sector "{sector_slug}": {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Get plan's max_industries limit (if set), otherwise default to 5
max_sectors = site.get_max_sectors_limit()
if site.get_active_sectors_count() > max_sectors:
return Response({
'error': f'Maximum {max_sectors} sectors allowed per site for this plan'
}, status=status.HTTP_400_BAD_REQUEST)
serializer = SectorSerializer(site.sectors.filter(is_active=True), many=True)
return Response({
'success': True,
'message': f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".',
'created_count': len(created_sectors),
'updated_count': len(updated_sectors),
'sectors': serializer.data,
'site': SiteSerializer(site).data
})
class SectorViewSet(AccountModelViewSet):
"""ViewSet for managing Sectors."""
serializer_class = SectorSerializer
permission_classes = [IsEditorOrAbove]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
def get_queryset(self):
"""Return sectors from sites accessible to the current user."""
user = self.request.user
if not user or not user.is_authenticated:
return Sector.objects.none()
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sectors across all sites
if user.is_admin_or_developer():
return Sector.objects.all().distinct()
accessible_sites = user.get_accessible_sites()
return Sector.objects.filter(site__in=accessible_sites)
def get_queryset_with_site_filter(self):
"""Get queryset, optionally filtered by site_id."""
queryset = self.get_queryset()
site_id = self.request.query_params.get('site_id')
if site_id:
queryset = queryset.filter(site_id=site_id)
return queryset
def list(self, request, *args, **kwargs):
"""Override list to apply site filter."""
queryset = self.get_queryset_with_site_filter()
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for industry templates."""
queryset = Industry.objects.filter(is_active=True).prefetch_related('sectors')
serializer_class = IndustrySerializer
permission_classes = [permissions.AllowAny]
def list(self, request):
"""Get all industries with their sectors."""
industries = self.get_queryset()
serializer = self.get_serializer(industries, many=True)
return Response({
'success': True,
'industries': serializer.data
})
class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for SeedKeyword - Global reference data (read-only for non-admins)."""
queryset = SeedKeyword.objects.filter(is_active=True).select_related('industry', 'sector')
serializer_class = SeedKeywordSerializer
permission_classes = [permissions.AllowAny] # Read-only, allow any authenticated user
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['keyword']
ordering_fields = ['keyword', 'volume', 'difficulty', 'created_at']
ordering = ['keyword']
filterset_fields = ['industry', 'sector', 'intent', 'is_active']
def get_queryset(self):
"""Filter by industry and sector if provided."""
queryset = super().get_queryset()
industry_id = self.request.query_params.get('industry_id')
sector_id = self.request.query_params.get('sector_id')
if industry_id:
queryset = queryset.filter(industry_id=industry_id)
if sector_id:
queryset = queryset.filter(sector_id=sector_id)
return queryset
# ============================================================================
# AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me)
# ============================================================================
class AuthViewSet(viewsets.GenericViewSet):
"""Authentication endpoints."""
permission_classes = [permissions.AllowAny]
@action(detail=False, methods=['post'])
def register(self, request):
"""User registration endpoint."""
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
# Log the user in (create session for session authentication)
from django.contrib.auth import login
login(request, user)
# Get account from user
account = getattr(user, 'account', None)
# Generate JWT tokens
access_token = generate_access_token(user, account)
refresh_token = generate_refresh_token(user, account)
access_expires_at = get_token_expiry('access')
refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user)
return Response({
'success': True,
'message': 'Registration successful',
'user': user_serializer.data,
'tokens': {
'access': access_token,
'refresh': refresh_token,
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
}
}, status=status.HTTP_201_CREATED)
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'])
def login(self, request):
"""User login endpoint."""
serializer = LoginSerializer(data=request.data)
if serializer.is_valid():
email = serializer.validated_data['email']
password = serializer.validated_data['password']
try:
user = User.objects.select_related('account', 'account__plan').get(email=email)
except User.DoesNotExist:
return Response({
'success': False,
'message': 'Invalid credentials'
}, status=status.HTTP_401_UNAUTHORIZED)
if user.check_password(password):
# Log the user in (create session for session authentication)
from django.contrib.auth import login
login(request, user)
# Get account from user
account = getattr(user, 'account', None)
# Generate JWT tokens
access_token = generate_access_token(user, account)
refresh_token = generate_refresh_token(user, account)
access_expires_at = get_token_expiry('access')
refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user)
return Response({
'success': True,
'message': 'Login successful',
'user': user_serializer.data,
'tokens': {
'access': access_token,
'refresh': refresh_token,
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
}
})
return Response({
'success': False,
'message': 'Invalid credentials'
}, status=status.HTTP_401_UNAUTHORIZED)
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def change_password(self, request):
"""Change password endpoint."""
serializer = ChangePasswordSerializer(data=request.data, context={'request': request})
if serializer.is_valid():
user = request.user
if not user.check_password(serializer.validated_data['old_password']):
return Response({
'success': False,
'message': 'Current password is incorrect'
}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(serializer.validated_data['new_password'])
user.save()
return Response({
'success': True,
'message': 'Password changed successfully'
})
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def me(self, request):
"""Get current user information."""
# Refresh user from DB to get latest account/plan data
# This ensures account/plan changes are reflected immediately
user = User.objects.select_related('account', 'account__plan').get(id=request.user.id)
serializer = UserSerializer(user)
return Response({
'success': True,
'user': serializer.data
})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def refresh(self, request):
"""Refresh access token using refresh token."""
serializer = RefreshTokenSerializer(data=request.data)
if not serializer.is_valid():
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
refresh_token = serializer.validated_data['refresh']
try:
# Decode and validate refresh token
payload = decode_token(refresh_token)
# Verify it's a refresh token
if payload.get('type') != 'refresh':
return Response({
'success': False,
'message': 'Invalid token type'
}, status=status.HTTP_400_BAD_REQUEST)
# Get user
user_id = payload.get('user_id')
account_id = payload.get('account_id')
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response({
'success': False,
'message': 'User not found'
}, status=status.HTTP_404_NOT_FOUND)
# Get account
account_id = payload.get('account_id')
account = None
if account_id:
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
pass
if not account:
account = getattr(user, 'account', None)
# Generate new access token
access_token = generate_access_token(user, account)
access_expires_at = get_token_expiry('access')
return Response({
'success': True,
'access': access_token,
'access_expires_at': access_expires_at.isoformat()
})
except jwt.InvalidTokenError as e:
return Response({
'success': False,
'message': 'Invalid or expired refresh token'
}, status=status.HTTP_401_UNAUTHORIZED)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def request_reset(self, request):
"""Request password reset - sends email with reset token."""
serializer = RequestPasswordResetSerializer(data=request.data)
if not serializer.is_valid():
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
email = serializer.validated_data['email']
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
# Don't reveal if email exists - return success anyway
return Response({
'success': True,
'message': 'If an account with that email exists, a password reset link has been sent.'
})
# Generate secure token
import secrets
token = secrets.token_urlsafe(32)
# Create reset token (expires in 1 hour)
from django.utils import timezone
from datetime import timedelta
expires_at = timezone.now() + timedelta(hours=1)
PasswordResetToken.objects.create(
user=user,
token=token,
expires_at=expires_at
)
# Send email (async via Celery if available, otherwise sync)
try:
from igny8_core.modules.system.tasks import send_password_reset_email
send_password_reset_email.delay(user.id, token)
except:
# Fallback to sync email sending
from django.core.mail import send_mail
from django.conf import settings
reset_url = f"{request.scheme}://{request.get_host()}/reset-password?token={token}"
send_mail(
subject='Reset Your IGNY8 Password',
message=f'Click the following link to reset your password: {reset_url}\n\nThis link expires in 1 hour.',
from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com'),
recipient_list=[user.email],
fail_silently=False,
)
return Response({
'success': True,
'message': 'If an account with that email exists, a password reset link has been sent.'
})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def reset_password(self, request):
"""Reset password using reset token."""
serializer = ResetPasswordSerializer(data=request.data)
if not serializer.is_valid():
return Response({
'success': False,
'errors': serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
token = serializer.validated_data['token']
new_password = serializer.validated_data['new_password']
try:
reset_token = PasswordResetToken.objects.get(token=token)
except PasswordResetToken.DoesNotExist:
return Response({
'success': False,
'message': 'Invalid reset token'
}, status=status.HTTP_400_BAD_REQUEST)
# Check if token is valid
if not reset_token.is_valid():
return Response({
'success': False,
'message': 'Reset token has expired or has already been used'
}, status=status.HTTP_400_BAD_REQUEST)
# Update password
user = reset_token.user
user.set_password(new_password)
user.save()
# Mark token as used
reset_token.used = True
reset_token.save()
return Response({
'success': True,
'message': 'Password has been reset successfully'
})