Added admin page
This commit is contained in:
@@ -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__ = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user