feat: Implement user and role management with repositories
- Added RoleRepository and UserRepository for managing roles and users. - Implemented methods for creating, retrieving, and assigning roles to users. - Introduced functions to ensure default roles and an admin user exist in the system. - Updated UnitOfWork to include user and role repositories. - Created new security module for password hashing and JWT token management. - Added tests for authentication flows, including registration, login, and password reset. - Enhanced HTML templates for user registration, login, and password management with error handling. - Added a logo image to the static assets.
This commit is contained in:
@@ -8,7 +8,16 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
|
||||
from models import FinancialInput, Project, Scenario, ScenarioStatus, SimulationParameter
|
||||
from models import (
|
||||
FinancialInput,
|
||||
Project,
|
||||
Role,
|
||||
Scenario,
|
||||
ScenarioStatus,
|
||||
SimulationParameter,
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from services.exceptions import EntityConflictError, EntityNotFoundError
|
||||
|
||||
|
||||
@@ -211,3 +220,220 @@ class SimulationParameterRepository:
|
||||
raise EntityNotFoundError(
|
||||
f"Simulation parameter {parameter_id} not found")
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class RoleRepository:
|
||||
"""Persistence operations for Role entities."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list(self) -> Sequence[Role]:
|
||||
stmt = select(Role).order_by(Role.name)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def get(self, role_id: int) -> Role:
|
||||
stmt = select(Role).where(Role.id == role_id)
|
||||
role = self.session.execute(stmt).scalar_one_or_none()
|
||||
if role is None:
|
||||
raise EntityNotFoundError(f"Role {role_id} not found")
|
||||
return role
|
||||
|
||||
def get_by_name(self, name: str) -> Role | None:
|
||||
stmt = select(Role).where(Role.name == name)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def create(self, role: Role) -> Role:
|
||||
self.session.add(role)
|
||||
try:
|
||||
self.session.flush()
|
||||
except IntegrityError as exc: # pragma: no cover - DB constraint enforcement
|
||||
raise EntityConflictError(
|
||||
"Role violates uniqueness constraints") from exc
|
||||
return role
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Persistence operations for User entities and their role assignments."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list(self, *, with_roles: bool = False) -> Sequence[User]:
|
||||
stmt = select(User).order_by(User.created_at)
|
||||
if with_roles:
|
||||
stmt = stmt.options(selectinload(User.roles))
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def _apply_role_option(self, stmt, with_roles: bool):
|
||||
if with_roles:
|
||||
stmt = stmt.options(
|
||||
joinedload(User.role_assignments).joinedload(UserRole.role),
|
||||
selectinload(User.roles),
|
||||
)
|
||||
return stmt
|
||||
|
||||
def get(self, user_id: int, *, with_roles: bool = False) -> User:
|
||||
stmt = select(User).where(User.id == user_id).execution_options(
|
||||
populate_existing=True)
|
||||
stmt = self._apply_role_option(stmt, with_roles)
|
||||
result = self.session.execute(stmt)
|
||||
if with_roles:
|
||||
result = result.unique()
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise EntityNotFoundError(f"User {user_id} not found")
|
||||
return user
|
||||
|
||||
def get_by_email(self, email: str, *, with_roles: bool = False) -> User | None:
|
||||
stmt = select(User).where(User.email == email).execution_options(
|
||||
populate_existing=True)
|
||||
stmt = self._apply_role_option(stmt, with_roles)
|
||||
result = self.session.execute(stmt)
|
||||
if with_roles:
|
||||
result = result.unique()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def get_by_username(self, username: str, *, with_roles: bool = False) -> User | None:
|
||||
stmt = select(User).where(User.username ==
|
||||
username).execution_options(populate_existing=True)
|
||||
stmt = self._apply_role_option(stmt, with_roles)
|
||||
result = self.session.execute(stmt)
|
||||
if with_roles:
|
||||
result = result.unique()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def create(self, user: User) -> User:
|
||||
self.session.add(user)
|
||||
try:
|
||||
self.session.flush()
|
||||
except IntegrityError as exc: # pragma: no cover - DB constraint enforcement
|
||||
raise EntityConflictError(
|
||||
"User violates uniqueness constraints") from exc
|
||||
return user
|
||||
|
||||
def assign_role(
|
||||
self,
|
||||
*,
|
||||
user_id: int,
|
||||
role_id: int,
|
||||
granted_by: int | None = None,
|
||||
) -> UserRole:
|
||||
stmt = select(UserRole).where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.role_id == role_id,
|
||||
)
|
||||
assignment = self.session.execute(stmt).scalar_one_or_none()
|
||||
if assignment:
|
||||
return assignment
|
||||
|
||||
assignment = UserRole(
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
granted_by=granted_by,
|
||||
)
|
||||
self.session.add(assignment)
|
||||
try:
|
||||
self.session.flush()
|
||||
except IntegrityError as exc: # pragma: no cover - DB constraint enforcement
|
||||
raise EntityConflictError(
|
||||
"Assignment violates constraints") from exc
|
||||
return assignment
|
||||
|
||||
def revoke_role(self, *, user_id: int, role_id: int) -> None:
|
||||
stmt = select(UserRole).where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.role_id == role_id,
|
||||
)
|
||||
assignment = self.session.execute(stmt).scalar_one_or_none()
|
||||
if assignment is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Role {role_id} not assigned to user {user_id}")
|
||||
self.session.delete(assignment)
|
||||
self.session.flush()
|
||||
|
||||
|
||||
DEFAULT_ROLE_DEFINITIONS: tuple[dict[str, str], ...] = (
|
||||
{
|
||||
"name": "admin",
|
||||
"display_name": "Administrator",
|
||||
"description": "Full platform access with user management rights.",
|
||||
},
|
||||
{
|
||||
"name": "project_manager",
|
||||
"display_name": "Project Manager",
|
||||
"description": "Manage projects, scenarios, and associated data.",
|
||||
},
|
||||
{
|
||||
"name": "analyst",
|
||||
"display_name": "Analyst",
|
||||
"description": "Review dashboards and scenario outputs.",
|
||||
},
|
||||
{
|
||||
"name": "viewer",
|
||||
"display_name": "Viewer",
|
||||
"description": "Read-only access to assigned projects and reports.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def ensure_default_roles(role_repo: RoleRepository) -> list[Role]:
|
||||
"""Ensure standard roles exist, creating missing ones.
|
||||
|
||||
Returns all current role records in creation order.
|
||||
"""
|
||||
|
||||
roles: list[Role] = []
|
||||
for definition in DEFAULT_ROLE_DEFINITIONS:
|
||||
existing = role_repo.get_by_name(definition["name"])
|
||||
if existing:
|
||||
roles.append(existing)
|
||||
continue
|
||||
role = Role(**definition)
|
||||
roles.append(role_repo.create(role))
|
||||
return roles
|
||||
|
||||
|
||||
def ensure_admin_user(
|
||||
user_repo: UserRepository,
|
||||
role_repo: RoleRepository,
|
||||
*,
|
||||
email: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> User:
|
||||
"""Ensure an administrator user exists and holds the admin role."""
|
||||
|
||||
user = user_repo.get_by_email(email, with_roles=True)
|
||||
if user is None:
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=User.hash_password(password),
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
user_repo.create(user)
|
||||
else:
|
||||
if not user.is_active:
|
||||
user.is_active = True
|
||||
if not user.is_superuser:
|
||||
user.is_superuser = True
|
||||
user_repo.session.flush()
|
||||
|
||||
admin_role = role_repo.get_by_name("admin")
|
||||
if admin_role is None: # pragma: no cover - safety if ensure_default_roles wasn't called
|
||||
admin_role = role_repo.create(
|
||||
Role(
|
||||
name="admin",
|
||||
display_name="Administrator",
|
||||
description="Full platform access with user management rights.",
|
||||
)
|
||||
)
|
||||
|
||||
user_repo.assign_role(
|
||||
user_id=user.id,
|
||||
role_id=admin_role.id,
|
||||
granted_by=user.id,
|
||||
)
|
||||
return user
|
||||
|
||||
Reference in New Issue
Block a user