diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 9bf6663..a9b8eb0 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -34,8 +34,11 @@ jobs: - name: Run mypy app run: uv run mypy app - - name: Run mypy mediaservice - run: uv run mypy mediaservice + - name: Run mypy media-service + run: uv run mypy media-service + + - name: Run mypy notification-service + run: uv run mypy notification-service - name: Run mypy packages run: uv run mypy packages diff --git a/.gitignore b/.gitignore index e5e54b2..6fd8134 100644 --- a/.gitignore +++ b/.gitignore @@ -221,4 +221,6 @@ __marimo__/ .secrets config.local.yaml -mediaservice/.env +media-service/.env +notification-service/.env +.env.docker-compose \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00d57fc..e97c812 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,11 +24,18 @@ repos: args: ["app"] - id: mypy - alias: mypy mediaservice - name: Run mypy mediaservice + alias: mypy media-service + name: Run mypy media-service language: system exclude: tests - args: ["mediaservice"] + args: ["media-service"] + + - id: mypy + alias: mypy notification-service + name: Run mypy notification-service + language: system + exclude: tests + args: ["notification-service"] - id: mypy alias: mypy packages diff --git a/README.md b/README.md index c7d2037..8502565 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,40 @@ -Сделать бэкап БД: +Quick start + +Configure minio: +```bash +docker exec -it minio mc alias set myminio http://localhost:9000 admin adminadmin +docker exec -it minio mc mb myminio/genre-posters --ignore-existing +docker exec -it minio mc mb myminio/movie-posters --ignore-existing +docker exec -it minio mc mb myminio/movies --ignore-existing +docker exec -it minio mc anonymous set download myminio/genre-posters +docker exec -it minio mc anonymous set download myminio/movie-posters +docker exec -it minio mc anonymous set download myminio/movies +``` + + +Start app first time: +```bash +docker compose build; docker compose up --watch +``` + + +Build app: +```bash +docker compose build +``` + +Run app: +```bash +docker compose up --watch +``` + +Backup database: +```bash docker exec -it database pg_dump -U postgres -d '"movie-catalog"' --data-only -f /tmp/backup_utf8.sql docker cp database:/tmp/backup_utf8.sql ./backup.sql +``` -Применить бэкап БД: +```bash docker cp ./backup.sql database:/tmp/backup.sql docker exec -it database psql -U postgres -d '"movie-catalog"' -f /tmp/backup.sql - - -Minio: -mc alias set myminio http://localhost:9000 admin adminadmin -mc anonymous set download myminio/movie-posters - - - -В ссылке нужно minio заменить на localhost. Вместо header пишем то, что указали в content_type - - -curl.exe -X PUT "http://localhost:9000/genre-posters/tmp/genre/4e702647-dccb-4f7c-9bb3-41e7c7e22aa7_photo123.png?AWSAccessKeyId=admin&Signature=6wYwqZVd2HUPcPx2f%2F9k6KW3gUs%3D&content-type=image%2Fpng&Expires=1780331034" --data-binary "@photo123.png" -H "Content-Type: image/png" - - -Итог: - -( -echo -ne "PUT /genre-posters/tmp/genre/40279a3b-2baf-4c36-b37b-1dff63fea25f_test.png?AWSAccessKeyId=admin&Signature=7d6A7K1P8azTtHsuTMq36AlxB%2FU%3D&content-type=image%2Fpng&Expires=1780402810 HTTP/1.1\r\n" -echo -ne "Host: minio:9000\r\n" -echo -ne "Content-Type: image/png\r\n" -echo -ne "Content-Length: $(wc -c < test.png)\r\n" -echo -ne "Connection: close\r\n\r\n" -cat test.png -sleep 1 -) | nc minio 9000 - - -run mypy: -$env:MYPYPATH="app;."; mypy app; $env:MYPYPATH="mediaservice;."; mypy mediaservice; $env:MYPYPATH="packages;."; mypy packages +``` diff --git a/app/alembic/env.py b/app/alembic/env.py index 5455d7a..03deec2 100644 --- a/app/alembic/env.py +++ b/app/alembic/env.py @@ -32,7 +32,7 @@ # ... etc. config.set_main_option( "sqlalchemy.url", - settings.database.url_database, + settings.database.url, ) diff --git a/app/api/api_v1/__init__.py b/app/api/api_v1/__init__.py index 49c65f4..5d1db39 100644 --- a/app/api/api_v1/__init__.py +++ b/app/api/api_v1/__init__.py @@ -1,7 +1,8 @@ __all__ = ("router",) from fastapi import APIRouter -from .auth import router as auth_router +from api.api_v1.auth import router as auth_router + from .favorite_movies import router as favorite_movies_router from .genres import router as genres_router from .media import router as media_router diff --git a/app/api/api_v1/auth.py b/app/api/api_v1/auth.py deleted file mode 100644 index 064bdcc..0000000 --- a/app/api/api_v1/auth.py +++ /dev/null @@ -1,76 +0,0 @@ -from fastapi import ( - APIRouter, - status, -) - -from core.constants import BEARER_TOKEN_TYPE -from core.security.jwt_utils import ( - create_access_token, - create_refresh_token, -) -from dependencies.annotations.cache_services import UserCacheServiceDep -from dependencies.annotations.security import ( - AuthUserByRefreshTokenDep, - OAuth2Dep, -) -from schemas.auth import UserLogin -from schemas.token_info import TokenInfo -from schemas.user import ( - UserCreate, - UserResponse, -) - -router = APIRouter( - tags=["Auth"], - prefix="/auth", -) - - -@router.post( - "/register", - response_model=UserResponse, - status_code=status.HTTP_201_CREATED, -) -async def register_user( - create_user_data: UserCreate, - user_service: UserCacheServiceDep, -) -> UserResponse: - return await user_service.create_user(create_user_data) - - -@router.post( - "/login", - response_model=TokenInfo, - status_code=status.HTTP_200_OK, -) -async def login_user( - oauth2_form: OAuth2Dep, - user_service: UserCacheServiceDep, -) -> TokenInfo: - login_data = UserLogin( - login=oauth2_form.username, - password=oauth2_form.password, - ) - user = await user_service.authenticate_user(login_data) - access_token = create_access_token(user) - refresh_token = create_refresh_token(user) - return TokenInfo( - access_token=access_token, - refresh_token=refresh_token, - token_type=BEARER_TOKEN_TYPE, - ) - - -@router.post( - "/refresh", - response_model=TokenInfo, - response_model_exclude_unset=True, - status_code=status.HTTP_200_OK, -) -async def refresh_access_token( - user_id: AuthUserByRefreshTokenDep, - user_service: UserCacheServiceDep, -) -> TokenInfo: - user = await user_service.get_user_by_id(user_id) - access_token = create_access_token(user) - return TokenInfo(access_token=access_token) diff --git a/app/api/api_v1/auth/__init__.py b/app/api/api_v1/auth/__init__.py new file mode 100644 index 0000000..b8de033 --- /dev/null +++ b/app/api/api_v1/auth/__init__.py @@ -0,0 +1,17 @@ +__all__ = ("router",) + +from fastapi import APIRouter + +from .login_views import router as login_router +from .recover_views import router as recover_account_router +from .refresh_token_views import router as refresh_token_router +from .registration_views import router as registration_router + +router = APIRouter( + prefix="/auth", +) + +router.include_router(registration_router) +router.include_router(login_router) +router.include_router(recover_account_router) +router.include_router(refresh_token_router) diff --git a/app/api/api_v1/auth/login_views.py b/app/api/api_v1/auth/login_views.py new file mode 100644 index 0000000..05d8a55 --- /dev/null +++ b/app/api/api_v1/auth/login_views.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter +from starlette import status + +from dependencies.annotations.security import GetLoginDataDep +from dependencies.annotations.services import AuthServiceDep +from schemas.auth import SendConfirmationCodeRequest, VerifyUserEmail +from schemas.token_info import TemporaryTokenInfo, TokenInfo + +router = APIRouter( + prefix="/login", + tags=["Login"], +) + + +@router.post( + "/", + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, +) +async def login_user( + login_data: GetLoginDataDep, + auth_service: AuthServiceDep, +) -> TemporaryTokenInfo: + return await auth_service.authenticate_user(login_data) + + +@router.post( + "/resend-confirmation-code", + status_code=status.HTTP_200_OK, +) +async def resend_authenticate_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + auth_service: AuthServiceDep, +) -> None: + await auth_service.send_authenticate_confirmation_code( + send_confirmation_code_request, + ) + + +@router.post( + "/verify", + status_code=status.HTTP_200_OK, + response_model=TokenInfo, +) +async def verify_authenticate_user( + verify_authenticate_user_data: VerifyUserEmail, + auth_service: AuthServiceDep, +) -> TokenInfo: + return await auth_service.verify_authenticate_user(verify_authenticate_user_data) diff --git a/app/api/api_v1/auth/recover_views.py b/app/api/api_v1/auth/recover_views.py new file mode 100644 index 0000000..d466aa6 --- /dev/null +++ b/app/api/api_v1/auth/recover_views.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter +from starlette import status + +from dependencies.annotations.services import AuthServiceDep +from schemas.auth import ( + RecoverAccountRequest, + ResetPasswordRequest, + SendConfirmationCodeRequest, + VerifyUserEmail, +) +from schemas.token_info import TemporaryTokenInfo + +router = APIRouter( + prefix="/recover", + tags=["Recover"], +) + + +@router.post( + "/", + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, +) +async def recover_account( + recover_account_data: RecoverAccountRequest, + auth_service: AuthServiceDep, +) -> TemporaryTokenInfo: + return await auth_service.recover_account(recover_account_data) + + +@router.post("/resend-confirmation-code") +async def resend_recover_account_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + auth_service: AuthServiceDep, +) -> None: + return await auth_service.send_recover_account_confirmation_code( + send_confirmation_code_request, + ) + + +@router.post("/verify") +async def verify_recover_account( + verify_recover_account_data: VerifyUserEmail, + auth_service: AuthServiceDep, +) -> TemporaryTokenInfo: + return await auth_service.verify_recover_account(verify_recover_account_data) + + +@router.post("/reset-password") +async def reset_password( + reset_password_data: ResetPasswordRequest, + auth_service: AuthServiceDep, +) -> None: + await auth_service.reset_password(reset_password_data) diff --git a/app/api/api_v1/auth/refresh_token_views.py b/app/api/api_v1/auth/refresh_token_views.py new file mode 100644 index 0000000..a7e1c6e --- /dev/null +++ b/app/api/api_v1/auth/refresh_token_views.py @@ -0,0 +1,27 @@ +from fastapi import ( + APIRouter, + status, +) + +from dependencies.annotations.security import ( + AuthUserByRefreshTokenDep, +) +from dependencies.annotations.services import AuthServiceDep +from schemas.token_info import TokenInfo + +router = APIRouter( + tags=["Refresh Access Token"], +) + + +@router.post( + "/refresh", + response_model=TokenInfo, + response_model_exclude_unset=True, + status_code=status.HTTP_200_OK, +) +async def refresh_access_token( + user_id: AuthUserByRefreshTokenDep, + auth_service: AuthServiceDep, +) -> TokenInfo: + return await auth_service.refresh_access_token(user_id) diff --git a/app/api/api_v1/auth/registration_views.py b/app/api/api_v1/auth/registration_views.py new file mode 100644 index 0000000..695b017 --- /dev/null +++ b/app/api/api_v1/auth/registration_views.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter +from starlette import status + +from dependencies.annotations.services import AuthServiceDep +from schemas.auth import SendConfirmationCodeRequest, VerifyUserEmail +from schemas.token_info import TemporaryTokenInfo, TokenInfo +from schemas.user import UserRegistration + +router = APIRouter( + prefix="/register", + tags=["Registration"], +) + + +@router.post( + "/", + response_model=TemporaryTokenInfo, + status_code=status.HTTP_200_OK, +) +async def register_user( + registration_user_data: UserRegistration, + auth_service: AuthServiceDep, +) -> TemporaryTokenInfo: + return await auth_service.register_user(registration_user_data) + + +@router.post( + "/resend-confirmation-code", + status_code=status.HTTP_200_OK, +) +async def resend_register_confirmation_code( + send_confirmation_code_request: SendConfirmationCodeRequest, + auth_service: AuthServiceDep, +) -> None: + await auth_service.send_register_confirmation_code(send_confirmation_code_request) + + +@router.post( + "/verify", + response_model=TokenInfo, + status_code=status.HTTP_201_CREATED, +) +async def verify_register_user( + verify_register_user_data: VerifyUserEmail, + auth_service: AuthServiceDep, +) -> TokenInfo: + return await auth_service.verify_register_user(verify_register_user_data) diff --git a/app/api/api_v1/genres/details_views.py b/app/api/api_v1/genres/details_views.py index af41446..d061279 100644 --- a/app/api/api_v1/genres/details_views.py +++ b/app/api/api_v1/genres/details_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from core.constants import BASE_MINIO_URL from dependencies.annotations.cache_services import GenreCacheServiceDep diff --git a/app/api/api_v1/genres/list_views.py b/app/api/api_v1/genres/list_views.py index dcf481d..b0ccf50 100644 --- a/app/api/api_v1/genres/list_views.py +++ b/app/api/api_v1/genres/list_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from core.constants import BASE_MINIO_URL from dependencies.annotations.cache_services import GenreCacheServiceDep diff --git a/app/api/api_v1/media.py b/app/api/api_v1/media.py index fbfe357..a2dbec1 100644 --- a/app/api/api_v1/media.py +++ b/app/api/api_v1/media.py @@ -1,7 +1,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, status -from packages.schemas import PresignUrlCreate, PresignUrlResponse +from packages.schemas.media import PresignUrlCreate, PresignUrlResponse from core.config import settings from core.constants import MethodType @@ -33,7 +33,7 @@ async def get_presign_url( ], ) -> PresignUrlResponse: return await http_request_service.get_schema_from_request( - url=settings.mediaservice.create_presign_url_endpoint, + url=settings.media_service.create_presign_url_endpoint, method=MethodType.post.value, # type: ignore[arg-type] json=presign_url_create.model_dump(), response_schema=PresignUrlResponse, # type: ignore[arg-type] diff --git a/app/api/api_v1/movies/details_views.py b/app/api/api_v1/movies/details_views.py index 514f14d..bc01729 100644 --- a/app/api/api_v1/movies/details_views.py +++ b/app/api/api_v1/movies/details_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from core.constants import BASE_MINIO_URL from dependencies.annotations.cache_services import MovieCacheServiceDep diff --git a/app/api/api_v1/movies/list_views.py b/app/api/api_v1/movies/list_views.py index 8abff20..c08dc42 100644 --- a/app/api/api_v1/movies/list_views.py +++ b/app/api/api_v1/movies/list_views.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, status -from packages.constants import S3Bucket +from packages.minio.constants import S3Bucket from starlette.responses import RedirectResponse from core.constants import BASE_MINIO_URL diff --git a/app/cache_services/favorite_movie.py b/app/cache_services/favorite_movie.py index 8d0e8a0..357d600 100644 --- a/app/cache_services/favorite_movie.py +++ b/app/cache_services/favorite_movie.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from schemas.favorite_movie import ( FavoriteMovieCreate, FavoriteMovieWithMovieResponse, @@ -13,7 +13,7 @@ class FavoriteMovieCacheService: def __init__( self, favorite_movie_service: FavoriteMovieService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.favorite_movie_service = favorite_movie_service self.cache_service = cache_service diff --git a/app/cache_services/genre.py b/app/cache_services/genre.py index 236c0ac..9c2e63a 100644 --- a/app/cache_services/genre.py +++ b/app/cache_services/genre.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from schemas.genre import ( GenreCreate, GenrePartialUpdate, @@ -15,13 +15,13 @@ class GenreCacheService: def __init__( self, genre_service: GenreService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.genre_service = genre_service self.cache_service = cache_service async def get_genre_by_id(self, genre_id: int) -> GenreResponse: - key = CacheService.create_cache_key("genre", genre_id=genre_id) + key = RedisService.create_cache_key("genre", genre_id=genre_id) cached_genre_response = await self.cache_service.get(key, GenreResponse) if cached_genre_response is not None: return cast(GenreResponse, cached_genre_response) @@ -35,7 +35,7 @@ async def get_all_genres( size: int = 10, page: int = 1, ) -> GenreResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "genres", size=size, page=page, @@ -54,7 +54,7 @@ async def search_genres_by_name( size: int = 10, page: int = 1, ) -> GenreResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "genres", name=name, size=size, @@ -74,7 +74,7 @@ async def search_genres_by_name( async def create_genre(self, create_data: GenreCreate) -> GenreResponse: genre_response = await self.genre_service.create_genre(create_data) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return genre_response @@ -85,7 +85,7 @@ async def update_genre( update_data: GenreUpdate, ) -> GenreResponse: genre_response = await self.genre_service.update_genre(genre_id, update_data) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return genre_response @@ -99,13 +99,13 @@ async def partial_update_genre( genre_id, update_data, ) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return genre_response async def delete_genre_by_id(self, genre_id: int) -> None: await self.genre_service.delete_genre_by_id(genre_id) - key = CacheService.create_cache_key("genre") + key = RedisService.create_cache_key("genre") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/cache_services/movie.py b/app/cache_services/movie.py index 0a21010..09f7df1 100644 --- a/app/cache_services/movie.py +++ b/app/cache_services/movie.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from dependencies.annotations.validators import PaginationPageDep, PaginationSizeDep from schemas.movie import ( MovieCreate, @@ -19,15 +19,15 @@ class MovieCacheService: def __init__( self, movie_service: MovieService, - cache_service_for_movie: CacheService, - cache_service_for_watch_history: CacheService, + cache_service_for_movie: RedisService, + cache_service_for_watch_history: RedisService, ) -> None: self.movie_service = movie_service self.cache_service_for_movie = cache_service_for_movie self.cache_service_for_watch_history = cache_service_for_watch_history async def get_movie_by_id(self, movie_id: int) -> MovieWithGenreResponse: - key = CacheService.create_cache_key("movie", movie_id=movie_id) + key = RedisService.create_cache_key("movie", movie_id=movie_id) cached_movie_response = await self.cache_service_for_movie.get( key, MovieWithGenreResponse, @@ -44,7 +44,7 @@ async def get_movies( size: int = 10, page: int = 1, ) -> MovieWithGenreResponseList: - key = CacheService.create_cache_key("movies", size=size, page=page) + key = RedisService.create_cache_key("movies", size=size, page=page) cached_movies_response = await self.cache_service_for_movie.get( key, MovieWithGenreResponseList, @@ -62,7 +62,7 @@ async def search_movies_with_filters( size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> MovieWithGenreResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "movies", size=size, page=page, @@ -89,7 +89,7 @@ async def get_movies_by_genre_id( size: int = 10, page: int = 1, ) -> MovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "movies", genre_id=genre_id, size=size, @@ -119,7 +119,7 @@ async def watch_movie( user_id, create_watch_history_data, ) - key = CacheService.create_cache_key("watch_history") + key = RedisService.create_cache_key("watch_history") pattern = key + "*" await self.cache_service_for_watch_history.delete_by_pattern(pattern) return movie_response @@ -129,7 +129,7 @@ async def create_movie( create_movie_data: MovieCreate, ) -> MovieWithGenreResponse: movie_response = await self.movie_service.create_movie(create_movie_data) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) return movie_response @@ -143,7 +143,7 @@ async def update_movie( movie_id, update_movie_data, ) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) return movie_response @@ -157,13 +157,13 @@ async def partial_update_movie( movie_id, update_movie_data, ) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) return movie_response async def delete_movie_by_id(self, movie_id: int) -> None: await self.movie_service.delete_movie_by_id(movie_id) - key = CacheService.create_cache_key("movie") + key = RedisService.create_cache_key("movie") pattern = key + "*" await self.cache_service_for_movie.delete_by_pattern(pattern) diff --git a/app/cache_services/review.py b/app/cache_services/review.py index 60b6231..cf3deca 100644 --- a/app/cache_services/review.py +++ b/app/cache_services/review.py @@ -1,6 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from dependencies.annotations.validators import PaginationPageDep, PaginationSizeDep from schemas.review import ( ReviewCreate, @@ -18,7 +18,7 @@ class ReviewCacheService: def __init__( self, review_service: ReviewService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.review_service = review_service self.cache_service = cache_service @@ -28,7 +28,7 @@ async def get_reviews( size: int = 10, page: int = 1, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key("reviews", size=size, page=page) + key = RedisService.create_cache_key("reviews", size=size, page=page) cached_reviews_response = await self.cache_service.get( key, ReviewWithUserResponseList, @@ -45,7 +45,7 @@ async def get_reviews( return reviews_response async def get_review_by_id(self, review_id: int) -> ReviewWithUserResponse: - key = CacheService.create_cache_key("review", review_id=review_id) + key = RedisService.create_cache_key("review", review_id=review_id) cached_review_response = await self.cache_service.get( key, ReviewWithUserResponse, @@ -67,7 +67,7 @@ async def get_user_reviews( size: int = 10, page: int = 1, ) -> ReviewWithMovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews", user_id=user_id, size=size, @@ -97,7 +97,7 @@ async def get_user_review_about_movie( user_id: int, movie_id: int, ) -> ReviewResponse: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "review", user_id=user_id, movie_id=movie_id, @@ -123,7 +123,7 @@ async def get_movie_reviews( size: int = 10, page: int = 1, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews", movie_id=movie_id, size=size, @@ -154,7 +154,7 @@ async def get_low_rated_movie_reviews( size: int, page: int, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:low-rated", movie_id=movie_id, size=size, @@ -185,7 +185,7 @@ async def get_top_rated_movie_reviews( size: PaginationSizeDep = 10, page: PaginationPageDep = 1, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:top-rated", movie_id=movie_id, size=size, @@ -216,7 +216,7 @@ async def get_top_newest_movie_reviews( size: int, page: int, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:top-newest", movie_id=movie_id, size=size, @@ -247,7 +247,7 @@ async def get_top_oldest_movie_reviews( size: int, page: int, ) -> ReviewWithUserResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "reviews:top-oldest", movie_id=movie_id, size=size, @@ -281,7 +281,7 @@ async def create_review( user_id, create_review_data, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return review_response @@ -297,7 +297,7 @@ async def update_review( review_id, update_review_data, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return review_response @@ -313,7 +313,7 @@ async def partial_update_review( review_id, update_review_data, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return review_response @@ -327,6 +327,6 @@ async def delete_review( current_user_id, review_id, ) - key = CacheService.create_cache_key("review") + key = RedisService.create_cache_key("review") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/app/cache_services/user.py b/app/cache_services/user.py index 1ab26bb..548c2ff 100644 --- a/app/cache_services/user.py +++ b/app/cache_services/user.py @@ -1,7 +1,6 @@ from typing import cast -from core.redis.cache_service import CacheService -from schemas.auth import UserLogin +from core.redis.service import RedisService from schemas.user import ( UserCreate, UserPartialUpdate, @@ -16,13 +15,13 @@ class UserCacheService: def __init__( self, user_service: UserService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.user_service = user_service self.cache_service = cache_service async def get_user_by_id(self, user_id: int) -> UserResponse: - key = CacheService.create_cache_key("user", user_id=user_id) + key = RedisService.create_cache_key("user", user_id=user_id) cached_user_response = await self.cache_service.get(key, UserResponse) if cached_user_response is not None: return cast(UserResponse, cached_user_response) @@ -32,7 +31,7 @@ async def get_user_by_id(self, user_id: int) -> UserResponse: return user_response async def get_user_by_login(self, login: str) -> UserResponse: - key = CacheService.create_cache_key("user", login=login) + key = RedisService.create_cache_key("user", login=login) cached_user_response = await self.cache_service.get(key, UserResponse) if cached_user_response is not None: return cast(UserResponse, cached_user_response) @@ -42,7 +41,7 @@ async def get_user_by_login(self, login: str) -> UserResponse: return user_response async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList: - key = CacheService.create_cache_key("users", size=size, page=page) + key = RedisService.create_cache_key("users", size=size, page=page) cached_users_response = await self.cache_service.get(key, UserResponseList) if cached_users_response is not None: return cast(UserResponseList, cached_users_response) @@ -51,9 +50,12 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList await self.cache_service.set(key, users_response) return users_response - async def create_user(self, create_user_data: UserCreate) -> UserResponse: - user_response = await self.user_service.create_user(create_user_data) - key = CacheService.create_cache_key("user") + async def create_user( + self, + user_create_data: UserCreate, + ) -> UserResponse: + user_response = await self.user_service.create_user(user_create_data) + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return user_response @@ -64,7 +66,7 @@ async def update_user( update_data: UserUpdate, ) -> UserResponse: user_response = await self.user_service.update_user(user_id, update_data) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return user_response @@ -78,33 +80,19 @@ async def partial_update_user( user_id, update_data, ) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) return user_response async def delete_user_by_id(self, user_id: int) -> None: await self.user_service.delete_user_by_id(user_id) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) async def delete_user_by_login(self, login: str) -> None: await self.user_service.delete_user_by_login(login) - key = CacheService.create_cache_key("user") + key = RedisService.create_cache_key("user") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) - - async def authenticate_user(self, login_data: UserLogin) -> UserResponse: - key = CacheService.create_cache_key( - "user", - login=login_data.login, - password=login_data.password, - ) - cached_user_response = await self.cache_service.get(key, UserResponse) - if cached_user_response is not None: - return cast(UserResponse, cached_user_response) - - user_response = await self.user_service.authenticate_user(login_data) - await self.cache_service.set(key, user_response) - return user_response diff --git a/app/cache_services/watch_history.py b/app/cache_services/watch_history.py index dd6dc1b..de2b9fe 100644 --- a/app/cache_services/watch_history.py +++ b/app/cache_services/watch_history.py @@ -1,7 +1,7 @@ from datetime import date from typing import cast -from core.redis.cache_service import CacheService +from core.redis.service import RedisService from schemas.watch_history import ( WatchHistoryWithMovieResponse, WatchHistoryWithMovieResponseList, @@ -13,7 +13,7 @@ class WatchHistoryCacheService: def __init__( self, watch_history_service: WatchHistoryService, - cache_service: CacheService, + cache_service: RedisService, ) -> None: self.watch_history_service = watch_history_service self.cache_service = cache_service @@ -22,13 +22,13 @@ async def get_watch_history_by_id( self, watch_history_id: int, ) -> WatchHistoryWithMovieResponse: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "watch_history", watch_history_id=watch_history_id, ) cached_watch_history_response = await self.cache_service.get( key, - WatchHistoryWithMovieResponse, + schema=WatchHistoryWithMovieResponse, ) if cached_watch_history_response is not None: return cast(WatchHistoryWithMovieResponse, cached_watch_history_response) @@ -45,7 +45,7 @@ async def get_watch_history_list( size: int = 10, page: int = 1, ) -> WatchHistoryWithMovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "watch_history_list", user_id=user_id, size=size, @@ -75,7 +75,7 @@ async def get_watch_history_by_date_range( size: int = 10, page: int = 1, ) -> WatchHistoryWithMovieResponseList: - key = CacheService.create_cache_key( + key = RedisService.create_cache_key( "watch_history_list", user_id=user_id, start_date=start_date, @@ -106,7 +106,7 @@ async def get_watch_history_by_date_range( return watch_history_list_response async def count_user_watch_history(self, user_id: int) -> int: - key = CacheService.create_cache_key("watch_history", user_id=user_id) + key = RedisService.create_cache_key("watch_history", user_id=user_id) cached_watch_history_response = await self.cache_service.get(key) if cached_watch_history_response is not None: return cast(int, cached_watch_history_response) @@ -125,12 +125,12 @@ async def delete_watch_history_by_id( user_id, watch_history_id, ) - key = CacheService.create_cache_key("watch_history") + key = RedisService.create_cache_key("watch_history") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) async def delete_user_watch_history(self, user_id: int) -> None: await self.watch_history_service.delete_user_watch_history(user_id) - key = CacheService.create_cache_key("watch_history") + key = RedisService.create_cache_key("watch_history") pattern = key + "*" await self.cache_service.delete_by_pattern(pattern) diff --git a/mediaservice/core/__init__.py b/app/core/celery/__init__.py similarity index 100% rename from mediaservice/core/__init__.py rename to app/core/celery/__init__.py diff --git a/app/core/celery/celery_app.py b/app/core/celery/celery_app.py new file mode 100644 index 0000000..a671d55 --- /dev/null +++ b/app/core/celery/celery_app.py @@ -0,0 +1,26 @@ +from celery import Celery +from celery.schedules import crontab +from packages.celery.constants import Queue, TaskType +from packages.config import settings as package_settings + +from core.config import settings +from core.constants import CELERY_APP_MODULE, CELERY_TASKS_MODULES + +app = Celery( + CELERY_APP_MODULE, + broker=package_settings.rabbitmq.url, + backend=package_settings.redis.url, + include=CELERY_TASKS_MODULES, +) + +app.conf.beat_schedule = { + "notify-inactive-users-with-movie-picks": { + "task": TaskType.create_chain_to_notify_inactive_users.value, + "schedule": crontab( + day_of_week=settings.celery.beat.notify_inactive_users_with_movie_picks.day_of_week, + hour=settings.celery.beat.notify_inactive_users_with_movie_picks.hour, + minute=settings.celery.beat.notify_inactive_users_with_movie_picks.minute, + ), + "options": {"queue": Queue.app}, + }, +} diff --git a/app/core/celery/tasks.py b/app/core/celery/tasks.py new file mode 100644 index 0000000..12d4d21 --- /dev/null +++ b/app/core/celery/tasks.py @@ -0,0 +1,33 @@ +from celery import chain +from packages.celery.constants import Queue, TaskType +from packages.celery.utils import sync_run_coroutine_function + +from .celery_app import app +from .utils import get_data_to_send_inactive_users_movie_selection + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.get_data_to_send_inactive_users_email.value, +) +def get_data_to_send_inactive_users_email() -> dict: # type: ignore[type-arg] + send_inactive_users_email_data = sync_run_coroutine_function( + get_data_to_send_inactive_users_movie_selection(), + ) + return send_inactive_users_email_data.model_dump() # type: ignore[no-any-return] + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.create_chain_to_notify_inactive_users.value, +) +def create_chain_to_notify_inactive_users() -> None: + send_inactive_users_email = app.signature( + TaskType.send_inactive_users_email.value, + queue=Queue.notification_service.value, + ) + task_chain = chain( + get_data_to_send_inactive_users_email.s().set( + queue=Queue.app.value, + ), + send_inactive_users_email, + ) + task_chain.apply_async() diff --git a/app/core/celery/utils.py b/app/core/celery/utils.py new file mode 100644 index 0000000..3cf708f --- /dev/null +++ b/app/core/celery/utils.py @@ -0,0 +1,65 @@ +from asyncio import gather + +from packages.rabbitmq.connection import rabbitmq_connection_startup +from packages.schemas.notification import ( + InactiveUser, + InactiveUserList, + SelectedMovie, + SelectedMovieList, + SendInactiveUsersMovieSelectionData, +) + +from core.constants import INACTIVE_DAYS, SortMonotony, SortType +from core.rabbitmq.utils import get_movie_service, get_user_service +from schemas.movie import MovieFilter + + +async def get_inactive_users(days: int) -> InactiveUserList: + async with get_user_service() as user_service: + inactive_users = await user_service.get_inactive_users(days=days) + inactive_user_list = [ + InactiveUser( + name=user.name, + email=user.email, + ) + for user in inactive_users.user_list + ] + return InactiveUserList(inactive_user_list=inactive_user_list) + + +async def get_movie_selection() -> SelectedMovieList: + await rabbitmq_connection_startup() + async with get_movie_service() as movie_service: + movie_filter = MovieFilter( + sort_by=SortType.date.value, + sorting_direction=SortMonotony.descending.value, + ) + selected_movies = await movie_service.search_movies_with_filters( + movie_filter=movie_filter, + ) + movie_selection = [ + SelectedMovie( + name=movie.name, + genre_name=movie.genre.name, + rating=movie.rating, + source_url=movie.source_url, + release_date=movie.release_date, + ) + for movie in selected_movies.movie_list + ] + return SelectedMovieList( + selected_movie_list=movie_selection, + ) + + +async def get_data_to_send_inactive_users_movie_selection() -> ( + SendInactiveUsersMovieSelectionData +): + inactive_users, movie_selection = await gather( + get_inactive_users(days=INACTIVE_DAYS), + get_movie_selection(), + ) + return SendInactiveUsersMovieSelectionData( + inactive_users=inactive_users, + movie_selection=movie_selection, + ) diff --git a/app/core/config.py b/app/core/config.py index d5537ca..8e47191 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -15,7 +15,7 @@ class DataBaseConfig(BaseModel): echo: bool = False @property - def url_database(self) -> str: + def url(self) -> str: return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.db_name}" @@ -32,6 +32,8 @@ class RedisDataBaseConfig(BaseModel): reviews: int = 4 favorite_movies: int = 5 watch_history: int = 6 + auth: int = 7 + celery_backend: int = 8 class RedisConfig(BaseModel): @@ -46,7 +48,7 @@ class RabbitMQConfig(BaseModel): password: str = "guest" @property - def url_rabbitmq(self) -> str: + def url(self) -> str: return f"amqp://{self.user}:{self.password}@{self.host}:{self.port}/" @@ -57,8 +59,61 @@ class AuthJWTConfig(BaseModel): refresh_token_expire_minutes: int = 30 * 24 * 60 +class RegistrationJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + expire_minutes: int = 15 + + +class TwoFactorJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + expire_minutes: int = 15 + + +class RecoverJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + expire_minutes: int = 15 + + +class ResetPasswordJWTConfig(BaseModel): + secret_key: str = "secret_key" + algorithm: str = "HS256" + expire_minutes: int = 15 + + +class JWTConfig(BaseModel): + auth: AuthJWTConfig = AuthJWTConfig() + registration: RegistrationJWTConfig = RegistrationJWTConfig() + two_factor_auth: TwoFactorJWTConfig = TwoFactorJWTConfig() + recover: RecoverJWTConfig = RecoverJWTConfig() + reset_password: ResetPasswordJWTConfig = ResetPasswordJWTConfig() + + +class ScheduleConfig(BaseModel): + minute: str | int = "*" + hour: str | int = "*" + day_of_week: str | int = "*" + day_of_month: str | int = "*" + month_of_year: str | int = "*" + + +class CeleryBeatConfig(BaseModel): + notify_inactive_users_with_movie_picks: ScheduleConfig = ScheduleConfig( + day_of_week="sun", + hour=16, + minute=0, + ) + + +class CeleryConfig(BaseModel): + beat: CeleryBeatConfig = CeleryBeatConfig() + base_dir: Path = Path(__file__).parent.parent / ".core" / "celery" / "celery_app" + + class MediaServiceConfig(BaseModel): - host: str = "mediaservice" + host: str = "media-service" port: int = 8000 @property @@ -66,20 +121,29 @@ def create_presign_url_endpoint(self) -> str: return f"http://{self.host}:{self.port}/api/v1/presign-url" +class NotificationServiceConfig(BaseModel): + host: str = "notification-service" + port: int = 8000 + + class Settings(BaseSettings): - BASE_DIR: Path = Path(__file__).parent.parent + base_dir: Path = Path(__file__).parent.parent database: DataBaseConfig = DataBaseConfig() redis: RedisConfig = RedisConfig() rabbitmq: RabbitMQConfig = RabbitMQConfig() - auth_jwt: AuthJWTConfig = AuthJWTConfig() + jwt: JWTConfig = JWTConfig() + celery: CeleryConfig = CeleryConfig() http_bearer: HTTPBearer = HTTPBearer() - oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer("/api/v1/auth/login") - mediaservice: MediaServiceConfig = MediaServiceConfig() + oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer( + "/api/v1/auth/login/", + ) + media_service: MediaServiceConfig = MediaServiceConfig() + notification_service: NotificationServiceConfig = NotificationServiceConfig() debug: bool = False model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, - env_file=BASE_DIR / ".env", + env_file=base_dir / ".env", env_nested_delimiter="__", ) diff --git a/app/core/constants.py b/app/core/constants.py index 92556ed..8aa16cb 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -29,11 +29,14 @@ from core.exceptions.user import UserAlreadyExistsError, UserNotFoundError from core.exceptions.watch_history import WatchHistoryNotFoundError +CELERY_APP_MODULE = "core.celery.celery_app" +CELERY_TASKS_MODULES = ["core.celery.tasks"] + BASE_MINIO_URL = "http://localhost:9000" AnyPydanticType = TypeVar("AnyPydanticType", bound=BaseModel) -PrimitiveType = int | str | bool +PrimitiveType = int | float | str | bool class UserRole(StrEnum): @@ -59,11 +62,30 @@ class MethodType(StrEnum): delete = "DELETE" -TOKEN_TYPE: str = "type" -ACCESS_TOKEN_TYPE: str = "access" -REFRESH_TOKEN_TYPE: str = "refresh" +class ConfirmationCodeType(StrEnum): + registration = "registration" + two_factor_auth = "two_factor_auth" + recover_password = "recover" + + +INACTIVE_DAYS = 7 + +ATTEMPT_FIELD = "attempt" +MAX_CONFIRM_CODE_ATTEMPTS = 5 +CONFIRMATION_CODE_LENGTH = 6 + +TOKEN_TYPE_FIELD = "type" +LOGIN_FIELD = "login" +EMAIL_FIELD = "email" + +ACCESS_TOKEN_TYPE = "access" +REFRESH_TOKEN_TYPE = "refresh" +REGISTRATION_TOKEN_TYPE = "registration" +TWO_FACTOR_TOKEN_TYPE = "two_factor" +RECOVER_TOKEN_TYPE = "recover" +RESET_PASSWORD_TOKEN_TYPE = "reset_password" -BEARER_TOKEN_TYPE: str = "Bearer" +BEARER_TOKEN_TYPE = "Bearer" BASE_ERROR = ( NotFoundError diff --git a/app/core/database/connection.py b/app/core/database/connection.py index 4039b74..d7c680c 100644 --- a/app/core/database/connection.py +++ b/app/core/database/connection.py @@ -1,3 +1,4 @@ +from sqlalchemy import NullPool from sqlalchemy.ext.asyncio import ( async_sessionmaker, create_async_engine, @@ -14,8 +15,9 @@ class Base(DeclarativeBase): engine = create_async_engine( - url=settings.database.url_database, + url=settings.database.url, echo=settings.database.echo, + poolclass=NullPool, ) session_factory = async_sessionmaker( diff --git a/app/core/database/init_db.py b/app/core/database/init_db.py index 9c053db..dd11ed8 100644 --- a/app/core/database/init_db.py +++ b/app/core/database/init_db.py @@ -1,4 +1,5 @@ from core.database import session_factory +from core.security.password_utils import hash_password from schemas.user import UserCreate from services import UserService @@ -7,12 +8,13 @@ async def init_admin() -> None: async with session_factory() as session: user_service = UserService(session) if not await user_service.user_login_exists("adminadmin"): + password = "adminadmin" # noqa: S105 create_user_data = UserCreate( surname="admin", name="admin", login="adminadmin", email="admin@admin.gmail.ru", - password="adminadmin", # noqa: S106 + encrypted_password=hash_password(password), ) await user_service.create_user(create_user_data) admin = await user_service.get_user_by_login("adminadmin") diff --git a/app/core/exceptions/confirmation_code.py b/app/core/exceptions/confirmation_code.py new file mode 100644 index 0000000..37891d9 --- /dev/null +++ b/app/core/exceptions/confirmation_code.py @@ -0,0 +1,47 @@ +from pydantic import EmailStr + +from core.exceptions.base import AuthenticationError, NotFoundError + + +class ConfirmationCodeNotFoundError(NotFoundError): + """ + Класс для ошибок, связанных с ненахождением кода подтверждения. + """ + + def __init__(self, detail: str) -> None: + super().__init__(detail) + + +class EmailConfirmationCodeNotFoundError(ConfirmationCodeNotFoundError): + """ + Класс для ошибок, связанных с ненахождением кода подтверждения на почте. + """ + + def __init__(self, email: EmailStr) -> None: + self.email = email + detail = f"The active confirmation code for email {email} does not exist." + super().__init__(detail) + + +class InvalidConfirmationCodeError(AuthenticationError): + """ + Класс для ошибок, связанных с вводом некорректного кода подтверждения. + """ + + def __init__(self, detail: str) -> None: + super().__init__(detail) + + +class InvalidEmailConfirmationCodeError(InvalidConfirmationCodeError): + """ + Класс для ошибок, связанных с некорректным кодом подтверждения, + отправленным на почту. + """ + + def __init__(self, email: EmailStr, confirmation_code: str) -> None: + self.email = email + self.confirmation_code = confirmation_code + detail = ( + f"The confirmation code {confirmation_code} for email {email} is not valid." + ) + super().__init__(detail) diff --git a/app/core/exceptions/user.py b/app/core/exceptions/user.py index 3eb1d1c..58cf6ae 100644 --- a/app/core/exceptions/user.py +++ b/app/core/exceptions/user.py @@ -1,3 +1,5 @@ +from pydantic import EmailStr + from core.exceptions.base import ConflictError, NotFoundError @@ -32,6 +34,17 @@ def __init__(self, login: str) -> None: super().__init__(detail) +class UserEmailNotFoundError(UserNotFoundError): + """ + Класс для ошибок, связанных с ненахождением пользователя с таким логином. + """ + + def __init__(self, email: EmailStr) -> None: + self.email = email + detail = f"User with email = {email} not found." + super().__init__(detail) + + class UserAlreadyExistsError(ConflictError): """ Класс для ошибок, связанных с существованием пользователя diff --git a/app/core/rabbitmq/consumer.py b/app/core/rabbitmq/consumers.py similarity index 96% rename from app/core/rabbitmq/consumer.py rename to app/core/rabbitmq/consumers.py index ef81443..1a33457 100644 --- a/app/core/rabbitmq/consumer.py +++ b/app/core/rabbitmq/consumers.py @@ -2,7 +2,7 @@ from typing import Any from aio_pika import IncomingMessage -from packages.constants import Exchange, ExchangeType, Queue +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message, get_message from cache_services import GenreCacheService, MovieCacheService @@ -58,8 +58,8 @@ async def update_media_url_function(message: IncomingMessage) -> None: inner_service = getattr(service, inner_service_attr_name) rabbitmq_service = inner_service.rabbitmq_service exchange = await rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, ) body = { "object_url": object_url, diff --git a/app/core/rabbitmq/startup.py b/app/core/rabbitmq/startup.py index a76267d..4303947 100644 --- a/app/core/rabbitmq/startup.py +++ b/app/core/rabbitmq/startup.py @@ -1,10 +1,10 @@ from collections.abc import AsyncGenerator -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.connection import rabbitmq_connection_startup +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import get_rabbitmq_service -from core.rabbitmq.consumer import ( +from core.rabbitmq.consumers import ( update_genre_poster_url, update_movie_poster_url, update_movie_source_url, @@ -14,20 +14,20 @@ async def rabbitmq_consumer_queues_startup() -> AsyncGenerator[None]: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.mediaservice.value, - type=ExchangeType.direct.value, + name=Exchange.media_service, + type=ExchangeType.direct, durable=True, ) queue_update_genre_poster_url = await rabbitmq_service.declare_queue( - name=Queue.update_genre_poster_url.value, + name=Queue.update_genre_poster_url, durable=True, ) queue_update_movie_poster_url = await rabbitmq_service.declare_queue( - name=Queue.update_movie_poster_url.value, + name=Queue.update_movie_poster_url, durable=True, ) queue_update_movie_source_url = await rabbitmq_service.declare_queue( - name=Queue.update_movie_source_url.value, + name=Queue.update_movie_source_url, durable=True, ) diff --git a/app/core/rabbitmq/utils.py b/app/core/rabbitmq/utils.py index 12a628c..ca8c91e 100644 --- a/app/core/rabbitmq/utils.py +++ b/app/core/rabbitmq/utils.py @@ -6,13 +6,17 @@ from cache_services import GenreCacheService, MovieCacheService from core.database import session_factory -from core.redis import CacheService, RedisClient +from core.redis import RedisClient, RedisService from dependencies.redis_client import ( - get_redis_client_for_genres, - get_redis_client_for_movies, - get_redis_client_for_watch_history, + get_genre_redis_client as get_genre_redis_client_dependency, ) -from services import GenreService, MovieService +from dependencies.redis_client import ( + get_movie_redis_client as get_movie_redis_client_dependency, +) +from dependencies.redis_client import ( + get_watch_history_redis_client as get_watch_history_redis_client_dependency, +) +from services import GenreService, MovieService, UserService @asynccontextmanager @@ -30,27 +34,27 @@ async def get_genre_service() -> AsyncGenerator[GenreService]: @asynccontextmanager async def get_genre_redis_client() -> AsyncGenerator[RedisClient]: - async for redis_client in get_redis_client_for_genres(): + async for redis_client in get_genre_redis_client_dependency(): yield redis_client @asynccontextmanager async def get_watch_history_redis_client() -> AsyncGenerator[RedisClient]: - async for redis_client in get_redis_client_for_watch_history(): + async for redis_client in get_watch_history_redis_client_dependency(): yield redis_client @asynccontextmanager -async def get_cache_service_for_genres() -> AsyncGenerator[CacheService]: +async def get_genre_redis_service() -> AsyncGenerator[RedisService]: async with get_genre_redis_client() as redis_client: - cache_service = CacheService(redis_client) - yield cache_service + redis_service = RedisService(redis_client) + yield redis_service @asynccontextmanager -async def get_cache_service_for_watch_history() -> AsyncGenerator[CacheService]: +async def get_watch_history_redis_service() -> AsyncGenerator[RedisService]: async with get_watch_history_redis_client() as redis_client: - cache_service = CacheService(redis_client) + cache_service = RedisService(redis_client) yield cache_service @@ -58,7 +62,7 @@ async def get_cache_service_for_watch_history() -> AsyncGenerator[CacheService]: async def get_genre_cache_service() -> AsyncGenerator[GenreCacheService]: async with ( get_genre_service() as genre_service, - get_cache_service_for_genres() as cache_service, + get_genre_redis_service() as cache_service, ): genre_cache_service = GenreCacheService(genre_service, cache_service) yield genre_cache_service @@ -73,14 +77,14 @@ async def get_movie_service() -> AsyncGenerator[MovieService]: @asynccontextmanager async def get_movie_redis_client() -> AsyncGenerator[RedisClient]: - async for redis_client in get_redis_client_for_movies(): + async for redis_client in get_movie_redis_client_dependency(): yield redis_client @asynccontextmanager -async def get_cache_service_for_movies() -> AsyncGenerator[CacheService]: +async def get_movie_redis_service() -> AsyncGenerator[RedisService]: async with get_movie_redis_client() as redis_client: - cache_service = CacheService(redis_client) + cache_service = RedisService(redis_client) yield cache_service @@ -88,8 +92,8 @@ async def get_cache_service_for_movies() -> AsyncGenerator[CacheService]: async def get_movie_cache_service() -> AsyncGenerator[MovieCacheService]: async with ( get_movie_service() as movie_service, - get_cache_service_for_movies() as cache_service_for_movies, - get_cache_service_for_watch_history() as cache_service_for_watch_history, + get_movie_redis_service() as cache_service_for_movies, + get_watch_history_redis_service() as cache_service_for_watch_history, ): movie_cache_service = MovieCacheService( movie_service, @@ -97,3 +101,10 @@ async def get_movie_cache_service() -> AsyncGenerator[MovieCacheService]: cache_service_for_watch_history, ) yield movie_cache_service + + +@asynccontextmanager +async def get_user_service() -> AsyncGenerator[UserService]: + async with get_session() as session: + user_service = UserService(session) + yield user_service diff --git a/app/core/redis/__init__.py b/app/core/redis/__init__.py index b8fe33c..6919045 100644 --- a/app/core/redis/__init__.py +++ b/app/core/redis/__init__.py @@ -1,9 +1,9 @@ -from core.redis.cache_service import CacheService from core.redis.client import RedisClient from core.redis.rate_limiter import RateLimiter +from core.redis.service import RedisService __all__ = ( - "CacheService", "RateLimiter", "RedisClient", + "RedisService", ) diff --git a/app/core/redis/cache_service.py b/app/core/redis/cache_service.py deleted file mode 100644 index 484bfcf..0000000 --- a/app/core/redis/cache_service.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Any - -from core.redis.client import RedisClient - - -class CacheService: - def __init__(self, redis: RedisClient) -> None: - self.redis = redis - - async def get(self, key: str, schema: Any = None) -> Any: - value = await self.redis.get(key) - if value is not None: - return self.convert_string_to_object(value, schema) - return None - - async def set(self, key: str, value: Any, ttl: int = 300) -> None: - encoded_value = self.convert_object_to_string(value) - await self.redis.set(key, encoded_value, ttl) - - async def expire(self, key: str, ttl: int) -> None: - await self.redis.expire(key, ttl) - - async def exists(self, key: str) -> bool: - return await self.redis.exists(key) - - async def delete(self, key: str) -> None: - await self.redis.delete(key) - - async def delete_by_pattern(self, pattern: str) -> None: - await self.redis.delete_by_pattern(pattern) - - @classmethod - def create_cache_key(cls, prefix: str, **kwargs: Any) -> str: - result = [prefix] - for key, value in kwargs.items(): - result.append(f"{key}:{value}") - return ":".join(result) - - @staticmethod - def convert_string_to_object(value: str, schema: Any) -> Any: - if schema is None: - return value - return schema.model_validate_json(value) - - @staticmethod - def convert_object_to_string(value: Any) -> Any: - if isinstance( - value, - (str, int, float, bool), - ): - return value - return value.model_dump_json() diff --git a/app/core/redis/client.py b/app/core/redis/client.py index 48e01a7..6c83a66 100644 --- a/app/core/redis/client.py +++ b/app/core/redis/client.py @@ -38,7 +38,7 @@ async def get(self, key: str) -> str | None: async def exists(self, key: str) -> bool: return cast(bool, await self._redis.exists(key)) - async def set(self, key: str, value: str, expire: int) -> None: + async def set(self, key: str, value: str | int, expire: int) -> None: await self._redis.set( key, value, diff --git a/app/core/redis/service.py b/app/core/redis/service.py new file mode 100644 index 0000000..75372f1 --- /dev/null +++ b/app/core/redis/service.py @@ -0,0 +1,103 @@ +from typing import Any, cast + +from core.constants import AnyPydanticType, PrimitiveType +from core.redis.client import RedisClient + + +class RedisService: + def __init__(self, redis: RedisClient) -> None: + self.redis = redis + + async def get( + self, + key: str, + schema: type[AnyPydanticType] | None = None, + is_integer: bool = False, + is_float: bool = False, + is_boolean: bool = False, + ) -> PrimitiveType | AnyPydanticType | None: + if schema is not None: + return await self._get_schema(key, schema) + + if is_integer: + return await self._get_integer(key) + + if is_float: + return await self._get_float(key) + + if is_boolean: + return await self._get_boolean(key) + + return await self.redis.get(key) + + async def _get_schema( + self, + key: str, + schema: type[AnyPydanticType], + ) -> AnyPydanticType | None: + value = await self.redis.get(key) + if value is not None: + return cast(AnyPydanticType, self.convert_string_to_object(value, schema)) + return None + + async def _get_integer(self, key: str) -> int | None: + value = await self.redis.get(key) + if value is not None: + return int(value) + return None + + async def _get_float(self, key: str) -> float | None: + value = await self.redis.get(key) + if value is not None: + return float(value) + return None + + async def _get_boolean(self, key: str) -> bool | None: + boolean = {"True": True, "False": False} + value = await self.redis.get(key) + if value is not None: + return boolean[value] + return None + + async def set(self, key: str, value: Any, ttl: int = 300) -> None: + encoded_value = self.convert_object_to_string(value) + await self.redis.set(key, encoded_value, ttl) + + async def incr_by(self, key: str, amount: int = 1) -> int: + return await self.redis.incr_by(key, amount) + + async def expire(self, key: str, ttl: int) -> None: + await self.redis.expire(key, ttl) + + async def exists(self, key: str) -> bool: + return await self.redis.exists(key) + + async def delete(self, key: str) -> None: + await self.redis.delete(key) + + async def delete_by_pattern(self, pattern: str) -> None: + await self.redis.delete_by_pattern(pattern) + + @classmethod + def create_cache_key(cls, prefix: str, **kwargs: Any) -> str: + result = [prefix] + for key, value in kwargs.items(): + result.append(f"{key}:{value}") + return ":".join(result) + + @staticmethod + def convert_string_to_object( + value: str | int, schema: type[AnyPydanticType], + ) -> AnyPydanticType | PrimitiveType: + if schema is None: + return value + return schema.model_validate_json(cast(str, value)) + + @staticmethod + def convert_object_to_string(value: Any) -> Any: + if isinstance( + value, + (str, int, float, bool), + ): + return value + return value.model_dump_json() diff --git a/app/core/schema_utils.py b/app/core/schema_utils.py new file mode 100644 index 0000000..d47f9fb --- /dev/null +++ b/app/core/schema_utils.py @@ -0,0 +1,14 @@ +from core.security.password_utils import hash_password +from schemas.user import UserCreate, UserRegistration + + +def convert_registration_to_create_schema( + user_registration_data: UserRegistration, +) -> UserCreate: + user_create_data = user_registration_data.model_dump( + exclude={"confirmation_code", "password"}, + ) + password = user_registration_data.password + encrypted_password = hash_password(password) + user_create_data["encrypted_password"] = encrypted_password + return UserCreate(**user_create_data) diff --git a/mediaservice/core/celery/__init__.py b/app/core/security/jwt/__init__.py similarity index 100% rename from mediaservice/core/celery/__init__.py rename to app/core/security/jwt/__init__.py diff --git a/app/core/security/jwt/token_factory.py b/app/core/security/jwt/token_factory.py new file mode 100644 index 0000000..edd32e6 --- /dev/null +++ b/app/core/security/jwt/token_factory.py @@ -0,0 +1,106 @@ +from core.config import settings +from core.constants import ( + ACCESS_TOKEN_TYPE, + BEARER_TOKEN_TYPE, + RECOVER_TOKEN_TYPE, + REFRESH_TOKEN_TYPE, + REGISTRATION_TOKEN_TYPE, + RESET_PASSWORD_TOKEN_TYPE, + TOKEN_TYPE_FIELD, + TWO_FACTOR_TOKEN_TYPE, +) +from core.security.jwt.utils import ( + create_access_token_payload, + create_recover_token_payload, + create_refresh_token_payload, + create_registration_token_payload, + create_reset_password_token_payload, + create_two_factor_auth_token_payload, + encode_jwt, +) +from schemas.token_info import TokenInfo +from schemas.user import UserRegistration, UserResponse + + +def create_access_token(user: UserResponse) -> str: + payload = create_access_token_payload(user) + payload.update( + {TOKEN_TYPE_FIELD: ACCESS_TOKEN_TYPE}, + ) + return encode_jwt( + payload, + expires_minutes=settings.jwt.auth.access_token_expire_minutes, + ) + + +def create_refresh_token(user: UserResponse) -> str: + payload = create_refresh_token_payload(user) + payload.update( + {TOKEN_TYPE_FIELD: REFRESH_TOKEN_TYPE}, + ) + return encode_jwt( + payload, + expires_minutes=settings.jwt.auth.refresh_token_expire_minutes, + ) + + +def create_auth_token(user: UserResponse) -> TokenInfo: + access_token = create_access_token(user) + refresh_token = create_refresh_token(user) + return TokenInfo( + access_token=access_token, + refresh_token=refresh_token, + token_type=BEARER_TOKEN_TYPE, + ) + + +def create_registration_token(user: UserRegistration) -> str: + payload = create_registration_token_payload(user) + payload.update( + {TOKEN_TYPE_FIELD: REGISTRATION_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.jwt.registration.secret_key, + algorithm=settings.jwt.registration.algorithm, + expires_minutes=settings.jwt.registration.expire_minutes, + ) + + +def create_two_factor_auth_token(user: UserResponse) -> str: + payload = create_two_factor_auth_token_payload(user) + payload.update( + {TOKEN_TYPE_FIELD: TWO_FACTOR_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.jwt.two_factor_auth.secret_key, + algorithm=settings.jwt.two_factor_auth.algorithm, + expires_minutes=settings.jwt.two_factor_auth.expire_minutes, + ) + + +def create_recover_token(user: UserResponse) -> str: + payload = create_recover_token_payload(user) + payload.update( + {TOKEN_TYPE_FIELD: RECOVER_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.jwt.recover.secret_key, + algorithm=settings.jwt.recover.algorithm, + expires_minutes=settings.jwt.recover.expire_minutes, + ) + + +def create_reset_password_token(user: UserResponse) -> str: + payload = create_reset_password_token_payload(user) + payload.update( + {TOKEN_TYPE_FIELD: RESET_PASSWORD_TOKEN_TYPE}, + ) + return encode_jwt( + payload=payload, + secret_key=settings.jwt.reset_password.secret_key, + algorithm=settings.jwt.reset_password.algorithm, + expires_minutes=settings.jwt.reset_password.expire_minutes, + ) diff --git a/app/core/security/jwt/utils.py b/app/core/security/jwt/utils.py new file mode 100644 index 0000000..6b9ddda --- /dev/null +++ b/app/core/security/jwt/utils.py @@ -0,0 +1,101 @@ +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt + +from core.config import settings +from core.constants import ( + EMAIL_FIELD, + LOGIN_FIELD, +) +from schemas.user import UserRegistration, UserResponse + + +def encode_jwt( + payload: dict[str, Any], + secret_key: str = settings.jwt.auth.secret_key, + algorithm: str = settings.jwt.auth.algorithm, + expires_minutes: int = settings.jwt.auth.access_token_expire_minutes, +) -> str: + to_encode = payload.copy() + now = datetime.now(UTC) + expire = now + timedelta(minutes=expires_minutes) + to_encode.update( + iat=now, + exp=expire, + ) + return jwt.encode( + to_encode, + secret_key, + algorithm=algorithm, + ) + + +def decode_jwt( + token: str, + secret_key: str = settings.jwt.auth.secret_key, + algorithm: str = settings.jwt.auth.algorithm, +) -> dict[str, Any]: + return jwt.decode( + token, + secret_key, + algorithms=[algorithm], + ) + + +def create_access_token_payload(user: UserResponse) -> dict[str, str]: + payload = { + "sub": str(user.id), + LOGIN_FIELD: user.login, + EMAIL_FIELD: user.email, + } + return payload + + +def create_refresh_token_payload(user: UserResponse) -> dict[str, str]: + payload = { + "sub": str(user.id), + } + return payload + + +def create_registration_token_payload( + user: UserRegistration, +) -> dict[str, str]: + payload = { + "sub": user.login, + LOGIN_FIELD: user.login, + EMAIL_FIELD: user.email, + } + return payload + + +def create_two_factor_auth_token_payload( + user: UserResponse, +) -> dict[str, str]: + payload = { + "sub": str(user.id), + EMAIL_FIELD: user.email, + } + return payload + + +def create_recover_token_payload( + user: UserResponse, +) -> dict[str, str]: + payload = { + "sub": str(user.id), + EMAIL_FIELD: user.email, + LOGIN_FIELD: user.login, + } + return payload + + +def create_reset_password_token_payload( + user: UserResponse, +) -> dict[str, str]: + payload = { + "sub": str(user.id), + EMAIL_FIELD: user.email, + } + return payload diff --git a/app/core/security/jwt_utils.py b/app/core/security/jwt_utils.py deleted file mode 100644 index 28fa782..0000000 --- a/app/core/security/jwt_utils.py +++ /dev/null @@ -1,78 +0,0 @@ -from datetime import UTC, datetime, timedelta -from typing import Any - -import jwt - -from core.config import settings -from core.constants import ACCESS_TOKEN_TYPE, REFRESH_TOKEN_TYPE, TOKEN_TYPE -from schemas.user import UserResponse - - -def encode_jwt( - payload: dict[str, Any], - secret_key: str = settings.auth_jwt.secret_key, - algorithm: str = settings.auth_jwt.algorithm, - expires_minutes: int = settings.auth_jwt.access_token_expire_minutes, -) -> str: - to_encode = payload.copy() - now = datetime.now(UTC) - expire = now + timedelta(minutes=expires_minutes) - to_encode.update( - iat=now, - exp=expire, - ) - return jwt.encode( - to_encode, - secret_key, - algorithm=algorithm, - ) - - -def decode_jwt( - token: str, - secret_key: str = settings.auth_jwt.secret_key, - algorithm: str = settings.auth_jwt.algorithm, -) -> dict[str, Any]: - return jwt.decode( - token, - secret_key, - algorithms=[algorithm], - ) - - -def create_access_token(user: UserResponse) -> str: - payload = create_user_payload_for_access_token(user) - payload.update( - {TOKEN_TYPE: ACCESS_TOKEN_TYPE}, - ) - return encode_jwt( - payload, - expires_minutes=settings.auth_jwt.access_token_expire_minutes, - ) - - -def create_refresh_token(user: UserResponse) -> str: - payload = create_user_payload_for_refresh_token(user) - payload.update( - {TOKEN_TYPE: REFRESH_TOKEN_TYPE}, - ) - return encode_jwt( - payload, - expires_minutes=settings.auth_jwt.refresh_token_expire_minutes, - ) - - -def create_user_payload_for_access_token(user: UserResponse) -> dict[str, str]: - payload = { - "sub": str(user.id), - "login": user.login, - "email": user.email, - } - return payload - - -def create_user_payload_for_refresh_token(user: UserResponse) -> dict[str, str]: - payload = { - "sub": str(user.id), - } - return payload diff --git a/app/core/security/validators.py b/app/core/security/validators.py index f3a0a1d..2ae57c6 100644 --- a/app/core/security/validators.py +++ b/app/core/security/validators.py @@ -1,11 +1,11 @@ -from core.constants import TOKEN_TYPE +from core.constants import TOKEN_TYPE_FIELD def validate_token_payload( payload: dict[str, str | int], target_token_type: str, ) -> None: - if payload[TOKEN_TYPE] != target_token_type: + if payload[TOKEN_TYPE_FIELD] != target_token_type: type_error_detail: str = "Invalid token type in payload" raise TypeError(type_error_detail) diff --git a/app/dependencies/annotations/cache_services.py b/app/dependencies/annotations/cache_services.py index f29537b..a3fd375 100644 --- a/app/dependencies/annotations/cache_services.py +++ b/app/dependencies/annotations/cache_services.py @@ -10,7 +10,7 @@ UserCacheService, WatchHistoryCacheService, ) -from core.redis import CacheService +from core.redis import RedisService from dependencies.cache_services import ( get_favorite_movie_cache_service, get_genre_cache_service, @@ -19,50 +19,50 @@ get_user_cache_service, get_watch_history_cache_service, ) -from dependencies.caching import ( - get_cache_service_for_favorite_movies, - get_cache_service_for_genres, - get_cache_service_for_movies, - get_cache_service_for_reviews, - get_cache_service_for_users, - get_cache_service_for_watch_history, +from dependencies.redis_services import ( + get_favorite_movie_redis_service, + get_genre_redis_service, + get_movie_redis_service, + get_review_redis_service, + get_user_redis_service, + get_watch_history_redis_service, ) -CacheServiceForGenresDep = Annotated[ - CacheService, - Depends(get_cache_service_for_genres), +GenreRedisServiceDep = Annotated[ + RedisService, + Depends(get_genre_redis_service), ] -CacheServiceForMoviesDep = Annotated[ - CacheService, - Depends(get_cache_service_for_movies), +MovieRedisServiceDep = Annotated[ + RedisService, + Depends(get_movie_redis_service), ] -CacheServiceForFavoriteMoviesDep = Annotated[ - CacheService, +FavoriteMovieRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_favorite_movies, + get_favorite_movie_redis_service, ), ] -CacheServiceForReviewsDep = Annotated[ - CacheService, +ReviewRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_reviews, + get_review_redis_service, ), ] -CacheServiceForUsersDep = Annotated[ - CacheService, +UserRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_users, + get_user_redis_service, ), ] -CacheServiceForWatchHistoryDep = Annotated[ - CacheService, +WatchHistoryRedisServiceDep = Annotated[ + RedisService, Depends( - get_cache_service_for_watch_history, + get_watch_history_redis_service, ), ] diff --git a/app/dependencies/annotations/security.py b/app/dependencies/annotations/security.py index f26a399..69a2ae4 100644 --- a/app/dependencies/annotations/security.py +++ b/app/dependencies/annotations/security.py @@ -5,9 +5,11 @@ from dependencies.auth import ( get_admin_by_access_token, + get_login_data, get_user_by_access_token, get_user_by_refresh_token, ) +from schemas.auth import UserLogin AuthUserByAccessTokenDep = Annotated[ int, @@ -34,3 +36,8 @@ OAuth2PasswordRequestForm, Depends(), ] + +GetLoginDataDep = Annotated[ + UserLogin, + Depends(get_login_data), +] diff --git a/app/dependencies/annotations/services.py b/app/dependencies/annotations/services.py index b610eec..d5f2443 100644 --- a/app/dependencies/annotations/services.py +++ b/app/dependencies/annotations/services.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from dependencies.services import ( + get_auth_service, get_db, get_favorite_movie_service, get_genre_service, @@ -20,6 +21,7 @@ UserService, WatchHistoryService, ) +from services.auth import AuthService DbSessionDep = Annotated[ AsyncSession, @@ -63,6 +65,13 @@ ), ] +AuthServiceDep = Annotated[ + AuthService, + Depends( + get_auth_service, + ), +] + WatchHistoryServiceDep = Annotated[ WatchHistoryService, Depends( diff --git a/app/dependencies/auth.py b/app/dependencies/auth.py index 26ed4ad..f201e57 100644 --- a/app/dependencies/auth.py +++ b/app/dependencies/auth.py @@ -1,13 +1,15 @@ from typing import Annotated, cast from fastapi import Depends +from fastapi.security import OAuth2PasswordRequestForm from core.config import settings from core.constants import ACCESS_TOKEN_TYPE, REFRESH_TOKEN_TYPE from core.exceptions.auth import PermissionDeniedError -from core.security.jwt_utils import decode_jwt +from core.security.jwt.utils import decode_jwt from core.security.validators import validate_token_payload from dependencies.services import get_user_service +from schemas.auth import UserLogin from services import UserService @@ -32,7 +34,7 @@ def get_user_by_access_token( payload=payload, target_token_type=ACCESS_TOKEN_TYPE, ) - user_id: int = cast(int, payload["sub"]) + user_id = cast(int, payload["sub"]) return user_id @@ -46,7 +48,7 @@ def get_user_by_refresh_token( payload=payload, target_token_type=REFRESH_TOKEN_TYPE, ) - user_id: int = cast(int, payload["sub"]) + user_id = cast(int, payload["sub"]) return user_id @@ -64,3 +66,15 @@ async def get_admin_by_access_token( return user_id raise PermissionDeniedError + + +def get_login_data( + oauth2_form: Annotated[ + OAuth2PasswordRequestForm, + Depends(), + ], +) -> UserLogin: + return UserLogin( + login=oauth2_form.username, + password=oauth2_form.password, + ) diff --git a/app/dependencies/cache_services.py b/app/dependencies/cache_services.py index 59b463d..b71f269 100644 --- a/app/dependencies/cache_services.py +++ b/app/dependencies/cache_services.py @@ -11,14 +11,14 @@ UserCacheService, ) from cache_services.watch_history import WatchHistoryCacheService -from core.redis.cache_service import CacheService -from dependencies.caching import ( - get_cache_service_for_favorite_movies, - get_cache_service_for_genres, - get_cache_service_for_movies, - get_cache_service_for_reviews, - get_cache_service_for_users, - get_cache_service_for_watch_history, +from core.redis.service import RedisService +from dependencies.redis_services import ( + get_favorite_movie_redis_service, + get_genre_redis_service, + get_movie_redis_service, + get_review_redis_service, + get_user_redis_service, + get_watch_history_redis_service, ) from dependencies.services import ( get_favorite_movie_service, @@ -43,13 +43,13 @@ async def get_genre_cache_service( GenreService, Depends(get_genre_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_genres), + genre_redis_service: Annotated[ + RedisService, + Depends(get_genre_redis_service), ], ) -> AsyncGenerator[GenreCacheService]: try: - genre_cache_service = GenreCacheService(genre_service, cache_service) + genre_cache_service = GenreCacheService(genre_service, genre_redis_service) yield genre_cache_service finally: """ @@ -62,20 +62,20 @@ async def get_movie_cache_service( MovieService, Depends(get_movie_service), ], - cache_service_for_movie: Annotated[ - CacheService, - Depends(get_cache_service_for_movies), + movie_redis_service: Annotated[ + RedisService, + Depends(get_movie_redis_service), ], - cache_service_for_watch_history: Annotated[ - CacheService, - Depends(get_cache_service_for_watch_history), + watch_history_redis_service: Annotated[ + RedisService, + Depends(get_watch_history_redis_service), ], ) -> AsyncGenerator[MovieCacheService]: try: movie_cache_service = MovieCacheService( movie_service, - cache_service_for_movie, - cache_service_for_watch_history, + movie_redis_service, + watch_history_redis_service, ) yield movie_cache_service finally: @@ -89,15 +89,15 @@ async def get_favorite_movie_cache_service( FavoriteMovieService, Depends(get_favorite_movie_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_favorite_movies), + favorite_movie_redis_service: Annotated[ + RedisService, + Depends(get_favorite_movie_redis_service), ], ) -> AsyncGenerator[FavoriteMovieCacheService]: try: favorite_movie_cache_service = FavoriteMovieCacheService( favorite_movie_service, - cache_service, + favorite_movie_redis_service, ) yield favorite_movie_cache_service finally: @@ -111,13 +111,13 @@ async def get_review_cache_service( ReviewService, Depends(get_review_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_reviews), + review_redis_service: Annotated[ + RedisService, + Depends(get_review_redis_service), ], ) -> AsyncGenerator[ReviewCacheService]: try: - review_cache_service = ReviewCacheService(review_service, cache_service) + review_cache_service = ReviewCacheService(review_service, review_redis_service) yield review_cache_service finally: """ @@ -130,15 +130,15 @@ async def get_watch_history_cache_service( WatchHistoryService, Depends(get_watch_history_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_watch_history), + watch_history_redis_service: Annotated[ + RedisService, + Depends(get_watch_history_redis_service), ], ) -> AsyncGenerator[WatchHistoryCacheService]: try: watch_history_cache_service = WatchHistoryCacheService( watch_history_service, - cache_service, + watch_history_redis_service, ) yield watch_history_cache_service finally: @@ -152,13 +152,13 @@ async def get_user_cache_service( UserService, Depends(get_user_service), ], - cache_service: Annotated[ - CacheService, - Depends(get_cache_service_for_users), + user_redis_service: Annotated[ + RedisService, + Depends(get_user_redis_service), ], ) -> AsyncGenerator[UserCacheService]: try: - user_cache_service = UserCacheService(user_service, cache_service) + user_cache_service = UserCacheService(user_service, user_redis_service) yield user_cache_service finally: """ diff --git a/app/dependencies/caching.py b/app/dependencies/caching.py deleted file mode 100644 index d3ed2fc..0000000 --- a/app/dependencies/caching.py +++ /dev/null @@ -1,57 +0,0 @@ -from collections.abc import AsyncGenerator, Callable -from typing import Annotated - -from fastapi import Depends - -from core.redis import CacheService, RedisClient -from dependencies.redis_client import ( - get_redis_client_for_favorite_movies, - get_redis_client_for_genres, - get_redis_client_for_movies, - get_redis_client_for_reviews, - get_redis_client_for_users, - get_redis_client_for_watch_history, -) - - -def cache_service_factory( - redis_dependency: Callable[[], AsyncGenerator[RedisClient]], -) -> Callable[ - [RedisClient], - AsyncGenerator[CacheService], -]: - async def dependency( - redis: Annotated[ - RedisClient, - Depends(redis_dependency), - ], - ) -> AsyncGenerator[CacheService]: - try: - cache_service = CacheService(redis) - yield cache_service - finally: - """ - Действия после view. - """ - - return dependency - - -get_cache_service_for_genres = cache_service_factory( - get_redis_client_for_genres, -) -get_cache_service_for_movies = cache_service_factory( - get_redis_client_for_movies, -) -get_cache_service_for_reviews = cache_service_factory( - get_redis_client_for_reviews, -) -get_cache_service_for_favorite_movies = cache_service_factory( - get_redis_client_for_favorite_movies, -) -get_cache_service_for_watch_history = cache_service_factory( - get_redis_client_for_watch_history, -) -get_cache_service_for_users = cache_service_factory( - get_redis_client_for_users, -) diff --git a/app/dependencies/rate_limiter.py b/app/dependencies/rate_limiter.py index 69d60ad..3317afc 100644 --- a/app/dependencies/rate_limiter.py +++ b/app/dependencies/rate_limiter.py @@ -7,7 +7,7 @@ from core.exceptions.base import TooManyRequestsError from core.redis.client import RedisClient from core.redis.rate_limiter import RateLimiter -from dependencies.redis_client import get_redis_client_for_rate_limiter +from dependencies.redis_client import get_rate_limiter_redis_client def rate_limit_dependency_factory( @@ -40,7 +40,7 @@ async def dependency( async def get_rate_limiter( redis_client: Annotated[ RedisClient, - Depends(get_redis_client_for_rate_limiter), + Depends(get_rate_limiter_redis_client), ], ) -> AsyncGenerator[RateLimiter]: try: diff --git a/app/dependencies/redis_client.py b/app/dependencies/redis_client.py index 19949cf..93466bd 100644 --- a/app/dependencies/redis_client.py +++ b/app/dependencies/redis_client.py @@ -22,30 +22,34 @@ async def get_redis_client() -> AsyncGenerator[RedisClient]: return get_redis_client -get_redis_client_for_rate_limiter = redis_client_factory( +get_rate_limiter_redis_client = redis_client_factory( db=settings.redis.db.rate_limiter, ) -get_redis_client_for_genres = redis_client_factory( +get_genre_redis_client = redis_client_factory( db=settings.redis.db.genres, ) -get_redis_client_for_movies = redis_client_factory( +get_movie_redis_client = redis_client_factory( db=settings.redis.db.movies, ) -get_redis_client_for_users = redis_client_factory( +get_user_redis_client = redis_client_factory( db=settings.redis.db.users, ) -get_redis_client_for_reviews = redis_client_factory( +get_review_redis_client = redis_client_factory( db=settings.redis.db.reviews, ) -get_redis_client_for_favorite_movies = redis_client_factory( +get_favorite_movie_redis_client = redis_client_factory( db=settings.redis.db.favorite_movies, ) -get_redis_client_for_watch_history = redis_client_factory( +get_watch_history_redis_client = redis_client_factory( db=settings.redis.db.watch_history, ) + +get_auth_redis_client = redis_client_factory( + db=settings.redis.db.auth, +) diff --git a/app/dependencies/redis_services.py b/app/dependencies/redis_services.py new file mode 100644 index 0000000..ffe33d3 --- /dev/null +++ b/app/dependencies/redis_services.py @@ -0,0 +1,61 @@ +from collections.abc import AsyncGenerator, Callable +from typing import Annotated + +from fastapi import Depends + +from core.redis import RedisClient, RedisService +from dependencies.redis_client import ( + get_auth_redis_client, + get_favorite_movie_redis_client, + get_genre_redis_client, + get_movie_redis_client, + get_review_redis_client, + get_user_redis_client, + get_watch_history_redis_client, +) + + +def redis_service_factory( + redis_dependency: Callable[[], AsyncGenerator[RedisClient]], +) -> Callable[ + [RedisClient], + AsyncGenerator[RedisService], +]: + async def dependency( + redis: Annotated[ + RedisClient, + Depends(redis_dependency), + ], + ) -> AsyncGenerator[RedisService]: + try: + redis_service = RedisService(redis) + yield redis_service + finally: + """ + Действия после view. + """ + + return dependency + + +get_genre_redis_service = redis_service_factory( + get_genre_redis_client, +) +get_movie_redis_service = redis_service_factory( + get_movie_redis_client, +) +get_review_redis_service = redis_service_factory( + get_review_redis_client, +) +get_favorite_movie_redis_service = redis_service_factory( + get_favorite_movie_redis_client, +) +get_watch_history_redis_service = redis_service_factory( + get_watch_history_redis_client, +) +get_user_redis_service = redis_service_factory( + get_user_redis_client, +) +get_auth_redis_service = redis_service_factory( + get_auth_redis_client, +) diff --git a/app/dependencies/services.py b/app/dependencies/services.py index 298c201..88b4092 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -3,16 +3,36 @@ from fastapi import Depends from httpx import AsyncClient -from packages.rabbitmq import RabbitMQService, get_rabbit_mq_service +from packages.rabbitmq import RabbitMQService, get_rabbitmq_service from sqlalchemy.ext.asyncio import AsyncSession from core.database.connection import session_factory +from core.redis import RedisService +from dependencies.redis_services import get_auth_redis_service from services import GenreService, MovieService, ReviewService, UserService +from services.auth import AuthService from services.favorite_movie import FavoriteMovieService from services.http_request import HttpRequestService from services.watch_history import WatchHistoryService +async def get_http_request_client() -> AsyncGenerator[AsyncClient]: + async with AsyncClient() as client: + yield client + + +async def get_http_request_service( + http_request_client: Annotated[ + AsyncClient, + Depends(get_http_request_client), + ], +) -> AsyncGenerator[HttpRequestService]: + http_request_service = HttpRequestService( + http_request_client=http_request_client, + ) + yield http_request_service + + async def get_db() -> AsyncGenerator[AsyncSession]: async with session_factory() as db: yield db @@ -25,7 +45,7 @@ async def get_genre_service( ], rabbitmq_service: Annotated[ RabbitMQService, - Depends(get_rabbit_mq_service), + Depends(get_rabbitmq_service), ], ) -> AsyncGenerator[GenreService]: try: @@ -44,7 +64,7 @@ async def get_movie_service( ], rabbitmq_service: Annotated[ RabbitMQService, - Depends(get_rabbit_mq_service), + Depends(get_rabbitmq_service), ], ) -> AsyncGenerator[MovieService]: try: @@ -86,6 +106,25 @@ async def get_user_service( """ +async def get_auth_service( + user_service: Annotated[ + UserService, + Depends(get_user_service), + ], + auth_redis_service: Annotated[ + RedisService, + Depends(get_auth_redis_service), + ], +) -> AsyncGenerator[AuthService]: + try: + auth_service = AuthService(user_service, auth_redis_service) + yield auth_service + finally: + """ + Действия после view. + """ + + async def get_favorite_movie_service( session: Annotated[ AsyncSession, @@ -114,20 +153,3 @@ async def get_watch_history_service( """ Действия после view. """ - - -async def get_http_request_client() -> AsyncGenerator[AsyncClient]: - async with AsyncClient() as client: - yield client - - -async def get_http_request_service( - http_request_client: Annotated[ - AsyncClient, - Depends(get_http_request_client), - ], -) -> AsyncGenerator[HttpRequestService]: - http_request_service = HttpRequestService( - http_request_client=http_request_client, - ) - yield http_request_service diff --git a/app/repositories/user.py b/app/repositories/user.py index 502390d..2791263 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -1,8 +1,9 @@ -from sqlalchemy import delete, select +from pydantic import EmailStr +from sqlalchemy import Date, and_, cast, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from core.constants import UserRole -from models import User +from models import User, WatchHistory from schemas.user import ( UserCreate, UserPartialUpdate, @@ -30,7 +31,7 @@ async def get_user_by_login(self, login: str) -> User | None: result = await self.session.execute(stmt) return result.scalars().first() - async def get_user_by_email(self, email: str) -> User | None: + async def get_user_by_email(self, email: EmailStr) -> User | None: stmt = select(User).where(User.email == email) result = await self.session.execute(stmt) return result.scalars().first() @@ -51,15 +52,38 @@ async def get_all_users( return list(result.scalars().all()) async def create_user(self, create_user_data: UserCreate) -> User: - user = User( - **create_user_data.model_dump(exclude={"password"}), - encrypted_password=create_user_data.password, - ) + user = User(**create_user_data.model_dump()) self.session.add(user) await self.session.commit() await self.session.refresh(user) return user + async def get_inactive_users(self, days: int) -> list[User]: + get_users_never_watch_movies_stmt = ( + select(User) + .outerjoin(WatchHistory, User.id == WatchHistory.user_id) + .where( + and_( + WatchHistory.id.is_(None), + func.current_date() - cast(User.registration_date, Date) >= days, + ), + ) + ) + get_users_watch_movies_a_long_time_ago_stmt = ( + select(User) + .join(WatchHistory, User.id == WatchHistory.user_id) + .group_by(User.id) + .having( + func.current_date() - cast(func.max(WatchHistory.watched_at), Date) + >= days, + ) + ) + result_stmt = get_users_never_watch_movies_stmt.union( + get_users_watch_movies_a_long_time_ago_stmt, + ) + result = await self.session.execute(result_stmt) + return list(result.all()) # type: ignore[arg-type] + async def make_admin(self, user_id: int) -> bool: user = await self.get_user_by_id(user_id) if user is not None: diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3572238..1deafb5 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr -from schemas.user import LoginConstraint, PasswordConstraint +from schemas.constraints.auth import ConfirmationCodeConstraint +from schemas.constraints.user import LoginConstraint, PasswordConstraint class UserLogin(BaseModel): @@ -10,3 +11,38 @@ class UserLogin(BaseModel): login: LoginConstraint password: PasswordConstraint + + +class SendConfirmationCodeRequest(BaseModel): + """ + Модель для отправки кода подтверждения на почту. + """ + + token: str + + +class VerifyUserEmail(BaseModel): + """ + Модель для подтверждения почты пользователя. + """ + + token: str + confirmation_code: ConfirmationCodeConstraint + + +class RecoverAccountRequest(BaseModel): + """ + Модель для получения токена для восстановления доступа к аккаунту. + """ + + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + """ + Модель для смены пароля по токену. + """ + + reset_password_token: str + password: PasswordConstraint + password_confirmation: PasswordConstraint diff --git a/mediaservice/core/rabbitmq/__init__.py b/app/schemas/constraints/__init__.py similarity index 100% rename from mediaservice/core/rabbitmq/__init__.py rename to app/schemas/constraints/__init__.py diff --git a/app/schemas/constraints/auth.py b/app/schemas/constraints/auth.py new file mode 100644 index 0000000..80749eb --- /dev/null +++ b/app/schemas/constraints/auth.py @@ -0,0 +1,13 @@ +from typing import Annotated + +from annotated_types import Len + +from core.constants import CONFIRMATION_CODE_LENGTH + +ConfirmationCodeConstraint = Annotated[ + str, + Len( + min_length=CONFIRMATION_CODE_LENGTH, + max_length=CONFIRMATION_CODE_LENGTH, + ), +] diff --git a/app/schemas/constraints/genre.py b/app/schemas/constraints/genre.py new file mode 100644 index 0000000..223e205 --- /dev/null +++ b/app/schemas/constraints/genre.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from annotated_types import Len, MaxLen + +from core.constants import ( + GENRE_DESCRIPTION_MAX_LENGTH, + GENRE_NAME_MAX_LENGTH, + GENRE_NAME_MIN_LENGTH, +) + +NameConstraint = Annotated[ + str, + Len( + min_length=GENRE_NAME_MIN_LENGTH, + max_length=GENRE_NAME_MAX_LENGTH, + ), +] +DescriptionConstraint = Annotated[ + str, + MaxLen(max_length=GENRE_DESCRIPTION_MAX_LENGTH), +] diff --git a/app/schemas/constraints/movie.py b/app/schemas/constraints/movie.py new file mode 100644 index 0000000..387fdc5 --- /dev/null +++ b/app/schemas/constraints/movie.py @@ -0,0 +1,31 @@ +from typing import Annotated + +from annotated_types import Len, MaxLen +from pydantic import Field + +from core.constants import ( + MOVIE_DESCRIPTION_MAX_LENGTH, + MOVIE_NAME_MAX_LENGTH, + MOVIE_NAME_MIN_LENGTH, + MOVIE_RATING_MAX_VALUE, + MOVIE_RATING_MIN_VALUE, +) + +NameConstraint = Annotated[ + str, + Len( + min_length=MOVIE_NAME_MIN_LENGTH, + max_length=MOVIE_NAME_MAX_LENGTH, + ), +] +DescriptionConstraint = Annotated[ + str, + MaxLen(max_length=MOVIE_DESCRIPTION_MAX_LENGTH), +] +RatingConstraint = Annotated[ + float, + Field( + ge=MOVIE_RATING_MIN_VALUE, + le=MOVIE_RATING_MAX_VALUE, + ), +] diff --git a/app/schemas/constraints/review.py b/app/schemas/constraints/review.py new file mode 100644 index 0000000..df8ea58 --- /dev/null +++ b/app/schemas/constraints/review.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from annotated_types import MaxLen +from pydantic import Field + +from core.constants import ( + REVIEW_RATING_MAX_VALUE, + REVIEW_RATING_MIN_VALUE, + REVIEW_TEXT_MAX_LENGTH, +) + +ReviewTextConstraint = Annotated[ + str, + MaxLen(max_length=REVIEW_TEXT_MAX_LENGTH), +] +RatingConstraint = Annotated[ + int, + Field( + ge=REVIEW_RATING_MIN_VALUE, + le=REVIEW_RATING_MAX_VALUE, + ), +] diff --git a/app/schemas/constraints/user.py b/app/schemas/constraints/user.py new file mode 100644 index 0000000..2a54088 --- /dev/null +++ b/app/schemas/constraints/user.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from annotated_types import Len +from pydantic import EmailStr + +from core.constants import ( + USER_EMAIL_MAX_LENGTH, + USER_EMAIL_MIN_LENGTH, + USER_ENCRYPTED_PASSWORD_MAX_LENGTH, + USER_LOGIN_MAX_LENGTH, + USER_LOGIN_MIN_LENGTH, + USER_NAME_MAX_LENGTH, + USER_NAME_MIN_LENGTH, + USER_PASSWORD_MAX_LENGTH, + USER_PASSWORD_MIN_LENGTH, + USER_SURNAME_MAX_LENGTH, + USER_SURNAME_MIN_LENGTH, +) + +SurnameConstraint = Annotated[ + str, + Len( + min_length=USER_SURNAME_MIN_LENGTH, + max_length=USER_SURNAME_MAX_LENGTH, + ), +] +NameConstraint = Annotated[ + str, + Len( + min_length=USER_NAME_MIN_LENGTH, + max_length=USER_NAME_MAX_LENGTH, + ), +] +LoginConstraint = Annotated[ + str, + Len( + min_length=USER_LOGIN_MIN_LENGTH, + max_length=USER_LOGIN_MAX_LENGTH, + ), +] +EmailConstraint = Annotated[ + EmailStr, + Len( + min_length=USER_EMAIL_MIN_LENGTH, + max_length=USER_EMAIL_MAX_LENGTH, + ), +] +PasswordConstraint = Annotated[ + str, + Len( + min_length=USER_PASSWORD_MIN_LENGTH, + max_length=USER_PASSWORD_MAX_LENGTH, + ), +] +EncryptedPasswordConstraint = Annotated[ + str, + Len( + max_length=USER_ENCRYPTED_PASSWORD_MAX_LENGTH, + ), +] diff --git a/app/schemas/genre.py b/app/schemas/genre.py index fcf233b..805c4d9 100644 --- a/app/schemas/genre.py +++ b/app/schemas/genre.py @@ -1,26 +1,8 @@ -from typing import Annotated, ClassVar +from typing import ClassVar -from annotated_types import Len, MaxLen from pydantic import BaseModel, ConfigDict -from core.constants import ( - GENRE_DESCRIPTION_MAX_LENGTH, - GENRE_NAME_MAX_LENGTH, - GENRE_NAME_MIN_LENGTH, -) - -NameConstraint = Annotated[ - str, - Len( - min_length=GENRE_NAME_MIN_LENGTH, - max_length=GENRE_NAME_MAX_LENGTH, - ), -] - -DescriptionConstraint = Annotated[ - str, - MaxLen(max_length=GENRE_DESCRIPTION_MAX_LENGTH), -] +from schemas.constraints.genre import DescriptionConstraint, NameConstraint class GenreBase(BaseModel): diff --git a/app/schemas/movie.py b/app/schemas/movie.py index 18b5d77..1ff20b3 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -1,41 +1,19 @@ from datetime import date -from typing import Annotated, ClassVar +from typing import ClassVar -from annotated_types import Len, MaxLen -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from core.constants import ( - MOVIE_DESCRIPTION_MAX_LENGTH, - MOVIE_NAME_MAX_LENGTH, - MOVIE_NAME_MIN_LENGTH, - MOVIE_RATING_MAX_VALUE, - MOVIE_RATING_MIN_VALUE, SortMonotony, SortType, ) +from schemas.constraints.movie import ( + DescriptionConstraint, + NameConstraint, + RatingConstraint, +) from schemas.genre import GenreResponse -NameConstraint = Annotated[ - str, - Len( - min_length=MOVIE_NAME_MIN_LENGTH, - max_length=MOVIE_NAME_MAX_LENGTH, - ), -] - -DescriptionConstraint = Annotated[ - str, - MaxLen(max_length=MOVIE_DESCRIPTION_MAX_LENGTH), -] - -RatingConstraint = Annotated[ - float, - Field( - ge=MOVIE_RATING_MIN_VALUE, - le=MOVIE_RATING_MAX_VALUE, - ), -] - class MovieBase(BaseModel): """ diff --git a/app/schemas/review.py b/app/schemas/review.py index 1681313..2d9e426 100644 --- a/app/schemas/review.py +++ b/app/schemas/review.py @@ -1,29 +1,12 @@ from datetime import datetime -from typing import Annotated, ClassVar +from typing import ClassVar -from annotated_types import MaxLen -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict -from core.constants import ( - REVIEW_RATING_MAX_VALUE, - REVIEW_RATING_MIN_VALUE, - REVIEW_TEXT_MAX_LENGTH, -) +from schemas.constraints.review import RatingConstraint, ReviewTextConstraint from schemas.movie import MovieResponse from schemas.user import UserResponse -ReviewTextConstraint = Annotated[ - str, - MaxLen(max_length=REVIEW_TEXT_MAX_LENGTH), -] -RatingConstraint = Annotated[ - int, - Field( - ge=REVIEW_RATING_MIN_VALUE, - le=REVIEW_RATING_MAX_VALUE, - ), -] - class ReviewBase(BaseModel): """ diff --git a/app/schemas/token_info.py b/app/schemas/token_info.py index 3a93353..1d18890 100644 --- a/app/schemas/token_info.py +++ b/app/schemas/token_info.py @@ -11,3 +11,14 @@ class TokenInfo(BaseModel): access_token: str refresh_token: str | None = None token_type: str = BEARER_TOKEN_TYPE + + +class TemporaryTokenInfo(BaseModel): + """ + Модель для вывода информации о токенах, + предназначенных для доступа к + отправке кодов подтверждения на почту. + """ + + token: str + token_type: str = BEARER_TOKEN_TYPE diff --git a/app/schemas/user.py b/app/schemas/user.py index 91e2b61..ce2320b 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,61 +1,16 @@ from datetime import datetime -from typing import Annotated, ClassVar - -from annotated_types import Len -from pydantic import BaseModel, ConfigDict, EmailStr - -from core.constants import ( - USER_EMAIL_MAX_LENGTH, - USER_EMAIL_MIN_LENGTH, - USER_LOGIN_MAX_LENGTH, - USER_LOGIN_MIN_LENGTH, - USER_NAME_MAX_LENGTH, - USER_NAME_MIN_LENGTH, - USER_PASSWORD_MAX_LENGTH, - USER_PASSWORD_MIN_LENGTH, - USER_SURNAME_MAX_LENGTH, - USER_SURNAME_MIN_LENGTH, -) +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict -SurnameConstraint = Annotated[ - str, - Len( - min_length=USER_SURNAME_MIN_LENGTH, - max_length=USER_SURNAME_MAX_LENGTH, - ), -] - -NameConstraint = Annotated[ - str, - Len( - min_length=USER_NAME_MIN_LENGTH, - max_length=USER_NAME_MAX_LENGTH, - ), -] - -LoginConstraint = Annotated[ - str, - Len( - min_length=USER_LOGIN_MIN_LENGTH, - max_length=USER_LOGIN_MAX_LENGTH, - ), -] - -EmailConstraint = Annotated[ - EmailStr, - Len( - min_length=USER_EMAIL_MIN_LENGTH, - max_length=USER_EMAIL_MAX_LENGTH, - ), -] - -PasswordConstraint = Annotated[ - str, - Len( - min_length=USER_PASSWORD_MIN_LENGTH, - max_length=USER_PASSWORD_MAX_LENGTH, - ), -] +from schemas.constraints.user import ( + EmailConstraint, + EncryptedPasswordConstraint, + LoginConstraint, + NameConstraint, + PasswordConstraint, + SurnameConstraint, +) class UserBase(BaseModel): @@ -75,6 +30,14 @@ class UserCreate(UserBase): Модель для создания пользователя. """ + encrypted_password: EncryptedPasswordConstraint + + +class UserRegistration(UserBase): + """ + Модель для регистрирования пользователя. + """ + password: PasswordConstraint diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..c3c1920 --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,364 @@ +import random +from typing import cast + +from packages.celery.constants import Queue, TaskType +from pydantic import EmailStr + +from core.celery.celery_app import app +from core.config import settings +from core.constants import ( + ATTEMPT_FIELD, + BEARER_TOKEN_TYPE, + EMAIL_FIELD, + LOGIN_FIELD, + MAX_CONFIRM_CODE_ATTEMPTS, + ConfirmationCodeType, +) +from core.exceptions.auth import InvalidPasswordError +from core.exceptions.confirmation_code import ( + EmailConfirmationCodeNotFoundError, + InvalidEmailConfirmationCodeError, +) +from core.exceptions.user import ( + UserEmailAlreadyExistsError, + UserLoginAlreadyExistsError, +) +from core.redis import RedisService +from core.schema_utils import convert_registration_to_create_schema +from core.security.jwt.token_factory import ( + create_access_token, + create_auth_token, + create_recover_token, + create_registration_token, + create_reset_password_token, + create_two_factor_auth_token, +) +from core.security.jwt.utils import decode_jwt +from core.security.password_utils import verify_password +from schemas.auth import ( + RecoverAccountRequest, + ResetPasswordRequest, + SendConfirmationCodeRequest, + UserLogin, + VerifyUserEmail, +) +from schemas.token_info import TemporaryTokenInfo, TokenInfo +from schemas.user import UserCreate, UserPartialUpdate, UserRegistration +from services import UserService + + +class AuthService: + def __init__( + self, + user_service: UserService, + auth_redis_service: RedisService, + ) -> None: + self.user_service = user_service + self.auth_redis_service = auth_redis_service + + async def register_user( + self, + user_registration_data: UserRegistration, + ) -> TemporaryTokenInfo: + if await self.user_service.user_login_exists(user_registration_data.login): + raise UserLoginAlreadyExistsError(user_registration_data.login) + + if await self.user_service.user_email_exists(user_registration_data.email): + raise UserEmailAlreadyExistsError(user_registration_data.email) + user_create_data = convert_registration_to_create_schema( + user_registration_data, + ) + token = create_registration_token(user_registration_data) + ttl_seconds = settings.jwt.registration.expire_minutes * 60 + key_list = [ConfirmationCodeType.registration.value, token] + key = ":".join(key_list) + await self.auth_redis_service.set( + key=key, + value=user_create_data, + ttl=ttl_seconds, + ) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_register_confirmation_code(send_confirmation_code_request) + return TemporaryTokenInfo( + token=token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def send_register_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token=token, + secret_key=settings.jwt.registration.secret_key, + algorithm=settings.jwt.registration.algorithm, + ) + email = payload[EMAIL_FIELD] + confirmation_code = await self.create_confirmation_code( + email, + confirmation_code_type=ConfirmationCodeType.registration, + ) + app.send_task( + name=TaskType.send_registration_confirmation_code_email.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification_service.value, + ) + + async def verify_register_user( + self, + verify_register_user_data: VerifyUserEmail, + ) -> TokenInfo: + token = verify_register_user_data.token + confirmation_code = verify_register_user_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.jwt.registration.secret_key, + algorithm=settings.jwt.registration.algorithm, + ) + email = payload[EMAIL_FIELD] + await self.verify_confirmation_code( + email, + confirmation_code, + confirmation_code_type=ConfirmationCodeType.registration, + ) + key_list = [ConfirmationCodeType.registration.value, token] + key = ":".join(key_list) + user_create_data = await self.auth_redis_service.get( + key=key, + schema=UserCreate, + ) + # user_create_data = UserCreate.model_validate_json(user_create_data_json) + user = await self.user_service.create_user(cast(UserCreate, user_create_data)) + await self.auth_redis_service.delete(key) + return create_auth_token(user) + + async def verify_login_data(self, login_data: UserLogin) -> None: + encrypted_password = await self.user_service.get_user_encrypted_password( + login_data.login, + ) + if not verify_password(login_data.password, encrypted_password): + raise InvalidPasswordError + + async def authenticate_user(self, login_data: UserLogin) -> TemporaryTokenInfo: + await self.verify_login_data(login_data) + user = await self.user_service.get_user_by_login(login_data.login) + token = create_two_factor_auth_token(user) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_authenticate_confirmation_code(send_confirmation_code_request) + return TemporaryTokenInfo( + token=token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def send_authenticate_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token=token, + secret_key=settings.jwt.two_factor_auth.secret_key, + algorithm=settings.jwt.two_factor_auth.algorithm, + ) + email = payload[EMAIL_FIELD] + confirmation_code = await self.create_confirmation_code( + email, + confirmation_code_type=ConfirmationCodeType.two_factor_auth, + ) + app.send_task( + name=TaskType.send_auth_confirmation_code_email.value, + args=[ + email, + confirmation_code, + ], + queue=Queue.notification_service.value, + ) + + async def verify_authenticate_user( + self, + verify_authenticate_user_data: VerifyUserEmail, + ) -> TokenInfo: + token = verify_authenticate_user_data.token + confirmation_code = verify_authenticate_user_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.jwt.two_factor_auth.secret_key, + algorithm=settings.jwt.two_factor_auth.algorithm, + ) + email = payload[EMAIL_FIELD] + await self.verify_confirmation_code( + email, + confirmation_code, + confirmation_code_type=ConfirmationCodeType.two_factor_auth, + ) + user = await self.user_service.get_user_by_email(email) + return create_auth_token(user) + + async def get_confirmation_code( + self, + email: EmailStr, + confirmation_code_type: ConfirmationCodeType, + ) -> str: + key_list = [confirmation_code_type, email] + key = ":".join(key_list) + confirmation_code = await self.auth_redis_service.get(key) + if confirmation_code is None: + raise EmailConfirmationCodeNotFoundError( + email=email, + ) + + return cast(str, confirmation_code) + + async def verify_confirmation_code( + self, + email: EmailStr, + confirmation_code: str, + confirmation_code_type: ConfirmationCodeType, + ) -> None: + sent_confirmation_code = await self.get_confirmation_code( + email, + confirmation_code_type, + ) + + key_list = [confirmation_code_type, email] + key = ":".join(key_list) + attempt_counter_key_list = [key, ATTEMPT_FIELD] + attempt_counter_key = ":".join(attempt_counter_key_list) + await self.auth_redis_service.incr_by(attempt_counter_key) + count_confirm_code_attempts = await self.auth_redis_service.get( + attempt_counter_key, + is_integer=True, + ) + if count_confirm_code_attempts == MAX_CONFIRM_CODE_ATTEMPTS: + await self.auth_redis_service.delete(key) + await self.auth_redis_service.delete(attempt_counter_key) + + if confirmation_code != sent_confirmation_code: + raise InvalidEmailConfirmationCodeError( + email=email, + confirmation_code=confirmation_code, + ) + + async def create_confirmation_code( + self, + email: EmailStr, + confirmation_code_type: ConfirmationCodeType, + ) -> str: + confirmation_code = "".join( + [str(random.randint(0, 9)) for _ in range(6)], # noqa: S311 + ) + key_list = [confirmation_code_type.value, email] + key = ":".join(key_list) + attempt_counter_key_list = [key, ATTEMPT_FIELD] + attempt_counter_key = ":".join(attempt_counter_key_list) + await self.auth_redis_service.set( + key=key, + value=confirmation_code, + ttl=60, + ) + await self.auth_redis_service.set( + key=attempt_counter_key, + value=0, + ttl=60, + ) + return confirmation_code + + async def recover_account( + self, + recover_account_data: RecoverAccountRequest, + ) -> TemporaryTokenInfo: + email = recover_account_data.email + user = await self.user_service.get_user_by_email(email) + token = create_recover_token(user) + send_confirmation_code_request = SendConfirmationCodeRequest( + token=token, + ) + await self.send_recover_account_confirmation_code( + send_confirmation_code_request, + ) + return TemporaryTokenInfo( + token=token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def send_recover_account_confirmation_code( + self, + send_confirmation_code_request: SendConfirmationCodeRequest, + ) -> None: + token = send_confirmation_code_request.token + payload = decode_jwt( + token, + secret_key=settings.jwt.recover.secret_key, + algorithm=settings.jwt.recover.algorithm, + ) + login = payload[LOGIN_FIELD] + email = payload[EMAIL_FIELD] + confirmation_code = await self.create_confirmation_code( + email, + confirmation_code_type=ConfirmationCodeType.recover_password, + ) + app.send_task( + name=TaskType.send_reset_password_confirmation_code_email.value, + args=[ + login, + email, + confirmation_code, + ], + queue=Queue.notification_service.value, + ) + + async def verify_recover_account( + self, + verify_recover_account_data: VerifyUserEmail, + ) -> TemporaryTokenInfo: + token = verify_recover_account_data.token + confirmation_code = verify_recover_account_data.confirmation_code + payload = decode_jwt( + token, + secret_key=settings.jwt.recover.secret_key, + algorithm=settings.jwt.recover.algorithm, + ) + email = payload[EMAIL_FIELD] + await self.verify_confirmation_code( + email, + confirmation_code, + confirmation_code_type=ConfirmationCodeType.recover_password, + ) + user = await self.user_service.get_user_by_email(email) + reset_password_token = create_reset_password_token(user) + return TemporaryTokenInfo( + token=reset_password_token, + token_type=BEARER_TOKEN_TYPE, + ) + + async def reset_password(self, reset_password_data: ResetPasswordRequest) -> None: + token = reset_password_data.reset_password_token + password = reset_password_data.password + password_confirmation = reset_password_data.password_confirmation + if password != password_confirmation: + raise InvalidPasswordError + + payload = decode_jwt( + token, + secret_key=settings.jwt.reset_password.secret_key, + algorithm=settings.jwt.reset_password.algorithm, + ) + email = payload[EMAIL_FIELD] + user = await self.user_service.get_user_by_email(email) + user_partial_update_data = UserPartialUpdate( + password=reset_password_data.password, + ) + await self.user_service.partial_update_user(user.id, user_partial_update_data) + + async def refresh_access_token(self, user_id: int) -> TokenInfo: + user = await self.user_service.get_user_by_id(user_id) + access_token = create_access_token(user) + return TokenInfo(access_token=access_token) diff --git a/app/services/genre.py b/app/services/genre.py index e184f35..c4b1a87 100644 --- a/app/services/genre.py +++ b/app/services/genre.py @@ -1,7 +1,7 @@ from typing import cast -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq import RabbitMQService +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message from sqlalchemy.ext.asyncio import AsyncSession @@ -83,8 +83,8 @@ async def create_genre(self, create_data: GenreCreate) -> GenreResponse: "object_url": create_data.preview_url, } exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) await self.rabbitmq_service.publish( @@ -114,8 +114,8 @@ async def update_genre( if current_genre_preview_url != update_data.preview_url: exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) @@ -175,8 +175,8 @@ async def delete_genre_by_id(self, genre_id: int) -> None: raise GenreIdNotFoundError(genre_id) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) body = {"object_url": genre.preview_url} diff --git a/app/services/movie.py b/app/services/movie.py index 69b167b..8f44f63 100644 --- a/app/services/movie.py +++ b/app/services/movie.py @@ -1,7 +1,7 @@ from typing import cast -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq import RabbitMQService +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message from sqlalchemy.ext.asyncio import AsyncSession @@ -138,8 +138,8 @@ async def create_movie( movie = await self.movie_repository.create_movie(create_movie_data) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) body = { @@ -191,8 +191,8 @@ async def update_movie( ) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) @@ -282,8 +282,8 @@ async def delete_movie_by_id(self, movie_id: int) -> None: raise MovieIdNotFoundError(movie_id) exchange = await self.rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) body = {"object_url": movie.preview_url} diff --git a/app/services/user.py b/app/services/user.py index e88abe4..74e3fdd 100644 --- a/app/services/user.py +++ b/app/services/user.py @@ -1,18 +1,20 @@ from typing import cast +from packages.celery.constants import Queue, TaskType +from pydantic import EmailStr from sqlalchemy.ext.asyncio import AsyncSession +from core.celery.celery_app import app from core.constants import UserRole -from core.exceptions.auth import InvalidPasswordError from core.exceptions.user import ( UserEmailAlreadyExistsError, + UserEmailNotFoundError, UserIdNotFoundError, UserLoginAlreadyExistsError, UserLoginNotFoundError, ) -from core.security.password_utils import hash_password, verify_password +from core.security.password_utils import hash_password from repositories import UserRepository -from schemas.auth import UserLogin from schemas.user import ( UserCreate, UserPartialUpdate, @@ -23,7 +25,10 @@ class UserService: - def __init__(self, session: AsyncSession) -> None: + def __init__( + self, + session: AsyncSession, + ) -> None: self.session = session self.user_repository = UserRepository(session) @@ -41,9 +46,18 @@ async def get_user_by_login(self, login: str) -> UserResponse: raise UserLoginNotFoundError(login) + async def get_user_by_email(self, email: EmailStr) -> UserResponse: + user = await self.user_repository.get_user_by_email(email) + if user is None: + raise UserEmailNotFoundError(email) + return UserResponse.model_validate(user) + async def user_login_exists(self, login: str) -> bool: return await self.user_repository.user_login_exists(login) + async def user_email_exists(self, email: str) -> bool: + return await self.user_repository.user_email_exists(email) + async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList: users = [ UserResponse.model_validate(user) @@ -55,21 +69,45 @@ async def get_all_users(self, size: int = 10, page: int = 1) -> UserResponseList page=page, ) - async def create_user(self, create_user_data: UserCreate) -> UserResponse: - if await self.user_repository.user_login_exists(create_user_data.login): - raise UserLoginAlreadyExistsError(create_user_data.login) - - if await self.user_repository.user_email_exists(create_user_data.email): - raise UserEmailAlreadyExistsError(create_user_data.email) + async def get_inactive_users(self, days: int) -> UserResponseList: + users = [ + UserResponse.model_validate(user) + for user in await self.user_repository.get_inactive_users(days=days) + ] + return UserResponseList( + user_list=users, + page=1, + size=1, + ) - create_user_data.password = hash_password(create_user_data.password) - user = await self.user_repository.create_user(create_user_data) + async def get_user_encrypted_password(self, login: str) -> str: + """ + Метод получения зашифрованного пароля пользователя + должен использоваться строго внутри монолита. + """ + user = await self.user_repository.get_user_by_login(login) + if user is None: + raise UserLoginNotFoundError(login) + return user.encrypted_password + + async def create_user(self, user_create_data: UserCreate) -> UserResponse: + if await self.user_repository.user_login_exists(user_create_data.login): + raise UserLoginAlreadyExistsError(user_create_data.login) + + if await self.user_repository.user_email_exists(user_create_data.email): + raise UserEmailAlreadyExistsError(user_create_data.email) + + user = await self.user_repository.create_user(user_create_data) + app.send_task( + name=TaskType.send_welcome_email.value, + args=[ + user.email, + user.name, + ], + queue=Queue.notification_service.value, + ) return UserResponse.model_validate(user) - async def make_admin(self, user_id: int) -> None: - if not await self.user_repository.make_admin(user_id): - raise UserIdNotFoundError(user_id) - async def update_user( self, user_id: int, @@ -138,19 +176,13 @@ async def delete_user_by_login(self, login: str) -> None: if not await self.user_repository.delete_user_by_login(login): raise UserLoginNotFoundError(login) - async def authenticate_user(self, login_data: UserLogin) -> UserResponse: - user = await self.user_repository.get_user_by_login(login_data.login) - if user is None: - raise UserLoginNotFoundError(login_data.login) - - if not verify_password(login_data.password, user.encrypted_password): - raise InvalidPasswordError - - return UserResponse.model_validate(user) - async def is_admin(self, user_id: int) -> bool: role = await self.user_repository.get_user_role(user_id) if role is None: raise UserIdNotFoundError(user_id) return role == UserRole.admin.value + + async def make_admin(self, user_id: int) -> None: + if not await self.user_repository.make_admin(user_id): + raise UserIdNotFoundError(user_id) diff --git a/backup.sql b/backup.sql deleted file mode 100644 index d6e1b06..0000000 --- a/backup.sql +++ /dev/null @@ -1,172 +0,0 @@ --- --- PostgreSQL database dump --- - -\restrict H6Y4983yzznWBCCXvUqSeBBtSlW3So5fHe3vL1tapxumEnQJmUO7CgbqzjnAaH0 - --- Dumped from database version 17.10 (Debian 17.10-1.pgdg12+1) --- Dumped by pg_dump version 17.10 (Debian 17.10-1.pgdg12+1) - -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET transaction_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; - --- --- Data for Name: alembic_version; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.alembic_version (version_num) FROM stdin; -24c4c5225bf8 -\. - - --- --- Data for Name: genres; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.genres (id, name, create_date, description, preview_url) FROM stdin; -2 Комедия 2026-05-23 15:54:19.525929 Комедия — жанр, где смех становится главным оружием и наградой. Всё строится на столкновении серьёзного с нелепым: полицейский с игрушечным пистолетом, профессор, который не может завязать шнурки, романтик, по ошибке целующий чужую невесту. Чем усерднее персонажи пытаются сохранить лицо, тем быстрее они его теряют — и зритель смеётся над их провалами, ощущая собственное превосходство. https://pics.livejournal.com/tema/pic/000y75fb -3 Мультфильм 2026-05-23 16:00:05.77206 Мультфильм — единственный жанр, который могут смотреть и младенец, и дедушка, причём рядом на одном диване. Младенец видит красный шарик. Дедушка видит метафору уходящей молодости. И оба не скучают. Попробуй найти другой жанр с таким разбросом. Хоррор для младенца — травма. Драма для дедушки — скука, если без жизненного опыта. А мультфильм работает на любом возрасте, потому что он обращается не к знаниям, а к чему-то более простому — к желанию смотреть на движущиеся картинки и верить, что они живые. Этот рефлекс у нас с детства. Мультфильм просто не даёт ему умереть. https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/d1e43746-fc59-459a-8205-841f600cb315/180 -4 Боевик 2026-05-23 16:04:30.534032 Боевик — жанр, где диалог — это пуля, а аргумент — взрыв. Никто не говорит больше минуты. Если персонаж начинает длинную речь, значит, через пять секунд кто-то выстрелит в окно. Здесь всё работает на инстинктах: громкий звук — опасность, быстрая смена кадра — погоня, тёмный коридор — засада. Боевик не требует думать. Он требует смотреть широко открытыми глазами и иногда кричать «Давай!» на экран. https://poster4.me/wp-content/uploads/2020/05/dedpul_2.jpg -5 Фантастика 2026-05-23 16:06:48.101594 Фантастика бывает твёрдой — где каждый прыжок гипердвигателя обсчитан на формулах, а герои спорят о парадоксе близнецов из теории относительности. И бывает мягкой — где космос залит розовым туманом, а инопланетян можно соблазнить песней. И то и другое имеет право на жизнь. Потому что фантастика — это не наука. Это мечта, притворившаяся правдой. https://avatars.mds.yandex.net/get-kinopoisk-image/1704946/8d716854-9ae4-4863-a821-b22a8f28a0f6/576x -6 Детектив 2026-05-23 16:08:59.633025 Детектив держится на трёх китах: сыщик, жертва, убийца. Сыщик — гений, но с причудами. Он замечает то, что другие пропускают: окурок в цветочном горшке, царапину на замке, фальшивую улыбку вдовы. Жертва — та, чья смерть запускает механизм расследования. Она может быть святой или мерзавкой, но её убийство всегда кому-то выгодно. Убийца — тот, кого ты не заподозришь до самого конца. Чаще всего — самый тихий, самый вежливый или самый очевидно невиновный. https://avatars.mds.yandex.net/get-mpic/13851176/2a000001933fb46d449c6ed9f8cf5a933bf8/orig -7 Криминал 2026-05-23 16:11:18.278025 Самое страшное в криминале — не кровь и не жестокость. Самое страшное — обыденность. Убийца вытирает руки о штаны и идёт ужинать. Торговец наркотиками нежно целует дочку перед сном. Вор в законе читает внукам сказки. Криминал не носит чёрную маску. Он носит уставшие глаза, дешёвый одеколон и улыбку соседа, который занял у вас тысячу рублей и до сих пор не отдал. Просто у этого соседа в багажнике труп. И он делает вид, что ничего не случилось. А жанр делает вид, что это кино. Хотя все мы знаем: такое кино каждый день снимают без камер. https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQkXV4d6YDAGUTo_fVGpnbmewBTSC64aHaV2A&s -8 Ужасы 2026-05-23 16:14:59.515817 Финал ужастика редко бывает счастливым. Монстра нельзя убить — только задержать. Он возвращается в сиквеле. Или оказывается, что герой сам был монстром всё это время. Или последний кадр показывает, что зло всё-таки выбралось наружу. Хэппи-энд в ужастике — это исключение, а не правило. Потому что жанр напоминает: мир не безопасен. Темнота не пуста. А тот звук на чердаке… лучше не проверять, что это было. Но ты всё равно проверишь. И ужастик знает это. И ждёт тебя там. https://images.iptv.rt.ru/imo/transform/profile=filmposter158x230/images/d866r7bir4sqiate8rq0.jpg -9 Приключения 2026-05-23 16:18:46.092929 В приключениях дорога важнее финала. Финал — это просто точка, где герой вытирает пот со лба и смотрит на закат. А дорога — это джунгли, пустыни, горные перевалы, затерянные храмы, подземные реки и пиратские корабли. Каждый новый кадр должен кричать: «Ты никогда такого не видел!». И зритель верит. Потому что приключения — это обещание чуда за следующим поворотом. И жанр почти никогда не врёт. https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRoabhDZ78LfwUxBf-_q6T8K0ugnrbxgurPTw&s -10 Триллер 2026-05-23 16:25:04.056152 Триллер начинается не со взрыва. Он начинается с тиканья часов. С кадра, где герой проверяет замок на двери два раза. Со случайной встречей в лифте, после которой у вас на секунду перехватывает дыхание. В триллере страх не громкий. Он липкий. Он заползает под кожу постепенно, так что ты не замечаешь момента, когда уже сидишь на краю кресла и не моргаешь. Это не ужастик с монстром. Здесь монстр — человек. Или время. Или правда, которую нельзя узнать. https://www.kino-teatr.ru/movie/posters/big/6/3/123436.jpg -\. - - --- --- Data for Name: movies; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.movies (id, name, description, rating, preview_url, source_url, genre_id, release_date) FROM stdin; -1 Интерстеллар Корабль «Эндюранс» вращается на фоне чёрной дыры Гаргантюа. Она огромная. Она переламывает свет, пространство, время. Она выглядит как глаз, смотрящий из ниоткуда. Физики консультировали съёмки — этот кадр буквально наука, ставшая искусством. Потому что «Интерстеллар» — это фильм, где формула общей теории относительности плачет на заднем плане, а зритель всё равно вытирает слёзы на переднем. 8 https://www.kino-teatr.ru/movie/posters/big/6/2/55826.jpg https://vk.com/video-220018529_456243270 5 2014-10-26 -2 Джентльмены Микки Пирсон — американский эмигрант, который построил в Лондоне империю марихуаны. Тоннели под старыми особняками, свои люди в полиции, чистый продукт, никакой уличной грязи. Он хочет выйти из игры. Продать бизнес за 400 миллионов и уехать жить в деревню с женой-красавицей. Но Лондон не отпускает своих. Находятся покупатели, перекупщики, шантажисты, китайская триада, русские олигархи и редактор таблоида с дикцией фокстерьера. И все хотят кусок империи. Или голову Микки. 8 https://upload.wikimedia.org/wikipedia/ru/c/c1/%D0%94%D0%B6%D0%B5%D0%BD%D1%82%D0%BB%D1%8C%D0%BC%D0%B5%D0%BD%D1%8B.jpg https://vk.com/video-233305177_456240899 2 2019-12-03 -7 Волк с Уолл-стрит Фильм повествует о взлете и падении Джордана Белфорта, который в конце 80-х начинает карьеру брокера. Потеряв работу из-за обвала рынка, он основывает собственную фирму, занимающуюся финансовыми махинациями. Используя харизму и агрессивные методы продаж, Белфорт вместе с партнером Донни строит империю, пока ею не начинает интересоваться ФБР. 8 https://avatars.mds.yandex.net/get-ott/1648503/2a00000198542cdfee1c29e01abb4e157d41/600x900 https://vk.com/video-229083599_456239081 10 2013-12-17 -8 Матрица : Жизнь Томаса Андерсона разделена на две части. Днём он — обычный офисный работник, а ночью превращается в неуловимого хакера по имени Нео. Однажды таинственные незнакомцы открывают ему страшную правду: привычный мир вокруг — всего лишь компьютерная иллюзия (Матрица), созданная разумными машинами. Всё человечество погружено в вечный сон, служа лишь источником энергии для искусственного интеллекта. Нео предстоит пробудиться в суровой реальности и возглавить партизанскую войну за свободу человечества. 9 https://avatars.mds.yandex.net/get-kinopoisk-image/4774061/cf1970bc-3f08-4e0e-a095-2fb57c3aa7c6/600x900 https://vk.com/video-220018529_456240812 5 1999-03-31 -9 Один дома Американское семейство отправляется из Чикаго в Европу, но в спешке сборов бестолковые родители забывают дома... одного из своих детей. Юное создание, однако, не теряется и демонстрирует чудеса изобретательности. И когда в дом залезают грабители, им приходится не раз пожалеть о встрече с милым крошкой. 8 https://avatars.mds.yandex.net/get-kinopoisk-image/6201401/022a58e3-5b9b-411b-bfb3-09fedb700401/600x900 https://vk.com/video-220018529_456248133 2 1990-11-10 -4 1+1 Пострадав в результате несчастного случая, богатый аристократ Филипп оказывается прикованным к инвалидному креслу. Ему нужен помощник с функциями сиделки, который должен за ним ухаживать. Филипп нанимает человека, который, казалось бы, менее всего подходит для этой работы. Им оказывается местный молодой человек, выходец из Сенегала, только что освободившийся из тюрьмы по имени Дрисс. 9 https://upload.wikimedia.org/wikipedia/ru/b/b9/Intouchables.jpg https://vk.com/video-220018529_456243240 2 2011-09-23 -11 Острые козырьки, 1 сезон Бирмингем, 1919 год. Ветеран Первой мировой войны Томас Шелби вместе со своими братьями возвращается в родной город, чтобы расширить влияние семейной банды «Острые козырьки», промышляющей грабежами и нелегальными ставками. Случайно в руки Томаса попадает крупная партия секретного заводского оружия. Теперь амбициозному главарю предстоит вступить в опасную игру не только с конкурирующими бандами, но и с прибывшим в город жестким инспектором полиции Кэмпбеллом, намеренным зачистить улицы от преступности. 8 https://sun9-29.userapi.com/impg/O2Wf5Fx8G2d-Qj9mw_wyc2ci6BXenw32kU3aUA/AnD5ale1us4.jpg?size=812x1200&quality=95&sign=0606d95874c6531540cd3a2d3d7bc06f&type=video_thumb https://vk.com/video-192884021_456239066 7 2013-09-12 -14 Острые козырьки 3 сезон Действие переносится в 1924 год. Томас Шелби наконец обретает долгожданный легальный статус, богатство и играет пышную свадьбу. Однако роскошная жизнь в огромном загородном поместье не приносит покоя. Семья Шелби оказывается втянутой в опасные международные интриги: Томми вынужден сотрудничать с тайной праворадикальной организацией и русскими эмигрантами-монархистами ради кражи партии оружия. Поставив на кон всё, глава «Козырьков» понимает, что новые покровители ведут двойную игру, а на кону стоят жизни его самых близких людей. 8 https://www.soyuz.ru/public/uploads/files/3/7650914/20251205215648302300d9ef.jpg https://vk.com/video-228609771_456239302 7 2016-09-05 -15 Острые козырьки 4 сезон Действие разворачивается в 1925 году. Семья Шелби разделена и практически не общается, однако перед лицом смертельной опасности им приходится вновь объединиться. В Бирмингем прибывает глава сицилийской мафии Лука Чангретта, который жаждет отомстить каждому члену клана по законам кровавой вендетты. Чтобы выжить под прицелом профессиональных киллеров, «Острые козырьки» вынуждены покинуть свои роскошные загородные дома, вернуться на родные и опасные улицы Смолл Хит и развязать полноценную войну на уничтожение. 8 https://media.kg-portal.ru/tv/p/peakyblinders/posters/peakyblinders_2.jpg https://vk.com/video-228609771_456239303 7 2017-11-15 -16 Острые козырьки 5 сезон Действие разворачивается в 1929 году на фоне мирового финансового кризиса и обрушения акций на Уолл-стрит, из-за которого законные активы компании «Шелби Лимитед» оказываются под ударом. Томас Шелби, заседающий в британском парламенте, вынужден искать новые, порой незаконные пути для восстановления семейного капитала. На политической арене Томми сталкивается с харизматичным и коварным сэром Освальдом Мосли — лидером британских фашистов. Пытаясь переиграть двуличных политиков и удержать контроль над разрастающейся империей, глава «Козырьков» начинает страдать от тяжелых галлюцинаций. Ситуация обостряется внутренним расколом в семье и появлением нового опасного врага из Детройта. 8 https://avatars.mds.yandex.net/get-kinopoisk-image/1773646/3d330c0e-0547-45a5-a6dc-69c1e986a4c2/orig https://vk.com/video-194145340_456239728 7 2019-08-25 -13 Острые козырьки 2 сезон Бизнес семьи Шелби процветает, и Томас планирует расширить криминальную империю, захватив столицу Англии — Лондон. Чтобы укрепиться на новом рынке, он решает извлечь выгоду из кровопролитной войны между итальянскими и еврейскими группировками. Однако амбициозные планы Томми оказываются под угрозой: его бару наносят сокрушительный удар, прошлое в лице инспектора Кэмпбелла снова напоминает о себе. 8 https://www.kino-teatr.ru/movie/poster/121956/100887.jpg https://vk.com/video-141357054_456239047 7 2014-10-02 -17 Острые козырьки 6 сезон : Действие начинается в 1933 году, сразу после отмены сухого закона в США, что открывает для Томми Шелби новые масштабные возможности на опиумном рынке Северной Америки. Однако клан Шелби истощен личными трагедиями, а Артур окончательно теряет контроль над собой. Противостояние с фашистским лидером Освальдом Мосли выходит на пиковый уровень, и Томми приходится вести смертельную игру на два фронта — против политических врагов и безжалостных бостонских гангстеров. Ситуация обостряется, когда Томас узнает о своей смертельной болезни, заставляющей его торопиться с завершением всех земных дел. 8 https://vseriale-ostrye-kozyrki.ru/4.jpg https://vk.com/video-80021931_456241711 7 2022-02-27 -19 Смешарики. Легенда о золотом драконе Выдающийся ученый Лосяш изобретает удивительный прибор «Улучшайзер», способный переносить лучшие качества одного существа другому. Трусливый Бараш решает воспользоваться аппаратом, чтобы избавиться от своей робости, но из-за нелепой случайности происходит сбой. В результате Бараш меняется телами с маленькой гусеницей. Ситуация осложняется тем, что герои попадают в дикие джунгли, где обитает племя туземцев. Дикари принимают гусеницу в теле Бараша за своего бога — Золотого Дракона, а за самим Барашем начинают охоту опасные расхитители гробниц. Смешарикам предстоит спасти друга и предотвратить древнее пророчество. 6 https://thumbs.dfs.ivi.ru/storage0/contents/d/7/d3654ac6f949a983f280cb5bf655df.jpg https://vk.com/video-21665793_456242980 3 2016-03-17 -18 Смешарики. Начало Спокойная и размеренная жизнь в Ромашковой долине переворачивается с ног на голову, когда Крош и Ёжик находят в земле старый телевизор. Починив его, друзья натыкаются на трансляцию «Шоу Люсьена», где отважный супергерой борется со зловещим доктором Калигари. Приняв телевизионную постановку за чистую монету, Смешарики решают спасти мир. На плоту они отправляются в огромный мегаполис, где их ждут суровые законы взрослого мира, арест и неожиданная правда о том, что грозный супергерой — это всего лишь уставший актер Копатыч. 5 https://s1.afisha.ru/mediastorage/7b/f0/d949ef59364848a89604daa6f07b.jpg https://vk.com/video-232922094_456245875 3 2011-12-22 -20 Смешарики. Дежавю Крош решает устроить лучший день рождения для Копатыча и обращается в необычное агентство «Дежавю», которое организует незабываемые путешествия во времени. Однако из-за несоблюдения правил безопасности и беспечности Кроша вся компания Смешариков оказывается разбросана по разным эпохам — от мезозоя до Древнего Китая и Дикого Запада. Крошу предстоит исправить свою ошибку, совершить скачки сквозь время и спасти друзей. Ситуация осложняется тем, что в процессе перемещений появляется его точная копия из будущего — ворчливый и дисциплинированный Шорк, который совсем не ладит с веселым кроликом. 7 https://avatars.mds.yandex.net/get-kinopoisk-image/1946459/6f120526-a8f7-41fd-a387-d941c1c40350/600x900 https://vk.com/video-21665793_456242979 3 2018-04-26 -\. - - --- --- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.users (id, surname, name, login, encrypted_password, registration_date, email, role) FROM stdin; -3 admin admin adminadmin $2b$12$nqICm8L8RY2V4nO12LBj3ez81NftJYcSZfdKwU3BDkbsLPc5n1dBW 2026-05-23 18:45:30.523017 admin@admin.gmail.ru admin -2 Широков Николай adminadmin $2b$12$UsSn8WUQjizfgcZX1sFjiO4ntdJ6gSsUOSLT7B/1PkrL68JD2XJ8C 2026-05-23 15:41:44.553813 admin@admin.gmail.ru admin -\. - - --- --- Data for Name: favorite_movies; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.favorite_movies (id, user_id, movie_id) FROM stdin; -15 3 15 -16 3 16 -17 3 13 -18 3 17 -\. - - --- --- Data for Name: reviews; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.reviews (id, user_id, movie_id, review_text, rating, publication_date) FROM stdin; -2 3 2 Очень хороший фильм 8 2026-05-24 19:09:43.431399 -\. - - --- --- Data for Name: watch_history; Type: TABLE DATA; Schema: public; Owner: postgres --- - -COPY public.watch_history (id, user_id, movie_id, watched_at) FROM stdin; -2 3 7 2026-05-24 15:59:04.139486 -3 3 8 2026-05-24 15:59:26.139949 -5 3 11 2026-05-24 16:01:29.257514 -6 3 1 2026-05-24 16:02:01.3108 -9 3 7 2026-05-24 19:10:42.6759 -10 3 19 2026-05-24 19:14:50.997454 -11 3 7 2026-05-24 19:21:16.810151 -12 3 7 2026-05-24 19:21:28.541346 -13 3 13 2026-05-24 19:23:31.725744 -14 3 15 2026-05-25 18:19:33.495 -15 3 11 2026-05-25 18:21:53.085286 -16 3 1 2026-05-25 18:27:25.631441 -17 3 4 2026-05-25 18:27:33.149385 -18 3 15 2026-05-25 18:27:41.897176 -19 3 15 2026-05-25 18:27:44.41712 -20 3 11 2026-05-25 18:27:51.585886 -\. - - --- --- Name: favorite_movies_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.favorite_movies_id_seq', 22, true); - - --- --- Name: genres_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.genres_id_seq', 33, true); - - --- --- Name: movies_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.movies_id_seq', 21, true); - - --- --- Name: reviews_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.reviews_id_seq', 2, true); - - --- --- Name: users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.users_id_seq', 4, true); - - --- --- Name: watch_history_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres --- - -SELECT pg_catalog.setval('public.watch_history_id_seq', 20, true); - - --- --- PostgreSQL database dump complete --- - -\unrestrict H6Y4983yzznWBCCXvUqSeBBtSlW3So5fHe3vL1tapxumEnQJmUO7CgbqzjnAaH0 diff --git a/docker-compose.yml b/docker-compose.yml index 8c7c968..4a78656 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,11 @@ services: RABBITMQ__HOST: rabbitmq RABBITMQ__PORT: 5672 - MEDIASERVICE__HOST: mediaservice - MEDIASERVICE__PORT: 8000 + MEDIA_SERVICE__HOST: media-service + MEDIA_SERVICE__PORT: 8000 + + NOTIFICATION_SERVICE__HOST: notification-service + NOTIFICATION_SERVICE__PORT: 8000 ports: - "8000:8000" develop: @@ -31,9 +34,9 @@ services: condition: service_healthy redis: condition: service_healthy - rabbitmq: + media-service: condition: service_healthy - mediaservice: + notification-service: condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] @@ -42,6 +45,64 @@ services: timeout: 2s retries: 3 + media-service: + build: + context: . + dockerfile: media-service/Dockerfile + container_name: media-service + environment: + MINIO__HOST: minio + MINIO__PORT: 9000 + MINIO__ACCESS_KEY: admin + MINIO__SECRET_KEY: adminadmin + ports: + - "8001:8000" + develop: + watch: + - path: media-service + action: sync+restart + target: /media-service + - path: ./packages + action: sync+restart + target: /media-service/packages + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] + start_period: 3s + interval: 2s + timeout: 2s + retries: 3 + depends_on: + minio: + condition: service_healthy + rabbitmq: + condition: service_healthy + + notification-service: + build: + context: . + dockerfile: notification-service/Dockerfile + container_name: notification-service + develop: + watch: + - path: ./notification-service + action: sync+restart + target: /notification-service + + - path: ./packages + action: sync+restart + target: /notification-service/packages + ports: + - "8003:8000" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] + start_period: 3s + interval: 2s + timeout: 2s + retries: 3 + depends_on: + rabbitmq: + condition: service_healthy + frontend: image: nginx:1.27-alpine container_name: frontend @@ -72,15 +133,6 @@ services: timeout: 2s retries: 3 - pgadmin: - image: dpage/pgadmin4 - container_name: pgadmin - environment: - PGADMIN_DEFAULT_EMAIL: postgres@postgres.com - PGADMIN_DEFAULT_PASSWORD: postgres - ports: - - "5050:80" - redis: image: redis:latest container_name: redis @@ -95,11 +147,24 @@ services: timeout: 2s retries: 3 - redis_gui: - image: redis/redisinsight - container_name: redis_gui + minio: + image: minio/minio:latest + container_name: minio + environment: + MINIO_ROOT_USER: admin + MINIO_ROOT_PASSWORD: adminadmin ports: - - "5540:5540" + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + command: server --console-address ":9001" /data + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] + start_period: 3s + interval: 2s + timeout: 2s + retries: 3 rabbitmq: image: rabbitmq:3.13-management @@ -115,17 +180,53 @@ services: - "rabbitmq-data:/var/lib/rabbitmq" healthcheck: test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] - start_period: 10s + start_period: 15s interval: 2s timeout: 2s - retries: 3 + retries: 5 - celery-worker-mediaservice: + celery-beat-app: + build: + context: ./ + dockerfile: app/Dockerfile + container_name: celery-beat-app + command: uv run celery --app core.celery.celery_app beat --loglevel=INFO + develop: + watch: + - path: ./app + action: sync+restart + target: /app + - path: ./packages + action: sync+restart + target: /app/packages + depends_on: + celery-worker-notification-service: + condition: service_started + + celery-worker-app: + build: + context: ./ + dockerfile: app/Dockerfile + container_name: celery-worker-app + command: uv run celery --app core.celery.celery_app worker -Q movie-catalog --loglevel=INFO + develop: + watch: + - path: ./app + action: sync+restart + target: /app + - path: ./packages + action: sync+restart + target: /app/packages + depends_on: + rabbitmq: + condition: service_healthy + + celery-worker-media-service: build: context: . - dockerfile: mediaservice/Dockerfile - container_name: celery-worker-mediaservice - command: uv run celery --app core.celery.celery_app worker --loglevel=INFO + dockerfile: media-service/Dockerfile + container_name: celery-worker-media-service + command: uv run celery --app core.celery.celery_app worker -Q media-service --loglevel=INFO environment: MINIO__HOST: minio MINIO__PORT: 9000 @@ -133,62 +234,67 @@ services: MINIO__SECRET_KEY: adminadmin develop: watch: - - path: mediaservice/core/celery + - path: media-service action: sync+restart - target: /celery-worker + target: /media-service + - path: ./packages + action: sync+restart + target: /media-service/packages depends_on: rabbitmq: condition: service_healthy - - minio: - image: minio/minio:latest - container_name: minio - environment: - MINIO_ROOT_USER: admin - MINIO_ROOT_PASSWORD: adminadmin - ports: - - "9000:9000" - - "9001:9001" - volumes: - - minio-data:/data - command: server --console-address ":9001" /data - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] - start_period: 3s - interval: 2s - timeout: 2s - retries: 3 - - mediaservice: + celery-worker-notification-service: build: context: . - dockerfile: mediaservice/Dockerfile - container_name: mediaservice - environment: - MINIO__HOST: minio - MINIO__PORT: 9000 - MINIO__ACCESS_KEY: admin - MINIO__SECRET_KEY: adminadmin - ports: - - "8001:8000" + dockerfile: notification-service/Dockerfile + container_name: celery-worker-notification-service + command: uv run celery --app core.celery.celery_app worker -Q notification-service --loglevel=INFO develop: watch: - - path: ./mediaservice + - path: ./notification-service action: sync+restart - target: /mediaservice + target: /notification-service - path: ./packages action: sync+restart - target: /mediaservice/packages - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - start_period: 3s - interval: 2s - timeout: 2s - retries: 3 + target: /notification-service/packages depends_on: - minio: + rabbitmq: condition: service_healthy + + maildev: + image: maildev/maildev + container_name: maidev + env_file: + - .env.docker-compose + ports: + - "1080:1080" + - "1025:1025" + + pgadmin: + image: dpage/pgadmin4 + container_name: pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: postgres@postgres.com + PGADMIN_DEFAULT_PASSWORD: postgres + ports: + - "5050:80" + + redis-gui: + image: redis/redisinsight + container_name: redis_gui + ports: + - "5540:5540" + + celery-flower: + image: mher/flower + container_name: celery-flower + environment: + - CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672/%2f + - FLOWER_BASIC_AUTH=admin:admin + ports: + - "5555:5555" + depends_on: rabbitmq: condition: service_healthy diff --git a/frontend/app/api/api_v1/auth.js b/frontend/app/api/api_v1/auth.js index 4f00ac9..4ea6bd9 100644 --- a/frontend/app/api/api_v1/auth.js +++ b/frontend/app/api/api_v1/auth.js @@ -3,8 +3,9 @@ var parseResponseJson = window.ApiClient.parseResponseJson; var readErrorMessage = window.ApiClient.readErrorMessage; + // ============ РЕГИСТРАЦИЯ ============ function registerUser(payload) { - return fetch(apiUrl("/api/v1/auth/register"), { + return fetch(apiUrl("/api/v1/auth/register/"), { method: "POST", headers: { "Content-Type": "application/json", @@ -21,11 +22,54 @@ }); } + function verifyRegistration(token, confirmationCode) { + return fetch(apiUrl("/api/v1/auth/register/verify"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + token: token, + confirmation_code: confirmationCode, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; // { access_token, refresh_token, token_type } + }); + }); + } + + function resendRegistrationCode(token) { + return fetch(apiUrl("/api/v1/auth/register/resend-confirmation-code"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + token: token, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // ============ ВХОД (2FA) ============ function loginUser(username, password) { var body = new URLSearchParams(); body.set("username", username); body.set("password", password); - return fetch(apiUrl("/api/v1/auth/login"), { + + return fetch(apiUrl("/api/v1/auth/login/"), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -37,19 +81,163 @@ if (!res.ok) { throw new Error(readErrorMessage(data)); } - window.TokenStore.setTokens(data.access_token, data.refresh_token); return data; }); }); } + function verifyLogin(token, confirmationCode) { + return fetch(apiUrl("/api/v1/auth/login/verify"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + token: token, + confirmation_code: confirmationCode, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + function resendLoginCode(token) { + return fetch(apiUrl("/api/v1/auth/login/resend-confirmation-code"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + token: token, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // ============ ВОССТАНОВЛЕНИЕ ПАРОЛЯ ============ + + // ШАГ 1: Отправка email для восстановления + function recoverAccount(email) { + return fetch(apiUrl("/api/v1/auth/recover/"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + email: email, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; // { token: "...", token_type: "bearer" } + }); + }); + } + + // ШАГ 2: Подтверждение кода восстановления + function verifyRecovery(token, confirmationCode) { + return fetch(apiUrl("/api/v1/auth/recover/verify"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + token: token, + confirmation_code: confirmationCode, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; // { token: "...", token_type: "bearer" } - токен для смены пароля + }); + }); + } + + // ШАГ 3: Смена пароля + function resetPassword(payload) { + return fetch(apiUrl("/api/v1/auth/recover/reset-password"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // ПОВТОРНАЯ ОТПРАВКА КОДА ВОССТАНОВЛЕНИЯ + function resendRecoveryCode(token) { + return fetch(apiUrl("/api/v1/auth/recover/resend-confirmation-code"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + token: token, + }), + }).then(function (res) { + return parseResponseJson(res).then(function (data) { + if (!res.ok) { + throw new Error(readErrorMessage(data)); + } + return data; + }); + }); + } + + // ============ ОБЩИЕ МЕТОДЫ ============ + function logout() { window.TokenStore.clearTokens(); } + // ============ ЭКСПОРТ ============ + window.ApiAuth = { + // Регистрация registerUser: registerUser, + verifyRegistration: verifyRegistration, + resendRegistrationCode: resendRegistrationCode, + + // Вход (2FA) loginUser: loginUser, + verifyLogin: verifyLogin, + resendLoginCode: resendLoginCode, + + // Восстановление + recoverAccount: recoverAccount, + verifyRecovery: verifyRecovery, + resetPassword: resetPassword, + resendRecoveryCode: resendRecoveryCode, + + // Общее logout: logout, }; -})(); +})(); \ No newline at end of file diff --git a/frontend/app/data/state.js b/frontend/app/data/state.js index 3eaba95..b4e4308 100644 --- a/frontend/app/data/state.js +++ b/frontend/app/data/state.js @@ -153,6 +153,33 @@ adminMoviesLoading: false, adminMoviesTotal: 0, + // Для двухэтапной регистрации + registerStep: 'form', // 'form' | 'verify' + registrationToken: '', + registrationData: { + surname: '', + name: '', + login: '', + email: '', + password: '', + }, + confirmationCode: '', + resendTimer: 60, + canResend: false, + timerInterval: null, + + // Для восстановления пароля + resetStep: 'form', // 'form' | 'verify' | 'change' | 'done' + resetEmail: '', + resetCode: '', + resetNewPassword: '', + resetConfirmPassword: '', + resetToken: '', + resetPasswordToken: '', + resetResendTimer: 60, + resetCanResend: false, + resetTimerInterval: null, + // Форма для фильма editingMovie: null, showMovieForm: false, @@ -171,6 +198,18 @@ movieSourceFile: null, movieSourceFileName: "", + messageTimeout: null, + + // Для двухфакторной аутентификации + loginStep: 'form', // 'form' | 'verify' + loginToken: '', + loginEmail: '', // email из ответа /login + loginCode: '', // 6-значный код + loginBlocked: false, // ← НОВОЕ: блокировка при слишком многих попытках + loginResendTimer: 60, + loginCanResend: false, + loginTimerInterval: null, + deletingGenreId: null, deletingGenreName: null, genreDeleting: false, diff --git a/frontend/app/services/methods/auth.js b/frontend/app/services/methods/auth.js index c412b7e..420e9ad 100644 --- a/frontend/app/services/methods/auth.js +++ b/frontend/app/services/methods/auth.js @@ -1,58 +1,847 @@ (function () { window.AppMethodsAuth = { - onLogin: function () { - var self = this; - this.error = ""; - this.loading = true; - window.Api.loginUser(this.loginForm.username, this.loginForm.password) - .then(function () { - window.location.hash = "#/"; - window.location.reload(); - }) - .catch(function (e) { - self.error = e.message || "Не удалось войти"; - }) - .finally(function () { - self.loading = false; - }); - }, - - onRegister: function () { - var self = this; - this.error = ""; - this.success = ""; - this.loading = true; - window.Api.registerUser({ - surname: this.registerForm.surname.trim(), - name: this.registerForm.name.trim(), - login: this.registerForm.login.trim(), - email: this.registerForm.email.trim(), - password: this.registerForm.password, - }) - .then(function () { - window.location.href = "#/"; - window.location.reload(); - }) - .catch(function (e) { - self.error = e.message || "Ошибка регистрации"; - }) - .finally(function () { - self.loading = false; - }); - }, - onLogout: function () { - window.Api.logout(); - window.location.reload(); - }, - formatRegistrationDate: function (iso) { - if (!iso) { - return "—"; + // ==================== УПРАВЛЕНИЕ СООБЩЕНИЯМИ ==================== + + // Показать сообщение об ошибке (заменяет success) + showError: function (message, autoClear = true) { + this.error = message; + this.success = ''; // Очищаем успешное сообщение + if (autoClear) { + this.clearMessagesAfterDelay(5000); + } + }, + + // Показать сообщение об успехе (заменяет error) + showSuccess: function (message, autoClear = true) { + this.success = message; + this.error = ''; // Очищаем сообщение об ошибке + if (autoClear) { + this.clearMessagesAfterDelay(5000); + } + }, + + // Очистить все сообщения + clearMessages: function () { + this.error = ''; + this.success = ''; + if (this.messageTimeout) { + clearTimeout(this.messageTimeout); + this.messageTimeout = null; + } + }, + + // Автоматическая очистка через заданное время + clearMessagesAfterDelay: function (delay) { + var self = this; + if (this.messageTimeout) { + clearTimeout(this.messageTimeout); + } + this.messageTimeout = setTimeout(function () { + self.error = ''; + self.success = ''; + self.messageTimeout = null; + }, delay); + }, + + // ==================== ЛОГИН С 2FA ==================== + onLogin: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + window.ApiAuth.loginUser( + this.loginForm.username.trim(), + this.loginForm.password + ) + .then(function (data) { + self.loginToken = data.token; + self.loginStep = 'verify'; + self.showSuccess("✅ Код подтверждения отправлен на почту"); + self.startLoginResendTimer(60); + }) + .catch(function (e) { + var message = e.message || "Не удалось войти"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + // 🔥 ОБРАБОТКА ОШИБОК ЛОГИНА + if (lowerMessage.includes("invalid") || + lowerMessage.includes("неверн") || + lowerMessage.includes("не правильн") || + lowerMessage.includes("incorrect")) { + if (lowerMessage.includes("password") || lowerMessage.includes("парол")) { + errorText = "❌ Неверный пароль. Пожалуйста, проверьте правильность введенного пароля."; + self.loginForm.password = ''; + setTimeout(function () { + var input = document.getElementById('login-password'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("login") || lowerMessage.includes("логин") || lowerMessage.includes("username")) { + errorText = "❌ Неверный логин. Пожалуйста, проверьте правильность введенного логина."; + self.loginForm.username = ''; + setTimeout(function () { + var input = document.getElementById('login-username'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else { + errorText = "❌ Неверный логин или пароль. Попробуйте еще раз."; + } + } else if (lowerMessage.includes("not found") || + lowerMessage.includes("не найден") || + lowerMessage.includes("does not exist")) { + errorText = "❌ Пользователь с таким логином не найден. Проверьте правильность введенного логина."; + self.loginForm.username = ''; + setTimeout(function () { + var input = document.getElementById('login-username'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); + }) + .finally(function () { + self.loading = false; + }); + }, + + // ПОДТВЕРЖДЕНИЕ 2FA КОДА + onVerifyLoginCode: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + if (this.loginCode.trim().length !== 6) { + this.showError("Введите 6-значный код"); + this.loading = false; + return; + } + + if (!this.loginToken) { + this.showError("❌ Ошибка: токен не найден. Попробуйте войти заново."); + this.loading = false; + return; + } + + window.ApiAuth.verifyLogin( + this.loginToken, + this.loginCode.trim() + ) + .then(function (data) { + window.TokenStore.setTokens(data.access_token, data.refresh_token); + window.location.hash = "#/"; + window.location.reload(); + }) + .catch(function (e) { + var message = e.message || "Неверный код подтверждения"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + if (lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует") || + lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Код подтверждения не найден. Возможно, он уже был использован или истек. Запросите новый код."; + self.loginCode = ''; + setTimeout(function () { + var input = document.getElementById('login-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout") || + lowerMessage.includes("не действителен") || + lowerMessage.includes("срок действия") || + lowerMessage.includes("устарел")) { + errorText = "⏰ Срок действия кода истек. Запросите новый код."; + self.loginCode = ''; + setTimeout(function () { + self.onResendLoginCode(); + }, 2000); + } else if (lowerMessage.includes("not valid") || + lowerMessage.includes("недействителен") || + lowerMessage.includes("invalid") || + lowerMessage.includes("неверн") || + lowerMessage.includes("не правильн")) { + errorText = "❌ Неверный код подтверждения. Проверьте правильность введенных цифр."; + self.loginCode = ''; + setTimeout(function () { + var input = document.getElementById('login-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("attempt") || + lowerMessage.includes("попытк") || + lowerMessage.includes("blocked") || + lowerMessage.includes("заблокирован") || + lowerMessage.includes("too many")) { + errorText = "⚠️ Слишком много неудачных попыток. Доступ временно заблокирован."; + self.loginBlocked = true; + setTimeout(function () { + self.loginBlocked = false; + self.onResendLoginCode(); + }, 5000); + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); + }) + .finally(function () { + self.loading = false; + }); + }, + + + // ПОВТОРНАЯ ОТПРАВКА 2FA КОДА + onResendLoginCode: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + if (!this.loginToken) { + this.showError("❌ Ошибка: токен не найден. Попробуйте войти заново."); + this.loading = false; + return; + } + + window.ApiAuth.resendLoginCode(this.loginToken) + .then(function () { + self.showSuccess("✅ Новый код отправлен на почту"); + self.startLoginResendTimer(60); + self.loginCode = ''; + self.loginBlocked = false; + setTimeout(function () { + var input = document.getElementById('login-code-input'); + if (input) input.focus(); + }, 100); + }) + .catch(function (e) { + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + self.showError("⏰ Срок действия сессии истек. Пожалуйста, войдите заново."); + setTimeout(function () { + self.onBackToLogin(); + }, 2000); + } else if (lowerMessage.includes("too many") || + lowerMessage.includes("много")) { + self.showError("⚠️ Слишком много запросов. Подождите немного."); + } else { + self.showError("❌ " + message); + } + }) + .finally(function () { + self.loading = false; + }); + }, + + + // ВОЗВРАТ К ФОРМЕ ЛОГИНА + onBackToLogin: function () { + this.loginStep = 'form'; + this.loginCode = ''; + this.loginToken = ''; + this.error = ''; + this.success = ''; + if (this.loginTimerInterval) { + clearInterval(this.loginTimerInterval); + this.loginTimerInterval = null; + } + }, + + // ТАЙМЕР ДЛЯ 2FA + startLoginResendTimer: function (seconds) { + var self = this; + this.loginResendTimer = seconds; + this.loginCanResend = false; + + if (this.loginTimerInterval) { + clearInterval(this.loginTimerInterval); + } + + this.loginTimerInterval = setInterval(function () { + self.loginResendTimer--; + if (self.loginResendTimer <= 0) { + clearInterval(self.loginTimerInterval); + self.loginTimerInterval = null; + self.loginCanResend = true; + } + }, 1000); + }, + + // ==================== РЕГИСТРАЦИЯ ==================== + // ОБНОВЛЕННАЯ РЕГИСТРАЦИЯ (ШАГ 1) + onRegister: function () { + var self = this; + this.clearMessages(); + + // Валидация + if (this.registerForm.password.length < 8) { + this.showError("Пароль должен быть минимум 8 символов"); + return; + } + + var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(this.registerForm.email.trim())) { + this.showError("Введите корректный email"); + return; + } + + if (this.registerForm.login.trim().length < 3) { + this.showError("Логин должен быть минимум 3 символа"); + return; + } + + if (this.registerForm.surname.trim().length < 2) { + this.showError("Фамилия должна быть минимум 2 символа"); + return; + } + + if (this.registerForm.name.trim().length < 2) { + this.showError("Имя должно быть минимум 2 символа"); + return; + } + + this.loading = true; + var payload = { + surname: this.registerForm.surname.trim(), + name: this.registerForm.name.trim(), + login: this.registerForm.login.trim(), + email: this.registerForm.email.trim(), + password: this.registerForm.password, + }; + + window.ApiAuth.registerUser(payload) + .then(function (data) { + self.registrationToken = data.token; + self.registrationData.email = payload.email; + self.registerStep = 'verify'; + self.showSuccess("✅ Код подтверждения отправлен на почту"); + self.startResendTimer(60); + }) + .catch(function (e) { + var message = e.message || "Ошибка регистрации"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + // 🔥 ОБРАБОТКА ОШИБОК УНИКАЛЬНОСТИ + if (lowerMessage.includes("login") && + (lowerMessage.includes("already exists") || + lowerMessage.includes("already taken") || + lowerMessage.includes("существует") || + lowerMessage.includes("занят") || + lowerMessage.includes("используется"))) { + errorText = "❌ Логин уже занят. Пожалуйста, выберите другой логин."; + // Очищаем поле логина и ставим фокус + self.registerForm.login = ''; + setTimeout(function () { + var input = document.getElementById('reg-login'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("email") && + (lowerMessage.includes("already exists") || + lowerMessage.includes("already taken") || + lowerMessage.includes("существует") || + lowerMessage.includes("занят") || + lowerMessage.includes("используется"))) { + errorText = "❌ Email уже используется. Пожалуйста, используйте другой email."; + // Очищаем поле email и ставим фокус + self.registerForm.email = ''; + setTimeout(function () { + var input = document.getElementById('reg-email'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("already exists") || + lowerMessage.includes("существует") || + lowerMessage.includes("already registered") || + lowerMessage.includes("already taken")) { + errorText = "❌ Пользователь с таким email или логином уже существует."; + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); + }) + .finally(function () { + self.loading = false; + }); + }, + + + // ПОДТВЕРЖДЕНИЕ КОДА РЕГИСТРАЦИИ (ШАГ 2) + onVerifyCode: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + if (this.confirmationCode.trim().length !== 6) { + this.showError("Введите 6-значный код"); + this.loading = false; + return; + } + + if (!this.registrationToken) { + this.showError("❌ Ошибка: токен не найден. Попробуйте зарегистрироваться заново."); + this.loading = false; + return; + } + + window.ApiAuth.verifyRegistration( + this.registrationToken, + this.confirmationCode.trim() + ) + .then(function (data) { + if (data.access_token) { + window.TokenStore.setTokens(data.access_token, data.refresh_token); + self.showSuccess("✅ Регистрация успешна! Добро пожаловать!"); + setTimeout(function () { + window.location.hash = "#/"; + window.location.reload(); + }, 1000); + } else { + self.showSuccess("✅ Регистрация успешна! Теперь вы можете войти."); + setTimeout(function () { + window.location.hash = "#/login"; + window.location.reload(); + }, 1500); + } + }) + .catch(function (e) { + var message = e.message || "Неверный код подтверждения"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + if (lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует") || + lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Код подтверждения не найден. Возможно, он уже был использован или истек. Запросите новый код."; + self.confirmationCode = ''; + setTimeout(function () { + var input = document.getElementById('code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + errorText = "⏰ Срок действия кода истек. Отправляем новый код..."; + setTimeout(function () { + self.onResendCode(); + }, 1500); + } else if (lowerMessage.includes("not valid") || + lowerMessage.includes("недействителен") || + lowerMessage.includes("invalid")) { + errorText = "❌ Неверный код. Проверьте правильность введенных цифр."; + self.confirmationCode = ''; + setTimeout(function () { + var input = document.getElementById('code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("already exists") || + lowerMessage.includes("существует") || + lowerMessage.includes("already registered")) { + errorText = "❌ Пользователь с таким email или логином уже существует."; + setTimeout(function () { + self.onBackToRegister(); + }, 2000); + } else if (lowerMessage.includes("attempt") || + lowerMessage.includes("попытк")) { + errorText = "⚠️ Слишком много неудачных попыток. Попробуйте позже."; + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); + }) + .finally(function () { + self.loading = false; + }); + }, + + + + // ПОВТОРНАЯ ОТПРАВКА КОДА РЕГИСТРАЦИИ + onResendCode: function () { + var self = this; + this.error = ""; + this.success = ""; + this.loading = true; + + if (!this.registrationToken) { + this.error = "Ошибка: токен не найден. Попробуйте зарегистрироваться заново."; + this.loading = false; + return; + } + + window.ApiAuth.resendRegistrationCode(this.registrationToken) + .then(function () { + self.success = "✅ Новый код отправлен на почту"; + self.startResendTimer(60); + }) + .catch(function (e) { + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + self.error = "⏰ Срок действия сессии истек. Пожалуйста, начните регистрацию заново."; + setTimeout(function () { + self.onBackToRegister(); + }, 2000); + } else { + self.error = "❌ " + message; + } + }) + .finally(function () { + self.loading = false; + }); + }, + + // ВОЗВРАТ К ФОРМЕ РЕГИСТРАЦИИ + onBackToRegister: function () { + this.registerStep = 'form'; + this.confirmationCode = ''; + this.registrationToken = ''; + this.error = ''; + this.success = ''; + if (this.timerInterval) { + clearInterval(this.timerInterval); + this.timerInterval = null; + } + }, + + // ТАЙМЕР ДЛЯ РЕГИСТРАЦИИ + startResendTimer: function (seconds) { + var self = this; + this.resendTimer = seconds; + this.canResend = false; + + if (this.timerInterval) { + clearInterval(this.timerInterval); + } + + this.timerInterval = setInterval(function () { + self.resendTimer--; + if (self.resendTimer <= 0) { + clearInterval(self.timerInterval); + self.timerInterval = null; + self.canResend = true; } - try { - return new Date(iso).toLocaleString("ru-RU"); - } catch (e) { - return iso; + }, 1000); + }, + + // ==================== ВОССТАНОВЛЕНИЕ ПАРОЛЯ ==================== + + // ШАГ 1: Отправка email для восстановления + onSendResetCode: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + var email = this.resetEmail.trim(); + var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + this.showError("Введите корректный email"); + this.loading = false; + return; + } + + window.ApiAuth.recoverAccount(email) + .then(function (data) { + self.resetToken = data.token; + self.resetEmail = email; + self.resetStep = 'verify'; + self.showSuccess("✅ Код восстановления отправлен на почту"); + self.startResetResendTimer(60); + }) + .catch(function (e) { + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + // 🔥 ОБРАБОТКА ОШИБКИ - EMAIL НЕ НАЙДЕН + if (lowerMessage.includes("not found") || + lowerMessage.includes("не найден") || + lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует")) { + errorText = "❌ Пользователь с таким email не найден. Проверьте правильность введенного адреса."; + self.resetEmail = ''; + setTimeout(function () { + var input = document.getElementById('reset-email'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); + }) + .finally(function () { + self.loading = false; + }); + }, + + + // ШАГ 2: Подтверждение кода восстановления + onVerifyResetCode: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + if (this.resetCode.trim().length !== 6) { + this.showError("Введите 6-значный код"); + this.loading = false; + return; + } + + if (!this.resetToken) { + this.showError("❌ Ошибка: токен не найден. Попробуйте начать заново."); + this.loading = false; + return; + } + + window.ApiAuth.verifyRecovery( + this.resetToken, + this.resetCode.trim() + ) + .then(function (data) { + self.resetPasswordToken = data.token; + self.showSuccess("✅ Код подтвержден! Теперь вы можете установить новый пароль."); + setTimeout(function () { + self.resetStep = 'change'; + }, 1000); + }) + .catch(function (e) { + var message = e.message || "Неверный код подтверждения"; + var lowerMessage = message.toLowerCase(); + var errorText = ""; + + if (lowerMessage.includes("does not exist") || + lowerMessage.includes("не существует") || + lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Код подтверждения не найден. Возможно, он уже был использован или истек. Запросите новый код."; + self.resetCode = ''; + setTimeout(function () { + var input = document.getElementById('reset-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout") || + lowerMessage.includes("не действителен") || + lowerMessage.includes("срок действия") || + lowerMessage.includes("устарел")) { + errorText = "⏰ Срок действия кода истек. Пожалуйста, запросите новый код."; + self.resetCode = ''; + setTimeout(function () { + self.onResendResetCode(); + }, 2000); + } else if (lowerMessage.includes("not valid") || + lowerMessage.includes("недействителен") || + lowerMessage.includes("invalid") || + lowerMessage.includes("неверн") || + lowerMessage.includes("не правильн")) { + errorText = "❌ Неверный код подтверждения. Проверьте правильность введенных цифр."; + self.resetCode = ''; + setTimeout(function () { + var input = document.getElementById('reset-code-input'); + if (input) { + input.focus(); + input.select(); + } + }, 100); + } else if (lowerMessage.includes("not found") || + lowerMessage.includes("не найден")) { + errorText = "❌ Пользователь с таким email не найден."; + setTimeout(function () { + self.onResetBackToLogin(); + }, 2000); + } else if (lowerMessage.includes("attempt") || + lowerMessage.includes("попытк") || + lowerMessage.includes("too many")) { + errorText = "⚠️ Слишком много неудачных попыток. Попробуйте позже."; + } else { + errorText = "❌ " + message; + } + + self.showError(errorText); + }) + .finally(function () { + self.loading = false; + }); + }, + + // ШАГ 3: Смена пароля + onChangePassword: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + if (this.resetNewPassword.length < 8) { + this.showError("Пароль должен быть минимум 8 символов"); + this.loading = false; + return; + } + + if (this.resetNewPassword !== this.resetConfirmPassword) { + this.showError("Пароли не совпадают"); + this.loading = false; + return; + } + + if (!this.resetPasswordToken) { + this.showError("❌ Ошибка: токен не найден. Попробуйте начать заново."); + this.loading = false; + return; + } + + var payload = { + reset_password_token: this.resetPasswordToken, + password: this.resetNewPassword, + password_confirmation: this.resetConfirmPassword, + }; + + window.ApiAuth.resetPassword(payload) + .then(function () { + self.showSuccess("✅ Пароль успешно изменен! Теперь вы можете войти."); + self.resetStep = 'done'; + }) + .catch(function (e) { + self.showError(e.message || "❌ Не удалось изменить пароль"); + }) + .finally(function () { + self.loading = false; + }); + }, + + // ПОВТОРНАЯ ОТПРАВКА КОДА ВОССТАНОВЛЕНИЯ + onResendResetCode: function () { + var self = this; + this.clearMessages(); + this.loading = true; + + if (!this.resetToken) { + this.showError("❌ Ошибка: токен не найден. Попробуйте начать заново."); + this.loading = false; + return; + } + + window.ApiAuth.resendRecoveryCode(this.resetToken) + .then(function () { + self.showSuccess("✅ Новый код отправлен на почту"); + self.startResetResendTimer(60); + }) + .catch(function (e) { + var message = e.message || "Не удалось отправить код"; + var lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("expired") || + lowerMessage.includes("истек") || + lowerMessage.includes("timeout")) { + self.showError("⏰ Срок действия сессии истек. Начните восстановление заново."); + setTimeout(function () { + self.onResetBackToLogin(); + }, 2000); + } else { + self.showError("❌ " + message); + } + }) + .finally(function () { + self.loading = false; + }); + }, + + // ВОЗВРАТ К ФОРМЕ ВОССТАНОВЛЕНИЯ + onResetBackToLogin: function () { + this.resetStep = 'form'; + this.resetEmail = ''; + this.resetCode = ''; + this.resetNewPassword = ''; + this.resetConfirmPassword = ''; + this.resetToken = ''; + this.resetPasswordToken = ''; + this.clearMessages(); // ← используем общий метод + if (this.resetTimerInterval) { + clearInterval(this.resetTimerInterval); + this.resetTimerInterval = null; + } + this.currentView = 'login'; + }, + + // ТАЙМЕР ДЛЯ ВОССТАНОВЛЕНИЯ + startResetResendTimer: function (seconds) { + var self = this; + this.resetResendTimer = seconds; + this.resetCanResend = false; + + if (this.resetTimerInterval) { + clearInterval(this.resetTimerInterval); + } + + this.resetTimerInterval = setInterval(function () { + self.resetResendTimer--; + if (self.resetResendTimer <= 0) { + clearInterval(self.resetTimerInterval); + self.resetTimerInterval = null; + self.resetCanResend = true; } - }, + }, 1000); + }, + + // ==================== ОБЩИЕ МЕТОДЫ ==================== + onLogout: function () { + window.ApiAuth.logout(); + window.location.reload(); + }, + + formatRegistrationDate: function (iso) { + if (!iso) { + return "—"; + } + try { + return new Date(iso).toLocaleString("ru-RU"); + } catch (e) { + return iso; + } + }, }; -})(); +})(); \ No newline at end of file diff --git a/frontend/app/services/methods/profile.js b/frontend/app/services/methods/profile.js index c5fe629..2a548ce 100644 --- a/frontend/app/services/methods/profile.js +++ b/frontend/app/services/methods/profile.js @@ -165,6 +165,7 @@ window.Api.logout(); self.profileData = null; self.goLogin(); + window.location.reload(); }) .catch(function (e) { if (e.message === "REFRESH_EXPIRED") { diff --git a/frontend/app/services/methods/routing.js b/frontend/app/services/methods/routing.js index a6a5817..9058df5 100644 --- a/frontend/app/services/methods/routing.js +++ b/frontend/app/services/methods/routing.js @@ -33,6 +33,22 @@ this.error = ""; return; } + if (hash === "#/reset-password") { + this.currentView = "reset-password"; + this.error = ""; + this.success = ""; + this.resetStep = "form"; + this.resetEmail = ""; + this.resetCode = ""; + this.resetNewPassword = ""; + this.resetConfirmPassword = ""; + // Очищаем таймер если был + if (this.resetTimerInterval) { + clearInterval(this.resetTimerInterval); + this.resetTimerInterval = null; + } + return; + } if (hash === "#/profile") { if (!this.isAuthenticated) { this.goLogin(); diff --git a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html index 8c1fdbb..8b63ce5 100644 --- a/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html +++ b/frontend/app/ui/templates/layout_navbar_auth_catalog_genres.html @@ -1,200 +1,575 @@ -
- - -
- - - - - -
-
-
-
-

