Skip to content

Backend API Reference

This page is auto-generated from the backend source code using mkdocstrings.

Live Interactive API Docs

If your backend is deployed, replace localhost:8000 with your server’s address.


FastAPI Endpoints & Schemas

api.v1.auth

REGISTER_SUMMARY = 'Register a new user' module-attribute

REGISTER_DESCRIPTION = '\n**User Registration**\n\nCreates a new user account and returns authentication tokens for immediate login.\n\n**Process:**\n1. Validates email format and password strength\n2. Checks if email is already registered\n3. Creates user account with hashed password\n4. Returns JWT tokens for authentication\n\n**Requirements:**\n- Email must be valid and unique\n- Password must be at least 8 characters\n- Name is optional but recommended\n- API key required for registration\n\n**Rate Limiting:** 5 attempts per email per hour\n\n**Security Features:**\n- Password hashing with bcrypt\n- Email verification (optional)\n- Rate limiting to prevent abuse\n- API key requirement for service protection\n\n**Response:** Returns access token, refresh token, and user profile data\n\n**Examples:**\n```json\n{\n "email": "user@example.com",\n "password": "SecurePassword123!",\n "name": "Jane Doe"\n}\n```\n\n**Error Codes:**\n- `400`: Invalid input data\n- `409`: Email already registered\n- `429`: Too many registration attempts\n- `422`: Validation errors\n' module-attribute

LOGIN_SUMMARY = 'User login' module-attribute

LOGIN_DESCRIPTION = '\n**User Authentication**\n\nAuthenticates user credentials and returns JWT tokens for API access.\n\n**Process:**\n1. Validates email and password credentials\n2. Checks account status (active, not locked)\n3. Issues access and refresh tokens\n4. Returns user profile information\n\n**Authentication Methods:**\n- **Email + Password**: Standard login method\n- **Rate Limited**: 10 attempts per IP per minute\n\n**Token Details:**\n- **Access Token**: Valid for 24 hours, use for API requests\n- **Refresh Token**: Valid for 7 days, use to get new access tokens\n- **Token Type**: Bearer (include in Authorization header)\n\n**Security Features:**\n- Password verification with timing attack protection\n- Account lockout after failed attempts\n- Secure token generation with cryptographic signatures\n- IP-based rate limiting\n\n**Usage Examples:**\n\n\n*Request:*\n```json\n{\n "email": "user@example.com",\n "password": "yourpassword"\n}\n```\n\n*Response:*\n```json\n{\n "access_token": "eyJhbGciOiJIUzI1NiIs...",\n "refresh_token": "eyJhbGciOiJIUzI1NiIs...",\n "token_type": "bearer",\n "expires_in": 86400,\n "user": {\n "id": 123,\n "email": "user@example.com",\n "name": "Jane Doe"\n }\n}\n```\n\n**Error Codes:**\n- `400`: Missing or invalid credentials\n- `401`: Invalid email or password\n- `429`: Too many login attempts\n- `423`: Account temporarily locked\n' module-attribute

LOGOUT_SUMMARY = 'Logout user' module-attribute

LOGOUT_DESCRIPTION = '\nLogs out the current user and blacklists the access token.\n\n**Steps:**\n1. User sends a logout request with a valid access token.\n2. System blacklists the token and ends the session.\n\n**Notes:**\n- Blacklisted tokens cannot be reused.\n- Rate limiting is applied to prevent abuse.\n' module-attribute

REFRESH_SUMMARY = 'Refresh JWT access token' module-attribute

REFRESH_DESCRIPTION = '\nRefreshes the JWT access token using a valid refresh token.\n\n**Steps:**\n1. User provides a valid refresh token.\n2. System validates the token and issues a new access token.\n\n**Notes:**\n- Expired or blacklisted tokens will be rejected.\n- Rate limiting is applied to prevent abuse.\n' module-attribute

PWRESET_REQUEST_SUMMARY = 'Request password reset' module-attribute

PWRESET_REQUEST_DESCRIPTION = '\nInitiates a password reset flow.\n\n**Steps:**\n1. User submits email via this endpoint.\n2. System sends a password reset link to the email if it exists.\n3. User clicks the link and is directed to the reset form.\n4. User completes the process via `/reset-password`.\n\n**Notes:**\n- For security, this endpoint always returns a success message, even if the email is not registered.\n- Rate limiting is applied to prevent abuse.\n' module-attribute

PWRESET_SUMMARY = 'Reset password' module-attribute

PWRESET_DESCRIPTION = '\nCompletes the password reset flow using a valid reset token.\n\n**Steps:**\n1. User receives a reset link from `/request-password-reset`.\n2. User submits the token and new password to this endpoint.\n3. System validates the token and updates the password.\n\n**Notes:**\n- The token must be valid and not expired.\n- Rate limiting is applied to prevent abuse.\n' module-attribute

ME_SUMMARY = 'Get current user profile' module-attribute

ME_DESCRIPTION = '\nReturns the profile information of the currently authenticated user.\n\n**How it works:**\n- Requires a valid JWT Bearer token.\n- Returns user ID, email, name, bio, avatar, and timestamps.\n' module-attribute

router = APIRouter(tags=['Auth']) module-attribute

LogExtraDict

Bases: TypedDict

email instance-attribute

limiter_key instance-attribute

action instance-attribute

error instance-attribute

error_type instance-attribute

user_id instance-attribute

token_length instance-attribute

token instance-attribute

token_prefix instance-attribute

traceback instance-attribute

UserActionLimiterProtocol

is_allowed(key) async

Source code in api/v1/auth.py
251
252
async def is_allowed(self, key: str) -> bool:
    return False

check_rate_limit(user_action_limiter, limiter_key, log_extra, action='action') async

Checks if the action is allowed by the rate limiter. Raises HTTP 429 if not allowed.

Source code in api/v1/auth.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
async def check_rate_limit(
    user_action_limiter: UserActionLimiterProtocol,
    limiter_key: str,
    log_extra: Mapping[str, object],
    action: str = "action",
) -> None:
    """Checks if the action is allowed by the rate limiter.
    Raises HTTP 429 if not allowed.
    """
    allowed: bool = await user_action_limiter.is_allowed(limiter_key)
    if not allowed:
        http_error(
            429,
            f"Too many {action} attempts. Please try again later.",
            logger.warning,
            cast("ExtraLogInfo", log_extra),
        )

common_auth_deps(feature)

Returns a tuple of common dependencies for auth endpoints.

Source code in api/v1/auth.py
274
275
276
277
278
279
280
281
282
def common_auth_deps(feature: str) -> tuple[object, object, object]:
    """Returns a tuple of common dependencies for auth endpoints."""
    from fastapi import Depends as FastAPIDepends

    return (
        FastAPIDepends(get_request_id),
        FastAPIDepends(require_feature(feature)),
        FastAPIDepends(require_api_key),
    )

rate_limit(action, key_func=None)

Returns a dependency for rate limiting.

Source code in api/v1/auth.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
def rate_limit(action: str, key_func: Callable[[Request], str] | None = None) -> object:
    """Returns a dependency for rate limiting."""

    async def dependency(
        request: Request,
        user_action_limiter: UserActionLimiterProtocol = Depends(
            get_user_action_limiter,
        ),
    ) -> None:
        key: str
        if key_func is not None:
            key = key_func(request)
        else:
            user: User | None = getattr(request.state, "user", None)
            client_host: str = (
                request.client.host if request.client is not None else "unknown"
            )
            user_id_or_host: str = str(getattr(user, "id", client_host))
            key = f"{action}:{user_id_or_host}"
        allowed: bool = await user_action_limiter.is_allowed(key)
        if not allowed:
            http_error(
                429,
                f"Too many {action} attempts. Please try again later.",
                logger.warning,
                cast("ExtraLogInfo", {"limiter_key": key}),
            )

    return Depends(dependency)

register(data=Body(..., examples=[{'summary': 'A typical registration', 'value': {'email': 'user@example.com', 'password': 'strongpassword123', 'name': 'Jane Doe'}}]), session=Depends(get_db), user_service=Depends(get_user_service), user_action_limiter=Depends(get_user_action_limiter)) async

Registers a new user account and returns a JWT access token.

Raises:

Type Description
UserAlreadyExistsError

If the user already exists.

InvalidDataError

If registration data is invalid.

Exception

For unexpected errors.

Source code in api/v1/auth.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
@router.post(
    "/register",
    response_model=AuthResponse,
    status_code=status.HTTP_201_CREATED,
    summary=REGISTER_SUMMARY,
    description=REGISTER_DESCRIPTION,
    dependencies=[
        Depends(get_request_id),
        Depends(require_feature("auth:register")),
        Depends(require_api_key),
    ],
)
async def register(
    data: UserRegisterRequest = Body(
        ...,
        examples=[
            {
                "summary": "A typical registration",
                "value": {
                    "email": "user@example.com",
                    "password": "strongpassword123",
                    "name": "Jane Doe",
                },
            },
        ],
    ),
    session: AsyncSession = Depends(get_db),
    user_service: UserService = Depends(get_user_service),
    user_action_limiter: UserActionLimiterProtocol = Depends(get_user_action_limiter),
) -> AuthResponse:
    """Registers a new user account and returns a JWT access token.

    Raises:
        UserAlreadyExistsError: If the user already exists.
        InvalidDataError: If registration data is invalid.
        Exception: For unexpected errors.

    """
    await check_rate_limit(
        user_action_limiter,
        f"register:{data.email}",
        {"email": data.email},
        action="registration",
    )
    logger.info(
        f"User registration attempt: {data.email}, name: {getattr(data, 'name', None)}",
    )
    logger.debug(f"Registration payload: {data}")
    try:
        user: User = await user_service.register_user(session, data.model_dump())
        access_token: str
        refresh_token: str
        access_token, refresh_token = await user_service.authenticate_user(
            session,
            data.email,
            data.password,
        )
        logger.info(f"User registered successfully: {user.email}")
        return AuthResponse(access_token=access_token, refresh_token=refresh_token)
    except UserAlreadyExistsError as e:
        http_error(
            400,
            "User with this email already exists.",
            logger.warning,
            cast("ExtraLogInfo", {"email": data.email}),
            e,
        )
    except ValidationError as e:
        http_error(
            400,
            "Invalid registration data.",
            logger.warning,
            cast("ExtraLogInfo", {"email": data.email}),
            e,
        )
    except InvalidDataError as e:
        http_error(
            400,
            "Invalid registration data.",
            logger.warning,
            cast("ExtraLogInfo", {"email": data.email}),
            e,
        )
    except Exception as e:
        # Add detailed error logging
        import traceback

        logger.error(f"DETAILED ERROR in registration: {type(e).__name__}: {e}")
        logger.error(f"TRACEBACK: {traceback.format_exc()}")
        http_error(
            500,
            "An unexpected error occurred. Please try again later.",
            logger.error,
            cast("ExtraLogInfo", {"email": data.email, "error": str(e)}),
            e,
        )
    raise AssertionError("Unreachable")

login(data, session=Depends(get_db), user_service=Depends(get_user_service), user_action_limiter=Depends(get_user_action_limiter)) async

Authenticates a user and returns a JWT access token.

Raises:

Type Description
UserNotFoundError

If user is not found.

ValidationError

If credentials are invalid.

Exception

For unexpected errors.

Source code in api/v1/auth.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
@router.post(
    "/login",
    response_model=AuthResponse,
    summary=LOGIN_SUMMARY,
    description=LOGIN_DESCRIPTION,
    dependencies=[
        Depends(get_request_id),
        Depends(require_feature("auth:login")),
        Depends(require_api_key),
    ],
)
async def login(
    data: UserLoginRequest,
    session: AsyncSession = Depends(get_db),
    user_service: UserService = Depends(get_user_service),
    user_action_limiter: UserActionLimiterProtocol = Depends(get_user_action_limiter),
) -> AuthResponse:
    """Authenticates a user and returns a JWT access token.

    Raises:
        UserNotFoundError: If user is not found.
        ValidationError: If credentials are invalid.
        Exception: For unexpected errors.

    """
    await check_rate_limit(
        user_action_limiter,
        f"login:{data.email}",
        {"email": data.email},
        action="login",
    )
    logger.info(f"User login attempt: {data.email}")
    try:
        access_token: str
        refresh_token: str
        access_token, refresh_token = await user_service.authenticate_user(
            session,
            data.email,
            data.password,
        )
        logger.info(f"User authenticated successfully: {data.email}")
        return AuthResponse(access_token=access_token, refresh_token=refresh_token)
    except UserNotFoundError as e:
        logger.warning(f"Login failed: {data.email}")
        http_error(
            401,
            "Invalid credentials",
            logger.warning,
            cast("ExtraLogInfo", {"email": data.email}),
            e,
        )
    except ValidationError as e:
        logger.warning(f"Login failed: {data.email}")
        http_error(
            401,
            "Invalid credentials",
            logger.warning,
            cast("ExtraLogInfo", {"email": data.email}),
            e,
        )
    except Exception as e:
        logger.error(f"Login failed: {data.email}")
        http_error(
            401,
            "An unexpected error occurred. Please try again later.",
            logger.error,
            cast("ExtraLogInfo", {"email": data.email, "error": str(e)}),
            e,
        )
    raise AssertionError("Unreachable")

logout(request, current_user=Depends(get_current_user), session=Depends(get_db), user_service=Depends(get_user_service), blacklist_token=Depends(get_blacklist_token)) async

Logs out the current user and blacklists the access token.

Raises:

Type Description
Exception

If token is invalid or expired.

Source code in api/v1/auth.py
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
@router.post(
    "/logout",
    response_model=MessageResponse,
    summary=LOGOUT_SUMMARY,
    description=LOGOUT_DESCRIPTION,
    dependencies=[
        Depends(get_request_id),
        Depends(require_feature("auth:logout")),
        Depends(require_api_key),
    ],
)
async def logout(
    request: Request,
    current_user: User = Depends(get_current_user),
    session: AsyncSession = Depends(get_db),
    user_service: UserService = Depends(get_user_service),
    blacklist_token: Callable[[AsyncSession, str, datetime], Awaitable[None]] = Depends(
        get_blacklist_token,
    ),
) -> MessageResponse:
    """Logs out the current user and blacklists the access token.

    Raises:
        Exception: If token is invalid or expired.

    """
    logger.info("logout_attempt", extra={"user_id": current_user.id})
    auth_header: str | None = request.headers.get("authorization") if request else None
    if auth_header is not None and auth_header.lower().startswith("bearer "):
        token: str = auth_header.split(" ", 1)[1]
        try:
            settings = get_settings()
            if not settings.jwt_secret_key:
                raise ValueError("JWT secret key is not configured.")
            payload: Mapping[str, object] = jwt.decode(
                token,
                str(settings.jwt_secret_key),
                algorithms=[settings.jwt_algorithm],
            )
            jti: str = cast("str", payload.get("jti") or token)
            exp: int | None = cast("int | None", payload.get("exp"))
            if exp is not None:
                expires_at: datetime = datetime.fromtimestamp(exp, tz=UTC)
                await blacklist_token(session, jti, expires_at)
                logger.info(
                    "logout_token_blacklisted",
                    extra={"user_id": current_user.id},
                )
        except Exception as e:
            logger.error(
                "logout_token_blacklist_error",
                extra={
                    "error": str(e),
                    "error_type": type(e).__name__,
                    "user_id": current_user.id,
                    "token_length": len(token) if token else 0,
                },
            )
            http_error(
                401,
                "Invalid or expired token.",
                logger.warning,
                cast("ExtraLogInfo", {"error": str(e)}),
            )
    await user_service.logout_user(session, current_user.id)
    logger.info("logout_success", extra={"user_id": current_user.id})
    return MessageResponse(message="Logged out successfully.")

refresh_token(body=Body(...), session=Depends(get_db), async_refresh_access_token=Depends(get_async_refresh_access_token)) async

Accepts either {"token": ...} or {"refresh_token": ...} for compatibility with tests.

Raises:

Type Description
RefreshTokenRateLimitError

If too many attempts.

RefreshTokenBlacklistedError

If token is blacklisted.

RefreshTokenError

If token is invalid.

ValueError

If token is missing or invalid.

