From 8e0131451d047aa85f3f17430663708861f32701 Mon Sep 17 00:00:00 2001 From: lorsan Date: Thu, 8 Jan 2026 15:05:23 +0300 Subject: [PATCH] refractor structure project and add user endpoint --- backend/app/{users => auth}/dependencies.py | 11 +- backend/app/auth/router.py | 89 +++++++++++ backend/app/auth/schemas.py | 9 ++ backend/app/auth/service.py | 102 ++++++++++++ backend/app/chats/router.py | 0 backend/app/{utils => core}/celery_app.py | 4 +- backend/app/{ => core}/config.py | 2 +- backend/app/{ => core}/constants.py | 0 backend/app/{ => core}/dao.py | 2 +- backend/app/{ => core}/database.py | 7 +- backend/app/{ => core}/exceptions.py | 0 backend/app/{ => core}/log_config.py | 2 +- backend/app/{utils => core}/redis.py | 2 +- backend/app/main.py | 9 +- backend/app/migration/env.py | 5 +- backend/app/services/email_service.py | 2 +- backend/app/services/redis_service.py | 2 +- backend/app/tasks/email_tasks.py | 2 +- backend/app/users/dao.py | 2 +- backend/app/users/models.py | 15 +- backend/app/users/router.py | 97 +++--------- backend/app/users/schemas.py | 8 +- backend/app/users/service.py | 164 ++++++++------------ backend/app/utils/cache.py | 9 +- backend/app/utils/email_client.py | 2 +- 25 files changed, 329 insertions(+), 218 deletions(-) rename backend/app/{users => auth}/dependencies.py (92%) create mode 100644 backend/app/auth/router.py create mode 100644 backend/app/auth/schemas.py create mode 100644 backend/app/auth/service.py create mode 100644 backend/app/chats/router.py rename backend/app/{utils => core}/celery_app.py (70%) rename backend/app/{ => core}/config.py (91%) rename backend/app/{ => core}/constants.py (100%) rename backend/app/{ => core}/dao.py (99%) rename backend/app/{ => core}/database.py (85%) rename backend/app/{ => core}/exceptions.py (100%) rename backend/app/{ => core}/log_config.py (95%) rename backend/app/{utils => core}/redis.py (91%) diff --git a/backend/app/users/dependencies.py b/backend/app/auth/dependencies.py similarity index 92% rename from backend/app/users/dependencies.py rename to backend/app/auth/dependencies.py index eefa70c..73473a7 100644 --- a/backend/app/users/dependencies.py +++ b/backend/app/auth/dependencies.py @@ -1,15 +1,14 @@ import logging from typing import Optional -import uuid from fastapi import Depends, HTTPException, status from jose import jwt, JWTError from app.utils.OAuth2WithCookie import OAuth2PasswordBearerWithCookie -from app.config import settings +from app.core.config import settings from app.users.models import UserModel from app.users.service import UserService -from app.exceptions import InvalidTokenException +from app.core.exceptions import InvalidTokenException log = logging.getLogger(__name__) oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/v1/auth/login") @@ -52,5 +51,7 @@ async def get_current_superuser(current_user: UserModel = Depends(get_current_us async def get_current_verified_user(current_user: UserModel = Depends(get_current_user)): if not current_user.is_verified: - log.debug("User has not confirmed the email.", extra={"user_id": current_user.id}) - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="verify email") \ No newline at end of file + log.debug("User has not confirmed the email.", extra={"user_id": str(current_user.id)}) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="verify email") + + return current_user \ No newline at end of file diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py new file mode 100644 index 0000000..7b75059 --- /dev/null +++ b/backend/app/auth/router.py @@ -0,0 +1,89 @@ +from typing import Dict +import logging +import uuid + +from fastapi import APIRouter, status, Response, Depends, Request, HTTPException +from fastapi.security import OAuth2PasswordRequestForm + +from app.users.schemas import UserCreate, User +from app.auth.schemas import Token +from app.users.service import UserService +from app.auth.service import AuthService +from app.users.models import UserModel +from app.core.exceptions import InvalidCredentialsException +from app.auth.dependencies import get_current_user +from app.core.config import settings + +router = APIRouter(prefix="/auth", tags=["Auth"]) + +log = logging.getLogger(__name__) + +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register(user: UserCreate) -> User: + return await UserService.register_new_user(user) + +@router.get("/verify/{token}") +async def verify_email(token: uuid.UUID) -> Dict: + await UserService.verify_email(token) + return {"status": True, "message": "User successfully verified email"} + +@router.post("/send/verify-email") +async def resend_verify_email(user: UserModel = Depends(get_current_user)) -> Dict: + if user.is_verified: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Email already verified") + + await UserService.send_verify_email(user) + return {"status": True, "message": "Successfully send email letter"} + +@router.post("/login") +async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token: + user = await AuthService.authenticate_user(credentials.username, credentials.password) + if not user: + log.warning("Failed login attempt", extra={"email or username": credentials.username}) + raise InvalidCredentialsException + token = await AuthService.create_token(user.id) + response.set_cookie( + 'access_token', + token.access_token, + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + httponly=True + ) + response.set_cookie( + 'refresh_token', + str(token.refresh_token), + max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60, + httponly=True + ) + return token + +@router.post("/refresh") +async def refresh_token(request: Request, response: Response) -> Token: + new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token"))) + + response.set_cookie( + 'access_token', + new_token.access_token, + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + httponly=True + ) + response.set_cookie( + 'refresh_token', + str(new_token.refresh_token), + max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60, + httponly=True + ) + log.debug("Token refreshed via endpoint") + return new_token + +@router.post("/logout") +async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)) -> Dict: + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + + await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token"))) + return {"status": True, "message": "Logged out successfully"} + +@router.post("/abort") +async def abort_all_sessions(user: UserModel = Depends(get_current_user)) -> Dict: + await AuthService.abort_all_sessions(user.id) + return {"status": True, "message": "All sessions was aborted"} \ No newline at end of file diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py new file mode 100644 index 0000000..5c0df85 --- /dev/null +++ b/backend/app/auth/schemas.py @@ -0,0 +1,9 @@ +import uuid + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + refresh_token: uuid.UUID + token_type: str \ No newline at end of file diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000..196eea7 --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,102 @@ +import logging +import uuid +from datetime import datetime, timedelta +from typing import Optional + +from jose import jwt +from sqlalchemy import or_ + +from app.utils.hash_password import verify_password +from app.services.redis_service import RefreshTokenStorage +from app.core.exceptions import InvalidTokenException +from app.users.models import UserModel +from app.users.dao import UserDAO +from app.core.database import async_session_maker +from app.auth.schemas import Token +from app.core.config import settings + +log = logging.getLogger(__name__) + + +class AuthService: + @classmethod + async def create_token(cls, user_id: int) -> Token: + + access_token = cls._create_access_token(user_id) + refresh_token_expires = timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + refresh_token = cls._create_refresh_token() + + await RefreshTokenStorage.save_token(refresh_token, user_id, int(refresh_token_expires.total_seconds())) + + log.info("Token created has user", extra={"user_id": user_id}) + return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") + + @classmethod + async def logout(cls, token: uuid.UUID) -> None: + user_id = await RefreshTokenStorage.getdel_token(token) + log.info("User logged out", extra={"user_id": user_id}) + + @classmethod + async def refresh_token(cls, token: uuid.UUID) -> Token: + async with async_session_maker() as session: + refresh_session = await RefreshTokenStorage.getdel_token(token) + + if refresh_session is None: + log.warning("Refresh token not found") + raise InvalidTokenException + + user = await UserDAO.find_one_or_none(session, id=int(refresh_session)) + if user is None: + log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)}) + raise InvalidTokenException + + access_token = cls._create_access_token(user.id) + refresh_token_expires = timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + refresh_token = cls._create_refresh_token() + + await RefreshTokenStorage.save_token( + refresh_token, + user.id, + int(refresh_token_expires.total_seconds()) + ) + + await session.commit() + log.info("Token refreshed for user", extra={"user_id": str(user.id)}) + return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") + + @classmethod + async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]: + async with async_session_maker() as session: + db_user = await UserDAO.find_one_or_none( + session, + or_( + UserModel.email==email_or_username, + UserModel.username==email_or_username + ) + ) + if db_user and verify_password(password, db_user.hashed_password): + log.info("User authenticated successfully", extra={"username": db_user.username}) + return db_user + log.warning("Authentication failed", extra={"email": email_or_username}) + return None + + @classmethod + async def abort_all_sessions(cls, user_id: int): + await RefreshTokenStorage.abort_all_tokens(user_id) + + @classmethod + def _create_access_token(cls, user_id: int) -> str: + to_encode = { + "sub": str(user_id), + "exp": datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + } + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return f'Bearer {encoded_jwt}' + + @classmethod + def _create_refresh_token(cls) -> uuid.UUID: + return uuid.uuid4() \ No newline at end of file diff --git a/backend/app/chats/router.py b/backend/app/chats/router.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/celery_app.py b/backend/app/core/celery_app.py similarity index 70% rename from backend/app/utils/celery_app.py rename to backend/app/core/celery_app.py index f3baeb7..4f0ee5b 100644 --- a/backend/app/utils/celery_app.py +++ b/backend/app/core/celery_app.py @@ -1,9 +1,9 @@ from celery import Celery -from app.config import settings +from app.core.config import settings celery_app = Celery( - "app.utils.celery_app", + "app.core.celery_app", broker=settings.RABBITMQ_URL, backend="rpc://" ) diff --git a/backend/app/config.py b/backend/app/core/config.py similarity index 91% rename from backend/app/config.py rename to backend/app/core/config.py index 023bb4d..995f6fc 100755 --- a/backend/app/config.py +++ b/backend/app/core/config.py @@ -14,7 +14,7 @@ class Settings(BaseSettings): WORKERS: int URL: str - CORS_ORIGINS: List[str] = ["*"] + CORS_ORIGINS: List[str] = ["http://localhost:5500", "http://127.0.0.1:5500", "http://localhost:8080", "http://127.0.0.1:8080", "null"] CORS_HEADERS: List[str] = ["*"] CORS_METHODS: List[str] = ["*"] diff --git a/backend/app/constants.py b/backend/app/core/constants.py similarity index 100% rename from backend/app/constants.py rename to backend/app/core/constants.py diff --git a/backend/app/dao.py b/backend/app/core/dao.py similarity index 99% rename from backend/app/dao.py rename to backend/app/core/dao.py index 76f90d4..d31d189 100755 --- a/backend/app/dao.py +++ b/backend/app/core/dao.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import SQLAlchemyError from pydantic import BaseModel -from app.database import Base +from app.core.database import Base log = logging.getLogger(__name__) diff --git a/backend/app/database.py b/backend/app/core/database.py similarity index 85% rename from backend/app/database.py rename to backend/app/core/database.py index fbd4f39..bb4d011 100755 --- a/backend/app/database.py +++ b/backend/app/core/database.py @@ -1,12 +1,11 @@ from datetime import datetime from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker -from sqlalchemy.engine import create_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy import MetaData, NullPool, func -from app.config import settings -from app.constants import DB_NAMING_CONVENTION +from app.core.config import settings +from app.core.constants import DB_NAMING_CONVENTION class Base(DeclarativeBase): diff --git a/backend/app/exceptions.py b/backend/app/core/exceptions.py similarity index 100% rename from backend/app/exceptions.py rename to backend/app/core/exceptions.py diff --git a/backend/app/log_config.py b/backend/app/core/log_config.py similarity index 95% rename from backend/app/log_config.py rename to backend/app/core/log_config.py index 2be9c98..67ce974 100755 --- a/backend/app/log_config.py +++ b/backend/app/core/log_config.py @@ -1,6 +1,6 @@ from logging.config import dictConfig -from app.config import settings +from app.core.config import settings LOGGING_CONFIG = { "version": 1, diff --git a/backend/app/utils/redis.py b/backend/app/core/redis.py similarity index 91% rename from backend/app/utils/redis.py rename to backend/app/core/redis.py index f48a028..579d65e 100755 --- a/backend/app/utils/redis.py +++ b/backend/app/core/redis.py @@ -1,6 +1,6 @@ from redis.asyncio import Redis, from_url -from app.config import settings +from app.core.config import settings redis_client: Redis = None diff --git a/backend/app/main.py b/backend/app/main.py index 185dcad..bd2c8b3 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,10 +5,11 @@ import logging from fastapi import FastAPI, APIRouter, Request, Response from fastapi.middleware.cors import CORSMiddleware -from app.utils.redis import close_redis, init_redis -from app.users.router import user_router, auth_router -from app.log_config import set_logging -from app.config import settings +from app.core.redis import close_redis, init_redis +from app.users.router import router as user_router +from app.auth.router import router as auth_router +from app.core.log_config import set_logging +from app.core.config import settings set_logging() log = logging.getLogger(__name__) diff --git a/backend/app/migration/env.py b/backend/app/migration/env.py index 7e4262f..6c8c18e 100755 --- a/backend/app/migration/env.py +++ b/backend/app/migration/env.py @@ -7,9 +7,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context -from app.users.models import UserModel -from app.database import Base -from app.config import settings +from app.core.database import Base +from app.core.config import settings # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 1c7d095..f61569f 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,7 +1,7 @@ import logging from app.utils.email_client import EmailClient -from app.config import settings +from app.core.config import settings log = logging.getLogger(__name__) diff --git a/backend/app/services/redis_service.py b/backend/app/services/redis_service.py index b3a7e33..bfcf5a8 100644 --- a/backend/app/services/redis_service.py +++ b/backend/app/services/redis_service.py @@ -1,7 +1,7 @@ import logging import uuid -from app.utils.redis import get_redis +from app.core.redis import get_redis log = logging.getLogger(__name__) diff --git a/backend/app/tasks/email_tasks.py b/backend/app/tasks/email_tasks.py index ae6b703..4b267e8 100644 --- a/backend/app/tasks/email_tasks.py +++ b/backend/app/tasks/email_tasks.py @@ -1,6 +1,6 @@ import logging -from app.utils.celery_app import celery_app +from app.core.celery_app import celery_app from app.services.email_service import EmailService log = logging.getLogger(__name__) diff --git a/backend/app/users/dao.py b/backend/app/users/dao.py index e6fcf94..ce5b51f 100644 --- a/backend/app/users/dao.py +++ b/backend/app/users/dao.py @@ -1,4 +1,4 @@ -from app.dao import BaseDAO +from app.core.dao import BaseDAO from app.users.models import UserModel from app.users.schemas import UserCreateDB, UserUpdateDB diff --git a/backend/app/users/models.py b/backend/app/users/models.py index f0e0b69..4f3825d 100755 --- a/backend/app/users/models.py +++ b/backend/app/users/models.py @@ -1,10 +1,9 @@ from datetime import date -import uuid from sqlalchemy.orm import mapped_column, Mapped -from sqlalchemy import DATE, UUID, ForeignKey +from sqlalchemy import DATE -from app.database import Base +from app.core.database import Base class UserModel(Base): __tablename__ = "user" @@ -19,12 +18,4 @@ class UserModel(Base): is_active: Mapped[bool] = mapped_column(default=True) is_verified: Mapped[bool] = mapped_column(default=False) is_superuser: Mapped[bool] = mapped_column(default=False) - hashed_password: Mapped[str] = mapped_column() - -# class RefreshSessionModel(Base): -# __tablename__ = "refresh_session" -# -# id: Mapped[int] = mapped_column(primary_key=True, index=True) -# refresh_token: Mapped[uuid.UUID] = mapped_column(UUID, index=True) -# expires_in: Mapped[int] = mapped_column() -# user_ud: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE")) \ No newline at end of file + hashed_password: Mapped[str] = mapped_column() \ No newline at end of file diff --git a/backend/app/users/router.py b/backend/app/users/router.py index 90befd0..ad90e90 100755 --- a/backend/app/users/router.py +++ b/backend/app/users/router.py @@ -1,88 +1,39 @@ from typing import Dict import logging -import uuid -from fastapi import APIRouter, status, Response, Depends, Request, HTTPException -from fastapi.security import OAuth2PasswordRequestForm +from fastapi import APIRouter, Response, Depends -from app.users.schemas import UserCreate, User, Token -from app.users.service import AuthService, UserService +from app.users.schemas import User, UserUpdate +from app.users.service import UserService +from app.auth.service import AuthService from app.users.models import UserModel -from app.exceptions import InvalidCredentialsException -from app.users.dependencies import get_current_user, get_current_verified_user -from app.config import settings +from app.auth.dependencies import get_current_verified_user, get_current_superuser -user_router = APIRouter(prefix="/users", tags=["User"]) -auth_router = APIRouter(prefix="/auth", tags=["Auth"]) +router = APIRouter(prefix="/users", tags=["User"]) log = logging.getLogger(__name__) -@auth_router.post("/register", status_code=status.HTTP_201_CREATED) -async def register(user: UserCreate) -> User: - return await UserService.register_new_user(user) -@auth_router.get("/verify/{token}") -async def verify_email(token: uuid.UUID) -> Dict: - await UserService.verify_email(token) - return {"status": True, "message": "User successfully verified email"} +@router.get("/me") +async def get_current_user(user: UserModel = Depends(get_current_verified_user)) -> User: + log.debug("Getting current user profile", extra={"user_id": str(user.id)}) + return user -@auth_router.post("/send/verify-email") -async def resend_verify_email(user: UserModel = Depends(get_current_user)) -> Dict: - if user.is_verified: - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Email already verified") +@router.get("/") +async def get_users(offset: int, limit: int, user: UserModel = Depends(get_current_superuser)): + log.info("Getting users list", extra={"offset": offset, "limit": limit}) + return await UserService.get_users_list(offset=offset, limit=limit) - await UserService.send_verify_email(user) - return {"status": True, "message": "Successfully send email letter"} +@router.put("/me") +async def update_current_user(update_user: UserUpdate, user: UserModel = Depends(get_current_verified_user)) -> User: + return await UserService.update_user(user.id, update_user) -@auth_router.post("/login") -async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token: - user = await AuthService.authenticate_user(credentials.username, credentials.password) - if not user: - log.warning("Failed login attempt", extra={"email or username": credentials.username}) - raise InvalidCredentialsException - token = await AuthService.create_token(user.id) - response.set_cookie( - 'access_token', - token.access_token, - max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - httponly=True - ) - response.set_cookie( - 'refresh_token', - str(token.refresh_token), - max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60, - httponly=True - ) - return token +@router.delete("/me") +async def delete_current_user(response: Response, user: UserModel = Depends(get_current_verified_user)) -> Dict: + log.debug("User deleting their account", extra={"user_id": user.id, "email": user.email}) + response.delete_cookie('access_token') + response.delete_cookie('refresh_token') -@auth_router.post("/refresh") -async def refresh_token(request: Request, response: Response) -> Token: - new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token"))) - - response.set_cookie( - 'access_token', - new_token.access_token, - max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - httponly=True - ) - response.set_cookie( - 'refresh_token', - str(new_token.refresh_token), - max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60, - httponly=True - ) - log.debug("Token refreshed via endpoint") - return new_token - -@auth_router.post("/logout") -async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)) -> Dict: - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - - await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token"))) - return {"status": True, "message": "Logged out successfully"} - -@auth_router.post("/abort") -async def abort_all_sessions(user: UserModel = Depends(get_current_user)) -> Dict: await AuthService.abort_all_sessions(user.id) - return {"status": True, "message": "All sessions was aborted"} \ No newline at end of file + await UserService.delete_user(user.id) + return {"status": True, "message": "User successfully deleted"} \ No newline at end of file diff --git a/backend/app/users/schemas.py b/backend/app/users/schemas.py index 028b215..041f4cd 100644 --- a/backend/app/users/schemas.py +++ b/backend/app/users/schemas.py @@ -1,6 +1,5 @@ from typing import Optional from datetime import date -import uuid from pydantic import BaseModel, EmailStr @@ -50,15 +49,10 @@ class UserCreateDB(UserBase): class UserUpdateDB(UserBase): email: Optional[str] = None - hashed_password: str + hashed_password: Optional[str] = None description: Optional[str] = None birth_day: Optional[date] = None is_active: Optional[bool] = None is_verified: Optional[bool] = None is_superuser: Optional[bool] = None -class Token(BaseModel): - access_token: str - refresh_token: uuid.UUID - token_type: str - diff --git a/backend/app/users/service.py b/backend/app/users/service.py index 73a6b2d..ab285c1 100644 --- a/backend/app/users/service.py +++ b/backend/app/users/service.py @@ -1,110 +1,25 @@ import logging import uuid -from datetime import datetime, timedelta -from typing import Optional +from datetime import timedelta +from typing import List from fastapi import HTTPException, status -from jose import jwt from sqlalchemy import or_ -from app.utils.hash_password import hash_password, verify_password -from app.services.redis_service import RefreshTokenStorage, EmailTokenStorage -from app.utils.redis import get_redis -from app.exceptions import InvalidTokenException, TokenExpiredException +from app.utils.hash_password import hash_password +from app.services.redis_service import EmailTokenStorage +from app.core.redis import get_redis +from app.core.exceptions import InvalidTokenException, TokenExpiredException from app.users.models import UserModel from app.users.dao import UserDAO -from app.database import async_session_maker -from app.users.schemas import Token, UserCreate, UserCreateDB, User +from app.core.database import async_session_maker +from app.users.schemas import UserCreate, UserCreateDB, User, UserUpdate, UserUpdateDB from app.tasks.email_tasks import EmailTasks -from app.config import settings +from app.core.config import settings log = logging.getLogger(__name__) -class AuthService: - @classmethod - async def create_token(cls, user_id: int) -> Token: - - access_token = cls._create_access_token(user_id) - refresh_token_expires = timedelta( - days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - refresh_token = cls._create_refresh_token() - - await RefreshTokenStorage.save_token(refresh_token, user_id, int(refresh_token_expires.total_seconds())) - - log.info("Token created has user", extra={"user_id": user_id}) - return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") - - @classmethod - async def logout(cls, token: uuid.UUID) -> None: - user_id = await RefreshTokenStorage.getdel_token(token) - log.info("User logged out", extra={"user_id": user_id}) - - @classmethod - async def refresh_token(cls, token: uuid.UUID) -> Token: - async with async_session_maker() as session: - refresh_session = await RefreshTokenStorage.getdel_token(token) - - if refresh_session is None: - log.warning("Refresh token not found") - raise InvalidTokenException - - user = await UserDAO.find_one_or_none(session, id=int(refresh_session)) - if user is None: - log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)}) - raise InvalidTokenException - - access_token = cls._create_access_token(user.id) - refresh_token_expires = timedelta( - days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - refresh_token = cls._create_refresh_token() - - await RefreshTokenStorage.save_token( - refresh_token, - user.id, - int(refresh_token_expires.total_seconds()) - ) - - await session.commit() - log.info("Token refreshed for user", extra={"user_id": str(user.id)}) - return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer") - - @classmethod - async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]: - async with async_session_maker() as session: - db_user = await UserDAO.find_one_or_none( - session, - or_( - UserModel.email==email_or_username, - UserModel.username==email_or_username - ) - ) - if db_user and verify_password(password, db_user.hashed_password): - log.info("User authenticated successfully", extra={"username": db_user.username}) - return db_user - log.warning("Authentication failed", extra={"email": email_or_username}) - return None - - @classmethod - async def abort_all_sessions(cls, user_id: int): - await RefreshTokenStorage.abort_all_tokens(user_id) - - @classmethod - def _create_access_token(cls, user_id: int) -> str: - to_encode = { - "sub": str(user_id), - "exp": datetime.utcnow() + timedelta( - minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - } - encoded_jwt = jwt.encode( - to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) - return f'Bearer {encoded_jwt}' - - @classmethod - def _create_refresh_token(cls) -> uuid.UUID: - return uuid.uuid4() - - class UserService: @classmethod async def get_user(cls, user_id: int) -> User: @@ -120,8 +35,6 @@ class UserService: @classmethod async def register_new_user(cls, user: UserCreate) -> User: - redis_client = await get_redis() - async with async_session_maker() as session: user_exist = await UserDAO.find_one_or_none(session, or_( UserModel.email==user.email, @@ -172,7 +85,6 @@ class UserService: @classmethod async def verify_email(cls, token: uuid.UUID): - redis_client = await get_redis() async with async_session_maker() as session: user_id = await EmailTokenStorage.getdel_token(token) @@ -192,3 +104,61 @@ class UserService: ) await session.commit() + + + @classmethod + async def get_users_list(cls, offset: int = 0, limit: int = 10) -> List[UserModel]: + async with async_session_maker() as session: + users = await UserDAO.find_all(session, offset, limit) + + if users is None: + log.warning("Users not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Users not found") + log.debug("Users fetched", extra={"count": len(users), "offset": offset, "limit": limit}) + return users + + + @classmethod + async def update_user(cls, user_id: int, update_user: UserUpdate): + async with async_session_maker() as session: + + user_exist = await UserDAO.find_one_or_none(session, id=user_id) + + if user_exist is None: + log.warning("User not found", extra={"user_id": user_id}) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") + + if user_exist.username != update_user.username: + username_exist = await UserDAO.find_one_or_none(session, username=update_user.username) + if username_exist: + log.warning("Username is taken", extra={"user_id": user_id}) + raise HTTPException(status.HTTP_409_CONFLICT, detail="Username is taken") + + update_user_db = await UserDAO.update( + session, + UserModel.id==user_id, + obj_in=UserUpdateDB( + **update_user.model_dump() + ) + ) + await session.commit() + log.info("User updated", extra={"user_id": user_id}) + return update_user_db + + + @classmethod + async def delete_user(cls, user_id): + async with async_session_maker() as session: + user_exist = await UserDAO.find_one_or_none(session, id=user_id) + + if user_exist is None: + log.warning("User not found", extra={"user_id": user_id}) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") + + await UserDAO.update( + session, + UserModel.id==user_id, + obj_in={"is_active": False} + ) + await session.commit() + log.info("User is inactive", extra={"user_id": user_id}) \ No newline at end of file diff --git a/backend/app/utils/cache.py b/backend/app/utils/cache.py index b3651a4..62af7cd 100755 --- a/backend/app/utils/cache.py +++ b/backend/app/utils/cache.py @@ -1,10 +1,13 @@ import json +import logging from functools import wraps from fastapi import Request +from fastapi.encoders import jsonable_encoder -from app.utils.redis import get_redis +from app.core.redis import get_redis +log = logging.getLogger(__name__) def cache(ttl: int = 10): if ttl <= 0: @@ -17,10 +20,12 @@ def cache(ttl: int = 10): request: Request = kwargs.get("request") response_cache = await redis.get(str(request.url)) if response_cache is not None: + log.debug("Getting from cache") return json.loads(response_cache) response_cache = await func(*args, **kwargs) - await redis.setex(str(request.url), ttl, json.dumps(response_cache)) + serializable_data = jsonable_encoder(response_cache) + await redis.setex(str(request.url), ttl, json.dumps(serializable_data)) return response_cache return wrapper diff --git a/backend/app/utils/email_client.py b/backend/app/utils/email_client.py index cdb4574..7cbac19 100644 --- a/backend/app/utils/email_client.py +++ b/backend/app/utils/email_client.py @@ -7,7 +7,7 @@ from email.mime.multipart import MIMEMultipart from jinja2 import Environment, FileSystemLoader -from app.config import settings +from app.core.config import settings log = logging.getLogger(__name__)