Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""replace user role enum with boolean flags

Revision ID: 29dd08592b64
Revises: f2a7b7ac51e6
Create Date: 2026-07-15 21:05:10.541763

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import mysql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "29dd08592b64"
down_revision: Union[str, None] = "f2a7b7ac51e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_leader", sa.Boolean(), nullable=False, server_default="0"),
)
op.add_column(
"users",
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="0"),
)
op.add_column(
"users",
sa.Column("is_president", sa.Boolean(), nullable=False, server_default="0"),
)

op.execute(
"UPDATE users SET is_leader = 1 "
"WHERE role IN ('LEADER', 'ADMIN_AND_LEADER', 'LEADER_AND_PRESIDENT')"
)
op.execute(
"UPDATE users SET is_admin = 1 "
"WHERE role IN ('ADMIN', 'PRESIDENT', 'ADMIN_AND_LEADER', 'LEADER_AND_PRESIDENT')"
)
op.execute(
"UPDATE users SET is_president = 1 "
"WHERE role IN ('PRESIDENT', 'LEADER_AND_PRESIDENT')"
)

op.drop_index("idx_users_role", table_name="users")
op.drop_column("users", "role")

op.create_index("idx_users_is_leader", "users", ["is_leader"])
op.create_index("idx_users_is_admin", "users", ["is_admin"])
op.create_index("idx_users_is_president", "users", ["is_president"])


def downgrade() -> None:
op.add_column(
"users",
sa.Column(
"role",
mysql.ENUM(
"MEMBER",
"LEADER",
"ADMIN",
"PRESIDENT",
"ADMIN_AND_LEADER",
"LEADER_AND_PRESIDENT",
name="userrole",
),
nullable=False,
server_default="MEMBER",
),
)
op.execute(
"""
UPDATE users SET role = CASE
WHEN is_leader = 1 AND is_president = 1 THEN 'LEADER_AND_PRESIDENT'
WHEN is_leader = 1 AND is_admin = 1 THEN 'ADMIN_AND_LEADER'
WHEN is_president = 1 THEN 'PRESIDENT'
WHEN is_admin = 1 THEN 'ADMIN'
WHEN is_leader = 1 THEN 'LEADER'
ELSE 'MEMBER'
END
"""
)

op.drop_index("idx_users_is_leader", table_name="users")
op.drop_index("idx_users_is_admin", table_name="users")
op.drop_index("idx_users_is_president", table_name="users")
op.drop_column("users", "is_leader")
op.drop_column("users", "is_admin")
op.drop_column("users", "is_president")

op.create_index("idx_users_role", "users", ["role"])
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""add president roles to user_role enum

Revision ID: f2a7b7ac51e6
Revises: a1b2c3d4e5f6
Create Date: 2026-07-12 08:05:54.190502

"""
from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import mysql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "f2a7b7ac51e6"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.alter_column(
"users",
"role",
existing_type=mysql.ENUM("MEMBER", "LEADER", "ADMIN", "ADMIN_AND_LEADER"),
type_=mysql.ENUM(
"MEMBER",
"LEADER",
"ADMIN",
"PRESIDENT",
"ADMIN_AND_LEADER",
"LEADER_AND_PRESIDENT",
),
nullable=False,
)


def downgrade() -> None:
op.alter_column(
"users",
"role",
existing_type=mysql.ENUM(
"MEMBER",
"LEADER",
"ADMIN",
"PRESIDENT",
"ADMIN_AND_LEADER",
"LEADER_AND_PRESIDENT",
),
type_=mysql.ENUM("MEMBER", "LEADER", "ADMIN", "ADMIN_AND_LEADER"),
nullable=False,
)
2 changes: 1 addition & 1 deletion app/deps/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
Require user to be admin.
Raises 403 if user is not admin.
"""
if not user.is_admin:
if not user.has_admin_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required"
)
Expand Down
2 changes: 1 addition & 1 deletion app/deps/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def require_leader_or_admin(project_id: int, user: User, db: Session) -> Project
Returns the project if authorized, raises 403 otherwise.
"""
# Admin has full access
if user.is_admin:
if user.has_admin_access:
project = ProjectService.get(db, project_id)
if not project:
raise HTTPException(
Expand Down
2 changes: 0 additions & 2 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
MemberRole,
ProjectStatus,
Qualification,
UserRole,
)
from app.models.project import Project
from app.models.project_member import ProjectMember
Expand All @@ -23,7 +22,6 @@
"ProjectStatus",
"MemberRole",
"ApprovalStatus",
"UserRole",
"User",
"AuditLog",
"UserActivity",
Expand Down
7 changes: 0 additions & 7 deletions app/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,6 @@ class MemberRole(str, Enum):
MEMBER = "member"


class UserRole(str, Enum):
MEMBER = "member"
LEADER = "leader"
ADMIN = "admin"
ADMIN_AND_LEADER = "admin_and_leader"


class AuditAction(str, Enum):
QUALIFICATION_CHANGED = "qualification_changed"
ROLE_CHANGED = "role_changed"
Expand Down
26 changes: 15 additions & 11 deletions app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
NotificationChannel,
ProjectStatus,
Qualification,
UserRole,
)


Expand Down Expand Up @@ -48,7 +47,18 @@ class User(Base, TimestampMixin, SoftDeleteMixin):
qualification = Column(
Enum(Qualification), nullable=False, default=Qualification.PENDING
)
role = Column(Enum(UserRole), nullable=False, default=UserRole.MEMBER)
is_leader = Column(Boolean, nullable=False, default=False)
is_admin = Column(Boolean, nullable=False, default=False)
is_president = Column(Boolean, nullable=False, default=False)

@property
def has_admin_access(self) -> bool:
"""Admin-gated actions should check this, not is_admin directly.

