- UserStatusView, EventModerationView, ReviewModerationView, PartnerKYCReviewView: each state change now emits _audit_log() inside the same transaction.atomic() block so the log stays consistent with DB state on partial failure - AuditLogMetricsView: GET /api/v1/rbac/audit-log/metrics/ returns total/today/week/distinct_users/by_action_group; 60 s cache with ?nocache=1 bypass - AuditLogListView: free-text search (Q over action/target/user), page_size bounded to [1, 200] - accounts.User.ALL_MODULES += 'audit-log'; StaffProfile.SCOPE_TO_MODULE['audit'] = 'audit-log' - Migration 0005: composite indexes (action,-created_at) and (target_type,target_id) on AuditLog - admin_api/tests.py: 11 tests covering list shape, search, page bounds, metrics shape+nocache, suspend/ban/reinstate audit emission Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
9.3 KiB
Python
254 lines
9.3 KiB
Python
from django.db import models
|
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
|
|
|
|
class Review(models.Model):
|
|
STATUS_PENDING = 'pending'
|
|
STATUS_LIVE = 'live'
|
|
STATUS_REJECTED = 'rejected'
|
|
STATUS_CHOICES = [
|
|
(STATUS_PENDING, 'Pending'),
|
|
(STATUS_LIVE, 'Live'),
|
|
(STATUS_REJECTED, 'Rejected'),
|
|
]
|
|
REJECT_CHOICES = [
|
|
('spam', 'Spam'),
|
|
('inappropriate', 'Inappropriate'),
|
|
('fake', 'Fake'),
|
|
]
|
|
|
|
reviewer = models.ForeignKey(
|
|
'accounts.User', on_delete=models.CASCADE, related_name='admin_reviews'
|
|
)
|
|
event = models.ForeignKey(
|
|
'events.Event', on_delete=models.CASCADE, related_name='admin_reviews'
|
|
)
|
|
rating = models.IntegerField(
|
|
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
|
)
|
|
review_text = models.TextField()
|
|
submission_date = models.DateTimeField(auto_now_add=True)
|
|
status = models.CharField(
|
|
max_length=10, choices=STATUS_CHOICES, default=STATUS_PENDING
|
|
)
|
|
reject_reason = models.CharField(
|
|
max_length=15, choices=REJECT_CHOICES, null=True, blank=True
|
|
)
|
|
display_name = models.CharField(max_length=100, blank=True, default='')
|
|
is_verified = models.BooleanField(default=False)
|
|
helpful_count = models.IntegerField(default=0)
|
|
flag_count = models.IntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ['-submission_date']
|
|
indexes = [
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['submission_date']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f'Review #{self.pk} by {self.reviewer_id} — {self.status}'
|
|
|
|
|
|
class ReviewInteraction(models.Model):
|
|
INTERACTION_CHOICES = [('HELPFUL', 'Helpful'), ('FLAG', 'Flag')]
|
|
|
|
review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name='interactions')
|
|
username = models.CharField(max_length=255)
|
|
interaction_type = models.CharField(max_length=20, choices=INTERACTION_CHOICES)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
unique_together = ('review', 'username', 'interaction_type')
|
|
|
|
def __str__(self):
|
|
return f'{self.username} {self.interaction_type} on Review #{self.review_id}'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RBAC Models
|
|
# ---------------------------------------------------------------------------
|
|
from accounts.models import User
|
|
|
|
|
|
class Department(models.Model):
|
|
name = models.CharField(max_length=100)
|
|
slug = models.SlugField(unique=True)
|
|
description = models.TextField(blank=True, default='')
|
|
base_scopes = models.JSONField(default=list)
|
|
color = models.CharField(max_length=7, default='#3B82F6')
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Squad(models.Model):
|
|
name = models.CharField(max_length=100)
|
|
department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name='squads')
|
|
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_squads')
|
|
extra_scopes = models.JSONField(default=list)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return f"{self.department.name} > {self.name}"
|
|
|
|
|
|
class StaffProfile(models.Model):
|
|
ROLE_CHOICES = [('SUPER_ADMIN', 'Super Admin'), ('MANAGER', 'Manager'), ('MEMBER', 'Member')]
|
|
STATUS_CHOICES = [('active', 'Active'), ('invited', 'Invited'), ('deactivated', 'Deactivated')]
|
|
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='staff_profile')
|
|
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='staff_members')
|
|
squad = models.ForeignKey(Squad, on_delete=models.SET_NULL, null=True, blank=True, related_name='members')
|
|
staff_role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='MEMBER')
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
|
joined_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['user__first_name']
|
|
|
|
def get_effective_scopes(self):
|
|
if self.staff_role == 'SUPER_ADMIN' or self.user.is_superuser:
|
|
return ['*']
|
|
scopes = set()
|
|
if self.department:
|
|
scopes.update(self.department.base_scopes or [])
|
|
if self.squad:
|
|
scopes.update(self.squad.extra_scopes or [])
|
|
if self.staff_role == 'MANAGER':
|
|
scopes.add('settings.staff')
|
|
return list(scopes)
|
|
|
|
def get_allowed_modules(self):
|
|
scopes = self.get_effective_scopes()
|
|
if '*' in scopes:
|
|
return ['dashboard', 'partners', 'events', 'ad-control', 'users', 'reviews', 'contributions', 'leads', 'financials', 'audit-log', 'settings']
|
|
SCOPE_TO_MODULE = {
|
|
'users': 'users',
|
|
'events': 'events',
|
|
'finance': 'financials',
|
|
'partners': 'partners',
|
|
'tickets': 'dashboard',
|
|
'settings': 'settings',
|
|
'ads': 'ad-control',
|
|
'contributions': 'contributions',
|
|
'leads': 'leads',
|
|
'audit': 'audit-log',
|
|
}
|
|
modules = {'dashboard'}
|
|
for scope in scopes:
|
|
prefix = scope.split('.')[0]
|
|
if prefix in SCOPE_TO_MODULE:
|
|
modules.add(SCOPE_TO_MODULE[prefix])
|
|
return list(modules)
|
|
|
|
def __str__(self):
|
|
return f"{self.user.username} ({self.staff_role})"
|
|
|
|
|
|
class CustomRole(models.Model):
|
|
name = models.CharField(max_length=100)
|
|
slug = models.SlugField(unique=True)
|
|
description = models.TextField(blank=True, default='')
|
|
scopes = models.JSONField(default=list)
|
|
is_system = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class AuditLog(models.Model):
|
|
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='audit_logs')
|
|
action = models.CharField(max_length=100)
|
|
target_type = models.CharField(max_length=50)
|
|
target_id = models.CharField(max_length=50)
|
|
details = models.JSONField(default=dict)
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
# Fast filter-by-action ordered by time (audit log page default view)
|
|
models.Index(fields=['action', '-created_at'], name='auditlog_action_time_idx'),
|
|
# Fast "related entries for this target" lookups in the detail panel
|
|
models.Index(fields=['target_type', 'target_id'], name='auditlog_target_idx'),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.action} by {self.user} at {self.created_at}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lead Manager
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Lead(models.Model):
|
|
EVENT_TYPE_CHOICES = [
|
|
('private', 'Private Event'),
|
|
('ticketed', 'Ticketed Event'),
|
|
('corporate', 'Corporate Event'),
|
|
('wedding', 'Wedding'),
|
|
('other', 'Other'),
|
|
]
|
|
STATUS_CHOICES = [
|
|
('new', 'New'),
|
|
('contacted', 'Contacted'),
|
|
('qualified', 'Qualified'),
|
|
('converted', 'Converted'),
|
|
('closed', 'Closed'),
|
|
]
|
|
SOURCE_CHOICES = [
|
|
('schedule_call', 'Schedule a Call'),
|
|
('website', 'Website'),
|
|
('manual', 'Manual'),
|
|
]
|
|
PRIORITY_CHOICES = [
|
|
('low', 'Low'),
|
|
('medium', 'Medium'),
|
|
('high', 'High'),
|
|
]
|
|
|
|
name = models.CharField(max_length=200)
|
|
email = models.EmailField()
|
|
phone = models.CharField(max_length=20)
|
|
event_type = models.CharField(max_length=20, choices=EVENT_TYPE_CHOICES, default='private')
|
|
message = models.TextField(blank=True, default='')
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new')
|
|
source = models.CharField(max_length=20, choices=SOURCE_CHOICES, default='schedule_call')
|
|
priority = models.CharField(max_length=10, choices=PRIORITY_CHOICES, default='medium')
|
|
assigned_to = models.ForeignKey(
|
|
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_leads'
|
|
)
|
|
user_account = models.ForeignKey(
|
|
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='submitted_leads',
|
|
help_text='Consumer platform account that submitted this lead (auto-matched by email)'
|
|
)
|
|
notes = models.TextField(blank=True, default='')
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['priority']),
|
|
models.Index(fields=['created_at']),
|
|
models.Index(fields=['email']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f'Lead #{self.pk} — {self.name} ({self.status})'
|