RBAC, Permissions, and Export/Import

This commit is contained in:
Koncept Kit
2025-12-16 20:03:50 +07:00
parent b268c3fff8
commit ed5526e27b
27 changed files with 10284 additions and 73 deletions

159
models.py
View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
@@ -8,16 +8,20 @@ from database import Base
class UserStatus(enum.Enum):
pending_email = "pending_email"
pending_approval = "pending_approval"
pre_approved = "pre_approved"
pending_validation = "pending_validation"
pre_validated = "pre_validated"
payment_pending = "payment_pending"
active = "active"
inactive = "inactive"
canceled = "canceled" # User or admin canceled membership
expired = "expired" # Subscription ended without renewal
abandoned = "abandoned" # Incomplete registration (no verification/event/payment)
class UserRole(enum.Enum):
guest = "guest"
member = "member"
admin = "admin"
superadmin = "superadmin"
class RSVPStatus(enum.Enum):
yes = "yes"
@@ -50,7 +54,8 @@ class User(Base):
partner_plan_to_become_member = Column(Boolean, default=False)
referred_by_member_name = Column(String, nullable=True)
status = Column(SQLEnum(UserStatus), default=UserStatus.pending_email, nullable=False)
role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False)
role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) # Legacy enum, kept for backward compatibility
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True) # New dynamic role FK
email_verified = Column(Boolean, default=False)
email_verification_token = Column(String, nullable=True)
newsletter_subscribed = Column(Boolean, default=False)
@@ -89,10 +94,31 @@ class User(Base):
social_media_twitter = Column(String, nullable=True)
social_media_linkedin = Column(String, nullable=True)
# Terms of Service Acceptance (Step 4)
accepts_tos = Column(Boolean, default=False, nullable=False)
tos_accepted_at = Column(DateTime, nullable=True)
# Member Since Date - Editable by admins for imported users
member_since = Column(DateTime, nullable=True, comment="Date when user became a member - editable by admins for imported users")
# Reminder Tracking - for admin dashboard visibility
email_verification_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of email verification reminders sent")
last_email_verification_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last verification reminder")
event_attendance_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of event attendance reminders sent")
last_event_attendance_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last event attendance reminder")
payment_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of payment reminders sent")
last_payment_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last payment reminder")
renewal_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of renewal reminders sent")
last_renewal_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last renewal reminder")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
role_obj = relationship("Role", back_populates="users", foreign_keys=[role_id])
events_created = relationship("Event", back_populates="creator")
rsvps = relationship("EventRSVP", back_populates="user")
subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id")
@@ -271,3 +297,128 @@ class StorageUsage(Base):
total_bytes_used = Column(BigInteger, default=0)
max_bytes_allowed = Column(BigInteger, nullable=False) # From .env
last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# ============================================================
# RBAC Permission Management Models
# ============================================================
class Permission(Base):
"""Granular permissions for role-based access control"""
__tablename__ = "permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String, unique=True, nullable=False, index=True) # "users.create", "events.edit"
name = Column(String, nullable=False) # "Create Users", "Edit Events"
description = Column(Text, nullable=True)
module = Column(String, nullable=False, index=True) # "users", "events", "subscriptions"
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
class Role(Base):
"""Dynamic roles that can be created by admins"""
__tablename__ = "roles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String, unique=True, nullable=False, index=True) # "superadmin", "finance", "custom_role_1"
name = Column(String, nullable=False) # "Superadmin", "Finance Manager", "Custom Role"
description = Column(Text, nullable=True)
is_system_role = Column(Boolean, default=False, nullable=False) # True for Superadmin, Member, Guest (non-deletable)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Relationships
users = relationship("User", back_populates="role_obj", foreign_keys="User.role_id")
role_permissions = relationship("RolePermission", back_populates="role_obj", cascade="all, delete-orphan")
creator = relationship("User", foreign_keys=[created_by])
class RolePermission(Base):
"""Junction table linking roles to permissions"""
__tablename__ = "role_permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
role = Column(SQLEnum(UserRole), nullable=False, index=True) # Legacy enum, kept for backward compatibility
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True) # New dynamic role FK
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Relationships
role_obj = relationship("Role", back_populates="role_permissions")
permission = relationship("Permission", back_populates="role_permissions")
creator = relationship("User", foreign_keys=[created_by])
# Composite unique index
__table_args__ = (
Index('idx_role_permission', 'role', 'permission_id', unique=True),
)
# ============================================================
# User Invitation Models
# ============================================================
class InvitationStatus(enum.Enum):
pending = "pending"
accepted = "accepted"
expired = "expired"
revoked = "revoked"
class UserInvitation(Base):
"""Email-based user invitations with tokens"""
__tablename__ = "user_invitations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String, nullable=False, index=True)
token = Column(String, unique=True, nullable=False, index=True)
role = Column(SQLEnum(UserRole), nullable=False)
status = Column(SQLEnum(InvitationStatus), default=InvitationStatus.pending, nullable=False)
# Optional pre-filled information
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
phone = Column(String, nullable=True)
# Invitation tracking
invited_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
invited_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
expires_at = Column(DateTime, nullable=False)
accepted_at = Column(DateTime, nullable=True)
accepted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Relationships
inviter = relationship("User", foreign_keys=[invited_by])
accepted_user = relationship("User", foreign_keys=[accepted_by])
# ============================================================
# CSV Import/Export Models
# ============================================================
class ImportJobStatus(enum.Enum):
processing = "processing"
completed = "completed"
failed = "failed"
partial = "partial"
class ImportJob(Base):
"""Track CSV import jobs with error handling"""
__tablename__ = "import_jobs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String, nullable=False)
file_key = Column(String, nullable=True) # R2 object key for uploaded CSV
total_rows = Column(Integer, nullable=False)
processed_rows = Column(Integer, default=0, nullable=False)
successful_rows = Column(Integer, default=0, nullable=False)
failed_rows = Column(Integer, default=0, nullable=False)
status = Column(SQLEnum(ImportJobStatus), default=ImportJobStatus.processing, nullable=False)
errors = Column(JSON, default=list, nullable=False) # [{row: 5, field: "email", error: "Invalid format"}]
# Tracking
imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
completed_at = Column(DateTime, nullable=True)
# Relationships
importer = relationship("User", foreign_keys=[imported_by])