Author SHA1 Message Date
MH.Dmitrii ba5e90c516 fix rate_limit tests and add prod_mode to env 2026-09-05 23:02:42 +03:00
MH.Dmitrii 5cdcd342dc add tests for the redis 2026-09-05 22:31:54 +03:00
MH.Dmitrii 9fd44c0ad9 fix rate-limit valid login bug and fix sessions of redis and psql in tests 2026-09-05 21:55:31 +03:00
MH.Dmitrii c4a6a88d05 revoke access_tokens 2026-09-05 20:26:42 +03:00
MH.Dmitrii da8284ae64 implemented rate limit and set up redis container 2026-09-05 13:06:04 +03:00
MH.Dmitrii 5ca1e2af15 docker multistage build 2026-09-03 16:14:41 +03:00
MH.Dmitrii d65c4117ff Merge pull request '500 handle' (#12) from feature/logging into dev
Reviewed-on: #12
2026-09-03 11:46:40 +00:00
MH.Dmitrii 5e3dfda882 500 handle 2026-09-03 14:45:42 +03:00
MH.Dmitrii 6a36b468e5 seed for the first user creation 2026-09-02 14:42:48 +03:00
MH.Dmitrii 230dd5201b fix migration script 2026-08-31 15:39:35 +03:00
MH.Dmitrii e0ebf45ccb readme add 2026-08-29 13:15:24 +03:00
MH.Dmitrii 41486fd35a Merge pull request 'dev' (#11) from dev into main
Reviewed-on: #11
2026-08-29 10:07:55 +00:00
MH.Dmitrii b65c68204e Merge pull request 'db change' (#10) from feature/logging into dev
Reviewed-on: #10
2026-08-29 10:07:18 +00:00
MH.Dmitrii 198506f062 db change 2026-08-29 13:04:41 +03:00
MH.Dmitrii 862a1eeddc Merge pull request 'dev' (#9) from dev into main
Reviewed-on: #9
2026-08-24 15:26:58 +00:00
MH.Dmitrii 97e4b07c47 Merge pull request 'logging feature' (#8) from feature/logging into dev
Reviewed-on: #8
2026-08-24 15:26:32 +00:00
MH.Dmitrii 443d40d7b6 logging feature 2026-08-24 18:22:43 +03:00
MH.Dmitrii 727491fbb8 Merge pull request 'feature/permissions' (#7) from feature/permissions into dev
Reviewed-on: #7
2026-08-23 11:51:49 +00:00
MH.Dmitrii 88bd61d28b permissions tests 2026-08-23 14:51:07 +03:00
MH.Dmitrii 6d1459f4d5 migrations sync fix 2026-08-21 14:12:45 +03:00
MH.Dmitrii 1ed602eeae permissions added 2026-08-21 12:28:25 +03:00
MH.Dmitrii 209049734e Merge pull request 'feature/testing' (#6) from feature/testing into dev
Reviewed-on: MH.Dmitrii/The_Great_Excel_Project#6
2026-08-19 08:29:10 +00:00
MH.Dmitrii 5dde797a1b remove boilerplate target url fix 2026-08-19 11:28:29 +03:00
MH.Dmitrii cfd3078216 removing boilerplate target url 2026-08-19 11:19:23 +03:00
MH.Dmitrii 98294ce91f Merge pull request 'feature/testing' (#5) from feature/testing into dev
Reviewed-on: MH.Dmitrii/The_Great_Excel_Project#5
2026-08-19 08:09:42 +00:00
MH.Dmitrii 73e983c6a5 all the remain tests 2026-08-19 11:08:33 +03:00
MH.Dmitrii 8bc3ff7b54 unit & interga tests 2026-08-19 00:22:59 +03:00
MH.Dmitrii c913909775 Merge pull request 'feature/testing' (#4) from feature/testing into dev
Reviewed-on: MH.Dmitrii/The_Great_Excel_Project#4
2026-08-18 16:34:25 +00:00
MH.Dmitrii b3083b0e82 e2e crud 0.2 2026-08-18 19:33:41 +03:00
MH.Dmitrii 1eb9935a15 conftest e2e auth/test user create 2026-08-17 19:33:23 +03:00
MH.Dmitrii 0a4a21f2e7 e2e tests 0.1 2026-08-17 11:42:12 +03:00
MH.Dmitrii 4486f62e17 uploads dir mount fix 2026-08-15 11:35:03 +03:00
MH.Dmitrii 33aa3cb7a4 create, delete, patch user 2026-08-14 13:24:55 +03:00
MH.Dmitrii 098461cd58 Merge pull request 'dev' (#3) from dev into main
Reviewed-on: MH.Dmitrii/The_Great_Excel_Project#3
2026-08-13 16:40:57 +00:00
MH.Dmitrii d3244666af makefile fix 2026-08-13 19:40:20 +03:00
MH.Dmitrii 6c5b85223e Merge pull request 'feature/docker' (#2) from feature/docker into dev
Reviewed-on: MH.Dmitrii/The_Great_Excel_Project#2
2026-08-13 16:32:55 +00:00
MH.Dmitrii f7e8b6b947 Merge pull request 'feature/docker' (#1) from feature/docker into main
Reviewed-on: MH.Dmitrii/The_Great_Excel_Project#1
2026-08-13 16:32:01 +00:00
MH.Dmitrii 0f8d816e7f feature gunicorn startup 2026-08-13 19:30:55 +03:00
MH.Dmitrii e23a6fc569 docker 2026-08-12 18:36:56 +03:00
MH.Dmitrii 50201542da imports fix 2026-08-10 17:56:41 +03:00
MH.Dmitrii 5ec033a21d asyncio style refactoring 2026-08-05 14:46:10 +03:00
MH.Dmitrii 2da58c7483 fix problems 2026-08-05 12:22:45 +03:00
MH.Dmitrii 4e14972cf6 fixes 2026-08-04 17:11:33 +03:00
MH.Dmitrii 8fa72daa6b makefile and http errors 2026-07-30 20:58:53 +03:00
MH.Dmitrii 3705ac4f0c conftest fix 2026-07-30 15:54:43 +03:00
MH.Dmitrii 439d57554c integration tests 2026-07-30 15:49:08 +03:00
MH.Dmitrii f87f54de55 tests logout 2026-07-29 18:32:22 +03:00
MH.Dmitrii 00403191f5 integrated tests 2026-07-29 00:44:14 +03:00
MH.Dmitrii d24b99b8b0 integrated tests + omit unnecessary 2026-07-28 15:16:52 +03:00
MH.Dmitrii ef1e39d506 fix gitignore 2026-07-28 13:35:38 +03:00
MH.Dmitrii 2781317797 rm coverage 2026-07-28 13:34:14 +03:00
MH.Dmitrii f81ba19da4 hash tests 2026-07-28 13:31:58 +03:00
MH.Dmitrii 7a4df2933d ruff fix 2026-07-28 01:43:35 +03:00
MH.Dmitrii 5522447b07 auth unit tests & ruff visual fix 2026-07-28 01:42:29 +03:00
MH.Dmitrii 1d25b0edc3 refactoring some code by removing unnecessary checks and moving tests block to root / 2026-07-24 17:13:37 +03:00
61 changed files with 3250 additions and 901 deletions
+18
View File
@@ -0,0 +1,18 @@
# виртуальное окружение
venv/
.venv/
# кэш питона
__pycache__/
*.pyc
*.pyo
*.pyd
*.pytest_cache
# IDE и редакторы
.vscode/
.idea/
# OS мусор
.DS_Store
Thumbs.db
+5
View File
@@ -21,7 +21,12 @@ Thumbs.db
*.env
#db
*.db
DB/
#logs
logs/
#Примеры документов
input/
output/
allure-results/
.coverage
+4
View File
@@ -0,0 +1,4 @@
HOST="change_me"
PORT="change_me"
TEST_USERNAME="change_me"
TEST_PASSWORD="change_me"
+14 -2
View File
@@ -1,4 +1,16 @@
SECRET_KEY = "change_me"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS= 45
ACCESS_TOKEN_EXPIRE_MINUTES = 15 #int
REFRESH_TOKEN_EXPIRE_DAYS= 45 #int
DB_USER="change_me"
DB_PASSWORD="change_me"
DB_POSTGRESS="change_me"
DB_HOST="change_me"
DB_PORT="change_me"
REDIS_PASSWORD="change_me"
REDIS_PORT=change_me #int
REDIS_HOST="change_me"
PROD_MODE=bool
+126
View File
@@ -0,0 +1,126 @@
name: disexcel
services:
backend-dev:
profiles: ["dev"]
image: "${DOCKER_REGISTRY:-local}/excel-dev:${IMAGE_TAG:-local}"
container_name: backend-dev
build:
dockerfile: ./docker/dockerfile
context: ../
target: dev
init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
environment:
- DB_HOST=psql #rewrite DB_HOST var to communicate inside the docker network
volumes:
- type: bind
source: ../src
target: /home/excel-project/src
- type: bind
source: ../main.py
target: /home/excel-project/main.py
- type: bind
source: ../configs
target: /home/excel-project/configs
- type: bind
source: ../DB
target: /home/excel-project/DB
- type: bind
source: ../uploads
target: /home/excel-project/uploads
- type: bind
source: ../logs
target: /home/excel-project/logs
networks:
- backend
depends_on:
psql:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "80:8000"
entrypoint: ["./entrypoint.sh", "--dev"]
backend-prod:
profiles: ["prod"]
image: "${DOCKER_REGISTRY:-local}/excel-prod:${IMAGE_TAG:-local}"
container_name: backend-prod
build:
dockerfile: ./docker/dockerfile
context: ../
target: prod
init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
environment:
- DB_HOST=psql
volumes:
- type: bind
source: ../configs
target: /home/excel-project/configs
- type: bind
source: ../DB
target: /home/excel-project/DB
- type: bind
source: ../uploads
target: /home/excel-project/uploads
- type: bind
source: ../logs
target: /home/excel-project/logs
networks:
- backend
restart: unless-stopped
depends_on:
psql:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "80:8000"
psql:
profiles: ["prod", "dev", "db"]
image: postgres:16-alpine
container_name: psql
init: true
ipc: private
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_POSTGRESS}
volumes:
- type: bind
source: ../DB
target: /var/lib/postgresql/data
networks:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_POSTGRESS}"]
interval: 5s
timeout: 5s
retries: 5
ports:
- "${DB_PORT}:5432"
redis:
image: redis:latest
profiles: ["prod", "dev", "redis"]
container_name: redis
init: true
ipc: private
ports:
- '${REDIS_PORT}:6379'
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"]
interval: 1s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
backend:
name: "${BACKEND_NETWORK:-backend_network}"
+65
View File
@@ -0,0 +1,65 @@
# --- Stage 1: Python Backend dev ---
FROM python:3.14-slim AS dev
LABEL org.opencontainers.image.title="The-DisExcel-project-dev"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
WORKDIR /home/excel-project
COPY pyproject.toml poetry.lock docker/entrypoint.sh alembic.ini ./
RUN chmod +x ./entrypoint.sh
RUN pip install --no-cache-dir --break-system-packages poetry \
&& poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction
# --- Stage 1: Python Backend builder ---
FROM python:3.14-slim AS builder
LABEL org.opencontainers.image.title="The-DisExcel-project-dev"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
WORKDIR /home/excel-project
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN python -m venv "$VIRTUAL_ENV"
COPY pyproject.toml poetry.lock ./
RUN pip install --no-cache-dir poetry \
&& poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction --only main \
&& pip uninstall -y poetry poetry-core poetry-plugin-export
# --- Stage 2: Python Backend prod ---
FROM python:3.14-slim AS prod
LABEL org.opencontainers.image.title="The-DisExcel-project-prod"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
WORKDIR /home/excel-project
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY --from=builder /opt/venv /opt/venv
COPY pyproject.toml poetry.lock main.py docker/entrypoint.sh alembic.ini ./
COPY src/ ./src/
RUN chmod +x ./entrypoint.sh
RUN groupadd --gid 1000 appuser \
&& useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser \
&& chown -R appuser:appuser /home/excel-project
USER appuser
ENTRYPOINT ["./entrypoint.sh", "--prod"]
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
MODE=""
WORKERS=""
while [ -n "$1" ]; do
case "$1" in
--dev) MODE="dev" ;;
--prod) MODE="prod" ;;
--workers)
shift
WORKERS="$1"
;;
*) echo "$1 is not an option" ;;
esac
shift
done
if [ -z "$MODE" ]; then
echo "Usage: entrypoint.sh --dev|--prod [--workers N]"
exit 1
fi
if ! alembic upgrade head; then
echo "Migration failed"
exit 1
fi
if [ "$MODE" = "dev" ]; then
WORKERS="${WORKERS:-1}"
exec gunicorn \
--workers "$WORKERS" \
--worker-class uvicorn.workers.UvicornWorker \
--worker-connections 1000 \
--reload \
--bind 0.0.0.0:8000 \
main:app
else
WORKERS="${WORKERS:-4}"
exec gunicorn \
--workers "$WORKERS" \
--worker-class uvicorn.workers.UvicornWorker \
--worker-connections 1000 \
--bind 0.0.0.0:8000 \
main:app
fi
+43 -9
View File
@@ -1,16 +1,50 @@
from fastapi import FastAPI
from src.web.protected_routes.routes import router as protected_router
import uvicorn
from contextlib import asynccontextmanager
from pathlib import Path
app=FastAPI(root_path="/")
# import uvicorn
from fastapi import FastAPI
from src.cache.redis_client import redis_client
from src.database.users.crud import Seed
from src.logging.logger import LoggingMiddleware, ProcessingTimeMiddleware
from src.web.protected_routes.auth_routes import router as protected_router
from src.web.protected_routes.protected_user_action_routes import (
router as protected_user_action_routes,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_dirs()
await create_first_user()
yield
await redis_client.close()
app=FastAPI(root_path="/", lifespan=lifespan)
app.add_middleware(LoggingMiddleware)
app.add_middleware(ProcessingTimeMiddleware)
app.include_router(router=protected_router)
app.include_router(router=protected_user_action_routes)
@app.get("")
def root()->dict:
async def root()->dict:
return {"root":"hello, this is root"}
def main():
uvicorn.run("main:app", reload=True)
def create_dirs()->None:
if __name__=="__main__":
main()
dirs_to_create=("./DB",
"./uploads/upload",
"./uploads/upload_bad",
"./uploads/upload_finished",
"./logs")
for x in dirs_to_create:
Path(x).mkdir(parents=True, exist_ok=True)
async def create_first_user()->None:
seed=Seed()
await seed.seed()
+84 -7
View File
@@ -1,11 +1,88 @@
VENV=source .venv/bin/activate;
VENV:=source .venv/bin/activate;
ALLURE:=.venv/allure-2.44.0/bin/allure #linux&macos
#ALLURE=.venv\allure-2.44.0\bin\allure #Windows
.PHONY:
run, m_gen, m_up
NUM_DOWN ?= 1
#make run-dev BUILD=--build
BUILD ?=
run:
${VENV} python3 main.py
m_gen:
.DEFAULT_GOAL := help
.PHONY: help
help:
@grep -E '(^[a-zA-Z0-9_-]+:.*?##.*$$)|(^##)' Makefile | awk 'BEGIN {FS = ":.*?## "}{printf "[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m## /[33m/' | sed -e 's/\[32m/ /' | sed -e 's/\[33m//'
##
## Init section
##
.PHONY: run
run: ## Run dev local application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload
.PHONY: down
down: ## Down dev local db
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis down
.PHONY: run-dev
run-dev: ## Run dev application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile dev up -d ${BUILD}
.PHONY: run-prod
run-prod: ## Run prod application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile prod up -d ${BUILD}
.PHONY: down-dev
down-dev: ## Down dev application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile dev down
.PHONY: down-prod
down-prod: ## Down prod application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile prod down
##
## Migration section
##
.PHONY: m_gen
m_gen: ## Generate alembic new revision
${VENV} alembic revision --autogenerate
m_up:
.PHONY: m_up
m_up: ## Set new alembic revision
${VENV} alembic upgrade head
.PHONY: m_down
m_down: ## Downgrade alembic revision
${VENV} alembic downgrade -${NUM_DOWN}
.PHONY: m_history
m_history: ## List history of migrations
${VENV} alembic history
.PHONY: m_current
m_current: ## Current migration
${VENV} alembic current
.PHONY: m_stamp
m_stamp: ## Stamp head
${VENV} alembic stamp head
##
## Test section
##
.PHONY: test
test: ## Run tests
${VENV} pytest
.PHONY: allure
allure: ## Generate allure report
${VENV} ${ALLURE} generate tests/allure-results/reports --single-file -o tests/allure-results/html --clean
.PHONY: coverage
coverage: ## Run pytest coverage
${VENV} pytest --cov=src tests/ --cov-report=term-missing
.PHONY: clear
clear: ## Delete old test results
rm -rf ./tests/allure-results/reports
Generated
+638 -183
View File
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -8,24 +8,55 @@ authors = [
license = "MH.Dmitrii's project"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"alembic (>=1.18.5,<2.0.0)",
"pytest (>=9.1.1,<10.0.0)",
"uvicorn (>=0.51.0,<0.52.0)",
"gunicorn (>=26.0.0,<27.0.0)",
"fastapi (>=0.139.1,<0.140.0)",
"pydantic[email] (>=2.13.4,<3.0.0)",
"pydantic-settings (>=2.14.2,<3.0.0)",
"sqlalchemy (>=2.0.51,<3.0.0)",
"sqlalchemy[asyncio] (>=2.0.52,<3.0.0)",
"pandas (>=3.0.3,<4.0.0)",
"bcrypt (>=5.0.0,<6.0.0)",
"python-jose (>=3.5.0,<4.0.0)",
"python-multipart (>=0.0.32,<0.0.33)",
"ipython (>=9.15.0,<10.0.0)",
"httpie (>=3.2.4,<4.0.0)"
"greenlet (>=3.5.4,<4.0.0)",
"aiofiles (>=25.1.0,<26.0.0)",
"asyncpg (>=0.31.0,<0.32.0)",
"psycopg2-binary (>=2.9.12,<3.0.0)",
"redis (>=8.1.0,<9.0.0)",
]
[tool.poetry.group.dev.dependencies]
pytest = ">=9.1.1,<10.0.0"
pytest-cov = ">=7.1.0,<8.0.0"
pytest-mock = ">=3.15.1,<4.0.0"
allure-pytest = ">=2.16.0,<3.0.0"
ipython = ">=9.15.0,<10.0.0"
httpie = ">=3.2.4,<4.0.0"
pytest-asyncio = ">=1.4.0,<2.0.0"
requests-async = ">=0.2.4,<0.3.0"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.coverage.run]
omit = [
# "*/models/*",
"*/migrations/*",
"*/database/*",
"*/errors/*",
"__init__.py",
"*/docker/*",
"*/rate_limit.py",
"*/logger.py"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
]
[tool.ruff.lint]
ignore=["B008"]
+16
View File
@@ -0,0 +1,16 @@
[pytest]
addopts =
-l
-v
-s
--alluredir=tests/allure-results/reports/
testpaths =
tests
markers=
unit: unit tests
integra: integrations test
e2e: e2e tests
smoke: smoke tests
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
+71
View File
@@ -0,0 +1,71 @@
# The_DisExcel_project
A FastAPI project combining Excel and digital data ("The Great Excel project that is going to be built from Excel and digital projects").
## Stack
- Python >= 3.13
- FastAPI + Uvicorn / Gunicorn
- SQLAlchemy 2.0 (async) + Alembic (migrations)
- PostgreSQL (asyncpg, psycopg2)
- Pydantic 2 / Pydantic Settings
- Poetry — dependency management
- Docker, Ansible — deployment
## Architecture
```
src/
├── database/ # DB CRUD operations
├── errors/ # HTTP errors
├── logging/ # logging middleware
├── migrations/ # Alembic migrations
├── models/ # Pydantic and SQLAlchemy models, configs
├── reports/ # reports
├── service/ # business logic (auth, users_crud)
└── web/ # routes (protected_routes)
```
Layers are connected top to bottom: `web → service → database → models`.
## Authentication
- JWT access + refresh tokens
- Refresh token is stored in the DB as a SHA256 hash, with rotation and revocation support
- Passwords are hashed with bcrypt
- RBAC: direct user permissions + permissions via groups, checked through `require_permissions()`
## Testing
```
tests/
├── unit/
├── integrated/
└── e2e/
```
Uses pytest, pytest-asyncio, pytest-cov, pytest-mock, allure-pytest.
## Installation
```bash
poetry install
```
## Running migrations
```bash
alembic upgrade head
```
## CI/CD
Pipeline is set up via Gitea Actions (`.gitea/workflows/ci.yml`).
## Deployment
The repository includes ready-made `docker/` and `ansible/` configs for containerization and deployment.
## License
MH.Dmitrii's project
+1
View File
@@ -0,0 +1 @@
#redis dir
+24
View File
@@ -0,0 +1,24 @@
from src.cache.redis_client import redis_client
from src.errors.http_errors.errors import Errors
class RateLimit:
def __init__(self) -> None:
self.errors=Errors()
async def rate_limit(self, ip:str)->None:
key=f"action attempt {ip}"
attempts = await redis_client.incrby(key)
if attempts == 1:
await redis_client.expire(key, 60)
if attempts>5:
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60)
async def check_rate_limit(self, client_ip:str) -> None:
await self.rate_limit(client_ip)
rate_limiter=RateLimit()
+23
View File
@@ -0,0 +1,23 @@
import redis.asyncio as redis
from src.models.configs_read.env import env_settings
class RedisClient:
def __init__(self) -> None:
self.client = redis.Redis(
host=env_settings.REDIS_HOST,
port=env_settings.REDIS_PORT,
password=env_settings.REDIS_PASSWORD,
decode_responses=True,
max_connections=10,
)
def __getattr__(self, name):
return getattr(self.client, name)
async def close(self) -> None:
await self.client.aclose()
redis_client = RedisClient()
+58 -53
View File
@@ -1,64 +1,69 @@
from uuid import UUID
from src.models.database_models.model import engine, RefreshTokens
from sqlalchemy import and_, not_, select
from sqlalchemy.orm import sessionmaker
from sqlalchemy import and_, not_, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from src.models.database_models.model import RefreshTokens, engine
from src.models.pydantic_models.model import RefreshTokensOut
class JwtCrudActions:
def __init__(self) -> None:
self.Session=sessionmaker(bind=engine)
self.Session=async_sessionmaker(bind=engine)
def get_token_by_user_id(self, user_id:UUID)->RefreshTokensOut|None:
with self.Session() as session:
with session.begin():
query=select(RefreshTokens).where(and_(RefreshTokens.user_id==user_id, not_(RefreshTokens.is_revoked)))
response=session.scalars(query).one_or_none()
if response is None:
return None
return RefreshTokensOut.model_validate(response)
async def get_token_by_user_id(self, user_id:UUID)->RefreshTokensOut|None:
async with self.Session() as session, session.begin():
query=select(RefreshTokens).where(and_(RefreshTokens.user_id==user_id, not_(RefreshTokens.is_revoked)))
response= (await session.scalars(query)).first()
if response is None:
return None
return RefreshTokensOut.model_validate(response)
def get_token_by_id(self, id:UUID)->RefreshTokensOut|None:
with self.Session() as session:
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.id==id)
response=session.scalars(query).one_or_none()
if response is None:
return None
return RefreshTokensOut.model_validate(response)
async def get_token_by_id(self, token_id:UUID)->RefreshTokensOut|None:
async with self.Session() as session, session.begin():
query=select(RefreshTokens).where(RefreshTokens.id==token_id)
response= (await session.scalars(query)).one_or_none()
if response is None:
return None
return RefreshTokensOut.model_validate(response)
def create_token(self, data:dict)->None:
with self.Session() as session:
with session.begin():
new_token=RefreshTokens(**data)
response=session.add(new_token)
return response
async def create_token(self, data:dict)->None:
async with self.Session() as session, session.begin():
new_token=RefreshTokens(**data)
session.add(new_token)
def update_token(self, old_jti:UUID, new_jti:UUID)->bool:
with self.Session() as session:
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.id==old_jti)
response=session.scalars(query).one()
async def create_and_update_token(self, data: dict, old_jti: UUID, new_jti: UUID) -> bool:
async with self.Session() as session, session.begin():
new_token = RefreshTokens(**data)
query = (
update(RefreshTokens)
.where(RefreshTokens.id == old_jti, RefreshTokens.is_revoked.is_(False))
.values(is_revoked=True, replaced_by=new_jti)
.returning(RefreshTokens.id)
)
updated_id = (await session.execute(query)).scalar_one_or_none()
if updated_id is None:
return False
session.add(new_token)
return True
async def revoke_all(self, user_id:UUID)->bool:
async with self.Session() as session, session.begin():
await session.execute(update(RefreshTokens).where(RefreshTokens.user_id==user_id).values(is_revoked=True)) #bulk update
return True
async def logout(self,token_id:UUID)->bool:
async with self.Session() as session, session.begin():
query=select(RefreshTokens).where(RefreshTokens.id == token_id)
response= (await session.scalars(query)).one_or_none()
if response is None:
return False
else:
response.is_revoked=True
response.replaced_by=new_jti
return True
def revoke_all(self, user_id:UUID)->bool:
with self.Session() as session:
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.user_id==user_id)
response=session.scalars(query).all()
for record in response:
record.is_revoked=True
return True
def logout(self,id:UUID)->bool:
with self.Session() as session:
with session.begin():
query=select(RefreshTokens).where(RefreshTokens.id == id)
response=session.scalars(query).one_or_none()
if response is None:
return False
else:
response.is_revoked=True
return True
return True
+151 -21
View File
@@ -1,26 +1,156 @@
from sqlalchemy import select
from src.models.database_models.model import User, engine
from src.models.pydantic_models.model import UserOutDB
from sqlalchemy.orm import sessionmaker
from uuid import UUID
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from src.models.database_models.model import (
Permissions,
PermissionsGroups,
User,
engine,
)
from src.models.pydantic_models.model import UserOutDB
from src.service.auth.jwt import HashService
class UsersCrudActions:
def __init__(self) -> None:
self.Session=sessionmaker(bind=engine)
def get_user_by_email(self, email:str)->UserOutDB|None:
with self.Session() as session:
with session.begin():
query=select(User).where(User.email==email)
response=session.scalars(query).one_or_none()
if response is None:
return None
return UserOutDB.model_validate(response)
def __init__(self) -> None:
self.Session=async_sessionmaker(bind=engine)
async def get_user_by_email(self, email:str)->UserOutDB|None:
async with self.Session() as session, session.begin():
def get_user_by_id(self, id:UUID)->UserOutDB|None:
with self.Session() as session:
with session.begin():
query=select(User).where(User.id==id)
response=session.scalars(query).one_or_none()
query=select(User).where(User.email==email)
response=(await session.scalars(query)).one_or_none()
if response is None:
return None
return UserOutDB.model_validate(response)
async def get_user_by_id(self, id:UUID)->UserOutDB|None:
async with self.Session() as session, session.begin():
query=select(User).where(User.id==id)
response=(await session.scalars(query)).one_or_none()
if response is None:
return None
return UserOutDB.model_validate(response)
async def create_user(self, data:dict)->UserOutDB|None:
async with self.Session() as session, session.begin():
groups_name=data.pop("group", None)
permissions_name=data.pop("direct_permissions", None)
new_user=User(**data)
if groups_name:
query=select(PermissionsGroups).where(PermissionsGroups.group.in_(groups_name))
response=(await session.scalars(query)).all()
if response is None:
return None
return UserOutDB.model_validate(response)
new_user.group=[]
else:
new_user.group=list(response)
else:
new_user.group=[]
if permissions_name:
query=select(Permissions).where(Permissions.permission.in_(permissions_name))
response=(await session.scalars(query)).all()
if response is None:
new_user.direct_permissions=[]
else:
new_user.direct_permissions=list(response)
else:
new_user.direct_permissions=[]
session.add(new_user)
await session.flush()
return UserOutDB.model_validate(new_user)
async def delete_user_soft(self, user_email:str)->bool|None:
async with self.Session() as session, session.begin():
query=select(User).where(User.email == user_email)
response=(await session.scalars(query)).one_or_none()
if response is None:
return None
response.status=False
return True
async def delete_user_hard(self, user_email:str)->bool|None:
async with self.Session() as session, session.begin():
query=delete(User).where(User.email == user_email).returning(User)
response=(await session.scalars(query)).one_or_none()
if response is None:
return None
return True
async def update_user_partially(self, user_email:str, data:dict)->UserOutDB|None:
async with self.Session() as session, session.begin():
groups_name=data.pop("group", None)
permissions_name=data.pop("direct_permissions", None)
query = update(User).where(User.email == user_email).values(**data).returning(User)
user_edit=(await session.scalars(query)).one_or_none()
if user_edit is None:
return None
if groups_name is not None:
query=select(PermissionsGroups).where(PermissionsGroups.group.in_(permissions_name))
groups=(await session.scalars(query)).all()
user_edit.group=list(groups)
if permissions_name is not None:
query=select(Permissions).where(Permissions.permission.in_(permissions_name))
groups=(await session.scalars(query)).all()
user_edit.direct_permissions=list(groups)
await session.flush()
return UserOutDB.model_validate(user_edit)
class Seed:
def __init__(self) -> None:
self.Session=async_sessionmaker(bind=engine)
self.hash_service=HashService()
async def seed(self) -> None:
async with self.Session() as session, session.begin():
existing = (await session.execute(select(User).limit(1))).scalar_one_or_none()
if existing is not None:
return
admin_permission = Permissions(permission="admin")
session.add(admin_permission)
admin_group = PermissionsGroups(group="admin_group", permissions=[admin_permission])
session.add(admin_group)
admin_user = User(
first_name="Admin",
last_name="Admin",
middle_name="Admin",
email="admin@admin.com",
hashed_password=self.hash_service.plain_to_hash("1234"),
direct_permissions=[admin_permission],
group=[admin_group],
)
session.add(admin_user)
print("Seed completed: admin user and permissions are created, credentials: email - admin@admin.com, password - 1234")
+27 -1
View File
@@ -11,4 +11,30 @@ class Errors:
raise HTTPException(status_code=403, detail=detail, headers={"Cache-Control": "no-store, max-age=0"})
def not_found_error(self, detail:str)->HTTPException:
raise HTTPException(status_code=404, detail=detail, headers={"Cache-Control": "no-store, max-age=0"})
raise HTTPException(status_code=404, detail=detail, headers={"Cache-Control": "no-store, max-age=0"})
def conflict_error(self, detail:str)->HTTPException:
raise HTTPException(status_code=409, detail=detail)
def bad_request_error(self, detail:str) -> HTTPException:
raise HTTPException(status_code=400, detail=detail)
def validation_error(self, detail:str) -> HTTPException:
raise HTTPException(status_code=422, detail=detail)
def rate_limit_error(self, detail:str, retry_after: int = 60) -> HTTPException:
raise HTTPException(
status_code=429,
detail=detail,
headers={"Retry-After":str(retry_after)}
)
def internal_server_error(self, detail:str = "Internal server error") -> HTTPException:
raise HTTPException(status_code=500, detail=detail)
def service_unavailable_error(self, detail:str, retry_after: int = 30) -> HTTPException:
raise HTTPException(
status_code=503,
detail=detail,
headers={"Retry-After":str(retry_after)}
)
+9
View File
@@ -0,0 +1,9 @@
#logger decorators and middlewares
import logging
from .logger import LoggerDB
sql_logger = logging.getLogger("sqlalchemy.engine")
sql_logger.setLevel(logging.INFO)
sql_logger.addHandler(LoggerDB())
+71
View File
@@ -0,0 +1,71 @@
import asyncio
import json
import logging
from time import gmtime, perf_counter, strftime
from typing import cast
import aiofiles
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.concurrency import iterate_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response, StreamingResponse
class ProcessingTimeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next)->Response:
start_time = perf_counter()
response = await call_next(request)
process_time = perf_counter() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
file_time = strftime("%b_%Y", gmtime())
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
try:
response = await call_next(request)
except Exception as exc: # noqa: BLE001
body = str(exc)
async with aiofiles.open(f"./logs/endpoints_log_{file_time}.txt", "a") as file:
await file.write(f"[{current_time}] [500] [{body}] [{client_ip}]\n")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
streaming_response = cast(StreamingResponse, response)
chunks = []
async for chunk in streaming_response.body_iterator:
chunks.append(chunk.encode() if isinstance(chunk, str) else bytes(chunk))
body_bytes = b"".join(chunks)
streaming_response.body_iterator = iterate_in_threadpool(iter([body_bytes]))
try:
parsed = json.loads(body_bytes)
body = parsed.get("detail", None) if not isinstance(parsed, bool) else None
except (json.JSONDecodeError, TypeError):
body = None
async with aiofiles.open(f"./logs/endpoints_log_{file_time}.txt", "a") as file:
await file.write(f"[{current_time}] [{response.status_code}] [{body}] [{client_ip}]\n")
return response
class LoggerDB(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
asyncio.create_task(self._write(msg))
async def _write(self, msg: str) -> None:
file_time = strftime("%b_%Y", gmtime())
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
async with aiofiles.open(f"./logs/sql_log_{file_time}.txt", "a") as f:
await f.write(f"[{current_time}] {msg}\n")
+6 -11
View File
@@ -1,13 +1,13 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import create_engine, pool
from src.models.database_models import Model
from src.models.database_models.model import engine
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
sync_url = engine.url.render_as_string(hide_password=False).replace("+asyncpg", "")
sync_engine = create_engine(sync_url, poolclass=pool.NullPool)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -23,7 +23,7 @@ if config.config_file_name is not None:
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Model.metadata
config.set_main_option("sqlalchemy.url", engine.url.render_as_string(hide_password=False))
config.set_main_option("sqlalchemy.url", sync_url)
# other values from the config, defined by the needs of env.py,
# can be acquired:
@@ -62,13 +62,8 @@ def run_migrations_online() -> None:
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
with sync_engine.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata, render_as_batch=True
)
-32
View File
@@ -1,32 +0,0 @@
"""empty message
Revision ID: 23dd6d3efe4b
Revises: 2f92088cdce4
Create Date: 2026-07-23 13:05:12.553687
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '23dd6d3efe4b'
down_revision: Union[str, Sequence[str], None] = '2f92088cdce4'
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! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
-32
View File
@@ -1,32 +0,0 @@
"""empty message
Revision ID: 2f92088cdce4
Revises: 75074097a2a3
Create Date: 2026-07-23 11:05:02.409697
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2f92088cdce4'
down_revision: Union[str, Sequence[str], None] = '75074097a2a3'
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! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
-50
View File
@@ -1,50 +0,0 @@
"""empty message
Revision ID: 385d4efec15f
Revises: 23dd6d3efe4b
Create Date: 2026-07-23 15:03:33.582896
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '385d4efec15f'
down_revision: Union[str, Sequence[str], None] = '23dd6d3efe4b'
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! ###
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.INTEGER(),
type_=sa.Uuid(),
existing_nullable=False)
batch_op.alter_column('replaced_by',
existing_type=sa.INTEGER(),
type_=sa.Uuid(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
batch_op.alter_column('replaced_by',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=True)
batch_op.alter_column('id',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False)
# ### end Alembic commands ###
-69
View File
@@ -1,69 +0,0 @@
"""empty message
Revision ID: 439a77f8a4d4
Revises: 5e60c8fbc553
Create Date: 2026-07-22 15:19:15.853280
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '439a77f8a4d4'
down_revision: Union[str, Sequence[str], None] = '5e60c8fbc553'
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('markets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_markets')),
sa.UniqueConstraint('name', name=op.f('uq_markets_name'))
)
with op.batch_alter_table('markets', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_markets_id'), ['id'], unique=False)
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.add_column(sa.Column('doc_id', sa.Uuid(), nullable=False))
batch_op.add_column(sa.Column('uploaded_at', sa.TIMESTAMP(), nullable=False))
batch_op.add_column(sa.Column('doc_date', sa.TIMESTAMP(), nullable=False))
batch_op.add_column(sa.Column('status', sa.String(length=64), nullable=False))
batch_op.add_column(sa.Column('user_id', sa.Uuid(), nullable=False))
batch_op.add_column(sa.Column('market_id', sa.Integer(), nullable=False))
batch_op.create_index(batch_op.f('ix_reports_market_id'), ['market_id'], unique=False)
batch_op.create_index(batch_op.f('ix_reports_user_id'), ['user_id'], unique=False)
batch_op.create_unique_constraint(batch_op.f('uq_reports_doc_id'), ['doc_id'])
batch_op.create_foreign_key(batch_op.f('fk_reports_user_id_users'), 'users', ['user_id'], ['id'], ondelete='CASCADE')
batch_op.create_foreign_key(batch_op.f('fk_reports_market_id_markets'), 'markets', ['market_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('fk_reports_market_id_markets'), type_='foreignkey')
batch_op.drop_constraint(batch_op.f('fk_reports_user_id_users'), type_='foreignkey')
batch_op.drop_constraint(batch_op.f('uq_reports_doc_id'), type_='unique')
batch_op.drop_index(batch_op.f('ix_reports_user_id'))
batch_op.drop_index(batch_op.f('ix_reports_market_id'))
batch_op.drop_column('market_id')
batch_op.drop_column('user_id')
batch_op.drop_column('status')
batch_op.drop_column('doc_date')
batch_op.drop_column('uploaded_at')
batch_op.drop_column('doc_id')
with op.batch_alter_table('markets', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_markets_id'))
op.drop_table('markets')
# ### end Alembic commands ###
-32
View File
@@ -1,32 +0,0 @@
"""empty message
Revision ID: 74814eb1b7f8
Revises: 8c136ff14180
Create Date: 2026-07-23 21:06:37.254211
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '74814eb1b7f8'
down_revision: Union[str, Sequence[str], None] = '8c136ff14180'
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! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
-103
View File
@@ -1,103 +0,0 @@
"""empty message
Revision ID: 75074097a2a3
Revises: 8c300c4d43ea
Create Date: 2026-07-22 16:11:08.077455
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '75074097a2a3'
down_revision: Union[str, Sequence[str], None] = '8c300c4d43ea'
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('accountant_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('database_key', sa.Uuid(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_accountant_settings')),
sa.UniqueConstraint('database_key', name=op.f('uq_accountant_settings_database_key'))
)
with op.batch_alter_table('accountant_settings', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_accountant_settings_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_accountant_settings_name'), ['name'], unique=True)
op.create_table('doc_types',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_doc_types'))
)
with op.batch_alter_table('doc_types', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_doc_types_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_doc_types_name'), ['name'], unique=True)
op.create_table('fabric',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_fabric'))
)
with op.batch_alter_table('fabric', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_fabric_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_fabric_name'), ['name'], unique=True)
op.create_table('goods',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('article', sa.String(length=16), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('tnvd', sa.Integer(), nullable=False),
sa.Column('doc_type_id', sa.Integer(), nullable=False),
sa.Column('fabric_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['doc_type_id'], ['doc_types.id'], name=op.f('fk_goods_doc_type_id_doc_types'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['fabric_id'], ['fabric.id'], name=op.f('fk_goods_fabric_id_fabric'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_goods'))
)
with op.batch_alter_table('goods', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_goods_article'), ['article'], unique=True)
batch_op.create_index(batch_op.f('ix_goods_doc_type_id'), ['doc_type_id'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_fabric_id'), ['fabric_id'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_price'), ['price'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_tnvd'), ['tnvd'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('goods', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_goods_tnvd'))
batch_op.drop_index(batch_op.f('ix_goods_price'))
batch_op.drop_index(batch_op.f('ix_goods_id'))
batch_op.drop_index(batch_op.f('ix_goods_fabric_id'))
batch_op.drop_index(batch_op.f('ix_goods_doc_type_id'))
batch_op.drop_index(batch_op.f('ix_goods_article'))
op.drop_table('goods')
with op.batch_alter_table('fabric', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_fabric_name'))
batch_op.drop_index(batch_op.f('ix_fabric_id'))
op.drop_table('fabric')
with op.batch_alter_table('doc_types', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_doc_types_name'))
batch_op.drop_index(batch_op.f('ix_doc_types_id'))
op.drop_table('doc_types')
with op.batch_alter_table('accountant_settings', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_accountant_settings_name'))
batch_op.drop_index(batch_op.f('ix_accountant_settings_id'))
op.drop_table('accountant_settings')
# ### end Alembic commands ###
-32
View File
@@ -1,32 +0,0 @@
"""empty message
Revision ID: 8c136ff14180
Revises: 385d4efec15f
Create Date: 2026-07-23 17:56:26.345954
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8c136ff14180'
down_revision: Union[str, Sequence[str], None] = '385d4efec15f'
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! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
-40
View File
@@ -1,40 +0,0 @@
"""empty message
Revision ID: 8c300c4d43ea
Revises: 439a77f8a4d4
Create Date: 2026-07-22 15:20:34.871724
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8c300c4d43ea'
down_revision: Union[str, Sequence[str], None] = '439a77f8a4d4'
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! ###
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.alter_column('uploaded_at',
existing_type=sa.TIMESTAMP(),
nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.alter_column('uploaded_at',
existing_type=sa.TIMESTAMP(),
nullable=False)
# ### end Alembic commands ###
@@ -1,18 +1,18 @@
"""empty message
Revision ID: 5e60c8fbc553
Revision ID: f4018d3509eb
Revises:
Create Date: 2026-07-17 20:02:01.237457
Create Date: 2026-08-28 12:45:18.014540
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
# revision identifiers, used by Alembic.
revision: str = '5e60c8fbc553'
revision: str = 'f4018d3509eb'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -21,6 +21,35 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('accountant_settings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('database_key', sa.Uuid(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_accountant_settings')),
sa.UniqueConstraint('database_key', name=op.f('uq_accountant_settings_database_key'))
)
with op.batch_alter_table('accountant_settings', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_accountant_settings_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_accountant_settings_name'), ['name'], unique=True)
op.create_table('doc_types',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_doc_types'))
)
with op.batch_alter_table('doc_types', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_doc_types_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_doc_types_name'), ['name'], unique=True)
op.create_table('fabric',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_fabric'))
)
with op.batch_alter_table('fabric', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_fabric_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_fabric_name'), ['name'], unique=True)
op.create_table('groups_of_permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('group', sa.String(length=255), nullable=False),
@@ -30,6 +59,15 @@ def upgrade() -> None:
with op.batch_alter_table('groups_of_permissions', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_groups_of_permissions_id'), ['id'], unique=False)
op.create_table('markets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_markets')),
sa.UniqueConstraint('name', name=op.f('uq_markets_name'))
)
with op.batch_alter_table('markets', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_markets_id'), ['id'], unique=False)
op.create_table('permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('permission', sa.String(length=255), nullable=False),
@@ -38,17 +76,6 @@ def upgrade() -> None:
)
with op.batch_alter_table('permissions', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_permissions_id'), ['id'], unique=False)
op.execute(text("INSERT INTO permissions (permission) VALUES ('admin');"))
op.create_table('reports',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_reports'))
)
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_reports_filename'), ['filename'], unique=False)
batch_op.create_index(batch_op.f('ix_reports_id'), ['id'], unique=False)
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
@@ -66,6 +93,26 @@ def upgrade() -> None:
batch_op.create_index(batch_op.f('ix_users_last_name'), ['last_name'], unique=False)
batch_op.create_index(batch_op.f('ix_users_middle_name'), ['middle_name'], unique=False)
op.create_table('goods',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('article', sa.String(length=16), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('tnvd', sa.Integer(), nullable=False),
sa.Column('doc_type_id', sa.Integer(), nullable=False),
sa.Column('fabric_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['doc_type_id'], ['doc_types.id'], name=op.f('fk_goods_doc_type_id_doc_types'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['fabric_id'], ['fabric.id'], name=op.f('fk_goods_fabric_id_fabric'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_goods'))
)
with op.batch_alter_table('goods', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_goods_article'), ['article'], unique=True)
batch_op.create_index(batch_op.f('ix_goods_doc_type_id'), ['doc_type_id'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_fabric_id'), ['fabric_id'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_price'), ['price'], unique=False)
batch_op.create_index(batch_op.f('ix_goods_tnvd'), ['tnvd'], unique=True)
op.create_table('group_permission',
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
@@ -74,15 +121,15 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('group_id', 'permission_id', name=op.f('pk_group_permission'))
)
op.create_table('refresh_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sa.String(length=255), nullable=False),
sa.Column('device_info', sa.String(length=255), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=False),
sa.Column('is_revoked', sa.Boolean(), nullable=False),
sa.Column('expires_at', sa.TIMESTAMP(), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('replaced_by', sa.Integer(), nullable=True),
sa.Column('expires_at', sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('replaced_by', sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(['replaced_by'], ['refresh_tokens.id'], name=op.f('fk_refresh_tokens_replaced_by_refresh_tokens')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens')),
@@ -92,18 +139,39 @@ def upgrade() -> None:
batch_op.create_index(batch_op.f('ix_refresh_tokens_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_refresh_tokens_user_id'), ['user_id'], unique=False)
op.create_table('reports',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('doc_id', sa.Uuid(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('uploaded_at', sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column('doc_date', sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column('status', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('market_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['market_id'], ['markets.id'], name=op.f('fk_reports_market_id_markets'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_reports_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_reports')),
sa.UniqueConstraint('doc_id', name=op.f('uq_reports_doc_id'))
)
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_reports_filename'), ['filename'], unique=False)
batch_op.create_index(batch_op.f('ix_reports_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_reports_market_id'), ['market_id'], unique=False)
batch_op.create_index(batch_op.f('ix_reports_user_id'), ['user_id'], unique=False)
op.create_table('user_direct_permissions',
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('fk_user_direct_permissions_permission_id_permissions')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_direct_permissions_user_id_users')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_direct_permissions_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'permission_id', name=op.f('pk_user_direct_permissions'))
)
op.create_table('user_group',
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('permission_group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['permission_group_id'], ['groups_of_permissions.id'], name=op.f('fk_user_group_permission_group_id_groups_of_permissions')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_group_user_id_users')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_group_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'permission_group_id', name=op.f('pk_user_group'))
)
# ### end Alembic commands ###
@@ -114,12 +182,28 @@ def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_group')
op.drop_table('user_direct_permissions')
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_reports_user_id'))
batch_op.drop_index(batch_op.f('ix_reports_market_id'))
batch_op.drop_index(batch_op.f('ix_reports_id'))
batch_op.drop_index(batch_op.f('ix_reports_filename'))
op.drop_table('reports')
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_refresh_tokens_user_id'))
batch_op.drop_index(batch_op.f('ix_refresh_tokens_id'))
op.drop_table('refresh_tokens')
op.drop_table('group_permission')
with op.batch_alter_table('goods', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_goods_tnvd'))
batch_op.drop_index(batch_op.f('ix_goods_price'))
batch_op.drop_index(batch_op.f('ix_goods_id'))
batch_op.drop_index(batch_op.f('ix_goods_fabric_id'))
batch_op.drop_index(batch_op.f('ix_goods_doc_type_id'))
batch_op.drop_index(batch_op.f('ix_goods_article'))
op.drop_table('goods')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_middle_name'))
batch_op.drop_index(batch_op.f('ix_users_last_name'))
@@ -127,17 +211,31 @@ def downgrade() -> None:
batch_op.drop_index(batch_op.f('ix_users_email'))
op.drop_table('users')
with op.batch_alter_table('reports', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_reports_id'))
batch_op.drop_index(batch_op.f('ix_reports_filename'))
op.drop_table('reports')
with op.batch_alter_table('permissions', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_permissions_id'))
op.drop_table('permissions')
with op.batch_alter_table('markets', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_markets_id'))
op.drop_table('markets')
with op.batch_alter_table('groups_of_permissions', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_groups_of_permissions_id'))
op.drop_table('groups_of_permissions')
with op.batch_alter_table('fabric', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_fabric_name'))
batch_op.drop_index(batch_op.f('ix_fabric_id'))
op.drop_table('fabric')
with op.batch_alter_table('doc_types', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_doc_types_name'))
batch_op.drop_index(batch_op.f('ix_doc_types_id'))
op.drop_table('doc_types')
with op.batch_alter_table('accountant_settings', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_accountant_settings_name'))
batch_op.drop_index(batch_op.f('ix_accountant_settings_id'))
op.drop_table('accountant_settings')
# ### end Alembic commands ###
+14 -1
View File
@@ -1,5 +1,6 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Base(BaseSettings):
pass
@@ -9,6 +10,18 @@ class Env(Base):
ACCESS_TOKEN_EXPIRE_MINUTES:int
REFRESH_TOKEN_EXPIRE_DAYS:int
DB_USER:str
DB_PASSWORD:str
DB_POSTGRESS:str
DB_HOST:str
DB_PORT:str
REDIS_PASSWORD:str
REDIS_PORT:int
REDIS_HOST:str
PROD_MODE:bool
model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
env_settings=Env()
env_settings=Env() # type: ignore[call-arg]
+5 -2
View File
@@ -1,7 +1,10 @@
from src.models.database_models.model import Model
from uuid import UUID, uuid1
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from uuid import UUID, uuid1
from src.models.database_models.model import Model
class AccountantSettings(Model):
__tablename__="accountant_settings"
+5 -3
View File
@@ -1,9 +1,11 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, String, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
from uuid import UUID, uuid1
from sqlalchemy import TIMESTAMP, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
class Stored(Model):
__tablename__="reports"
+4 -1
View File
@@ -1,8 +1,11 @@
from decimal import Decimal
from src.models.database_models.model import Model
from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
class Nomenclature(Model):
__tablename__="goods"
+26 -6
View File
@@ -1,8 +1,28 @@
from sqlalchemy import TIMESTAMP, Table, create_engine, String, Boolean, MetaData, Column, ForeignKey, func, Uuid
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, relationship
from uuid import UUID, uuid4
from datetime import datetime
engine = create_engine("sqlite:///DB/database.db", echo=True)
from uuid import UUID, uuid4
from sqlalchemy import (
TIMESTAMP,
Boolean,
Column,
ForeignKey,
MetaData,
String,
Table,
Uuid,
func,
)
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from src.models.configs_read.env import env_settings
engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}",
pool_size=20, # сколько соединений держать открытыми постоянно
max_overflow=10, # сколько доп. соединений можно создать при пиковой нагрузке
pool_timeout=30, # сколько ждать свободное соединение, прежде чем упасть с ошибкой
pool_pre_ping=True, # проверять соединение перед использованием (ловит "протухшие" соединения)
)
'''remember as a boilerplate, or just cp/pst'''
class Model(DeclarativeBase):
@@ -62,14 +82,14 @@ class Permissions(Model):
user_group_of_permissions=Table(
"user_group",
Model.metadata,
Column("user_id", ForeignKey("users.id"), primary_key=True),
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("permission_group_id", ForeignKey("groups_of_permissions.id"), primary_key=True)
)
user_permission=Table(
"user_direct_permissions",
Model.metadata,
Column("user_id",ForeignKey("users.id"), primary_key=True),
Column("user_id",ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id"), primary_key=True)
)
+6 -2
View File
@@ -1,7 +1,11 @@
from typing import Annotated
from src.models.pydantic_models.model import Base
from pydantic import Field
from uuid import UUID
from pydantic import Field
from src.models.pydantic_models.model import Base
class AccountantCreate(Base):
name:Annotated[str, Field(...,max_length=64,description="name of the accountant setting")]
+4 -1
View File
@@ -1,9 +1,12 @@
from datetime import datetime
from typing import Annotated
from uuid import UUID
from src.models.pydantic_models.model import Base
from pydantic import Field
from src.models.pydantic_models.model import Base
class ReportCreate(Base):
filename:Annotated[str,Field(..., min_length=2, max_length=255, description="name of the report")]
doc_date:Annotated[datetime, Field(..., description="ts of the report")]
+5 -2
View File
@@ -1,8 +1,11 @@
from decimal import Decimal
from src.models.pydantic_models.model import Base
from pydantic import Field
from typing import Annotated
from pydantic import Field
from src.models.pydantic_models.model import Base
class NomenclatureCreate(Base):
article:Annotated[str, Field(...,min_length=5,max_length=16, description="name of the article")]
+20 -4
View File
@@ -1,14 +1,29 @@
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
from uuid import UUID
from pydantic import AfterValidator, BaseModel, EmailStr, Field
def validate_password(password: str) -> str:
PUNCTUATION: set[str] = {"$", "@", "#", "%", "!", "^", "&", "*", "(", ")", "-", "_", "+", "=", "{", "}", "[", "]"}
if len(password) < 8 or len(password) > 72:
raise ValueError("Password must be 8-72 characters")
if (
not any(c.isupper() for c in password)
or not any(c.islower() for c in password)
or not any(c.isdigit() for c in password)
or not any(c in PUNCTUATION for c in password)
):
raise ValueError("Password must contain uppercase, lowercase, digit and special char")
return password
PasswordStr = Annotated[str, AfterValidator(validate_password)]
class Base(BaseModel):
model_config = {"from_attributes": True}
class PermissionsCreate(Base):
permission:Annotated[str, Field(..., max_length=30, description="permission name")]
@@ -27,6 +42,7 @@ class PermissionsGroupsOut(Base):
id:Annotated[int, Field(..., description="id of the permission group")]
group:Annotated[str, Field(..., max_length=255, description="group name for the permissions")]
permissions: Annotated[list[PermissionsOut], Field(..., description="permissions granted by this group")]
class UserCreate(Base):
@@ -34,8 +50,7 @@ class UserCreate(Base):
last_name:Annotated[str, Field(...,max_length=64, description="last name of the user")]
middle_name:Annotated[str, Field(...,max_length=64, description="middle name of the user")]
email:Annotated[EmailStr, Field(...,min_length=5, max_length=255, description="email of the user")]
plain_password:Annotated[str, Field(...,min_length=8,max_length=72, description="plain password of the user")]
status:Annotated[bool, Field(..., description="status of the user")]
plain_password:Annotated[PasswordStr, Field(..., description="plain password of the user")]
direct_permissions:Annotated[list[str], Field(..., description="permissions of the user")]
group:Annotated[list[str], Field(..., description="permissions groups of the user")]
@@ -62,6 +77,7 @@ class UserUpdate(Base):
last_name:Annotated[str|None, Field(None, max_length=64,description="last name of the user")]
middle_name:Annotated[str|None, Field(None, max_length=64,description="middle name of the user")]
email:Annotated[EmailStr|None, Field(None, min_length=5, max_length=255, description="email of the user")]
plain_password:Annotated[PasswordStr|None, Field(None, description="plain password of the user")]
status:Annotated[bool|None, Field(None, description="status of the user")]
direct_permissions:Annotated[list[str]|None, Field(None, description="permissions of the user")]
group:Annotated[list[str]|None, Field(None, description="permissions groups of the user")]
+134 -93
View File
@@ -1,31 +1,36 @@
from datetime import datetime, timedelta, timezone
import asyncio
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import Request
from .jwt import Jwt, Hashes
from src.database.users.crud import UsersCrudActions
from src.cache.redis_client import redis_client
from src.database.auth.refresh_tokens import JwtCrudActions
from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors
from src.models.pydantic_models.model import RefreshTokensCreate, UserOut
from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import RefreshTokensCreate, UserOut
from .jwt import HashService, JwtService
class CurrentUser:
class CurrentUserService:
def __init__(self) -> None:
self.jwt_service=Jwt()
self.hash=Hashes()
self.jwt_service=JwtService()
self.hash=HashService()
self.crud_db_actions=UsersCrudActions()
self.jwt_db_actions=JwtCrudActions()
self.error=Errors()
def _check(self, form_data_email:str, form_data_password:str,):
async def _check(self, form_data_email:str, form_data_password:str,):
'''check user by email'''
user=self.crud_db_actions.get_user_by_email(form_data_email)
user=await self.crud_db_actions.get_user_by_email(form_data_email)
if user is None:
raise self.error.credentials_error(detail="Wrong credentials")
if not self.hash.verify_password(plain_password=form_data_password, hashed_password=user.hashed_password):
if not await asyncio.to_thread(self.hash.verify_password, plain_password=form_data_password, hashed_password=user.hashed_password):
raise self.error.credentials_error(detail="Wrong credentials")
if user.status is False:
@@ -33,86 +38,113 @@ class CurrentUser:
return user
def get_current_user(self, token:str)->UserOut:
async def _token_record_create(self, jti:UUID,user_id:UUID,token:str, request:Request)->RefreshTokensCreate:
payload=self.jwt_service.jwt_decode(token)
if (sub:=payload.get("sub")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect")
try:
sub=UUID(sub)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
user=self.crud_db_actions.get_user_by_id(sub)
if user is None:
raise self.error.not_found_error(detail="User with this email address not found")
return UserOut.model_validate(user)
def create_access_token(self, user_id:UUID)->str:
'''create new access token if all the checks are successful'''
return self.jwt_service.create_access_token({"sub":str(user_id)})
def create_refresh_token(self,user_id:UUID, request:Request)->str:
token, jti=self.jwt_service.create_refresh_token({"sub":str(user_id)})
try:
jti=UUID(jti)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
'''create new refresh token if all the checks are successful'''
token_record=RefreshTokensCreate(
return RefreshTokensCreate(
id=jti,
user_id=user_id,
token_hash=self.hash.token_to_hash(token),
device_info=request.headers.get("user-agent", "unknown"),
ip_address=request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown"),
expires_at=datetime.now(timezone.utc)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS)
expires_at=datetime.now(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
self.jwt_db_actions.create_token(RefreshTokensCreate.model_dump(token_record))
async def get_current_user(self, token:str, *permissions: str)->UserOut:
payload= await self.jwt_service.jwt_decode(token)
sub=payload.get("sub")
jti_access=payload.get("jti")
if jti_access and await redis_client.get(f"revoked_access_token:{jti_access}"):
raise self.error.credentials_error(detail="Token has been revoked")
try:
sub=UUID(sub)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
if not (payload.get("token_type")=="access"):
raise self.error.credentials_error(detail="Jwt token type is incorrect")
user=await self.crud_db_actions.get_user_by_id(sub)
if user is None:
raise self.error.not_found_error(detail="User with this email address not found")
if user.status is False:
raise self.error.credentials_error(detail="This user is deactivated")
effective = {p.permission for p in user.direct_permissions} | {p.permission for group in user.group for p in group.permissions}
missing = set(permissions) - effective
if missing:
raise self.error.forbidden_error(detail=f"Missing: {missing}")
return UserOut.model_validate(user)
async def create_access_token(self, user_id:UUID)->str:
'''create new access token if all the checks are successful'''
return await self.jwt_service.create_access_token({"sub":str(user_id)})
async def create_refresh_token(self,user_id:UUID, request:Request)->str:
token, jti= await self.jwt_service.create_refresh_token({"sub":str(user_id)})
try:
jti=UUID(jti)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''create new refresh token if all the checks are successful'''
token_record=await self._token_record_create(jti=jti, user_id=user_id, token=token, request=request)
await self.jwt_db_actions.create_token(RefreshTokensCreate.model_dump(token_record))
return token
def refresh_token(self, refresh_token:str, request:Request)->tuple[str, str]:
async def refresh_token(self, refresh_token:str, request:Request)->tuple[str, str]:
'''decode old refresh token'''
old_refresh_token=self.jwt_service.jwt_decode(refresh_token)
if (sub:=old_refresh_token.get("sub")) is None or (old_jti:=old_refresh_token.get("jti")) is None:
old_refresh_token= await self.jwt_service.jwt_decode(refresh_token)
sub=old_refresh_token.get("sub")
if (old_jti:=old_refresh_token.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect")
try:
old_jti=UUID(old_jti)
sub=UUID(sub)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''old refresh token check'''
old_record=self.jwt_db_actions.get_token_by_id(old_jti)
if (old_refresh_token.get("token_type")=="access"):
raise self.error.credentials_error(detail="Jwt token type is incorrect")
old_record=await self.jwt_db_actions.get_token_by_id(old_jti)
if old_record is None:
raise self.error.not_found_error(detail="Token not found")
if old_record.is_revoked:
self.jwt_db_actions.revoke_all(old_record.user_id)
raise self.error.credentials_error(detail="Reuse token detected")
'''sqlite constraints about timezone'''
expires_at=old_record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at<datetime.now(timezone.utc):
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at<datetime.now(UTC):
raise self.error.credentials_error(detail="Token expired")
'''user check'''
user = self.crud_db_actions.get_user_by_id(sub)
user = await self.crud_db_actions.get_user_by_id(sub)
if user is None:
raise self.error.not_found_error(detail="User not found")
if user.status is False:
@@ -120,62 +152,71 @@ class CurrentUser:
'''create new refresh token if all the checks are successful'''
new_refresh_token, new_jti=self.jwt_service.create_refresh_token({"sub":str(sub)})
new_access_token=self.create_access_token(user_id=sub)
new_refresh_token, new_jti= await self.jwt_service.create_refresh_token({"sub":str(sub)})
new_access_token=await self.create_access_token(user_id=sub)
try:
new_jti=UUID(new_jti)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''create database record with the new token'''
new_token_record=RefreshTokensCreate(
id=new_jti,
user_id=sub,
token_hash=self.hash.token_to_hash(new_refresh_token),
device_info=request.headers.get("user-agent", "unknown"),
ip_address=request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown"),
expires_at=datetime.now(timezone.utc)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
self.jwt_db_actions.create_token(RefreshTokensCreate.model_dump(new_token_record))
new_token_record=await self._token_record_create(jti=new_jti, user_id=sub, token=new_refresh_token, request=request)
'''update old token to deactivate it and assign replaced_by'''
self.jwt_db_actions.update_token(old_jti, new_jti)
success = await self.jwt_db_actions.create_and_update_token(RefreshTokensCreate.model_dump(new_token_record), old_jti, new_jti)
return (new_access_token,new_refresh_token)
if not success:
raise self.error.not_found_error(detail="Token not found")
return (new_access_token, new_refresh_token)
def logout(self, refresh_token:str)->bool:
async def logout(self, refresh_token:str, access_token:str)->bool:
'''decode current refresh token'''
payload=self.jwt_service.jwt_decode(refresh_token)
payload_refresh=await self.jwt_service.jwt_decode(refresh_token)
'''decode current access token'''
payload_access=await self.jwt_service.jwt_decode(access_token)
if (jti_refresh:=payload_refresh.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt refresh token is incorrect")
if (jti_access:=payload_access.get("jti")) is None or (exp_access:=payload_access.get("exp")) is None:
raise self.error.credentials_error(detail="Jwt access token is incorrect")
if (jti:=payload.get("jti")) is None:
raise self.error.credentials_error(detail="Invalid Refresh Token")
try:
jti=UUID(jti)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
jti_refresh=UUID(jti_refresh)
jti_access=UUID(jti_access)
except (ValueError, TypeError, AttributeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
current_token = self.jwt_db_actions.get_token_by_id(jti)
if current_token is None:
raise self.error.not_found_error(detail="Refresh Token Not Found")
if jti_access and exp_access:
exp_datetime = datetime.fromtimestamp(exp_access, tz=UTC)
remaining_ttl = max(int((exp_datetime - datetime.now(UTC)).total_seconds()), 1)
await redis_client.set(f"revoked_access_token:{jti_access}", "1", ex=remaining_ttl) #revoke tokens and set them to redis until their exp ends
'''logout by assigning revoked flag'''
return self.jwt_db_actions.logout(jti)
if await self.jwt_db_actions.logout(jti_refresh):
return True
else:
raise self.error.not_found_error(detail="Refresh Token Not Found")
def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
async def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
'''revoke all the old refresh tokens'''
user = self._check(form_data_email, form_data_password)
self.jwt_db_actions.revoke_all(user_id=user.id)
user = await self._check(form_data_email, form_data_password)
await self.jwt_db_actions.revoke_all(user_id=user.id)
'''create access and refresh tokens'''
access_token=self.create_access_token(user_id=user.id)
refresh_token=self.create_refresh_token(user_id=user.id,request=request)
access_token=await self.create_access_token(user_id=user.id)
refresh_token=await self.create_refresh_token(user_id=user.id,request=request)
return (access_token, refresh_token)
auth=CurrentUser()
async def auth_service()->CurrentUserService:
return CurrentUserService()
+37 -23
View File
@@ -1,13 +1,15 @@
from jose import JWTError, jwt
import bcrypt
from src.errors.http_errors.errors import Errors
from datetime import datetime, timedelta, timezone
from src.models.configs_read.env import env_settings
from uuid import uuid4
import hashlib
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import bcrypt
from jose import JWTError, jwt
from src.errors.http_errors.errors import Errors
from src.models.configs_read.env import env_settings
'''Hash/Check hash'''
class Hashes:
class HashService:
def __init__(self) -> None:
pass
@@ -23,26 +25,38 @@ class Hashes:
'''jwt'''
class Jwt:
class JwtService:
def __init__(self) -> None:
self.error=Errors()
def create_access_token(self, data:dict)->str:
user_info=data.copy()
user_info.update({"exp": datetime.now(timezone.utc)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"token_type":"access"})
print(f"DEBUG: expires at {datetime.now(timezone.utc)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES)}, minutes={env_settings.ACCESS_TOKEN_EXPIRE_MINUTES}")
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
def create_refresh_token(self, data:dict)->tuple[str, str]:
async def _validate_sub(self,data:dict)->None:
if not (data.get("sub")) or data.get("sub") == "":
raise self.error.credentials_error(detail="Jwt token is incorrect")
async def create_access_token(self, data:dict)->str:
user_info=data.copy()
jti=str(uuid4())
user_info.update({"exp":datetime.now(timezone.utc)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
await self._validate_sub(user_info)
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"token_type":"access",
"jti":jti})
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
async def create_refresh_token(self, data:dict)->tuple[str, str]:
user_info=data.copy()
jti=str(uuid4())
await self._validate_sub(user_info)
user_info.update({"exp":datetime.now(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
"token_type":"refresh",
"jti":jti
})
@@ -51,13 +65,13 @@ class Jwt:
def jwt_decode(self, token:str)->dict:
async def jwt_decode(self, token:str)->dict:
try:
payload=jwt.decode(token, env_settings.SECRET_KEY, algorithms=[env_settings.ALGORITHM])
payload=jwt.decode(token, env_settings.SECRET_KEY, algorithms=[env_settings.ALGORITHM], options={"require_exp": True} )
if (payload.get("sub")) is None:
raise self.error.credentials_error(detail="Sub block is missing")
if not (payload.get("sub")) or not (payload.get("token_type")):
raise self.error.credentials_error(detail="Jwt token is incorrect")
except JWTError as e:
raise self.error.credentials_error(detail="JWTerror") from e
+80
View File
@@ -0,0 +1,80 @@
import asyncio
from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors
from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate
from src.service.auth.jwt import HashService
class CrudService:
def __init__(self) -> None:
self.errors=Errors()
self.crud_db_actions=UsersCrudActions()
self.hash_service=HashService()
async def _plain_to_hash(self, user_data:dict)->dict:
user_data["hashed_password"]=user_data.pop("plain_password")
user_data["hashed_password"]= await asyncio.to_thread(self.hash_service.plain_to_hash, user_data["hashed_password"])
return user_data
async def get_user_by_email(self, email:str)->UserOut:
user_entity=await self.crud_db_actions.get_user_by_email(email)
if not user_entity:
raise self.errors.not_found_error(detail="User wasn't found")
return UserOut.model_validate(user_entity)
async def create_user(self, data:UserCreate)->UserOut:
user_data=UserCreate.model_dump(data)
user_data=await self._plain_to_hash(user_data)
user_entity=await self.crud_db_actions.create_user(user_data)
if not user_entity:
raise self.errors.validation_error(detail="User creation gone wrong")
return UserOut.model_validate(user_entity)
async def delete_user_soft(self, email:str)->bool:
user_entity=await self.crud_db_actions.delete_user_soft(email)
if not user_entity:
raise self.errors.not_found_error(detail="User not found")
return user_entity
async def delete_user_hard(self, email:str, current_user)->bool:
user_entity=await self.crud_db_actions.delete_user_hard(email)
if not user_entity:
raise self.errors.not_found_error(detail="User not found")
return user_entity
async def update_user(self, email:str, data:UserUpdate)->UserOut:
user_data=UserUpdate.model_dump(data, exclude_unset=True)
if not user_data:
raise self.errors.bad_request_error(detail="User info to update can not be empty")
if user_data.get("plain_password"):
user_data=await self._plain_to_hash(user_data)
user_entity=await self.crud_db_actions.update_user_partially(email, user_data)
if not user_entity:
raise self.errors.not_found_error(detail="User not found")
return UserOut.model_validate(user_entity)
async def crud_service()->CrudService:
return CrudService()
-1
View File
@@ -1 +0,0 @@
'''unit, integr, e2e tests'''
+79
View File
@@ -0,0 +1,79 @@
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from src.cache.rate_limit import rate_limiter
from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import CurrentUserService, auth_service
router=APIRouter(prefix="/protected")
oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh")
def require_permissions(*permissions: str): #permissions check dependency
async def checker(
token: str = Depends(oauth2_schema),
auth: CurrentUserService = Depends(auth_service),
) -> UserOut:
return UserOut.model_validate(await auth.get_current_user(token, *permissions))
return checker
@router.post("/token")
async def get_access_token(request: Request,
response:Response,
auth:CurrentUserService=Depends(auth_service),
form_data:OAuth2PasswordRequestForm=Depends(),
)->dict:
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
try:
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
except HTTPException:
await rate_limiter.rate_limit(client_ip)
raise
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=env_settings.PROD_MODE,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/refresh")
async def get_refresh_token(request:Request,
response:Response,
refresh_token: str = Cookie(),
auth:CurrentUserService=Depends(auth_service))->dict:
access_token, refresh_token= await auth.refresh_token(refresh_token=refresh_token,request=request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=env_settings.PROD_MODE,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
return {"access_token":access_token, "token_type": "bearer"}
@router.get("/logout")
async def logout(response:Response,
refresh_token: str = Cookie(),
access_token: str = Depends(oauth2_schema),
auth:CurrentUserService=Depends(auth_service),
current_user:UserOut=Depends(require_permissions()))->bool:
response.delete_cookie("refresh_token")
return await auth.logout(refresh_token, access_token)
@router.get("")
async def protected(current_user:UserOut=Depends(require_permissions()))->dict:
return {"protected router": "Hello, this is a protected router"}
@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate
from src.service.users_crud.users_crud import CrudService, crud_service
from src.web.protected_routes.auth_routes import require_permissions
router=APIRouter(prefix="/user")
@router.get("/get_by_email")
async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.get_user_by_email(email)
@router.post("/create_user")
async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.create_user(data)
@router.post("/delete_user_soft")
async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool:
return await crud.delete_user_soft(email)
@router.post("/delete_user_hard")
async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool:
return await crud.delete_user_hard(email, current_user)
@router.patch("/patch_user")
async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.update_user(email, data)
-55
View File
@@ -1,55 +0,0 @@
from fastapi import APIRouter, Depends, Request, Response, Cookie
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import auth
router=APIRouter(prefix="/protected")
oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh")
@router.post("/token")
async def get_access_token(request: Request,response:Response, form_data:OAuth2PasswordRequestForm=Depends())->dict:
access_token, refresh_token=auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/refresh")
async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie())->dict:
access_token, refresh_token= auth.refresh_token(refresh_token=refresh_token,request=request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
return {"access_token":access_token, "token_type": "bearer"}
async def get_current_user(token:str = Depends(oauth2_schema)) -> UserOut:
return UserOut.model_validate(auth.get_current_user(token))
@router.get("/logout")
async def logout(response:Response,refresh_token: str = Cookie(),current_user:UserOut=Depends(get_current_user))->bool:
response.delete_cookie("refresh_token")
return auth.logout(refresh_token)
@router.get("")
async def protected(current_user:UserOut=Depends(get_current_user))->dict:
return {"protected router": "Hello, this is a protected router"}
+1
View File
@@ -0,0 +1 @@
'''unit, integr, e2e tests'''
+30
View File
@@ -0,0 +1,30 @@
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.models.configs_read.env import env_settings
from src.service.auth.jwt import HashService, JwtService
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture
async def jwt_service()->JwtService:
jwt_service=JwtService()
return jwt_service
@pytest_asyncio.fixture
async def hash_service()->HashService:
hash_service=HashService()
return hash_service
@pytest_asyncio.fixture
async def crud_service():
test_engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}",
pool_size=20,
max_overflow=10,
pool_timeout=30,
pool_pre_ping=True
)
crud_service = CrudService()
crud_service.crud_db_actions.Session = async_sessionmaker(bind=test_engine)
yield crud_service
await test_engine.dispose()
+77
View File
@@ -0,0 +1,77 @@
from uuid import uuid4
import pytest_asyncio
import requests_async
from pydantic_settings import BaseSettings, SettingsConfigDict
class Env(BaseSettings):
HOST:str
PORT:str
TEST_USERNAME:str
TEST_PASSWORD:str
model_config=SettingsConfigDict(env_file="configs/.e2e.env", extra=None)
e2e_settings=Env() # type: ignore[call-arg]
class MySession(requests_async.AsyncSession):
def __init__(self):
super().__init__()
self.headers = {}
self.token = None
async def request(self, method:str, url:str, **kwargs):
if self.token:
self.headers['Authorization'] = f"Bearer {self.token}"
kwargs.setdefault('headers', self.headers)
return await super().request(method, url, **kwargs)
@pytest_asyncio.fixture(scope="function")
async def auth_fixture(target_url:str):
payload = {"username": e2e_settings.TEST_USERNAME, "password": e2e_settings.TEST_PASSWORD}
async with MySession() as session:
response = await session.post(target_url + "/protected/token", data=payload)
response.raise_for_status()
session.token = response.json()["access_token"]
yield session
@pytest_asyncio.fixture(scope="function")
async def test_user_fixture(request, auth_fixture: MySession, target_url:str):
test_id=uuid4()
direct_permission_param, group_param = request.param
new_user_record={
"first_name":f"TEST_{test_id}",
"last_name":f"TEST_{test_id}",
"middle_name":f"TEST_{test_id}",
"email":f"TEST_{test_id}@d.d",
"plain_password":"Test1234!",
"direct_permissions":direct_permission_param,
"group":group_param
}
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
response.raise_for_status()
async with MySession() as session:
payload={"username": new_user_record.get("email"), "password": new_user_record.get("plain_password")}
response = await session.post(target_url + "/protected/token", data=payload)
response.raise_for_status()
session.token = response.json()["access_token"]
yield (session, new_user_record)
response=await auth_fixture.post(f"{target_url}/user/delete_user_hard", params={"email":new_user_record.get("email")})
response.raise_for_status()
@pytest_asyncio.fixture()
async def target_url()->str:
return f"http://{e2e_settings.HOST}:{e2e_settings.PORT}"
+84
View File
@@ -0,0 +1,84 @@
import allure
import pytest
import requests_async
from httpx import HTTPStatusError
class TestPermissions:
async def test_get_access_token_positive(self, target_url:str)->None:
with allure.step("get_access_token"), pytest.raises(HTTPStatusError) as exc_info:
response = await requests_async.post(f"{target_url}/protected/token")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401
async def test_get_refresh_token_positive(self, target_url:str)->None:
with allure.step("get_refresh_token"), pytest.raises(HTTPStatusError) as exc_info:
response = await requests_async.post(f"{target_url}/protected/refresh")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401
async def test_get_root_unauthorized(self, target_url:str)->None:
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
response = await requests_async.get(f"{target_url}/protected")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code == 401
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_get_logout_positive(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.get(f"{target_url}/protected/logout")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401
class TestRedis:
@pytest.mark.parametrize("wrong_user_data, expected_status",[
pytest.param({"username":"Wrong_user", "password":"Wrong_password"},429,id="Wrong_user_creds")
])
async def test_rate_limit_positive(self, wrong_user_data:dict, target_url:str, expected_status:int):
with allure.step("logging with invalid creds"):
for i in range(5):
with pytest.raises(HTTPStatusError) as exc_info:
response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data)
response.raise_for_status()
assert exc_info.value.response.status_code == 401, f"Attempt {i+1} should be 401"
with allure.step("verify rate_limit works"),pytest.raises(HTTPStatusError) as exc_info:
response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data)
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_logout_revokes_access_token(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("logout"):
response = await session.get(f"{target_url}/protected/logout")
response.raise_for_status()
with allure.step("verify token is revoked"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.get(f"{target_url}/protected")
response.raise_for_status()
assert exc_info.value.response.status_code==401
+383
View File
@@ -0,0 +1,383 @@
from uuid import uuid4
import allure
import pytest
from httpx import HTTPStatusError
from tests.e2e.conftest import MySession
@pytest.mark.integra
class TestCrud:
@pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True)
async def test_get_user_by_email_positive(self,test_user_fixture, target_url:str)->None:
session, new_user_record=test_user_fixture
with allure.step("Get user by email"):
email = new_user_record.get("email") #get email from the fixture in yield sector
response = await session.get(f"{target_url}/user/get_by_email",params={"email":email})
response.raise_for_status()
response=response.json()
with allure.step("Validate response"):
assert response.get("email")==email
assert "TEST_" in response.get("first_name")
assert "TEST_" in response.get("last_name")
assert "TEST_" in response.get("middle_name")
assert response.get("direct_permissions") != []
assert response.get("group") != []
assert not response.get("hashed_password") or not response.get("plain_password") or not response.get("password")
@pytest.mark.parametrize("email, expected_status", [
pytest.param("test@test.test", 404, id="non_existed_email"),
pytest.param("test",404, id="wrong_email"),
pytest.param("@d", 404, id="wrong_email")
])
async def test_get_user_by_email_negative(self, email:str, expected_status:int, auth_fixture:MySession,target_url:str)->None:
with allure.step("Get user by email"):
with pytest.raises(HTTPStatusError) as exc_info:
response = await auth_fixture.get(f"{target_url}/user/get_by_email", params={"email": email})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("new_user_record",[
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":f"TEST_{uuid4()}@d.d",
"plain_password":"Test1234!",
"direct_permissions":[],
"group":[]}, id="Positive_user_creation_with_all_the_fields"),
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":f"TEST_{uuid4()}@d.d",
"plain_password":"Test1234!",
"direct_permissions":["WRONG_PERMISSIONS"],
"group":["WRONG_GROUP"]},id="Positive_wrong_permissions"),
])
async def test_create_delete_user_hard_positive(self, new_user_record:dict,auth_fixture:MySession, target_url:str)->None:
with allure.step("Create new test user and check for the new user"):
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
response.raise_for_status()
try:
with allure.step("Check for the new user"):
response = await auth_fixture.get(f"{target_url}/user/get_by_email",params={"email":new_user_record.get("email")})
response.raise_for_status()
response=response.json()
assert response.get("email")==new_user_record["email"]
assert response.get("first_name")==new_user_record["first_name"]
assert response.get("last_name")==new_user_record["last_name"]
assert response.get("middle_name")==new_user_record["middle_name"]
assert response.get("direct_permissions") == new_user_record["direct_permissions"] or response.get("direct_permissions") == []
assert response.get("group") == new_user_record["group"] or response.get("group") == []
assert not response.get("hashed_password") or not response.get("plain_password") or not response.get("password")
finally:
with allure.step("delete new user"):
response=await auth_fixture.post(f"{target_url}/user/delete_user_hard", params={"email":new_user_record["email"]})
response.raise_for_status()
@pytest.mark.parametrize("new_user_record, expected_status", [
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":"WRONGEMAIL",
"plain_password":"Test1234!",
"direct_permissions":[],
"group":[]},422,id="Non_existed_email"),
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":"TEST1@d.d",
"plain_password":"1234",
"direct_permissions":[],
"group":[]},422,id="Wrong_password"),
pytest.param({ "first_name":"TEST",
"email":"TEST1@d.d",
"plain_password":"Test1234!",
},422,id="Not_all_the_fields"),
])
async def test_create_user_negative(self, new_user_record:dict, auth_fixture:MySession, expected_status:int, target_url:str):
with allure.step("Preparing data to create new user negative"):
user_created=False
try:
with allure.step("Create new test user and check for the new user"):
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
if response.status_code < 400:
user_created = True
with pytest.raises(HTTPStatusError) as exc_info:
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
finally:
if user_created:
with allure.step("delete new user"):
response = await auth_fixture.post(
f"{target_url}/user/delete_user_hard",
params={"email": new_user_record["email"]}
)
response.raise_for_status()
@pytest.mark.parametrize("new_user_record",[
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":f"TEST_{uuid4()}@d.d",
"plain_password":"Test1234!",
"direct_permissions":[],
"group":[]}, id="Positive_user_delete_soft"),
])
async def test_user_create_delete_soft_positive(self, new_user_record:dict, auth_fixture:MySession, target_url:str)->None:
with allure.step("Create new test user and check for the new user"):
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
response.raise_for_status()
try:
with allure.step("Check for the new user"):
response = await auth_fixture.get(f"{target_url}/user/get_by_email",params={"email":new_user_record.get("email")})
response.raise_for_status()
with allure.step("Delete user soft"):
response=await auth_fixture.post(f"{target_url}/user/delete_user_soft", params={"email":new_user_record["email"]})
response.raise_for_status()
finally:
with allure.step("delete new user"):
response=await auth_fixture.post(f"{target_url}/user/delete_user_hard", params={"email":new_user_record["email"]})
response.raise_for_status()
@pytest.mark.parametrize("email, expected_status, ",[
pytest.param("Test", 404,id="Wrong_email")
])
async def test_user_delete_soft_negative(self, email:str,expected_status:int, auth_fixture:MySession, target_url:str)->None:
with allure.step("Delete user soft"):
with pytest.raises(HTTPStatusError) as exc_info:
response=await auth_fixture.post(f"{target_url}/user/delete_user_soft", params={"email":email})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("email, expected_status, ",[
pytest.param("Test", 404,id="Wrong_email")
])
async def test_user_delete_hard_negative(self, email:str,expected_status:int, auth_fixture:MySession, target_url:str)->None:
with allure.step("Delete user hard"):
with pytest.raises(HTTPStatusError) as exc_info:
response=await auth_fixture.post(f"{target_url}/user/delete_user_soft", params={"email":email})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("test_user_fixture", [
(["admin"], ["admin_group"])
], indirect=True)
@pytest.mark.parametrize("user_record_to_update",[
pytest.param({"first_name": "Test_New"},id="Positive_user_update_partially")
])
async def test_user_update_partially_positive(self, test_user_fixture, user_record_to_update:dict, target_url:str)->None:
session, new_user_record=test_user_fixture
with allure.step("Update user"):
response= await session.patch(f"{target_url}/user/patch_user", json=user_record_to_update, params={"email":new_user_record["email"]})
response.raise_for_status()
with allure.step("Check for the updated user"):
response=await session.get(f"{target_url}/user/get_by_email", params={"email":new_user_record["email"]})
response.raise_for_status()
response=response.json()
actual_permissions = [item.get("permission") for item in response.get("direct_permissions")] #unpacking json like {group:[{},{}]}
actual_groups =[item.get("group") for item in response.get("group")]
assert response.get("email")==new_user_record["email"]
assert response.get("first_name")==user_record_to_update["first_name"]
assert response.get("last_name")==new_user_record["last_name"]
assert response.get("middle_name")==new_user_record["middle_name"]
assert actual_permissions == new_user_record["direct_permissions"]
assert actual_groups == new_user_record["group"]
assert not response.get("hashed_password") or not response.get("plain_password") or not response.get("password")
@pytest.mark.parametrize("test_user_fixture", [
(["admin"], ["admin_group"])
], indirect=True)
@pytest.mark.parametrize("user_record_to_update, expected_exception, expected_status",[
pytest.param({"plain_password": "Wrong_pass"},HTTPStatusError,422,id="Wrong_password"),
pytest.param({"email": "Wrong_email"},HTTPStatusError,422,id="Wrong_email"),
pytest.param({},HTTPStatusError, 400,id="Negative_user_update_nothing")
])
async def test_user_update_partially_negative(self, test_user_fixture, user_record_to_update:dict, expected_exception, expected_status:int, target_url:str)->None:
session, new_user_record=test_user_fixture
with allure.step("Update user"), pytest.raises(expected_exception) as exc_info:
response= await session.patch(f"{target_url}/user/patch_user", json=user_record_to_update, params={"email":new_user_record["email"]})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True)
async def test_get_user_by_email_permissions_positive(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.get(f"{target_url}/user/get_by_email")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_get_user_by_email_permissions_negative(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.get(f"{target_url}/user/get_by_email")
response.raise_for_status()
assert exc_info.value.response.status_code == 403
@pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True)
async def test_create_user_permissions_positive(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.post(f"{target_url}/user/create_user")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_create_user_permissions_negative(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.post(f"{target_url}/user/create_user")
response.raise_for_status()
assert exc_info.value.response.status_code == 403
@pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True)
async def test_patch_user_permissions_positive(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.patch(f"{target_url}/user/patch_user")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_patch_user_permissions_negative(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.patch(f"{target_url}/user/patch_user")
response.raise_for_status()
assert exc_info.value.response.status_code == 403
@pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True)
async def test_delete_user_soft_permissions_positive(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.post(f"{target_url}/user/delete_user_soft")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_delete_user_soft_permissions_negative(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.post(f"{target_url}/user/delete_user_soft")
response.raise_for_status()
assert exc_info.value.response.status_code == 403
@pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True)
async def test_delete_user_hard_permissions_positive(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.post(f"{target_url}/user/delete_user_hard")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_delete_user_hard_permissions_negative(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("Get user by email"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.post(f"{target_url}/user/delete_user_hard")
response.raise_for_status()
assert exc_info.value.response.status_code == 403
+23
View File
@@ -0,0 +1,23 @@
import pytest_asyncio
from fastapi import Request
from src.cache.redis_client import RedisClient
from src.service.auth.auth import CurrentUserService
@pytest_asyncio.fixture
async def current_user_service(monkeypatch):
test_redis = RedisClient()
monkeypatch.setattr("src.service.auth.auth.redis_client", test_redis)
service = CurrentUserService()
yield service
await test_redis.aclose()
@pytest_asyncio.fixture
async def requests(mocker):
fake_request = mocker.MagicMock(spec=Request)
fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"}
return fake_request
+227
View File
@@ -0,0 +1,227 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import allure
import pytest
from fastapi import HTTPException, Request
from jose import jwt
from pydantic import ValidationError
from src.models.configs_read.env import env_settings
from src.service.auth.auth import CurrentUserService
from src.service.auth.jwt import HashService, JwtService
current_user_service=CurrentUserService()
@pytest.mark.integra
class TestAuth:
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), id="correct_data")
])
async def test_get_current_user_positive(self,current_user_service:CurrentUserService, jwt_service:JwtService, monkeypatch, user_data:SimpleNamespace)->None:
with allure.step("create token"):
token=await jwt_service.create_access_token({"sub":str(uuid4())})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data))
with allure.step("test get_current_user_with_fake_token"):
test_result= await current_user_service.get_current_user(token)
assert test_result.first_name==user_data.first_name
assert test_result.last_name==user_data.last_name
assert test_result.middle_name==user_data.middle_name
assert test_result.email==user_data.email
assert test_result.direct_permissions==user_data.direct_permissions
assert test_result.group==user_data.group
assert not hasattr(test_result, "password") or not hasattr(test_result, "plain_password") or not hasattr(test_result, "hashed_password")
assert not hasattr(test_result, "status")
@pytest.mark.parametrize("user_data, uuid, expected_exception,expected_status",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), uuid4(), HTTPException,401, id="false_status"),
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),1234, HTTPException,401, id="wrong_id"),
pytest.param(SimpleNamespace(status=True,direct_permissions=[],group=[]),uuid4(), ValidationError,None,id="empty_model_data")
])
async def test_get_current_user_negative(self,current_user_service:CurrentUserService, jwt_service:JwtService, monkeypatch, user_data:SimpleNamespace,expected_exception, expected_status:int, uuid)->None:
with allure.step("create token"):
token=await jwt_service.create_access_token({"sub":str(uuid)})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data))
with allure.step("test get_current_user_with_fake_token"), pytest.raises(expected_exception) as exc_info:
await current_user_service.get_current_user(token)
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
@pytest.mark.parametrize("user_data, form_data_email,form_data_password",[
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "1234", id="correct_data"),
])
async def test_login_positive(self, jwt_service:JwtService,current_user_service:CurrentUserService, monkeypatch, user_data:SimpleNamespace, hash_service:HashService, form_data_email:str, form_data_password:str, requests)->None:
with allure.step("patching db call functions"):
user_data.hashed_password=hash_service.plain_to_hash(user_data.hashed_password)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_email", AsyncMock(return_value=user_data))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_token", AsyncMock(return_value=True))
fake_request = requests
with allure.step("test login_with_fake_data"):
access, refresh=await current_user_service.login(form_data_email, form_data_password,fake_request)
parts_a=access.split(".")
parts_b=refresh.split(".")
assert isinstance(access, str)
assert len(parts_a)==3
assert isinstance(refresh, str)
assert len(parts_b)==3
@pytest.mark.parametrize("user_data, form_data_email,form_data_password, expected_exception, expected_status",[
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "wrong_password", HTTPException,401, id="wrong_password"),
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), "d@d.d", "1234",HTTPException,401, id="false_status"),
pytest.param(SimpleNamespace(id=1234,hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "1234",ValidationError,None, id="wrong_id"),
])
async def test_login_negative(self, current_user_service:CurrentUserService, user_data:SimpleNamespace, jwt_service:JwtService, monkeypatch, requests, hash_service:HashService, form_data_email:str, form_data_password:str, expected_exception, expected_status:int):
with allure.step("patching db call functions"):
user_data.hashed_password=hash_service.plain_to_hash(user_data.hashed_password)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_email", AsyncMock(return_value=user_data))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_token", AsyncMock(return_value=True))
fake_request = requests
with allure.step("test login_with_fake_data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.login(form_data_email, form_data_password,fake_request)
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
async def test_logout_positive(self, jwt_service:JwtService, monkeypatch, current_user_service:CurrentUserService)->None:
with allure.step("create fake refresh token"):
refresh_token=await jwt_service.create_refresh_token({"sub":str(uuid4())})
access_token=await jwt_service.create_access_token({"sub":str(uuid4)})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions, "logout", AsyncMock(return_value=True))
with allure.step("test logout with fake data"):
status=await current_user_service.logout(refresh_token[0], access_token)
assert status is True
@pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[
pytest.param(None, True, HTTPException,401, id="jti_none"),
pytest.param(1234, True, HTTPException,401, id="jti_int"),
pytest.param(str(uuid4()), False, HTTPException,404,id="db_result_none"),
])
async def test_logout_negative(self, jwt_service:JwtService, monkeypatch, current_user_service:CurrentUserService, expected_exception, jti, db_result, expected_status:int)->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions, "logout", AsyncMock(return_value=db_result) )
async def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
refresh_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
access_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(minutes=30)})
with allure.step("test logout with fake data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.logout(refresh_token, access_token)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
@pytest.mark.parametrize("db_result_token, user_data_result_db", [
pytest.param(SimpleNamespace(is_revoked=False, expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True), id="correct_data")
])
async def test_refresh_token_positive(self, monkeypatch, current_user_service:CurrentUserService, db_result_token, requests:Request, jwt_service:JwtService,user_data_result_db )->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions,"get_token_by_id", AsyncMock(return_value=db_result_token))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data_result_db))
monkeypatch.setattr(current_user_service.jwt_db_actions,"create_and_update_token", AsyncMock(return_value=True))
fake_request = requests
async def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
with allure.step("test refresh token with fake data"):
new_access_token, new_refresh_token = await current_user_service.refresh_token(token, fake_request)
parts_a=new_access_token.split(".")
parts_b=new_refresh_token.split(".")
assert isinstance(new_access_token, str)
assert len(parts_a)==3
assert isinstance(new_refresh_token, str)
assert len(parts_b)==3
assert new_access_token!=new_refresh_token
assert new_access_token!=token
assert new_refresh_token!=token
@pytest.mark.parametrize("db_result_token, user_data_result_db, update_result, fake_token_data,expected_exception, expected_status", [
pytest.param(SimpleNamespace(is_revoked=True,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True),False,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,404,id="false_revoke_status"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True),True,{"sub":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,401, id="jti_missing"),
pytest.param(None,SimpleNamespace(status=True),True,{"sub":str(uuid4()), "jti":str(uuid4()),"token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException, 404,id="token_missing"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=False),True,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,401,id="false_user_status"),
pytest.param(SimpleNamespace(is_revoked=False, user_id="123",expires_at=datetime.now(UTC)+timedelta(days=15)),None,True,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,404,id="user_missing"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)-timedelta(days=15)),SimpleNamespace(status=True),True,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,401,id="wrong_exp")
])
async def test_refresh_token_negative(self, monkeypatch, current_user_service:CurrentUserService, db_result_token, requests, jwt_service:JwtService,user_data_result_db, expected_exception, fake_token_data, update_result, expected_status:int)->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions,"get_token_by_id", AsyncMock(return_value=db_result_token))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data_result_db))
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_and_update_token", AsyncMock(return_value=update_result))
fake_request = requests
async def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=await fake_create_refresh_token(fake_token_data)
with allure.step("test refresh token with fake data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.refresh_token(token, fake_request)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
+68
View File
@@ -0,0 +1,68 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import allure
import pytest
from fastapi import HTTPException
from src.models.pydantic_models.model import UserCreate, UserUpdate
from src.service.users_crud.users_crud import CrudService
@pytest.mark.integra
class TestCrud:
@pytest.mark.parametrize("user_data", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),id="Create_user_positive")
])
async def test_create_user_positive(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "create_user", AsyncMock(return_value=user_data))
with allure.step("Test Create User"):
await crud_service.create_user(UserCreate.model_validate(user_data))
@pytest.mark.parametrize("user_data,expected_exception,expected_status ", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),HTTPException,422,id="Create_user_None")
])
async def test_create_user_negative(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace, expected_exception, expected_status:int)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "create_user", AsyncMock(return_value=None))
with allure.step("Test Create User"), pytest.raises(expected_exception) as exc_info:
await crud_service.create_user(UserCreate.model_validate(user_data))
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
@pytest.mark.parametrize("user_data", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),id="Create_user_positive")
])
async def test_update_user_positive(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "update_user_partially", AsyncMock(return_value=user_data))
with allure.step("Test Update User"):
await crud_service.update_user(user_data.email,UserUpdate.model_validate(user_data))
@pytest.mark.parametrize("user_data,expected_exception,expected_status ", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),HTTPException,404,id="Update_user_None"),
pytest.param(SimpleNamespace(),HTTPException,400,id="Empty_user_data")
])
async def test_update_user_negative(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace, expected_exception, expected_status:int)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "update_user_partially", AsyncMock(return_value=None))
with allure.step("Test Update User"), pytest.raises(expected_exception) as exc_info:
await crud_service.update_user("d@d.d",UserUpdate.model_validate(user_data))
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
View File
+103
View File
@@ -0,0 +1,103 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import allure
import pytest
from fastapi import HTTPException
from src.service.users_crud.users_crud import CrudService
@pytest.mark.unit
class TestCrud:
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),id="Get_user_by_email_positive")
])
async def test_get_user_by_email_positive(self, monkeypatch, user_data:SimpleNamespace, crud_service:CrudService)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "get_user_by_email",AsyncMock(return_value=user_data))
with allure.step("Test get_by_email"):
test_result = await crud_service.get_user_by_email(user_data.email)
assert test_result.email==user_data.email
assert test_result.first_name==user_data.first_name
assert test_result.last_name==user_data.last_name
assert test_result.middle_name==user_data.middle_name
assert test_result.email==user_data.email
assert test_result.email==user_data.email
assert not hasattr(test_result, "password") or not hasattr(test_result, "plain_password") or not hasattr(test_result, "hashed_password")
assert test_result.direct_permissions==user_data.direct_permissions
assert test_result.group==user_data.group
assert not hasattr(test_result, "status")
@pytest.mark.parametrize("email, expected_exception, expected_status",[
pytest.param("Wrong_email", HTTPException, 404,id="Wrong_email"),
pytest.param("",HTTPException, 404,id="Empty_email"),
])
async def test_get_user_by_email_negative(self, email, crud_service:CrudService, expected_exception, expected_status:int)->None:
with allure.step("Test get_by_email"), pytest.raises(expected_exception) as exc_info:
await crud_service.get_user_by_email(email)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),id="Get_user_by_email_positive")
])
async def test_delete_user_soft_positive(self, monkeypatch, user_data:SimpleNamespace, crud_service:CrudService)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "delete_user_soft",AsyncMock(return_value=True))
with allure.step("Test get_by_email"):
test_result = await crud_service.delete_user_soft(user_data.email)
assert test_result == True
@pytest.mark.parametrize("email, expected_exception, expected_status",[
pytest.param("Wrong_email", HTTPException, 404,id="Wrong_email"),
pytest.param("",HTTPException, 404,id="Empty_email"),
])
async def test_delete_user_soft_negative(self, email, crud_service:CrudService, expected_exception, expected_status:int)->None:
with allure.step("Test get_by_email"), pytest.raises(expected_exception) as exc_info:
await crud_service.delete_user_soft(email)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),id="Get_user_by_email_positive")
])
async def test_delete_user_hard_positive(self, monkeypatch, user_data:SimpleNamespace, crud_service:CrudService)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "delete_user_hard",AsyncMock(return_value=True))
with allure.step("Test get_by_email"):
test_result = await crud_service.delete_user_hard(user_data.email, "current_user")
assert test_result==True
@pytest.mark.parametrize("email, expected_exception, expected_status",[
pytest.param("Wrong_email", HTTPException, 404,id="Wrong_email"),
pytest.param("",HTTPException, 404,id="Empty_email"),
])
async def test_delete_user_hard_negative(self, email, crud_service:CrudService, expected_exception, expected_status:int)->None:
with allure.step("Test get_by_email"), pytest.raises(expected_exception) as exc_info:
await crud_service.delete_user_hard(email, "current_user")
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
+148
View File
@@ -0,0 +1,148 @@
from datetime import UTC, datetime, timedelta
import allure
import pytest
from fastapi import HTTPException
from jose import jwt
from src.models.configs_read.env import env_settings
from src.service.auth.jwt import HashService, JwtService
@pytest.mark.unit
class TestJwt:
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123"}, id="full_sub")
])
async def test_access_create_positive(self, jwt_service:JwtService, data:dict)->None:
with allure.step("create correct access token"):
token = await jwt_service.create_access_token(data)
parts=token.split(".")
assert isinstance(token, str)
assert len(parts)==3
@pytest.mark.parametrize("data, expected_exception, expected_status",[
pytest.param("", AttributeError,None,id="not_dict_value"),
pytest.param({"sub":""},HTTPException,401,id="empty_value"),
pytest.param({"":""},HTTPException,401,id="empty_key_value")
])
async def test_access_create_negative(self, jwt_service:JwtService, data:dict, expected_exception, expected_status:int)->None:
with allure.step("create invalid access token"),pytest.raises(expected_exception) as exc_info:
await jwt_service.create_access_token(data)
if expected_exception is HTTPException:
assert expected_status == exc_info.value.status_code
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123"}, id="full_sub")
])
async def test_refresh_create_positive(self, jwt_service:JwtService, data:dict)->None:
with allure.step("create correct access token"):
token = await jwt_service.create_refresh_token(data)
parts=token[0].split(".")
assert isinstance(token[0], str)
assert isinstance(token[1], str)
assert len(parts)==3
@pytest.mark.parametrize("data, expected_exception",[
pytest.param("", AttributeError,id="not_dict_value"),
pytest.param({"sub":""},HTTPException, id="empty_value"),
pytest.param({"":""},HTTPException, id="empty_key_value")
])
async def test_refresh_create_negative(self, jwt_service:JwtService, data, expected_exception)->None:
with allure.step("create invalid access token"), pytest.raises(expected_exception):
await jwt_service.create_refresh_token(data)
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123", "exp":datetime.now(UTC)+timedelta(minutes=15), "token_type":"access"}, id="correct_data"),
])
async def test_jwt_decode_positive(self, data:dict, monkeypatch, jwt_service:JwtService)->None:
with allure.step("patch a create token function"):
async def fake_create_access_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode correct token"):
fake_token = await jwt_service.create_access_token(data)
payload=await jwt_service.jwt_decode(fake_token)
assert payload.get("sub")
assert payload.get("exp")
assert payload.get("token_type")
@pytest.mark.parametrize("data, expected_exception, expected_status", [
pytest.param({"sub": "123", "exp":datetime.now(UTC)-timedelta(minutes=15), "token_type":"access"}, HTTPException,401, id="wrong_exp"),
pytest.param({"sub": "123", "exp":datetime.now(UTC)+timedelta(minutes=15)}, HTTPException, 401,id="no_token_type"),
pytest.param({"sub": "123", "token_type":"access"}, HTTPException,401,id="no_exp"),
pytest.param({}, HTTPException, 401,id="empty_data"),
pytest.param("", AttributeError, None,id="not_dict_data")
])
async def test_jwt_decode_invalid(self,jwt_service:JwtService, expected_exception, data, monkeypatch, expected_status)->None:
with allure.step("patch a create token function"):
async def fake_create_access_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode invalid token"), pytest.raises(expected_exception) as exc_info:
fake_token=await jwt_service.create_access_token(data)
await jwt_service.jwt_decode(fake_token)
if expected_exception is HTTPException:
assert expected_status == exc_info.value.status_code
@pytest.mark.parametrize("time, key, algorithm", [
pytest.param(15, "wrong_key", "HS256",id="wrong_key"),
pytest.param(-15, "correct_key", "HS256", id="wrong_time"),
pytest.param(15, "correct_key", "HS512", id="wrong_algorithm"),
])
async def test_jwt_decode_wrong_env(self, monkeypatch, time:int, key:str, algorithm:str, jwt_service)->None:
with allure.step("patch a create token function"):
async def fake_create_access_token(data:dict, key:str, algorithm:str)->str:
data.update({"exp":datetime.now(UTC)+timedelta(minutes=time)})
return jwt.encode(data, key, algorithm)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode token with wrong data inside"), pytest.raises(HTTPException):
fake_token=await jwt_service.create_access_token({"sub": "123", "token_type":"access"}, key, algorithm)
await jwt_service.jwt_decode(fake_token)
@pytest.mark.parametrize("password",[
pytest.param("plain_password", id="correct_plain_password")
])
async def test_hash_and_veryfy_positive(self, password:str, hash_service:HashService)->None:
with allure.step("encode password"):
encoded_password=hash_service.plain_to_hash(password)
assert isinstance(encoded_password, str)
assert encoded_password!=password
with allure.step("decode password"):
assert hash_service.verify_password(password, encoded_password) is True
async def test_verify_wrong_password(self, hash_service:HashService)->None:
with allure.step("encode password"):
encoded_password=hash_service.plain_to_hash("plain_password")
with allure.step("decode password"):
assert hash_service.verify_password("wrong_password", encoded_password) is False
async def test_token_to_hash_determistic(self, hash_service:HashService)->None:
assert hash_service.token_to_hash("abc")==hash_service.token_to_hash("abc")
async def test_token_to_hash_different_input(self, hash_service:HashService)->None:
assert hash_service.token_to_hash("abc")!=hash_service.token_to_hash("xyz")