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 @@ -