feat: приложение профиля
Some checks failed
Lint project / lint (push) Has been cancelled

- Добавлен новый маршрут смены почты и связанная с ним бизнес-логика
- Добавлен новый маршрут смены пароля и связанная с ним бизнес логика
- В схеме `AuthUser` изменена аннотация типа с `str` на `StringConstraints` с учётом минимальной и максимальной длинны пароля
- В `alembic.ini` исправлен путь до директории с миграциями после последнего обновления архитектуры под `Poetry 2.1.3`
- Добавлен `docker-compose.dev.yaml` для запуска БД и Redis в окружении для разработки
- Добавлен `Makefile` с описанием основных команд
- Обновлён `README.md`
This commit is contained in:
2025-07-23 02:03:16 +04:00
parent 170492a5c2
commit 5a299cf47d
12 changed files with 193 additions and 4 deletions

27
Makefile Normal file
View File

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

View File

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

View File

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

39
docker-compose.dev.yaml Normal file
View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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