many fixes of backeend and fronteend
This commit is contained in:
@@ -181,6 +181,25 @@ class AccountModelViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
# 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)
|
self.perform_destroy(instance)
|
||||||
return success_response(
|
return success_response(
|
||||||
data=None,
|
data=None,
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|||||||
pass
|
pass
|
||||||
return qs.none()
|
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)
|
@admin.register(Subscription)
|
||||||
class SubscriptionAdmin(AccountAdminMixin, admin.ModelAdmin):
|
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.contrib.auth.models import AbstractUser
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
|
||||||
|
|
||||||
|
|
||||||
class AccountBaseModel(models.Model):
|
class AccountBaseModel(models.Model):
|
||||||
@@ -52,7 +53,7 @@ class SiteSectorBaseModel(AccountBaseModel):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Account(models.Model):
|
class Account(SoftDeletableModel):
|
||||||
"""
|
"""
|
||||||
Account/Organization model for multi-account support.
|
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')
|
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
|
||||||
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
|
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 information
|
||||||
billing_email = models.EmailField(blank=True, null=True, help_text="Email for billing notifications")
|
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']),
|
models.Index(fields=['status']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -107,6 +116,15 @@ class Account(models.Model):
|
|||||||
# System accounts bypass all filtering restrictions
|
# System accounts bypass all filtering restrictions
|
||||||
return self.slug in ['aws-admin', 'default-account', 'default']
|
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):
|
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.
|
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.
|
Each site belongs to ONE industry and can have 1-5 sectors from that industry.
|
||||||
@@ -275,6 +293,9 @@ class Site(AccountBaseModel):
|
|||||||
help_text="SEO metadata: meta tags, Open Graph, Schema.org"
|
help_text="SEO metadata: meta tags, Open Graph, Schema.org"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'igny8_sites'
|
db_table = 'igny8_sites'
|
||||||
unique_together = [['account', 'slug']] # Slug unique per account
|
unique_together = [['account', 'slug']] # Slug unique per account
|
||||||
@@ -409,7 +430,7 @@ class SeedKeyword(models.Model):
|
|||||||
return f"{self.keyword} ({self.industry.name} - {self.sector.name})"
|
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.
|
Sector model - Each site can have 1-5 sectors.
|
||||||
Sectors are site-specific instances that reference an IndustrySector template.
|
Sectors are site-specific instances that reference an IndustrySector template.
|
||||||
@@ -437,6 +458,9 @@ class Sector(AccountBaseModel):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'igny8_sectors'
|
db_table = 'igny8_sectors'
|
||||||
unique_together = [['site', 'slug']] # Slug unique per site
|
unique_together = [['site', 'slug']] # Slug unique per site
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from igny8_core.auth.models import SiteSectorBaseModel
|
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"""
|
"""Tasks model for content generation queue"""
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
@@ -116,6 +117,9 @@ class Tasks(SiteSectorBaseModel):
|
|||||||
models.Index(fields=['site', 'sector']),
|
models.Index(fields=['site', 'sector']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
@@ -133,7 +137,7 @@ class ContentTaxonomyRelation(models.Model):
|
|||||||
unique_together = [['content', 'taxonomy']]
|
unique_together = [['content', 'taxonomy']]
|
||||||
|
|
||||||
|
|
||||||
class Content(SiteSectorBaseModel):
|
class Content(SoftDeletableModel, SiteSectorBaseModel):
|
||||||
"""
|
"""
|
||||||
Content model for AI-generated or WordPress-imported content.
|
Content model for AI-generated or WordPress-imported content.
|
||||||
Final architecture: simplified content management.
|
Final architecture: simplified content management.
|
||||||
@@ -267,6 +271,9 @@ class Content(SiteSectorBaseModel):
|
|||||||
models.Index(fields=['site', 'sector']),
|
models.Index(fields=['site', 'sector']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title or f"Content {self.id}"
|
return self.title or f"Content {self.id}"
|
||||||
|
|
||||||
@@ -350,7 +357,7 @@ class ContentTaxonomy(SiteSectorBaseModel):
|
|||||||
return f"{self.name} ({self.get_taxonomy_type_display()})"
|
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)"""
|
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
|
||||||
|
|
||||||
IMAGE_TYPE_CHOICES = [
|
IMAGE_TYPE_CHOICES = [
|
||||||
@@ -399,6 +406,9 @@ class Images(SiteSectorBaseModel):
|
|||||||
models.Index(fields=['task', 'position']),
|
models.Index(fields=['task', 'position']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Automatically set account, site, and sector from content or task"""
|
"""Automatically set account, site, and sector from content or task"""
|
||||||
# Prefer content over task
|
# Prefer content over task
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
|
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"""
|
"""Clusters model for keyword grouping - pure topic clusters"""
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
@@ -33,11 +34,14 @@ class Clusters(SiteSectorBaseModel):
|
|||||||
models.Index(fields=['site', 'sector']),
|
models.Index(fields=['site', 'sector']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Keywords(SiteSectorBaseModel):
|
class Keywords(SoftDeletableModel, SiteSectorBaseModel):
|
||||||
"""
|
"""
|
||||||
Keywords model for SEO keyword management.
|
Keywords model for SEO keyword management.
|
||||||
Site-specific instances that reference global SeedKeywords.
|
Site-specific instances that reference global SeedKeywords.
|
||||||
@@ -101,6 +105,9 @@ class Keywords(SiteSectorBaseModel):
|
|||||||
models.Index(fields=['seed_keyword', 'site', 'sector']),
|
models.Index(fields=['seed_keyword', 'site', 'sector']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def keyword(self):
|
def keyword(self):
|
||||||
"""Get keyword text from seed_keyword"""
|
"""Get keyword text from seed_keyword"""
|
||||||
@@ -144,7 +151,7 @@ class Keywords(SiteSectorBaseModel):
|
|||||||
return self.keyword
|
return self.keyword
|
||||||
|
|
||||||
|
|
||||||
class ContentIdeas(SiteSectorBaseModel):
|
class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):
|
||||||
"""Content Ideas model for planning content based on keyword clusters"""
|
"""Content Ideas model for planning content based on keyword clusters"""
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
@@ -232,6 +239,9 @@ class ContentIdeas(SiteSectorBaseModel):
|
|||||||
models.Index(fields=['site', 'sector']),
|
models.Index(fields=['site', 'sector']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.idea_title
|
return self.idea_title
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ app.conf.beat_schedule = {
|
|||||||
'task': 'automation.check_scheduled_automations',
|
'task': 'automation.check_scheduled_automations',
|
||||||
'schedule': crontab(minute=0), # Every hour at :00
|
'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)
|
@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
|
Billing Module Admin
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
from igny8_core.admin.base import AccountAdminMixin
|
from igny8_core.admin.base import AccountAdminMixin
|
||||||
|
from igny8_core.business.billing.models import CreditCostConfig
|
||||||
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
|
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):
|
class BillingConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'igny8_core.modules.billing'
|
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
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Suspense, lazy, useEffect } from "react";
|
import { Suspense, lazy, useEffect } from "react";
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import AppLayout from "./layout/AppLayout";
|
import AppLayout from "./layout/AppLayout";
|
||||||
import { ScrollToTop } from "./components/common/ScrollToTop";
|
import { ScrollToTop } from "./components/common/ScrollToTop";
|
||||||
@@ -176,7 +176,6 @@ export default function App() {
|
|||||||
<>
|
<>
|
||||||
<GlobalErrorDisplay />
|
<GlobalErrorDisplay />
|
||||||
<LoadingStateMonitor />
|
<LoadingStateMonitor />
|
||||||
<Router>
|
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -833,7 +832,6 @@ export default function App() {
|
|||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</Router>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ThemeProvider } from "./context/ThemeContext";
|
|||||||
import { ToastProvider } from "./components/ui/toast/ToastContainer";
|
import { ToastProvider } from "./components/ui/toast/ToastContainer";
|
||||||
import { HeaderMetricsProvider } from "./context/HeaderMetricsContext";
|
import { HeaderMetricsProvider } from "./context/HeaderMetricsContext";
|
||||||
import { ErrorBoundary } from "./components/common/ErrorBoundary";
|
import { ErrorBoundary } from "./components/common/ErrorBoundary";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -16,7 +17,9 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<HeaderMetricsProvider>
|
<HeaderMetricsProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</HeaderMetricsProvider>
|
</HeaderMetricsProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user