Вход

-
-
- - + + +
+
+
+
+

Регистрация

+ + + + + +
+
+ + +
+
+ + +
+
+ +
-
- - + + +
+
+ + + minlength="8" maxlength="30" autocomplete="new-password"> +
+
+ + +

+ Уже есть аккаунт? + Войти +

+
+
+
+
+ + +
+
+
+
+

Подтверждение email

+

+ На почту {{ registrationData.email }} отправлен код подтверждения. + Введите его ниже. +

+ + + + +
+
+ + +
+ Введите 6-значный код из письма
-
+ +
+ - -

- Нет аккаунта? - Зарегистрироваться -

+ +
+ + + +
+ + Не пришло письмо? + +
+ +

+ Вернуться ко входу +

+
- -
-
-
-
-

Регистрация

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ + +
+
+
+
+

Восстановление пароля

+

+ Введите email, на который зарегистрирован аккаунт. Мы отправим код для восстановления. +

+ + + + + +
+ + +
+ +
+ + +
+ + +

+ Вернуться ко входу +

+
+
+
+
+ + +
+
+
+
+

Введите код восстановления

+

+ На почту {{ resetEmail }} отправлен код восстановления. + Введите его ниже. +

+ + + + +
+
+ + +
+ Введите 6-значный код из письма
-
+ +
+ - -

