Add pyproject.toml for consolidated project configuration and replace Black, isort, and Flake8 with Ruff

- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest).
- Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting.
- Updated `requirements.txt` to include Ruff and remove replaced tools.
- Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
This commit is contained in:
2025-11-10 11:55:15 +01:00
parent a5c671c133
commit c589b565f0
86 changed files with 4572 additions and 3956 deletions

View File

@@ -1,12 +1,10 @@
from typing import Optional
from fastapi import Depends, HTTPException, status, Header
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from fastapi.security.utils import get_authorization_scheme_param
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import get_token_data, TokenExpiredError, TokenInvalidError
from app.core.auth import TokenExpiredError, TokenInvalidError, get_token_data
from app.core.database import get_db
from app.models.user import User
@@ -15,8 +13,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
db: AsyncSession = Depends(get_db),
token: str = Depends(oauth2_scheme)
db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> User:
"""
Get the current authenticated user.
@@ -36,21 +33,17 @@ async def get_current_user(
token_data = get_token_data(token)
# Get user from database
result = await db.execute(
select(User).where(User.id == token_data.user_id)
)
result = await db.execute(select(User).where(User.id == token_data.user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return user
@@ -59,19 +52,17 @@ async def get_current_user(
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"}
headers={"WWW-Authenticate": "Bearer"},
)
except TokenInvalidError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
"""
Check if the current user is active.
@@ -86,15 +77,12 @@ def get_current_active_user(
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return current_user
def get_current_superuser(
current_user: User = Depends(get_current_user)
) -> User:
def get_current_superuser(current_user: User = Depends(get_current_user)) -> User:
"""
Check if the current user is a superuser.
@@ -109,13 +97,12 @@ def get_current_superuser(
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return current_user
async def get_optional_token(authorization: str = Header(None)) -> Optional[str]:
async def get_optional_token(authorization: str = Header(None)) -> str | None:
"""
Get the token from the Authorization header without requiring it.
@@ -139,9 +126,8 @@ async def get_optional_token(authorization: str = Header(None)) -> Optional[str]
async def get_optional_current_user(
db: AsyncSession = Depends(get_db),
token: Optional[str] = Depends(get_optional_token)
) -> Optional[User]:
db: AsyncSession = Depends(get_db), token: str | None = Depends(get_optional_token)
) -> User | None:
"""
Get the current user if authenticated, otherwise return None.
Useful for endpoints that work with both authenticated and unauthenticated users.
@@ -158,12 +144,10 @@ async def get_optional_current_user(
try:
token_data = get_token_data(token)
result = await db.execute(
select(User).where(User.id == token_data.user_id)
)
result = await db.execute(select(User).where(User.id == token_data.user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
return None
return user
except (TokenExpiredError, TokenInvalidError):
return None
return None

View File

@@ -7,7 +7,7 @@ These dependencies are optional and flexible:
- Use require_org_role for organization-specific access control
- Projects can choose to use these or implement their own permission system
"""
from typing import Optional
from uuid import UUID
from fastapi import Depends, HTTPException, status
@@ -20,9 +20,7 @@ from app.models.user import User
from app.models.user_organization import OrganizationRole
def require_superuser(
current_user: User = Depends(get_current_user)
) -> User:
def require_superuser(current_user: User = Depends(get_current_user)) -> User:
"""
Dependency to ensure the current user is a superuser.
@@ -36,7 +34,7 @@ def require_superuser(
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Superuser privileges required"
detail="Superuser privileges required",
)
return current_user
@@ -62,7 +60,7 @@ class OrganizationPermission:
self,
organization_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> User:
"""
Check if user has required role in the organization.
@@ -84,21 +82,19 @@ class OrganizationPermission:
# Get user's role in organization
user_role = await organization_crud.get_user_role_in_org(
db,
user_id=current_user.id,
organization_id=organization_id
db, user_id=current_user.id, organization_id=organization_id
)
if not user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this organization"
detail="Not a member of this organization",
)
if user_role not in self.allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role {user_role} not authorized. Required: {self.allowed_roles}"
detail=f"Role {user_role} not authorized. Required: {self.allowed_roles}",
)
return current_user
@@ -106,18 +102,18 @@ class OrganizationPermission:
# Common permission presets for convenience
require_org_owner = OrganizationPermission([OrganizationRole.OWNER])
require_org_admin = OrganizationPermission([OrganizationRole.OWNER, OrganizationRole.ADMIN])
require_org_member = OrganizationPermission([
OrganizationRole.OWNER,
OrganizationRole.ADMIN,
OrganizationRole.MEMBER
])
require_org_admin = OrganizationPermission(
[OrganizationRole.OWNER, OrganizationRole.ADMIN]
)
require_org_member = OrganizationPermission(
[OrganizationRole.OWNER, OrganizationRole.ADMIN, OrganizationRole.MEMBER]
)
async def require_org_membership(
organization_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> User:
"""
Ensure user is a member of the organization (any role).
@@ -128,15 +124,13 @@ async def require_org_membership(
return current_user
user_role = await organization_crud.get_user_role_in_org(
db,
user_id=current_user.id,
organization_id=organization_id
db, user_id=current_user.id, organization_id=organization_id
)
if not user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this organization"
detail="Not a member of this organization",
)
return current_user

View File

@@ -1,10 +1,12 @@
from fastapi import APIRouter
from app.api.routes import auth, users, sessions, admin, organizations
from app.api.routes import admin, auth, organizations, sessions, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(sessions.router, prefix="/sessions", tags=["Sessions"])
api_router.include_router(admin.router, prefix="/admin", tags=["Admin"])
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])
api_router.include_router(
organizations.router, prefix="/organizations", tags=["Organizations"]
)

View File

@@ -5,9 +5,10 @@ Admin-specific endpoints for managing users and organizations.
These endpoints require superuser privileges and provide CMS-like functionality
for managing the application.
"""
import logging
from enum import Enum
from typing import Any, List, Optional
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
@@ -16,27 +17,32 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.permissions import require_superuser
from app.core.database import get_db
from app.core.exceptions import NotFoundError, DuplicateError, AuthorizationError, ErrorCode
from app.core.exceptions import (
AuthorizationError,
DuplicateError,
ErrorCode,
NotFoundError,
)
from app.crud.organization import organization as organization_crud
from app.crud.user import user as user_crud
from app.crud.session import session as session_crud
from app.crud.user import user as user_crud
from app.models.user import User
from app.models.user_organization import OrganizationRole
from app.schemas.common import (
PaginationParams,
PaginatedResponse,
MessageResponse,
PaginatedResponse,
PaginationParams,
SortParams,
create_pagination_meta
create_pagination_meta,
)
from app.schemas.organizations import (
OrganizationResponse,
OrganizationCreate,
OrganizationMemberResponse,
OrganizationResponse,
OrganizationUpdate,
OrganizationMemberResponse
)
from app.schemas.users import UserResponse, UserCreate, UserUpdate
from app.schemas.sessions import AdminSessionResponse
from app.schemas.users import UserCreate, UserResponse, UserUpdate
logger = logging.getLogger(__name__)
@@ -46,6 +52,7 @@ router = APIRouter()
# Schemas for bulk operations
class BulkAction(str, Enum):
"""Supported bulk actions."""
ACTIVATE = "activate"
DEACTIVATE = "deactivate"
DELETE = "delete"
@@ -53,36 +60,41 @@ class BulkAction(str, Enum):
class BulkUserAction(BaseModel):
"""Schema for bulk user actions."""
action: BulkAction = Field(..., description="Action to perform on selected users")
user_ids: List[UUID] = Field(..., min_items=1, max_items=100, description="List of user IDs (max 100)")
user_ids: list[UUID] = Field(
..., min_items=1, max_items=100, description="List of user IDs (max 100)"
)
class BulkActionResult(BaseModel):
"""Result of a bulk action."""
success: bool
affected_count: int
failed_count: int
message: str
failed_ids: Optional[List[UUID]] = []
failed_ids: list[UUID] | None = []
# ===== User Management Endpoints =====
@router.get(
"/users",
response_model=PaginatedResponse[UserResponse],
summary="Admin: List All Users",
description="Get paginated list of all users with filtering and search (admin only)",
operation_id="admin_list_users"
operation_id="admin_list_users",
)
async def admin_list_users(
pagination: PaginationParams = Depends(),
sort: SortParams = Depends(),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
is_superuser: Optional[bool] = Query(None, description="Filter by superuser status"),
search: Optional[str] = Query(None, description="Search by email, name"),
is_active: bool | None = Query(None, description="Filter by active status"),
is_superuser: bool | None = Query(None, description="Filter by superuser status"),
search: str | None = Query(None, description="Search by email, name"),
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
List all users with comprehensive filtering and search.
@@ -105,20 +117,20 @@ async def admin_list_users(
sort_by=sort.sort_by or "created_at",
sort_order=sort.sort_order.value if sort.sort_order else "desc",
filters=filters if filters else None,
search=search
search=search,
)
pagination_meta = create_pagination_meta(
total=total,
page=pagination.page,
limit=pagination.limit,
items_count=len(users)
items_count=len(users),
)
return PaginatedResponse(data=users, pagination=pagination_meta)
except Exception as e:
logger.error(f"Error listing users (admin): {str(e)}", exc_info=True)
logger.error(f"Error listing users (admin): {e!s}", exc_info=True)
raise
@@ -128,12 +140,12 @@ async def admin_list_users(
status_code=status.HTTP_201_CREATED,
summary="Admin: Create User",
description="Create a new user (admin only)",
operation_id="admin_create_user"
operation_id="admin_create_user",
)
async def admin_create_user(
user_in: UserCreate,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Create a new user with admin privileges.
@@ -145,13 +157,10 @@ async def admin_create_user(
logger.info(f"Admin {admin.email} created user {user.email}")
return user
except ValueError as e:
logger.warning(f"Failed to create user: {str(e)}")
raise NotFoundError(
message=str(e),
error_code=ErrorCode.USER_ALREADY_EXISTS
)
logger.warning(f"Failed to create user: {e!s}")
raise NotFoundError(message=str(e), error_code=ErrorCode.USER_ALREADY_EXISTS)
except Exception as e:
logger.error(f"Error creating user (admin): {str(e)}", exc_info=True)
logger.error(f"Error creating user (admin): {e!s}", exc_info=True)
raise
@@ -160,19 +169,18 @@ async def admin_create_user(
response_model=UserResponse,
summary="Admin: Get User Details",
description="Get detailed user information (admin only)",
operation_id="admin_get_user"
operation_id="admin_get_user",
)
async def admin_get_user(
user_id: UUID,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Get detailed information about a specific user."""
user = await user_crud.get(db, id=user_id)
if not user:
raise NotFoundError(
message=f"User {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
message=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND
)
return user
@@ -182,21 +190,20 @@ async def admin_get_user(
response_model=UserResponse,
summary="Admin: Update User",
description="Update user information (admin only)",
operation_id="admin_update_user"
operation_id="admin_update_user",
)
async def admin_update_user(
user_id: UUID,
user_in: UserUpdate,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Update user information with admin privileges."""
try:
user = await user_crud.get(db, id=user_id)
if not user:
raise NotFoundError(
message=f"User {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
message=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND
)
updated_user = await user_crud.update(db, db_obj=user, obj_in=user_in)
@@ -206,7 +213,7 @@ async def admin_update_user(
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error updating user (admin): {str(e)}", exc_info=True)
logger.error(f"Error updating user (admin): {e!s}", exc_info=True)
raise
@@ -215,20 +222,19 @@ async def admin_update_user(
response_model=MessageResponse,
summary="Admin: Delete User",
description="Soft delete a user (admin only)",
operation_id="admin_delete_user"
operation_id="admin_delete_user",
)
async def admin_delete_user(
user_id: UUID,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Soft delete a user (sets deleted_at timestamp)."""
try:
user = await user_crud.get(db, id=user_id)
if not user:
raise NotFoundError(
message=f"User {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
message=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND
)
# Prevent deleting yourself
@@ -236,21 +242,20 @@ async def admin_delete_user(
# Use AuthorizationError for permission/operation restrictions
raise AuthorizationError(
message="Cannot delete your own account",
error_code=ErrorCode.OPERATION_FORBIDDEN
error_code=ErrorCode.OPERATION_FORBIDDEN,
)
await user_crud.soft_delete(db, id=user_id)
logger.info(f"Admin {admin.email} deleted user {user.email}")
return MessageResponse(
success=True,
message=f"User {user.email} has been deleted"
success=True, message=f"User {user.email} has been deleted"
)
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error deleting user (admin): {str(e)}", exc_info=True)
logger.error(f"Error deleting user (admin): {e!s}", exc_info=True)
raise
@@ -259,34 +264,32 @@ async def admin_delete_user(
response_model=MessageResponse,
summary="Admin: Activate User",
description="Activate a user account (admin only)",
operation_id="admin_activate_user"
operation_id="admin_activate_user",
)
async def admin_activate_user(
user_id: UUID,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Activate a user account."""
try:
user = await user_crud.get(db, id=user_id)
if not user:
raise NotFoundError(
message=f"User {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
message=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND
)
await user_crud.update(db, db_obj=user, obj_in={"is_active": True})
logger.info(f"Admin {admin.email} activated user {user.email}")
return MessageResponse(
success=True,
message=f"User {user.email} has been activated"
success=True, message=f"User {user.email} has been activated"
)
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error activating user (admin): {str(e)}", exc_info=True)
logger.error(f"Error activating user (admin): {e!s}", exc_info=True)
raise
@@ -295,20 +298,19 @@ async def admin_activate_user(
response_model=MessageResponse,
summary="Admin: Deactivate User",
description="Deactivate a user account (admin only)",
operation_id="admin_deactivate_user"
operation_id="admin_deactivate_user",
)
async def admin_deactivate_user(
user_id: UUID,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Deactivate a user account."""
try:
user = await user_crud.get(db, id=user_id)
if not user:
raise NotFoundError(
message=f"User {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
message=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND
)
# Prevent deactivating yourself
@@ -316,21 +318,20 @@ async def admin_deactivate_user(
# Use AuthorizationError for permission/operation restrictions
raise AuthorizationError(
message="Cannot deactivate your own account",
error_code=ErrorCode.OPERATION_FORBIDDEN
error_code=ErrorCode.OPERATION_FORBIDDEN,
)
await user_crud.update(db, db_obj=user, obj_in={"is_active": False})
logger.info(f"Admin {admin.email} deactivated user {user.email}")
return MessageResponse(
success=True,
message=f"User {user.email} has been deactivated"
success=True, message=f"User {user.email} has been deactivated"
)
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error deactivating user (admin): {str(e)}", exc_info=True)
logger.error(f"Error deactivating user (admin): {e!s}", exc_info=True)
raise
@@ -339,12 +340,12 @@ async def admin_deactivate_user(
response_model=BulkActionResult,
summary="Admin: Bulk User Action",
description="Perform bulk actions on multiple users (admin only)",
operation_id="admin_bulk_user_action"
operation_id="admin_bulk_user_action",
)
async def admin_bulk_user_action(
bulk_action: BulkUserAction,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Perform bulk actions on multiple users using optimized bulk operations.
@@ -356,22 +357,16 @@ async def admin_bulk_user_action(
# Use efficient bulk operations instead of loop
if bulk_action.action == BulkAction.ACTIVATE:
affected_count = await user_crud.bulk_update_status(
db,
user_ids=bulk_action.user_ids,
is_active=True
db, user_ids=bulk_action.user_ids, is_active=True
)
elif bulk_action.action == BulkAction.DEACTIVATE:
affected_count = await user_crud.bulk_update_status(
db,
user_ids=bulk_action.user_ids,
is_active=False
db, user_ids=bulk_action.user_ids, is_active=False
)
elif bulk_action.action == BulkAction.DELETE:
# bulk_soft_delete automatically excludes the admin user
affected_count = await user_crud.bulk_soft_delete(
db,
user_ids=bulk_action.user_ids,
exclude_user_id=admin.id
db, user_ids=bulk_action.user_ids, exclude_user_id=admin.id
)
else:
raise ValueError(f"Unsupported bulk action: {bulk_action.action}")
@@ -390,29 +385,30 @@ async def admin_bulk_user_action(
affected_count=affected_count,
failed_count=failed_count,
message=f"Bulk {bulk_action.action.value}: {affected_count} users affected, {failed_count} skipped",
failed_ids=None # Bulk operations don't track individual failures
failed_ids=None, # Bulk operations don't track individual failures
)
except Exception as e:
logger.error(f"Error in bulk user action: {str(e)}", exc_info=True)
logger.error(f"Error in bulk user action: {e!s}", exc_info=True)
raise
# ===== Organization Management Endpoints =====
@router.get(
"/organizations",
response_model=PaginatedResponse[OrganizationResponse],
summary="Admin: List Organizations",
description="Get paginated list of all organizations (admin only)",
operation_id="admin_list_organizations"
operation_id="admin_list_organizations",
)
async def admin_list_organizations(
pagination: PaginationParams = Depends(),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
search: Optional[str] = Query(None, description="Search by name, slug, description"),
is_active: bool | None = Query(None, description="Filter by active status"),
search: str | None = Query(None, description="Search by name, slug, description"),
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""List all organizations with filtering and search."""
try:
@@ -422,14 +418,14 @@ async def admin_list_organizations(
skip=pagination.offset,
limit=pagination.limit,
is_active=is_active,
search=search
search=search,
)
# Build response objects from optimized query results
orgs_with_count = []
for item in orgs_with_data:
org = item['organization']
member_count = item['member_count']
org = item["organization"]
member_count = item["member_count"]
org_dict = {
"id": org.id,
@@ -440,7 +436,7 @@ async def admin_list_organizations(
"settings": org.settings,
"created_at": org.created_at,
"updated_at": org.updated_at,
"member_count": member_count
"member_count": member_count,
}
orgs_with_count.append(OrganizationResponse(**org_dict))
@@ -448,13 +444,13 @@ async def admin_list_organizations(
total=total,
page=pagination.page,
limit=pagination.limit,
items_count=len(orgs_with_count)
items_count=len(orgs_with_count),
)
return PaginatedResponse(data=orgs_with_count, pagination=pagination_meta)
except Exception as e:
logger.error(f"Error listing organizations (admin): {str(e)}", exc_info=True)
logger.error(f"Error listing organizations (admin): {e!s}", exc_info=True)
raise
@@ -464,12 +460,12 @@ async def admin_list_organizations(
status_code=status.HTTP_201_CREATED,
summary="Admin: Create Organization",
description="Create a new organization (admin only)",
operation_id="admin_create_organization"
operation_id="admin_create_organization",
)
async def admin_create_organization(
org_in: OrganizationCreate,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Create a new organization."""
try:
@@ -486,18 +482,15 @@ async def admin_create_organization(
"settings": org.settings,
"created_at": org.created_at,
"updated_at": org.updated_at,
"member_count": 0
"member_count": 0,
}
return OrganizationResponse(**org_dict)
except ValueError as e:
logger.warning(f"Failed to create organization: {str(e)}")
raise NotFoundError(
message=str(e),
error_code=ErrorCode.ALREADY_EXISTS
)
logger.warning(f"Failed to create organization: {e!s}")
raise NotFoundError(message=str(e), error_code=ErrorCode.ALREADY_EXISTS)
except Exception as e:
logger.error(f"Error creating organization (admin): {str(e)}", exc_info=True)
logger.error(f"Error creating organization (admin): {e!s}", exc_info=True)
raise
@@ -506,19 +499,18 @@ async def admin_create_organization(
response_model=OrganizationResponse,
summary="Admin: Get Organization Details",
description="Get detailed organization information (admin only)",
operation_id="admin_get_organization"
operation_id="admin_get_organization",
)
async def admin_get_organization(
org_id: UUID,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Get detailed information about a specific organization."""
org = await organization_crud.get(db, id=org_id)
if not org:
raise NotFoundError(
message=f"Organization {org_id} not found",
error_code=ErrorCode.NOT_FOUND
message=f"Organization {org_id} not found", error_code=ErrorCode.NOT_FOUND
)
org_dict = {
@@ -530,7 +522,9 @@ async def admin_get_organization(
"settings": org.settings,
"created_at": org.created_at,
"updated_at": org.updated_at,
"member_count": await organization_crud.get_member_count(db, organization_id=org.id)
"member_count": await organization_crud.get_member_count(
db, organization_id=org.id
),
}
return OrganizationResponse(**org_dict)
@@ -540,13 +534,13 @@ async def admin_get_organization(
response_model=OrganizationResponse,
summary="Admin: Update Organization",
description="Update organization information (admin only)",
operation_id="admin_update_organization"
operation_id="admin_update_organization",
)
async def admin_update_organization(
org_id: UUID,
org_in: OrganizationUpdate,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Update organization information."""
try:
@@ -554,7 +548,7 @@ async def admin_update_organization(
if not org:
raise NotFoundError(
message=f"Organization {org_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
updated_org = await organization_crud.update(db, db_obj=org, obj_in=org_in)
@@ -569,14 +563,16 @@ async def admin_update_organization(
"settings": updated_org.settings,
"created_at": updated_org.created_at,
"updated_at": updated_org.updated_at,
"member_count": await organization_crud.get_member_count(db, organization_id=updated_org.id)
"member_count": await organization_crud.get_member_count(
db, organization_id=updated_org.id
),
}
return OrganizationResponse(**org_dict)
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error updating organization (admin): {str(e)}", exc_info=True)
logger.error(f"Error updating organization (admin): {e!s}", exc_info=True)
raise
@@ -585,12 +581,12 @@ async def admin_update_organization(
response_model=MessageResponse,
summary="Admin: Delete Organization",
description="Delete an organization (admin only)",
operation_id="admin_delete_organization"
operation_id="admin_delete_organization",
)
async def admin_delete_organization(
org_id: UUID,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Delete an organization and all its relationships."""
try:
@@ -598,21 +594,20 @@ async def admin_delete_organization(
if not org:
raise NotFoundError(
message=f"Organization {org_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
await organization_crud.remove(db, id=org_id)
logger.info(f"Admin {admin.email} deleted organization {org.name}")
return MessageResponse(
success=True,
message=f"Organization {org.name} has been deleted"
success=True, message=f"Organization {org.name} has been deleted"
)
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error deleting organization (admin): {str(e)}", exc_info=True)
logger.error(f"Error deleting organization (admin): {e!s}", exc_info=True)
raise
@@ -621,14 +616,14 @@ async def admin_delete_organization(
response_model=PaginatedResponse[OrganizationMemberResponse],
summary="Admin: List Organization Members",
description="Get all members of an organization (admin only)",
operation_id="admin_list_organization_members"
operation_id="admin_list_organization_members",
)
async def admin_list_organization_members(
org_id: UUID,
pagination: PaginationParams = Depends(),
is_active: Optional[bool] = Query(True, description="Filter by active status"),
is_active: bool | None = Query(True, description="Filter by active status"),
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""List all members of an organization."""
try:
@@ -636,7 +631,7 @@ async def admin_list_organization_members(
if not org:
raise NotFoundError(
message=f"Organization {org_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
members, total = await organization_crud.get_organization_members(
@@ -644,7 +639,7 @@ async def admin_list_organization_members(
organization_id=org_id,
skip=pagination.offset,
limit=pagination.limit,
is_active=is_active
is_active=is_active,
)
# Convert to response models
@@ -654,7 +649,7 @@ async def admin_list_organization_members(
total=total,
page=pagination.page,
limit=pagination.limit,
items_count=len(member_responses)
items_count=len(member_responses),
)
return PaginatedResponse(data=member_responses, pagination=pagination_meta)
@@ -662,14 +657,19 @@ async def admin_list_organization_members(
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error listing organization members (admin): {str(e)}", exc_info=True)
logger.error(
f"Error listing organization members (admin): {e!s}", exc_info=True
)
raise
class AddMemberRequest(BaseModel):
"""Request to add a member to an organization."""
user_id: UUID = Field(..., description="User ID to add")
role: OrganizationRole = Field(OrganizationRole.MEMBER, description="Role in organization")
role: OrganizationRole = Field(
OrganizationRole.MEMBER, description="Role in organization"
)
@router.post(
@@ -677,13 +677,13 @@ class AddMemberRequest(BaseModel):
response_model=MessageResponse,
summary="Admin: Add Member to Organization",
description="Add a user to an organization (admin only)",
operation_id="admin_add_organization_member"
operation_id="admin_add_organization_member",
)
async def admin_add_organization_member(
org_id: UUID,
request: AddMemberRequest,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Add a user to an organization."""
try:
@@ -691,21 +691,18 @@ async def admin_add_organization_member(
if not org:
raise NotFoundError(
message=f"Organization {org_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
user = await user_crud.get(db, id=request.user_id)
if not user:
raise NotFoundError(
message=f"User {request.user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
error_code=ErrorCode.USER_NOT_FOUND,
)
await organization_crud.add_user(
db,
organization_id=org_id,
user_id=request.user_id,
role=request.role
db, organization_id=org_id, user_id=request.user_id, role=request.role
)
logger.info(
@@ -714,22 +711,21 @@ async def admin_add_organization_member(
)
return MessageResponse(
success=True,
message=f"User {user.email} added to organization {org.name}"
success=True, message=f"User {user.email} added to organization {org.name}"
)
except ValueError as e:
logger.warning(f"Failed to add user to organization: {str(e)}")
logger.warning(f"Failed to add user to organization: {e!s}")
# Use DuplicateError for "already exists" scenarios
raise DuplicateError(
message=str(e),
error_code=ErrorCode.USER_ALREADY_EXISTS,
field="user_id"
message=str(e), error_code=ErrorCode.USER_ALREADY_EXISTS, field="user_id"
)
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error adding member to organization (admin): {str(e)}", exc_info=True)
logger.error(
f"Error adding member to organization (admin): {e!s}", exc_info=True
)
raise
@@ -738,13 +734,13 @@ async def admin_add_organization_member(
response_model=MessageResponse,
summary="Admin: Remove Member from Organization",
description="Remove a user from an organization (admin only)",
operation_id="admin_remove_organization_member"
operation_id="admin_remove_organization_member",
)
async def admin_remove_organization_member(
org_id: UUID,
user_id: UUID,
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""Remove a user from an organization."""
try:
@@ -752,39 +748,40 @@ async def admin_remove_organization_member(
if not org:
raise NotFoundError(
message=f"Organization {org_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
user = await user_crud.get(db, id=user_id)
if not user:
raise NotFoundError(
message=f"User {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
message=f"User {user_id} not found", error_code=ErrorCode.USER_NOT_FOUND
)
success = await organization_crud.remove_user(
db,
organization_id=org_id,
user_id=user_id
db, organization_id=org_id, user_id=user_id
)
if not success:
raise NotFoundError(
message="User is not a member of this organization",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
logger.info(f"Admin {admin.email} removed user {user.email} from organization {org.name}")
logger.info(
f"Admin {admin.email} removed user {user.email} from organization {org.name}"
)
return MessageResponse(
success=True,
message=f"User {user.email} removed from organization {org.name}"
message=f"User {user.email} removed from organization {org.name}",
)
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error removing member from organization (admin): {str(e)}", exc_info=True)
logger.error(
f"Error removing member from organization (admin): {e!s}", exc_info=True
)
raise
@@ -792,6 +789,7 @@ async def admin_remove_organization_member(
# Session Management Endpoints
# ============================================================================
@router.get(
"/sessions",
response_model=PaginatedResponse[AdminSessionResponse],
@@ -802,13 +800,13 @@ async def admin_remove_organization_member(
Returns paginated list of sessions with user information.
Useful for admin dashboard statistics and session monitoring.
""",
operation_id="admin_list_sessions"
operation_id="admin_list_sessions",
)
async def admin_list_sessions(
pagination: PaginationParams = Depends(),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
is_active: bool | None = Query(None, description="Filter by active status"),
admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""List all sessions across all users with filtering and pagination."""
try:
@@ -818,7 +816,7 @@ async def admin_list_sessions(
skip=pagination.offset,
limit=pagination.limit,
active_only=is_active if is_active is not None else True,
with_user=True
with_user=True,
)
# Build response objects with user information
@@ -847,21 +845,23 @@ async def admin_list_sessions(
last_used_at=session.last_used_at,
created_at=session.created_at,
expires_at=session.expires_at,
is_active=session.is_active
is_active=session.is_active,
)
session_responses.append(session_response)
logger.info(f"Admin {admin.email} listed {len(session_responses)} sessions (total: {total})")
logger.info(
f"Admin {admin.email} listed {len(session_responses)} sessions (total: {total})"
)
pagination_meta = create_pagination_meta(
total=total,
page=pagination.page,
limit=pagination.limit,
items_count=len(session_responses)
items_count=len(session_responses),
)
return PaginatedResponse(data=session_responses, pagination=pagination_meta)
except Exception as e:
logger.error(f"Error listing sessions (admin): {str(e)}", exc_info=True)
logger.error(f"Error listing sessions (admin): {e!s}", exc_info=True)
raise

View File

@@ -1,39 +1,43 @@
# app/api/routes/auth.py
import logging
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import get_current_user
from app.core.auth import TokenExpiredError, TokenInvalidError, decode_token
from app.core.auth import get_password_hash
from app.core.auth import (
TokenExpiredError,
TokenInvalidError,
decode_token,
get_password_hash,
)
from app.core.database import get_db
from app.core.exceptions import (
AuthenticationError as AuthError,
DatabaseError,
ErrorCode
ErrorCode,
)
from app.crud.session import session as session_crud
from app.crud.user import user as user_crud
from app.models.user import User
from app.schemas.common import MessageResponse
from app.schemas.sessions import SessionCreate, LogoutRequest
from app.schemas.sessions import LogoutRequest, SessionCreate
from app.schemas.users import (
LoginRequest,
PasswordResetConfirm,
PasswordResetRequest,
RefreshTokenRequest,
Token,
UserCreate,
UserResponse,
Token,
LoginRequest,
RefreshTokenRequest,
PasswordResetRequest,
PasswordResetConfirm
)
from app.services.auth_service import AuthService, AuthenticationError
from app.services.auth_service import AuthenticationError, AuthService
from app.services.email_service import email_service
from app.utils.device import extract_device_info
from app.utils.security import create_password_reset_token, verify_password_reset_token
@@ -54,7 +58,7 @@ async def _create_login_session(
request: Request,
user: User,
tokens: Token,
login_type: str = "login"
login_type: str = "login",
) -> None:
"""
Create a session record for successful login.
@@ -81,8 +85,8 @@ async def _create_login_session(
device_id=device_info.device_id,
ip_address=device_info.ip_address,
user_agent=device_info.user_agent,
last_used_at=datetime.now(timezone.utc),
expires_at=datetime.fromtimestamp(refresh_payload.exp, tz=timezone.utc),
last_used_at=datetime.now(UTC),
expires_at=datetime.fromtimestamp(refresh_payload.exp, tz=UTC),
location_city=device_info.location_city,
location_country=device_info.location_country,
)
@@ -95,15 +99,20 @@ async def _create_login_session(
)
except Exception as session_err:
# Log but don't fail login if session creation fails
logger.error(f"Failed to create session for {user.email}: {str(session_err)}", exc_info=True)
logger.error(
f"Failed to create session for {user.email}: {session_err!s}", exc_info=True
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED, operation_id="register")
@router.post(
"/register",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
operation_id="register",
)
@limiter.limit(f"{5 * RATE_MULTIPLIER}/minute")
async def register_user(
request: Request,
user_data: UserCreate,
db: AsyncSession = Depends(get_db)
request: Request, user_data: UserCreate, db: AsyncSession = Depends(get_db)
) -> Any:
"""
Register a new user.
@@ -116,25 +125,23 @@ async def register_user(
return user
except AuthenticationError as e:
# SECURITY: Don't reveal if email exists - generic error message
logger.warning(f"Registration failed: {str(e)}")
logger.warning(f"Registration failed: {e!s}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Registration failed. Please check your information and try again."
detail="Registration failed. Please check your information and try again.",
)
except Exception as e:
logger.error(f"Unexpected error during registration: {str(e)}", exc_info=True)
logger.error(f"Unexpected error during registration: {e!s}", exc_info=True)
raise DatabaseError(
message="An unexpected error occurred. Please try again later.",
error_code=ErrorCode.INTERNAL_ERROR
error_code=ErrorCode.INTERNAL_ERROR,
)
@router.post("/login", response_model=Token, operation_id="login")
@limiter.limit(f"{10 * RATE_MULTIPLIER}/minute")
async def login(
request: Request,
login_data: LoginRequest,
db: AsyncSession = Depends(get_db)
request: Request, login_data: LoginRequest, db: AsyncSession = Depends(get_db)
) -> Any:
"""
Login with username and password.
@@ -146,14 +153,16 @@ async def login(
"""
try:
# Attempt to authenticate the user
user = await AuthService.authenticate_user(db, login_data.email, login_data.password)
user = await AuthService.authenticate_user(
db, login_data.email, login_data.password
)
# Explicitly check for None result and raise correct exception
if user is None:
logger.warning(f"Invalid login attempt for: {login_data.email}")
raise AuthError(
message="Invalid email or password",
error_code=ErrorCode.INVALID_CREDENTIALS
error_code=ErrorCode.INVALID_CREDENTIALS,
)
# User is authenticated, generate tokens
@@ -166,29 +175,26 @@ async def login(
except AuthenticationError as e:
# Handle specific authentication errors like inactive accounts
logger.warning(f"Authentication failed: {str(e)}")
raise AuthError(
message=str(e),
error_code=ErrorCode.INVALID_CREDENTIALS
)
logger.warning(f"Authentication failed: {e!s}")
raise AuthError(message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS)
except AuthError:
# Re-raise custom auth exceptions without modification
raise
except Exception as e:
# Handle unexpected errors
logger.error(f"Unexpected error during login: {str(e)}", exc_info=True)
logger.error(f"Unexpected error during login: {e!s}", exc_info=True)
raise DatabaseError(
message="An unexpected error occurred. Please try again later.",
error_code=ErrorCode.INTERNAL_ERROR
error_code=ErrorCode.INTERNAL_ERROR,
)
@router.post("/login/oauth", response_model=Token, operation_id='login_oauth')
@router.post("/login/oauth", response_model=Token, operation_id="login_oauth")
@limiter.limit("10/minute")
async def login_oauth(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db)
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
) -> Any:
"""
OAuth2-compatible login endpoint, used by the OpenAPI UI.
@@ -199,12 +205,14 @@ async def login_oauth(
Access and refresh tokens.
"""
try:
user = await AuthService.authenticate_user(db, form_data.username, form_data.password)
user = await AuthService.authenticate_user(
db, form_data.username, form_data.password
)
if user is None:
raise AuthError(
message="Invalid email or password",
error_code=ErrorCode.INVALID_CREDENTIALS
error_code=ErrorCode.INVALID_CREDENTIALS,
)
# Generate tokens
@@ -216,28 +224,25 @@ async def login_oauth(
# Return full token response with user data
return tokens
except AuthenticationError as e:
logger.warning(f"OAuth authentication failed: {str(e)}")
raise AuthError(
message=str(e),
error_code=ErrorCode.INVALID_CREDENTIALS
)
logger.warning(f"OAuth authentication failed: {e!s}")
raise AuthError(message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS)
except AuthError:
# Re-raise custom auth exceptions without modification
raise
except Exception as e:
logger.error(f"Unexpected error during OAuth login: {str(e)}", exc_info=True)
logger.error(f"Unexpected error during OAuth login: {e!s}", exc_info=True)
raise DatabaseError(
message="An unexpected error occurred. Please try again later.",
error_code=ErrorCode.INTERNAL_ERROR
error_code=ErrorCode.INTERNAL_ERROR,
)
@router.post("/refresh", response_model=Token, operation_id="refresh_token")
@limiter.limit("30/minute")
async def refresh_token(
request: Request,
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
request: Request,
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Refresh access token using a refresh token.
@@ -249,13 +254,17 @@ async def refresh_token(
"""
try:
# Decode the refresh token to get the JTI
refresh_payload = decode_token(refresh_data.refresh_token, verify_type="refresh")
refresh_payload = decode_token(
refresh_data.refresh_token, verify_type="refresh"
)
# Check if session exists and is active
session = await session_crud.get_active_by_jti(db, jti=refresh_payload.jti)
if not session:
logger.warning(f"Refresh token used for inactive or non-existent session: {refresh_payload.jti}")
logger.warning(
f"Refresh token used for inactive or non-existent session: {refresh_payload.jti}"
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session has been revoked. Please log in again.",
@@ -274,10 +283,12 @@ async def refresh_token(
db,
session=session,
new_jti=new_refresh_payload.jti,
new_expires_at=datetime.fromtimestamp(new_refresh_payload.exp, tz=timezone.utc)
new_expires_at=datetime.fromtimestamp(new_refresh_payload.exp, tz=UTC),
)
except Exception as session_err:
logger.error(f"Failed to update session {session.id}: {str(session_err)}", exc_info=True)
logger.error(
f"Failed to update session {session.id}: {session_err!s}", exc_info=True
)
# Continue anyway - tokens are already issued
return tokens
@@ -300,10 +311,10 @@ async def refresh_token(
# Re-raise HTTP exceptions (like session revoked)
raise
except Exception as e:
logger.error(f"Unexpected error during token refresh: {str(e)}")
logger.error(f"Unexpected error during token refresh: {e!s}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred. Please try again later."
detail="An unexpected error occurred. Please try again later.",
)
@@ -320,13 +331,13 @@ async def refresh_token(
**Rate Limit**: 3 requests/minute
""",
operation_id="request_password_reset"
operation_id="request_password_reset",
)
@limiter.limit("3/minute")
async def request_password_reset(
request: Request,
reset_request: PasswordResetRequest,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Request a password reset.
@@ -345,26 +356,26 @@ async def request_password_reset(
# Send password reset email
await email_service.send_password_reset_email(
to_email=user.email,
reset_token=reset_token,
user_name=user.first_name
to_email=user.email, reset_token=reset_token, user_name=user.first_name
)
logger.info(f"Password reset requested for {user.email}")
else:
# Log attempt but don't reveal if email exists
logger.warning(f"Password reset requested for non-existent or inactive email: {reset_request.email}")
logger.warning(
f"Password reset requested for non-existent or inactive email: {reset_request.email}"
)
# Always return success to prevent email enumeration
return MessageResponse(
success=True,
message="If your email is registered, you will receive a password reset link shortly"
message="If your email is registered, you will receive a password reset link shortly",
)
except Exception as e:
logger.error(f"Error processing password reset request: {str(e)}", exc_info=True)
logger.error(f"Error processing password reset request: {e!s}", exc_info=True)
# Still return success to prevent information leakage
return MessageResponse(
success=True,
message="If your email is registered, you will receive a password reset link shortly"
message="If your email is registered, you will receive a password reset link shortly",
)
@@ -378,13 +389,13 @@ async def request_password_reset(
**Rate Limit**: 5 requests/minute
""",
operation_id="confirm_password_reset"
operation_id="confirm_password_reset",
)
@limiter.limit("5/minute")
async def confirm_password_reset(
request: Request,
reset_confirm: PasswordResetConfirm,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Confirm password reset with token.
@@ -398,7 +409,7 @@ async def confirm_password_reset(
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired password reset token"
detail="Invalid or expired password reset token",
)
# Look up user
@@ -406,14 +417,13 @@ async def confirm_password_reset(
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User account is inactive"
detail="User account is inactive",
)
# Update password
@@ -424,29 +434,33 @@ async def confirm_password_reset(
# SECURITY: Invalidate all existing sessions after password reset
# This prevents stolen sessions from being used after password change
from app.crud.session import session as session_crud
try:
deactivated_count = await session_crud.deactivate_all_user_sessions(
db,
user_id=str(user.id)
db, user_id=str(user.id)
)
logger.info(
f"Password reset successful for {user.email}, invalidated {deactivated_count} sessions"
)
logger.info(f"Password reset successful for {user.email}, invalidated {deactivated_count} sessions")
except Exception as session_error:
# Log but don't fail password reset if session invalidation fails
logger.error(f"Failed to invalidate sessions after password reset: {str(session_error)}")
logger.error(
f"Failed to invalidate sessions after password reset: {session_error!s}"
)
return MessageResponse(
success=True,
message="Password has been reset successfully. All devices have been logged out for security. You can now log in with your new password."
message="Password has been reset successfully. All devices have been logged out for security. You can now log in with your new password.",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error confirming password reset: {str(e)}", exc_info=True)
logger.error(f"Error confirming password reset: {e!s}", exc_info=True)
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while resetting your password"
detail="An error occurred while resetting your password",
)
@@ -464,14 +478,14 @@ async def confirm_password_reset(
**Rate Limit**: 10 requests/minute
""",
operation_id="logout"
operation_id="logout",
)
@limiter.limit("10/minute")
async def logout(
request: Request,
logout_request: LogoutRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Logout from current device by deactivating the session.
@@ -487,15 +501,14 @@ async def logout(
try:
# Decode refresh token to get JTI
try:
refresh_payload = decode_token(logout_request.refresh_token, verify_type="refresh")
refresh_payload = decode_token(
logout_request.refresh_token, verify_type="refresh"
)
except (TokenExpiredError, TokenInvalidError) as e:
# Even if token is expired/invalid, try to deactivate session
logger.warning(f"Logout with invalid/expired token: {str(e)}")
logger.warning(f"Logout with invalid/expired token: {e!s}")
# Don't fail - return success anyway
return MessageResponse(
success=True,
message="Logged out successfully"
)
return MessageResponse(success=True, message="Logged out successfully")
# Find the session by JTI
session = await session_crud.get_by_jti(db, jti=refresh_payload.jti)
@@ -509,7 +522,7 @@ async def logout(
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only logout your own sessions"
detail="You can only logout your own sessions",
)
# Deactivate the session
@@ -522,22 +535,20 @@ async def logout(
else:
# Session not found - maybe already deleted or never existed
# Return success anyway (idempotent)
logger.info(f"Logout requested for non-existent session (JTI: {refresh_payload.jti})")
logger.info(
f"Logout requested for non-existent session (JTI: {refresh_payload.jti})"
)
return MessageResponse(
success=True,
message="Logged out successfully"
)
return MessageResponse(success=True, message="Logged out successfully")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error during logout for user {current_user.id}: {str(e)}", exc_info=True)
# Don't expose error details
return MessageResponse(
success=True,
message="Logged out successfully"
logger.error(
f"Error during logout for user {current_user.id}: {e!s}", exc_info=True
)
# Don't expose error details
return MessageResponse(success=True, message="Logged out successfully")
@router.post(
@@ -553,13 +564,13 @@ async def logout(
**Rate Limit**: 5 requests/minute
""",
operation_id="logout_all"
operation_id="logout_all",
)
@limiter.limit("5/minute")
async def logout_all(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Logout from all devices by deactivating all user sessions.
@@ -573,19 +584,25 @@ async def logout_all(
"""
try:
# Deactivate all sessions for this user
count = await session_crud.deactivate_all_user_sessions(db, user_id=str(current_user.id))
count = await session_crud.deactivate_all_user_sessions(
db, user_id=str(current_user.id)
)
logger.info(f"User {current_user.id} logged out from all devices ({count} sessions)")
logger.info(
f"User {current_user.id} logged out from all devices ({count} sessions)"
)
return MessageResponse(
success=True,
message=f"Successfully logged out from all devices ({count} sessions terminated)"
message=f"Successfully logged out from all devices ({count} sessions terminated)",
)
except Exception as e:
logger.error(f"Error during logout-all for user {current_user.id}: {str(e)}", exc_info=True)
logger.error(
f"Error during logout-all for user {current_user.id}: {e!s}", exc_info=True
)
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred while logging out"
detail="An error occurred while logging out",
)

View File

@@ -4,8 +4,9 @@ Organization endpoints for regular users.
These endpoints allow users to view and manage organizations they belong to.
"""
import logging
from typing import Any, List
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
@@ -14,18 +15,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import get_current_user
from app.api.dependencies.permissions import require_org_admin, require_org_membership
from app.core.database import get_db
from app.core.exceptions import NotFoundError, ErrorCode
from app.core.exceptions import ErrorCode, NotFoundError
from app.crud.organization import organization as organization_crud
from app.models.user import User
from app.schemas.common import (
PaginationParams,
PaginatedResponse,
create_pagination_meta
PaginationParams,
create_pagination_meta,
)
from app.schemas.organizations import (
OrganizationResponse,
OrganizationMemberResponse,
OrganizationUpdate
OrganizationResponse,
OrganizationUpdate,
)
logger = logging.getLogger(__name__)
@@ -35,15 +36,15 @@ router = APIRouter()
@router.get(
"/me",
response_model=List[OrganizationResponse],
response_model=list[OrganizationResponse],
summary="Get My Organizations",
description="Get all organizations the current user belongs to",
operation_id="get_my_organizations"
operation_id="get_my_organizations",
)
async def get_my_organizations(
is_active: bool = Query(True, description="Filter by active membership"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Get all organizations the current user belongs to.
@@ -54,15 +55,13 @@ async def get_my_organizations(
try:
# Get all org data in single query with JOIN and subquery
orgs_data = await organization_crud.get_user_organizations_with_details(
db,
user_id=current_user.id,
is_active=is_active
db, user_id=current_user.id, is_active=is_active
)
# Transform to response objects
orgs_with_data = []
for item in orgs_data:
org = item['organization']
org = item["organization"]
org_dict = {
"id": org.id,
"name": org.name,
@@ -72,14 +71,14 @@ async def get_my_organizations(
"settings": org.settings,
"created_at": org.created_at,
"updated_at": org.updated_at,
"member_count": item['member_count']
"member_count": item["member_count"],
}
orgs_with_data.append(OrganizationResponse(**org_dict))
return orgs_with_data
except Exception as e:
logger.error(f"Error getting user organizations: {str(e)}", exc_info=True)
logger.error(f"Error getting user organizations: {e!s}", exc_info=True)
raise
@@ -88,12 +87,12 @@ async def get_my_organizations(
response_model=OrganizationResponse,
summary="Get Organization Details",
description="Get details of an organization the user belongs to",
operation_id="get_organization"
operation_id="get_organization",
)
async def get_organization(
organization_id: UUID,
current_user: User = Depends(require_org_membership),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Get details of a specific organization.
@@ -105,7 +104,7 @@ async def get_organization(
if not org: # pragma: no cover - Permission check prevents this (see docs/UNREACHABLE_DEFENSIVE_CODE_ANALYSIS.md)
raise NotFoundError(
detail=f"Organization {organization_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
org_dict = {
@@ -117,14 +116,16 @@ async def get_organization(
"settings": org.settings,
"created_at": org.created_at,
"updated_at": org.updated_at,
"member_count": await organization_crud.get_member_count(db, organization_id=org.id)
"member_count": await organization_crud.get_member_count(
db, organization_id=org.id
),
}
return OrganizationResponse(**org_dict)
except NotFoundError: # pragma: no cover - See above
raise
except Exception as e:
logger.error(f"Error getting organization: {str(e)}", exc_info=True)
logger.error(f"Error getting organization: {e!s}", exc_info=True)
raise
@@ -133,14 +134,14 @@ async def get_organization(
response_model=PaginatedResponse[OrganizationMemberResponse],
summary="Get Organization Members",
description="Get all members of an organization (members can view)",
operation_id="get_organization_members"
operation_id="get_organization_members",
)
async def get_organization_members(
organization_id: UUID,
pagination: PaginationParams = Depends(),
is_active: bool = Query(True, description="Filter by active status"),
current_user: User = Depends(require_org_membership),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Get all members of an organization.
@@ -153,7 +154,7 @@ async def get_organization_members(
organization_id=organization_id,
skip=pagination.offset,
limit=pagination.limit,
is_active=is_active
is_active=is_active,
)
member_responses = [OrganizationMemberResponse(**member) for member in members]
@@ -162,13 +163,13 @@ async def get_organization_members(
total=total,
page=pagination.page,
limit=pagination.limit,
items_count=len(member_responses)
items_count=len(member_responses),
)
return PaginatedResponse(data=member_responses, pagination=pagination_meta)
except Exception as e:
logger.error(f"Error getting organization members: {str(e)}", exc_info=True)
logger.error(f"Error getting organization members: {e!s}", exc_info=True)
raise
@@ -177,13 +178,13 @@ async def get_organization_members(
response_model=OrganizationResponse,
summary="Update Organization",
description="Update organization details (admin/owner only)",
operation_id="update_organization"
operation_id="update_organization",
)
async def update_organization(
organization_id: UUID,
org_in: OrganizationUpdate,
current_user: User = Depends(require_org_admin),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Update organization details.
@@ -195,11 +196,13 @@ async def update_organization(
if not org: # pragma: no cover - Permission check prevents this (see docs/UNREACHABLE_DEFENSIVE_CODE_ANALYSIS.md)
raise NotFoundError(
detail=f"Organization {organization_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
updated_org = await organization_crud.update(db, db_obj=org, obj_in=org_in)
logger.info(f"User {current_user.email} updated organization {updated_org.name}")
logger.info(
f"User {current_user.email} updated organization {updated_org.name}"
)
org_dict = {
"id": updated_org.id,
@@ -210,12 +213,14 @@ async def update_organization(
"settings": updated_org.settings,
"created_at": updated_org.created_at,
"updated_at": updated_org.updated_at,
"member_count": await organization_crud.get_member_count(db, organization_id=updated_org.id)
"member_count": await organization_crud.get_member_count(
db, organization_id=updated_org.id
),
}
return OrganizationResponse(**org_dict)
except NotFoundError: # pragma: no cover - See above
raise
except Exception as e:
logger.error(f"Error updating organization: {str(e)}", exc_info=True)
logger.error(f"Error updating organization: {e!s}", exc_info=True)
raise

View File

@@ -3,11 +3,12 @@ Session management endpoints.
Allows users to view and manage their active sessions across devices.
"""
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,11 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import get_current_user
from app.core.auth import decode_token
from app.core.database import get_db
from app.core.exceptions import NotFoundError, AuthorizationError, ErrorCode
from app.core.exceptions import AuthorizationError, ErrorCode, NotFoundError
from app.crud.session import session as session_crud
from app.models.user import User
from app.schemas.common import MessageResponse
from app.schemas.sessions import SessionResponse, SessionListResponse
from app.schemas.sessions import SessionListResponse, SessionResponse
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -39,13 +40,13 @@ limiter = Limiter(key_func=get_remote_address)
**Rate Limit**: 30 requests/minute
""",
operation_id="list_my_sessions"
operation_id="list_my_sessions",
)
@limiter.limit("30/minute")
async def list_my_sessions(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
List all active sessions for the current user.
@@ -60,18 +61,15 @@ async def list_my_sessions(
try:
# Get all active sessions for user
sessions = await session_crud.get_user_sessions(
db,
user_id=str(current_user.id),
active_only=True
db, user_id=str(current_user.id), active_only=True
)
# Try to identify current session from Authorization header
current_session_jti = None
auth_header = request.headers.get("authorization")
if auth_header and auth_header.startswith("Bearer "):
try:
access_token = auth_header.split(" ")[1]
token_payload = decode_token(access_token)
decode_token(access_token)
# Note: Access tokens don't have JTI by default, but we can try
# For now, we'll mark current based on most recent activity
except Exception:
@@ -90,22 +88,27 @@ async def list_my_sessions(
last_used_at=s.last_used_at,
created_at=s.created_at,
expires_at=s.expires_at,
is_current=(s == sessions[0] if sessions else False) # Most recent = current
is_current=(
s == sessions[0] if sessions else False
), # Most recent = current
)
session_responses.append(session_response)
logger.info(f"User {current_user.id} listed {len(session_responses)} active sessions")
logger.info(
f"User {current_user.id} listed {len(session_responses)} active sessions"
)
return SessionListResponse(
sessions=session_responses,
total=len(session_responses)
sessions=session_responses, total=len(session_responses)
)
except Exception as e:
logger.error(f"Error listing sessions for user {current_user.id}: {str(e)}", exc_info=True)
logger.error(
f"Error listing sessions for user {current_user.id}: {e!s}", exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve sessions"
detail="Failed to retrieve sessions",
)
@@ -122,14 +125,14 @@ async def list_my_sessions(
**Rate Limit**: 10 requests/minute
""",
operation_id="revoke_session"
operation_id="revoke_session",
)
@limiter.limit("10/minute")
async def revoke_session(
request: Request,
session_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Revoke a specific session by ID.
@@ -149,7 +152,7 @@ async def revoke_session(
if not session:
raise NotFoundError(
message=f"Session {session_id} not found",
error_code=ErrorCode.NOT_FOUND
error_code=ErrorCode.NOT_FOUND,
)
# Verify session belongs to current user
@@ -160,7 +163,7 @@ async def revoke_session(
)
raise AuthorizationError(
message="You can only revoke your own sessions",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
)
# Deactivate the session
@@ -173,16 +176,16 @@ async def revoke_session(
return MessageResponse(
success=True,
message=f"Session revoked: {session.device_name or 'Unknown device'}"
message=f"Session revoked: {session.device_name or 'Unknown device'}",
)
except (NotFoundError, AuthorizationError):
raise
except Exception as e:
logger.error(f"Error revoking session {session_id}: {str(e)}", exc_info=True)
logger.error(f"Error revoking session {session_id}: {e!s}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to revoke session"
detail="Failed to revoke session",
)
@@ -198,13 +201,13 @@ async def revoke_session(
**Rate Limit**: 5 requests/minute
""",
operation_id="cleanup_expired_sessions"
operation_id="cleanup_expired_sessions",
)
@limiter.limit("5/minute")
async def cleanup_expired_sessions(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Cleanup expired sessions for the current user.
@@ -219,21 +222,24 @@ async def cleanup_expired_sessions(
try:
# Use optimized bulk DELETE instead of N individual deletes
deleted_count = await session_crud.cleanup_expired_for_user(
db,
user_id=str(current_user.id)
db, user_id=str(current_user.id)
)
logger.info(f"User {current_user.id} cleaned up {deleted_count} expired sessions")
logger.info(
f"User {current_user.id} cleaned up {deleted_count} expired sessions"
)
return MessageResponse(
success=True,
message=f"Cleaned up {deleted_count} expired sessions"
success=True, message=f"Cleaned up {deleted_count} expired sessions"
)
except Exception as e:
logger.error(f"Error cleaning up sessions for user {current_user.id}: {str(e)}", exc_info=True)
logger.error(
f"Error cleaning up sessions for user {current_user.id}: {e!s}",
exc_info=True,
)
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to cleanup sessions"
detail="Failed to cleanup sessions",
)

View File

@@ -1,33 +1,30 @@
"""
User management endpoints for CRUD operations.
"""
import logging
from typing import Any, Optional
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status, Request
from fastapi import APIRouter, Depends, Query, Request, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import get_current_user, get_current_superuser
from app.api.dependencies.auth import get_current_superuser, get_current_user
from app.core.database import get_db
from app.core.exceptions import (
NotFoundError,
AuthorizationError,
ErrorCode
)
from app.core.exceptions import AuthorizationError, ErrorCode, NotFoundError
from app.crud.user import user as user_crud
from app.models.user import User
from app.schemas.common import (
PaginationParams,
PaginatedResponse,
MessageResponse,
PaginatedResponse,
PaginationParams,
SortParams,
create_pagination_meta
create_pagination_meta,
)
from app.schemas.users import UserResponse, UserUpdate, PasswordChange
from app.services.auth_service import AuthService, AuthenticationError
from app.schemas.users import PasswordChange, UserResponse, UserUpdate
from app.services.auth_service import AuthenticationError, AuthService
logger = logging.getLogger(__name__)
@@ -50,15 +47,15 @@ limiter = Limiter(key_func=get_remote_address)
**Rate Limit**: 60 requests/minute
""",
operation_id="list_users"
operation_id="list_users",
)
async def list_users(
pagination: PaginationParams = Depends(),
sort: SortParams = Depends(),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
is_superuser: Optional[bool] = Query(None, description="Filter by superuser status"),
is_active: bool | None = Query(None, description="Filter by active status"),
is_superuser: bool | None = Query(None, description="Filter by superuser status"),
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
List all users with pagination, filtering, and sorting.
@@ -80,7 +77,7 @@ async def list_users(
limit=pagination.limit,
sort_by=sort.sort_by,
sort_order=sort.sort_order.value if sort.sort_order else "asc",
filters=filters if filters else None
filters=filters if filters else None,
)
# Create pagination metadata
@@ -88,15 +85,12 @@ async def list_users(
total=total,
page=pagination.page,
limit=pagination.limit,
items_count=len(users)
items_count=len(users),
)
return PaginatedResponse(
data=users,
pagination=pagination_meta
)
return PaginatedResponse(data=users, pagination=pagination_meta)
except Exception as e:
logger.error(f"Error listing users: {str(e)}", exc_info=True)
logger.error(f"Error listing users: {e!s}", exc_info=True)
raise
@@ -111,11 +105,9 @@ async def list_users(
**Rate Limit**: 60 requests/minute
""",
operation_id="get_current_user_profile"
operation_id="get_current_user_profile",
)
def get_current_user_profile(
current_user: User = Depends(get_current_user)
) -> Any:
def get_current_user_profile(current_user: User = Depends(get_current_user)) -> Any:
"""Get current user's profile."""
return current_user
@@ -133,12 +125,12 @@ def get_current_user_profile(
**Rate Limit**: 30 requests/minute
""",
operation_id="update_current_user"
operation_id="update_current_user",
)
async def update_current_user(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Update current user's profile.
@@ -147,17 +139,17 @@ async def update_current_user(
"""
try:
updated_user = await user_crud.update(
db,
db_obj=current_user,
obj_in=user_update
db, db_obj=current_user, obj_in=user_update
)
logger.info(f"User {current_user.id} updated their profile")
return updated_user
except ValueError as e:
logger.error(f"Error updating user {current_user.id}: {str(e)}")
logger.error(f"Error updating user {current_user.id}: {e!s}")
raise
except Exception as e:
logger.error(f"Unexpected error updating user {current_user.id}: {str(e)}", exc_info=True)
logger.error(
f"Unexpected error updating user {current_user.id}: {e!s}", exc_info=True
)
raise
@@ -175,12 +167,12 @@ async def update_current_user(
**Rate Limit**: 60 requests/minute
""",
operation_id="get_user_by_id"
operation_id="get_user_by_id",
)
async def get_user_by_id(
user_id: UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Get user by ID.
@@ -194,7 +186,7 @@ async def get_user_by_id(
)
raise AuthorizationError(
message="Not enough permissions to view this user",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
)
# Get user
@@ -202,7 +194,7 @@ async def get_user_by_id(
if not user:
raise NotFoundError(
message=f"User with id {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
error_code=ErrorCode.USER_NOT_FOUND,
)
return user
@@ -222,13 +214,13 @@ async def get_user_by_id(
**Rate Limit**: 30 requests/minute
""",
operation_id="update_user"
operation_id="update_user",
)
async def update_user(
user_id: UUID,
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Update user by ID.
@@ -245,7 +237,7 @@ async def update_user(
)
raise AuthorizationError(
message="Not enough permissions to update this user",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
)
# Get user
@@ -253,7 +245,7 @@ async def update_user(
if not user:
raise NotFoundError(
message=f"User with id {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
error_code=ErrorCode.USER_NOT_FOUND,
)
try:
@@ -261,10 +253,10 @@ async def update_user(
logger.info(f"User {user_id} updated by {current_user.id}")
return updated_user
except ValueError as e:
logger.error(f"Error updating user {user_id}: {str(e)}")
logger.error(f"Error updating user {user_id}: {e!s}")
raise
except Exception as e:
logger.error(f"Unexpected error updating user {user_id}: {str(e)}", exc_info=True)
logger.error(f"Unexpected error updating user {user_id}: {e!s}", exc_info=True)
raise
@@ -281,14 +273,14 @@ async def update_user(
**Rate Limit**: 5 requests/minute
""",
operation_id="change_current_user_password"
operation_id="change_current_user_password",
)
@limiter.limit("5/minute")
async def change_current_user_password(
request: Request,
password_change: PasswordChange,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Change current user's password.
@@ -300,23 +292,23 @@ async def change_current_user_password(
db=db,
user_id=current_user.id,
current_password=password_change.current_password,
new_password=password_change.new_password
new_password=password_change.new_password,
)
if success:
logger.info(f"User {current_user.id} changed their password")
return MessageResponse(
success=True,
message="Password changed successfully"
success=True, message="Password changed successfully"
)
except AuthenticationError as e:
logger.warning(f"Failed password change attempt for user {current_user.id}: {str(e)}")
logger.warning(
f"Failed password change attempt for user {current_user.id}: {e!s}"
)
raise AuthorizationError(
message=str(e),
error_code=ErrorCode.INVALID_CREDENTIALS
message=str(e), error_code=ErrorCode.INVALID_CREDENTIALS
)
except Exception as e:
logger.error(f"Error changing password for user {current_user.id}: {str(e)}")
logger.error(f"Error changing password for user {current_user.id}: {e!s}")
raise
@@ -335,12 +327,12 @@ async def change_current_user_password(
**Note**: This performs a hard delete. Consider implementing soft deletes for production.
""",
operation_id="delete_user"
operation_id="delete_user",
)
async def delete_user(
user_id: UUID,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Delete user by ID (superuser only).
@@ -351,7 +343,7 @@ async def delete_user(
if str(user_id) == str(current_user.id):
raise AuthorizationError(
message="Cannot delete your own account",
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS
error_code=ErrorCode.INSUFFICIENT_PERMISSIONS,
)
# Get user
@@ -359,7 +351,7 @@ async def delete_user(
if not user:
raise NotFoundError(
message=f"User with id {user_id} not found",
error_code=ErrorCode.USER_NOT_FOUND
error_code=ErrorCode.USER_NOT_FOUND,
)
try:
@@ -367,12 +359,11 @@ async def delete_user(
await user_crud.soft_delete(db, id=str(user_id))
logger.info(f"User {user_id} soft-deleted by {current_user.id}")
return MessageResponse(
success=True,
message=f"User {user_id} deleted successfully"
success=True, message=f"User {user_id} deleted successfully"
)
except ValueError as e:
logger.error(f"Error deleting user {user_id}: {str(e)}")
logger.error(f"Error deleting user {user_id}: {e!s}")
raise
except Exception as e:
logger.error(f"Unexpected error deleting user {user_id}: {str(e)}", exc_info=True)
logger.error(f"Unexpected error deleting user {user_id}: {e!s}", exc_info=True)
raise