Source code in api/v1/auth.py
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
@router.post(
    "/refresh-token",
    response_model=AuthResponse,
    summary=REFRESH_SUMMARY,
    description=REFRESH_DESCRIPTION,
    dependencies=[
        Depends(get_request_id),
        Depends(require_feature("auth:refresh_token")),
        Depends(require_api_key),
    ],
)
async def refresh_token(
    body: Mapping[str, object] = Body(...),
    session: AsyncSession = Depends(get_db),
    async_refresh_access_token: Callable[[AsyncSession, str], Awaitable[str]] = Depends(
        get_async_refresh_access_token,
    ),
) -> AuthResponse:
    """Accepts either {"token": ...} or {"refresh_token": ...} for compatibility with tests.

    Raises:
        RefreshTokenRateLimitError: If too many attempts.
        RefreshTokenBlacklistedError: If token is blacklisted.
        RefreshTokenError: If token is invalid.
        ValueError: If token is missing or invalid.

    """
    token_val: object | None = body.get("token") or body.get("refresh_token")
    if not isinstance(token_val, str):
        http_error(422, "Missing refresh token.", logger.warning, {})
    token: str = token_val if isinstance(token_val, str) else ""
    try:
        new_token: str = await async_refresh_access_token(session, token)
        logger.info("refresh_success", extra={"token": token})
        return AuthResponse(access_token=new_token, refresh_token=token)
    except RefreshTokenRateLimitError:
        http_error(
            429,
            "Too many token refresh attempts. Please try again later.",
            logger.warning,
            cast("ExtraLogInfo", {"token": token}),
        )
    except RefreshTokenBlacklistedError:
        http_error(
            401,
            "Invalid or expired refresh token.",
            logger.warning,
            cast("ExtraLogInfo", {"token": token}),
        )
    except RefreshTokenError:
        http_error(
            401,
            "Invalid or expired refresh token.",
            logger.warning,
            cast("ExtraLogInfo", {"token": token}),
        )
    except ValueError as e:
        http_error(
            401,
            "Invalid or expired refresh token.",
            logger.warning,
            cast("ExtraLogInfo", {"token": token, "error": str(e)}),
            e,
        )
    except Exception as e:
        http_error(
            401,
            "Invalid or expired refresh token.",
            logger.warning,
            cast("ExtraLogInfo", {"token": token, "error": str(e)}),
            e,
        )
    raise AssertionError("Unreachable")

request_password_reset(data, session=Depends(get_db), user_service=Depends(get_user_service), user_action_limiter=Depends(get_user_action_limiter), validate_email=Depends(get_validate_email)) async

Initiates a password reset flow.

Raises:

Type Description
Exception

For unexpected errors.

Source code in api/v1/auth.py
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
@router.post(
    "/request-password-reset",
    response_model=MessageResponse,
    summary=PWRESET_REQUEST_SUMMARY,
    description=PWRESET_REQUEST_DESCRIPTION,
    dependencies=[
        Depends(get_request_id),
        Depends(require_feature("auth:request_password_reset")),
        Depends(require_api_key),
    ],
)
async def request_password_reset(
    data: PasswordResetRequest,
    session: AsyncSession = Depends(get_db),
    user_service: UserService = Depends(get_user_service),
    user_action_limiter: UserActionLimiterProtocol = Depends(get_user_action_limiter),
    validate_email: Callable[[str], bool] = Depends(get_validate_email),
) -> MessageResponse:
    """Initiates a password reset flow.

    Raises:
        Exception: For unexpected errors.

    """
    await check_rate_limit(
        user_action_limiter,
        f"pwreset:{data.email}",
        {"email": data.email},
        action="pwreset",
    )
    logger.info(f"Password reset requested: {data.email}")
    try:
        user_service.get_password_reset_token(data.email)
        logger.info("pwreset_link_generated", extra={"email": data.email})
        return MessageResponse(message="Password reset link sent.")
    except Exception as e:
        logger.warning(
            f"Password reset request failed: {data.email}",
            extra={"error": str(e)},
        )
        return MessageResponse(message="Password reset link sent.")

reset_password(data=Body(...), session=Depends(get_db), user_service=Depends(get_user_service), get_password_validation_error=Depends(get_password_validation_error)) async

Completes the password reset flow using a valid reset token.

Raises:

Type Description
ValidationError

If password is invalid.

Exception

For unexpected errors.

Source code in api/v1/auth.py
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
@router.post(
    "/reset-password",
    response_model=MessageResponse,
    summary=PWRESET_SUMMARY,
    description=PWRESET_DESCRIPTION,
    dependencies=[
        Depends(get_request_id),
        Depends(require_feature("auth:reset_password")),
        Depends(require_api_key),
    ],
)
async def reset_password(
    data: PasswordResetConfirmRequest = Body(...),
    session: AsyncSession = Depends(get_db),
    user_service: UserService = Depends(get_user_service),
    get_password_validation_error: Callable[[str], str | None] = Depends(
        get_password_validation_error,
    ),
) -> MessageResponse:
    """Completes the password reset flow using a valid reset token.

    Raises:
        ValidationError: If password is invalid.
        Exception: For unexpected errors.

    """
    pw_error: str | None = get_password_validation_error(data.new_password)
    if pw_error is not None:
        http_error(
            400,
            pw_error,
            logger.warning,
            cast("ExtraLogInfo", {"token_prefix": data.token[:8]}),
        )
    logger.info(f"Password reset confirm attempt: {data.token[:8]}")
    try:
        await user_service.reset_password(session, data.token, data.new_password)
        logger.info(f"Password reset successful: {data.token[:8]}")
        return MessageResponse(message="Password has been reset.")
    except ValidationError as e:
        http_error(
            400,
            str(e),
            logger.warning,
            cast("ExtraLogInfo", {"token_prefix": data.token[:8], "error": str(e)}),
            e,
        )
    except Exception as e:
        http_error(
            400,
            "An error occurred while resetting the password. Please try again later.",
            logger.error,
            cast("ExtraLogInfo", {"token_prefix": data.token[:8], "error": str(e)}),
            e,
        )
    raise AssertionError("Unreachable")

get_me(current_user=Depends(get_current_user)) async

Returns the profile information of the currently authenticated user.

Source code in api/v1/auth.py
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
@router.get(
    "/me",
    response_model=UserProfile,
    summary=ME_SUMMARY,
    description=ME_DESCRIPTION,
    dependencies=[
        Depends(get_request_id),
        Depends(require_feature("auth:me")),
        Depends(require_api_key),
    ],
)
async def get_me(
    current_user: User = Depends(get_current_user),
) -> UserProfile:
    """Returns the profile information of the currently authenticated user."""
    logger.info("Get current user info", extra={"user_id": current_user.id})
    user_dict: dict[str, object] = {
        "id": current_user.id,
        "email": current_user.email,
        "name": current_user.name,
        "bio": current_user.bio,
        "avatar_url": current_user.avatar_url,
        "created_at": (
            current_user.created_at.isoformat() if current_user.created_at else None
        ),
        "updated_at": (
            current_user.updated_at.isoformat() if current_user.updated_at else None
        ),
    }
    return UserProfile.model_validate(user_dict)

api.v1.uploads

ROUTER_PREFIX = '/uploads' module-attribute

ROUTER_TAGS = ('File',) module-attribute

FileInDB = DBFile module-attribute

router = APIRouter(prefix=ROUTER_PREFIX, tags=list(ROUTER_TAGS)) module-attribute

FileDict

Bases: TypedDict

filename instance-attribute

url instance-attribute

content_type instance-attribute

size instance-attribute

created_at instance-attribute

FileUploadResponse

Bases: BaseModel

filename instance-attribute

url instance-attribute

model_config = ConfigDict(json_schema_extra={'example': {'filename': 'document.pdf', 'url': '/uploads/document.pdf'}}) class-attribute instance-attribute

FileListResponse

Bases: BaseModel

files instance-attribute

total instance-attribute

model_config = ConfigDict(json_schema_extra={'examples': [{'files': [{'filename': 'document.pdf', 'url': '/uploads/document.pdf'}, {'filename': 'image.jpg', 'url': '/uploads/image.jpg'}, {'filename': 'only_filename.pdf'}], 'total': 3}]}) class-attribute instance-attribute

FileResponse

Bases: BaseModel

filename instance-attribute

url instance-attribute

content_type = None class-attribute instance-attribute

size = None class-attribute instance-attribute

created_at = None class-attribute instance-attribute

model_config = ConfigDict(json_schema_extra={'example': {'filename': 'document.pdf', 'url': '/uploads/document.pdf', 'content_type': 'application/pdf', 'size': 1024, 'created_at': '2025-06-20T12:00:00Z'}}) class-attribute instance-attribute

BulkDeleteRequest

Bases: BaseModel

filenames instance-attribute

model_config = ConfigDict(json_schema_extra={'example': {'filenames': ['document1.pdf', 'document2.pdf', 'document3.pdf']}}) class-attribute instance-attribute

BulkDeleteResponse

Bases: BaseModel

deleted instance-attribute

failed instance-attribute

model_config = ConfigDict(json_schema_extra={'example': {'deleted': ['document1.pdf', 'document2.pdf'], 'failed': ['document3.pdf']}}) class-attribute instance-attribute

ensure_nonempty_filename(file=FastAPIFile(...))

Ensures the uploaded file has a non-empty filename. Raises: HTTPException: If the filename is empty.

Source code in api/v1/uploads.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def ensure_nonempty_filename(file: UploadFile = FastAPIFile(...)) -> UploadFile:
    """
    Ensures the uploaded file has a non-empty filename.
    Raises:
        HTTPException: If the filename is empty.
    """
    if not file.filename:
        http_error(
            400,
            "Invalid file.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": str(file.filename)}),
        )
    return file

root_test() async

Source code in api/v1/uploads.py
215
216
217
async def root_test() -> Mapping[str, str]:
    logging.warning("UPLOADS ROOT-TEST CALLED")
    return _root_test_response()

test_alive() async

Source code in api/v1/uploads.py
248
249
250
async def test_alive() -> Mapping[str, str]:
    logging.warning("UPLOADS TEST-ALIVE CALLED")
    return _test_alive_response()

export_alive() async

Source code in api/v1/uploads.py
279
280
281
async def export_alive() -> Mapping[str, str]:
    logging.warning("UPLOADS EXPORT-ALIVE CALLED")
    return _export_alive_response()

export_test(current_user=Depends(get_current_user)) async

Source code in api/v1/uploads.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
@router.get(
    "/export-test",
    summary="Test endpoint for export router",
    description="""
    **Export Router Test**

    Returns 200 if the uploads export router is active. Requires authentication.

    **Returns:**
    - JSON status
    - Current user ID (if authenticated)
    """,
    tags=["File"],
    responses={
        200: {
            "description": "Export router test successful",
            "content": {
                "application/json": {
                    "examples": {
                        "default": {"value": {"status": "uploads export test"}}
                    }
                }
            },
        }
    },
)
async def export_test(
    current_user: User | None = Depends(get_current_user),
) -> Mapping[str, str]:
    logging.warning(
        f"UPLOADS EXPORT-TEST CALLED with user_id={current_user.id if current_user else 'None'}"
    )
    return {"status": "uploads export test"}

export_files_csv(session=Depends(get_async_session), current_user=Depends(get_current_user), params=Depends(pagination_params), q=Query(None, description='Search by filename (partial match)'), sort=Query('created_at', description='Sort by field: created_at, filename'), order=Query('desc', description='Sort order: asc or desc'), fields=Query(None, description='Comma-separated list of fields to include in response (e.g. filename,url)'), created_before=Query(None, description='Filter files created before this datetime (ISO 8601, e.g. 2024-01-01T00:00:00Z)'), created_after=Query(None, description='Filter files created after this datetime (ISO 8601, e.g. 2024-01-01T00:00:00Z)'), request_id=Depends(get_request_id), feature_flag_ok=Depends(require_feature('uploads:export')), api_key_ok=Depends(require_api_key)) async

Export the list of uploaded files as a CSV file. Raises: HTTPException: If date parsing fails.

Source code in api/v1/uploads.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
@router.get(
    "/export",
    summary="Export files as CSV",
    description="""
    **Export Operation**

    Exports the list of uploaded files as a CSV file.

    **Steps:**
    1. Authenticated user requests export.
    2. Server filters and sorts files as requested.
    3. Returns a CSV with filename and URL columns.

    **Notes:**
    - Supports filtering by creation date and filename.
    - Rate limiting and authentication required.
    """,
    responses={
        200: {
            "description": "Files exported as CSV",
            "content": {
                "text/csv": {
                    "examples": {
                        "default": {
                            "value": "filename,url\ndocument.pdf,/uploads/document.pdf"
                        }
                    }
                }
            },
        },
        401: {
            "description": "Unauthorized. Missing or invalid authentication.",
            "content": {
                "application/json": {"example": {"detail": "Not authenticated."}}
            },
        },
        403: {
            "description": "Forbidden. Not enough permissions.",
            "content": {
                "application/json": {"example": {"detail": "Not enough permissions."}}
            },
        },
        422: {
            "description": "Unprocessable Entity. Invalid input.",
            "content": {"application/json": {"example": {"detail": "Invalid input."}}},
        },
        429: {
            "description": "Too many requests.",
            "content": {
                "application/json": {"example": {"detail": "Rate limit exceeded."}}
            },
        },
        500: {
            "description": "Internal server error.",
            "content": {
                "application/json": {"example": {"detail": "Unexpected error."}}
            },
        },
        503: {
            "description": "Service unavailable.",
            "content": {
                "application/json": {
                    "example": {"detail": "Service temporarily unavailable."}
                }
            },
        },
    },
    openapi_extra={
        "x-codeSamples": [
            {
                "lang": "curl",
                "label": "cURL",
                "source": "curl -H 'Authorization: Bearer <token>' 'https://api.reviewpoint.org/api/v1/uploads/export'",
            },
            {
                "lang": "Python",
                "label": "Python (requests)",
                "source": "import requests\nurl = 'https://api.reviewpoint.org/api/v1/uploads/export'\nheaders = {'Authorization': 'Bearer <token>'}\nresponse = requests.get(url, headers=headers)\nprint(response.content.decode())",
            },
            {
                "lang": "JavaScript",
                "label": "JavaScript (fetch)",
                "source": "fetch('https://api.reviewpoint.org/api/v1/uploads/export', {\n  headers: { 'Authorization': 'Bearer <token>' }\n})\n  .then(res => res.text())\n  .then(console.log);",
            },
            {
                "lang": "Go",
                "label": "Go (net/http)",
                "source": 'package main\nimport (\n  "net/http"\n)\nfunc main() {\n  req, _ := http.NewRequest("GET", "https://api.reviewpoint.org/api/v1/uploads/export", nil)\n  req.Header.Set("Authorization", "Bearer <token>")\n  http.DefaultClient.Do(req)\n}',
            },
            {
                "lang": "Java",
                "label": "Java (OkHttp)",
                "source": 'OkHttpClient client = new OkHttpClient();\nRequest request = new Request.Builder()\n  .url("https://api.reviewpoint.org/api/v1/uploads/export")\n  .get()\n  .addHeader("Authorization", "Bearer <token>")\n  .build();\nResponse response = client.newCall(request).execute();',
            },
            {
                "lang": "PHP",
                "label": "PHP (cURL)",
                "source": "$ch = curl_init('https://api.reviewpoint.org/api/v1/uploads/export');\ncurl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer <token>']);\n$response = curl_exec($ch);\ncurl_close($ch);",
            },
            {
                "lang": "Ruby",
                "label": "Ruby (Net::HTTP)",
                "source": "require 'net/http'\nuri = URI('https://api.reviewpoint.org/api/v1/uploads/export')\nreq = Net::HTTP::Get.new(uri)\nreq['Authorization'] = 'Bearer <token>'\nres = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }\nputs res.body",
            },
            {
                "lang": "HTTPie",
                "label": "HTTPie",
                "source": "http GET https://api.reviewpoint.org/api/v1/uploads/export Authorization:'Bearer <token>'",
            },
            {
                "lang": "PowerShell",
                "label": "PowerShell",
                "source": "$headers = @{Authorization='Bearer <token>'}\nInvoke-RestMethod -Uri 'https://api.reviewpoint.org/api/v1/uploads/export' -Headers $headers -Method Get",
            },
        ]
    },
)
async def export_files_csv(
    session: AsyncSession = Depends(get_async_session),
    current_user: User = Depends(get_current_user),
    params: object = Depends(pagination_params),
    q: str | None = Query(None, description="Search by filename (partial match)"),
    sort: Literal["created_at", "filename"] = Query(
        "created_at", description="Sort by field: created_at, filename"
    ),
    order: Literal["desc", "asc"] = Query(
        "desc", description="Sort order: asc or desc"
    ),
    fields: str | None = Query(
        None,
        description="Comma-separated list of fields to include in response (e.g. filename,url)",
    ),
    created_before: str | None = Query(
        None,
        description="Filter files created before this datetime (ISO 8601, e.g. 2024-01-01T00:00:00Z)",
    ),
    created_after: str | None = Query(
        None,
        description="Filter files created after this datetime (ISO 8601, e.g. 2024-01-01T00:00:00Z)",
    ),
    request_id: str = Depends(get_request_id),
    feature_flag_ok: bool = Depends(require_feature("uploads:export")),
    api_key_ok: None = Depends(require_api_key),
) -> StreamingResponse:
    """
    Export the list of uploaded files as a CSV file.
    Raises:
        HTTPException: If date parsing fails.
    """
    logging.info(
        f"UPLOADS EXPORT CALLED with user_id={getattr(current_user, 'id', None)}"
    )

    def _generate_csv(files: Sequence[DBFile], columns: Sequence[str]) -> Iterator[str]:
        output: StringIO = StringIO()
        writer = csv.writer(output)
        writer.writerow(columns)
        for f in files:
            row: list[str] = []
            if "filename" in columns:
                row.append(f.filename)
            if "url" in columns:
                row.append(f"/uploads/{f.filename}")
            writer.writerow(row)
        yield output.getvalue()

    created_after_dt: datetime | None
    created_before_dt: datetime | None
    try:
        created_after_dt = (
            parse_flexible_datetime(created_after) if created_after else None
        )
    except ValueError as e:
        logger.error(f"Invalid created_after: {e}")
        http_error(
            422,
            str(e),
            logger.error,
            cast(ExtraLogInfo, {"created_after": created_after or ""}),
            e,
        )
    try:
        created_before_dt = (
            parse_flexible_datetime(created_before) if created_before else None
        )
    except ValueError as e:
        logger.error(f"Invalid created_before: {e}")
        http_error(
            422,
            str(e),
            logger.error,
            cast(ExtraLogInfo, {"created_before": created_before or ""}),
            e,
        )
    files: Sequence[DBFile]
    _total: int
    files, _total = await repo_list_files(
        session,
        current_user.id,
        offset=getattr(params, "offset", 0),
        limit=getattr(params, "limit", 10000),  # Large limit for export
        q=q,
        sort=sort,
        order=order,
        created_after=created_after_dt,
        created_before=created_before_dt,
    )
    columns: list[str] = ["filename", "url"]
    if fields:
        requested: list[str] = [
            f.strip() for f in fields.split(",") if f.strip() in columns
        ]
        if requested:
            columns = requested
    return StreamingResponse(
        _generate_csv(files, columns),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=uploads_export.csv"},
    )

