Compare commits
7 Commits
40d45e8379
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e80b4ab99 | |||
| c74c8bffff | |||
| be4e939f39 | |||
| 5a299cf47d | |||
| 170492a5c2 | |||
| ae14c51b0e | |||
| 78cc7e3f54 |
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: 'pip'
|
||||
env:
|
||||
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry==2.0.0
|
||||
pip install poetry==2.1.3
|
||||
poetry install
|
||||
poetry run pre-commit install
|
||||
|
||||
|
||||
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache pre-commit hooks
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry==2.0.0
|
||||
pip install poetry==2.1.3
|
||||
poetry install
|
||||
poetry run pre-commit install
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ repos:
|
||||
rev: v3.19.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [ --py312-plus ]
|
||||
args: [ --py313-plus ]
|
||||
|
||||
- repo: https://github.com/hhatto/autopep8
|
||||
rev: v2.3.1
|
||||
@@ -39,7 +39,7 @@ repos:
|
||||
rev: 24.10.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.12
|
||||
language_version: python3.13
|
||||
args: [ --line-length=120 ]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
|
||||
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
|
||||
18
README.md
18
README.md
@@ -1,5 +1,10 @@
|
||||
# Napkin Tools: Lkeep (Links Keeper)
|
||||
|
||||