- Уже есть аккаунт? - Войти -

+ +
+ + + +
+ + Не пришло письмо? + +
+ +

+ Вернуться ко входу +

+
+ + +
+
+
+
+

Создание нового пароля

+

+ Придумайте новый пароль для аккаунта {{ resetEmail }} +

-
-
-
-
-

Каталог фильмов

-

- Добро пожаловать. Просматривайте жанры без регистрации или войдите в аккаунт для персональных функций — всё это доступно из шапки сайта. -

+ + + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +

+ Вернуться ко входу +

+
- -
-
-
- ← На главную + +
+
+
+
+

✅ Пароль изменен!

+

+ Ваш пароль успешно изменен. Теперь вы можете войти в аккаунт с новым паролем. +

+ + Войти +
-
-
-

🎭 Жанры

-
-
- - -
-
- -
-
- -
-
-

- Результаты по запросу: «{{ genreActiveSearch }}» -

-
+
+
+
+ + +
+
+
+
+

Каталог фильмов

+

+ Добро пожаловать. Просматривайте жанры без регистрации или войдите в аккаунт для персональных функций — всё это доступно из шапки сайта. +

-
-
Загрузка…
+
+
+
+ + +
+
+ +
+
+

🎭 Жанры

+
+
+ + +
+
+ +
+
+ +
+
+