upload_file(file=FastAPIFile(..., description='The file to upload. Must be a valid file type.'), session=Depends(get_async_session), current_user=Depends(get_current_user), request_id=Depends(get_request_id), feature_flag_ok=Depends(require_feature('uploads:upload')), api_key_ok=Depends(require_api_key)) async

Uploads a file and returns its filename and URL. Raises: HTTPException: If file is invalid or upload fails.

Source code in api/v1/uploads.py
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
@router.post(
    "",
    summary="Upload a file",
    description="""
    **Secure File Upload**

    Uploads files with comprehensive validation, virus scanning, and metadata generation.

    **Process:**
    1. **File Validation**: Checks file type, size, and content
    2. **Security Scan**: Virus scanning and malware detection
    3. **Metadata Generation**: Extracts file information and generates checksums
    4. **Storage**: Secure storage with access controls
    5. **Response**: Returns file metadata and access URLs

    **Supported File Types:**
    - **Documents**: PDF, DOC, DOCX, TXT, RTF
    - **Images**: JPEG, PNG, GIF, SVG, WEBP
    - **Data**: CSV, XLS, XLSX, JSON, XML
    - **Archives**: ZIP, TAR, GZ (with content scanning)

    **File Constraints:**
    - **Maximum Size**: 100MB per file
    - **Rate Limiting**: 5 uploads per minute per user
    - **Naming**: Automatic sanitization and duplicate handling

    **Security Features:**
    - Virus and malware scanning
    - Content type validation (not just extension)
    - File size limits and timeout protection
    - Automatic quarantine of suspicious files
    - Access logging and audit trail

    **Response Format:**
    ```json
    {
      "id": 123,
      "filename": "document.pdf",
      "content_type": "application/pdf",
      "size": 2048576,
      "md5_hash": "d41d8cd98f00b204e9800998ecf8427e",
      "upload_url": "/api/v1/uploads/123",
      "download_url": "/api/v1/uploads/123/download",
      "created_at": "2024-01-16T09:15:00Z"
    }
    ```

    **Usage Examples:**

    *cURL:*
    ```bash
    curl -X POST "https://api.reviewpoint.org/api/v1/uploads" \\
      -H "Authorization: Bearer YOUR_JWT_TOKEN" \\
      -F "file=@document.pdf" \\
      -F "description=Research paper draft"
    ```

    *Python:*
    ```python
    import requests
    files = {"file": ("doc.pdf", open("doc.pdf", "rb"))}
    headers = {"Authorization": "Bearer YOUR_JWT_TOKEN"}
    response = requests.post(
        "https://api.reviewpoint.org/api/v1/uploads",
        files=files,
        headers=headers
    )
    ```

    **Error Handling:**
    - `400`: Invalid file type or corrupt file
    - `401`: Authentication required
    - `413`: File too large (>100MB)
    - `415`: Unsupported file type
    - `429`: Upload rate limit exceeded
    - `422`: Validation errors (missing file, etc.)
    """,
    response_model=FileUploadResponse,
    status_code=status.HTTP_201_CREATED,
    responses={
        201: {
            "description": "File uploaded successfully",
            "content": {
                "application/json": {
                    "example": {
                        "filename": "document.pdf",
                        "url": "/uploads/document.pdf",
                    }
                }
            },
        },
        400: {
            "description": "Invalid file",
            "content": {
                "application/json": {"example": {"detail": "File type not allowed."}}
            },
        },
        401: {
            "description": "Unauthorized. Missing or invalid authentication.",
            "content": {
                "application/json": {"example": {"detail": "Not authenticated."}}
            },
        },
        413: {
            "description": "File too large.",
            "content": {
                "application/json": {"example": {"detail": "File size exceeds limit."}}
            },
        },
        415: {
            "description": "Unsupported Media Type.",
            "content": {
                "application/json": {"example": {"detail": "Unsupported file type."}}
            },
        },
        422: {
            "description": "Unprocessable Entity. Invalid input.",
            "content": {"application/json": {"example": {"detail": "Invalid input."}}},
        },
        429: {
            "description": "Too many requests.",
            "content": {
                "application/json": {"example": {"detail": "Rate limit exceeded."}}
            },
        },
        500: {
            "description": "Internal server error.",
            "content": {
                "application/json": {"example": {"detail": "Unexpected error."}}
            },
        },
        503: {
            "description": "Service unavailable.",
            "content": {
                "application/json": {
                    "example": {"detail": "Service temporarily unavailable."}
                }
            },
        },
    },
    openapi_extra={
        "requestBody": {
            "content": {
                "multipart/form-data": {
                    "example": {"file": "(binary file, e.g. document.pdf)"}
                }
            }
        },
        "x-codeSamples": [
            {
                "lang": "curl",
                "label": "cURL",
                "source": "curl -X POST 'https://api.reviewpoint.org/api/v1/uploads' \\\n  -H 'Authorization: Bearer <token>' \\\n  -F 'file=@document.pdf'",
            },
            {
                "lang": "Python",
                "label": "Python (requests)",
                "source": "import requests\nurl = 'https://api.reviewpoint.org/api/v1/uploads'\nheaders = {'Authorization': 'Bearer <token>'}\nfiles = {'file': open('document.pdf', 'rb')}\nresponse = requests.post(url, headers=headers, files=files)\nprint(response.json())",
            },
            {
                "lang": "JavaScript",
                "label": "JavaScript (fetch)",
                "source": "const form = new FormData();\nform.append('file', fileInput.files[0]);\nfetch('https://api.reviewpoint.org/api/v1/uploads', {\n  method: 'POST',\n  headers: { 'Authorization': 'Bearer <token>' },\n  body: form\n})\n  .then(res => res.json())\n  .then(console.log);",
            },
            {
                "lang": "Go",
                "label": "Go (net/http)",
                "source": 'package main\nimport (\n  "bytes"\n  "mime/multipart"\n  "net/http"\n  "os"\n)\nfunc main() {\n  file, _ := os.Open("document.pdf")\n  defer file.Close()\n  body := &bytes.Buffer{}\n  writer := multipart.NewWriter(body)\n  part, _ := writer.CreateFormFile("file", "document.pdf")\n  io.Copy(part, file)\n  writer.Close()\n  req, _ := http.NewRequest("POST", "https://api.reviewpoint.org/api/v1/uploads", body)\n  req.Header.Set("Authorization", "Bearer <token>")\n  req.Header.Set("Content-Type", writer.FormDataContentType())\n  http.DefaultClient.Do(req)\n}',
            },
            {
                "lang": "Java",
                "label": "Java (OkHttp)",
                "source": 'OkHttpClient client = new OkHttpClient();\nMediaType mediaType = MediaType.parse("application/pdf");\nFile file = new File("document.pdf");\nRequestBody fileBody = RequestBody.create(mediaType, file);\nMultipartBody requestBody = new MultipartBody.Builder()\n  .setType(MultipartBody.FORM)\n  .addFormDataPart("file", file.getName(), fileBody)\n  .build();\nRequest request = new Request.Builder()\n  .url("https://api.reviewpoint.org/api/v1/uploads")\n  .post(requestBody)\n  .addHeader("Authorization", "Bearer <token>")\n  .build();\nResponse response = client.newCall(request).execute();',
            },
            {
                "lang": "PHP",
                "label": "PHP (cURL)",
                "source": "$ch = curl_init('https://api.reviewpoint.org/api/v1/uploads');\ncurl_setopt($ch, CURLOPT_POST, 1);\ncurl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => new CURLFile('document.pdf')]);\ncurl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer <token>']);\n$response = curl_exec($ch);\ncurl_close($ch);",
            },
            {
                "lang": "Ruby",
                "label": "Ruby (Net::HTTP)",
                "source": "require 'net/http'\nrequire 'uri'\nrequire 'json'\nuri = URI('https://api.reviewpoint.org/api/v1/uploads')\nrequest = Net::HTTP::Post.new(uri)\nrequest['Authorization'] = 'Bearer <token>'\nform_data = [['file', File.open('document.pdf')]]\nrequest.set_form form_data, 'multipart/form-data'\nresponse = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|\n  http.request(request)\nend\nputs response.body",
            },
            {
                "lang": "HTTPie",
                "label": "HTTPie",
                "source": "http -f POST https://api.reviewpoint.org/api/v1/uploads Authorization:'Bearer <token>' file@document.pdf",
            },
            {
                "lang": "PowerShell",
                "label": "PowerShell",
                "source": "$file = Get-Item .\\document.pdf\n$Form = @{file = $file}\nInvoke-RestMethod -Uri 'https://api.reviewpoint.org/api/v1/uploads' -Method Post -Form $Form -Headers @{Authorization='Bearer <token>'}",
            },
        ],
    },
)
async def upload_file(
    file: UploadFile = FastAPIFile(
        ..., description="The file to upload. Must be a valid file type."
    ),
    session: AsyncSession = Depends(get_async_session),
    current_user: User = Depends(get_current_user),
    request_id: str = Depends(get_request_id),
    feature_flag_ok: bool = Depends(require_feature("uploads:upload")),
    api_key_ok: None = Depends(require_api_key),
) -> FileUploadResponse:
    """
    Uploads a file and returns its filename and URL.
    Raises:
        HTTPException: If file is invalid or upload fails.
    """

    # Enforce a maximum upload size (e.g., 5MB)
    MAX_UPLOAD_SIZE = 5 * 1024 * 1024  # 5MB
    file.file.seek(0, 2)  # Seek to end
    file_size = file.file.tell()
    file.file.seek(0)
    if file_size > MAX_UPLOAD_SIZE:
        http_error(
            413,
            f"File size exceeds limit of {MAX_UPLOAD_SIZE // (1024*1024)}MB.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": str(file.filename), "size": file_size}),
        )

    if not file.filename:
        http_error(
            400,
            "Invalid file.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": str(file.filename)}),
        )

    filename_str: str = file.filename if file.filename is not None else ""
    if not is_safe_filename(filename_str):
        http_error(
            400,
            "Invalid filename. Path traversal attempts are not allowed.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": filename_str}),
        )

    safe_filename: str = sanitize_filename(filename_str)
    original_filename: str = filename_str
    file.filename = safe_filename

    if original_filename != safe_filename:
        logging.warning(
            f"Filename sanitized from '{original_filename}' to '{safe_filename}'"
        )

    max_retries: Final[int] = 3
    last_exception: Exception | None = None

    for attempt in range(max_retries):
        try:
            # Create a new session for each attempt to avoid SQLAlchemy IllegalStateChangeError
            async with session.begin_nested():
                db_file: DBFile = await create_file(
                    session,
                    file.filename,
                    file.content_type or "application/octet-stream",
                    user_id=current_user.id,
                    size=file_size,
                )

            await session.commit()
            return FileUploadResponse(
                filename=db_file.filename, url=f"/uploads/{db_file.filename}"
            )
        except Exception as e:
            last_exception = e
            try:
                await session.rollback()
            except Exception as rollback_error:
                logging.error(f"Error during session rollback: {rollback_error}")
            error_str: str = str(e).lower()

            if attempt < max_retries - 1 and (
                "database is locked" in error_str
                or "unique constraint failed" in error_str
                or "illegal state change" in error_str
            ):
                wait_time: float = 0.1 * (2**attempt)
                await asyncio.sleep(wait_time)
                continue

            if "unique constraint failed" in error_str:
                http_error(
                    409,
                    "File with same name already exists or concurrent upload conflict",
                    logger.warning,
                    cast(
                        ExtraLogInfo,
                        {
                            "filename": (
                                file.filename if file.filename is not None else ""
                            )
                        },
                    ),
                    e,
                )

            logging.error(f"Failed to upload file on attempt {attempt+1}: {str(e)}")

    if last_exception:
        if "illegal state change" in str(last_exception).lower():
            http_error(
                500,
                "Database concurrency conflict. Please try again.",
                logger.error,
                cast(
                    ExtraLogInfo,
                    {"filename": file.filename if file.filename is not None else ""},
                ),
                last_exception,
            )
        http_error(
            500,
            f"Failed to upload file: {str(last_exception)}",
            logger.error,
            cast(
                ExtraLogInfo,
                {"filename": file.filename if file.filename is not None else ""},
            ),
            last_exception,
        )
    http_error(
        500,
        "Failed to upload file after multiple retries",
        logger.error,
        cast(
            ExtraLogInfo,
            {"filename": file.filename if file.filename is not None else ""},
        ),
    )

    # Defensive: static type checkers require a return, but this is unreachable
    raise RuntimeError(
        "Unreachable: all code paths in upload_file should raise or return"
    )

bulk_delete_files_endpoint(request_data, session=Depends(get_async_session), current_user=Depends(get_current_user), request_id=Depends(get_request_id), feature_flag_ok=Depends(require_feature('uploads:bulk_delete')), api_key_ok=Depends(require_api_key)) async

Bulk delete files for the current user.

Source code in api/v1/uploads.py
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
@router.post(
    "/bulk-delete",
    summary="Bulk delete files",
    description="""
    **Bulk File Deletion**

    Delete multiple files in a single request.

    **Features:**
    - Delete up to 100 files per request
    - Only deletes files owned by the current user
    - Returns detailed success/failure results
    - Atomic operation per file (failure of one doesn't affect others)

    **Request Body:**
    ```json
    {
      "filenames": ["file1.pdf", "file2.pdf", "file3.pdf"]
    }
    ```

    **Response:**
    ```json
    {
      "deleted": ["file1.pdf", "file2.pdf"],
      "failed": ["file3.pdf"]
    }
    ```
    """,
    response_model=BulkDeleteResponse,
    status_code=status.HTTP_200_OK,
)
async def bulk_delete_files_endpoint(
    request_data: BulkDeleteRequest,
    session: AsyncSession = Depends(get_async_session),
    current_user: User = Depends(get_current_user),
    request_id: str = Depends(get_request_id),
    feature_flag_ok: bool = Depends(require_feature("uploads:bulk_delete")),
    api_key_ok: None = Depends(require_api_key),
) -> BulkDeleteResponse:
    """
    Bulk delete files for the current user.
    """
    # Limit bulk operations to prevent abuse
    if len(request_data.filenames) > 100:
        http_error(
            400,
            "Cannot delete more than 100 files at once",
            logger.warning,
            cast(ExtraLogInfo, {"count": len(request_data.filenames)}),
        )

    if not request_data.filenames:
        return BulkDeleteResponse(deleted=[], failed=[])

    try:
        deleted, failed = await bulk_delete_files(
            session, request_data.filenames, current_user.id
        )
        await session.commit()

        logger.info(
            f"Bulk delete completed: {len(deleted)} deleted, {len(failed)} failed",
            extra={"user_id": current_user.id, "deleted": deleted, "failed": failed},
        )

        return BulkDeleteResponse(deleted=deleted, failed=failed)
    except Exception as e:
        await session.rollback()
        http_error(
            500,
            f"Bulk delete failed: {str(e)}",
            logger.error,
            cast(ExtraLogInfo, {"filenames": request_data.filenames}),
            e,
        )
        # This should never be reached due to http_error raising, but for type safety
        return BulkDeleteResponse(deleted=[], failed=request_data.filenames)

get_file(filename=Path(..., description='The name of the file to retrieve.'), session=Depends(get_async_session), current_user=Depends(get_current_user)) async

Retrieves metadata for an uploaded file by filename. Raises: HTTPException: If file is not found.

