feature/docker #1

Merged
MH.Dmitrii merged 29 commits from feature/docker into main 2026-08-13 16:32:02 +00:00
55 changed files with 3300 additions and 306 deletions
+18
View File
@@ -0,0 +1,18 @@
# виртуальное окружение
venv/
.venv/
# кэш питона
__pycache__/
*.pyc
*.pyo
*.pyd
*.pytest_cache
# IDE и редакторы
.vscode/
.idea/
# OS мусор
.DS_Store
Thumbs.db
+2
View File
@@ -25,3 +25,5 @@ Thumbs.db
#Примеры документов
input/
output/
allure-results/
.coverage
View File
View File
+76
View File
@@ -0,0 +1,76 @@
name: excel-project
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
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: ../upload
target: /home/excel-project/upload
- type: bind
source: ../upload_bad
target: /home/excel-project/upload_bad
- type: bind
source: ../upload_finished
target: /home/excel-project/upload_finished
networks:
- backend
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
volumes:
- type: bind
source: ../configs
target: /home/excel-project/configs
- type: bind
source: ../DB
target: /home/excel-project/DB
- type: bind
source: ../upload
target: /home/excel-project/upload
- type: bind
source: ../upload_bad
target: /home/excel-project/upload_bad
- type: bind
source: ../upload_finished
target: /home/excel-project/upload_finished
networks:
- backend
restart: unless-stopped
ports:
- "80:8000"
networks:
backend:
name: "${BACKEND_NETWORK:-backend_network}"
+41
View File
@@ -0,0 +1,41 @@
# --- Stage 1: Python Backend dev ---
FROM python:3.14-slim AS dev
LABEL org.opencontainers.image.title="the-great-excel-project-dev"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_Great_Excel_Project"
WORKDIR /home/excel-project
COPY pyproject.toml poetry.lock docker/entrypoint.sh ./
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 2: Python Backend prod ---
FROM python:3.14-slim AS prod
LABEL org.opencontainers.image.title="the-great-excel-project-prod"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_Great_Excel_Project"
WORKDIR /home/excel-project
COPY pyproject.toml poetry.lock main.py docker/entrypoint.sh ./
COPY src/ ./src/
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 --only main
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"]
+41
View File
@@ -0,0 +1,41 @@
#!/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 [ "$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
+31 -7
View File
@@ -1,14 +1,38 @@
from contextlib import asynccontextmanager
from pathlib import Path
# import uvicorn
from fastapi import FastAPI
import uvicorn
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()
yield
print("shutting down")
app=FastAPI(root_path="/", lifespan=lifespan)
app.include_router(router=protected_router)
app.include_router(router=protected_user_action_routes)
app=FastAPI(root_path="/")
@app.get("")
def root()->dict:
async def root()->dict:
return {"root":"hello, this is root"}
def main():
uvicorn.run("main:app", reload=True)
if __name__=="__main__":
main()
def create_dirs():
dirs_to_create=("./DB",
"./upload",
"./upload_bad",
"./upload_finished")
for x in dirs_to_create:
Path(x).mkdir(parents=True, exist_ok=True)
+64 -8
View File
@@ -1,11 +1,67 @@
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
run:
.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 application
${VENV} python3 main.py
m_gen:
alembic revision --autogenerate
m_up:
alembic upgrade head
##
## Migration section
##
.PHONY: m_gen
m_gen: ## Generate alembic new revision
${VENV} alembic revision --autogenerate
.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/
.PHONY: clear
clear: ## Delete old test results
rm -rf ./tests/allure-results/reports
Generated
+1164 -274
View File
File diff suppressed because it is too large Load Diff
+30 -4
View File
@@ -8,21 +8,47 @@ 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 (>=2.13.4,<3.0.0)",
"pydantic[email] (>=2.13.4,<3.0.0)",
"pydantic-settings (>=2.14.2,<3.0.0)",
"sqlalchemy (>=2.0.51,<3.0.0)",
"pandas (>=3.0.3,<4.0.0)",
"python-jwt (>=4.1.0,<5.0.0)",
"bcrypt (>=5.0.0,<6.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)",
"aiosqlite (>=0.22.1,<0.23.0)",
"greenlet (>=3.5.4,<4.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"
[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/*"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
]
+14
View File
@@ -0,0 +1,14 @@
[pytest]
addopts =
-l
-v
--alluredir=tests/allure-results/reports/
testpaths =
tests
markers=
unit: unit tests
integra: integrations test
e2e: e2e tests
smoke: smoke tests
asyncio_mode = auto
+69
View File
@@ -0,0 +1,69 @@
from uuid import UUID
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=async_sessionmaker(bind=engine)
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)
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)
async def create_token(self, data:dict)->None:
async with self.Session() as session, session.begin():
new_token=RefreshTokens(**data)
session.add(new_token)
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
return True
+29
View File
@@ -0,0 +1,29 @@
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from src.models.database_models.model import User, engine
from src.models.pydantic_models.model import UserOutDB
class UsersCrudActions:
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():
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)
+26
View File
@@ -12,3 +12,29 @@ class Errors:
def not_found_error(self, detail:str)->HTTPException:
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)}
)
+7 -2
View File
@@ -1,5 +1,9 @@
from logging.config import fileConfig
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
@@ -18,7 +22,8 @@ if config.config_file_name is not None:
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
target_metadata = Model.metadata
config.set_main_option("sqlalchemy.url", engine.url.render_as_string(hide_password=False))
# other values from the config, defined by the needs of env.py,
# can be acquired:
@@ -65,7 +70,7 @@ def run_migrations_online() -> None:
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
connection=connection, target_metadata=target_metadata, render_as_batch=True
)
with context.begin_transaction():
+32
View File
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,50 @@
"""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
@@ -0,0 +1,69 @@
"""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 ###
+143
View File
@@ -0,0 +1,143 @@
"""empty message
Revision ID: 5e60c8fbc553
Revises:
Create Date: 2026-07-17 20:02:01.237457
"""
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'
down_revision: Union[str, Sequence[str], None] = None
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('groups_of_permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('group', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_groups_of_permissions')),
sa.UniqueConstraint('group', name=op.f('uq_groups_of_permissions_group'))
)
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('permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('permission', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_permissions')),
sa.UniqueConstraint('permission', name=op.f('uq_permissions_permission'))
)
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),
sa.Column('first_name', sa.String(length=64), nullable=False),
sa.Column('last_name', sa.String(length=64), nullable=False),
sa.Column('middle_name', sa.String(length=64), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('status', sa.Boolean(), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True)
batch_op.create_index(batch_op.f('ix_users_first_name'), ['first_name'], unique=False)
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('group_permission',
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups_of_permissions.id'], name=op.f('fk_group_permission_group_id_groups_of_permissions')),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('fk_group_permission_permission_id_permissions')),
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('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.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')),
sa.UniqueConstraint('token_hash', name=op.f('uq_refresh_tokens_token_hash'))
)
with op.batch_alter_table('refresh_tokens', schema=None) as batch_op:
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('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.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.PrimaryKeyConstraint('user_id', 'permission_group_id', name=op.f('pk_user_group'))
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_group')
op.drop_table('user_direct_permissions')
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('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'))
batch_op.drop_index(batch_op.f('ix_users_first_name'))
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('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')
# ### end Alembic commands ###
+32
View File
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,103 @@
"""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
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,40 @@
"""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
View File
@@ -1,5 +1,6 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Base(BaseSettings):
pass
+4
View File
@@ -0,0 +1,4 @@
from .files import Stored, Markets
from .model import Model
from .goods import Nomenclature, DocumentTypes, Fabric
from .accountant import AccountantSettings
+17
View File
@@ -0,0 +1,17 @@
from uuid import UUID, uuid1
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from src.models.database_models.model import Model
class AccountantSettings(Model):
__tablename__="accountant_settings"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
name:Mapped[str]=mapped_column(String(64),unique=True, index=True)
database_key:Mapped[UUID]=mapped_column(default=uuid1, unique=True)
def __repr__(self) -> str:
return f'ID: {self.id}, Name: {self.name}'
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
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"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
doc_id:Mapped[UUID]=mapped_column(default=uuid1, unique=True)
filename:Mapped[str]=mapped_column(String(255), index=True)
created_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
uploaded_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True),onupdate=func.now(), nullable=True)
doc_date:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True))
status:Mapped[str]=mapped_column(String(64))
user_id:Mapped[UUID]=mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
market_id:Mapped[int]=mapped_column(ForeignKey("markets.id", ondelete="CASCADE"),index=True)
user:Mapped["User"]=relationship(back_populates="report")
market:Mapped["Markets"]=relationship(back_populates="report")
def __repr__(self) -> str:
return f'ID: {self.id}, Filename: {self.filename}, Status: {self.status}'
class Markets(Model):
__tablename__="markets"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
name:Mapped[str]=mapped_column(String(64), unique=True)
report:Mapped[list["Stored"]]=relationship(back_populates="market")
def __repr__(self) -> str:
return f'ID: {self.id}, Name: {self.name}'
+45
View File
@@ -0,0 +1,45 @@
from decimal import Decimal
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"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
article:Mapped[str]=mapped_column(String(16), unique=True, index=True)
price:Mapped[Decimal]=mapped_column(Numeric(10, 2), index=True)
tnvd:Mapped[int]=mapped_column(Integer, index=True, unique=True)
doc_type_id:Mapped[int]=mapped_column(ForeignKey("doc_types.id", ondelete="CASCADE"), index=True)
fabric_id:Mapped[int]=mapped_column(ForeignKey("fabric.id", ondelete="CASCADE"), index=True)
status:Mapped[bool]=mapped_column(Boolean)
doc_type:Mapped["DocumentTypes"]=relationship(back_populates="good")
fabric:Mapped["Fabric"]=relationship(back_populates="good")
def __repr__(self) -> str:
return f'ID: {self.id}, Article: {self.article}, Price: {self.price}'
class DocumentTypes(Model):
__tablename__="doc_types"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
name:Mapped[str]=mapped_column(String(64), unique=True, index=True)
good:Mapped[list["Nomenclature"]]=relationship(back_populates="doc_type")
def __repr__(self) -> str:
return f'ID: {self.id}, Name:{self.name}'
class Fabric(Model):
__tablename__="fabric"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
name:Mapped[str]=mapped_column(String(64),unique=True, index=True)
good:Mapped[list["Nomenclature"]]=relationship(back_populates="fabric")
def __repr__(self) -> str:
return f"ID: {self.id}, Name: {self.name}"
+115
View File
@@ -0,0 +1,115 @@
from datetime import datetime
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
engine = create_async_engine("sqlite+aiosqlite:///DB/database.db", echo=True)
'''remember as a boilerplate, or just cp/pst'''
class Model(DeclarativeBase):
metadata = MetaData(naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
})
class User(Model):
__tablename__ = "users"
id:Mapped[UUID] = mapped_column(Uuid(as_uuid=True),default=uuid4,primary_key=True)
first_name:Mapped[str] = mapped_column(String(64), index=True)
last_name:Mapped[str]=mapped_column(String(64), index=True)
middle_name:Mapped[str]=mapped_column(String(64), index=True)
email:Mapped[str]=mapped_column(String(255), index=True, unique=True)
status:Mapped[bool]=mapped_column(Boolean, default=True)
hashed_password:Mapped[str]=mapped_column(String(255))
group:Mapped[list['PermissionsGroups']]=relationship(secondary="user_group", back_populates="user", lazy="selectin")
direct_permissions:Mapped[list['Permissions']]=relationship(secondary="user_direct_permissions", back_populates="users_direct", lazy="selectin")
refresh_token:Mapped[list['RefreshTokens']]=relationship(back_populates="user")
report:Mapped[list["Stored"]]=relationship(back_populates="user")
def __repr__(self) -> str:
return f"ID: {self.id}, Name: {self.first_name}, Status: {self.status}"
class PermissionsGroups(Model):
__tablename__="groups_of_permissions"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
group:Mapped[str]=mapped_column(String(255), unique=True)
user:Mapped[list['User']]=relationship(secondary="user_group", back_populates="group")
permissions:Mapped[list["Permissions"]]=relationship(secondary="group_permission", back_populates="group", lazy="selectin")
def __repr__(self)->str:
return f"ID: {self.id}, Permissions: {self.group}"
class Permissions(Model):
__tablename__ = "permissions"
id:Mapped[int]=mapped_column(primary_key=True, index=True)
permission:Mapped[str]=mapped_column(String(255), unique=True)
group:Mapped[list['PermissionsGroups']]=relationship(secondary="group_permission", back_populates="permissions")
users_direct:Mapped[list["User"]]=relationship(secondary="user_direct_permissions", back_populates="direct_permissions")
def __repr__(self)->str:
return f"ID: {self.id}, Permissions: {self.permission}"
user_group_of_permissions=Table(
"user_group",
Model.metadata,
Column("user_id", ForeignKey("users.id"), 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("permission_id", ForeignKey("permissions.id"), primary_key=True)
)
group_permission = Table(
"group_permission",
Model.metadata,
Column("group_id", ForeignKey("groups_of_permissions.id"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id"), primary_key=True),
)
class RefreshTokens(Model):
__tablename__= "refresh_tokens"
id:Mapped[UUID]=mapped_column(Uuid(as_uuid=True),primary_key=True, index=True)
user_id:Mapped[UUID]=mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash:Mapped[str]=mapped_column(String(255), unique=True)
device_info:Mapped[str]=mapped_column(String(255))
ip_address:Mapped[str]=mapped_column(String(45))
is_revoked:Mapped[bool]=mapped_column(Boolean, default=False)
expires_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True))
created_at:Mapped[datetime]=mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
replaced_by:Mapped[UUID|None]=mapped_column(ForeignKey("refresh_tokens.id"), nullable=True, default=None)
user:Mapped["User"]=relationship(back_populates="refresh_token")
def __repr__(self)->str:
return f'ID: {self.id}, USER_ID: {self.user_id}, Is_revoked: {self.is_revoked}, Token_Hash: {self.token_hash}'
+13
View File
@@ -0,0 +1,13 @@
from typing import Annotated
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")]
database_key:Annotated[UUID, Field(..., description="database key from the 1C server")]
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime
from typing import Annotated
from uuid import UUID
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")]
status:Annotated[str, Field(..., max_length=64, description="status of the report (uploaded,finalized, failed)")]
user:Annotated[UUID, Field(..., description="id of the user author")]
market:Annotated[str, Field(..., max_length=64,description="name of the market")]
class ReportOut(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")]
status:Annotated[str, Field(..., max_length=64, description="status of the report (uploaded,finalized, failed)")]
user:Annotated[UUID, Field(..., description="id of the user author")]
market:Annotated[str, Field(..., max_length=64, description="name of the market")]
class MarketCreate(Base):
name:Annotated[str, Field(..., max_length=64,description="certificate or declaration")]
class MarketOut(Base):
name:Annotated[str, Field(..., max_length=64,description="certificate or declaration")]
+38
View File
@@ -0,0 +1,38 @@
from decimal import Decimal
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")]
price:Annotated[Decimal, Field(..., max_digits=12, description="price of the article")]
tnvd:Annotated[int, Field(..., max_digits=10, description="tnvd from the Markirovka.crpt")]
status:Annotated[bool, Field(..., description="status of the article")]
doc_type:Annotated[str, Field(..., description="document type (declaration or certificate)")]
fabric:Annotated[str, Field(..., description="fabric of the product")]
class NomenclatureOut(Base):
article:Annotated[str, Field(..., min_length=5,max_length=16, description="name of the article")]
price:Annotated[Decimal, Field(..., max_digits=12,description="price of the article")]
tnvd:Annotated[int, Field(..., max_digits=10,description="tnvd from the Markirovka.crpt")]
status:Annotated[bool, Field(..., description="status of the article")]
doc_type:Annotated[str, Field(...,max_length=64, description="document type (declaration or certificate)")]
fabric:Annotated[str, Field(...,max_length=64, description="fabric of the product")]
class DocumentTypeCreate(Base):
name:Annotated[str, Field(...,max_length=64, description="certificate or declaration")]
class DocumentTypeOut(Base):
name:Annotated[str, Field(..., max_length=64,description="certificate or declaration")]
class FabricCreate(Base):
name:Annotated[str, Field(..., max_length=64,description="certificate or declaration")]
class FabricOut(Base):
name:Annotated[str, Field(..., max_length=64,description="certificate or declaration")]
+95
View File
@@ -0,0 +1,95 @@
from datetime import datetime
from typing import Annotated
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class Base(BaseModel):
model_config = {"from_attributes": True}
class PermissionsCreate(Base):
permission:Annotated[str, Field(..., max_length=30, description="permission name")]
class PermissionsOut(Base):
id:Annotated[int, Field(..., description="id of the permission")]
permission:Annotated[str, Field(..., max_length=30, description="permission name")]
class PermissionsGroupsCreate(Base):
group:Annotated[str, Field(..., max_length=255, description="group name for the permissions")]
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")]
class UserCreate(Base):
first_name:Annotated[str, Field(..., max_length=64,description="first name of the user")]
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")]
direct_permissions:Annotated[list[str], Field(..., description="permissions of the user")]
group:Annotated[list[str], Field(..., description="permissions groups of the user")]
class UserOut(Base):
first_name:Annotated[str, Field(..., max_length=64,description="first name of the user")]
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")]
direct_permissions:Annotated[list[PermissionsOut], Field(..., description="permissions of the user")]
group:Annotated[list[PermissionsGroupsOut], Field(..., description="permissions groups of the user")]
class UserOutDB(UserOut):
id:Annotated[UUID, Field(..., description="Id of the user")]
status:Annotated[bool, Field(..., description="status of the user")]
hashed_password:Annotated[str, Field(..., description="hashed password of the user")]
class UserUpdate(Base):
first_name:Annotated[str|None, Field(None, max_length=64, description="first name of the user")]
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")]
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")]
class RefreshTokensCreate(Base):
id:Annotated[UUID, Field(..., description="jti")]
user_id:Annotated[UUID, Field(..., description="foreign key for the user")]
token_hash:Annotated[str, Field(...,max_length=255, description="token hash")]
device_info:Annotated[str, Field(...,max_length=255, description="User device info")]
ip_address:Annotated[str, Field(...,max_length=45, description="ip v4/v6 of the user")]
expires_at:Annotated[datetime, Field(..., description="when token is going to be expired")]
class RefreshTokensUpdate(Base):
is_revoked:Annotated[bool, Field(..., description="revoke token if logout was made")]
replaced_by:Annotated[UUID, Field(...,description="old_jti")]
class RefreshTokensOut(Base):
user_id:Annotated[UUID, Field(..., description="foreign key for the user")]
token_hash:Annotated[str, Field(..., max_length=255,description="token hash")]
device_info:Annotated[str, Field(..., max_length=255, description="User device info")]
ip_address:Annotated[str, Field(...,max_length=45, description="ip v4/v6 of the user")]
is_revoked:Annotated[bool|None, Field(None, description="revoke token if logout was made")]
expires_at:Annotated[datetime, Field(..., description="when token is going to be expired")]
replaced_by:Annotated[UUID|None, Field(..., description="Old refresh token")]
class RefreshRequest(Base):
refresh_token:str
+1
View File
@@ -0,0 +1 @@
'''business logic'''
+197
View File
@@ -0,0 +1,197 @@
import asyncio
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import Request
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.configs_read.env import env_settings
from src.models.pydantic_models.model import RefreshTokensCreate, UserOut
from .jwt import HashService, JwtService
class CurrentUserService:
def __init__(self) -> None:
self.jwt_service=JwtService()
self.hash=HashService()
self.crud_db_actions=UsersCrudActions()
self.jwt_db_actions=JwtCrudActions()
self.error=Errors()
async def _check(self, form_data_email:str, form_data_password:str,):
'''check user by 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 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:
raise self.error.credentials_error(detail="This user is deactivated")
return user
async def _token_record_create(self, jti:UUID,user_id:UUID,token:str, request:Request)->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(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
async def get_current_user(self, token:str)->UserOut:
payload= await self.jwt_service.jwt_decode(token)
sub=payload.get("sub")
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")
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
async def refresh_token(self, refresh_token:str, request:Request)->tuple[str, str]:
'''decode old refresh token'''
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) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''old refresh token check'''
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")
'''sqlite constraints about timezone'''
expires_at=old_record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at<datetime.now(UTC):
raise self.error.credentials_error(detail="Token expired")
'''user check'''
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:
raise self.error.credentials_error(detail="This user is deactivated")
'''create new refresh token if all the checks are successful'''
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) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''create database record with the new token'''
new_token_record=await self._token_record_create(jti=new_jti, user_id=sub, token=new_refresh_token, request=request)
success = await self.jwt_db_actions.create_and_update_token(RefreshTokensCreate.model_dump(new_token_record), old_jti, new_jti)
if not success:
raise self.error.not_found_error(detail="Token not found")
return (new_access_token, new_refresh_token)
async def logout(self, refresh_token:str)->bool:
'''decode current refresh token'''
payload=await self.jwt_service.jwt_decode(refresh_token)
if (jti:=payload.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect")
try:
jti=UUID(jti)
except (ValueError, TypeError, AttributeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''logout by assigning revoked flag'''
if await self.jwt_db_actions.logout(jti):
return True
else:
raise self.error.not_found_error(detail="Refresh Token Not Found")
async def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
'''revoke all the old refresh tokens'''
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=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)
async def auth_service()->CurrentUserService:
return CurrentUserService()
+77
View File
@@ -0,0 +1,77 @@
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 HashService:
def __init__(self) -> None:
pass
def plain_to_hash(self, plain_password:str)->str:
return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(self, plain_password:str, hashed_password:str)->bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def token_to_hash(self, token:str)->str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
'''jwt'''
class JwtService:
def __init__(self) -> None:
self.error=Errors()
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()
await self._validate_sub(user_info)
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"token_type":"access"})
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
})
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM), jti
async def jwt_decode(self, token:str)->dict:
try:
payload=jwt.decode(token, env_settings.SECRET_KEY, algorithms=[env_settings.ALGORITHM], options={"require_exp": True} )
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
return payload
+19
View File
@@ -0,0 +1,19 @@
from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors
from src.models.pydantic_models.model import UserOut
class CrudService:
def __init__(self) -> None:
self.errors=Errors()
self.crud_db_actions=UsersCrudActions()
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 crud_service()->CrudService:
return CrudService()
-1
View File
@@ -1 +0,0 @@
'''unit, integr, e2e tests'''
+55
View File
@@ -0,0 +1,55 @@
from fastapi import APIRouter, Cookie, Depends, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
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")
@router.post("/token")
async def get_access_token(request: Request,response:Response,auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008
access_token, refresh_token=await 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(), auth:CurrentUserService=Depends(auth_service))->dict: # noqa: B008
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=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), auth:CurrentUserService=Depends(auth_service)) -> UserOut: # noqa: B008
return UserOut.model_validate(await auth.get_current_user(token))
@router.get("/logout")
async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(get_current_user))->bool: # noqa: B008
response.delete_cookie("refresh_token")
return await auth.logout(refresh_token)
@router.get("")
async def protected(current_user:UserOut=Depends(get_current_user))->dict: # noqa: B008
return {"protected router": "Hello, this is a protected router"}
@@ -0,0 +1,11 @@
from fastapi import APIRouter, Depends
from src.models.pydantic_models.model import UserOut
from src.service.users_crud.users_crud import CrudService, crud_service
from src.web.protected_routes.auth_routes import get_current_user
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(get_current_user))->UserOut: # noqa: B008
return await crud.get_user_by_email(email)
-9
View File
@@ -1,9 +0,0 @@
from fastapi import APIRouter
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
router=APIRouter(prefix="/protected")
oauth2_scheme=OAuth2PasswordBearer(tokenUrl="/protected/token")
@router.get("")
def protected()->dict:
return {"protected router": "Hello, this is a protected router"}
+1
View File
@@ -0,0 +1 @@
'''unit, integr, e2e tests'''
+14
View File
@@ -0,0 +1,14 @@
import pytest_asyncio
from src.service.auth.jwt import HashService, JwtService
@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
View File
View File
+16
View File
@@ -0,0 +1,16 @@
import pytest_asyncio
from fastapi import Request
from src.service.auth.auth import CurrentUserService
@pytest_asyncio.fixture
async def current_user_service()->CurrentUserService:
current_user_service=CurrentUserService()
return current_user_service
@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
+215
View File
@@ -0,0 +1,215 @@
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
@pytest.mark.parametrize("user_data, uuid, expected_exception",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), uuid4(), HTTPException, 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, id="wrong_id"),
pytest.param(SimpleNamespace(status=True),uuid4(), ValidationError,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, 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):
await current_user_service.get_current_user(token)
@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",[
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, 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, 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, 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):
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):
await current_user_service.login(form_data_email, form_data_password,fake_request)
async def test_logout_positive(self, jwt_service:JwtService, monkeypatch, current_user_service:CurrentUserService)->None:
with allure.step("create fake refresh token"):
token=await jwt_service.create_refresh_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(token[0])
assert status is True
@pytest.mark.parametrize("jti,db_result, expected_exception",[
pytest.param(None, True, HTTPException, id="jti_none"),
pytest.param(1234, True, HTTPException, id="jti_int"),
pytest.param(str(uuid4()), False, HTTPException, id="db_result_none"),
])
async def test_logout_negative(self, jwt_service:JwtService, monkeypatch, current_user_service:CurrentUserService, expected_exception, jti, db_result)->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"):
token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
with allure.step("test logout with fake data"), pytest.raises(expected_exception):
await current_user_service.logout(token)
@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", [
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,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, 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, 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,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,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,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)->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):
await current_user_service.refresh_token(token, fake_request)
View File
+2
View File
@@ -0,0 +1,2 @@
+144
View File
@@ -0,0 +1,144 @@
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",[
pytest.param("", AttributeError,id="not_dict_value"),
pytest.param({"sub":""},HTTPException, id="empty_value"),
pytest.param({"":""},HTTPException, id="empty_key_value")
])
async def test_access_create_negative(self, jwt_service:JwtService, data:dict, expected_exception)->None:
with allure.step("create invalid access token"),pytest.raises(expected_exception):
await jwt_service.create_access_token(data)
@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", [
pytest.param({"sub": "123", "exp":datetime.now(UTC)-timedelta(minutes=15), "token_type":"access"}, HTTPException, id="wrong_exp"),
pytest.param({"sub": "123", "exp":datetime.now(UTC)+timedelta(minutes=15)}, HTTPException, id="no_token_type"),
pytest.param({"sub": "123", "token_type":"access"}, HTTPException,id="no_exp"),
pytest.param({}, HTTPException, id="empty_data"),
pytest.param("", AttributeError, id="not_dict_data")
])
async def test_jwt_decode_invalid(self,jwt_service:JwtService, expected_exception, data, monkeypatch)->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):
fake_token=await jwt_service.create_access_token(data)
await jwt_service.jwt_decode(fake_token)
@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")