many fixes of backeend and fronteend
This commit is contained in:
@@ -181,7 +181,26 @@ class AccountModelViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
try:
|
||||
instance = self.get_object()
|
||||
self.perform_destroy(instance)
|
||||
# Protect system account
|
||||
if hasattr(instance, 'slug') and getattr(instance, 'slug', '') == 'aws-admin':
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("System account cannot be deleted.")
|
||||
|
||||
if hasattr(instance, 'soft_delete'):
|
||||
user = getattr(request, 'user', None)
|
||||
retention_days = None
|
||||
account = getattr(instance, 'account', None)
|
||||
if account and hasattr(account, 'deletion_retention_days'):
|
||||
retention_days = account.deletion_retention_days
|
||||
elif hasattr(instance, 'deletion_retention_days'):
|
||||
retention_days = getattr(instance, 'deletion_retention_days', None)
|
||||
instance.soft_delete(
|
||||
user=user if getattr(user, 'is_authenticated', False) else None,
|
||||
retention_days=retention_days,
|
||||
reason='api_delete'
|
||||
)
|
||||
else:
|
||||
self.perform_destroy(instance)
|
||||
return success_response(
|
||||
data=None,
|
||||
message='Deleted successfully',
|
||||
|
||||
@@ -56,6 +56,11 @@ class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
pass
|
||||
return qs.none()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if obj and getattr(obj, 'slug', '') == 'aws-admin':
|
||||
return False
|
||||
return super().has_delete_permission(request, obj)
|
||||
|
||||
|
||||
@admin.register(Subscription)
|
||||
class SubscriptionAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector
|
||||
from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas
|
||||
from igny8_core.business.content.models import Tasks, Content, Images
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Permanently delete soft-deleted records whose retention window has expired."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
now = timezone.now()
|
||||
total_deleted = 0
|
||||
|
||||
models = [
|
||||
Account,
|
||||
Site,
|
||||
Sector,
|
||||
Clusters,
|
||||
Keywords,
|
||||
ContentIdeas,
|
||||
Tasks,
|
||||
Content,
|
||||
Images,
|
||||
]
|
||||
|
||||
for model in models:
|
||||
qs = model.all_objects.filter(is_deleted=True, restore_until__lt=now)
|
||||
if model is Account:
|
||||
qs = qs.exclude(slug='aws-admin')
|
||||
count = qs.count()
|
||||
if count:
|
||||
qs.delete()
|
||||
total_deleted += count
|
||||
self.stdout.write(self.style.SUCCESS(f"Purged {count} {model.__name__} record(s)."))
|
||||
|
||||
if total_deleted == 0:
|
||||
self.stdout.write("No expired soft-deleted records to purge.")
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f"Total purged: {total_deleted}"))
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0005_account_owner_nullable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='deletion_retention_days',
|
||||
field=models.PositiveIntegerField(default=14, help_text='Retention window (days) before soft-deleted items are purged', validators=[MinValueValidator(1), MaxValueValidator(365)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sector',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sector',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sector',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sector',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sector',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
|
||||
|
||||
|
||||
class AccountBaseModel(models.Model):
|
||||
@@ -52,7 +53,7 @@ class SiteSectorBaseModel(AccountBaseModel):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Account(models.Model):
|
||||
class Account(SoftDeletableModel):
|
||||
"""
|
||||
Account/Organization model for multi-account support.
|
||||
"""
|
||||
@@ -76,6 +77,11 @@ class Account(models.Model):
|
||||
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
|
||||
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
|
||||
deletion_retention_days = models.PositiveIntegerField(
|
||||
default=14,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(365)],
|
||||
help_text="Retention window (days) before soft-deleted items are purged",
|
||||
)
|
||||
|
||||
# Billing information
|
||||
billing_email = models.EmailField(blank=True, null=True, help_text="Email for billing notifications")
|
||||
@@ -99,6 +105,9 @@ class Account(models.Model):
|
||||
models.Index(fields=['status']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -107,6 +116,15 @@ class Account(models.Model):
|
||||
# System accounts bypass all filtering restrictions
|
||||
return self.slug in ['aws-admin', 'default-account', 'default']
|
||||
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||
if self.is_system_account():
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("System account cannot be deleted.")
|
||||
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
return self.soft_delete()
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
"""
|
||||
@@ -202,7 +220,7 @@ class Subscription(models.Model):
|
||||
|
||||
|
||||
|
||||
class Site(AccountBaseModel):
|
||||
class Site(SoftDeletableModel, AccountBaseModel):
|
||||
"""
|
||||
Site model - Each account can have multiple sites based on their plan.
|
||||
Each site belongs to ONE industry and can have 1-5 sectors from that industry.
|
||||
@@ -274,6 +292,9 @@ class Site(AccountBaseModel):
|
||||
blank=True,
|
||||
help_text="SEO metadata: meta tags, Open Graph, Schema.org"
|
||||
)
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_sites'
|
||||
@@ -409,7 +430,7 @@ class SeedKeyword(models.Model):
|
||||
return f"{self.keyword} ({self.industry.name} - {self.sector.name})"
|
||||
|
||||
|
||||
class Sector(AccountBaseModel):
|
||||
class Sector(SoftDeletableModel, AccountBaseModel):
|
||||
"""
|
||||
Sector model - Each site can have 1-5 sectors.
|
||||
Sectors are site-specific instances that reference an IndustrySector template.
|
||||
@@ -436,6 +457,9 @@ class Sector(AccountBaseModel):
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_sectors'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from igny8_core.auth.models import SiteSectorBaseModel
|
||||
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
|
||||
|
||||
|
||||
class Tasks(SiteSectorBaseModel):
|
||||
class Tasks(SoftDeletableModel, SiteSectorBaseModel):
|
||||
"""Tasks model for content generation queue"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
@@ -116,6 +117,9 @@ class Tasks(SiteSectorBaseModel):
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -133,7 +137,7 @@ class ContentTaxonomyRelation(models.Model):
|
||||
unique_together = [['content', 'taxonomy']]
|
||||
|
||||
|
||||
class Content(SiteSectorBaseModel):
|
||||
class Content(SoftDeletableModel, SiteSectorBaseModel):
|
||||
"""
|
||||
Content model for AI-generated or WordPress-imported content.
|
||||
Final architecture: simplified content management.
|
||||
@@ -267,6 +271,9 @@ class Content(SiteSectorBaseModel):
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
def __str__(self):
|
||||
return self.title or f"Content {self.id}"
|
||||
|
||||
@@ -350,7 +357,7 @@ class ContentTaxonomy(SiteSectorBaseModel):
|
||||
return f"{self.name} ({self.get_taxonomy_type_display()})"
|
||||
|
||||
|
||||
class Images(SiteSectorBaseModel):
|
||||
class Images(SoftDeletableModel, SiteSectorBaseModel):
|
||||
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
|
||||
|
||||
IMAGE_TYPE_CHOICES = [
|
||||
@@ -399,6 +406,9 @@ class Images(SiteSectorBaseModel):
|
||||
models.Index(fields=['task', 'position']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Automatically set account, site, and sector from content or task"""
|
||||
# Prefer content over task
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.db import models
|
||||
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
|
||||
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
|
||||
|
||||
|
||||
class Clusters(SiteSectorBaseModel):
|
||||
class Clusters(SoftDeletableModel, SiteSectorBaseModel):
|
||||
"""Clusters model for keyword grouping - pure topic clusters"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
@@ -33,11 +34,14 @@ class Clusters(SiteSectorBaseModel):
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Keywords(SiteSectorBaseModel):
|
||||
class Keywords(SoftDeletableModel, SiteSectorBaseModel):
|
||||
"""
|
||||
Keywords model for SEO keyword management.
|
||||
Site-specific instances that reference global SeedKeywords.
|
||||
@@ -101,6 +105,9 @@ class Keywords(SiteSectorBaseModel):
|
||||
models.Index(fields=['seed_keyword', 'site', 'sector']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
@property
|
||||
def keyword(self):
|
||||
"""Get keyword text from seed_keyword"""
|
||||
@@ -144,7 +151,7 @@ class Keywords(SiteSectorBaseModel):
|
||||
return self.keyword
|
||||
|
||||
|
||||
class ContentIdeas(SiteSectorBaseModel):
|
||||
class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):
|
||||
"""Content Ideas model for planning content based on keyword clusters"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
@@ -232,6 +239,9 @@ class ContentIdeas(SiteSectorBaseModel):
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
def __str__(self):
|
||||
return self.idea_title
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ app.conf.beat_schedule = {
|
||||
'task': 'automation.check_scheduled_automations',
|
||||
'schedule': crontab(minute=0), # Every hour at :00
|
||||
},
|
||||
# Maintenance: purge expired soft-deleted records daily at 3:15 AM
|
||||
'purge-soft-deleted-records': {
|
||||
'task': 'igny8_core.purge_soft_deleted',
|
||||
'schedule': crontab(hour=3, minute=15),
|
||||
},
|
||||
}
|
||||
|
||||
@app.task(bind=True, ignore_result=True)
|
||||
|
||||
95
backend/igny8_core/common/soft_delete.py
Normal file
95
backend/igny8_core/common/soft_delete.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SoftDeleteQuerySet(models.QuerySet):
|
||||
"""QuerySet that filters out soft-deleted rows by default."""
|
||||
|
||||
def delete(self):
|
||||
# Prevent accidental hard deletes through queryset.delete()
|
||||
for obj in self:
|
||||
obj.delete()
|
||||
|
||||
def hard_delete(self):
|
||||
return super().delete()
|
||||
|
||||
def with_deleted(self):
|
||||
"""Return all rows, including soft-deleted."""
|
||||
return super().all()
|
||||
|
||||
|
||||
class SoftDeleteManager(models.Manager):
|
||||
"""Manager that hides soft-deleted rows by default."""
|
||||
|
||||
def get_queryset(self):
|
||||
return SoftDeleteQuerySet(self.model, using=self._db).filter(is_deleted=False)
|
||||
|
||||
def with_deleted(self):
|
||||
return SoftDeleteQuerySet(self.model, using=self._db)
|
||||
|
||||
|
||||
class SoftDeletableModel(models.Model):
|
||||
"""
|
||||
Abstract mixin for soft-deletion with retention window.
|
||||
Objects are hidden by default via SoftDeleteManager.
|
||||
"""
|
||||
|
||||
is_deleted = models.BooleanField(default=False, db_index=True)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
restore_until = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
delete_reason = models.CharField(max_length=255, null=True, blank=True)
|
||||
deleted_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='+',
|
||||
)
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
DEFAULT_RETENTION_DAYS = 14
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||
"""Mark the instance as deleted with a retention window."""
|
||||
if self.is_deleted:
|
||||
return
|
||||
now = timezone.now()
|
||||
if retention_days is None:
|
||||
retention_days = getattr(
|
||||
getattr(self, 'account', None),
|
||||
'deletion_retention_days',
|
||||
self.DEFAULT_RETENTION_DAYS,
|
||||
)
|
||||
self.is_deleted = True
|
||||
self.deleted_at = now
|
||||
self.restore_until = now + timezone.timedelta(days=retention_days)
|
||||
self.deleted_by = user
|
||||
if reason:
|
||||
self.delete_reason = reason
|
||||
self.save(update_fields=['is_deleted', 'deleted_at', 'restore_until', 'deleted_by', 'delete_reason'])
|
||||
|
||||
def restore(self):
|
||||
"""Restore a soft-deleted instance if within the retention window."""
|
||||
if not self.is_deleted:
|
||||
return
|
||||
self.is_deleted = False
|
||||
self.deleted_at = None
|
||||
self.restore_until = None
|
||||
self.delete_reason = None
|
||||
self.deleted_by = None
|
||||
self.save(update_fields=['is_deleted', 'deleted_at', 'restore_until', 'delete_reason', 'deleted_by'])
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
"""Override delete to perform a soft delete."""
|
||||
self.soft_delete()
|
||||
|
||||
def hard_delete(self, using=None, keep_parents=False):
|
||||
"""Irrevocably delete the row."""
|
||||
return super().delete(using=using, keep_parents=keep_parents)
|
||||
|
||||
2
backend/igny8_core/management/__init__.py
Normal file
2
backend/igny8_core/management/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Package marker for Django management commands
|
||||
|
||||
2
backend/igny8_core/management/commands/__init__.py
Normal file
2
backend/igny8_core/management/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Package marker for Django management commands
|
||||
|
||||
42
backend/igny8_core/management/commands/purge_soft_deleted.py
Normal file
42
backend/igny8_core/management/commands/purge_soft_deleted.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from igny8_core.auth.models import Account, Site, Sector
|
||||
from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas
|
||||
from igny8_core.business.content.models import Tasks, Content, Images
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Permanently delete soft-deleted records whose retention window has expired."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
now = timezone.now()
|
||||
total_deleted = 0
|
||||
|
||||
models = [
|
||||
Account,
|
||||
Site,
|
||||
Sector,
|
||||
Clusters,
|
||||
Keywords,
|
||||
ContentIdeas,
|
||||
Tasks,
|
||||
Content,
|
||||
Images,
|
||||
]
|
||||
|
||||
for model in models:
|
||||
qs = model.all_objects.filter(is_deleted=True, restore_until__lt=now)
|
||||
if model is Account:
|
||||
qs = qs.exclude(slug='aws-admin')
|
||||
count = qs.count()
|
||||
if count:
|
||||
qs.delete()
|
||||
total_deleted += count
|
||||
self.stdout.write(self.style.SUCCESS(f"Purged {count} {model.__name__} record(s)."))
|
||||
|
||||
if total_deleted == 0:
|
||||
self.stdout.write("No expired soft-deleted records to purge.")
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f"Total purged: {total_deleted}"))
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
Billing Module Admin
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from igny8_core.admin.base import AccountAdminMixin
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
||||
|
||||
|
||||
@@ -70,3 +72,78 @@ class AccountPaymentMethodAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CreditCostConfig)
|
||||
class CreditCostConfigAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'operation_type',
|
||||
'display_name',
|
||||
'credits_cost_display',
|
||||
'unit',
|
||||
'is_active',
|
||||
'cost_change_indicator',
|
||||
'updated_at',
|
||||
'updated_by'
|
||||
]
|
||||
|
||||
list_filter = ['is_active', 'unit', 'updated_at']
|
||||
search_fields = ['operation_type', 'display_name', 'description']
|
||||
|
||||
fieldsets = (
|
||||
('Operation', {
|
||||
'fields': ('operation_type', 'display_name', 'description')
|
||||
}),
|
||||
('Cost Configuration', {
|
||||
'fields': ('credits_cost', 'unit', 'is_active')
|
||||
}),
|
||||
('Audit Trail', {
|
||||
'fields': ('previous_cost', 'updated_by', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'previous_cost']
|
||||
|
||||
def credits_cost_display(self, obj):
|
||||
"""Show cost with color coding"""
|
||||
if obj.credits_cost >= 20:
|
||||
color = 'red'
|
||||
elif obj.credits_cost >= 10:
|
||||
color = 'orange'
|
||||
else:
|
||||
color = 'green'
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{} credits</span>',
|
||||
color,
|
||||
obj.credits_cost
|
||||
)
|
||||
credits_cost_display.short_description = 'Cost'
|
||||
|
||||
def cost_change_indicator(self, obj):
|
||||
"""Show if cost changed recently"""
|
||||
if obj.previous_cost is not None:
|
||||
if obj.credits_cost > obj.previous_cost:
|
||||
icon = '📈' # Increased
|
||||
color = 'red'
|
||||
elif obj.credits_cost < obj.previous_cost:
|
||||
icon = '📉' # Decreased
|
||||
color = 'green'
|
||||
else:
|
||||
icon = '➡️' # Same
|
||||
color = 'gray'
|
||||
|
||||
return format_html(
|
||||
'{} <span style="color: {};">({} → {})</span>',
|
||||
icon,
|
||||
color,
|
||||
obj.previous_cost,
|
||||
obj.credits_cost
|
||||
)
|
||||
return '—'
|
||||
cost_change_indicator.short_description = 'Recent Change'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Track who made the change"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ from django.apps import AppConfig
|
||||
class BillingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'igny8_core.modules.billing'
|
||||
verbose_name = 'Billing'
|
||||
verbose_name = 'Billing & Tenancy'
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('planner', '0007_fix_cluster_unique_constraint'),
|
||||
('igny8_core_auth', '0006_soft_delete_and_retention'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clusters',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clusters',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clusters',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clusters',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clusters',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentideas',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentideas',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentideas',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentideas',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentideas',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='keywords',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('writer', '0011_content_external_metadata_content_external_type_and_more'),
|
||||
('igny8_core_auth', '0006_soft_delete_and_retention'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='images',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='images',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='images',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='images',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='images',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='delete_reason',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='deleted_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='restore_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
]
|
||||
|
||||
16
backend/igny8_core/tasks.py
Normal file
16
backend/igny8_core/tasks.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.core.management import call_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='igny8_core.purge_soft_deleted')
|
||||
def purge_soft_deleted_task():
|
||||
"""Periodic task to purge expired soft-deleted records."""
|
||||
try:
|
||||
call_command('purge_soft_deleted')
|
||||
except Exception as exc:
|
||||
logger.exception("purge_soft_deleted task failed: %s", exc)
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user