Source code in api/v1/uploads.py
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
@router.get(
    "/{filename}",
    summary="Get uploaded file info",
    description="Retrieves metadata for an uploaded file by filename.",
    response_model=FileUploadResponse,
    responses={
        200: {
            "description": "File found",
            "content": {
                "application/json": {
                    "example": {
                        "filename": "document.pdf",
                        "url": "/uploads/document.pdf",
                    }
                }
            },
        },
        401: {
            "description": "Unauthorized. Missing or invalid authentication.",
            "content": {
                "application/json": {"example": {"detail": "Not authenticated."}}
            },
        },
        403: {
            "description": "Forbidden. Not enough permissions.",
            "content": {
                "application/json": {"example": {"detail": "Not enough permissions."}}
            },
        },
        404: {
            "description": "File not found",
            "content": {"application/json": {"example": {"detail": "File not found."}}},
        },
        422: {
            "description": "Unprocessable Entity. Invalid filename.",
            "content": {
                "application/json": {"example": {"detail": "Invalid filename."}}
            },
        },
        429: {
            "description": "Too many requests.",
            "content": {
                "application/json": {"example": {"detail": "Rate limit exceeded."}}
            },
        },
        500: {
            "description": "Internal server error.",
            "content": {
                "application/json": {"example": {"detail": "Unexpected error."}}
            },
        },
        503: {
            "description": "Service unavailable.",
            "content": {
                "application/json": {
                    "example": {"detail": "Service temporarily unavailable."}
                }
            },
        },
    },
)
async def get_file(
    filename: str = Path(..., description="The name of the file to retrieve."),
    session: AsyncSession = Depends(get_async_session),
    current_user: User = Depends(get_current_user),
) -> FileUploadResponse:
    """
    Retrieves metadata for an uploaded file by filename.
    Raises:
        HTTPException: If file is not found.
    """
    logging.warning(f"GET FILE BY FILENAME CALLED: {filename}")
    db_file: DBFile | None = await get_file_by_filename(session, filename)
    if db_file is None:
        http_error(
            404,
            "File not found.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": filename}),
        )
    return FileUploadResponse(
        filename=getattr(db_file, "filename", filename),
        url=f"/uploads/{getattr(db_file, 'filename', filename)}",
    )

delete_file_by_filename(filename=Path(..., description='The name of the file to delete.'), session=Depends(get_async_session), current_user=Depends(get_current_user), request_id=Depends(get_request_id), feature_flag_ok=Depends(require_feature('uploads:delete')), api_key_ok=Depends(require_api_key)) async

Deletes an uploaded file by filename. Raises: HTTPException: If file is not found.

Source code in api/v1/uploads.py
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
@router.delete(
    "/{filename}",
    summary="Delete uploaded file",
    description="Deletes an uploaded file by filename.",
    responses={
        204: {
            "description": "File deleted successfully",
            "content": {"application/json": {"examples": {"default": {"value": {}}}}},
        },
        401: {
            "description": "Unauthorized. Missing or invalid authentication.",
            "content": {
                "application/json": {"example": {"detail": "Not authenticated."}}
            },
        },
        403: {
            "description": "Forbidden. Not enough permissions.",
            "content": {
                "application/json": {"example": {"detail": "Not enough permissions."}}
            },
        },
        404: {
            "description": "File not found",
            "content": {"application/json": {"example": {"detail": "File not found."}}},
        },
        422: {
            "description": "Unprocessable Entity. Invalid filename.",
            "content": {
                "application/json": {"example": {"detail": "Invalid filename."}}
            },
        },
        429: {
            "description": "Too many requests.",
            "content": {
                "application/json": {"example": {"detail": "Rate limit exceeded."}}
            },
        },
        500: {
            "description": "Internal server error.",
            "content": {
                "application/json": {"example": {"detail": "Unexpected error."}}
            },
        },
        503: {
            "description": "Service unavailable.",
            "content": {
                "application/json": {
                    "example": {"detail": "Service temporarily unavailable."}
                }
            },
        },
    },
)
async def delete_file_by_filename(
    filename: str = Path(..., description="The name of the file to delete."),
    session: AsyncSession = Depends(get_async_session),
    current_user: User = Depends(get_current_user),
    request_id: str = Depends(get_request_id),
    feature_flag_ok: bool = Depends(require_feature("uploads:delete")),
    api_key_ok: None = Depends(require_api_key),
) -> Response:
    """
    Deletes an uploaded file by filename.
    Raises:
        HTTPException: If file is not found.
    """
    db_file: bool = await delete_file(session, filename)
    if not db_file:
        http_error(
            404,
            "File not found.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": filename}),
        )
    await session.commit()  # Explicitly commit the transaction
    return Response(status_code=204)

download_file(filename=Path(..., description='The name of the file to download.'), session=Depends(get_async_session), current_user=Depends(get_current_user), request_id=Depends(get_request_id), feature_flag_ok=Depends(require_feature('uploads:download')), api_key_ok=Depends(require_api_key)) async

Download file content with proper headers. Raises: HTTPException: If file is not found or access denied.

Source code in api/v1/uploads.py
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
@router.get(
    "/{filename}/download",
    summary="Download uploaded file",
    description="""
    **File Download**

    Download the actual file content with proper headers for browser download.

    **Features:**
    - Proper Content-Type headers
    - Content-Disposition for browser download
    - File size information
    - Security checks (user ownership verification)

    **Response:**
    - Returns the file content with appropriate headers
    - Sets filename for browser downloads
    """,
    responses={
        200: {
            "description": "File content",
            "content": {
                "application/octet-stream": {
                    "schema": {"type": "string", "format": "binary"}
                }
            },
        },
        401: {"description": "Unauthorized"},
        403: {"description": "Forbidden"},
        404: {"description": "File not found"},
    },
)
async def download_file(
    filename: str = Path(..., description="The name of the file to download."),
    session: AsyncSession = Depends(get_async_session),
    current_user: User = Depends(get_current_user),
    request_id: str = Depends(get_request_id),
    feature_flag_ok: bool = Depends(require_feature("uploads:download")),
    api_key_ok: None = Depends(require_api_key),
) -> Response:
    """
    Download file content with proper headers.
    Raises:
        HTTPException: If file is not found or access denied.
    """
    # Verify file exists and user has access
    db_file: DBFile | None = await get_file_by_filename(session, filename)
    if db_file is None:
        http_error(
            404,
            "File not found.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": filename}),
        )

    # At this point, db_file is guaranteed to not be None
    assert db_file is not None

    # Verify user owns the file
    if db_file.user_id != current_user.id:
        http_error(
            403,
            "Access denied to file.",
            logger.warning,
            cast(ExtraLogInfo, {"filename": filename, "user_id": current_user.id}),
        )

    # For now, return a placeholder response since we don't have actual file storage
    # In a real implementation, you would read the file from storage (filesystem, S3, etc.)
    content = f"File content for {filename} would be served here"

    headers = {
        "Content-Disposition": f'attachment; filename="{filename}"',
        "Content-Type": db_file.content_type or "application/octet-stream",
    }

    if db_file.size:
        headers["Content-Length"] = str(db_file.size)

    logger.info(
        "File download requested",
        extra={"filename": filename, "user_id": current_user.id, "size": db_file.size},
    )

    return Response(
        content=content.encode(),
        media_type=db_file.content_type or "application/octet-stream",
        headers=headers,
    )

catch_all_uploads(path, request) async

Catch-all route for uploads. Always returns status 418.

Source code in api/v1/uploads.py
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
@router.api_route(
    "/{path:path}",
    methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
    include_in_schema=False,
)
async def catch_all_uploads(path: str, request: Request) -> Response:
    """
    Catch-all route for uploads. Always returns status 418.
    """
    logging.warning(f"UPLOADS CATCH-ALL: path={path}, method={request.method}")
    return Response(content=f"uploads catch-all: {path}", status_code=418)

list_files(params=Depends(pagination_params), session=Depends(get_async_session), current_user=Depends(get_current_user), request_id=Depends(get_request_id), feature_flag_ok=Depends(require_feature('uploads:list')), api_key_ok=Depends(require_api_key), q=Query(None, description='Search term across all fields'), fields=Query(None, description='Comma-separated list of fields to include'), sort=Query('created_at', description='Field to sort by'), order=Query('desc', description='Sort order (asc or desc)'), created_after=Query(None, description='Filter by creation date (ISO format)'), created_before=Query(None, description='Filter by creation date (ISO format)')) async

List all uploaded files with pagination and filtering options. Raises: HTTPException: If date parsing fails.

Source code in api/v1/uploads.py
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
@router.get(
    "",
    summary="List all uploaded files",
    description="""
    **File Listing**

    Returns a paginated list of all uploaded files for the current user.

    **How it works:**
    1. The server retrieves files uploaded by the current user.
    2. Files can be filtered and sorted.
    3. Returns a list of files with pagination information.

    **Notes:**
    - Supports filtering by filename and creation date.
    - Supports sorting by creation date or filename.
    - Supports field selection to limit the returned data.
    """,
    response_model=FileListResponse,
    responses={
        200: {
            "description": "Files found",
            "content": {
                "application/json": {
                    "examples": {
                        "default": {
                            "value": {
                                "files": [
                                    {
                                        "filename": "document.pdf",
                                        "url": "/uploads/document.pdf",
                                    }
                                ],
                                "total": 1,
                            }
                        }
                    }
                }
            },
        },
        401: {
            "description": "Unauthorized. Missing or invalid authentication.",
            "content": {
                "application/json": {"example": {"detail": "Not authenticated."}}
            },
        },
    },
)
async def list_files(
    params: object = Depends(pagination_params),
    session: AsyncSession = Depends(get_async_session),
    current_user: User = Depends(get_current_user),
    request_id: str = Depends(get_request_id),
    feature_flag_ok: bool = Depends(require_feature("uploads:list")),
    api_key_ok: None = Depends(require_api_key),
    q: str | None = Query(None, description="Search term across all fields"),
    fields: str | None = Query(
        None, description="Comma-separated list of fields to include"
    ),
    sort: Literal["created_at", "filename"] = Query(
        "created_at", description="Field to sort by"
    ),
    order: Literal["desc", "asc"] = Query(
        "desc", description="Sort order (asc or desc)"
    ),
    created_after: str | None = Query(
        None, description="Filter by creation date (ISO format)"
    ),
    created_before: str | None = Query(
        None, description="Filter by creation date (ISO format)"
    ),
) -> FileListResponse:
    """
    List all uploaded files with pagination and filtering options.
    Raises:
        HTTPException: If date parsing fails.
    """
    created_after_dt: datetime | None = None
    created_before_dt: datetime | None = None
    if created_after:
        created_after_dt = parse_flexible_datetime(created_after)
    if created_before:
        created_before_dt = parse_flexible_datetime(created_before)
    files: Sequence[DBFile]
    total: int
    files, total = await repo_list_files(
        session,
        current_user.id,
        offset=getattr(params, "offset", 0),
        limit=getattr(params, "limit", 100),
        q=q,
        sort=sort,
        order=order,
        created_after=created_after_dt,
        created_before=created_before_dt,
    )

    selected_fields: Sequence[str] | None = None
    if fields:
        selected_fields = [f.strip() for f in fields.split(",")]

    file_responses: list[FileDict] = []
    for file in files:
        file_data: FileDict = {
            "filename": file.filename,
            "url": f"/uploads/{file.filename}",
        }

        # Add additional fields if requested or no field selection
        if not selected_fields or "content_type" in selected_fields:
            file_data["content_type"] = file.content_type
        if not selected_fields or "size" in selected_fields:
            file_data["size"] = file.size
        if not selected_fields or "created_at" in selected_fields:
            file_data["created_at"] = (
                file.created_at.isoformat() if file.created_at else None
            )

        if selected_fields:
            # Use cast to FileDict for type safety
            # Always include filename as it's the primary identifier
            file_data = cast(
                FileDict,
                {k: v for k, v in file_data.items() if k in selected_fields},
            )
        file_responses.append(file_data)

    return FileListResponse(files=file_responses, total=total)

api.v1.users

Aggregate all user-related routers for easy import in the FastAPI app.

all_routers = [exports_router, core_router, test_only_router] module-attribute


Models

models.user

User

Bases: BaseModel

User model.

__tablename__ = 'users' class-attribute instance-attribute

email = mapped_column(String(255), unique=True, nullable=False, index=True) class-attribute instance-attribute

hashed_password = mapped_column(String(255), nullable=False) class-attribute instance-attribute

is_active = mapped_column(Boolean, default=True, nullable=False) class-attribute instance-attribute

is_deleted = mapped_column(Boolean, default=False, nullable=False) class-attribute instance-attribute

last_login_at = mapped_column(DateTime, nullable=True) class-attribute instance-attribute

name = mapped_column(String(128), nullable=True) class-attribute instance-attribute

bio = mapped_column(String(512), nullable=True) class-attribute instance-attribute

avatar_url = mapped_column(String(255), nullable=True) class-attribute instance-attribute

preferences = mapped_column(JSON, nullable=True) class-attribute instance-attribute

is_admin = mapped_column(Boolean, default=False, nullable=False) class-attribute instance-attribute

files = relationship('File', back_populates='user', passive_deletes=True) class-attribute instance-attribute

role property writable

Returns the role of the user.

Returns:

Type Description
Literal['admin', 'user']

Literal["admin", "user"]: The user's role.

__repr__()

Return a string representation of the User instance.

Returns:

Name Type Description
str str

String representation of the instance.

Source code in models/user.py
68
69
70
71
72
73
74
75
def __repr__(self: User) -> str:
    """
    Return a string representation of the User instance.

    Returns:
        str: String representation of the instance.
    """
    return f"<User id={self.id} email={self.email}>"

models.file

File

Bases: BaseModel

SQLAlchemy model for a file uploaded by a user.

__tablename__ = 'files' class-attribute instance-attribute

__table_args__ = (Index('ix_files_user_id', 'user_id'),) class-attribute instance-attribute

filename = mapped_column(String(255), nullable=False) class-attribute instance-attribute

content_type = mapped_column(String(128), nullable=False) class-attribute instance-attribute

size = mapped_column(BigInteger, nullable=True, default=0) class-attribute instance-attribute

user_id = mapped_column(ForeignKey('users.id'), nullable=False) class-attribute instance-attribute

user = relationship('User', back_populates='files') class-attribute instance-attribute

__repr__()

Return a string representation of the File instance.

:raises AttributeError: If 'id' or 'filename' attributes are not set (e.g., before flush). :return: String representation of the File instance.

Source code in models/file.py
35
36
37
38
39
40
41
def __repr__(self: File) -> str:
    """Return a string representation of the File instance.

    :raises AttributeError: If 'id' or 'filename' attributes are not set (e.g., before flush).
    :return: String representation of the File instance.
    """
    return f"<File id={self.id} filename={self.filename}>"

models.used_password_reset_token

UsedPasswordResetToken(*args, **kwargs)

Bases: BaseModel

Source code in models/used_password_reset_token.py
45
46
47
48
49
50
51
def __init__(self, *args: object, **kwargs: object) -> None:
    used_at = kwargs.get("used_at")
    if isinstance(used_at, datetime):
        if used_at.tzinfo is None:
            # Convert naive datetime to UTC
            kwargs["used_at"] = used_at.replace(tzinfo=UTC)
    super().__init__(*args, **kwargs)

__tablename__ = 'used_password_reset_tokens' class-attribute instance-attribute

email = mapped_column(String(255), nullable=False, index=True) class-attribute instance-attribute

nonce = mapped_column(String(64), nullable=False, index=True) class-attribute instance-attribute

used_at_default = lambda: datetime.now(UTC) class-attribute

used_at = mapped_column('used_at', DateTime(timezone=True), default=used_at_default, nullable=False) class-attribute instance-attribute

used_at_aware property

Always return used_at as a timezone-aware datetime (UTC).

validate_not_empty(key, value)

Validate that the given value is a non-empty string.

Parameters:

Name Type Description Default
key str

The name of the field being validated.

required
value str

The value to validate.

required

Returns:

Name Type Description
str str

The validated value.

Raises:

Type Description
ValueError

If the value is not a non-empty string.

Source code in models/used_password_reset_token.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@validates("email", "nonce")
def validate_not_empty(self, key: str, value: str) -> str:
    """
    Validate that the given value is a non-empty string.

    Args:
        key (str): The name of the field being validated.
        value (str): The value to validate.

    Returns:
        str: The validated value.

    Raises:
        ValueError: If the value is not a non-empty string.
    """
    if not isinstance(key, str):
        raise ValueError("key must be a string")
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"{key} must be a non-empty string")
    return value

__repr__()

Return a string representation of the UsedPasswordResetToken instance.

Returns:

Name Type Description
str str

String representation of the instance.

Source code in models/used_password_reset_token.py
74
75
76
77
78
79
80
81
def __repr__(self: "UsedPasswordResetToken") -> str:
    """
    Return a string representation of the UsedPasswordResetToken instance.

    Returns:
        str: String representation of the instance.
    """
    return f"<UsedPasswordResetToken email={self.email} nonce={self.nonce} used_at={self.used_at}>"

Schemas

schemas.auth

TOKEN_TYPE_BEARER = 'bearer' module-attribute

UserRegisterRequest

Bases: BaseModel

email instance-attribute

password = Field(..., min_length=8, max_length=128) class-attribute instance-attribute

name = Field(None, max_length=128) class-attribute instance-attribute

validate_email_field(v) classmethod

Validates the email field. Raises: ValueError: If the email format is invalid.

