feat: add RBAC migrations, user modules, admin API updates, and utility scripts

This commit is contained in:
2026-04-02 04:06:02 +00:00
parent 1b6185c758
commit 255519473b
10 changed files with 481 additions and 8 deletions

View File

@@ -0,0 +1,92 @@
# Generated by Django 4.2.21 on 2026-03-26 13:42
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_api', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CustomRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Department',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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(default='#3B82F6', max_length=7)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Squad',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('extra_scopes', models.JSONField(default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='squads', to='admin_api.department')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_squads', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='StaffProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('staff_role', models.CharField(choices=[('SUPER_ADMIN', 'Super Admin'), ('MANAGER', 'Manager'), ('MEMBER', 'Member')], default='MEMBER', max_length=20)),
('status', models.CharField(choices=[('active', 'Active'), ('invited', 'Invited'), ('deactivated', 'Deactivated')], default='active', max_length=20)),
('joined_at', models.DateTimeField(auto_now_add=True)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_members', to='admin_api.department')),
('squad', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='admin_api.squad')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='staff_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['user__first_name'],
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -63,3 +63,121 @@ class ReviewInteraction(models.Model):
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', 'financials', 'settings']
SCOPE_TO_MODULE = {
'users': 'users',
'events': 'events',
'finance': 'financials',
'partners': 'partners',
'tickets': 'dashboard',
'settings': 'settings',
'ads': 'ad-control',
'contributions': 'contributions',
}
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']
def __str__(self):
return f"{self.action} by {self.user} at {self.created_at}"

View File

@@ -18,6 +18,8 @@ urlpatterns = [
path('partners/<int:pk>/', views.PartnerDetailView.as_view(), name='partner-detail'),
path('partners/<int:pk>/status/', views.PartnerStatusView.as_view(), name='partner-status'),
path('partners/<int:pk>/kyc/review/', views.PartnerKYCReviewView.as_view(), name='partner-kyc-review'),
path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
path('users/', views.UserListView.as_view(), name='user-list'),
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
@@ -41,8 +43,34 @@ urlpatterns = [
path('reviews/<int:pk>/moderate/', views.ReviewModerationView.as_view(), name='review-moderate'),
path('reviews/<int:pk>/', views.ReviewDeleteView.as_view(), name='review-delete'),
path('gamification/submit-event/', views.GamificationSubmitEventView.as_view(), name='gamification-submit-event'),
path('gamification/submit-event', views.GamificationSubmitEventView.as_view()),
path('shop/items/', views.ShopItemsView.as_view(), name='shop-items'),
path('shop/items', views.ShopItemsView.as_view()),
path('shop/redeem/', views.ShopRedeemView.as_view(), name='shop-redeem'),
path('shop/redeem', views.ShopRedeemView.as_view()),
path('gamification/dashboard/', views.GamificationDashboardView.as_view(), name='gamification-dashboard'),
path('gamification/dashboard', views.GamificationDashboardView.as_view()),
# Payment gateway settings
path('settings/payment-gateway/active/', views.ActivePaymentGatewayView.as_view(), name='active-payment-gateway'),
path('settings/payment-gateways/', views.PaymentGatewaySettingsView.as_view(), name='payment-gateways'),
path('settings/payment-gateways/<int:pk>/', views.PaymentGatewaySettingsView.as_view(), name='payment-gateway-detail'),
# RBAC
path('rbac/departments/', views.DepartmentListCreateView.as_view(), name='rbac-department-list'),
path('rbac/departments/<int:pk>/', views.DepartmentDetailView.as_view(), name='rbac-department-detail'),
path('rbac/squads/', views.SquadListCreateView.as_view(), name='rbac-squad-list'),
path('rbac/squads/<int:pk>/', views.SquadDetailView.as_view(), name='rbac-squad-detail'),
path('rbac/staff/', views.StaffListView.as_view(), name='rbac-staff-list'),
path('rbac/staff/invite/', views.StaffInviteView.as_view(), name='rbac-staff-invite'),
path('rbac/staff/<int:pk>/', views.StaffUpdateView.as_view(), name='rbac-staff-update'),
path('rbac/staff/<int:pk>/deactivate/', views.StaffDeactivateView.as_view(), name='rbac-staff-deactivate'),
path('rbac/staff/<int:pk>/move/', views.StaffMoveView.as_view(), name='rbac-staff-move'),
path('rbac/roles/', views.RoleListCreateView.as_view(), name='rbac-role-list'),
path('rbac/roles/<int:pk>/', views.RoleDetailView.as_view(), name='rbac-role-detail'),
path('rbac/scopes/', views.ScopeListView.as_view(), name='rbac-scope-list'),
path('rbac/org-tree/', views.OrgTreeView.as_view(), name='rbac-org-tree'),
path('rbac/audit-log/', views.AuditLogListView.as_view(), name='rbac-audit-log'),
]

View File

@@ -682,6 +682,8 @@ def _serialize_event(e):
'isFeatured': bool(e.is_featured),
'isTopEvent': bool(e.is_top_event),
'source': e.source or 'eventify',
'eventTypeId': e.event_type_id,
'eventTypeName': e.event_type.event_type if e.event_type_id and e.event_type else '',
}
@@ -740,11 +742,13 @@ class EventListView(APIView):
def get(self, request):
from events.models import Event
from django.db.models import Q
qs = Event.objects.select_related('partner').all()
qs = Event.objects.select_related('partner', 'event_type').all()
if s := request.GET.get('status'):
reverse_map = {v: k for k, v in _EVENT_STATUS_MAP.items()}
backend_status = reverse_map.get(s, s)
qs = qs.filter(event_status=backend_status)
if etid := request.GET.get('event_type'):
qs = qs.filter(event_type_id=etid)
if pid := request.GET.get('partner_id'):
qs = qs.filter(partner_id=pid)
if q := request.GET.get('search'):
@@ -2238,3 +2242,91 @@ class PartnerStaffCreateView(APIView):
},
status=status.HTTP_201_CREATED,
)
# ─── Gamification Dashboard (stub) ───────────────────────────────────────────
class GamificationDashboardView(APIView):
permission_classes = [] # public for now; restrict when auth is wired up
def get(self, request):
user_id = request.GET.get('user_id', '')
return Response({
'status': 'success',
'profile': {
'user_id': user_id,
'current_tier': 'BRONZE',
'current_ep': 0,
'current_rp': 0,
'lifetime_ep': 0,
},
'submissions': [],
})
# ─── Gamification: Event Submission (stub) ────────────────────────────────────
class GamificationSubmitEventView(APIView):
permission_classes = []
def post(self, request):
data = request.data
return Response({
'status': 'success',
'submission': {
'id': 1,
'event_name': data.get('event_name', ''),
'status': 'PENDING',
'total_ep_awarded': 0,
'created_at': __import__('datetime').datetime.now().isoformat(),
},
'message': 'Event submitted for review. You will earn EP once approved!',
})
# ─── Reward Shop: List Items (stub) ──────────────────────────────────────────
class ShopItemsView(APIView):
permission_classes = []
def get(self, request):
return Response({
'status': 'success',
'items': [
{
'id': 1,
'name': 'BookMyShow Voucher',
'description': 'Get a Rs.100 BookMyShow gift card',
'rp_cost': 50,
'stock_quantity': 10,
},
{
'id': 2,
'name': 'Event Priority Listing',
'description': 'Feature your next event at the top for 7 days',
'rp_cost': 30,
'stock_quantity': 5,
},
{
'id': 3,
'name': 'Eventify Merch Pack',
'description': 'Exclusive stickers, badge & notebook',
'rp_cost': 100,
'stock_quantity': 3,
},
],
})
# ─── Reward Shop: Redeem (stub) ──────────────────────────────────────────────
class ShopRedeemView(APIView):
permission_classes = []
def post(self, request):
import uuid
item_id = request.data.get('item_id')
return Response({
'status': 'success',
'voucher': {
'item_id': item_id,
'voucher_code_issued': 'EVF-' + uuid.uuid4().hex[:8].upper(),
},
'message': 'Reward redeemed successfully!',
})