The president is the top of the org hierarchy and always has admin
access, whether or not is_admin was also explicitly granted.
"""
return self.is_admin or self.is_president

@property
def current_projects(self):
Expand All @@ -58,14 +68,6 @@ def current_projects(self):
if m.left_at is None and m.project.status != ProjectStatus.ENDED
]

@property
def is_admin(self) -> bool:
return self.role in (UserRole.ADMIN, UserRole.ADMIN_AND_LEADER)

@property
def is_leader(self) -> bool:
return self.role in (UserRole.LEADER, UserRole.ADMIN_AND_LEADER)

# Profile (optional)
phone = Column(String(20), nullable=True)
affiliation = Column(String(200), nullable=True)
Expand Down Expand Up @@ -113,7 +115,9 @@ def is_leader(self) -> bool:

__table_args__ = (
Index("idx_users_qualification", "qualification"),
Index("idx_users_role", "role"),
Index("idx_users_is_leader", "is_leader"),
Index("idx_users_is_admin", "is_admin"),
Index("idx_users_is_president", "is_president"),
Index("idx_users_created_at", "created_at"),
Index("idx_users_is_temporary", "is_temporary"),
# Roster import matches existing members by student_id.
Expand Down
23 changes: 18 additions & 5 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
InvalidAuthTokenError,
UserNotRegisteredError,
)
from app.models import Qualification, User, UserRole
from app.models import Qualification, User
from app.schemas import (
AuthResult,
AuthStatus,
Expand Down Expand Up @@ -508,7 +508,6 @@ async def signup(
bio=request.bio,
github_username=request.github_username,
qualification=Qualification.PENDING,
role=UserRole.MEMBER,
)

# Generate JWT
Expand Down Expand Up @@ -624,7 +623,14 @@ async def signin_dev(

if user:
# Update existing user's admin status and qualification
UserService.update(db, user, role=request.role, qualification=qualification)
UserService.update(
db,
user,
is_leader=request.is_leader,
is_admin=request.is_admin,
is_president=request.is_president,
qualification=qualification,
)
else:
# Create new user with dev google_id
# Handle race condition: if concurrent request created user, catch and retry
Expand All @@ -638,14 +644,21 @@ async def signin_dev(
email=request.email,
name=request.name,
qualification=qualification,
role=request.role,
is_leader=request.is_leader,
is_admin=request.is_admin,
is_president=request.is_president,
)
except IntegrityError:
db.rollback()
user = UserService.get_by_email(db, request.email)
if user:
UserService.update(
db, user, role=request.role, qualification=qualification
db,
user,
is_leader=request.is_leader,
is_admin=request.is_admin,
is_president=request.is_president,
qualification=qualification,
)
else:
raise HTTPException(
Expand Down
36 changes: 33 additions & 3 deletions app/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
RosterFileTooLargeError,
TemporaryMemberApprovalError,
)
from app.models import AuditAction, Qualification, User, UserRole
from app.models import AuditAction, Qualification, User
from app.schemas import (
ActivityCreateRequest,
ActivityDetail,
Expand Down Expand Up @@ -153,6 +153,31 @@ async def get_my_projects(
return Response(ok=True, data=projects)


@router.get(
"/me/activities",
response_model=Response[list[ActivityDetail]],
summary="Get my activities",
description="Returns activities of a current member.",
responses={
200: {"description": "Activities retrieved successfully"},
401: {"description": "Not authenticated"},
403: {"description": "Requires REGULAR qualification or higher"},
},
)
async def get_my_activities(
current_user: User = Depends(require_regular), db: Session = Depends(get_db)
):
"""
Get activities of a current member.

**Requires**: REGULAR qualification or higher.

Returns a list of activities (current projects and histories).
"""
activities = ActivityService.list_by_user(db, current_user.id)
return Response(ok=True, data=activities)


# === Admin management ===
@router.get(
"",
Expand Down Expand Up @@ -374,12 +399,17 @@ async def update_user(
)

# Log role changes
if "role" in update_data and update_data["role"] != user.role:
role_changes = {
field: {"from": getattr(user, field), "to": update_data[field]}
for field in ("is_leader", "is_admin", "is_president")
if field in update_data and update_data[field] != getattr(user, field)
}
if role_changes:
AuditLogService.log(
db=db,
user_id=user.id,
action=AuditAction.ROLE_CHANGED,
payload={"from": user.role.value, "to": update_data["role"].value},
payload=role_changes,
actor_id=admin.id,
)

Expand Down
8 changes: 3 additions & 5 deletions app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from pydantic import BaseModel, EmailStr, Field

from app.models.enums import UserRole
from app.schemas.user import UserDetail


Expand Down Expand Up @@ -86,10 +85,9 @@ class DevSigninRequest(BaseModel):
description="User name for dev signin",
examples=["Admin User"],
)
role: UserRole = Field(
default=UserRole.MEMBER,
description="User role: member, leader, admin, or admin_and_leader",
)
is_leader: bool = Field(default=False, description="Grant leader privileges")
is_admin: bool = Field(default=False, description="Grant admin privileges")
is_president: bool = Field(default=False, description="Grant president privileges")
qualification: Literal["pending", "associate", "regular", "active"] = Field(
default="active",
description="User qualification level",
Expand Down
Loading
Loading