Compare commits

60 Commits

Author SHA1 Message Date
IluaAir 88ace64d13 add priority filter 2025-09-30 22:19:43 +03:00
IluaAir 9decb7591e change admin dep 2025-09-30 22:09:26 +03:00
IluaAir 41c64127a5 del old task dep, page start from 1 2025-09-30 00:23:16 +03:00
IluaAir ac28f9b420 add limit page date to date from 2025-09-30 00:01:13 +03:00
IluaAir 91daaf9275 delete protocol 2025-09-28 22:34:14 +03:00
IluaAir 64dcc77518 delete old dep 2025-09-28 22:20:34 +03:00
IluaAir ddaa5b0c74 fix dep user for endpoints 2025-09-28 22:18:23 +03:00
IluaAir 23927e5347 add id tasks endpoint 2025-09-27 23:58:39 +03:00
IluaAir 67a570ceac format and login exception 2025-09-27 12:07:31 +03:00
IluaAir 966b59ff20 fix max age 2025-09-27 12:00:17 +03:00
IluaAir b87c37b149 add test fingerprint 2025-09-27 11:52:55 +03:00
IluaAir 4e47ce80fd fix fingerprint body 2025-09-27 11:26:10 +03:00
IluaAir f4e46bef22 full refresh token 2025-09-27 11:24:57 +03:00
IluaAir 3dc36a2f25 add fingerprint httpbearer schema 2025-09-21 21:35:46 +03:00
IluaAir 408e32d05f add expire and fingerprint for refreshed 2025-09-21 19:54:17 +03:00
IluaAir 42b8c3a2c9 new refresh dep add refresh endpoint 2025-09-21 15:17:27 +03:00
IluaAir c3bfb9cb6a add logout 2025-09-21 12:10:50 +03:00
IluaAir cd51902313 add login endpoint 2025-09-21 12:08:04 +03:00
IluaAir 8620e9b5a1 add creation refresh token 2025-09-20 21:54:56 +03:00
IluaAir 9099120ee2 username validation 2025-09-20 14:11:14 +03:00
IluaAir c642b89581 full view User profile 2025-09-20 14:04:07 +03:00
IluaAir e0cddbdd34 add is_superuser for jwt token 2025-09-20 13:55:36 +03:00
IluaAir 0de7d63817 add migration 2025-09-08 15:10:07 +03:00
IluaAir c18487e22a refresh token model 2025-09-08 14:53:00 +03:00
IluaAir c35416bece add test_task_crud 2025-09-06 22:16:42 +03:00
IluaAir 6fa74bffe8 del user update repo 2025-09-06 22:07:00 +03:00
IluaAir 7fe13be684 add base update one 2025-09-06 13:54:52 +03:00
IluaAir d7e522d362 add patch endpoint and service update_task 2025-09-03 23:55:26 +03:00
IluaAir 6e6613662a patch schema 2025-09-03 23:32:31 +03:00
IluaAir e1c554e4f0 add statusenum 2025-09-03 23:12:27 +03:00
IluaAir b1ae775706 fix status for tasks 2025-08-31 23:36:58 +03:00
IluaAir a9fc764c38 add status to user with loads 2025-08-31 23:27:02 +03:00
IluaAir bd9786c14c add to tasks service 2025-08-29 22:10:15 +03:00
IluaAir 07f14b0564 add date_to date_from endpoints 2025-08-29 21:48:38 +03:00
IluaAir 3e6468fa38 add quary status 2025-08-29 21:37:42 +03:00
IluaAir c941b25a90 repo tasks count 2025-08-28 00:36:59 +03:00
IluaAir 45c5492ff8 api pagin, db _task_subquary, tasks depends 2025-08-28 00:27:27 +03:00
IluaAir cd4eb11604 tests user_with_load 2025-08-27 01:17:24 +03:00
IluaAir 2d54f595db add date from and date to 2025-08-24 21:55:42 +03:00
IluaAir 186b497130 repo user with loads filters 2025-08-24 13:43:07 +03:00
gitark 2dde22eb19 Merge pull request 'add tests for project' (#1) from test into dev1
Reviewed-on: #1
2025-08-24 09:52:07 +03:00
IluaAir 543920f476 test crud 2025-08-20 00:15:15 +03:00
IluaAir a8c4b622a4 test auth api, add auth fixture 2025-08-19 17:06:53 +03:00
IluaAir cc3aad5c20 test auth api 2025-08-18 18:26:45 +03:00
IluaAir eb5a1b9d85 add test_db dep 2025-08-17 14:10:32 +03:00
IluaAir 340c3e1077 add jwt check 2025-08-17 12:55:41 +03:00
IluaAir 4104dd7e15 add conftest 2025-08-16 13:16:46 +03:00
IluaAir 053f97daf0 add pytest 2025-08-16 12:40:25 +03:00
IluaAir 9a1b2b4f93 add get-task 2025-08-16 12:37:39 +03:00
IluaAir 9346aae5ef add delete task, update dep 2025-08-16 11:52:53 +03:00
IluaAir 464689bd29 add user with tasks and endpoint 2025-08-15 16:20:56 +03:00
IluaAir a9997b1207 get one with load 2025-08-15 15:47:58 +03:00
IluaAir 9e5cde8e28 fix token data 2025-08-15 01:06:25 +03:00
IluaAir 723f59d35e add task post 2025-08-15 01:03:48 +03:00
IluaAir fdc688cf5e repo typing fix 2025-08-11 10:58:41 +03:00
IluaAir 860962bcc3 pydantic validation in service 2025-08-11 10:15:27 +03:00
IluaAir ddc38dbd07 type cheking 2025-08-11 10:13:08 +03:00
IluaAir 644d5614b9 add get filtered 2025-08-10 20:51:25 +03:00
IluaAir d6646716b8 move get_all_users to get_all 2025-08-09 01:00:39 +03:00
IluaAir b2824c2bb2 move delete_one 2025-08-09 00:56:13 +03:00
40 changed files with 1054 additions and 174 deletions
+1
View File
@@ -23,6 +23,7 @@ ___
- 🔥 Установка приоритетов и дедлайнов
- 🔔 Напоминания и уведомления
- ⚙️ Асинхронная обработка задач
- 📄 Пагинация и фильтрация задач с поддержкой limit/offset
- 💡 Современный и интуитивно понятный интерфейс
---
+4
View File
@@ -9,6 +9,10 @@ ___
- `GET /users` — Получить список всех пользователей
- `GET /users/{user_id}` — Получить конкретного пользователя
- `GET /users/{user_id}/tasks` — Получить задачи пользователя
- **Query параметры:**
- `limit` (int, опционально) — Максимальное количество задач для возврата
- `offset` (int, опционально, по умолчанию 0) — Количество задач для пропуска
- **Пример:** `GET /users/1/tasks?limit=10&offset=20`
- `POST /users` — Создать нового пользователя
- `PUT /users/{user_id}` — Обновить данные пользователя
- `PATCH /users/{user_id}` — Частично обновить данные пользователя
Generated
+99 -3
View File
@@ -129,12 +129,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
markers = "platform_system == \"Windows\""
groups = ["main", "test"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {main = "platform_system == \"Windows\"", test = "sys_platform == \"win32\""}
[[package]]
name = "dnspython"
@@ -290,6 +290,18 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "mako"
version = "1.3.10"
@@ -381,6 +393,18 @@ files = [
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "passlib"
version = "1.7.4"
@@ -399,6 +423,22 @@ bcrypt = ["bcrypt (>=3.1.0)"]
build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"]
totp = ["cryptography"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pydantic"
version = "2.11.7"
@@ -557,6 +597,21 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyjwt"
version = "2.10.1"
@@ -575,6 +630,47 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pytest"
version = "8.4.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"},
{file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.1.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"},
{file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"},
]
[package.dependencies]
pytest = ">=8.2,<9"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "python-dotenv"
version = "1.1.0"
@@ -805,4 +901,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)
[metadata]
lock-version = "2.1"
python-versions = ">=3.12"
content-hash = "7f9ca5ce7505707747e59087ccbb804dc0fef135c963fd2d2ebc8c91285ec188"
content-hash = "cc2947613c2711ad32ccfa1b8f04a0d8bad6043c6e52bffaff12761ec76cc805"
+6 -2
View File
@@ -64,7 +64,7 @@ exclude = [
# Set the maximum line length for both linting and formatting.
line-length = 88
# Assume Python 3.9 for compatibility checks.
target-version = "py39"
target-version = "py312"
# Enable preview features for early access to new rules and formatting changes.
preview = true
@@ -81,7 +81,7 @@ select = [
]
# Ignore specific rules within the selected groups.
ignore = [
"UP035",
"UP",
"B903",
"B904",
"E501",
@@ -91,3 +91,7 @@ ignore = [
[tool.ruff.format]
# Enable Ruff's formatter.
docstring-code-format = true
[tool.poetry.group.test.dependencies]
pytest = "^8.4.1"
pytest-asyncio = "^1.1.0"
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = . src
asyncio_mode = auto
+3 -3
View File
@@ -1,15 +1,15 @@
from typing import Annotated, AsyncGenerator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.database import async_session_maker
from src.core.db_manager import DBManager
from src.core.interfaces import IUOWDB
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def get_db() -> AsyncGenerator[IUOWDB, None]:
async with DBManager(async_session_maker) as db:
yield db
sessionDep = Annotated[AsyncSession, Depends(get_db)]
sessionDep = Annotated[IUOWDB, Depends(get_db)]
+41
View File
@@ -0,0 +1,41 @@
from datetime import date
from typing import Annotated
from fastapi import Depends, Query
from fastapi.exceptions import HTTPException
from pydantic import BaseModel, model_validator
from src.schemas.tasks import PriorityEnum, StatusEnum
class Date(BaseModel):
date_from: date | None = Query(default=None)
date_to: date | None = Query(default=None)
@model_validator(mode="after")
def check_dates(self):
if self.date_from and self.date_to and self.date_to < self.date_from:
raise HTTPException(
status_code=422, detail="date_to cannot be less than date_from"
)
return self
class Page(BaseModel):
limit: int = Query(default=30, ge=1, le=100)
page: int | None = Query(default=1, ge=1)
class Status(BaseModel):
status: StatusEnum | None = Query(default=None)
class Priority(BaseModel):
priority: PriorityEnum | None = Query(default=None)
class Filters(Date, Status, Priority, Page):
pass
FilterDep = Annotated[Filters, Depends()]
+30 -31
View File
@@ -1,64 +1,63 @@
from typing import Annotated
from fastapi import Depends, HTTPException, Path
from fastapi.security import OAuth2PasswordBearer
from fastapi import Depends, HTTPException
from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBearer,
OAuth2PasswordBearer,
)
from jwt import InvalidTokenError
from src.api.dependacies.db_dep import sessionDep
from src.core.auth_manager import AuthManager
from src.core.settings import settings
from src.schemas.auth import TokenData
from src.services.users import UserService
http_bearer = HTTPBearer(auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login")
AccessTokenDep = Annotated[HTTPAuthorizationCredentials, Depends(http_bearer)]
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
async def get_current_user(
token: AccessTokenDep, verify_exp: bool = True, check_active: bool = False
):
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = AuthManager.decode_access_token(token=token)
payload = AuthManager.decode_access_token(token.credentials, verify_exp)
if payload is None:
raise credentials_exception
user = TokenData(**payload)
except InvalidTokenError:
if check_active and not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
except (InvalidTokenError, AttributeError):
raise credentials_exception
return user
CurrentUser = Annotated[TokenData, Depends(get_current_user)]
async def get_current_user_basic(token: AccessTokenDep):
return await get_current_user(token, verify_exp=True, check_active=False)
def get_current_active_user(
current_user: CurrentUser,
):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
async def get_current_active_user(token: AccessTokenDep):
return await get_current_user(token, verify_exp=True, check_active=True)
ActiveUser = Annotated[TokenData, Depends(get_current_active_user)]
async def get_current_user_for_refresh(token: AccessTokenDep):
return await get_current_user(token, verify_exp=False, check_active=True)
async def get_admin_user(db: sessionDep, current_user: ActiveUser):
await UserService(db).validate_admin_user(current_user.sub)
return current_user
AdminUser = Annotated[TokenData, Depends(get_admin_user)]
async def user_or_admin(
db: sessionDep, current_user: ActiveUser, id: Annotated[int, Path()]
):
if current_user.id == id:
return current_user
else:
admin = await get_admin_user(db, current_user)
async def get_current_user_for_admin(token: AccessTokenDep):
admin = await get_current_user(token, verify_exp=True, check_active=True)
if not admin.is_superuser:
raise HTTPException(status_code=403, detail="Admin access required")
return admin
CurrentOrAdmin = Annotated[TokenData, Depends(user_or_admin)]
CurrentUser = Annotated[TokenData, Depends(get_current_user_basic)]
ActiveUser = Annotated[TokenData, Depends(get_current_active_user)]
RefreshUser = Annotated[TokenData, Depends(get_current_user_for_refresh)]
AdminUser = Annotated[TokenData, Depends(get_current_user_for_admin)]
+64 -6
View File
@@ -1,14 +1,19 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Body, Cookie, Depends, Form, HTTPException, Response
from fastapi.security import OAuth2PasswordRequestForm
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser, RefreshUser, http_bearer
from src.core.settings import settings
from src.schemas.auth import Token
from src.schemas.users import UserRequestADD
from src.services.auth import AuthService
from src.services.users import UserService
router = APIRouter(prefix=settings.api.v1.auth, tags=["Auth"])
router = APIRouter(
prefix=settings.api.v1.auth, tags=["Auth"], dependencies=[Depends(http_bearer)]
)
@router.post(path="/signup")
@@ -17,12 +22,65 @@ async def registration(session: sessionDep, credential: UserRequestADD):
return auth
@router.post(path="/login")
@router.post(path="/login", response_model=Token)
async def login(
session: sessionDep,
credential: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response,
fingerprint: str = Form(min_length=5),
):
access_token = await AuthService(session).login(
credential.username, credential.password
result = await AuthService(session).login(
credential.username, credential.password, fingerprint=fingerprint
)
return access_token
response.set_cookie(
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.post(path="/refresh", response_model=Token)
async def refresh(
session: sessionDep,
current_user: RefreshUser,
response: Response,
fingerprint: Annotated[str, Body(embed=True)],
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
result = await AuthService(session).refresh_tokens(
refresh_token, current_user, fingerprint
)
response.set_cookie(
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.get("/me")
async def get_me(session: sessionDep, user: ActiveUser):
cur_user = await UserService(session).get_user_by_filter_or_raise(id=user.id)
return cur_user
@router.post(path="/logout")
async def logout(
session: sessionDep,
response: Response,
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
await AuthService(session).delete_token(token=refresh_token)
response.delete_cookie(key="refresh_token")
return {"status": "ok"}
+45 -22
View File
@@ -1,36 +1,59 @@
from fastapi import APIRouter
from sqlalchemy import select
from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser
from src.models.tasks import TasksORM
from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest
from src.services.tasks import TaskService
router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
async def get_tasks(db: sessionDep, user: ActiveUser):
query = select(TasksORM.id, TasksORM.description).where(TasksORM.user_id == user.id)
tasks = await db.session.execute(query)
result = tasks.scalars().all()
return result
@router.get("/{task_id}")
async def get_task_id(task_id: int): ...
@router.get("/{id}")
async def get_task_id(session: sessionDep, id: int, user: ActiveUser):
task = await TaskService(session).get_task(id)
if task.user_id != user.id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
return task
@router.post("/")
async def post_task(): ...
async def post_task(
task_data: Annotated[TaskADDRequest, Depends()],
session: sessionDep,
user: ActiveUser,
):
result = await TaskService(session).create_task(
user_id=user.id, task_data=task_data
)
return result
@router.put("/{task_id}")
async def put_task(task_id: int): ...
@router.patch("/{id}")
async def patch_task(
session: sessionDep,
id: int,
user: ActiveUser,
task_data: TaskPATCHRequest = Body(),
):
if user.is_superuser is False:
task = await TaskService(session).get_task(id)
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
updated_task = await TaskService(session).update_task(id, task_data)
return updated_task
@router.patch("/{task_id}")
async def patch_task(task_id: int): ...
@router.delete("/{task_id}")
async def delete_task(task_id: int): ...
@router.delete("/{id}")
async def delete_task(
session: sessionDep,
id: int,
user: ActiveUser,
):
if user.is_superuser is False:
task = await TaskService(session).get_task(id)
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await TaskService(session).delete_task(id)
return {"message": "Task deleted successfully"}
+31 -15
View File
@@ -1,7 +1,11 @@
from fastapi import APIRouter, Body
from fastapi import APIRouter, Body, HTTPException
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser, AdminUser, CurrentOrAdmin
from src.api.dependacies.task_dep import FilterDep
from src.api.dependacies.user_dep import (
ActiveUser,
AdminUser,
)
from src.core.settings import settings
from src.schemas.users import UserUpdate
from src.services.users import UserService
@@ -9,32 +13,44 @@ from src.services.users import UserService
router = APIRouter(prefix=settings.api.v1.users, tags=["Users"])
@router.get("/me")
async def get_me(user: ActiveUser):
return user
@router.get("/")
async def get_all_users(db: sessionDep, _: AdminUser):
users = await UserService(db).get_all_users()
async def get_all_users(session: sessionDep, _: AdminUser):
users = await UserService(session).get_all_users()
return users
@router.get("/{id}")
async def get_user_by_id(db: sessionDep, id: int, _: CurrentOrAdmin):
user = await UserService(db).get_user_by_filter_or_raise(id=id)
async def get_user_by_id(session: sessionDep, id: int, _: AdminUser):
user = await UserService(session).get_user_by_filter_or_raise(id=id)
return user
@router.get("/{id}/tasks")
async def get_user_tasks(
session: sessionDep, id: int, user: ActiveUser, filters: FilterDep
):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
tasks = await UserService(session).get_user_with_tasks(id, **filters.model_dump())
return tasks.tasks
@router.patch("/{id}")
async def patch_user(
db: sessionDep, id: int, _: CurrentOrAdmin, user_update: UserUpdate = Body()
session: sessionDep,
id: int,
user: ActiveUser,
user_update: UserUpdate = Body(),
):
updated_user = await UserService(db).update_user(id=id, update_data=user_update)
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
updated_user = await UserService(session).update_user(
id=id, update_data=user_update
)
return updated_user
@router.delete("/{id}")
async def delete_user(db: sessionDep, id: int, _: AdminUser):
await UserService(db).delete_user(id)
async def delete_user(session: sessionDep, id: int, user: AdminUser):
await UserService(session).delete_user(id)
return {"message": "User deleted successfully"}
+8 -1
View File
@@ -1,3 +1,4 @@
import secrets
from datetime import datetime, timedelta, timezone
import jwt
@@ -35,9 +36,15 @@ class AuthManager:
return encoded_jwt
@classmethod
def decode_access_token(cls, token: str) -> dict:
def create_refresh_token(cls) -> str:
token_hash = secrets.token_urlsafe(32)
return token_hash
@classmethod
def decode_access_token(cls, token: str, verify_exp: bool = True) -> dict:
return jwt.decode(
token,
settings.access_token.secret_key,
algorithms=[settings.access_token.algorithm],
options={"verify_exp": verify_exp},
)
+1 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from src.core.settings import settings
engine = create_async_engine(settings.db.url, echo=True)
engine = create_async_engine(settings.db.url, echo=settings.db.echo)
@event.listens_for(engine.sync_engine, "connect")
+11 -5
View File
@@ -1,20 +1,26 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo
class DBManager:
def __init__(self, session_factory):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
self.session_factory = session_factory
async def __aenter__(self):
self.session = self.session_factory()
async def __aenter__(self) -> "DBManager":
self.session: AsyncSession = self.session_factory()
self.user = UsersRepo(self.session)
self.task = TasksRepo(self.session)
self.auth = AuthRepo(self.session)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
await self.session.rollback()
await self.session.close()
async def commit(self):
async def commit(self) -> None:
await self.session.commit()
+10 -4
View File
@@ -1,15 +1,21 @@
from typing import Protocol
from typing import TYPE_CHECKING, Any, Protocol
from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo
class IUOWDB(Protocol):
user: UsersRepo
task: TasksRepo
session: AsyncSession
user: "UsersRepo"
task: "TasksRepo"
auth: "AuthRepo"
async def __aenter__(self) -> "IUOWDB": ...
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ...
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ...
async def commit(self) -> None: ...
+13 -7
View File
@@ -28,22 +28,28 @@ class ApiPrefix(BaseModel):
class DbSettings(BaseModel):
url: str = f"sqlite+aiosqlite:///{DB_PATH}"
echo: bool = False
class AccessToken(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_prefix="ACCESS_TOKEN_"
)
expire_minutes: int = 15
class AccessToken(BaseModel):
expire_minutes: int
secret_key: str
algorithm: str = "HS256"
token_type: str = "bearer" # noqa: S105
class RefreshToken(BaseModel):
expire_days: int
class Settings(BaseSettings):
api: ApiPrefix = ApiPrefix()
db: DbSettings = DbSettings()
access_token: AccessToken = AccessToken()
access_token: AccessToken
refresh_token: RefreshToken
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_nested_delimiter="__"
)
settings = Settings()
settings = Settings() # type: ignore
@@ -0,0 +1,48 @@
"""add refresh token
Revision ID: b879d3502c37
Revises: 4b0f3ea2fd26
Create Date: 2025-09-08 14:56:01.439089
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b879d3502c37"
down_revision: Union[str, None] = "4b0f3ea2fd26"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("refresh_tokens")
# ### end Alembic commands ###
@@ -0,0 +1,45 @@
"""expire and fingerprintjs token
Revision ID: 5821f37941a8
Revises: b879d3502c37
Create Date: 2025-09-21 20:16:48.289050
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5821f37941a8"
down_revision: Union[str, None] = "b879d3502c37"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"refresh_tokens",
sa.Column("fingerprint", sa.String(length=255), nullable=False),
)
op.add_column(
"refresh_tokens",
sa.Column(
"expired_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(datetime('now', '+7 days'))"),
nullable=False,
),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("refresh_tokens", "expired_at")
op.drop_column("refresh_tokens", "fingerprint")
# ### end Alembic commands ###
+2 -4
View File
@@ -1,7 +1,5 @@
from src.models.tasks import TasksORM
from src.models.tokens import RefreshTokensORM
from src.models.users import UsersORM
__all__ = [
"UsersORM",
"TasksORM",
]
__all__ = ["UsersORM", "TasksORM", "RefreshTokensORM"]
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from src.core.database import Base
from src.core.settings import settings
class RefreshTokensORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
token: Mapped[str] = mapped_column(String(255), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
expired_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=text(
f"datetime('now', '+{settings.refresh_token.expire_days} days')"
),
)
+6
View File
@@ -0,0 +1,6 @@
from src.models.tokens import RefreshTokensORM
from src.repository.base import BaseRepo
class AuthRepo(BaseRepo[RefreshTokensORM]):
model: type[RefreshTokensORM] = RefreshTokensORM
+41 -12
View File
@@ -1,23 +1,52 @@
from pydantic import BaseModel
from sqlalchemy import insert, select
from typing import Any, Generic, Mapping, Sequence, Type, TypeVar
from src.core.database import Base
from sqlalchemy import delete, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
ModelType = TypeVar("ModelType")
class BaseRepo:
model: type[Base] = None
class BaseRepo(Generic[ModelType]):
model: Type[ModelType]
def __init__(self, session):
self.session = session
def __init__(self, session: AsyncSession) -> None:
self.session: AsyncSession = session
async def create_one(self, data: BaseModel):
statement = insert(self.model).values(data.model_dump()).returning(self.model)
async def get_filtered(
self, *filters: Any, **filter_by: Any
) -> Sequence[ModelType]:
query = select(self.model).filter(*filters).filter_by(**filter_by)
result = await self.session.execute(query)
models = result.scalars().all()
return models
async def create_one(self, data: Mapping[str, Any]) -> ModelType:
statement = insert(self.model).values(data).returning(self.model)
result = await self.session.execute(statement)
obj = result.scalar_one()
obj: ModelType = result.scalar_one()
return obj
async def get_one_or_none(self, **filter_by):
async def create_bulk(self, data: Sequence[Mapping[str, Any]]) -> list[ModelType]:
result = [await self.create_one(item) for item in data]
return result
async def get_one_or_none(self, **filter_by: Any) -> ModelType | None:
query = select(self.model).filter_by(**filter_by)
result = await self.session.execute(query)
model = result.scalars().one_or_none()
model_obj: ModelType | None = result.scalars().one_or_none()
return model_obj
async def get_all(self, *args: Any, **kwargs: Any) -> Sequence[ModelType]:
result: Sequence[ModelType] = await self.get_filtered(*args, **kwargs)
return result
async def delete_one(self, **filter_by) -> None:
await self.session.execute(delete(self.model).filter_by(**filter_by))
async def update_one(self, data: dict[str, Any], **filter_by: Any) -> ModelType:
stmt = (
update(self.model).filter_by(**filter_by).values(data).returning(self.model)
)
result = await self.session.execute(stmt)
model = result.scalar_one()
return model
+2 -2
View File
@@ -2,5 +2,5 @@ from src.models.tasks import TasksORM
from src.repository.base import BaseRepo
class TasksRepo(BaseRepo):
model = TasksORM
class TasksRepo(BaseRepo[TasksORM]):
model: type[TasksORM] = TasksORM
+75 -20
View File
@@ -1,28 +1,83 @@
from sqlalchemy import delete, select, update
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from src.models import UsersORM
from src.models.tasks import TasksORM
from src.repository.base import BaseRepo
class UsersRepo(BaseRepo):
model = UsersORM
class UsersRepo(BaseRepo[UsersORM]):
model: type[UsersORM] = UsersORM
async def get_all_users(self) -> list[UsersORM]:
query = select(self.model)
result = await self.session.execute(query)
models = result.scalars().all()
return models
async def delete_one(self, id: int) -> None:
await self.session.execute(delete(self.model).where(self.model.id == id))
async def update_one(self, id: int, data: dict) -> UsersORM:
stmt = (
update(self.model)
.where(self.model.id == id)
.values(data.model_dump(exclude_unset=True))
.returning(self.model)
async def get_one_with_load(
self,
user_id: int,
status: str | None = None,
priority: str | None = None,
tasks_limit: int | None = None,
tasks_offset: int | None = 0,
date_to: date | None = None,
date_from: date | None = None,
) -> UsersORM | None:
filters_sq: dict = {"user_id": user_id}
if status:
filters_sq["status"] = status
if priority:
filters_sq["priority"] = priority
tasks_subquery = self._tasks_subquary(
date_from=date_from, date_to=date_to, **filters_sq
)
if tasks_limit is not None:
tasks_subquery = tasks_subquery.limit(tasks_limit)
if tasks_offset is not None and tasks_offset > 0:
tasks_subquery = tasks_subquery.offset(tasks_offset)
query = (
select(self.model)
.where(self.model.id == user_id)
.options(
selectinload(
self.model.tasks.and_(TasksORM.id.in_(tasks_subquery))
).load_only(
TasksORM.id,
TasksORM.title,
TasksORM.due_date,
TasksORM.priority,
TasksORM.status,
)
)
)
result = await self.session.execute(query)
obj = result.scalar_one_or_none()
if obj and obj.tasks:
obj.tasks.sort(
key=lambda task: (
task.due_date or date.max,
-self._priority(task.priority),
)
)
return obj
async def get_tasks_count(
self, date_from: date | None, date_to: date | None, **filter_by
) -> int:
subq = self._tasks_subquary(date_from, date_to, **filter_by).subquery()
stmt = select(func.count()).select_from(subq)
result = await self.session.execute(stmt)
model = result.scalar_one()
return model
return result.scalar_one()
def _priority(self, priority: str):
priority_map = {"low": 1, "medium": 2, "high": 3, "critical": 4}
return priority_map.get(priority, 0)
def _tasks_subquary(
self, date_from: date | None, date_to: date | None, **filter_by
):
subq = select(TasksORM.id).filter_by(**filter_by)
if date_from is not None:
subq = subq.filter(TasksORM.due_date >= date_from)
if date_to is not None:
subq = subq.filter(TasksORM.due_date <= date_to)
return subq
+7 -3
View File
@@ -1,12 +1,16 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
class Token(BaseModel):
access_token: str
token_type: str
model_config = ConfigDict(extra="ignore")
class TokenData(BaseModel):
id: int | None = None
sub: str | None = None
id: int
sub: str = Field(alias="username")
is_superuser: bool
is_active: bool
model_config = ConfigDict(populate_by_name=True)
+52
View File
@@ -0,0 +1,52 @@
from datetime import date
from enum import Enum
from pydantic import BaseModel, ConfigDict
class PriorityEnum(str, Enum):
low = "low"
medium = "medium"
high = "high"
critical = "critical"
class StatusEnum(str, Enum):
open = "open"
closed = "closed"
in_progress = "in_progress"
todo = "todo"
class TaskShort(BaseModel):
title: str
due_date: date | None = None
priority: PriorityEnum = PriorityEnum.medium
model_config = ConfigDict(from_attributes=True)
class TaskWithId(TaskShort):
id: int
status: str
class TaskADDRequest(TaskShort):
description: str | None = None
class TaskPATCHRequest(BaseModel):
title: str | None = None
description: str | None = None
due_date: date | None = None
status: StatusEnum | None = None
priority: PriorityEnum | None = None
time_spent: int | None = None
class Task(TaskADDRequest):
id: int
user_id: int
status: StatusEnum
time_spent: int
model_config = ConfigDict(from_attributes=True)
+9 -2
View File
@@ -2,7 +2,8 @@ from typing import Annotated
from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr
from src.schemas.validators import ensure_password
from src.schemas.tasks import TaskWithId
from src.schemas.validators import ensure_password, ensure_username
class UserUpdate(BaseModel):
@@ -18,6 +19,8 @@ class User(BaseModel):
username: str
is_active: bool
is_superuser: bool
telegram_id: int | None
avatar_path: str | None
model_config = ConfigDict(from_attributes=True, extra="ignore")
@@ -25,13 +28,17 @@ class UserWithHashedPass(User):
hashed_password: str
class UserWithTasks(User):
tasks: list[TaskWithId]
class UserRequest(BaseModel):
username: str
password: str
class UserRequestADD(BaseModel):
username: str
username: Annotated[str, BeforeValidator(ensure_username)]
email: EmailStr | None = None
password: Annotated[str, BeforeValidator(ensure_password)]
+12 -2
View File
@@ -1,11 +1,21 @@
from typing import Any
def ensure_password(value: Any) -> Any:
def ensure_password(value: Any) -> str:
if not isinstance(value, str):
raise TypeError("Password must be a string")
value = value.strip()
if len(value) < 8:
raise ValueError("Password must be at least 8 characters")
if value.strip() == "":
elif value.strip() == "":
raise ValueError("Password cannot be empty")
return value
def ensure_username(value: str) -> str:
value = value.strip()
if len(value) < 3:
raise ValueError("Username must be at least 3 characters")
elif value.lower() in ["admin", "moderator", "админ", "модератор"]:
raise ValueError("Login is already taken")
return value
+41 -16
View File
@@ -2,7 +2,7 @@ from fastapi import HTTPException
from src.core.auth_manager import AuthManager
from src.core.settings import settings
from src.schemas.auth import Token
from src.schemas.auth import TokenData
from src.schemas.users import User, UserAdd, UserRequestADD, UserWithHashedPass
from src.services.base import BaseService
@@ -15,29 +15,54 @@ class AuthService(BaseService):
email=cred.email,
hashed_password=hashed_pass,
)
result = await self.session.user.create_one(user_to_insert)
result = await self.session.user.create_one(user_to_insert.model_dump())
await self.session.commit()
return User.model_validate(result)
async def login(self, username: str, password: str):
result = await self.session.user.get_one_or_none(username=username)
if result is None:
raise HTTPException(
async def _tokens_create(self, user_data: TokenData, fingerprint: str):
access_token = AuthManager.create_access_token(user_data.model_dump())
refresh_token = AuthManager.create_refresh_token()
await self.session.auth.create_one({
"token": refresh_token,
"user_id": user_data.id,
"fingerprint": fingerprint,
})
return {
"access_token": access_token,
"token_type": settings.access_token.token_type,
"refresh_token": refresh_token,
}
async def login(self, username: str, password: str, fingerprint: str) -> dict:
login_exception = HTTPException(
status_code=401,
detail="Incorrect username or password",
)
result = await self.session.user.get_one_or_none(username=username)
if result is None:
raise login_exception
user = UserWithHashedPass.model_validate(result)
token_data = TokenData.model_validate(user.model_dump())
verify = AuthManager.verify_password(
plain_password=password, hashed_password=user.hashed_password
)
if not verify or user.is_active is False:
raise HTTPException(
status_code=401,
detail="Incorrect username or password",
)
access_token = AuthManager.create_access_token(
data={"id": user.id, "sub": user.username, "is_active": user.is_active}
)
return Token(
access_token=access_token, token_type=settings.access_token.token_type
)
raise login_exception
tokens = await self._tokens_create(token_data, fingerprint)
await self.session.commit()
return tokens
async def refresh_tokens(
self, refresh_token: str, user_data: TokenData, fingerprint: str
) -> dict:
token_record = await self.session.auth.get_one_or_none(token=refresh_token)
if not token_record or token_record.user_id != user_data.id:
raise HTTPException(status_code=401, detail="Invalid refresh token")
token = await self._tokens_create(user_data, fingerprint)
await self.session.auth.delete_one(token=refresh_token)
await self.session.commit()
return token
async def delete_token(self, token: str) -> None:
await self.session.auth.delete_one(token=token)
await self.session.commit()
+1 -1
View File
@@ -2,7 +2,7 @@ from src.core.interfaces import IUOWDB
class BaseService:
session: IUOWDB | None
session: IUOWDB
def __init__(self, session: "IUOWDB"):
self.session = session
+35 -1
View File
@@ -1,4 +1,38 @@
from fastapi import HTTPException
from src.schemas.tasks import Task, TaskADDRequest, TaskPATCHRequest
from src.services.base import BaseService
class TasksService(BaseService): ...
class TaskService(BaseService):
async def create_task(self, user_id: int, task_data: TaskADDRequest) -> Task:
user = await self.session.user.get_one_or_none(id=user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
data_to_insert = task_data.model_dump(exclude_none=True)
data_to_insert["user_id"] = user.id
created_task_orm = await self.session.task.create_one(data_to_insert)
await self.session.commit()
return Task.model_validate(created_task_orm)
async def get_task(self, task_id: int):
task = await self.session.task.get_one_or_none(id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found.")
return Task.model_validate(task)
async def delete_task(self, task_id: int):
await self.session.task.delete_one(id=task_id)
await self.session.commit()
async def update_task(
self,
task_id: int,
task_data: TaskPATCHRequest,
exclude_unset: bool = True,
):
task = await self.session.task.update_one(
data=task_data.model_dump(exclude_unset=exclude_unset), id=task_id
)
await self.session.commit()
return Task.model_validate(task)
+23 -3
View File
@@ -1,6 +1,6 @@
from fastapi import HTTPException
from src.schemas.users import User, UserUpdate
from src.schemas.users import User, UserUpdate, UserWithTasks
from src.services.base import BaseService
@@ -24,7 +24,7 @@ class UserService(BaseService):
return user
async def get_all_users(self) -> list[User]:
users = await self.session.user.get_all_users()
users = await self.session.user.get_all()
return [User.model_validate(user) for user in users]
async def delete_user(self, id: int) -> None:
@@ -33,6 +33,26 @@ class UserService(BaseService):
async def update_user(self, id: int, update_data: UserUpdate) -> User:
await self.get_user_by_filter_or_raise(id=id)
user = await self.session.user.update_one(id=id, data=update_data)
user = await self.session.user.update_one(
data=update_data.model_dump(exclude_unset=True), id=id
)
await self.session.commit()
return User.model_validate(user)
async def get_user_with_tasks(self, user_id: int, **attrs):
if attrs.get("page") and attrs.get("limit"):
tasks_offset = (attrs.get("page", 0) - 1) * attrs.get("limit")
else:
tasks_offset = None
user = await self.session.user.get_one_with_load(
user_id=user_id,
status=attrs.get("status"),
priority=attrs.get("priority"),
tasks_limit=attrs.get("limit"),
tasks_offset=tasks_offset,
date_from=attrs.get("date_from"),
date_to=attrs.get("date_to"),
)
if user is None:
raise HTTPException(status_code=404, detail="User not found.")
return UserWithTasks.model_validate(user)
View File
+77
View File
@@ -0,0 +1,77 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import NullPool, insert
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.api.dependacies.db_dep import get_db
from src.core.auth_manager import AuthManager
from src.core.database import Base
from src.core.db_manager import DBManager
from src.core.settings import settings
from src.main import app
from src.models import * # noqa: F403
engine_null_pool = create_async_engine(
"sqlite+aiosqlite:///tests/test_db.db", poolclass=NullPool
)
test_session_maker = async_sessionmaker(engine_null_pool, expire_on_commit=False)
class TestDBManager(DBManager):
def __init__(self):
self.session_factory = test_session_maker
async def get_test_db():
async with TestDBManager() as db:
yield db
@pytest.fixture(scope="function")
async def db():
async for db in get_test_db():
yield db
@pytest.fixture(scope="function")
async def ac():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as ac:
yield ac
app.dependency_overrides[get_db] = get_test_db
@pytest.fixture(scope="session", autouse=True)
async def setup_database():
async with engine_null_pool.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
@pytest.fixture(scope="session", autouse=True)
async def add_admin(setup_database):
hashed_pass = AuthManager.get_password_hash("admin")
user_admin = {
"username": "admin",
"hashed_password": hashed_pass,
"is_superuser": True,
}
async with test_session_maker() as conn:
result = await conn.execute(
insert(UsersORM).values(user_admin).returning(UsersORM) # noqa: F405
)
await conn.commit()
admin = result.scalar_one()
assert admin.is_superuser is True
@pytest.fixture
def auth_token(ac, add_admin):
response = ac.post(
f"{settings.api.v1_login_url}/login",
data={"username": "admin", "password": "admin"},
)
return response.json()["access_token"]
View File
+45
View File
@@ -0,0 +1,45 @@
import pytest
from httpx import AsyncClient
from src.core.settings import settings
from src.schemas.users import User
async def test_registration(ac):
user = {"username": "kot", "email": "super@kot.ru", "password": "P@ssw0rd"}
result = await ac.post(
f"{settings.api.v1_login_url}/signup",
json=user,
)
assert result.status_code == 200
assert User.model_validate(result.json())
assert result.json()["is_active"]
@pytest.mark.parametrize(
"fingerprint, username,password,expected_status",
[("string", "kot", "P@ssw0rd", 200), ("", "kot", "P@ssw0rd", 422)],
)
async def test_login(
ac: AsyncClient,
fingerprint: str,
username: str,
password: str,
expected_status: int,
):
result = await ac.post(
f"{settings.api.v1_login_url}/login",
data={
"fingerprint": fingerprint,
"grant_type": "password",
"username": username,
"password": password,
},
)
assert result.status_code == expected_status
if expected_status == 200:
assert result.json().get("access_token") is not None
else:
json_response = result.json()
if expected_status == 422:
assert "detail" in json_response
+38
View File
@@ -0,0 +1,38 @@
[
{
"title": "test1",
"description": "test1",
"due_date": "2026-06-01",
"status": "open",
"priority": "medium"
},
{
"title": "test2",
"description": "test2",
"due_date": "2026-06-01",
"status": "open",
"priority": "high"
},
{
"title": "test3",
"description": "test3",
"due_date": "2026-06-02",
"status": "todo",
"priority": "medium"
},
{
"title": "test4",
"description": "test4",
"due_date": "2026-06-02",
"status": "open",
"priority": "high"
},
{
"title": "test5",
"description": "test5",
"due_date": "2026-06-02",
"status": "todo",
"priority": "low"
}
]
View File
+10
View File
@@ -0,0 +1,10 @@
from src.core.auth_manager import AuthManager
async def test_jwt():
token = AuthManager.create_access_token(
data={"id": 1, "sub": "testuser", "is_active": "True"}
)
assert token
encode_token = AuthManager.decode_access_token(token=token)
assert encode_token["id"] == 1 and encode_token["sub"] == "testuser"
+86
View File
@@ -0,0 +1,86 @@
import json
from datetime import datetime
from typing import TYPE_CHECKING
from src.models.users import UsersORM
from src.schemas.users import User
if TYPE_CHECKING:
from tests.conftest import TestDBManager
async def test_user_crud(db: "TestDBManager"):
data = {
"username": "test",
"hashed_password": "hashed_pass",
"email": "test@mail.ru",
"is_active": True,
"is_superuser": False,
}
user = await db.user.create_one(data=data)
assert user.username == data["username"]
filtered_user = await db.user.get_filtered(username=data["username"])
assert filtered_user[0] == user
new_user = User.model_validate(user)
new_user.username = "Test2"
new_user.email = None
await db.user.update_one(id=new_user.id, data=User.model_dump(new_user))
updated_user = await db.user.get_one_or_none(id=new_user.id)
assert updated_user
assert updated_user.username == new_user.username
assert not updated_user.email
await db.user.delete_one(id=updated_user.id)
delete_user = await db.user.get_one_or_none(id=new_user.id)
assert not delete_user
async def test_tasks_user(db: "TestDBManager"):
with open("tests/mock_tasks.json") as jsonfile:
data = json.load(jsonfile)
admin_user: UsersORM | None = await db.user.get_one_or_none(id=1)
assert admin_user
data = [
{
**item,
"user_id": admin_user.id,
"due_date": datetime.strptime(item["due_date"], "%Y-%m-%d"),
}
for item in data
]
result = await db.task.create_bulk(data)
await db.commit()
assert result
tasks = await db.task.get_filtered(user_id=admin_user.id)
assert tasks
user_with_tasks = await db.user.get_one_with_load(user_id=admin_user.id)
assert user_with_tasks
assert user_with_tasks.tasks
async def test_tasks_crud(db: "TestDBManager"):
data = {
"title": "test_tasks_crud",
"description": "test",
"due_date": datetime.now(),
"status": "open",
"priority": "medium",
"user_id": 1,
}
task = await db.task.create_one(data)
assert task
assert task.title == data["title"]
assert task.description == data["description"]
assert task.due_date == data["due_date"].date()
assert task.status == data["status"]
assert task.priority == data["priority"]
assert task.user_id == data["user_id"]
assert task.created_at is not None
find_task = await db.task.get_filtered(title=data["title"])
assert find_task
assert find_task[0].title == task.title
data["title"] = "test2"
task = await db.task.update_one(id=task.id, data=data)
assert task.title == data["title"]
await db.task.delete_one(id=task.id)
task = await db.task.get_one_or_none(id=task.id)
assert not task