|
||||
[](https://t.me/press_any_button)
|
||||
[](https://t.me/writeanynotes)
|
||||
[](https://t.me/+Li2vbxfWo0Q4ZDk6)
|
||||
|
||||
Lkeep — сервис сокращения ссылок, написанный на Python с использованием современных технологий, таких как FastAPI,
|
||||
PostgreSQL, Poetry, Pydantic и других.
|
||||
|
||||
@@ -53,6 +58,10 @@ 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. Изменение данных пользователя](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-10-izmenenie-dannyh-polzovatelya/)
|
||||
11. [FastAPI 11. Хранение и сокращение ссылок](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-11-hranenie-i-sokrashenie-ssylok/)
|
||||
11. [FastAPI 12. Интеграция Starlette Admin](https://pressanybutton.ru/post/servis-na-fastapi/fastapi-12-integraciya-starlette-admin/)
|
||||
|
||||
## Установка
|
||||
|
||||
@@ -97,7 +106,14 @@ PostgreSQL, Poetry, Pydantic и других.
|
||||
Затем откройте файл `.env` и заполните его значениями, соответствующими вашей системе (например, настройки
|
||||
подключения к базе данных PostgreSQL).
|
||||
|
||||
4. **Запустите приложение:**
|
||||
4. **Запустите БД и Redis**
|
||||
|
||||
Для запуска контейнера с PostgreSQL и Redis используйте команду в терминале:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Запустите приложение:**
|
||||
|
||||
Для запуска сервера в режиме разработки используйте команду с Poetry:
|
||||
```bash
|
||||
|
||||
@@ -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
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:
|
||||
818
poetry.lock
generated
818
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
2
poetry.toml
Normal file
2
poetry.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
@@ -6,31 +6,40 @@ authors = [
|
||||
{ name = "proDream", email = "sushkoos@gmail.com" }
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
requires-python = ">=3.12,<4"
|
||||
dependencies = [
|
||||
"fastapi[standard] (>=0.115.6,<0.116.0)",
|
||||
"uvicorn[standard] (>=0.34.0,<0.35.0)",
|
||||
"pre-commit (>=4.0.1,<5.0.0)",
|
||||
"sqlalchemy (>=2.0.37,<3.0.0)",
|
||||
"fastapi[standard] (>=0.118.0,<0.120.0)",
|
||||
"uvicorn[standard] (>=0.37.0,<0.40.0)",
|
||||
"pre-commit (>=4.3.0,<5.0.0)",
|
||||
"sqlalchemy (>=2.0.44,<3.0.0)",
|
||||
"asyncpg (>=0.30.0,<0.31.0)",
|
||||
"pydantic-settings (>=2.7.1,<3.0.0)",
|
||||
"alembic (>=1.14.0,<2.0.0)",
|
||||
"ruff (>=0.9.0,<0.10.0)",
|
||||
"pydantic[email] (>=2.10.5,<3.0.0)",
|
||||
"pydantic-settings (>=2.11.0,<3.0.0)",
|
||||
"alembic (>=1.17.0,<2.0.0)",
|
||||
"ruff (>=0.14.0,<0.20.0)",
|
||||
"pydantic[email] (>=2.12.1,<3.0.0)",
|
||||
"passlib (>=1.7.4,<2.0.0)",
|
||||
"bcrypt (==4.0.1)",
|
||||
"celery (>=5.4.0,<6.0.0)",
|
||||
"redis (>=5.2.1,<6.0.0)",
|
||||
"celery (>=5.5.3,<6.0.0)",
|
||||
"redis (>=6.4.0,<7.0.0)",
|
||||
"itsdangerous (>=2.2.0,<3.0.0)",
|
||||
"pyjwt (>=2.10.1,<3.0.0)",
|
||||
"poetry-core (>=2.2.1,<3.0.0)",
|
||||
"starlette-admin (>=0.15.1,<0.16.0)",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
packages = [{include = "lkeep", from = "src"}]
|
||||
|
||||
[project.scripts]
|
||||
app = "lkeep.main:start"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^25.1.0"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
indent-width = 4
|
||||
|
||||
@@ -9,7 +9,11 @@ https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lkeep.apps.auth.routes import auth_router
|
||||
from lkeep.apps.links.routes import links_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)
|
||||
apps_router.include_router(router=links_router)
|
||||
80
src/lkeep/apps/admin/admin_auth.py
Normal file
80
src/lkeep/apps/admin/admin_auth.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from fastapi import HTTPException
|
||||
from pydantic import EmailStr, ValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette_admin.auth import AuthProvider
|
||||
from starlette_admin.exceptions import LoginFailed
|
||||
|
||||
from lkeep.apps.auth.handlers import AuthHandler
|
||||
from lkeep.apps.auth.managers import UserManager
|
||||
from lkeep.apps.auth.schemas import AuthUser, GetUserByID
|
||||
from lkeep.apps.auth.utils import get_token_from_cookies
|
||||
from lkeep.core.core_dependency.db_dependency import DBDependency
|
||||
from lkeep.core.core_dependency.redis_dependency import RedisDependency
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
|
||||
class AdminAuthProvider(AuthProvider):
|
||||
def __init__(self, handler: AuthHandler, manager: UserManager):
|
||||
super().__init__()
|
||||
self.handler = handler
|
||||
self.manager = manager
|
||||
|
||||
async def is_authenticated(self, request: Request) -> bool:
|
||||
try:
|
||||
token = await get_token_from_cookies(request=request)
|
||||
decoded_token = await self.handler.decode_access_token(token=token)
|
||||
except HTTPException:
|
||||
return False
|
||||
|
||||
user_id = str(decoded_token.get("user_id"))
|
||||
session_id = str(decoded_token.get("session_id"))
|
||||
|
||||
if not await self.manager.get_access_token(user_id=user_id, session_id=session_id):
|
||||
return False
|
||||
|
||||
request.state.user = {"id": user_id, "session_id": session_id}
|
||||
return True
|
||||
|
||||
async def login(
|
||||
self, email: EmailStr, password: str, remember_me: bool, request: Request, response: Response
|
||||
) -> Response:
|
||||
try:
|
||||
auth_data = AuthUser(email=email, password=password)
|
||||
except ValidationError:
|
||||
raise LoginFailed(msg="Invalid email or password")
|
||||
exist_user = await self.manager.get_user_by_email_for_admin(email=auth_data.email)
|
||||
|
||||
if (
|
||||
exist_user is None
|
||||
or not exist_user.is_superuser
|
||||
or not await self.handler.verify_password(
|
||||
hashed_password=exist_user.hashed_password, raw_password=auth_data.password
|
||||
)
|
||||
):
|
||||
raise LoginFailed(msg="Invalid credentials")
|
||||
|
||||
token, session_id = await self.handler.create_access_token(user_id=exist_user.id)
|
||||
|
||||
await self.manager.store_access_token(token=token, user_id=exist_user.id, session_id=session_id)
|
||||
|
||||
response.set_cookie(key="Authorization", value=token, httponly=True, max_age=settings.access_token_expire)
|
||||
|
||||
return response
|
||||
|
||||
async def logout(self, request: Request, response: Response) -> Response:
|
||||
response.delete_cookie("Authorization")
|
||||
|
||||
user = request.state.user
|
||||
|
||||
await self.manager.revoke_access_token(user_id=user["id"], session_id=user["session_id"])
|
||||
|
||||
return response
|
||||
|
||||
def get_admin_user(self, request: Request) -> GetUserByID:
|
||||
return GetUserByID(**request.state.user)
|
||||
|
||||
|
||||
def get_admin_auth_provider() -> AdminAuthProvider:
|
||||
manager = UserManager(db=DBDependency(), redis=RedisDependency())
|
||||
return AdminAuthProvider(handler=AuthHandler(), manager=manager)
|
||||
17
src/lkeep/apps/admin/admin_base.py
Normal file
17
src/lkeep/apps/admin/admin_base.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import FastAPI
|
||||
from starlette_admin.contrib.sqla import Admin
|
||||
|
||||
from lkeep.apps.admin.admin_auth import get_admin_auth_provider
|
||||
from lkeep.apps.admin.views.link_view import LinkViewView
|
||||
from lkeep.apps.admin.views.user_view import UserView
|
||||
from lkeep.core.core_dependency.db_dependency import get_db_engine
|
||||
from lkeep.database.models import Link, User
|
||||
|
||||
|
||||
def setup_admin(app: FastAPI) -> None:
|
||||
admin = Admin(engine=get_db_engine(), title="Lkeep Admin", auth_provider=get_admin_auth_provider())
|
||||
|
||||
admin.add_view(UserView(User))
|
||||
admin.add_view(LinkViewView(Link))
|
||||
|
||||
admin.mount_to(app=app)
|
||||
0
src/lkeep/apps/admin/views/__init__.py
Normal file
0
src/lkeep/apps/admin/views/__init__.py
Normal file
20
src/lkeep/apps/admin/views/link_view.py
Normal file
20
src/lkeep/apps/admin/views/link_view.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette_admin import StringField
|
||||
from starlette_admin.contrib.sqla import ModelView
|
||||
|
||||
from lkeep.database.models import Link
|
||||
|
||||
|
||||
class LinkViewView(ModelView):
|
||||
fields = [
|
||||
"id",
|
||||
"full_link",
|
||||
"short_link",
|
||||
StringField("owner_id", label="owner_id", read_only=True),
|
||||
]
|
||||
|
||||
async def before_create(self, request: Request, data: dict[str, Any], link: Link):
|
||||
admin_user = request.state.user
|
||||
link.owner_id = admin_user["id"]
|
||||
49
src/lkeep/apps/admin/views/user_view.py
Normal file
49
src/lkeep/apps/admin/views/user_view.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from typing import Any
|
||||
|
||||
from email_validator import EmailSyntaxError, validate_email
|
||||
from passlib.context import CryptContext
|
||||
from starlette.requests import Request
|
||||
from starlette_admin import PasswordField
|
||||
from starlette_admin.contrib.sqla import ModelView
|
||||
from starlette_admin.exceptions import FormValidationError
|
||||
|
||||
from lkeep.database.models import User
|
||||
|
||||
|
||||
class UserView(ModelView):
|
||||
fields = [
|
||||
"id",
|
||||
"email",
|
||||
PasswordField(
|
||||
name="password",
|
||||
label="Password",
|
||||
required=True,
|
||||
exclude_from_list=True,
|
||||
exclude_from_detail=True,
|
||||
exclude_from_edit=True,
|
||||
),
|
||||
"is_active",
|
||||
"is_verified",
|
||||
"is_superuser",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
exclude_fields_from_edit = ["created_at", "updated_at"]
|
||||
exclude_fields_from_create = ["created_at", "updated_at"]
|
||||
|
||||
sortable_fields = ["email", "created_at"]
|
||||
fields_default_sort = [("created_at", True)]
|
||||
searchable_fields = ["email"]
|
||||
|
||||
page_size = 20
|
||||
page_size_options = [5, 10, 25, 50, -1]
|
||||
|
||||
async def before_create(self, request: Request, data: dict[str, Any], user: User) -> None:
|
||||
try:
|
||||
validate_email(data["email"])
|
||||
except EmailSyntaxError:
|
||||
raise FormValidationError(errors={"email": "Invalid email"})
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
user.hashed_password = pwd_context.hash(data.pop("password"))
|
||||
52
src/lkeep/apps/auth/depends.py
Normal file
52
src/lkeep/apps/auth/depends.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from starlette import status
|
||||
|
||||
from lkeep.apps.auth.handlers import AuthHandler
|
||||
from lkeep.apps.auth.managers import UserManager
|
||||
from lkeep.apps.auth.schemas import UserVerifySchema
|
||||
from lkeep.apps.auth.utils import get_token_from_cookies
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(get_token_from_cookies)],
|
||||
handler: AuthHandler = Depends(AuthHandler),
|
||||
manager: UserManager = Depends(UserManager),
|
||||
) -> UserVerifySchema:
|
||||
"""
|
||||
Получает текущего пользователя из токена аутентификации.
|
||||
|
||||
:param token: Токен аутентификации, полученный из куки.
|
||||
:type token: str
|
||||
:param handler: Обработчик аутентификации, использующийся для декодирования токена.
|
||||
:type handler: AuthHandler
|
||||
:param manager: Менеджер пользователей, используется для проверки и получения данных о пользователе.
|
||||
:type manager: UserManager
|
||||
:returns: Схема с информацией о текущем пользователе.
|
||||
:rtype: UserVerifySchema
|
||||
:raises HTTPException: Если токен невалиден или пользователь не найден.
|
||||
"""
|
||||
decoded_token = await handler.decode_access_token(token=token)
|
||||
user_id = str(decoded_token.get("user_id"))
|
||||
session_id = str(decoded_token.get("session_id"))
|
||||
|
||||
if not await manager.get_access_token(user_id=user_id, session_id=session_id):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token is invalid")
|
||||
|
||||
user = await manager.get_user_by_id(user_id=uuid.UUID(user_id))
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
user.session_id = session_id
|
||||
|
||||
return user
|
||||
@@ -10,7 +10,9 @@ import datetime
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
from passlib.context import CryptContext
|
||||
from starlette import status
|
||||
|
||||
from lkeep.apps.auth.named_tuples import CreateTokenTuple
|
||||
from lkeep.core.settings import settings
|
||||
@@ -70,3 +72,22 @@ class AuthHandler:
|
||||
encoded_jwt = jwt.encode(payload=data, key=self.secret, algorithm="HS256")
|
||||
|
||||
return CreateTokenTuple(encoded_jwt=encoded_jwt, session_id=session_id)
|
||||
|
||||
async def decode_access_token(self, token: str) -> dict:
|
||||
"""
|
||||
Декодирует JWT-токен и возвращает его содержимое.
|
||||
|
||||
:param token: Строка с JWT-токеном, который нужно декодировать.
|
||||
:type token: str
|
||||
:returns: Данные, содержащиеся в декодированном токене.
|
||||
:rtype: dict
|
||||
:raises HTTPException: При ошибке декодирования токена (например, токен просрочен или невалиден).
|
||||
Статус-код ответа 401 UNAUTHORIZED, детализация "Token has expired" при просрочке,
|
||||
и "Invalid token" при недопустимости токена.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(jwt=token, key=self.secret, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
@@ -12,7 +12,13 @@ from fastapi import Depends, HTTPException
|
||||
from sqlalchemy import insert, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from lkeep.apps.auth.schemas import CreateUser, GetUserWithIDAndEmail, UserReturnData
|
||||
from lkeep.apps.auth.schemas import (
|
||||
CreateUser,
|
||||
GetUserForAdmin,
|
||||
GetUserWithIDAndEmail,
|
||||
UserReturnData,
|
||||
UserVerifySchema,
|
||||
)
|
||||
from lkeep.core.core_dependency.db_dependency import DBDependency
|
||||
from lkeep.core.core_dependency.redis_dependency import RedisDependency
|
||||
from lkeep.database.models import User
|
||||
@@ -93,6 +99,41 @@ class UserManager:
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_email_for_admin(self, email: str) -> GetUserForAdmin | None:
|
||||
async with self.db.db_session() as session:
|
||||
query = select(self.model.id, self.model.email, self.model.hashed_password, self.model.is_superuser).where(
|
||||
self.model.email == email
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
user = result.mappings().first()
|
||||
|
||||
if user:
|
||||
return GetUserForAdmin(**user)
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_id(self, user_id: uuid.UUID | str) -> UserVerifySchema | None:
|
||||
"""
|
||||
Возвращает информацию о пользователе по его идентификатору.
|
||||
|
||||
:param user_id: Идентификатор пользователя, для которого нужно получить информацию.
|
||||
Может быть представлен в виде UUID или строки.
|
||||
:type user_id: uuid.UUID | str
|
||||
:returns: Схема данных пользователя, если пользователь найден; None, если пользователь не найден.
|
||||
:rtype: UserVerifySchema | None
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
query = select(self.model.id, self.model.email).where(self.model.id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
user = result.mappings().one_or_none()
|
||||
|
||||
if user:
|
||||
return UserVerifySchema(**user)
|
||||
|
||||
return None
|
||||
|
||||
async def store_access_token(self, token: str, user_id: uuid.UUID | str, session_id: str) -> None:
|
||||
"""
|
||||
Сохраняет токен доступа в хранилище (Redis).
|
||||
@@ -106,3 +147,30 @@ class UserManager:
|
||||
"""
|
||||
async with self.redis.get_client() as client:
|
||||
await client.set(f"{user_id}:{session_id}", token)
|
||||
|
||||
async def get_access_token(self, user_id: uuid.UUID | str, session_id: str) -> str | None:
|
||||
"""
|
||||
Получает токен доступа из кэша по идентификаторам пользователя и сессии.
|
||||
|
||||
:param user_id: Идентификатор пользователя, может быть в формате UUID или строка.
|
||||
:type user_id: uuid.UUID | str
|
||||
:param session_id: Идентификатор сессии, строка.
|
||||
:type session_id: str
|
||||
:returns: Токен доступа из кэша, если он существует; иначе None.
|
||||
:rtype: str | None
|
||||
"""
|
||||
async with self.redis.get_client() as client:
|
||||
return await client.get(f"{user_id}:{session_id}")
|
||||
|
||||
async def revoke_access_token(self, user_id: uuid.UUID | str, session_id: str | uuid.UUID | None) -> None:
|
||||
"""
|
||||
Отзывает доступный токен доступа пользователя.
|
||||
|
||||
:param user_id: Идентификатор пользователя, которому принадлежит токен.
|
||||
:type user_id: uuid.UUID | str
|
||||
:param session_id: Идентификатор сессии, для которой должен быть отозван токен.
|
||||
Если None, то все сессии пользователя будут отозваны.
|
||||
:type session_id: str | uuid.UUID | None
|
||||
"""
|
||||
async with self.redis.get_client() as client:
|
||||
await client.delete(f"{user_id}:{session_id}")
|
||||
@@ -6,11 +6,14 @@
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from starlette import status
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from lkeep.apps.auth.schemas import AuthUser, UserReturnData
|
||||
from lkeep.apps.auth.depends import get_current_user
|
||||
from lkeep.apps.auth.schemas import AuthUser, UserReturnData, UserVerifySchema
|
||||
from lkeep.apps.auth.services import UserService
|
||||
|
||||
auth_router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -63,3 +66,35 @@ async def login(user: AuthUser, service: UserService = Depends(UserService)) ->
|
||||
:raises HTTPException: Если учетные данные не верны или произошла другая ошибка при входе.
|
||||
"""
|
||||
return await service.login_user(user=user)
|
||||
|
||||
|
||||
@auth_router.get(path="/logout", status_code=status.HTTP_200_OK)
|
||||
async def logout(
|
||||
user: Annotated[UserVerifySchema, Depends(get_current_user)], service: UserService = Depends(UserService)
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Описание функции logout.
|
||||
|
||||
:param user: Текущий авторизованный пользователь.
|
||||
:type user: UserVerifySchema
|
||||
:param service: Сервис для управления пользователями.
|
||||
:type service: UserService
|
||||
:returns: JSON-ответ, содержащий результат логаута.
|
||||
:rtype: JSONResponse
|
||||
"""
|
||||
return await service.logout_user(user=user)
|
||||
|
||||
|
||||
@auth_router.get(path="/get-user", status_code=status.HTTP_200_OK, response_model=UserVerifySchema)
|
||||
async def get_auth_user(user: Annotated[UserVerifySchema, Depends(get_current_user)]) -> UserVerifySchema:
|
||||
"""
|
||||
Возвращает информацию об авторизованном пользователе.
|
||||
|
||||
:param user: Информация о пользователе, полученная с помощью механизма аутентификации.
|
||||
:type user: UserVerifySchema
|
||||
:return: Схема данных пользователя, содержащая необходимую информацию для работы системы.
|
||||
:rtype: UserVerifySchema
|
||||
|
||||
:raises HTTPException 401: Если пользователь не авторизован и попытка получить доступ к защищенному ресурсу.
|
||||
"""
|
||||
return user
|
||||
@@ -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):
|
||||
@@ -17,10 +18,10 @@ class GetUserByID(BaseModel):
|
||||
Класс для получения пользователя по его уникальному идентификатору (ID).
|
||||
|
||||
:ivar id: Уникальный идентификатор пользователя, может быть представлен как объект типа uuid.UUID или строкой.
|
||||
:type id: uuid.UUID | str
|
||||
:type id: uuid.UUID
|
||||
"""
|
||||
|
||||
id: uuid.UUID | str
|
||||
id: uuid.UUID
|
||||
|
||||
|
||||
class GetUserByEmail(BaseModel):
|
||||
@@ -34,6 +35,17 @@ class GetUserByEmail(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class UserVerifySchema(GetUserByID, GetUserByEmail):
|
||||
"""
|
||||
Класс для валидации данных пользователя.
|
||||
|
||||
Данный класс наследует методы из классов GetUserByID и GetUserByEmail,
|
||||
что позволяет использовать их функциональность для проверки данных пользователя.
|
||||
"""
|
||||
|
||||
session_id: uuid.UUID | str | None = None
|
||||
|
||||
|
||||
class AuthUser(GetUserByEmail):
|
||||
"""
|
||||
Класс для регистрации пользователя, наследующий класс GetUserByEmail.
|
||||
@@ -42,7 +54,7 @@ class AuthUser(GetUserByEmail):
|
||||
:type password: str
|
||||
"""
|
||||
|
||||
password: str
|
||||
password: Annotated[str, StringConstraints(min_length=8, max_length=128)]
|
||||
|
||||
|
||||
class CreateUser(GetUserByEmail):
|
||||
@@ -71,6 +83,10 @@ class GetUserWithIDAndEmail(GetUserByID, CreateUser):
|
||||
pass
|
||||
|
||||
|
||||
class GetUserForAdmin(GetUserWithIDAndEmail):
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
class UserReturnData(GetUserByID, GetUserByEmail):
|
||||
"""
|
||||
Класс для представления данных пользователя, возвращаемых из API.
|
||||
@@ -13,7 +13,12 @@ from starlette.responses import JSONResponse
|
||||
|
||||
from lkeep.apps.auth.handlers import AuthHandler
|
||||
from lkeep.apps.auth.managers import UserManager
|
||||
from lkeep.apps.auth.schemas import AuthUser, CreateUser, UserReturnData
|
||||
from lkeep.apps.auth.schemas import (
|
||||
AuthUser,
|
||||
CreateUser,
|
||||
UserReturnData,
|
||||
UserVerifySchema,
|
||||
)
|
||||
from lkeep.apps.auth.tasks import send_confirmation_email
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
@@ -85,7 +90,7 @@ class UserService:
|
||||
"""
|
||||
exist_user = await self.manager.get_user_by_email(email=user.email)
|
||||
|
||||
if exist_user is None or not self.handler.verify_password(
|
||||
if exist_user is None or not await self.handler.verify_password(
|
||||
hashed_password=exist_user.hashed_password, raw_password=user.password
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong email or password")
|
||||
@@ -103,3 +108,20 @@ class UserService:
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
async def logout_user(self, user: UserVerifySchema) -> JSONResponse:
|
||||
"""
|
||||
Отправляет запрос на выход пользователя из системы.
|
||||
|
||||
:param user: Схема, содержащая информацию о пользователе для аутентификации.
|
||||
:type user: UserVerifySchema
|
||||
:returns: Ответ сервера с сообщением об успешном выходе пользователя.
|
||||
:rtype: JSONResponse
|
||||
:raises Exception: Если произошла ошибка при отмене токена доступа.
|
||||
"""
|
||||
await self.manager.revoke_access_token(user_id=user.id, session_id=user.session_id)
|
||||
|
||||
response = JSONResponse(content={"message": "Logged out"})
|
||||
response.delete_cookie(key="Authorization")
|
||||
|
||||
return response
|
||||
27
src/lkeep/apps/auth/utils.py
Normal file
27
src/lkeep/apps/auth/utils.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException
|
||||
from starlette import status
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
async def get_token_from_cookies(request: Request) -> str:
|
||||
"""
|
||||
Получает токен из куки запроса.
|
||||
|
||||
:param request: Объект HTTP-запроса.
|
||||
:type request: Request
|
||||
:return: Токен из cookies.
|
||||
:rtype: str
|
||||
:raises HTTPException: Если в запросе отсутствует cookie с ключом "Authorization".
|
||||
"""
|
||||
token = request.cookies.get("Authorization")
|
||||
if token is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token is missing")
|
||||
return token
|
||||
124
src/lkeep/apps/links/managers.py
Normal file
124
src/lkeep/apps/links/managers.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import delete, insert, select
|
||||
|
||||
from lkeep.apps.links.schemas import GetLinkSchema, LinkSchema
|
||||
from lkeep.core.core_dependency.db_dependency import DBDependency
|
||||
from lkeep.database.models import Link
|
||||
|
||||
|
||||
class LinksManager:
|
||||
"""
|
||||
Менеджер для выполнения операций над ссылками в базе данных.
|
||||
"""
|
||||
|
||||
def __init__(self, db: DBDependency = Depends(DBDependency)) -> None:
|
||||
"""
|
||||
Инициализирует менеджер с зависимостью доступа к базе данных.
|
||||
|
||||
:param db: Объект для получения асинхронных сессий с базой данных.
|
||||
:type db: DBDependency
|
||||
"""
|
||||
self.db = db
|
||||
self.link_model = Link
|
||||
|
||||
async def get_link(self, short_link: str) -> GetLinkSchema | None:
|
||||
"""
|
||||
Возвращает полную ссылку по короткому идентификатору.
|
||||
|
||||
:param short_link: Сокращенный идентификатор ссылки.
|
||||
:type short_link: str
|
||||
:returns: Найденная ссылка или None, если запись отсутствует.
|
||||
:rtype: GetLinkSchema | None
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
query = select(self.link_model.full_link).where(self.link_model.short_link == short_link)
|
||||
|
||||
result = await session.execute(query)
|
||||
link = result.scalar_one_or_none()
|
||||
|
||||
if link:
|
||||
return GetLinkSchema(full_link=link)
|
||||
|
||||
return None
|
||||
|
||||
async def get_links(self, user_id: uuid.UUID) -> list[LinkSchema]:
|
||||
"""
|
||||
Получает список ссылок, принадлежащих пользователю.
|
||||
|
||||
:param user_id: Идентификатор владельца ссылок.
|
||||
:type user_id: uuid.UUID
|
||||
:returns: Список ссылок пользователя.
|
||||
:rtype: list[LinkSchema]
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
query = select(self.link_model).where(self.link_model.owner_id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
links = result.scalars().all()
|
||||
|
||||
return [LinkSchema.model_validate(link, from_attributes=True) for link in links]
|
||||
|
||||
async def create_link(self, full_link: str, user_id: uuid.UUID, short_link: str) -> LinkSchema:
|
||||
"""
|
||||
Создает новую ссылку и возвращает сохраненную запись.
|
||||
|
||||
:param full_link: Полный адрес, который требуется сократить.
|
||||
:type full_link: str
|
||||
:param user_id: Идентификатор владельца ссылки.
|
||||
:type user_id: uuid.UUID
|
||||
:param short_link: Сгенерированное короткое представление ссылки.
|
||||
:type short_link: str
|
||||
:returns: Созданная ссылка с заполненными полями.
|
||||
:rtype: LinkSchema
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
query = (
|
||||
insert(self.link_model)
|
||||
.values(full_link=full_link, short_link=short_link, owner_id=user_id)
|
||||
.returning(self.link_model)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
await session.commit()
|
||||
link = result.scalar()
|
||||
|
||||
return LinkSchema.model_validate(link, from_attributes=True)
|
||||
|
||||
async def get_link_owner(self, link_id: uuid.UUID) -> uuid.UUID | None:
|
||||
"""
|
||||
Возвращает идентификатор владельца ссылки.
|
||||
|
||||
:param link_id: Идентификатор ссылки.
|
||||
:type link_id: uuid.UUID
|
||||
:returns: Идентификатор владельца или None, если ссылка не найдена.
|
||||
:rtype: uuid.UUID | None
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
query = select(self.link_model.owner_id).where(self.link_model.id == link_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def delete_link(self, link_id: uuid.UUID) -> None:
|
||||
"""
|
||||
Удаляет ссылку по ее идентификатору.
|
||||
|
||||
:param link_id: Идентификатор ссылки, которую требуется удалить.
|
||||
:type link_id: uuid.UUID
|
||||
:returns: None
|
||||
"""
|
||||
async with self.db.db_session() as session:
|
||||
query = delete(self.link_model).where(self.link_model.id == link_id)
|
||||
|
||||
await session.execute(query)
|
||||
await session.commit()
|
||||
97
src/lkeep/apps/links/routes.py
Normal file
97
src/lkeep/apps/links/routes.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from starlette import status
|
||||
|
||||
from lkeep.apps.auth.depends import get_current_user
|
||||
from lkeep.apps.auth.schemas import UserVerifySchema
|
||||
from lkeep.apps.links.schemas import (
|
||||
CreateLinkSchema,
|
||||
DeleteLinkSchema,
|
||||
GetLinkSchema,
|
||||
LinkSchema,
|
||||
)
|
||||
from lkeep.apps.links.services import LinksService
|
||||
|
||||
links_router = APIRouter(prefix="/links", tags=["links"])
|
||||
|
||||
|
||||
@links_router.get("/get_link", response_model=GetLinkSchema | None, status_code=status.HTTP_200_OK)
|
||||
async def get_link(short_link: str, service: LinksService = Depends(LinksService)) -> GetLinkSchema | None:
|
||||
"""
|
||||
Возвращает полную ссылку по сокращенному идентификатору.
|
||||
|
||||
:param short_link: Короткий идентификатор ссылки.
|
||||
:type short_link: str
|
||||
:param service: Сервис ссылок, содержащий бизнес-логику.
|
||||
:type service: LinksService
|
||||
:returns: Полная ссылка либо None, если запись не найдена.
|
||||
:rtype: GetLinkSchema | None
|
||||
"""
|
||||
return await service.get_link(short_link=short_link)
|
||||
|
||||
|
||||
@links_router.get("/get_user_links", response_model=list[LinkSchema], status_code=status.HTTP_200_OK)
|
||||
async def get_user_links(
|
||||
user: Annotated[UserVerifySchema, Depends(get_current_user)], service: LinksService = Depends(LinksService)
|
||||
) -> list[LinkSchema]:
|
||||
"""
|
||||
Возвращает список ссылок текущего пользователя.
|
||||
|
||||
:param user: Авторизованный пользователь, для которого запрашиваются ссылки.
|
||||
:type user: UserVerifySchema
|
||||
:param service: Сервис ссылок, выполняющий выборку данных.
|
||||
:type service: LinksService
|
||||
:returns: Коллекция ссылок пользователя.
|
||||
:rtype: list[LinkSchema]
|
||||
"""
|
||||
return await service.get_links(user=user)
|
||||
|
||||
|
||||
@links_router.post("/create_link", response_model=LinkSchema, status_code=status.HTTP_201_CREATED)
|
||||
async def create_link(
|
||||
link_data: CreateLinkSchema,
|
||||
user: Annotated[UserVerifySchema, Depends(get_current_user)],
|
||||
service: LinksService = Depends(LinksService),
|
||||
) -> LinkSchema:
|
||||
"""
|
||||
Создает новую сокращенную ссылку для пользователя.
|
||||
|
||||
:param link_data: Данные с полным адресом ссылки.
|
||||
:type link_data: CreateLinkSchema
|
||||
:param user: Пользователь, для которого создается ссылка.
|
||||
:type user: UserVerifySchema
|
||||
:param service: Сервис ссылок, отвечающий за генерацию и сохранение записи.
|
||||
:type service: LinksService
|
||||
:returns: Созданная ссылка с коротким идентификатором.
|
||||
:rtype: LinkSchema
|
||||
"""
|
||||
return await service.create_link(link_data=link_data, user=user)
|
||||
|
||||
|
||||
@links_router.delete("/delete_link", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_link(
|
||||
link_data: DeleteLinkSchema,
|
||||
user: Annotated[UserVerifySchema, Depends(get_current_user)],
|
||||
service: LinksService = Depends(LinksService),
|
||||
) -> None:
|
||||
"""
|
||||
Удаляет ссылку, если она принадлежит текущему пользователю.
|
||||
|
||||
:param link_data: Данные с идентификатором ссылки для удаления.
|
||||
:type link_data: DeleteLinkSchema
|
||||
:param user: Авторизованный пользователь, запрашивающий удаление.
|
||||
:type user: UserVerifySchema
|
||||
:param service: Сервис ссылок, выполняющий проверку владельца и удаление.
|
||||
:type service: LinksService
|
||||
:returns: None
|
||||
"""
|
||||
await service.delete_link(link_data=link_data, user=user)
|
||||
45
src/lkeep/apps/links/schemas.py
Normal file
45
src/lkeep/apps/links/schemas.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BaseFullLink(BaseModel):
|
||||
"""
|
||||
Базовая схема, содержащая полный адрес ссылки.
|
||||
"""
|
||||
|
||||
full_link: str
|
||||
|
||||
|
||||
class DeleteLinkSchema(BaseModel):
|
||||
"""
|
||||
Схема для удаления ссылки по идентификатору.
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
|
||||
|
||||
class LinkSchema(BaseFullLink, DeleteLinkSchema):
|
||||
"""
|
||||
Полная схема ссылки с коротким адресом и метаданными.
|
||||
"""
|
||||
|
||||
short_link: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GetLinkSchema(BaseFullLink):
|
||||
"""Схема ответа при получении полной ссылки по сокращенному адресу."""
|
||||
|
||||
|
||||
class CreateLinkSchema(BaseFullLink):
|
||||
"""Схема запроса на создание новой ссылки."""
|
||||
100
src/lkeep/apps/links/services.py
Normal file
100
src/lkeep/apps/links/services.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette import status
|
||||
|
||||
from lkeep.apps.auth.schemas import UserVerifySchema
|
||||
from lkeep.apps.links.managers import LinksManager
|
||||
from lkeep.apps.links.schemas import (
|
||||
CreateLinkSchema,
|
||||
DeleteLinkSchema,
|
||||
GetLinkSchema,
|
||||
LinkSchema,
|
||||
)
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
|
||||
class LinksService:
|
||||
"""
|
||||
Сервисный слой для работы с пользовательскими ссылками.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: LinksManager = Depends(LinksManager)) -> None:
|
||||
"""
|
||||
Создает сервис со связанным менеджером ссылок.
|
||||
|
||||
:param manager: Менеджер, выполняющий операции с базой данных.
|
||||
:type manager: LinksManager
|
||||
"""
|
||||
self.manager = manager
|
||||
|
||||
async def get_link(self, short_link: str) -> GetLinkSchema | None:
|
||||
"""
|
||||
Получает полную ссылку по ее короткому представлению.
|
||||
|
||||
:param short_link: Сокращенный идентификатор ссылки.
|
||||
:type short_link: str
|
||||
:returns: Полная ссылка или None, если запись отсутствует.
|
||||
:rtype: GetLinkSchema | None
|
||||
"""
|
||||
return await self.manager.get_link(short_link=short_link)
|
||||
|
||||
async def get_links(self, user: UserVerifySchema) -> list[LinkSchema]:
|
||||
"""
|
||||
Возвращает все ссылки, принадлежащие пользователю.
|
||||
|
||||
:param user: Данные авторизованного пользователя.
|
||||
:type user: UserVerifySchema
|
||||
:returns: Список ссылок пользователя.
|
||||
:rtype: list[LinkSchema]
|
||||
"""
|
||||
return await self.manager.get_links(user_id=user.id)
|
||||
|
||||
async def create_link(self, link_data: CreateLinkSchema, user: UserVerifySchema) -> LinkSchema:
|
||||
"""
|
||||
Создает новую сокращенную ссылку для пользователя.
|
||||
|
||||
:param link_data: Данные запроса с полным адресом ссылки.
|
||||
:type link_data: CreateLinkSchema
|
||||
:param user: Пользователь, которому будет принадлежать ссылка.
|
||||
:type user: UserVerifySchema
|
||||
:returns: Созданная запись о ссылке.
|
||||
:rtype: LinkSchema
|
||||
"""
|
||||
link_length = settings.link_length
|
||||
|
||||
while True:
|
||||
short_link = secrets.token_urlsafe(link_length)[:link_length]
|
||||
try:
|
||||
return await self.manager.create_link(
|
||||
full_link=link_data.full_link, user_id=user.id, short_link=short_link
|
||||
)
|
||||
except IntegrityError:
|
||||
continue
|
||||
|
||||
async def delete_link(self, link_data: DeleteLinkSchema, user: UserVerifySchema) -> None:
|
||||
"""
|
||||
Удаляет ссылку пользователя после проверки владельца.
|
||||
|
||||
:param link_data: Данные с идентификатором ссылки для удаления.
|
||||
:type link_data: DeleteLinkSchema
|
||||
:param user: Пользователь, запрашивающий удаление.
|
||||
:type user: UserVerifySchema
|
||||
:raises HTTPException: Если пользователь не является владельцем ссылки.
|
||||
:returns: None
|
||||
"""
|
||||
link_owner = await self.manager.get_link_owner(link_id=link_data.id)
|
||||
|
||||
if link_owner and link_owner == user.id:
|
||||
await self.manager.delete_link(link_id=link_data.id)
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Wrong link owner")
|
||||
65
src/lkeep/apps/profile/managers.py
Normal file
65
src/lkeep/apps/profile/managers.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
Инициализирует менеджер с зависимостью доступа к базе данных.
|
||||
|
||||
:param db: Провайдер асинхронных сессий базы данных.
|
||||
:type db: DBDependency
|
||||
"""
|
||||
self.db = db
|
||||
self.user_model = User
|
||||
|
||||
async def update_user_fields(self, user_id: uuid.UUID | str, **kwargs: Any) -> None:
|
||||
"""
|
||||
Обновляет выбранные поля пользователя по его идентификатору.
|
||||
|
||||
:param user_id: Идентификатор пользователя, данные которого нужно изменить.
|
||||
:type user_id: uuid.UUID | str
|
||||
:param kwargs: Поля и значения, подлежащие обновлению.
|
||||
:type kwargs: Any
|
||||
:returns: 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:
|
||||
"""
|
||||
Возвращает хешированный пароль пользователя.
|
||||
|
||||
:param user_id: Идентификатор пользователя для поиска.
|
||||
:type user_id: uuid.UUID | str
|
||||
:returns: Хеш текущего пароля пользователя.
|
||||
:rtype: 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()
|
||||
61
src/lkeep/apps/profile/routes.py
Normal file
61
src/lkeep/apps/profile/routes.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
Изменяет адрес электронной почты текущего пользователя.
|
||||
|
||||
:param data: Данные с новым адресом электронной почты пользователя.
|
||||
:type data: ChangeEmailRequest
|
||||
:param user: Авторизованный пользователь, инициирующий изменение почты.
|
||||
:type user: UserVerifySchema
|
||||
:param service: Сервисный слой, выполняющий бизнес-логику профиля.
|
||||
:type service: ProfileService
|
||||
:returns: 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:
|
||||
"""
|
||||
Обновляет пароль авторизованного пользователя.
|
||||
|
||||
:param data: Данные с текущим и новым паролем.
|
||||
:type data: ChangePasswordRequest
|
||||
:param user: Пользователь, для которого выполняется смена пароля.
|
||||
:type user: UserVerifySchema
|
||||
:param service: Сервис профиля, реализующий проверку и обновление данных.
|
||||
:type service: ProfileService
|
||||
:returns: HTTP-ответ, подтверждающий успешную операцию либо ошибку.
|
||||
:rtype: Response
|
||||
"""
|
||||
return await service.change_password(data=data, user=user)
|
||||
28
src/lkeep/apps/profile/schemas.py
Normal file
28
src/lkeep/apps/profile/schemas.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
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)]
|
||||
69
src/lkeep/apps/profile/services.py
Normal file
69
src/lkeep/apps/profile/services.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
Создает экземпляр сервиса профиля с необходимыми зависимостями.
|
||||
|
||||
:param manager: Менеджер для выполнения операций с моделью пользователя.
|
||||
:type manager: ProfileManager
|
||||
:param handler: Обработчик аутентификации, предоставляющий функции хеширования и проверки пароля.
|
||||
:type handler: AuthHandler
|
||||
"""
|
||||
self.manager = manager
|
||||
self.handler = handler
|
||||
|
||||
async def change_email(self, data: ChangeEmailRequest, user: UserVerifySchema) -> None:
|
||||
"""
|
||||
Обновляет адрес электронной почты пользователя.
|
||||
|
||||
:param data: Запрос с новым адресом электронной почты.
|
||||
:type data: ChangeEmailRequest
|
||||
:param user: Пользователь, для которого применяется изменение.
|
||||
:type user: UserVerifySchema
|
||||
:returns: 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:
|
||||
"""
|
||||
Изменяет пароль пользователя после проверки старого значения.
|
||||
|
||||
:param data: Запрос, содержащий старый и новый пароль.
|
||||
:type data: ChangePasswordRequest
|
||||
:param user: Пользователь, выполняющий смену пароля.
|
||||
:type user: UserVerifySchema
|
||||
:returns: None при успешном обновлении либо JSON-ответ с ошибкой.
|
||||
:rtype: 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)
|
||||
@@ -5,16 +5,3 @@
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from lkeep.apps import apps_router
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(router=apps_router)
|
||||
|
||||
|
||||
def start():
|
||||
uvicorn.run(app="lkeep.main:app", reload=True)
|
||||
@@ -1,4 +1,17 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from lkeep.core.settings import settings
|
||||
|
||||
@@ -24,3 +37,11 @@ class DBDependency:
|
||||
:rtype: async_sessionmaker[AsyncSession]
|
||||
"""
|
||||
return self._session_factory
|
||||
|
||||
@property
|
||||
def db_engine(self) -> AsyncEngine:
|
||||
return self._engine
|
||||
|
||||
|
||||
def get_db_engine() -> AsyncEngine:
|
||||
return DBDependency().db_engine
|
||||
@@ -41,7 +41,7 @@ class RedisDependency:
|
||||
return ConnectionPool.from_url(url=self._url, encoding="utf-8", decode_responses=True)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_client(self) -> AsyncGenerator[Redis, None]:
|
||||
async def get_client(self) -> AsyncGenerator[Redis]:
|
||||
"""
|
||||
Получает клиентскую сессию Redis для взаимодействия с базой данных.
|
||||
|
||||
@@ -119,6 +119,8 @@ class Settings(BaseSettings):
|
||||
:type frontend_url: str
|
||||
:ivar access_token_expire: Срок жизни JWT-токена
|
||||
:type access_token_expire: int
|
||||
:ivar link_length: Максимальная длина короткой ссылки
|
||||
:type link_length: int
|
||||
"""
|
||||
|
||||
db_settings: DBSettings = DBSettings()
|
||||
@@ -128,6 +130,8 @@ class Settings(BaseSettings):
|
||||
templates_dir: str = "templates"
|
||||
frontend_url: str
|
||||
access_token_expire: int
|
||||
domain: str
|
||||
link_length: int = 12
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf8", extra="ignore")
|
||||
|
||||
7
src/lkeep/database/__init__.py
Normal file
7
src/lkeep/database/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
0
src/lkeep/database/alembic/__init__.py
Normal file
0
src/lkeep/database/alembic/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Links model
|
||||
|
||||
Revision ID: d93cd9da97e5
|
||||
Revises: ccf7560dd457
|
||||
Create Date: 2025-09-24 14:30:04.603787
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d93cd9da97e5"
|
||||
down_revision: str | None = "ccf7560dd457"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"link",
|
||||
sa.Column("full_link", sa.String(), nullable=False),
|
||||
sa.Column("short_link", sa.String(length=12), nullable=False),
|
||||
sa.Column("owner_id", sa.UUID(), nullable=False),
|
||||
sa.Column("id", sa.UUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_link_short_link"), "link", ["short_link"], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_link_short_link"), table_name="link")
|
||||
op.drop_table("link")
|
||||
# ### end Alembic commands ###
|
||||
0
src/lkeep/database/alembic/versions/__init__.py
Normal file
0
src/lkeep/database/alembic/versions/__init__.py
Normal file
7
src/lkeep/database/mixins/__init__.py
Normal file
7
src/lkeep/database/mixins/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
@@ -7,7 +7,8 @@ https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from lkeep.database.models.base import Base
|
||||
from lkeep.database.models.links import Link
|
||||
from lkeep.database.models.user import User
|
||||
|
||||
|
||||
__all__ = ("Base", "User")
|
||||
__all__ = ("Base", "User", "Link")
|
||||
31
src/lkeep/database/models/links.py
Normal file
31
src/lkeep/database/models/links.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
from sqlalchemy import UUID, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lkeep.database.mixins.id_mixins import IDMixin
|
||||
from lkeep.database.mixins.timestamp_mixins import CreatedAtMixin
|
||||
from lkeep.database.models import Base
|
||||
|
||||
|
||||
class Link(IDMixin, CreatedAtMixin, Base):
|
||||
"""
|
||||
Модель сокращенной ссылки с указанием владельца.
|
||||
|
||||
:ivar full_link: Полная ссылка
|
||||
:type full_link: str
|
||||
:ivar short_link: Сокращённая ссылка
|
||||
:type short_link: str
|
||||
:ivar owner_id: Создатель ссылки
|
||||
:type owner_id: UUID
|
||||
"""
|
||||
|
||||
full_link: Mapped[str] = mapped_column(String)
|
||||
short_link: Mapped[str] = mapped_column(String(12), unique=True, index=True)
|
||||
owner_id: Mapped[UUID] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
|
||||
37
src/lkeep/main.py
Normal file
37
src/lkeep/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Проект: Lkeep
|
||||
Автор: Иван Ашихмин
|
||||
Год: 2025
|
||||
Специально для проекта "Код на салфетке"
|
||||
https://pressanybutton.ru/category/servis-na-fastapi/
|
||||
"""
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from lkeep.apps import apps_router
|
||||
from lkeep.apps.admin.admin_base import setup_admin
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(router=apps_router)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://127.0.0.1:3000"], # TODO: ЗАМЕНИТЬ ПОТОМ НА ДОМЕН
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
setup_admin(app=app)
|
||||
|
||||
|
||||
def start():
|
||||
"""
|
||||
Запускает локальный сервер приложения с поддержкой автоматической перезагрузки.
|
||||
|
||||
:returns: None
|
||||
"""
|
||||
uvicorn.run(app="lkeep.main:app", reload=True)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Reference in New Issue
Block a user