+ Результаты по запросу: «{{ genreActiveSearch }}» +

-
-
+
+
+
Загрузка…
+
+
+
diff --git a/frontend/app/ui/templates/view_admin_movies_profile.html b/frontend/app/ui/templates/view_admin_movies_profile.html index 8755710..97a3b25 100644 --- a/frontend/app/ui/templates/view_admin_movies_profile.html +++ b/frontend/app/ui/templates/view_admin_movies_profile.html @@ -110,7 +110,11 @@
{{ editingMovie ? '✏️ Редактир
-
+
{{ editingGenre ? '✏️ Редактир
diff --git a/frontend/app/ui/templates/view_movie_details.html b/frontend/app/ui/templates/view_movie_details.html index de7ed98..80a297f 100644 --- a/frontend/app/ui/templates/view_movie_details.html +++ b/frontend/app/ui/templates/view_movie_details.html @@ -77,9 +77,6 @@

{{ currentMovie.name || 'Без названия' ⚠️ Для просмотра фильма необходимо войти в аккаунт

-
- URL источника: {{ truncateText(currentMovie.source_url, 50) }} -
diff --git a/frontend/app/ui/templates/view_movie_reviews_favorites_history.html b/frontend/app/ui/templates/view_movie_reviews_favorites_history.html index 08c460f..818bef8 100644 --- a/frontend/app/ui/templates/view_movie_reviews_favorites_history.html +++ b/frontend/app/ui/templates/view_movie_reviews_favorites_history.html @@ -23,25 +23,13 @@
- +
diff --git a/mediaservice/Dockerfile b/media-service/Dockerfile similarity index 82% rename from mediaservice/Dockerfile rename to media-service/Dockerfile index b4e0ba9..595df2e 100644 --- a/mediaservice/Dockerfile +++ b/media-service/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.13-bookworm -WORKDIR /mediaservice +WORKDIR /media-service RUN pip install uv @@ -10,6 +10,6 @@ RUN uv sync COPY packages ./packages -COPY mediaservice . +COPY media-service . CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/mediaservice/api/__init__.py b/media-service/api/__init__.py similarity index 100% rename from mediaservice/api/__init__.py rename to media-service/api/__init__.py diff --git a/mediaservice/api/api_v1/__init__.py b/media-service/api/api_v1/__init__.py similarity index 100% rename from mediaservice/api/api_v1/__init__.py rename to media-service/api/api_v1/__init__.py diff --git a/mediaservice/api/api_v1/file_views.py b/media-service/api/api_v1/file_views.py similarity index 93% rename from mediaservice/api/api_v1/file_views.py rename to media-service/api/api_v1/file_views.py index 2457956..e4c1041 100644 --- a/mediaservice/api/api_v1/file_views.py +++ b/media-service/api/api_v1/file_views.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, UploadFile, status -from packages.constants import S3Bucket -from packages.schemas import ( +from packages.minio.constants import S3Bucket +from packages.schemas.media import ( ConfirmUploadRequest, PresignUrlCreate, PresignUrlResponse, diff --git a/mediaservice/api/main_views.py b/media-service/api/main_views.py similarity index 90% rename from mediaservice/api/main_views.py rename to media-service/api/main_views.py index 0216f72..b6043e8 100644 --- a/mediaservice/api/main_views.py +++ b/media-service/api/main_views.py @@ -21,5 +21,5 @@ def read_root( @router.get("/health") -async def check_health() -> dict[str, str]: +def check_health() -> dict[str, str]: return {"status": "ok"} diff --git a/media-service/core/__init__.py b/media-service/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/media-service/core/celery/__init__.py b/media-service/core/celery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/media-service/core/celery/celery_app.py b/media-service/core/celery/celery_app.py new file mode 100644 index 0000000..51397b0 --- /dev/null +++ b/media-service/core/celery/celery_app.py @@ -0,0 +1,10 @@ +from celery import Celery +from packages.config import settings as package_settings + +from core.constants import CELERY_APP_MODULE, CELERY_TASKS_MODULES + +app = Celery( + CELERY_APP_MODULE, + broker=package_settings.rabbitmq.url, + include=CELERY_TASKS_MODULES, +) diff --git a/mediaservice/core/celery/tasks.py b/media-service/core/celery/tasks.py similarity index 68% rename from mediaservice/core/celery/tasks.py rename to media-service/core/celery/tasks.py index f6fec06..270e0bf 100644 --- a/mediaservice/core/celery/tasks.py +++ b/media-service/core/celery/tasks.py @@ -1,11 +1,14 @@ -import asyncio -from ..rabbitmq.utils import get_minio_service +from packages.celery.constants import TaskType +from packages.celery.utils import sync_run_coroutine_function + +from core.minio.utils import get_minio_service + from .celery_app import app @app.task( # type: ignore[untyped-decorator] - name="mediaservice.media.delete_temporary_file", + name=TaskType.delete_temporary_file.value, ) def delete_temporary_file(bucket_name: str, object_name: str) -> None: async def async_delete_temporary_file() -> None: @@ -16,6 +19,6 @@ async def async_delete_temporary_file() -> None: key=object_name, ) - asyncio.run( + sync_run_coroutine_function( async_delete_temporary_file(), ) diff --git a/mediaservice/core/config.py b/media-service/core/config.py similarity index 87% rename from mediaservice/core/config.py rename to media-service/core/config.py index f4fa52f..455951e 100644 --- a/mediaservice/core/config.py +++ b/media-service/core/config.py @@ -14,7 +14,7 @@ class MinioConfig(BaseModel): temporary_prefix: str = "tmp/" @property - def url_minio(self) -> str: + def url(self) -> str: return f"http://{self.host}:{self.port}" @@ -23,12 +23,12 @@ class CeleryConfig(BaseModel): class Settings(BaseSettings): - BASE_DIR: Path = Path(__file__).parent + base_dir: Path = Path(__file__).parent.parent minio: MinioConfig = MinioConfig() celery: CeleryConfig = CeleryConfig() model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( case_sensitive=False, - env_file=BASE_DIR / ".env", + env_file=base_dir / ".env", env_nested_delimiter="__", ) diff --git a/media-service/core/constants.py b/media-service/core/constants.py new file mode 100644 index 0000000..68e2687 --- /dev/null +++ b/media-service/core/constants.py @@ -0,0 +1,3 @@ +CELERY_APP_MODULE = "core.celery.celery_app" + +CELERY_TASKS_MODULES = ["core.celery.tasks"] diff --git a/media-service/core/minio/__init__.py b/media-service/core/minio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mediaservice/minio_client.py b/media-service/core/minio/client.py similarity index 100% rename from mediaservice/minio_client.py rename to media-service/core/minio/client.py diff --git a/media-service/core/minio/connection.py b/media-service/core/minio/connection.py new file mode 100644 index 0000000..127aec7 --- /dev/null +++ b/media-service/core/minio/connection.py @@ -0,0 +1,11 @@ +from aioboto3 import Session + +S3_SESSION = Session() + + +def get_session() -> Session: + """ + Метод для получения сессии s3 хранилища. + """ + global S3_SESSION # noqa: PLW0602 + return S3_SESSION diff --git a/mediaservice/service.py b/media-service/core/minio/service.py similarity index 81% rename from mediaservice/service.py rename to media-service/core/minio/service.py index 5810bd8..47d4043 100644 --- a/mediaservice/service.py +++ b/media-service/core/minio/service.py @@ -1,13 +1,19 @@ from typing import cast +from urllib.parse import urlsplit from uuid import uuid4 from fastapi import UploadFile -from packages.constants import S3Bucket -from packages.schemas import ConfirmUploadRequest, PresignUrlCreate, PresignUrlResponse +from packages.celery.constants import Queue, TaskType +from packages.minio.constants import S3Bucket +from packages.schemas.media import ( + ConfirmUploadRequest, + PresignUrlCreate, + PresignUrlResponse, +) from core.celery.celery_app import app from core.config import settings -from minio_client import MinioClient +from core.minio.client import MinioClient class MinioService: @@ -39,12 +45,13 @@ async def create_presign_url( ) app.send_task( - name="mediaservice.media.delete_temporary_file", + name=TaskType.delete_temporary_file.value, args=[ presign_url_create.bucket_name.value, object_name, ], countdown=settings.celery.delete_temporary_file_in, + queue=Queue.media_service.value, ) return PresignUrlResponse( presign_url=presign_url.replace("minio", "localhost"), @@ -125,3 +132,18 @@ async def file_exists( bucket_name=bucket_name, object_name=object_name, ) + + @classmethod + def get_bucket_name_from_url(cls, object_url: str) -> str: + parsed_url = urlsplit(object_url) + path_url = parsed_url.path + bucket_name = path_url.split("/")[1] + return bucket_name + + @classmethod + def get_object_name_from_url(cls, object_url: str) -> str: + parsed_url = urlsplit(object_url) + path_url = parsed_url.path + object_name_list = path_url.split("/")[2:] + object_name = "/".join(object_name_list) + return object_name diff --git a/mediaservice/core/rabbitmq/utils.py b/media-service/core/minio/utils.py similarity index 63% rename from mediaservice/core/rabbitmq/utils.py rename to media-service/core/minio/utils.py index 381a51b..7d6f40b 100644 --- a/mediaservice/core/rabbitmq/utils.py +++ b/media-service/core/minio/utils.py @@ -1,34 +1,18 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from urllib.parse import urlsplit from types_aiobotocore_s3 import S3Client from core.config import settings -from dependencies import get_session -from minio_client import MinioClient -from service import MinioService - - -def get_bucket_name_from_url(object_url: str) -> str: - parsed_url = urlsplit(object_url) - path_url = parsed_url.path - bucket_name = path_url.split("/")[1] - return bucket_name - - -def get_object_name_from_url(object_url: str) -> str: - parsed_url = urlsplit(object_url) - path_url = parsed_url.path - object_name_list = path_url.split("/")[2:] - object_name = "/".join(object_name_list) - return object_name +from core.minio.client import MinioClient +from core.minio.connection import get_session +from core.minio.service import MinioService @asynccontextmanager async def get_s3_client( service_name: str = "s3", - endpoint_url: str = settings.minio.url_minio, + endpoint_url: str = settings.minio.url, aws_access_key_id: str = settings.minio.access_key, aws_secret_access_key: str = settings.minio.secret_key, ) -> AsyncGenerator[S3Client]: diff --git a/media-service/core/rabbitmq/__init__.py b/media-service/core/rabbitmq/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mediaservice/core/rabbitmq/consumer.py b/media-service/core/rabbitmq/consumers.py similarity index 77% rename from mediaservice/core/rabbitmq/consumer.py rename to media-service/core/rabbitmq/consumers.py index 3770269..3e0352f 100644 --- a/mediaservice/core/rabbitmq/consumer.py +++ b/media-service/core/rabbitmq/consumers.py @@ -1,13 +1,10 @@ from aio_pika import IncomingMessage -from packages.constants import Exchange, ExchangeType, Queue, S3Bucket +from packages.minio.constants import S3Bucket +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import create_message, get_message, get_rabbitmq_service from core.config import settings -from core.rabbitmq.utils import ( - get_bucket_name_from_url, - get_minio_service, - get_object_name_from_url, -) +from core.minio.utils import get_minio_service async def copy_file(message: IncomingMessage) -> None: @@ -15,13 +12,13 @@ async def copy_file(message: IncomingMessage) -> None: data = get_message(message) entity_id, object_url = data["entity_id"], data["object_url"] - bucket_name = get_bucket_name_from_url(object_url) - object_name = get_object_name_from_url(object_url) + bucket_name = minio_service.get_bucket_name_from_url(object_url) + object_name = minio_service.get_object_name_from_url(object_url) destination_object_name = object_name.replace( settings.minio.temporary_prefix, "", ) - prefix_url = settings.minio.url_minio.replace("minio", "localhost") + prefix_url = settings.minio.url.replace("minio", "localhost") updated_object_url_list = [prefix_url, bucket_name, destination_object_name] updated_object_url = "/".join(updated_object_url_list) @@ -34,8 +31,8 @@ async def copy_file(message: IncomingMessage) -> None: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.mediaservice.value, - type=ExchangeType.direct.value, + name=Exchange.media_service, + type=ExchangeType.direct, durable=True, ) body = { @@ -60,8 +57,8 @@ async def delete_file(message: IncomingMessage) -> None: async with message.process(), get_minio_service() as minio_service: data = get_message(message) object_url = data["object_url"] - bucket_name = get_bucket_name_from_url(object_url) - object_name = get_object_name_from_url(object_url) + bucket_name = minio_service.get_bucket_name_from_url(object_url) + object_name = minio_service.get_object_name_from_url(object_url) await minio_service.delete_file( bucket_name=bucket_name, key=object_name, diff --git a/mediaservice/core/rabbitmq/startup.py b/media-service/core/rabbitmq/startup.py similarity index 79% rename from mediaservice/core/rabbitmq/startup.py rename to media-service/core/rabbitmq/startup.py index cf9c727..26fd14d 100644 --- a/mediaservice/core/rabbitmq/startup.py +++ b/media-service/core/rabbitmq/startup.py @@ -1,25 +1,25 @@ from collections.abc import AsyncGenerator -from packages.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.connection import rabbitmq_connection_startup +from packages.rabbitmq.constants import Exchange, ExchangeType, Queue from packages.rabbitmq.utils import get_rabbitmq_service -from core.rabbitmq.consumer import copy_file, delete_file +from core.rabbitmq.consumers import copy_file, delete_file async def rabbitmq_consumer_queues_startup() -> AsyncGenerator[None]: async with get_rabbitmq_service() as rabbitmq_service: exchange = await rabbitmq_service.declare_exchange( - name=Exchange.app.value, - type=ExchangeType.direct.value, + name=Exchange.app, + type=ExchangeType.direct, durable=True, ) queue_copy = await rabbitmq_service.declare_queue( - name=Queue.copy_file.value, + name=Queue.copy_file, durable=True, ) queue_delete = await rabbitmq_service.declare_queue( - name=Queue.delete_file.value, + name=Queue.delete_file, durable=True, ) diff --git a/mediaservice/dependencies.py b/media-service/dependencies.py similarity index 82% rename from mediaservice/dependencies.py rename to media-service/dependencies.py index 4c4acd0..ad0d3ce 100644 --- a/mediaservice/dependencies.py +++ b/media-service/dependencies.py @@ -6,15 +6,9 @@ from types_aiobotocore_s3 import S3Client from core.config import settings -from minio_client import MinioClient -from service import MinioService - -S3_SESSION = Session() - - -def get_session() -> Session: - global S3_SESSION # noqa: PLW0602 - return S3_SESSION +from core.minio.client import MinioClient +from core.minio.connection import get_session +from core.minio.service import MinioService async def get_client( @@ -25,7 +19,7 @@ async def get_client( ) -> AsyncGenerator[S3Client]: async with session.client( "s3", - endpoint_url=settings.minio.url_minio, + endpoint_url=settings.minio.url, aws_access_key_id=settings.minio.access_key, aws_secret_access_key=settings.minio.secret_key, ) as client: diff --git a/mediaservice/lifespan.py b/media-service/lifespan.py similarity index 100% rename from mediaservice/lifespan.py rename to media-service/lifespan.py diff --git a/mediaservice/main.py b/media-service/main.py similarity index 100% rename from mediaservice/main.py rename to media-service/main.py diff --git a/mediaservice/core/celery/celery_app.py b/mediaservice/core/celery/celery_app.py deleted file mode 100644 index ff986c9..0000000 --- a/mediaservice/core/celery/celery_app.py +++ /dev/null @@ -1,7 +0,0 @@ -from celery import Celery - -app = Celery( - "core.celery.celery_app", - broker="amqp://guest:guest@rabbitmq:5672/%2f", - include=["core.celery.tasks"], -) diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile new file mode 100644 index 0000000..45815f8 --- /dev/null +++ b/notification-service/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-bookworm + +WORKDIR /notification-service + +RUN pip install uv + +COPY pyproject.toml uv.lock ./ + +RUN uv sync + +COPY packages ./packages + +COPY notification-service . + +CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000","--reload"] \ No newline at end of file diff --git a/notification-service/api/__init__.py b/notification-service/api/__init__.py new file mode 100644 index 0000000..ff00717 --- /dev/null +++ b/notification-service/api/__init__.py @@ -0,0 +1,7 @@ +__all__ = ("router",) +from fastapi import APIRouter + +from .api_v1 import router as api_v1_router + +router = APIRouter(prefix="/api") +router.include_router(api_v1_router) diff --git a/notification-service/api/api_v1/__init__.py b/notification-service/api/api_v1/__init__.py new file mode 100644 index 0000000..4114c99 --- /dev/null +++ b/notification-service/api/api_v1/__init__.py @@ -0,0 +1,8 @@ +__all__ = ("router",) + +from fastapi import APIRouter + +from .send_email_views import router as send_email_views_router + +router = APIRouter(prefix="/v1") +router.include_router(send_email_views_router) diff --git a/notification-service/api/api_v1/send_email_views.py b/notification-service/api/api_v1/send_email_views.py new file mode 100644 index 0000000..e73b0e5 --- /dev/null +++ b/notification-service/api/api_v1/send_email_views.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter +from packages.schemas.notification import SendEmailRequest + +from service import EmailService + +router = APIRouter( + tags=["Send Email"], +) + + +@router.get("/send-welcome-email") +async def send_welcome_email_message( + email: str, + name: str, +) -> None: + await EmailService.send_welcome_email( + email=email, + name=name, + ) + + +@router.post("/send-email") +async def send_email( + email_data: SendEmailRequest, +) -> None: + await EmailService.send_email( + subject=email_data.subject, + body=email_data.body, + to_email=email_data.to_email, + ) diff --git a/notification-service/api/main_views.py b/notification-service/api/main_views.py new file mode 100644 index 0000000..b6043e8 --- /dev/null +++ b/notification-service/api/main_views.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Request + +router = APIRouter( + tags=["Main"], +) + + +@router.get("/") +def read_root( + request: Request, + name: str = "Nikolay", +) -> dict[str, str]: + docs_url = request.url.replace( + path="/docs", + query="", + ) + return { + "message": f"Hello {name}!", + "docs": str(docs_url), + } + + +@router.get("/health") +def check_health() -> dict[str, str]: + return {"status": "ok"} diff --git a/notification-service/core/__init__.py b/notification-service/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notification-service/core/celery/__init__.py b/notification-service/core/celery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notification-service/core/celery/celery_app.py b/notification-service/core/celery/celery_app.py new file mode 100644 index 0000000..31f05a3 --- /dev/null +++ b/notification-service/core/celery/celery_app.py @@ -0,0 +1,11 @@ +from celery import Celery +from packages.config import settings as package_settings + +from core.constants import CELERY_APP_MODULE, CELERY_TASKS_MODULES + +app = Celery( + CELERY_APP_MODULE, + broker=package_settings.rabbitmq.url, + backend=package_settings.redis.url, + include=CELERY_TASKS_MODULES, +) diff --git a/notification-service/core/celery/tasks.py b/notification-service/core/celery/tasks.py new file mode 100644 index 0000000..fa7d0b6 --- /dev/null +++ b/notification-service/core/celery/tasks.py @@ -0,0 +1,88 @@ +from packages.celery.constants import TaskType +from packages.celery.utils import sync_run_coroutine_function +from packages.schemas.notification import ( + SendInactiveUsersMovieSelectionData, +) +from pydantic import EmailStr + +from core.celery.celery_app import app +from service import EmailService + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.send_welcome_email.value, +) +def send_welcome_email(email: str, name: str) -> None: + sync_run_coroutine_function( + EmailService.send_welcome_email( + email, + name, + ), + ) + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.send_registration_confirmation_code_email.value, +) +def send_registration_confirmation_code_email( + email: EmailStr, + confirmation_code: str, +) -> None: + sync_run_coroutine_function( + EmailService.send_registration_confirmation_code_email( + email, + confirmation_code, + ), + ) + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.send_auth_confirmation_code_email.value, +) +def send_auth_confirmation_code_email( + email: EmailStr, + confirmation_code: str, +) -> None: + sync_run_coroutine_function( + EmailService.send_auth_confirmation_code_email( + email=email, + confirmation_code=confirmation_code, + ), + ) + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.send_reset_password_confirmation_code_email.value, +) +def send_reset_password_confirmation_code_email( + login: str, + email: EmailStr, + confirmation_code: str, +) -> None: + sync_run_coroutine_function( + EmailService.send_reset_password_confirmation_code_email( + login=login, + email=email, + confirmation_code=confirmation_code, + ), + ) + + +@app.task( # type: ignore[untyped-decorator] + name=TaskType.send_inactive_users_email.value, +) +def send_inactive_users_email(send_inactive_users_email_data: dict) -> None: # type: ignore[type-arg] + send_inactive_users_movie_selection_data = ( + SendInactiveUsersMovieSelectionData.model_validate( + send_inactive_users_email_data, + ) + ) + + inactive_users = send_inactive_users_movie_selection_data.inactive_users + movie_selection = send_inactive_users_movie_selection_data.movie_selection + sync_run_coroutine_function( + EmailService.send_inactive_users_email( + inactive_users=inactive_users, + movie_selection=movie_selection, + ), + ) diff --git a/notification-service/core/config.py b/notification-service/core/config.py new file mode 100644 index 0000000..7239be3 --- /dev/null +++ b/notification-service/core/config.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import ClassVar + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + base_dir: Path = Path(__file__).parent.parent + mail_host: str = "smtp.yandex.ru" + mail_port: int = 587 + corporate_email: str = "email" + corporate_email_password: str = "password" # noqa: S105 + start_tls: bool = True + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + case_sensitive=False, + env_file=base_dir / ".env", + env_nested_delimiter="__", + ) + + +settings = Settings() diff --git a/notification-service/core/constants.py b/notification-service/core/constants.py new file mode 100644 index 0000000..68e2687 --- /dev/null +++ b/notification-service/core/constants.py @@ -0,0 +1,3 @@ +CELERY_APP_MODULE = "core.celery.celery_app" + +CELERY_TASKS_MODULES = ["core.celery.tasks"] diff --git a/notification-service/core/email_templates.py b/notification-service/core/email_templates.py new file mode 100644 index 0000000..d3e6722 --- /dev/null +++ b/notification-service/core/email_templates.py @@ -0,0 +1,113 @@ +# ruff: disable[W291, W293, S105, E501] +WELCOME_EMAIL_SUBJECT = "Потому что вы любите кино так же сильно, как и мы 🎬" + +WELCOME_EMAIL_BODY_TEMPLATE = """ + Дорогой {name}, + + Некоторые люди смотрят фильмы. Другие — живут ими. + + Если вы читаете это, вам, скорее всего, важнее не просто названия и постеры. Вам важны истории. + + Тот самый кадр, который остаётся с вами на дни. + + Именно для этого мы создали MovieAPI. + + Представьте, что это ваш второй дом: + + Записывайте каждый фильм, который вы когда-либо видели + + Открывайте скрытые жемчужины, которые вы никогда не найдёте на популярных сайтах + + Ведите свой личный блокнот с мыслями и оценками + + Никаких алгоритмов, кричащих на вас. Только чистое кино. + + Добро пожаловать домой, {name}. + + Давайте посмотрим что-то великое. + """ + +REGISTRATION_CONFIRMATION_CODE_EMAIL_SUBJECT = ( + "🔑 MovieAPI — Код подтверждения регистрации" +) + +REGISTRATION_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE = """ + Здравствуйте! + + Спасибо за регистрацию в MovieAPI. + + Для завершения создания аккаунта и подтверждения вашего email-адреса, + + пожалуйста, введите следующий код на странице регистрации: {confirmation_code} + + Код действителен в течение 60 секунд. + + Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо. + """ + +AUTH_CONFIRMATION_CODE_EMAIL_SUBJECT = "🛡️ Код безопасности для входа в MovieAPI" + +AUTH_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE = """ + Здравствуйте! + + Выполнен запрос на вход в ваш аккаунт MovieAPI. + + Для подтверждения личности введите одноразовый код безопасности: {confirmation_code} + + Код действует 60 секунд. Никому не сообщайте этот код. + + Если вы не запрашивали вход в аккаунт MovieAPI, просто проигнорируйте это письмо. + """ + +RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_SUBJECT = ( + "🛡️ Восстановление доступа к приложению MovieAPI" +) + +RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE = """ + Здравствуйте! + + Мы получили запрос на восстановление доступа к вашему аккаунту. + + Ваш логин: {login} + + Чтобы войти, создайте новый пароль, используя код подтверждения. + + Ваш код подтверждения: {confirmation_code} + + Код подтверждения действует 60 секунд. + + Если вы не запрашивали восстановление, просто проигнорируйте это письмо. + """ + +INACTIVE_USER_EMAIL_SUBJECT_TEMPLATE = ( + "{name}, мы по вам соскучились! 🎬 Готовы зажечь экран?" +) + +INACTIVE_USER_EMAIL_BODY_TEMPLATE = """ + Привет, {name}! + + Давно не виделись. Мы заметили, что вы уже целую вечность не заглядывали + + в наш кинотеатр, а ведь без вашего мнения обсуждения стали тише... + + Чтобы исправить это, мы подготовили для вас персональную подборку из + + свежих новинок, которые вышли совсем недавно. Мы уверены, что среди них + + есть тот самый фильм, ради которого стоит устроить уютный вечер с пледом и попкорном. + + Ваша эксклюзивная подборка новинок: + """ + +SELECTED_MOVIE_TEMPLATE = """ + Название: {name} + Жанр: {genre_name} + Рейтинг: {rating} + Дата выхода: {release_date} + Подробнее: {source_url} + """ + +EMAIL_FOOTER = """ + — Команда MovieAPI + """ +# ruff: enable[W291, W293, S105, E501] diff --git a/notification-service/lifespan.py b/notification-service/lifespan.py new file mode 100644 index 0000000..232d97f --- /dev/null +++ b/notification-service/lifespan.py @@ -0,0 +1,15 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: # noqa: ARG001 + """ + Действия до старта приложения. + """ + yield + """ + Действия при завершении работы приложения. + """ diff --git a/notification-service/main.py b/notification-service/main.py new file mode 100644 index 0000000..bf3a82c --- /dev/null +++ b/notification-service/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from api import router as api_router +from api.main_views import router as main_router +from lifespan import lifespan + +app = FastAPI( + title="Notification Service", + lifsepan=lifespan, +) + +app.include_router(main_router) +app.include_router(api_router) diff --git a/notification-service/service.py b/notification-service/service.py new file mode 100644 index 0000000..266c82c --- /dev/null +++ b/notification-service/service.py @@ -0,0 +1,165 @@ +from email.message import EmailMessage +from typing import Any + +from aiosmtplib import SMTP +from packages.schemas.notification import ( + InactiveUser, + InactiveUserList, + SelectedMovieList, +) +from pydantic import EmailStr + +from core.config import settings +from core.email_templates import ( + AUTH_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE, + AUTH_CONFIRMATION_CODE_EMAIL_SUBJECT, + EMAIL_FOOTER, + INACTIVE_USER_EMAIL_BODY_TEMPLATE, + INACTIVE_USER_EMAIL_SUBJECT_TEMPLATE, + REGISTRATION_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE, + REGISTRATION_CONFIRMATION_CODE_EMAIL_SUBJECT, + RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE, + RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_SUBJECT, + SELECTED_MOVIE_TEMPLATE, + WELCOME_EMAIL_BODY_TEMPLATE, + WELCOME_EMAIL_SUBJECT, +) + + +class EmailService: + @staticmethod + def get_smtp_client() -> SMTP: + smtp_client = SMTP( + hostname=settings.mail_host, + port=settings.mail_port, + username=settings.corporate_email, + password=settings.corporate_email_password, + start_tls=settings.start_tls, + ) + return smtp_client + + @classmethod + async def send_email( + cls, + subject: str, + body: str, + to_email: EmailStr, + ) -> None: + smtp_client = cls.get_smtp_client() + body_with_footer = cls.add_footer(body=body, footer=EMAIL_FOOTER) + async with smtp_client: + message = EmailMessage() + message["Subject"] = subject + message["From"] = settings.corporate_email + message["To"] = to_email + message.set_content(body_with_footer) + + await smtp_client.send_message( + message, + sender=settings.corporate_email, + recipients=[to_email], + ) + + @classmethod + def add_footer(cls, body: str, footer: str = EMAIL_FOOTER) -> str: + body_with_footer_list = [body, footer] + body_with_footer = "\n".join(body_with_footer_list) + return body_with_footer + + @classmethod + async def send_welcome_email(cls, email: str, name: str) -> None: + await cls.send_email( + subject=WELCOME_EMAIL_SUBJECT, + body=WELCOME_EMAIL_BODY_TEMPLATE.format(name=name), + to_email=email, + ) + + @classmethod + async def send_registration_confirmation_code_email( + cls, + email: EmailStr, + confirmation_code: str, + ) -> None: + await cls.send_email( + subject=REGISTRATION_CONFIRMATION_CODE_EMAIL_SUBJECT, + body=REGISTRATION_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE.format( + confirmation_code=confirmation_code, + ), + to_email=email, + ) + + @classmethod + async def send_auth_confirmation_code_email( + cls, + email: EmailStr, + confirmation_code: str, + ) -> None: + await cls.send_email( + subject=AUTH_CONFIRMATION_CODE_EMAIL_SUBJECT, + body=AUTH_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE.format( + confirmation_code=confirmation_code, + ), + to_email=email, + ) + + @classmethod + async def send_reset_password_confirmation_code_email( + cls, + login: str, + email: EmailStr, + confirmation_code: str, + ) -> None: + await cls.send_email( + subject=RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_SUBJECT, + body=RESET_PASSWORD_CONFIRMATION_CODE_EMAIL_BODY_TEMPLATE.format( + login=login, + confirmation_code=confirmation_code, + ), + to_email=email, + ) + + @classmethod + def create_movie_selection_email(cls, movie_selection: SelectedMovieList) -> str: + movie_selection_list = [ + SELECTED_MOVIE_TEMPLATE.format( + name=movie.name, + genre_name=movie.genre_name, + rating=movie.rating, + source_url=movie.source_url, + release_date=movie.release_date, + ) + for movie in movie_selection.selected_movie_list + ] + movie_selection_body = "\n".join(movie_selection_list) + return movie_selection_body + + @classmethod + async def send_inactive_user_email( + cls, + user: InactiveUser, + subject_template: str, + body_template: str, + **kwargs: Any, + ) -> None: + name = kwargs["name"] + await cls.send_email( + subject=subject_template.format(name=name), + body=body_template.format(name=name), + to_email=user.email, + ) + + @classmethod + async def send_inactive_users_email( + cls, + inactive_users: InactiveUserList, + movie_selection: SelectedMovieList, + ) -> None: + movie_selection_body = cls.create_movie_selection_email(movie_selection) + body_template = INACTIVE_USER_EMAIL_BODY_TEMPLATE + movie_selection_body + for user in inactive_users.inactive_user_list: + await cls.send_inactive_user_email( + user, + INACTIVE_USER_EMAIL_SUBJECT_TEMPLATE, + body_template, + name=user.name, + ) diff --git a/packages/celery/__init__.py b/packages/celery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/celery/constants.py b/packages/celery/constants.py new file mode 100644 index 0000000..091ce5d --- /dev/null +++ b/packages/celery/constants.py @@ -0,0 +1,26 @@ +from enum import StrEnum + + +class Queue(StrEnum): + app = "movie-catalog" + media_service = "media-service" + notification_service = "notification-service" + + +class TaskType(StrEnum): + delete_temporary_file = "media-service.media.delete-temporary-file" + send_welcome_email = "notification-service.email.send-welcome-email" + send_registration_confirmation_code_email = ( + "notification-service.email.send-registration-confirmation-code-email" + ) + send_auth_confirmation_code_email = ( + "notification-service.email.send-auth-confirmation-code-email" + ) + send_reset_password_confirmation_code_email = "notification-service.email.send-reset-password-confirmation-code-email" # noqa: S105 E501 + create_chain_to_notify_inactive_users = ( + "movie-catalog.celery.create-chain-to-notify-inactive-users" + ) + get_data_to_send_inactive_users_email = ( + "movie-catalog.mailing-list.get-data-to-send-inactive-users-email" + ) + send_inactive_users_email = "notification-service.email.send-inactive-users-email" diff --git a/packages/celery/utils.py b/packages/celery/utils.py new file mode 100644 index 0000000..1bdde68 --- /dev/null +++ b/packages/celery/utils.py @@ -0,0 +1,7 @@ +import asyncio +from collections.abc import Coroutine +from typing import Any + + +def sync_run_coroutine_function(coroutine: Coroutine) -> Any: # type: ignore[type-arg] + return asyncio.run(coroutine) diff --git a/packages/config.py b/packages/config.py new file mode 100644 index 0000000..a947a38 --- /dev/null +++ b/packages/config.py @@ -0,0 +1,41 @@ +from pathlib import Path +from typing import ClassVar + +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RabbitMQConfig(BaseModel): + host: str = "rabbitmq" + port: int = 5672 + username: str = "guest" + password: str = "guest" # noqa: S105 + + @property + def url(self) -> str: + return f"amqp://{self.username}:{self.password}@{self.host}:{self.port}/%2f" + + +class RedisConfig(BaseModel): + host: str = "redis" + port: int = 6379 + celery_backend_database: int = 8 + + @property + def url(self) -> str: + return f"redis://{self.host}:{self.port}/{self.celery_backend_database}" + + +class Settings(BaseSettings): + base_dir: Path = Path(__file__).parent + rabbitmq: RabbitMQConfig = RabbitMQConfig() + redis: RedisConfig = RedisConfig() + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + case_sensitive=False, + env_file=base_dir / ".env", + env_nested_delimiter="__", + ) + + +settings = Settings() diff --git a/packages/minio/__init__.py b/packages/minio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/minio/constants.py b/packages/minio/constants.py new file mode 100644 index 0000000..32cd719 --- /dev/null +++ b/packages/minio/constants.py @@ -0,0 +1,23 @@ +from enum import StrEnum + + +class S3Bucket(StrEnum): + genre_posters = "genre-posters" + movie_posters = "movie-posters" + movies = "movies" + + +class S3ContentType(StrEnum): + image_jpeg = "image/jpeg" + image_jpg = "image/jpg" + image_png = "image/png" + image_webp = "image/webp" + video_mp4 = "video/mp4" + + +class S3ClientMethod(StrEnum): + get_objects = "get_object" + put_object = "put_object" + copy_object = "copy_object" + delete_object = "delete_object" + delete_objects = "delete_objects" diff --git a/packages/rabbitmq/__init__.py b/packages/rabbitmq/__init__.py index 4bf165d..c158b89 100644 --- a/packages/rabbitmq/__init__.py +++ b/packages/rabbitmq/__init__.py @@ -1,7 +1,7 @@ __all__ = ( "RabbitMQService", - "get_rabbit_mq_service", + "get_rabbitmq_service", ) -from .dependencies import get_rabbit_mq_service +from .dependencies import get_rabbitmq_service from .service import RabbitMQService diff --git a/packages/rabbitmq/connection.py b/packages/rabbitmq/connection.py index fe59957..36c9f18 100644 --- a/packages/rabbitmq/connection.py +++ b/packages/rabbitmq/connection.py @@ -3,13 +3,15 @@ import aio_pika from aio_pika.abc import AbstractChannel +from packages.config import settings + RABBIT_MQ_CONNECTION = None async def rabbitmq_connection_startup() -> None: global RABBIT_MQ_CONNECTION # noqa: PLW0603 RABBIT_MQ_CONNECTION = await aio_pika.connect_robust( - url="amqp://guest:guest@rabbitmq:5672/%2f", + url=settings.rabbitmq.url, ) diff --git a/packages/constants.py b/packages/rabbitmq/constants.py similarity index 58% rename from packages/constants.py rename to packages/rabbitmq/constants.py index b5fa715..c6d3af2 100644 --- a/packages/constants.py +++ b/packages/rabbitmq/constants.py @@ -3,6 +3,18 @@ from packages.rabbitmq.utils import create_exchange_name, create_queue_name +class ConsumerType(StrEnum): + app = "app" + media_service = "media-service" + notification_service = "notification-service" + + +class ProducerType(StrEnum): + app = "app" + media_service = "media-service" + notification_service = "notification-service" + + class ActionType(StrEnum): update_genre_poster_url = "update_genre_poster_url" update_movie_poster_url = "update_movie_poster_url" @@ -18,73 +30,42 @@ class ExchangeType(StrEnum): headers = "headers" -class S3Bucket(StrEnum): - genre_posters = "genre-posters" - movie_posters = "movie-posters" - movies = "movies" - - -class S3ContentType(StrEnum): - image_jpeg = "image/jpeg" - image_jpg = "image/jpg" - image_png = "image/png" - image_webp = "image/webp" - video_mp4 = "video/mp4" - - -class S3ClientMethod(StrEnum): - get_objects = "get_object" - put_object = "put_object" - copy_object = "copy_object" - delete_object = "delete_object" - delete_objects = "delete_objects" - - class Exchange(StrEnum): app = create_exchange_name( - producer="app", + producer=ProducerType.app, entity="content", exchange_type=ExchangeType.direct, ) - mediaservice = create_exchange_name( - "mediaservice", + media_service = create_exchange_name( + producer=ProducerType.media_service, entity="content", exchange_type=ExchangeType.direct, ) - @staticmethod - def create_queue_name( - consumer: str, - entity: str, - action: "ActionType", - ) -> str: - queue_name = f"{consumer}.{entity}.{action.value}" - return queue_name - class Queue(StrEnum): update_genre_poster_url = create_queue_name( - consumer="app", + consumer=ConsumerType.app, entity="content", action=ActionType.update_genre_poster_url, ) update_movie_poster_url = create_queue_name( - consumer="app", + consumer=ConsumerType.app, entity="content", action=ActionType.update_movie_poster_url, ) update_movie_source_url = create_queue_name( - consumer="app", + consumer=ConsumerType.app, entity="content", action=ActionType.update_movie_source_url, ) copy_file = create_queue_name( - consumer="mediaservice", + consumer=ConsumerType.media_service, entity="content", action=ActionType.copy_file, ) delete_file = create_queue_name( - consumer="mediaservice", + consumer=ConsumerType.media_service, entity="content", action=ActionType.delete_file, ) diff --git a/packages/rabbitmq/dependencies.py b/packages/rabbitmq/dependencies.py index 2f80599..dba82dd 100644 --- a/packages/rabbitmq/dependencies.py +++ b/packages/rabbitmq/dependencies.py @@ -8,7 +8,7 @@ from packages.rabbitmq.service import RabbitMQService -async def get_rabbit_mq_service( +async def get_rabbitmq_service( channel: Annotated[ AbstractChannel, Depends(get_channel), diff --git a/packages/rabbitmq/service.py b/packages/rabbitmq/service.py index bf5fb0b..6eb6bcf 100644 --- a/packages/rabbitmq/service.py +++ b/packages/rabbitmq/service.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from typing import TYPE_CHECKING from aio_pika.abc import ( AbstractChannel, @@ -7,6 +8,9 @@ AbstractQueue, ) +if TYPE_CHECKING: + from packages.rabbitmq.constants import Exchange, ExchangeType, Queue + class RabbitMQService: def __init__(self, channel: AbstractChannel) -> None: @@ -14,23 +18,23 @@ def __init__(self, channel: AbstractChannel) -> None: async def declare_queue( self, - name: str, + name: "Queue", durable: bool = True, ) -> AbstractQueue: return await self.channel.declare_queue( - name=name, + name=name.value, durable=durable, ) async def declare_exchange( self, - name: str, - type: str = "direct", + name: "Exchange", + type: "ExchangeType", durable: bool = True, ) -> AbstractExchange: return await self.channel.declare_exchange( - name=name, - type=type, + name=name.value, + type=type.value, durable=durable, ) diff --git a/packages/rabbitmq/utils.py b/packages/rabbitmq/utils.py index b1d6255..18055f0 100644 --- a/packages/rabbitmq/utils.py +++ b/packages/rabbitmq/utils.py @@ -9,7 +9,12 @@ from packages.rabbitmq import RabbitMQService, connection if TYPE_CHECKING: - from packages.constants import ActionType, ExchangeType + from packages.rabbitmq.constants import ( + ActionType, + ConsumerType, + ExchangeType, + ProducerType, + ) def get_message( @@ -28,20 +33,20 @@ def create_message(body: dict[Any, Any]) -> Message: def create_exchange_name( - producer: str, + producer: "ProducerType", entity: str, exchange_type: "ExchangeType", ) -> str: - exchange_name = f"{producer}.{entity}.{exchange_type.value}" + exchange_name = f"{producer.value}.{entity}.{exchange_type.value}" return exchange_name def create_queue_name( - consumer: str, + consumer: "ConsumerType", entity: str, action: "ActionType", ) -> str: - queue_name = f"{consumer}.{entity}.{action.value}" + queue_name = f"{consumer.value}.{entity}.{action.value}" return queue_name diff --git a/packages/schemas/__init__.py b/packages/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/schemas.py b/packages/schemas/media.py similarity index 92% rename from packages/schemas.py rename to packages/schemas/media.py index bd649e4..32590b7 100644 --- a/packages/schemas.py +++ b/packages/schemas/media.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from packages.constants import S3Bucket, S3ClientMethod, S3ContentType +from packages.minio.constants import S3Bucket, S3ClientMethod, S3ContentType class PresignUrlCreate(BaseModel): diff --git a/packages/schemas/notification.py b/packages/schemas/notification.py new file mode 100644 index 0000000..48ff850 --- /dev/null +++ b/packages/schemas/notification.py @@ -0,0 +1,59 @@ +from datetime import date + +from pydantic import BaseModel, EmailStr + + +class SendEmailRequest(BaseModel): + """ + Модель для отправки сообщения на почту. + """ + + subject: str + to_email: EmailStr + body: str + + +class InactiveUser(BaseModel): + """ + Модель для получения информации о неактивном пользователе. + """ + + name: str + email: EmailStr + + +class InactiveUserList(BaseModel): + """ + Модель для получения списка неактивных пользователей. + """ + + inactive_user_list: list[InactiveUser] + + +class SelectedMovie(BaseModel): + """ + Модель для получения рекомендованного фильма. + """ + + name: str + genre_name: str + rating: int + source_url: str + release_date: date + + +class SelectedMovieList(BaseModel): + """ + Модель для получения списка рекомендованных фильмов. + """ + + selected_movie_list: list[SelectedMovie] + + +class SendInactiveUsersMovieSelectionData(BaseModel): + """ + Модель для получения неактивных пользователей и рекомендованных фильмов. + """ + + inactive_users: InactiveUserList + movie_selection: SelectedMovieList diff --git a/pyproject.toml b/pyproject.toml index 161125f..83f9e16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "aio-pika>=9.6.2", "aioboto3>=15.5.0", + "aiosmtplib>=5.1.1", "alembic>=1.18.4", "asyncpg>=0.31.0", "bcrypt>=5.0.0", @@ -56,7 +57,8 @@ required-version = ">=0.15.11" src = [ "app", - "mediaservice", + "media-service", + "notification-service", ] # Exclude a variety of commonly ignored directories. diff --git a/tests/test_core/test_security/conftest.py b/tests/test_core/test_security/conftest.py index 7a8a2e7..b1e36e1 100644 --- a/tests/test_core/test_security/conftest.py +++ b/tests/test_core/test_security/conftest.py @@ -1,6 +1,6 @@ import pytest -from core.constants import TOKEN_TYPE +from core.constants import TOKEN_TYPE_FIELD from tests.utils.data_generators.base import generate_string @@ -14,7 +14,7 @@ def payload() -> dict[str, str | int]: "sub": sub, "login": login, "email": email, - TOKEN_TYPE: token_type, + TOKEN_TYPE_FIELD: token_type, } return data diff --git a/tests/test_core/test_security/test_jwt_utils.py b/tests/test_core/test_security/test_jwt_utils.py index e749530..e1a7a0d 100644 --- a/tests/test_core/test_security/test_jwt_utils.py +++ b/tests/test_core/test_security/test_jwt_utils.py @@ -2,7 +2,7 @@ import pytest -from core.security.jwt_utils import encode_jwt, decode_jwt +from core.security.jwt.utils import encode_jwt, decode_jwt @pytest.fixture(scope="function") diff --git a/tests/test_core/test_security/test_validators.py b/tests/test_core/test_security/test_validators.py index bab9b64..1b8c817 100644 --- a/tests/test_core/test_security/test_validators.py +++ b/tests/test_core/test_security/test_validators.py @@ -1,6 +1,6 @@ import pytest -from core.constants import TOKEN_TYPE +from core.constants import TOKEN_TYPE_FIELD from core.security.validators import validate_token_payload from tests.utils.data_generators.base import generate_string @@ -8,7 +8,7 @@ def test_validate_token_payload_no_sub(payload: dict[str, str | int]) -> None: payload.pop("sub") with pytest.raises(KeyError): - validate_token_payload(payload, target_token_type=payload[TOKEN_TYPE]) + validate_token_payload(payload, target_token_type=payload[TOKEN_TYPE_FIELD]) def test_validate_token_payload_invalid_data_token_type( @@ -17,5 +17,5 @@ def test_validate_token_payload_invalid_data_token_type( with pytest.raises(TypeError): validate_token_payload( payload, - target_token_type=payload[TOKEN_TYPE] + generate_string(), + target_token_type=payload[TOKEN_TYPE_FIELD] + generate_string(), ) diff --git a/tests/test_schemas/test_user.py b/tests/test_schemas/test_user.py index 8dc3e19..8359da7 100644 --- a/tests/test_schemas/test_user.py +++ b/tests/test_schemas/test_user.py @@ -7,7 +7,6 @@ from schemas.user import ( UserResponse, UserBase, - UserCreate, UserUpdate, UserResponseList, UserPartialUpdate, @@ -38,7 +37,6 @@ "schema", [ UserBase, - UserCreate, UserUpdate, UserPartialUpdate, UserResponse, @@ -138,7 +136,6 @@ def test_user_without_field( @pytest.mark.parametrize( "schema", [ - UserCreate, UserUpdate, ], ) diff --git a/uv.lock b/uv.lock index 9b45298..4732901 100644 --- a/uv.lock +++ b/uv.lock @@ -175,6 +175,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosmtplib" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/ba/34f2fef90d13e21ae3f1b360da98d825c40832bb232613513be92457ff65/aiosmtplib-5.1.1.tar.gz", hash = "sha256:d9a35e9d170bc1a9f66e2fdfe7fd212f7eebb8c1581c621f79395d0bcaba7a68", size = 68123, upload-time = "2026-05-31T17:25:36.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/97/d1030d897e96c79cf0682ff93c11a2118085b3af4c27993675eda9e55da3/aiosmtplib-5.1.1-py3-none-any.whl", hash = "sha256:9d384f0c3d8906f745c1cf6819f073145bb2de8b10407905f5e2ee3389bfe6c7", size = 27937, upload-time = "2026-05-31T17:25:35.283Z" }, +] + [[package]] name = "alembic" version = "1.18.4" @@ -1186,6 +1195,7 @@ source = { virtual = "." } dependencies = [ { name = "aio-pika" }, { name = "aioboto3" }, + { name = "aiosmtplib" }, { name = "alembic" }, { name = "asyncpg" }, { name = "bcrypt" }, @@ -1216,6 +1226,7 @@ dev = [ requires-dist = [ { name = "aio-pika", specifier = ">=9.6.2" }, { name = "aioboto3", specifier = ">=15.5.0" }, + { name = "aiosmtplib", specifier = ">=5.1.1" }, { name = "alembic", specifier = ">=1.18.4" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "bcrypt", specifier = ">=5.0.0" },