- Добавлен новый маршрут смены почты и связанная с ним бизнес-логика - Добавлен новый маршрут смены пароля и связанная с ним бизнес логика - В схеме `AuthUser` изменена аннотация типа с `str` на `StringConstraints` с учётом минимальной и максимальной длинны пароля - В `alembic.ini` исправлен путь до директории с миграциями после последнего обновления архитектуры под `Poetry 2.1.3` - Добавлен `docker-compose.dev.yaml` для запуска БД и Redis в окружении для разработки - Добавлен `Makefile` с описанием основных команд - Обновлён `README.md`
This commit is contained in:
27
Makefile
Normal file
27
Makefile
Normal 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
|
16
README.md
16
README.md
@ -1,5 +1,10 @@
|
|||||||
# Napkin Tools: Lkeep (Links Keeper)
|
# Napkin Tools: Lkeep (Links Keeper)
|
||||||
|
|
||||||
|

|
||||||
|
[](https://t.me/press_any_button)
|
||||||
|
[](https://t.me/writeanynotes)
|
||||||
|
[](https://t.me/+Li2vbxfWo0Q4ZDk6)
|
||||||
|
|
||||||
Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI,
|
Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI,
|
||||||
PostgreSQL, Poetry, Pydantic и других.
|
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/)
|
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/)
|
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/)
|
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` и заполните его значениями, соответствующими вашей системе (например, настройки
|
Затем откройте файл `.env` и заполните его значениями, соответствующими вашей системе (например, настройки
|
||||||
подключения к базе данных PostgreSQL).
|
подключения к базе данных PostgreSQL).
|
||||||
|
|
||||||
4. **Запустите приложение:**
|
4. **Запустите БД и Redis**
|
||||||
|
|
||||||
|
Для запуска контейнера с PostgreSQL и Redis используйте команду в терминале:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Запустите приложение:**
|
||||||
|
|
||||||
Для запуска сервера в режиме разработки используйте команду с Poetry:
|
Для запуска сервера в режиме разработки используйте команду с Poetry:
|
||||||
```bash
|
```bash
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
[alembic]
|
[alembic]
|
||||||
# path to migration scripts.
|
# path to migration scripts.
|
||||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
# 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
|
# 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
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
39
docker-compose.dev.yaml
Normal file
39
docker-compose.dev.yaml
Normal 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:
|
@ -37,3 +37,7 @@ app = "lkeep.main:start"
|
|||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
black = "^25.1.0"
|
black = "^25.1.0"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
indent-width = 4
|
||||||
|
@ -9,7 +9,9 @@ https://pressanybutton.ru/category/servis-na-fastapi/
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from lkeep.apps.auth.routes import auth_router
|
from lkeep.apps.auth.routes import auth_router
|
||||||
|
from lkeep.apps.profile.routes import profile_router
|
||||||
|
|
||||||
apps_router = APIRouter(prefix="/api/v1")
|
apps_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
apps_router.include_router(router=auth_router)
|
apps_router.include_router(router=auth_router)
|
||||||
|
apps_router.include_router(router=profile_router)
|
||||||
|
@ -8,8 +8,9 @@ https://pressanybutton.ru/category/servis-na-fastapi/
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, StringConstraints
|
||||||
|
|
||||||
|
|
||||||
class GetUserByID(BaseModel):
|
class GetUserByID(BaseModel):
|
||||||
@ -53,7 +54,7 @@ class AuthUser(GetUserByEmail):
|
|||||||
:type password: str
|
:type password: str
|
||||||
"""
|
"""
|
||||||
|
|
||||||
password: str
|
password: Annotated[str, StringConstraints(min_length=8, max_length=128)]
|
||||||
|
|
||||||
|
|
||||||
class CreateUser(GetUserByEmail):
|
class CreateUser(GetUserByEmail):
|
||||||
|
0
src/lkeep/apps/profile/__init__.py
Normal file
0
src/lkeep/apps/profile/__init__.py
Normal file
30
src/lkeep/apps/profile/managers.py
Normal file
30
src/lkeep/apps/profile/managers.py
Normal 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()
|
30
src/lkeep/apps/profile/routes.py
Normal file
30
src/lkeep/apps/profile/routes.py
Normal 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)
|
12
src/lkeep/apps/profile/schemas.py
Normal file
12
src/lkeep/apps/profile/schemas.py
Normal 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)]
|
30
src/lkeep/apps/profile/services.py
Normal file
30
src/lkeep/apps/profile/services.py
Normal 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)
|
Reference in New Issue
Block a user