- SCOPE_DEFINITIONS extended with 13 new scopes across 4 categories so the admin Roles & Permissions grid and new Base Permissions tab can grant module-level access - StaffProfile.SCOPE_TO_MODULE was missing 'reviews': 'reviews' — staff with reviews.* scopes could not resolve the Reviews module in their sidebar - NotificationSchedule CRUD views now emit AuditLog rows (notification.schedule.created / .updated / .deleted) matching the v1.13.0 audit coverage pattern Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
255 lines
9.4 KiB
Python
255 lines
9.4 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',
|
|
'reviews': 'reviews',
|
|
}
|
|
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})'
|