diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..63efb6d --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: install create_migration_dev install_dev lint migrate run_dev run_prod + +install: + poetry install + +install_dev: install + poetry run pre-commit install + +run_dev: + poetry run app + +run_prod: install migrate + poetry run app + +run_all: + docker compose up -d + $(MAKE) run_prod + +migrate: + poetry run alembic upgrade head + +create_migration_dev: + @read -p "Введите описание ревизии: " msg; \ + poetry run alembic revision --autogenerate -m "$$msg" + +lint: + poetry run pre-commit run --all diff --git a/README.md b/README.md index 8fbe67c..f4f115f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Napkin Tools: Lkeep (Links Keeper) +![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/proDreams/lkeep/lint.yaml) +[![Код на салфетке](https://img.shields.io/badge/Telegram-Код_на_салфетке-blue)](https://t.me/press_any_button) +[![Заметки на салфетке](https://img.shields.io/badge/Telegram-Заметки_на_салфетке-blue)](https://t.me/writeanynotes) +[![Кот на салфетке чат](https://img.shields.io/badge/Telegram-Кот_на_салфетке_чат-blue)](https://t.me/+Li2vbxfWo0Q4ZDk6) + Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI, PostgreSQL, Poetry, Pydantic и других. @@ -53,6 +58,8 @@ PostgreSQL, Poetry, Pydantic и других. 6. [FastAPI 6. Пользовательский сервис и маршруты регистрации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-6-polzovatelskij-servis-i-marshruty-regist/) 7. [FastAPI 7. Электронная почта, подтверждение регистрации, Celery и Redis](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-7-elektronnaya-pochta-podtverzhdenie-registracii-celery-i-redis/) 8. [FastAPI 8. Маршрут авторизации и JWT](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-8-marshrut-avtorizacii-i-jwt/) +9. [FastAPI 9. Logout и проверка авторизации](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-9-logout-i-proverka-avtorizacii/) +10. [FastAPI 10. Изменение данных пользователя]() ## Установка @@ -97,7 +104,14 @@ PostgreSQL, Poetry, Pydantic и других. Затем откройте файл `.env` и заполните его значениями, соответствующими вашей системе (например, настройки подключения к базе данных PostgreSQL). -4. **Запустите приложение:** +4. **Запустите БД и Redis** + + Для запуска контейнера с PostgreSQL и Redis используйте команду в терминале: + ```bash + docker compose up -d + ``` + +5. **Запустите приложение:** Для запуска сервера в режиме разработки используйте команду с Poetry: ```bash diff --git a/alembic.ini b/alembic.ini index 956785b..8ea6270 100644 --- a/alembic.ini +++ b/alembic.ini @@ -3,7 +3,7 @@ [alembic] # path to migration scripts. # Use forward slashes (/) also on windows to provide an os agnostic path -script_location = lkeep/database/alembic +script_location = src/lkeep/database/alembic # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # Uncomment the line below if you want the files to be prepended with date and time diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..f689ef3 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,39 @@ +services: + lkeep_db: + image: postgres:17.5-alpine + container_name: lkeep_db + restart: always + environment: + - TZ=Europe/Moscow + - POSTGRES_USER=${DB_USER} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME} + - PGDATA=/var/lib/postgresql/data/pgdata + ports: + - "5432:5432" + volumes: + - lkeep_db:/var/lib/postgresql/data + - /etc/localtime:/etc/localtime:ro + healthcheck: + test: [ "CMD-SHELL", "sh -c 'pg_isready -U ${DB_USER} -d ${DB_NAME}'" ] + interval: 10s + timeout: 3s + retries: 3 + + lkeep_redis: + image: redis:7.4-alpine + container_name: lkeep_redis + restart: always + ports: + - "6379:6379" + volumes: + - lkeep_redis:/data + healthcheck: + test: [ "CMD-SHELL", "redis-cli", "ping" ] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + lkeep_db: + lkeep_redis: diff --git a/pyproject.toml b/pyproject.toml index 651402e..4db49ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,3 +37,7 @@ app = "lkeep.main:start" [tool.poetry.group.dev.dependencies] black = "^25.1.0" + +[tool.ruff] +line-length = 120 +indent-width = 4 diff --git a/src/lkeep/apps/__init__.py b/src/lkeep/apps/__init__.py index 98e016b..41c8a47 100644 --- a/src/lkeep/apps/__init__.py +++ b/src/lkeep/apps/__init__.py @@ -9,7 +9,9 @@ https://pressanybutton.ru/category/servis-na-fastapi/ from fastapi import APIRouter from lkeep.apps.auth.routes import auth_router +from lkeep.apps.profile.routes import profile_router apps_router = APIRouter(prefix="/api/v1") apps_router.include_router(router=auth_router) +apps_router.include_router(router=profile_router) diff --git a/src/lkeep/apps/auth/schemas.py b/src/lkeep/apps/auth/schemas.py index cecafb1..d1e09ac 100644 --- a/src/lkeep/apps/auth/schemas.py +++ b/src/lkeep/apps/auth/schemas.py @@ -8,8 +8,9 @@ https://pressanybutton.ru/category/servis-na-fastapi/ import datetime import uuid +from typing import Annotated -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, StringConstraints class GetUserByID(BaseModel): @@ -53,7 +54,7 @@ class AuthUser(GetUserByEmail): :type password: str """ - password: str + password: Annotated[str, StringConstraints(min_length=8, max_length=128)] class CreateUser(GetUserByEmail): diff --git a/src/lkeep/apps/profile/__init__.py b/src/lkeep/apps/profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lkeep/apps/profile/managers.py b/src/lkeep/apps/profile/managers.py new file mode 100644 index 0000000..62328cb --- /dev/null +++ b/src/lkeep/apps/profile/managers.py @@ -0,0 +1,30 @@ +import uuid +from typing import Any + +from fastapi import Depends +from sqlalchemy import select, update + +from lkeep.core.core_dependency.db_dependency import DBDependency +from lkeep.database.models import User + + +class ProfileManager: + def __init__(self, db: DBDependency = Depends(DBDependency)) -> None: + self.db = db + self.user_model = User + + async def update_user_fields(self, user_id: uuid.UUID | str, **kwargs: Any) -> None: + async with self.db.db_session() as session: + query = update(self.user_model).where(self.user_model.id == user_id).values(**kwargs) + + await session.execute(query) + + await session.commit() + + async def get_user_hashed_password(self, user_id: uuid.UUID | str) -> str: + async with self.db.db_session() as session: + query = select(self.user_model.hashed_password).where(self.user_model.id == user_id) + + result = await session.execute(query) + + return result.scalar() diff --git a/src/lkeep/apps/profile/routes.py b/src/lkeep/apps/profile/routes.py new file mode 100644 index 0000000..01212a5 --- /dev/null +++ b/src/lkeep/apps/profile/routes.py @@ -0,0 +1,30 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from starlette import status +from starlette.responses import Response + +from lkeep.apps.auth.depends import get_current_user +from lkeep.apps.auth.schemas import UserVerifySchema +from lkeep.apps.profile.schemas import ChangeEmailRequest, ChangePasswordRequest +from lkeep.apps.profile.services import ProfileService + +profile_router = APIRouter(prefix="/profile", tags=["profile"]) + + +@profile_router.post("/change-email", status_code=status.HTTP_200_OK) +async def change_email( + data: ChangeEmailRequest, + user: Annotated[UserVerifySchema, Depends(get_current_user)], + service: ProfileService = Depends(ProfileService), +) -> None: + return await service.change_email(data=data, user=user) + + +@profile_router.post("/change-password", status_code=status.HTTP_200_OK) +async def change_password( + data: ChangePasswordRequest, + user: Annotated[UserVerifySchema, Depends(get_current_user)], + service: ProfileService = Depends(ProfileService), +) -> Response: + return await service.change_password(data=data, user=user) diff --git a/src/lkeep/apps/profile/schemas.py b/src/lkeep/apps/profile/schemas.py new file mode 100644 index 0000000..d395dbf --- /dev/null +++ b/src/lkeep/apps/profile/schemas.py @@ -0,0 +1,12 @@ +from typing import Annotated + +from pydantic import BaseModel, EmailStr, StringConstraints + + +class ChangeEmailRequest(BaseModel): + new_email: EmailStr + + +class ChangePasswordRequest(BaseModel): + old_password: Annotated[str, StringConstraints(min_length=8, max_length=128)] + new_password: Annotated[str, StringConstraints(min_length=8, max_length=128)] diff --git a/src/lkeep/apps/profile/services.py b/src/lkeep/apps/profile/services.py new file mode 100644 index 0000000..c17eb4f --- /dev/null +++ b/src/lkeep/apps/profile/services.py @@ -0,0 +1,30 @@ +from fastapi import Depends +from starlette.responses import JSONResponse + +from lkeep.apps.auth.handlers import AuthHandler +from lkeep.apps.auth.schemas import UserVerifySchema +from lkeep.apps.profile.managers import ProfileManager +from lkeep.apps.profile.schemas import ChangeEmailRequest, ChangePasswordRequest + + +class ProfileService: + def __init__( + self, + manager: ProfileManager = Depends(ProfileManager), + handler: AuthHandler = Depends(AuthHandler), + ) -> None: + self.manager = manager + self.handler = handler + + async def change_email(self, data: ChangeEmailRequest, user: UserVerifySchema) -> None: + return await self.manager.update_user_fields(user_id=user.id, email=data.new_email) + + async def change_password(self, data: ChangePasswordRequest, user: UserVerifySchema) -> None | JSONResponse: + current_password_hash = await self.manager.get_user_hashed_password(user_id=user.id) + + if await self.handler.verify_password(raw_password=data.old_password, hashed_password=current_password_hash): + hashed_password = await self.handler.get_password_hash(password=data.new_password) + await self.manager.update_user_fields(user_id=user.id, hashed_password=hashed_password) + return None + + return JSONResponse({"error": "Invalid password"}, status_code=401)