Source code in schemas/auth.py
28
29
30
31
32
33
34
35
36
37
38
@field_validator("email")
@classmethod
def validate_email_field(cls: type["UserRegisterRequest"], v: str) -> str:
    """
    Validates the email field.
    Raises:
        ValueError: If the email format is invalid.
    """
    if not validate_email(v):
        raise ValueError("Invalid email format.")
    return v

validate_password_field(v) classmethod

Validates the password field. Raises: ValueError: If the password does not meet requirements.

Source code in schemas/auth.py
40
41
42
43
44
45
46
47
48
49
50
51
@field_validator("password")
@classmethod
def validate_password_field(cls: type["UserRegisterRequest"], v: str) -> str:
    """
    Validates the password field.
    Raises:
        ValueError: If the password does not meet requirements.
    """
    err: str | None = get_password_validation_error(v)
    if err:
        raise ValueError(err)
    return v

UserLoginRequest

Bases: BaseModel

email instance-attribute

password instance-attribute

PasswordResetRequest

Bases: BaseModel

email instance-attribute

validate_email_field(v) classmethod

Validates the email field for password reset. Raises: ValueError: If the email format is invalid.

Source code in schemas/auth.py
67
68
69
70
71
72
73
74
75
76
77
@field_validator("email")
@classmethod
def validate_email_field(cls: type["PasswordResetRequest"], v: str) -> str:
    """
    Validates the email field for password reset.
    Raises:
        ValueError: If the email format is invalid.
    """
    if not validate_email(v):
        raise ValueError("Invalid email format.")
    return v

PasswordResetConfirmRequest

Bases: BaseModel

token instance-attribute

new_password = Field(..., min_length=8, max_length=128) class-attribute instance-attribute

validate_password_field(v) classmethod

Validates the new password field for password reset confirmation. Raises: ValueError: If the password does not meet requirements.

Source code in schemas/auth.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
@field_validator("new_password")
@classmethod
def validate_password_field(
    cls: type["PasswordResetConfirmRequest"], v: str
) -> str:
    """
    Validates the new password field for password reset confirmation.
    Raises:
        ValueError: If the password does not meet requirements.
    """
    err: str | None = get_password_validation_error(v)
    if err:
        raise ValueError(err)
    return v

AuthResponseDict

Bases: TypedDict

access_token instance-attribute

refresh_token instance-attribute

token_type instance-attribute

AuthResponse

Bases: BaseModel

access_token instance-attribute

refresh_token instance-attribute

token_type = TOKEN_TYPE_BEARER class-attribute instance-attribute

MessageResponseDict

Bases: TypedDict

message instance-attribute

MessageResponse

Bases: BaseModel

message instance-attribute

schemas.file

FileSchema

Bases: BaseModel

Schema for file metadata used in API requests and responses.

Attributes:

Name Type Description
id int

Unique identifier for the file.

filename str

Name of the file.

content_type str

MIME type of the file.

user_id int

ID of the user who owns the file.

created_at datetime

Timestamp when the file was created.

id = Field(..., description='Unique identifier for the file') class-attribute instance-attribute

filename = Field(..., max_length=255, description='Name of the file') class-attribute instance-attribute

content_type = Field(..., max_length=128, description='MIME type of the file') class-attribute instance-attribute

user_id = Field(..., description='ID of the user who owns the file') class-attribute instance-attribute

created_at = Field(..., description='Timestamp when the file was created') class-attribute instance-attribute

model_config = ConfigDict(from_attributes=True) class-attribute instance-attribute

schemas.token

Token-related schemas for authentication and authorization.

TokenResponse

Bases: BaseModel

Response schema for authentication endpoints that return tokens.

access_token = Field(..., description='JWT access token') class-attribute instance-attribute

refresh_token = Field(..., description='JWT refresh token') class-attribute instance-attribute

token_type = Field(default='bearer', description='Token type') class-attribute instance-attribute

expires_in = Field(None, description='Token expiration time in seconds') class-attribute instance-attribute

RefreshTokenRequest

Bases: BaseModel

Request schema for refresh token endpoint.

refresh_token = Field(..., description='Refresh token to exchange for new access token') class-attribute instance-attribute

TokenData

Bases: BaseModel

Token payload data schema.

user_id = Field(..., description='User ID from token') class-attribute instance-attribute

email = Field(None, description='User email from token') class-attribute instance-attribute

exp = Field(None, description='Token expiration timestamp') class-attribute instance-attribute

jti = Field(None, description='JWT ID for token tracking') class-attribute instance-attribute

schemas.user

UserResponse = UserProfile module-attribute

UserProfile

Bases: BaseModel

id instance-attribute

email instance-attribute

name = None class-attribute instance-attribute

bio = None class-attribute instance-attribute

avatar_url = None class-attribute instance-attribute

created_at = None class-attribute instance-attribute

updated_at = None class-attribute instance-attribute

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

UserProfileUpdate

Bases: BaseModel

name = Field(None, max_length=128) class-attribute instance-attribute

bio = Field(None, max_length=512) class-attribute instance-attribute

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

UserPreferences

Bases: BaseModel

theme = Field(None, description="UI theme, e.g. 'dark' or 'light'") class-attribute instance-attribute

locale = Field(None, description="User locale, e.g. 'en', 'fr'") class-attribute instance-attribute

model_config = ConfigDict(extra='forbid') class-attribute instance-attribute

UserPreferencesUpdate

Bases: BaseModel

preferences instance-attribute

UserAvatarResponse

Bases: BaseModel

avatar_url instance-attribute

UserRead

Bases: UserProfile

UserCreateRequest

Bases: BaseModel

email instance-attribute

password instance-attribute

name instance-attribute

model_config = ConfigDict(json_schema_extra={'examples': [{'email': 'user@example.com', 'password': 'strongpassword123', 'name': 'Jane Doe'}]}) class-attribute instance-attribute

UserListResponse

Bases: BaseModel

users instance-attribute

total instance-attribute


Services

services.user

User service: registration, authentication, logout, and authentication check.

InvalidDataError = InvalidDataError module-attribute

UserAlreadyExistsError = UserAlreadyExistsError module-attribute

user_service_instance = UserService() module-attribute

__all__ = ['InvalidDataError', 'RefreshTokenBlacklistedError', 'RefreshTokenError', 'RefreshTokenRateLimitError', 'UserAlreadyExistsError', 'UserNotFoundError', 'ValidationError', 'create_access_token', 'update_last_login', 'user_repo'] module-attribute

UserRole

Bases: str, Enum

ADMIN = 'admin' class-attribute instance-attribute

USER = 'user' class-attribute instance-attribute

MODERATOR = 'moderator' class-attribute instance-attribute

RegisterUserData

Bases: TypedDict

email instance-attribute

password instance-attribute

name instance-attribute

PaginatedUsersResponse

Bases: TypedDict

users instance-attribute

total instance-attribute

page instance-attribute

limit instance-attribute

RefreshTokenError

Bases: Exception

RefreshTokenRateLimitError

Bases: Exception

RefreshTokenBlacklistedError

Bases: Exception

UserService

Service class for user registration, authentication, profile, and password management. Wraps the module-level functions for better type safety and DI.

register_user(session, data) async

Source code in services/user.py
695
696
697
698
699
700
async def register_user(
    self,
    session: AsyncSession,
    data: Mapping[str, object],
) -> User:
    return await register_user(session, data)

authenticate_user(session, email, password) async

Source code in services/user.py
702
703
704
705
706
707
708
async def authenticate_user(
    self,
    session: AsyncSession,
    email: str,
    password: str,
) -> tuple[str, str]:
    return await authenticate_user(session, email, password)

logout_user(session, user_id) async

Source code in services/user.py
710
711
async def logout_user(self, session: AsyncSession, user_id: int) -> None:
    return await logout_user(session, user_id)

reset_password(session, token, new_password) async

Source code in services/user.py
713
714
715
716
717
718
719
async def reset_password(
    self,
    session: AsyncSession,
    token: str,
    new_password: str,
) -> None:
    return await reset_password(session, token, new_password)

get_password_reset_token(email)

Source code in services/user.py
721
722
def get_password_reset_token(self, email: str) -> str:
    return get_password_reset_token(email)

get_users_paginated(session, page=1, limit=20) async

Source code in services/user.py
724
725
726
727
728
729
730
async def get_users_paginated(
    self,
    session: AsyncSession,
    page: int = 1,
    limit: int = 20,
) -> PaginatedUsersResponse:
    return await get_users_paginated(session, page, limit)

get_user_profile(session, user_id) async

Source code in services/user.py
732
733
734
735
736
737
async def get_user_profile(
    self,
    session: AsyncSession,
    user_id: int,
) -> UserProfile:
    return await get_user_profile(session, user_id)

update_user(session, user_id, data) async

Source code in services/user.py
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
async def update_user(
    self,
    session: AsyncSession,
    user_id: int,
    data: Mapping[str, object],
) -> User:
    # Implement update logic or call the appropriate repository/service function
    from src.repositories.user import is_email_unique

    user: User | None = await get_user_by_id(session, user_id)
    if not user:
        raise UserNotFoundError("User not found.")
    if "email" in data:
        email_val = data["email"]
        if not isinstance(email_val, str):
            raise ValidationError("Email must be a string.")
        # Check for unique email, excluding current user
        is_unique: bool = await is_email_unique(
            session,
            email_val,
            exclude_user_id=user_id,
        )
        if not is_unique:
            raise UserAlreadyExistsError("Email already exists.")
        user.email = email_val
    if "name" in data:
        name_val = data["name"]
        if not isinstance(name_val, str):
            raise ValidationError("Name must be a string.")
        user.name = name_val
    if "password" in data:
        from src.utils.hashing import hash_password

        password_val = data["password"]
        if not isinstance(password_val, str):
            raise ValidationError("Password must be a string.")
        user.hashed_password = hash_password(password_val)
    await session.commit()
    await session.refresh(user)
    return user

delete_user(session, user_id) async

Source code in services/user.py
780
781
782
783
784
785
async def delete_user(self, session: AsyncSession, user_id: int) -> None:
    user: User | None = await get_user_by_id(session, user_id)
    if not user:
        raise UserNotFoundError("User not found.")
    await session.delete(user)
    await session.commit()

list_users(session, offset=0, limit=20, email=None, name=None, created_after=None) async

Source code in services/user.py
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
async def list_users(
    self,
    session: AsyncSession,
    offset: int = 0,
    limit: int = 20,
    email: str | None = None,
    name: str | None = None,
    created_after: datetime | None = None,
) -> Sequence[User]:
    from src.repositories.user import list_users

    users, _ = await list_users(
        session,
        offset=offset,
        limit=limit,
        email=email,
        name=name,
        created_after=created_after,
    )
    return users

get_user_by_id(session, user_id) async

Source code in services/user.py
808
809
810
811
async def get_user_by_id(self, session: AsyncSession, user_id: int) -> User | None:
    from src.repositories.user import get_user_by_id

    return await get_user_by_id(session, user_id)

register_user(session, data) async

Register a new user. Hashes the password and stores the user in the database. Raises ValidationError or UserAlreadyExistsError on error. :raises ValidationError: If email or password is missing. :raises UserAlreadyExistsError: If user already exists.

Source code in services/user.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
async def register_user(session: AsyncSession, data: Mapping[str, object]) -> User:
    """Register a new user. Hashes the password and stores the user in the database.
    Raises ValidationError or UserAlreadyExistsError on error.
    :raises ValidationError: If email or password is missing.
    :raises UserAlreadyExistsError: If user already exists.
    """
    email: str | None = cast("str | None", data.get("email"))
    password: str | None = cast("str | None", data.get("password"))
    name: str | None = cast("str | None", data.get("name"))
    if not email or not password:
        raise ValidationError("Email and password are required.")
    logger.info("User registration attempt", email=email)
    # Use repo helper for validation and creation
    user: User = await user_repo.create_user_with_validation(
        session,
        email,
        password,
        name,
    )
    logger.info("User registered successfully", user_id=user.id, email=user.email)
    return user

authenticate_user(session, email, password) async

Authenticate user credentials and return a tuple of (access_token, refresh_token). Raises ValidationError or UserNotFoundError on error. If authentication is disabled, return default tokens for dev user. :raises ValidationError: If password is incorrect. :raises UserNotFoundError: If user not found or inactive.

Source code in services/user.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
async def authenticate_user(
    session: AsyncSession,
    email: str,
    password: str,
) -> tuple[str, str]:
    """Authenticate user credentials and return a tuple of (access_token, refresh_token).
    Raises ValidationError or UserNotFoundError on error.
    If authentication is disabled, return default tokens for dev user.
    :raises ValidationError: If password is incorrect.
    :raises UserNotFoundError: If user not found or inactive.
    """
    logger.info("User login attempt", email=email)
    settings = get_settings()
    if not settings.auth_enabled:
        logger.warning(
            "Authentication is DISABLED! Returning dev token for any credentials.",
            email=email,
        )
        dev_access_token: str = create_access_token(
            {
                "sub": "dev-user",
                "user_id": "dev-user",
                "email": email,
                "role": "admin",
                "is_authenticated": True,
            },
        )
        dev_jti: str = str(uuid.uuid4())
        dev_exp: int = int((datetime.now(UTC) + timedelta(days=7)).timestamp())
        dev_refresh_token: str = create_refresh_token(
            {
                "sub": "dev-user",
                "user_id": "dev-user",
                "email": email,
                "role": "admin",
                "is_authenticated": True,
                "jti": dev_jti,
                "exp": dev_exp,
            },
        )
        logger.debug(
            f"Refresh token payload at creation: {{'sub': 'dev-user', 'user_id': 'dev-user', 'email': '{email}', 'role': 'admin', 'is_authenticated': True, 'jti': '{dev_jti}', 'exp': {dev_exp}}}",
        )
        return dev_access_token, dev_refresh_token

    # Fetch user by email
    result = await session.execute(user_repo.select(User).where(User.email == email))
    user: User | None = result.scalar_one_or_none()
    if not user or not user.is_active or user.is_deleted:
        logger.warning("Login failed: user not found or inactive", email=email)
        raise UserNotFoundError("User not found or inactive.")
    if not verify_password(password, user.hashed_password):
        logger.warning("Login failed: incorrect password", user_id=user.id, email=email)
        raise ValidationError("Incorrect password.")
    # Update last login
    await user_repo.update_last_login(session, user.id)
    logger.info("User authenticated successfully", user_id=user.id, email=user.email)
    # Create JWT tokens
    user_access_token: str = create_access_token(
        {
            "sub": str(user.id),
            "user_id": str(user.id),
            "email": user.email,
            "role": (
                user.role
                if hasattr(user, "role")
                else ("admin" if getattr(user, "is_admin", False) else "user")
            ),
        },
    )
    user_jti: str = str(uuid.uuid4())
    user_exp: int = int((datetime.now(UTC) + timedelta(days=7)).timestamp())
    user_refresh_token: str = create_refresh_token(
        {
            "sub": str(user.id),
            "user_id": str(user.id),
            "email": user.email,
            "role": (
                user.role
                if hasattr(user, "role")
                else ("admin" if getattr(user, "is_admin", False) else "user")
            ),
            "jti": user_jti,
            "exp": user_exp,
        },
    )
    logger.debug(
        f"Refresh token payload at creation: {{'sub': '{user.id}', 'user_id': '{user.id}', 'email': '{user.email}', 'role': '{user.role}', 'jti': '{user_jti}', 'exp': {user_exp}}}",
    )
    return user_access_token, user_refresh_token

logout_user(session, user_id) async

Invalidate the user's session or refresh token (stub: deactivate user for now). :raises Exception: If deactivation fails.

Source code in services/user.py
195
196
197
198
199
200
201
async def logout_user(session: AsyncSession, user_id: int) -> None:
    """Invalidate the user's session or refresh token (stub: deactivate user for now).
    :raises Exception: If deactivation fails.
    """
    logger.info("User logout attempt", user_id=user_id)
    await user_repo.deactivate_user(session, user_id)
    logger.info("User logged out (deactivated)", user_id=user_id)

is_authenticated(user)

Check if a user is currently authenticated. If authentication is disabled, always return True.

Source code in services/user.py
204
205
206
207
208
209
210
211
212
213
214
def is_authenticated(user: User) -> bool:
    """Check if a user is currently authenticated.
    If authentication is disabled, always return True.
    """
    settings = get_settings()
    if not settings.auth_enabled:
        logger.warning(
            "Authentication is DISABLED! All users considered authenticated.",
        )
        return True
    return user.is_active and not user.is_deleted

refresh_access_token(user_id, refresh_token)

Validate the refresh token and issue a new access token. Stub: No persistent token storage yet. :raises ValidationError: If token is invalid or subject mismatch.

