Added admin page

This commit is contained in:
gulimabr
2025-12-01 12:39:13 -03:00
parent 74454c7b6b
commit a52a669521
13 changed files with 1430 additions and 65 deletions

View File

@@ -318,7 +318,12 @@ class RelationshipType(Base):
# Relationships
project: Mapped["Project"] = relationship("Project", back_populates="relationship_types")
links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="relationship_type")
links: Mapped[List["RequirementLink"]] = relationship(
"RequirementLink",
back_populates="relationship_type",
cascade="all, delete-orphan",
passive_deletes=True
)
# Constraints
__table_args__ = (

View File

@@ -11,8 +11,9 @@ from src.models import (
RequirementCreateRequest, RequirementUpdateRequest,
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
RelationshipTypeResponse, RelationshipTypeCreateRequest,
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult,
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES
)
from src.controller import AuthController
from src.config import get_openid, get_settings
@@ -521,6 +522,240 @@ async def remove_project_member(
await db.commit()
# ===========================================
# Admin Endpoints (Role Management, Project Admin)
# ===========================================
@app.get("/api/roles", response_model=List[RoleResponse])
async def get_all_roles(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get all available roles with their display names.
Returns:
List of roles with id, role_name, and display_name.
"""
# Ensure user is authenticated
await _get_current_user_db(request, db)
role_repo = RoleRepository(db)
roles = await role_repo.get_all()
return [RoleResponse.from_role(r) for r in roles]
@app.get("/api/projects/{project_id}/members", response_model=List[ProjectMemberResponse])
async def get_project_members(
project_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get all members of a project with their role information.
User must be a member of the project.
Args:
project_id: The project ID
Returns:
List of project members with role info.
"""
user = await _get_current_user_db(request, db)
await _verify_project_membership(project_id, user.id, db)
project_repo = ProjectRepository(db)
members = await project_repo.get_members(project_id)
return [
ProjectMemberResponse(
id=member.id,
sub=member.sub,
role_id=member.role_id,
role_name=member.role.role_name if member.role else "unknown",
role_display_name=ROLE_DISPLAY_NAMES.get(member.role.role_name, member.role.role_name.title()) if member.role else "Unknown",
created_at=member.created_at
)
for member in members
]
@app.put("/api/projects/{project_id}/members/{user_id}/role", response_model=ProjectMemberResponse)
async def update_member_role(
project_id: int,
user_id: int,
request: Request,
role_data: UserRoleUpdateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Update a project member's role.
Only project admins (role_id=3) can update roles.
Admin cannot demote themselves.
Args:
project_id: The project ID
user_id: The user ID to update
role_data: The new role ID
Returns:
The updated member info.
"""
current_user = await _get_current_user_db(request, db)
# Only admins (role_id=3) can update roles
_require_role(current_user, [3], "update member roles")
await _verify_project_membership(project_id, current_user.id, db)
# Check target user is a member of the project
project_repo = ProjectRepository(db)
if not await project_repo.is_member(project_id, user_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User is not a member of this project"
)
# Prevent self-demotion
if current_user.id == user_id and role_data.role_id != 3:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot demote yourself. Ask another admin to change your role."
)
# Verify role exists
role_repo = RoleRepository(db)
role = await role_repo.get_by_id(role_data.role_id)
if not role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role id {role_data.role_id}"
)
# Update the user's role
from src.repositories import UserRepository
user_repo = UserRepository(db)
updated_user = await user_repo.update_role(user_id, role_data.role_id)
if not updated_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with id {user_id} not found"
)
await db.commit()
return ProjectMemberResponse(
id=updated_user.id,
sub=updated_user.sub,
role_id=updated_user.role_id,
role_name=role.role_name,
role_display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title()),
created_at=updated_user.created_at
)
@app.put("/api/projects/{project_id}/relationship-types/{type_id}", response_model=RelationshipTypeResponse)
async def update_relationship_type(
project_id: int,
type_id: int,
request: Request,
type_data: RelationshipTypeUpdateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Update a relationship type.
Only project admins (role_id=3) can update relationship types.
Args:
project_id: The project ID
type_id: The relationship type ID to update
type_data: The updated relationship type data
Returns:
The updated relationship type.
"""
user = await _get_current_user_db(request, db)
# Only admins (role_id=3) can update relationship types
_require_role(user, [3], "update relationship types")
await _verify_project_membership(project_id, user.id, db)
rel_type_repo = RelationshipTypeRepository(db)
# Check if relationship type exists and belongs to the project
existing_type = await rel_type_repo.get_by_id(type_id)
if not existing_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Relationship type with id {type_id} not found"
)
if existing_type.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Relationship type does not belong to this project"
)
updated_type = await rel_type_repo.update(
relationship_type_id=type_id,
type_name=type_data.type_name,
type_description=type_data.type_description,
inverse_type_name=type_data.inverse_type_name
)
await db.commit()
return RelationshipTypeResponse.model_validate(updated_type)
@app.delete("/api/projects/{project_id}/relationship-types/{type_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_relationship_type(
project_id: int,
type_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Delete a relationship type.
Only project admins (role_id=3) can delete relationship types.
This will also delete all links using this relationship type.
Args:
project_id: The project ID
type_id: The relationship type ID to delete
"""
user = await _get_current_user_db(request, db)
# Only admins (role_id=3) can delete relationship types
_require_role(user, [3], "delete relationship types")
await _verify_project_membership(project_id, user.id, db)
rel_type_repo = RelationshipTypeRepository(db)
# Check if relationship type exists and belongs to the project
existing_type = await rel_type_repo.get_by_id(type_id)
if not existing_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Relationship type with id {type_id} not found"
)
if existing_type.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Relationship type does not belong to this project"
)
deleted = await rel_type_repo.delete(type_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Relationship type with id {type_id} not found"
)
await db.commit()
# ===========================================
# Requirements Endpoints
# ===========================================
@@ -968,7 +1203,7 @@ async def create_relationship_type(
):
"""
Create a new relationship type for a project.
Only admins (role_id=1) can create relationship types.
Only project admins (role_id=3) can create relationship types.
Args:
project_id: The project ID
@@ -979,8 +1214,8 @@ async def create_relationship_type(
"""
user = await _get_current_user_db(request, db)
# Only admins can create relationship types
_require_role(user, [1], "create relationship types")
# Only admins (role_id=3) can create relationship types
_require_role(user, [3], "create relationship types")
await _verify_project_membership(project_id, user.id, db)

View File

@@ -20,7 +20,52 @@ class UserInfo(BaseModel):
full_name: Optional[str] = None
db_user_id: Optional[int] = None # Database user ID (populated after login)
role: Optional[str] = None # User role name
role_id: Optional[int] = None # User role ID (1=admin, 2=auditor, 3=user, etc.)
role_id: Optional[int] = None # User role ID (1=editor, 2=auditor, 3=admin)
# Role schemas
ROLE_DISPLAY_NAMES = {
"editor": "Editor",
"auditor": "Auditor",
"admin": "Project Admin"
}
class RoleResponse(BaseModel):
"""Response schema for a role."""
id: int
role_name: str
display_name: str
class Config:
from_attributes = True
@classmethod
def from_role(cls, role) -> "RoleResponse":
"""Create a RoleResponse from a Role model."""
return cls(
id=role.id,
role_name=role.role_name,
display_name=ROLE_DISPLAY_NAMES.get(role.role_name, role.role_name.title())
)
class ProjectMemberResponse(BaseModel):
"""Response schema for a project member with role info."""
id: int
sub: str
role_id: int
role_name: str
role_display_name: str
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class UserRoleUpdateRequest(BaseModel):
"""Request schema for updating a user's role."""
role_id: int
# Project schemas
@@ -215,6 +260,13 @@ class RelationshipTypeCreateRequest(BaseModel):
inverse_type_name: Optional[str] = None
class RelationshipTypeUpdateRequest(BaseModel):
"""Request schema for updating a relationship type."""
type_name: Optional[str] = None
type_description: Optional[str] = None
inverse_type_name: Optional[str] = None
# Requirement Link schemas
class LinkedRequirementInfo(BaseModel):
"""Brief info about a linked requirement."""

View File

@@ -229,6 +229,7 @@ class ProjectRepository:
"""
result = await self.session.execute(
select(User)
.options(selectinload(User.role))
.join(ProjectMember, User.id == ProjectMember.user_id)
.where(ProjectMember.project_id == project_id)
)

View File

@@ -76,6 +76,40 @@ class RelationshipTypeRepository:
await self.db.refresh(relationship_type)
return relationship_type
async def update(
self,
relationship_type_id: int,
type_name: Optional[str] = None,
type_description: Optional[str] = None,
inverse_type_name: Optional[str] = None
) -> Optional[RelationshipType]:
"""
Update a relationship type.
Args:
relationship_type_id: The ID of the relationship type to update
type_name: New name (optional)
type_description: New description (optional)
inverse_type_name: New inverse name (optional)
Returns:
The updated relationship type or None if not found.
"""
relationship_type = await self.get_by_id(relationship_type_id)
if not relationship_type:
return None
if type_name is not None:
relationship_type.type_name = type_name
if type_description is not None:
relationship_type.type_description = type_description
if inverse_type_name is not None:
relationship_type.inverse_type_name = inverse_type_name
await self.db.flush()
await self.db.refresh(relationship_type)
return relationship_type
async def delete(self, relationship_type_id: int) -> bool:
"""
Delete a relationship type by ID.

View File

@@ -2,7 +2,7 @@
Repository layer for User database operations.
Handles CRUD operations and user provisioning on first login.
"""
from typing import Optional
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -11,8 +11,64 @@ import logging
logger = logging.getLogger(__name__)
# Default role for new users
DEFAULT_ROLE_NAME = "user"
# Default role for new users (editor = role_id 1)
DEFAULT_ROLE_NAME = "editor"
class RoleRepository:
"""Repository for Role-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_name(self, role_name: str) -> Optional[Role]:
"""Get a role by name."""
result = await self.session.execute(
select(Role).where(Role.role_name == role_name)
)
return result.scalar_one_or_none()
async def get_by_id(self, role_id: int) -> Optional[Role]:
"""Get a role by ID."""
result = await self.session.execute(
select(Role).where(Role.id == role_id)
)
return result.scalar_one_or_none()
async def create(self, role_name: str) -> Role:
"""Create a new role."""
role = Role(role_name=role_name)
self.session.add(role)
await self.session.flush()
await self.session.refresh(role)
return role
async def get_all(self) -> List[Role]:
"""
Get all roles.
Returns:
List of all roles
"""
result = await self.session.execute(
select(Role).order_by(Role.id)
)
return list(result.scalars().all())
async def ensure_default_roles_exist(self) -> None:
"""
Ensure default roles exist in the database.
Called during application startup.
Creates roles in order: editor (1), auditor (2), admin (3)
"""
# Order matters for role IDs: editor=1, auditor=2, admin=3
default_roles = ["editor", "auditor", "admin"]
for role_name in default_roles:
existing = await self.get_by_name(role_name)
if existing is None:
logger.info(f"Creating default role: {role_name}")
await self.create(role_name)
class UserRepository:
@@ -72,6 +128,26 @@ class UserRepository:
await self.session.refresh(user)
return user
async def update_role(self, user_id: int, role_id: int) -> Optional[User]:
"""
Update a user's role.
Args:
user_id: The user ID to update
role_id: The new role ID
Returns:
The updated User, or None if not found
"""
user = await self.get_by_id(user_id)
if not user:
return None
user.role_id = role_id
await self.session.flush()
await self.session.refresh(user)
return user
async def get_or_create_default_role(self) -> Role:
"""
Get the default user role, creating it if it doesn't exist.
@@ -79,17 +155,12 @@ class UserRepository:
Returns:
The default Role
"""
result = await self.session.execute(
select(Role).where(Role.role_name == DEFAULT_ROLE_NAME)
)
role = result.scalar_one_or_none()
role_repo = RoleRepository(self.session)
role = await role_repo.get_by_name(DEFAULT_ROLE_NAME)
if role is None:
logger.info(f"Creating default role: {DEFAULT_ROLE_NAME}")
role = Role(role_name=DEFAULT_ROLE_NAME)
self.session.add(role)
await self.session.flush()
await self.session.refresh(role)
role = await role_repo.create(DEFAULT_ROLE_NAME)
return role
@@ -122,45 +193,3 @@ class UserRepository:
logger.info(f"Created new user with id: {user.id}, sub: {sub}")
return user, True
class RoleRepository:
"""Repository for Role-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_name(self, role_name: str) -> Optional[Role]:
"""Get a role by name."""
result = await self.session.execute(
select(Role).where(Role.role_name == role_name)
)
return result.scalar_one_or_none()
async def get_by_id(self, role_id: int) -> Optional[Role]:
"""Get a role by ID."""
result = await self.session.execute(
select(Role).where(Role.id == role_id)
)
return result.scalar_one_or_none()
async def create(self, role_name: str) -> Role:
"""Create a new role."""
role = Role(role_name=role_name)
self.session.add(role)
await self.session.flush()
await self.session.refresh(role)
return role
async def ensure_default_roles_exist(self) -> None:
"""
Ensure default roles exist in the database.
Called during application startup.
"""
default_roles = ["admin", "user", "viewer"]
for role_name in default_roles:
existing = await self.get_by_name(role_name)
if existing is None:
logger.info(f"Creating default role: {role_name}")
await self.create(role_name)