Source code in services/user.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def refresh_access_token(user_id: int | str, refresh_token: str) -> str:
    """Validate the refresh token and issue a new access token.
    Stub: No persistent token storage yet.
    :raises ValidationError: If token is invalid or subject mismatch.
    """
    try:
        payload: Mapping[str, object] = verify_refresh_token(refresh_token)
        # Enforce type consistency: always compare as strings
        if str(payload.get("sub")) != str(user_id):
            raise ValidationError("Refresh token subject mismatch.")
        email_val = payload.get("email")
        # Only allow str, int, bool for JWT payloads
        if not (isinstance(email_val, str | int | bool) or email_val is None):
            raise ValidationError("Invalid email type in refresh token payload.")
        # Optionally check audience, issuer, exp, etc.
        # Issue new access token
        return create_access_token(
            {
                "sub": str(user_id),
                "user_id": str(user_id),
                "email": email_val if email_val is not None else "",
            },
        )
    except Exception as e:
        raise ValidationError(f"Invalid refresh token: {e}") from e

revoke_refresh_token(user_id, token)

Blacklist or invalidate the refresh token (stub).

Source code in services/user.py
244
245
def revoke_refresh_token(user_id: int, token: str) -> None:
    """Blacklist or invalidate the refresh token (stub)."""

verify_email_token(token)

Decode and verify an email confirmation token. :raises ValidationError: If token is invalid.

Source code in services/user.py
249
250
251
252
253
254
255
256
257
258
def verify_email_token(token: str) -> Mapping[str, object]:
    """Decode and verify an email confirmation token.
    :raises ValidationError: If token is invalid.
    """
    try:
        payload: Mapping[str, object] = verify_access_token(token)
        # Optionally check for specific claims (purpose, exp, etc.)
        return payload
    except Exception as e:
        raise ValidationError(f"Invalid email verification token: {e}") from e

get_password_reset_token(email)

Generate a secure token and simulate sending a reset link via logging.

Source code in services/user.py
261
262
263
264
265
266
267
268
269
270
271
272
def get_password_reset_token(email: str) -> str:
    """Generate a secure token and simulate sending a reset link via logging."""
    token: str = create_access_token(
        {"sub": email, "purpose": "reset", "nonce": secrets.token_urlsafe(8)},
    )
    # Use correct environment check (dev/test/prod)
    settings = get_settings()
    if settings.environment in ("dev", "test"):
        logger.debug("Password reset token for development", email=email, token=token)
    else:
        logger.info("Password reset link sent to user", email=email)
    return token

reset_password(session, token, new_password) async

Validate the reset token and update the user's password. Enforces one-time-use tokens. :raises ValidationError: If token is invalid or password is invalid. :raises UserNotFoundError: If user not found.

Source code in services/user.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
async def reset_password(session: AsyncSession, token: str, new_password: str) -> None:
    """Validate the reset token and update the user's password. Enforces one-time-use tokens.
    :raises ValidationError: If token is invalid or password is invalid.
    :raises UserNotFoundError: If user not found.
    """
    try:
        payload: Mapping[str, object] = verify_access_token(token)
        if payload.get("purpose") != "reset":
            logger.warning("Password reset failed: invalid token purpose")
            raise ValidationError("Invalid reset token purpose.")
        email: str | None = cast("str | None", payload.get("sub"))
        nonce: str | None = cast("str | None", payload.get("nonce"))
        if not email:
            logger.warning("Password reset failed: missing subject in token")
            raise ValidationError("Invalid reset token: missing subject.")
        if not nonce:
            logger.warning("Password reset failed: missing nonce in token", email=email)
            raise ValidationError("Invalid reset token: missing nonce.")
        # Check if this nonce has already been used for this email
        used = await session.execute(
            select(UsedPasswordResetToken).where(
                UsedPasswordResetToken.email == email,
                UsedPasswordResetToken.nonce == nonce,
            ),
        )
        if used.scalar_one_or_none():
            logger.warning("Password reset failed: token already used", email=email)
            raise ValidationError("This password reset link has already been used.")
        err: str | None = get_password_validation_error(new_password)
        if err:
            logger.warning(
                "Password reset failed: validation requirements not met",
                email=email,
            )
            raise ValidationError(err)
        result = await session.execute(
            user_repo.select(User).where(User.email == email),
        )
        user: User | None = result.scalar_one_or_none()
        if not user or not user.is_active or user.is_deleted:
            logger.warning(
                "Password reset failed: user not found or inactive",
                email=email,
            )
            raise UserNotFoundError("User not found.")
        hashed: str = hash_password(new_password)
        await change_user_password(session, user.id, hashed)
        # Mark this nonce as used
        from datetime import datetime

        session.add(
            UsedPasswordResetToken(
                email=email,
                nonce=nonce,
                used_at=datetime.now().replace(tzinfo=None),
            ),
        )
        await session.commit()
        logger.info("Password reset successful", user_id=user.id, email=email)
    except UserNotFoundError:
        raise
    except Exception as e:
        logger.error("Password reset failed: {}", str(e))
        raise ValidationError(f"Invalid or expired reset token: {e}") from e

change_password(session, user_id, old_pw, new_pw) async

Check old password and update if correct. :raises UserNotFoundError: If user not found. :raises ValidationError: If password is invalid.

Source code in services/user.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
async def change_password(
    session: AsyncSession,
    user_id: int,
    old_pw: str,
    new_pw: str,
) -> None:
    """Check old password and update if correct.
    :raises UserNotFoundError: If user not found.
    :raises ValidationError: If password is invalid.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if not user or not user.is_active or user.is_deleted:
        logger.warning(
            "Password change failed: user not found or inactive",
            user_id=user_id,
        )
        raise UserNotFoundError("User not found.")
    if not verify_password(old_pw, user.hashed_password):
        logger.warning(
            "Password change failed: incorrect old password",
            user_id=user_id,
        )
        raise ValidationError("Old password is incorrect.")
    if old_pw == new_pw or verify_password(new_pw, user.hashed_password):
        logger.warning(
            "Password change failed: new password same as old",
            user_id=user_id,
        )
        raise ValidationError("New password must be different from the old password.")
    err: str | None = get_password_validation_error(new_pw)
    if err:
        logger.warning(
            "Password change failed: validation requirements not met",
            user_id=user_id,
        )
        raise ValidationError(err)
    hashed: str = hash_password(new_pw)
    await change_user_password(session, user_id, hashed)
    logger.info("Password changed for user", user_id=user_id)

validate_password_strength(password)

Ensure the password is strong (length, characters, etc). Raise if not. Rejects passwords with whitespace or non-ASCII characters. :raises ValidationError: If password is weak or contains invalid characters.

Source code in services/user.py
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def validate_password_strength(password: str) -> None:
    """Ensure the password is strong (length, characters, etc). Raise if not.
    Rejects passwords with whitespace or non-ASCII characters.
    :raises ValidationError: If password is weak or contains invalid characters.
    """
    if any(c.isspace() for c in password):
        raise ValidationError("Password must not contain whitespace characters.")
    try:
        password.encode("ascii")
    except UnicodeEncodeError as e:
        raise ValidationError("Password must only contain ASCII characters.") from e
    err: str | None = get_password_validation_error(password)
    if err:
        raise ValidationError(err)

get_user_profile(session, user_id) async

Source code in services/user.py
398
399
400
401
402
403
404
405
406
407
408
409
410
async def get_user_profile(session: AsyncSession, user_id: int) -> UserProfile:
    user: User | None = await get_user_by_id(session, user_id)
    if not user or user.is_deleted:
        raise UserNotFoundError("User not found.")
    return UserProfile(
        id=user.id,
        email=user.email,
        name=user.name,
        bio=user.bio,
        avatar_url=user.avatar_url,
        created_at=user.created_at.isoformat() if user.created_at else None,
        updated_at=user.updated_at.isoformat() if user.updated_at else None,
    )

update_user_profile(session, user_id, data) async

Source code in services/user.py
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
async def update_user_profile(
    session: AsyncSession,
    user_id: int,
    data: Mapping[str, object],
) -> UserProfile:
    # Only pass fields that are valid for UserProfileUpdate and cast to correct types
    valid_fields: dict[str, object] = {}
    for k, v in data.items():
        if k in UserProfileUpdate.model_fields:
            if k in ("name", "bio"):
                # These fields expect str | None
                valid_fields[k] = v if isinstance(v, str) else None
            else:
                valid_fields[k] = v
    # Now, explicitly type the arguments for UserProfileUpdate
    name_val = valid_fields.get("name")
    bio_val = valid_fields.get("bio")
    name: str | None = name_val if isinstance(name_val, str) else None
    bio: str | None = bio_val if isinstance(bio_val, str) else None
    # Remove from valid_fields to avoid duplicate keys
    valid_fields.pop("name", None)
    valid_fields.pop("bio", None)
    update_data: dict[str, object] = UserProfileUpdate(
        name=name,
        bio=bio,
        **valid_fields,
    ).model_dump(exclude_unset=True)
    user: User | None = await partial_update_user(session, user_id, update_data)
    if not user:
        raise UserNotFoundError("User not found.")
    return await get_user_profile(session, user_id)

set_user_preferences(session, user_id, preferences) async

Source code in services/user.py
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
async def set_user_preferences(
    session: AsyncSession,
    user_id: int,
    preferences: Mapping[str, object],
) -> UserPreferences:
    user: User | None = await get_user_by_id(session, user_id)
    if not user or user.is_deleted:
        raise UserNotFoundError("User not found.")
    user.preferences = dict(preferences)
    await session.commit()
    # Defensive: Only pass known fields to UserPreferences
    theme_val = preferences.get("theme")
    locale_val = preferences.get("locale")
    theme: Literal["dark", "light"] | None = None
    if theme_val in ("dark", "light"):
        theme = theme_val  # type: ignore[assignment]
    locale: str | None = locale_val if isinstance(locale_val, str) else None
    return UserPreferences(theme=theme, locale=locale)

upload_avatar(session, user_id, file) async

Source code in services/user.py
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
async def upload_avatar(
    session: AsyncSession,
    user_id: int,
    file: UploadFile,
) -> UserAvatarResponse:
    user: User | None = await get_user_by_id(session, user_id)
    if not user or user.is_deleted:
        raise UserNotFoundError("User not found.")
    upload_dir: str = os.path.join("uploads", "avatars")
    os.makedirs(upload_dir, exist_ok=True)
    filename: str = f"{user_id}_{file.filename}"
    file_path: str = os.path.join(upload_dir, filename)
    content: bytes = await file.read()
    with open(file_path, "wb") as f:
        f.write(content)
    user.avatar_url = f"/uploads/avatars/{filename}"
    await session.commit()
    return UserAvatarResponse(avatar_url=user.avatar_url or "")

delete_user_account(session, user_id, *, anonymize=False) async

Delete or anonymize a user account depending on policy. If anonymize=True, irreversibly remove PII and disable account (GDPR). Otherwise, perform a soft delete (set is_deleted flag). Always logs the action for audit purposes. Returns True if operation succeeded, False otherwise.

Source code in services/user.py
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
async def delete_user_account(
    session: AsyncSession,
    user_id: int,
    *,
    anonymize: bool = False,
) -> bool:
    """Delete or anonymize a user account depending on policy.
    If anonymize=True, irreversibly remove PII and disable account (GDPR).
    Otherwise, perform a soft delete (set is_deleted flag).
    Always logs the action for audit purposes.
    Returns True if operation succeeded, False otherwise.
    """
    result: bool
    action: str
    details: str
    if anonymize:
        result = await user_repo.anonymize_user(session, user_id)
        action = "anonymize"
        details = "User data anonymized (GDPR/CCPA)."
    else:
        result = await user_repo.soft_delete_user(session, user_id)
        action = "soft_delete"
        details = "User soft-deleted (is_deleted=True)."
    await user_repo.audit_log_user_change(session, user_id, action, details)
    return result

deactivate_user(session, user_id) async

Mark the user as inactive (is_active=False). Logs the change for audit. Returns True if operation succeeded, False otherwise.

Source code in services/user.py
513
514
515
516
517
518
519
520
521
522
523
524
async def deactivate_user(session: AsyncSession, user_id: int) -> bool:
    """Mark the user as inactive (is_active=False). Logs the change for audit.
    Returns True if operation succeeded, False otherwise.
    """
    result: bool = await user_repo.deactivate_user(session, user_id)
    await user_repo.audit_log_user_change(
        session,
        user_id,
        "deactivate",
        "User deactivated (is_active=False).",
    )
    return result

reactivate_user(session, user_id) async

Reactivate a previously deactivated user (is_active=True). Logs the change for audit. Returns True if operation succeeded, False otherwise.

Source code in services/user.py
527
528
529
530
531
532
533
534
535
536
537
538
async def reactivate_user(session: AsyncSession, user_id: int) -> bool:
    """Reactivate a previously deactivated user (is_active=True). Logs the change for audit.
    Returns True if operation succeeded, False otherwise.
    """
    result: bool = await user_repo.reactivate_user(session, user_id)
    await user_repo.audit_log_user_change(
        session,
        user_id,
        "reactivate",
        "User reactivated (is_active=True).",
    )
    return result

get_user_by_username(session, username) async

Look up a user by username (email). Returns user if found, else None. Validates input. :raises ValidationError: If username is missing or invalid.

Source code in services/user.py
541
542
543
544
545
546
547
548
549
550
551
async def get_user_by_username(session: AsyncSession, username: str) -> User | None:
    """Look up a user by username (email). Returns user if found, else None. Validates input.
    :raises ValidationError: If username is missing or invalid.
    """
    if not username:
        raise ValidationError("Username (email) is required.")
    if not validate_email(username):
        raise ValidationError("Invalid email format.")
    result = await session.execute(user_repo.select(User).where(User.email == username))
    user: User | None = result.scalar_one_or_none()
    return user

get_users_paginated(session, page=1, limit=20, email=None, name=None) async

Return paginated users and total count. Validates input and returns structured response. :raises ValidationError: If pagination parameters are invalid.

Source code in services/user.py
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
async def get_users_paginated(
    session: AsyncSession,
    page: int = 1,
    limit: int = 20,
    email: str | None = None,
    name: str | None = None,
) -> PaginatedUsersResponse:
    """Return paginated users and total count. Validates input and returns structured response.
    :raises ValidationError: If pagination parameters are invalid.
    """
    if page < 1 or limit < 1 or limit > 100:
        raise ValidationError("Invalid pagination parameters.")
    offset: int = (page - 1) * limit
    users_list, total_count = await user_repo.list_users(
        session,
        offset=offset,
        limit=limit,
        email=email,
        name=name,
    )
    users: Sequence[User] = users_list
    total: int = total_count
    return {"users": users, "total": total, "page": page, "limit": limit}

user_exists(session, email) async

Return whether the email is already registered. Validates input. :raises ValidationError: If email is missing or invalid.

Source code in services/user.py
586
587
588
589
590
591
592
593
594
595
596
async def user_exists(session: AsyncSession, email: str) -> bool:
    """Return whether the email is already registered. Validates input.
    :raises ValidationError: If email is missing or invalid.
    """
    if not email:
        raise ValidationError("Email is required.")
    if not validate_email(email):
        raise ValidationError("Invalid email format.")
    # is_email_unique returns True if not found, so invert
    exists: bool = not await user_repo.is_email_unique(session, email)
    return exists

assign_role(user_id, role) async

Assign a role to a user. Allowed roles: admin, user, moderator. This is a stub; in production, store in DB. :raises ValidationError: If role is invalid.

Source code in services/user.py
599
600
601
602
603
604
605
606
607
608
async def assign_role(user_id: int, role: str) -> bool:
    """Assign a role to a user. Allowed roles: admin, user, moderator.
    This is a stub; in production, store in DB.
    :raises ValidationError: If role is invalid.
    """
    if role not in (r.value for r in UserRole):
        raise ValidationError(f"Invalid role: {role}")
    roles: set[str] = _mock_user_roles.setdefault(user_id, set())
    roles.add(role)
    return True

check_user_role(user_id, required_role) async

Check if user has the required role. Stub: checks in-memory store.

Source code in services/user.py
611
612
613
614
async def check_user_role(user_id: int, required_role: str) -> bool:
    """Check if user has the required role. Stub: checks in-memory store."""
    roles: set[str] = _mock_user_roles.get(user_id, set())
    return required_role in roles

async_refresh_access_token(session, token, jwt_secret, jwt_algorithm, max_attempts=15, window_seconds=3600) async

Validate the refresh token, apply rate limiting, check blacklist, and issue a new access token. Maximized robustness: strict type checks, user existence check, clear error messages, and no debug logs in production. Raises custom exceptions for all error/edge branches. :raises RefreshTokenError: On JWT decode or user not found. :raises RefreshTokenRateLimitError: On rate limit exceeded. :raises RefreshTokenBlacklistedError: If token is blacklisted.

Source code in services/user.py
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
async def async_refresh_access_token(
    session: AsyncSession,
    token: str,
    jwt_secret: str,
    jwt_algorithm: str,
    max_attempts: int = 15,
    window_seconds: int = 3600,
) -> str:
    """Validate the refresh token, apply rate limiting, check blacklist, and issue a new access token.
    Maximized robustness: strict type checks, user existence check, clear error messages, and no debug logs in production.
    Raises custom exceptions for all error/edge branches.
    :raises RefreshTokenError: On JWT decode or user not found.
    :raises RefreshTokenRateLimitError: On rate limit exceeded.
    :raises RefreshTokenBlacklistedError: If token is blacklisted.
    """
    try:
        payload: Mapping[str, object] = jwt.decode(
            token,
            jwt_secret,
            algorithms=[jwt_algorithm],
        )
        user_id_val = payload.get("user_id")
        if not isinstance(user_id_val, int | str):
            raise RefreshTokenError("Invalid token format: missing user_id.")
        user_id: int = int(user_id_val)
        # Ensure user exists and is active
        user: User | None = await get_user_by_id(session, user_id)
        if not user or not user.is_active or user.is_deleted:
            raise RefreshTokenError("User not found or inactive.")
        # Rate limiting
        limiter_key: str = f"refresh:{user_id}"
        if callable(user_action_limiter):
            is_allowed: bool = await user_action_limiter(
                limiter_key,
                max_attempts=max_attempts,
                window_seconds=window_seconds,
            )
            if not is_allowed:
                raise RefreshTokenRateLimitError("Too many token refresh attempts.")
        # Blacklist check
        jti_val = payload.get("jti")
        jti: str = str(jti_val) if isinstance(jti_val, str | int) else token
        if await is_token_blacklisted(session, jti):
            raise RefreshTokenBlacklistedError("Refresh token is blacklisted.")
        # Issue new access token
        new_token: str = refresh_access_token(user_id, token)
        return new_token
    except JWTError as e:
        raise RefreshTokenError(f"JWT decode failed: {e}") from e
    except Exception as e:
        # Only raise as RefreshTokenError if not a known custom error
        if isinstance(
            e,
            RefreshTokenRateLimitError
            | RefreshTokenBlacklistedError
            | RefreshTokenError,
        ):
            raise
        raise RefreshTokenError(f"Unexpected error: {e}") from e

get_user_service()

Source code in services/user.py
818
819
def get_user_service() -> UserService:
    return user_service_instance

services.upload

Upload service for handling file uploads and management.

UploadService()

Service for handling file uploads and management.

Source code in services/upload.py
18
19
20
21
def __init__(self) -> None:
    self.settings = get_settings()
    self.upload_dir = Path(self.settings.upload_dir)
    self.upload_dir.mkdir(parents=True, exist_ok=True)

settings = get_settings() instance-attribute

upload_dir = Path(self.settings.upload_dir) instance-attribute

upload_file(session, file, user_id) async

Upload a file and save it to storage.

Source code in services/upload.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
async def upload_file(
    self, session: AsyncSession, file: UploadFile, user_id: uuid.UUID
) -> File:
    """Upload a file and save it to storage."""
    if not file.filename:
        raise HTTPException(status_code=400, detail="No filename provided")

    # Sanitize filename
    safe_filename = sanitize_filename(file.filename)
    if not is_safe_filename(safe_filename):
        raise HTTPException(status_code=400, detail="Invalid filename")

    # Check file size
    max_size = 10 * 1024 * 1024  # 10MB
    content = await file.read()
    if len(content) > max_size:
        raise HTTPException(status_code=413, detail="File too large")

    # Generate unique filename
    file_id = str(uuid.uuid4())
    file_extension = Path(safe_filename).suffix
    stored_filename = f"{file_id}{file_extension}"

    # Save file to disk
    file_path = self.upload_dir / stored_filename
    try:
        with open(file_path, "wb") as f:
            f.write(content)
    except Exception as e:
        raise HTTPException(
            status_code=500, detail=f"Failed to save file: {str(e)}"
        ) from e

    # Save file metadata to database
    file_record = await create_file(
        session=session,
        filename=stored_filename,
        content_type=file.content_type or "application/octet-stream",
        user_id=int(user_id),
    )

    return file_record

get_file(session, filename) async

Get file metadata by filename.

Source code in services/upload.py
66
67
68
async def get_file(self, session: AsyncSession, filename: str) -> File | None:
    """Get file metadata by filename."""
    return await get_file_by_filename(session, filename)

delete_file(session, filename, user_id) async

Delete a file from storage and database.

Source code in services/upload.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
async def delete_file(
    self, session: AsyncSession, filename: str, user_id: uuid.UUID
) -> bool:
    """Delete a file from storage and database."""
    file_record = await get_file_by_filename(session, filename)
    if not file_record:
        return False

    # Check ownership
    if file_record.user_id != user_id:
        raise HTTPException(
            status_code=403, detail="Not authorized to delete this file"
        )

    # Delete from disk
    file_path = self.upload_dir / filename
    try:
        if file_path.exists():
            file_path.unlink()
    except Exception:
        pass  # Continue even if file deletion fails

    # Delete from database
    return await delete_file(session, filename)

get_file_path(filename)

Get the full path to a file.

Source code in services/upload.py
95
96
97
def get_file_path(self, filename: str) -> Path:
    """Get the full path to a file."""
    return self.upload_dir / filename

Repositories

repositories.user

user_action_limiter = AsyncRateLimiter(max_calls=5, period=60.0) module-attribute

logger = logging.getLogger('user_audit') module-attribute

__all__ = ['safe_get_user_by_id', 'create_user_with_validation', 'sensitive_user_action', 'anonymize_user', 'user_signups_per_month', 'UserNotFoundError', 'select'] module-attribute

UserUpdateData

Bases: TypedDict

email instance-attribute

name instance-attribute

is_active instance-attribute

is_deleted instance-attribute

hashed_password instance-attribute

last_login_at instance-attribute

updated_at instance-attribute

UserCSVRow

Bases: TypedDict

id instance-attribute

email instance-attribute

is_active instance-attribute

is_deleted instance-attribute

created_at instance-attribute

updated_at instance-attribute

last_login_at instance-attribute

UserJSONRow

Bases: TypedDict

id instance-attribute

email instance-attribute

is_active instance-attribute

is_deleted instance-attribute

created_at instance-attribute

updated_at instance-attribute

last_login_at instance-attribute

get_user_by_id(session, user_id, use_cache=True) async

Fetch a user by their ID, optionally using async cache (cache only user id, not ORM instance). Raises: None

Source code in repositories/user.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
async def get_user_by_id(
    session: AsyncSession, user_id: int, use_cache: bool = True
) -> User | None:
    """Fetch a user by their ID, optionally using async cache (cache only user id, not ORM instance).
    Raises:
        None
    """
    cache_key: Final[str] = f"user_id:{user_id}"
    if use_cache:
        cached_id_obj = await user_cache.get(cache_key)
        cached_id: int | None = (
            cached_id_obj if isinstance(cached_id_obj, int) else None
        )
        if cached_id is not None:
            result = await session.execute(select(User).where(User.id == cached_id))
            return result.scalar_one_or_none()
    result = await session.execute(select(User).where(User.id == user_id))
    user = result.scalar_one_or_none()
    if user is not None and use_cache:
        # user.id is always int, but mypy may not know user is User here
        await user_cache.set(cache_key, user.id, ttl=60)
    return user

create_user_with_validation(session, email, password, name=None) async

Create a user with validation and error handling. Raises: ValidationError: If email or password is invalid. UserAlreadyExistsError: If email already exists. Exception: On DB commit failure.

Source code in repositories/user.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
async def create_user_with_validation(
    session: AsyncSession, email: str, password: str, name: str | None = None
) -> User:
    """Create a user with validation and error handling.
    Raises:
        ValidationError: If email or password is invalid.
        UserAlreadyExistsError: If email already exists.
        Exception: On DB commit failure.
    """
    import traceback

    logging.debug(f"create_user_with_validation called with email={email}, name={name}")
    if not validate_email(email):
        logging.warning(f"Invalid email format: {email}")
        raise ValidationError("Invalid email format.")
    err: str | None = get_password_validation_error(password)
    if err is not None:
        logging.warning(f"Password validation failed for {email}")
        raise ValidationError(err)
    is_unique: bool = await is_email_unique(session, email)
    if not is_unique:
        logging.warning(f"Email already exists: {email}")
        raise UserAlreadyExistsError("Email already exists.")
    from src.utils.hashing import hash_password

    hashed: str = hash_password(password)
    user = User(email=email, hashed_password=hashed, is_active=True)
    if name is not None:
        user.name = name
    session.add(user)
    try:
        await session.commit()
        await session.refresh(user)
        logging.info(f"User created successfully: {user.email}, id={user.id}")
    except Exception as e:
        tb: str = traceback.format_exc()
        logging.error(
            f"Exception during user creation for {email}: {e}\nTraceback: {tb}"
        )
        await session.rollback()
        raise
    return user

sensitive_user_action(session, user_id, action) async

Example of a rate-limited sensitive action. Raises: UserNotFoundError: If user not found. RateLimitExceededError: If rate limit exceeded.

Source code in repositories/user.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
async def sensitive_user_action(
    session: AsyncSession, user_id: int, action: str
) -> None:
    """Example of a rate-limited sensitive action.
    Raises:
        UserNotFoundError: If user not found.
        RateLimitExceededError: If rate limit exceeded.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None:
        raise UserNotFoundError(f"User with id {user_id} not found.")
    limiter_key: Final[str] = f"user:{user_id}:{action}"
    allowed: bool = await user_action_limiter.is_allowed(limiter_key)
    if not allowed:
        raise RateLimitExceededError(
            f"Too many {action} attempts. Please try again later."
        )

safe_get_user_by_id(session, user_id) async

Get user by ID or raise UserNotFoundError. Raises: UserNotFoundError: If user not found.

Source code in repositories/user.py
133
134
135
136
137
138
139
140
141
async def safe_get_user_by_id(session: AsyncSession, user_id: int) -> User:
    """Get user by ID or raise UserNotFoundError.
    Raises:
        UserNotFoundError: If user not found.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None:
        raise UserNotFoundError(f"User with id {user_id} not found.")
    return user

get_users_by_ids(session, user_ids) async

Fetch multiple users by a list of IDs.

Source code in repositories/user.py
144
145
146
147
148
149
150
151
152
async def get_users_by_ids(
    session: AsyncSession, user_ids: Sequence[int]
) -> Sequence[User]:
    """Fetch multiple users by a list of IDs."""
    if not user_ids:
        return []
    result = await session.execute(select(User).where(User.id.in_(user_ids)))
    users: Sequence[User] = result.scalars().all()
    return users

list_users_paginated(session, offset=0, limit=20) async

List users with pagination support.

Source code in repositories/user.py
155
156
157
158
159
160
161
162
163
async def list_users_paginated(
    session: AsyncSession, offset: int = 0, limit: int = 20
) -> Sequence[User]:
    """List users with pagination support."""
    if limit is None or limit <= 0:
        return []
    result = await session.execute(select(User).offset(offset).limit(limit))
    users: Sequence[User] = result.scalars().all()
    return users

list_users(session, offset=0, limit=20, email=None, name=None, q=None, sort='created_at', order='desc', created_after=None, created_before=None) async

List users with filtering and pagination. Returns: (users, total_count)

Source code in repositories/user.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
async def list_users(
    session: AsyncSession,
    offset: int = 0,
    limit: int = 20,
    email: str | None = None,
    name: str | None = None,
    q: str | None = None,
    sort: Literal["created_at", "name", "email"] = "created_at",
    order: Literal["desc", "asc"] = "desc",
    created_after: datetime | None = None,
    created_before: datetime | None = None,
) -> tuple[list[User], int]:
    """List users with filtering and pagination.
    Returns:
        (users, total_count)
    """
    stmt = select(User)
    if email:
        stmt = stmt.where(User.email.ilike(f"%{email}%"))
    if name:
        stmt = stmt.where(User.name.ilike(f"%{name}%"))
    if q:
        stmt = stmt.where(or_(User.email.ilike(f"%{q}%"), User.name.ilike(f"%{q}%")))
    if created_after:
        stmt = stmt.where(User.created_at >= created_after)
    if created_before:
        stmt = stmt.where(User.created_at <= created_before)
    if sort in {"created_at", "name", "email"}:
        col = getattr(User, sort)
        # mypy: col is InstrumentedAttribute[Any, Any], so .desc()/.asc() is fine
        if order == "desc":
            col = col.desc()
        else:
            col = col.asc()
        stmt = stmt.order_by(col)
    count_stmt = select(func.count()).select_from(stmt.subquery())
    total = (await session.execute(count_stmt)).scalar_one()
    stmt = stmt.offset(offset).limit(limit)
    result = await session.execute(stmt)
    users = list(result.scalars().all())
    return users, total

search_users_by_name_or_email(session, query, offset=0, limit=20) async

Search users by partial match on email (and name if available).

Source code in repositories/user.py
209
210
211
212
213
214
215
216
217
218
async def search_users_by_name_or_email(
    session: AsyncSession, query: str, offset: int = 0, limit: int = 20
) -> Sequence[User]:
    """Search users by partial match on email (and name if available)."""
    stmt = (
        select(User).where(User.email.ilike(f"%{query}%")).offset(offset).limit(limit)
    )
    result = await session.execute(stmt)
    users: Sequence[User] = result.scalars().all()
    return users

filter_users_by_status(session, is_active) async

Fetch users filtered by their active status.

Source code in repositories/user.py
221
222
223
224
225
226
227
async def filter_users_by_status(
    session: AsyncSession, is_active: bool
) -> Sequence[User]:
    """Fetch users filtered by their active status."""
    result = await session.execute(select(User).where(User.is_active == is_active))
    users: Sequence[User] = result.scalars().all()
    return users

filter_users_by_role(session, role) async

Stub: Fetch users filtered by role. Not implemented (no role field).

Source code in repositories/user.py
230
231
232
233
async def filter_users_by_role(session: AsyncSession, role: str) -> Sequence[User]:
    """Stub: Fetch users filtered by role. Not implemented (no role field)."""
    # Role field not present in User model
    return []

get_users_created_within(session, start, end) async

Fetch users created within a date range (inclusive).

Source code in repositories/user.py
236
237
238
239
240
241
242
243
244
async def get_users_created_within(
    session: AsyncSession, start: datetime, end: datetime
) -> Sequence[User]:
    """Fetch users created within a date range (inclusive)."""
    result = await session.execute(
        select(User).where(User.created_at >= start, User.created_at <= end)
    )
    users: Sequence[User] = result.scalars().all()
    return users

count_users(session, is_active=None) async

Count users, optionally filtered by active status.

Source code in repositories/user.py
247
248
249
250
251
252
253
254
async def count_users(session: AsyncSession, is_active: bool | None = None) -> int:
    """Count users, optionally filtered by active status."""
    stmt = select(func.count()).select_from(User)
    if is_active is not None:
        stmt = stmt.where(User.is_active == is_active)
    result = await session.execute(stmt)
    count: int = result.scalar_one()
    return count

get_active_users(session) async

Fetch all active users.

Source code in repositories/user.py
257
258
259
async def get_active_users(session: AsyncSession) -> Sequence[User]:
    """Fetch all active users."""
    return await filter_users_by_status(session, True)

get_inactive_users(session) async

Fetch all inactive users.

Source code in repositories/user.py
262
263
264
async def get_inactive_users(session: AsyncSession) -> Sequence[User]:
    """Fetch all inactive users."""
    return await filter_users_by_status(session, False)

get_users_by_custom_field(session, field, value) async

Stub: Fetch users by a custom field (e.g., organization). Not implemented (no such field).

Source code in repositories/user.py
267
268
269
270
271
272
async def get_users_by_custom_field(
    session: AsyncSession, field: str, value: object
) -> Sequence[User]:
    """Stub: Fetch users by a custom field (e.g., organization). Not implemented (no such field)."""
    # No custom field in User model
    return []

bulk_create_users(session, users) async

Bulk create users and return them with IDs. Raises: Exception: On DB commit failure.

Source code in repositories/user.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
async def bulk_create_users(
    session: AsyncSession, users: Sequence[User]
) -> Sequence[User]:
    """Bulk create users and return them with IDs.
    Raises:
        Exception: On DB commit failure.
    """
    session.add_all(users)
    try:
        await session.commit()
        for user in users:
            await session.refresh(user)
    except Exception as exc:
        await session.rollback()
        raise exc
    return users

bulk_update_users(session, user_ids, update_data) async

Bulk update users by IDs with the given update_data dict. Returns number of updated rows. Raises: Exception: On DB commit failure.

Source code in repositories/user.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
async def bulk_update_users(
    session: AsyncSession, user_ids: Sequence[int], update_data: Mapping[str, object]
) -> int:
    """Bulk update users by IDs with the given update_data dict. Returns number of updated rows.
    Raises:
        Exception: On DB commit failure.
    """
    if not user_ids or not update_data:
        return 0
    result = await session.execute(select(User).where(User.id.in_(user_ids)))
    users: Sequence[User] = result.scalars().all()
    for user in users:
        for key, value in update_data.items():
            setattr(user, key, value)
    try:
        await session.commit()
    except Exception as exc:
        await session.rollback()
        raise exc
    return len(users)

bulk_delete_users(session, user_ids) async

Bulk delete users by IDs. Returns number of deleted rows. Raises: Exception: On DB commit failure.

Source code in repositories/user.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
async def bulk_delete_users(session: AsyncSession, user_ids: Sequence[int]) -> int:
    """Bulk delete users by IDs. Returns number of deleted rows.
    Raises:
        Exception: On DB commit failure.
    """
    if not user_ids:
        return 0
    result = await session.execute(select(User).where(User.id.in_(user_ids)))
    users: Sequence[User] = result.scalars().all()
    for user in users:
        await session.delete(user)
    try:
        await session.commit()
    except Exception as exc:
        await session.rollback()
        raise exc
    return len(users)

soft_delete_user(session, user_id) async

Mark a user as deleted (soft delete). Raises: Exception: On DB commit failure.

Source code in repositories/user.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
async def soft_delete_user(session: AsyncSession, user_id: int) -> bool:
    """Mark a user as deleted (soft delete).
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None or getattr(user, "is_deleted", False):
        return False
    user.is_deleted = True
    try:
        await session.commit()
    except Exception as exc:
        logging.error(f"Failed to commit soft delete for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return True

restore_user(session, user_id) async

Restore a soft-deleted user. Raises: Exception: On DB commit failure.

Source code in repositories/user.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
async def restore_user(session: AsyncSession, user_id: int) -> bool:
    """Restore a soft-deleted user.
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None or not getattr(user, "is_deleted", False):
        return False
    user.is_deleted = False
    try:
        await session.commit()
    except Exception as exc:
        logging.error(f"Failed to commit restore for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return True

upsert_user(session, email, defaults) async

Insert or update a user by email. Returns the user. Raises: ValidationError: If email is invalid. Exception: On DB commit failure.

Source code in repositories/user.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
async def upsert_user(
    session: AsyncSession, email: str, defaults: Mapping[str, object]
) -> User:
    """Insert or update a user by email. Returns the user.
    Raises:
        ValidationError: If email is invalid.
        Exception: On DB commit failure.
    """
    if not email or not validate_email(email):
        raise ValidationError("Invalid email format.")
    result = await session.execute(select(User).where(User.email == email))
    user = result.scalar_one_or_none()
    if user is not None:
        for key, value in defaults.items():
            if hasattr(user, key):
                setattr(user, key, value)
    else:
        user = User(email=email, **defaults)
        session.add(user)
    try:
        await session.commit()
        await session.refresh(user)
    except Exception as exc:
        logging.error(f"Failed to commit upsert for user {email}: {exc}")
        await session.rollback()
        raise exc
    return user

partial_update_user(session, user_id, update_data) async

Update only provided fields for a user. Raises: Exception: On DB commit failure.

Source code in repositories/user.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
async def partial_update_user(
    session: AsyncSession, user_id: int, update_data: Mapping[str, object]
) -> User | None:
    """Update only provided fields for a user.
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None:
        return None
    for key, value in update_data.items():
        if hasattr(user, key):
            setattr(user, key, value)
    try:
        await session.commit()
        await session.refresh(user)
    except Exception as exc:
        logging.error(f"Failed to commit partial update for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return user

user_exists(session, user_id) async

Check if a user exists by ID.

Source code in repositories/user.py
433
434
435
436
437
async def user_exists(session: AsyncSession, user_id: int) -> bool:
    """Check if a user exists by ID."""
    result = await session.execute(select(User.id).where(User.id == user_id))
    exists: bool = result.scalar_one_or_none() is not None
    return exists

is_email_unique(session, email, exclude_user_id=None) async

Check if an email is unique (optionally excluding a user by ID).

Source code in repositories/user.py
440
441
442
443
444
445
446
447
448
449
async def is_email_unique(
    session: AsyncSession, email: str, exclude_user_id: int | None = None
) -> bool:
    """Check if an email is unique (optionally excluding a user by ID)."""
    stmt = select(User).where(User.email == email)
    if exclude_user_id is not None:
        stmt = stmt.where(User.id != exclude_user_id)
    result = await session.execute(stmt)
    unique: bool = result.scalar_one_or_none() is None
    return unique

change_user_password(session, user_id, new_hashed_password) async

Change a user's password (hashed). Raises: Exception: On DB commit failure.

Source code in repositories/user.py
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
async def change_user_password(
    session: AsyncSession, user_id: int, new_hashed_password: str
) -> bool:
    """Change a user's password (hashed).
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None:
        return False
    user.hashed_password = new_hashed_password
    try:
        await session.commit()
    except Exception as exc:
        logging.error(f"Failed to commit password change for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return True

audit_log_user_change(session, user_id, action, details='') async

Log an audit event for a user change. In production, consider integrating with Azure Monitor or Application Insights. Raises: None

Source code in repositories/user.py
475
476
477
478
479
480
481
482
async def audit_log_user_change(
    session: AsyncSession, user_id: int, action: str, details: str = ""
) -> None:
    """Log an audit event for a user change. In production, consider integrating with Azure Monitor or Application Insights.
    Raises:
        None
    """
    logger.info(f"User {user_id}: {action}. {details}")

assign_role_to_user(session, user_id, role) async

Stub: Assign a role to a user. Not implemented (no role field).

Source code in repositories/user.py
486
487
488
async def assign_role_to_user(session: AsyncSession, user_id: int, role: str) -> bool:
    """Stub: Assign a role to a user. Not implemented (no role field)."""
    return False

revoke_role_from_user(session, user_id, role) async

Stub: Revoke a role from a user. Not implemented (no role field).

Source code in repositories/user.py
491
492
493
async def revoke_role_from_user(session: AsyncSession, user_id: int, role: str) -> bool:
    """Stub: Revoke a role from a user. Not implemented (no role field)."""
    return False

db_session_context() async

Async context manager for DB session. Yields: AsyncSession

Source code in repositories/user.py
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
@asynccontextmanager
async def db_session_context() -> AsyncIterator[AsyncSession]:
    """Async context manager for DB session.
    Yields:
        AsyncSession
    """
    # Adjust the import below to match your actual async session maker location and symbol.
    # For example, if your sessionmaker is named "get_async_session" in "src.core.database", import it as shown:
    # from src.core.database import get_async_session
    # async with get_async_session() as session:

    # If you use SQLAlchemy's async sessionmaker, it might look like this:
    from src.core.database import get_async_session

    async with get_async_session() as session:
        try:
            yield session
        finally:
            await session.close()

db_transaction(session) async

Async context manager for DB transaction (atomic operations). Yields: AsyncSession

Source code in repositories/user.py
517
518
519
520
521
522
523
524
@asynccontextmanager
async def db_transaction(session: AsyncSession) -> AsyncIterator[AsyncSession]:
    """Async context manager for DB transaction (atomic operations).
    Yields:
        AsyncSession
    """
    async with session.begin():
        yield session

get_user_with_files(session, user_id) async

Fetch a user and their files separately (WriteOnlyMapped doesn't support eager loading).

Source code in repositories/user.py
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
async def get_user_with_files(session: AsyncSession, user_id: int) -> User | None:
    """Fetch a user and their files separately (WriteOnlyMapped doesn't support eager loading)."""
    from src.models.file import File

    # Get the user first
    result = await session.execute(select(User).where(User.id == user_id))
    user: User | None = result.scalar_one_or_none()

    if user is None:
        return None

    # Get files separately and attach them as a list
    files_result = await session.execute(select(File).where(File.user_id == user_id))
    files_list = files_result.scalars().all()

    # Store files in a way the test can access
    # For test access only; not a real model field
    user._files = files_list  # type: ignore[attr-defined]

    return user

export_users_to_csv(session) async

Export all users to CSV string.

Source code in repositories/user.py
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
async def export_users_to_csv(session: AsyncSession) -> str:
    """Export all users to CSV string."""
    result = await session.execute(select(User))
    users: Sequence[User] = result.scalars().all()
    output: io.StringIO = io.StringIO()
    writer: csv.DictWriter[str] = csv.DictWriter(
        output,
        fieldnames=[
            "id",
            "email",
            "is_active",
            "is_deleted",
            "created_at",
            "updated_at",
            "last_login_at",
        ],
    )
    writer.writeheader()
    for user in users:
        row: UserCSVRow = {
            "id": user.id,
            "email": user.email,
            "is_active": user.is_active,
            "is_deleted": user.is_deleted,
            "created_at": user.created_at,
            "updated_at": user.updated_at,
            "last_login_at": user.last_login_at,
        }
        writer.writerow(row)
    return output.getvalue()

export_users_to_json(session) async

Export all users to JSON string.

Source code in repositories/user.py
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
async def export_users_to_json(session: AsyncSession) -> str:
    """Export all users to JSON string."""
    result = await session.execute(select(User))
    users: Sequence[User] = result.scalars().all()
    data: list[UserJSONRow] = [
        {
            "id": user.id,
            "email": user.email,
            "is_active": user.is_active,
            "is_deleted": user.is_deleted,
            "created_at": user.created_at.isoformat() if user.created_at else None,
            "updated_at": user.updated_at.isoformat() if user.updated_at else None,
            "last_login_at": (
                user.last_login_at.isoformat() if user.last_login_at else None
            ),
        }
        for user in users
    ]
    return json.dumps(data)

import_users_from_dicts(session, user_dicts) async

Bulk import users from a list of dicts. Raises: Exception: On DB commit failure.

Source code in repositories/user.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
async def import_users_from_dicts(
    session: AsyncSession, user_dicts: Sequence[Mapping[str, object]]
) -> Sequence[User]:
    """Bulk import users from a list of dicts.
    Raises:
        Exception: On DB commit failure.
    """
    users: list[User] = [User(**d) for d in user_dicts]
    session.add_all(users)
    try:
        await session.commit()
        for user in users:
            await session.refresh(user)
    except Exception as exc:
        await session.rollback()
        raise exc
    return users

deactivate_user(session, user_id) async

Deactivate a user (set is_active=False). Raises: Exception: On DB commit failure.

Source code in repositories/user.py
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
async def deactivate_user(session: AsyncSession, user_id: int) -> bool:
    """Deactivate a user (set is_active=False).
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None or not getattr(user, "is_active", False):
        return False
    user.is_active = False
    try:
        await session.commit()
    except Exception as exc:
        logging.error(f"Failed to commit deactivate for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return True

reactivate_user(session, user_id) async

Reactivate a user (set is_active=True). Raises: Exception: On DB commit failure.

Source code in repositories/user.py
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
async def reactivate_user(session: AsyncSession, user_id: int) -> bool:
    """Reactivate a user (set is_active=True).
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None or getattr(user, "is_active", False):
        return False
    user.is_active = True
    try:
        await session.commit()
    except Exception as exc:
        logging.error(f"Failed to commit reactivate for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return True

update_last_login(session, user_id, login_time=None) async

Update the last_login_at timestamp for a user. Raises: Exception: On DB commit failure.

Source code in repositories/user.py
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
async def update_last_login(
    session: AsyncSession, user_id: int, login_time: datetime | None = None
) -> bool:
    """Update the last_login_at timestamp for a user.
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id)
    if user is None:
        return False
    dt: datetime = login_time or datetime.now(UTC)
    if dt.tzinfo is not None:
        dt = dt.replace(tzinfo=None)
    user.last_login_at = dt
    try:
        await session.commit()
    except Exception as exc:
        logging.error(f"Failed to commit last login update for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return True

anonymize_user(session, user_id) async

Anonymize user data for privacy/GDPR (irreversibly removes PII, disables account). Raises: Exception: On DB commit failure.

Source code in repositories/user.py
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
async def anonymize_user(session: AsyncSession, user_id: int) -> bool:
    """Anonymize user data for privacy/GDPR (irreversibly removes PII, disables account).
    Raises:
        Exception: On DB commit failure.
    """
    user: User | None = await get_user_by_id(session, user_id, use_cache=False)
    if user is None or getattr(user, "is_deleted", False):
        return False
    user.email = f"anon_{user.id}_{int(datetime.now(UTC).timestamp())}@anon.invalid"
    user.hashed_password = ""
    user.is_active = False
    user.is_deleted = True
    user.last_login_at = None
    try:
        await session.commit()
    except Exception as exc:
        logging.error(f"Failed to commit anonymize for user {user_id}: {exc}")
        await session.rollback()
        raise exc
    return True

user_signups_per_month(session, year) async

Return a dict of {month: signup_count} for the given year (1-12).

Source code in repositories/user.py
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
async def user_signups_per_month(session: AsyncSession, year: int) -> dict[int, int]:
    """Return a dict of {month: signup_count} for the given year (1-12)."""
    stmt = (
        select(extract("month", User.created_at).label("month"), func.count(User.id))
        .where(extract("year", User.created_at) == year)
        .group_by("month")
        .order_by("month")
    )
    result = await session.execute(stmt)
    # result.all() returns Sequence[Row[Any]], so extract values explicitly
    rows: Sequence[Row[Any]] = result.all()
    stats: dict[int, int] = dict.fromkeys(range(1, 13), 0)
    for row in rows:
        month: int = int(row[0])
        count: int = int(row[1])
        stats[month] = count
    return stats

repositories.file

get_file_by_filename(session, filename) async

Source code in repositories/file.py
12
13
14
15
16
17
18
async def get_file_by_filename(session: AsyncSession, filename: str) -> File | None:
    from typing import Any

    result: sqlalchemy.engine.Result[Any] = await session.execute(
        select(File).where(File.filename == filename)
    )
    return result.scalar_one_or_none()

create_file(session, filename, content_type, user_id, size=0) async

Create a new File record in the database. Raises: ValidationError: If filename is empty. Exception: If the database operation fails.

Source code in repositories/file.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
async def create_file(
    session: AsyncSession, filename: str, content_type: str, user_id: int, size: int = 0
) -> File:
    """
    Create a new File record in the database.
    Raises:
        ValidationError: If filename is empty.
        Exception: If the database operation fails.
    """
    if not filename:
        raise ValidationError("Filename is required.")

    file: File = File(
        filename=filename, content_type=content_type, user_id=user_id, size=size
    )

    try:
        session.add(file)
        await session.flush()
    except Exception as exc:
        await session.rollback()
        raise exc

    return file

delete_file(session, filename) async

Delete a file by filename. Returns: bool: True if file was deleted, False if not found. Raises: Exception: If the database operation fails.

Source code in repositories/file.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
async def delete_file(session: AsyncSession, filename: str) -> bool:
    """
    Delete a file by filename.
    Returns:
        bool: True if file was deleted, False if not found.
    Raises:
        Exception: If the database operation fails.
    """
    file: File | None = await get_file_by_filename(session, filename)
    if not file:
        return False
    await session.delete(file)
    # Note: Don't flush here - let the endpoint handle the commit
    return True

bulk_delete_files(session, filenames, user_id) async

Bulk delete files by filenames for a specific user. Returns: tuple[list[str], list[str]]: (successfully_deleted, failed_to_delete) Raises: Exception: If the database operation fails.

Source code in repositories/file.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
async def bulk_delete_files(
    session: AsyncSession, filenames: list[str], user_id: int
) -> tuple[list[str], list[str]]:
    """
    Bulk delete files by filenames for a specific user.
    Returns:
        tuple[list[str], list[str]]: (successfully_deleted, failed_to_delete)
    Raises:
        Exception: If the database operation fails.
    """
    deleted: list[str] = []
    failed: list[str] = []

    for filename in filenames:
        try:
            # Check if file exists and belongs to the user
            result = await session.execute(
                select(File).where(File.filename == filename, File.user_id == user_id)
            )
            file: File | None = result.scalar_one_or_none()

            if file:
                await session.delete(file)
                deleted.append(filename)
            else:
                failed.append(filename)
        except Exception:
            failed.append(filename)

    return deleted, failed

list_files(session, user_id, offset=0, limit=20, q=None, sort='created_at', order='desc', created_after=None, created_before=None) async

List files for a user with optional filters and pagination. Returns: tuple[list[File], int]: (files, total count) Raises: Exception: If the database operation fails.

Source code in repositories/file.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
async def list_files(
    session: AsyncSession,
    user_id: int,
    offset: int = 0,
    limit: int = 20,
    q: str | None = None,
    sort: Literal["created_at", "filename"] = "created_at",
    order: Literal["desc", "asc"] = "desc",
    created_after: datetime | None = None,
    created_before: datetime | None = None,
) -> tuple[list[File], int]:
    """
    List files for a user with optional filters and pagination.
    Returns:
        tuple[list[File], int]: (files, total count)
    Raises:
        Exception: If the database operation fails.
    """
    stmt: sqlalchemy.sql.Select[Any] = select(File).where(File.user_id == user_id)
    if q is not None:
        stmt = stmt.where(File.filename.ilike(f"%{q}%"))
    if created_after is not None:
        stmt = stmt.where(File.created_at >= created_after)
    if created_before is not None:
        stmt = stmt.where(File.created_at <= created_before)
    if sort in ("created_at", "filename"):
        col: sqlalchemy.sql.ColumnElement[Any] = getattr(File, sort)
        if order == "desc":
            col = col.desc()
        else:
            col = col.asc()
        stmt = stmt.order_by(col)
    count_stmt: sqlalchemy.sql.Select[Any] = select(func.count()).select_from(
        stmt.subquery()
    )
    total: int = (await session.execute(count_stmt)).scalar_one()
    stmt = stmt.offset(offset).limit(limit)
    result = await session.execute(stmt)
    files: list[File] = list(result.scalars().all())
    return files, total

This page is always up to date with the latest code and docstrings.