Author SHA1 Message Date
MH.Dmitrii 3fb0f8c76d tests, reset_password first steps 2026-09-15 17:04:24 +03:00
MH.Dmitrii 2ac6f898a7 update claude.md, imports sort 2026-09-14 18:40:00 +03:00
MH.Dmitrii e124d897eb add logging and retry to connect to the rabbitmq server 2026-09-14 18:34:15 +03:00
MH.Dmitrii a93c6d5fca refactor logging module, add welcome and reset daemons, changed structure of the rabbitmq queues 2026-09-13 18:40:14 +03:00
MH.Dmitrii b4e88a6ff4 docker container for daemons 2026-09-09 18:01:48 +03:00
MH.Dmitrii 1aa3085468 topic exchange rabbitmq 2026-09-09 14:07:08 +03:00
MH.Dmitrii 10ce1755bd producers, consumers, rabbit_client 2026-09-06 18:12:59 +03:00
MH.Dmitrii d707c8b329 rabbitmq docker instance 2026-09-06 15:40:08 +03:00
MH.Dmitrii a63bfa16c6 fix redis container 2026-09-05 23:09:20 +03:00
MH.Dmitrii 5c1eab88aa Merge pull request 'feature/redis' (#14) from feature/redis into dev
Reviewed-on: #14
2026-09-05 20:06:33 +00:00
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 99ab6ae3af Merge pull request 'docker multistage build' (#13) from feature/docker into dev
Reviewed-on: #13
2026-09-03 13:15:33 +00: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 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 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 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 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 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
62 changed files with 2498 additions and 830 deletions
+7
View File
@@ -0,0 +1,7 @@
---
name: code-reviewer
description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.
tools: Read, Grep, Glob, Bash
model: sonnet
---
You are a senior code reviewer. Read the recent git diff or modified files, then report only what matters: correctness bugs, security vulnerabilities, and maintainability traps. Lead with the highest-severity finding.
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"sandbox": {
"filesystem": {
"allowRead": ["."],
"denyRead": ["**/.env", "./DB", "./RTMQ", "./.vscode", "./.pytest_cache", "**/__pycache__"]
}
}
}
+10 -2
View File
@@ -17,10 +17,18 @@ __pycache__/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
#env # env files
*.env *.env
#db
# DB
*.db *.db
DB/
# rabbitmq
RTMQ/
# logs
logs/
#Примеры документов #Примеры документов
input/ input/
+206
View File
@@ -0,0 +1,206 @@
# The DisExcel Project — Context for Claude Code
Backend: FastAPI + SQLAlchemy (async) + Pydantic v2 + PostgreSQL. JWT auth
(access + refresh tokens), RBAC permissions, Redis cache, RabbitMQ background
workers. Python >=3.13,<4.0, Poetry for dependency management.
## Auth & Permissions
- Users have `direct_permissions` (list of `Permissions`) and `group`
(list of `PermissionsGroups`, each with its own `permissions`) —
many-to-many both ways.
- Effective permissions = `direct_permissions (union of all groups' permissions)`.
- `require_permissions(*permissions)` in `src/web/protected_routes` is a
FastAPI dependency factory — wraps `CurrentUserService.get_current_user`.
Call with no args (`require_permissions()`) for "just authenticated, no
specific permission needed".
- Access tokens carry a `jti` claim. Logout writes `revoked_access_token:{jti}`
to Redis with TTL = remaining token lifetime — `get_current_user` checks
this key before anything else.
- `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE`
(bool) — `False` locally/tests so cookies work over plain HTTP, `True` in
prod.
## Redis (`src/cache/`)
- `RedisClient(redis.Redis)` — module-level shared singleton, subclasses
`redis.Redis` directly (inherits all commands, no manual wrapping needed).
- Three uses: permissions is-cache was considered and rejected (no real DB
savings — `get_user_by_id` already eager-loads everything via `selectin`
in one call); rate limiting on login (`RateLimit.rate_limit(ip)`
`INCR` + `EXPIRE` on first attempt, blocks >5/60s); access-token revoke
blacklist (see above).
- Rate limit is only triggered inside `except HTTPException` on `/protected/token`
— i.e. only on failed logins, not successful ones (otherwise legitimate
repeated logins would trip it).
## RabbitMQ (`src/messaging/`)
- `RabbitMQClient` — shared class, lazy `connect()` (can't be async `__init__`),
holds one `connection` + one `channel`, `get_channel()` ensures setup.
`connect()` retries `connect_robust()` up to 5 times with exponential
backoff (`asyncio.sleep(2**attempt)`) before raising — needed because
when running the app locally (outside Docker Compose) right after
`docker compose up rabbitmq`, the broker's AMQP listener isn't
immediately ready and resets the connection mid-handshake
(`ConnectionResetError`). Compose's own `depends_on: condition:
service_healthy` only helps when the app itself also runs as a Compose
service — it does nothing when the app is started on the host.
- **Topology is centralized, not declared ad-hoc.** `src/models/rabbitmq_models/email.py`
defines the whole topology as pydantic data (`Topology`/`ExchangeSpec`/`QueueSpec`,
exported as `email_topology`) — topic exchange `"email"`, one durable
quorum queue per message type (`queue_welcome_email`/`email.welcome`,
`queue_reset_email`/`email.reset`), each with a matching dead-letter queue.
`src/messaging/topology_setup.py::apply_topology(channel, topology)` is
the only place that actually calls `declare_exchange`/`declare_queue`/`bind`
against RabbitMQ. It's called once at startup — in `main.py`'s `lifespan`
(web) and in `daemon_run.py`'s `main()` (daemon entrypoint) — **before**
any producer/consumer touches the exchange/queues. `EmailProducer`/
`WelcomeEmailConsumer`/`ResetEmailConsumer` no longer declare or bind
anything themselves — they only `get_exchange("email")` /
`get_queue("queue_...")`, assuming `apply_topology` already ran.
Changing a queue's arguments in `email_topology` requires manually
deleting the existing queue in RabbitMQ first — `declare_queue` on an
already-existing queue with different arguments fails with
`PRECONDITION_FAILED`, it doesn't update in place.
- **Dead-lettering**: each original queue's `x-dead-letter-exchange` points
at `email.dlx`, a **`direct`** exchange (not `fanout` — fanout would
broadcast every dead-lettered message to *all* DLQs, mixing up
welcome/reset failures). Each DLQ is bound to `email.dlx` with the same
routing key as the original queue (`email.welcome`/`email.reset`);
RabbitMQ preserves a message's original routing key when dead-lettering,
so no explicit `x-dead-letter-routing-key` override is needed — the
`direct` exchange alone routes each dead letter back to the correct DLQ.
- `message.process(ignore_processed=True)` + manual `await message.nack(...)`
in `process_message`, not the plain auto-ack/nack that `message.process()`
does by default — needed because business-logic exceptions are caught
*inside* the block (to log them and decide retry vs. dead-letter) rather
than left to propagate, so `process()` itself must be told not to also
try to ack/nack on exit. Classification: transient errors (SMTP/network:
`SMTPServerDisconnected`, `SMTPConnectError`, `TimeoutError`,
`ConnectionRefusedError`) → `nack(requeue=True)`; everything else
(bad message body, `SMTPAuthenticationError`, bugs) → `nack(requeue=False)`
→ DLQ. Note `SMTPAuthenticationError` is a known rough edge here — it's
a systemic config problem (bad credentials), not a per-message one, but
is currently DLQ'd per-message like any other permanent error rather
than halting the daemon with an alert.
- Email templates: Jinja2 (`src/service/email/jinja_env.py` — one shared
`Environment`/`FileSystemLoader` singleton, templates in
`src/service/email/templates/`), inline CSS (email clients don't support
`<style>` reliably), `EmailMessage` with `set_content()` (plain-text
fallback) + `add_alternative(html, subtype="html")`.
- **Known gap**: the reset-password flow is a stub. `ResetEmailConsumer.process_message`
only prints and acks — it never calls a sender — and
`src/service/email/email_reset.py` is empty. `templates/reset.html` still
has a hardcoded placeholder password. Don't assume reset emails actually
send until this is wired up like `WelcomeEmailConsumer`/`DaemonEmailSender`.
## Logging (`src/logging/`)
- All log output (HTTP endpoints, SQL, daemons) funnels through one
module-level `asyncio.Queue` (`log_queue` in `src/logging/logger.py`) and
a single `LogWriter.log_writer()` background task that drains it and
appends to `./logs/{type}_log_{month}_{year}.txt` — this avoids the
original bug of firing an unsupervised `asyncio.create_task` per log
line (unordered writes, lost logs if the process died before the task
ran). **`log_writer()` must be started explicitly wherever the process
entrypoint is** — it's `asyncio.create_task(writer.log_writer())` in
`main.py`'s `lifespan` for the web process, and the same call in
`daemon_run.py`'s `main()` for the daemon process. `log_queue` is
process-local (plain in-memory `asyncio.Queue`, not shared across
processes), so **every new entrypoint needs its own writer task** or its
logs silently queue forever and are never written (unbounded memory
growth, not a crash).
- `logging.Handler` subclasses (`LoggerDB` for `sqlalchemy.engine`,
`LoggerDaemon` for the `"daemon"` logger) just push `(type, formatted_msg)`
onto `log_queue` from `emit()` — do **not** give them a custom `__init__`
that doesn't call `super().__init__()`; skipping it means `self.level`/
`self.filters`/etc. never get set and any log call raises `AttributeError:
'LoggerX' object has no attribute 'level'`.
- Two `ContextVar`s tag log lines with a correlation id without threading
it through every function call: `request_id_ctx` (set once per HTTP
request in `LoggingMiddleware.dispatch`) and `message_id_ctx` (meant to
be set once per RabbitMQ message in `process_message`). Only visible
within the same async call chain — a `ContextVar` set in an HTTP request
is `"-"` (the default) inside daemon code, and vice versa; they don't
cross the process boundary either.
- **`src/logging/logger.py` vs `src/logging/http_logger.py` split matters
for Docker.** `logger.py` has zero `fastapi`/`starlette` imports —
intentional, because the `daemon` Poetry group (and therefore the daemon
Docker image) never installs `fastapi`. `http_logger.py` holds
`LoggingMiddleware`/`ProcessingTimeMiddleware` (the only things that
actually need FastAPI/Starlette) and is imported only from `main.py`.
`src/logging/__init__.py` imports only from `logger.py`. **Never import
`src.logging.http_logger` from anything that runs in the daemon
process** (`consumers.py`, `email_welcome.py`, `daemon_run.py`, etc.) —
it would drag in `fastapi`, which raises `ModuleNotFoundError` in the
daemon container.
- Plain `logger.exception(...)` must only be called from inside an
`except` block — it pulls the active exception via `sys.exc_info()` to
attach a traceback. Called outside `except` (e.g. for a routine "message
received" log line), it still runs but appends a literal `NoneType: None`
instead of a traceback, since there's no active exception to format.
## Daemons / workers (`src/daemons/`)
- `BaseDaemon` ABC (`name` + async `run()`), one subclass per consumer
(`WelcomeEmailDaemon`, `ResetEmailDaemon`, more to come — e.g. reports).
- `DAEMONS` registry dict maps string name → daemon class.
- `daemon_run.py` (project root) is the single entrypoint: `python
daemon_run.py <name>` runs one daemon, `python daemon_run.py --all` reads
`configs/daemons.json` (`{"daemons": [...]}`) and runs all enabled ones
concurrently via `asyncio.gather`. `main()` also applies the RabbitMQ
topology (`apply_topology`) and starts the log writer task before
dispatching to either mode, inside a `try`/`finally` so both the
single-daemon and `--all` paths cancel the writer task on the way out.
- Each daemon runs as its own Docker service/container (`command: ["python",
"daemon_run.py", "<name>"]`), same pattern as the `migration` service.
- `daemons.json` is read with a plain Pydantic `BaseModel` + manual
`json.load`, NOT `pydantic-settings` `json_file` — that requires wiring
`settings_customise_sources` manually in this pydantic-settings version
and isn't worth the complexity here.
## Docker / Poetry groups
- `pyproject.toml` uses PEP 621 `[project.dependencies]` for shared deps
(sqlalchemy, redis, aio-pika, pydantic, bcrypt, jose, aiofiles, asyncpg,
psycopg2-binary, alembic, greenlet).
- `web` group: fastapi, uvicorn, gunicorn, python-multipart — only needed
by the API server.
- `daemon` group: worker-only deps — `aiosmtpd`, `jinja2` (email sending
and templating). Must **not** gain a `fastapi`/`starlette` dependency;
see the `src/logging/logger.py` vs `http_logger.py` split above for why
that boundary is enforced deliberately.
- `dev` group: pytest stack, allure, httpie, requests-async.
- Dockerfile has parallel builder→final stage pairs: `builder`→`prod`
(installs `main,web`) and `worker-builder`→`worker` (installs
`main,daemon`). Same base pattern: venv builder stage copies
`/opt/venv` into a clean final stage, poetry itself is uninstalled
after install to keep the final image lean.
- Alembic runs against a **separate sync engine** (`asyncpg` swapped out,
psycopg2 used instead) — async SQLAlchemy engine can't drive Alembic
directly without the `run_sync` bridge, and a dedicated sync engine is
simpler than that bridge.
- `DB_HOST` differs between contexts: `psql` (Docker service name) for
containers talking to each other, `localhost` for anything run on the
host (e.g. local `alembic revision --autogenerate`). Compose services
override `DB_HOST` via `environment:`; the `.env` file's own default is
for host-side runs.
## Testing (`tests/unit`, `tests/integrated`, `tests/e2e`)
- **Recurring root cause of "different event loop" / `MissingGreenlet`-style
errors**: prod code uses module-level singletons (`engine`, `redis_client`)
created once at import time and reused for the app's whole lifetime — this
is correct for prod (one event loop, whole uptime) but breaks under
pytest-asyncio's default `function`-scoped event loop (a new loop per
test, but the singleton's connections stay bound to the *first* loop).
Fix: test fixtures create a **fresh** `engine`/`RedisClient` per test and
monkeypatch or inject them in place of the global singleton, then dispose
on teardown — not a global `session`-scoped event loop (that would mask
real isolation bugs).
- e2e `MySession` must subclass `httpx.AsyncClient` (not `requests_async.
AsyncSession` — that library silently drops cookies between requests,
which broke refresh-token-cookie-dependent tests like logout).
- `test_user_fixture` is `indirect=True` parametrized with
`(direct_permissions, group)` tuples.
+27 -2
View File
@@ -1,4 +1,29 @@
SECRET_KEY = "change_me" SECRET_KEY = "change_me"
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15 ACCESS_TOKEN_EXPIRE_MINUTES = 15 #int
REFRESH_TOKEN_EXPIRE_DAYS= 45 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"
RABBITMQ_PASSWORD="change_me"
RABBITMQ_LOGIN="change_me"
RABBITMQ_HOST="change_me"
RABBITMQ_PORT=change_me #int
RABBITMQ_PORT_UI=chane_me #int
EMAIL_PORT=change_me #int
SMTP_SERVER="change_me"
EMAIL_LOGIN="change_me"
EMAIL_PASSWORD="change_me"
PROD_MODE=bool
+7
View File
@@ -0,0 +1,7 @@
{
"daemons":
[
"welcome_email",
"reset_email"
]
}
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import os
import sys
from src.daemons.registry import DAEMONS
from src.logging.logger import LogWriter
from src.messaging.rabbitmq_client import rabbitmq_client
from src.messaging.topology_setup import apply_topology
from src.models.configs_read.daemons_json import daemons_config
from src.models.rabbitmq_models.email import email_topology
writer = LogWriter()
async def run_one(daemon_name: str) -> None:
daemon_cls = DAEMONS.get(daemon_name)
if daemon_cls is None:
print(f"Unknown daemon: {daemon_name}. Available: {list(DAEMONS.keys())}")
sys.exit(1)
daemon = daemon_cls()
await daemon.run()
async def run_enabled_from_config() -> None:
daemons = []
for name in daemons_config.daemons:
name = name.strip()
if name not in DAEMONS:
print(f"Warning: unknown daemon '{name}' in config, skipping")
continue
daemons.append(DAEMONS[name]())
if not daemons:
print("No enabled daemons found in config")
sys.exit(1)
await asyncio.gather(*(d.run() for d in daemons))
async def main() -> None:
channel = await rabbitmq_client.get_channel()
await apply_topology(channel, email_topology)
writer_task = asyncio.create_task(writer.log_writer())
try:
if len(sys.argv) < 2:
print("Usage: python run_daemon.py <daemon_name> | --all")
sys.exit(1)
arg = sys.argv[1]
if arg == "--all":
await run_enabled_from_config()
else:
await run_one(arg)
finally:
writer_task.cancel()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print('Interrupted')
try:
sys.exit(0)
except SystemExit:
os._exit(0)
+127 -1
View File
@@ -1,6 +1,7 @@
name: excel-project name: disexcel
services: services:
backend-dev: backend-dev:
profiles: ["dev"] profiles: ["dev"]
image: "${DOCKER_REGISTRY:-local}/excel-dev:${IMAGE_TAG:-local}" image: "${DOCKER_REGISTRY:-local}/excel-dev:${IMAGE_TAG:-local}"
@@ -11,6 +12,10 @@ services:
target: dev target: dev
init: true #Manage processes and reap zombies init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications 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
- RABBITMQ_HOST=rabbitmq
- REDIS_HOST=redis
volumes: volumes:
- type: bind - type: bind
source: ../src source: ../src
@@ -27,8 +32,21 @@ services:
- type: bind - type: bind
source: ../uploads source: ../uploads
target: /home/excel-project/uploads target: /home/excel-project/uploads
- type: bind
source: ../logs
target: /home/excel-project/logs
- type: bind
source: ../daemon_run.py
target: /home/excel-project/daemon_run.py
networks: networks:
- backend - backend
depends_on:
psql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
ports: ports:
- "80:8000" - "80:8000"
entrypoint: ["./entrypoint.sh", "--dev"] entrypoint: ["./entrypoint.sh", "--dev"]
@@ -43,6 +61,10 @@ services:
target: prod target: prod
init: true #Manage processes and reap zombies init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
environment:
- DB_HOST=psql
- RABBITMQ_HOST=rabbitmq
- REDIS_HOST=redis
volumes: volumes:
- type: bind - type: bind
source: ../configs source: ../configs
@@ -53,12 +75,116 @@ services:
- type: bind - type: bind
source: ../uploads source: ../uploads
target: /home/excel-project/uploads target: /home/excel-project/uploads
- type: bind
source: ../logs
target: /home/excel-project/logs
networks: networks:
- backend - backend
restart: unless-stopped restart: unless-stopped
depends_on:
psql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
ports: ports:
- "80:8000" - "80:8000"
daemons:
profiles: ["prod", "dev"]
image: "${DOCKER_REGISTRY:-local}/excel-daemons:${IMAGE_TAG:-local}"
container_name: daemons
environment:
- RABBITMQ_HOST=rabbitmq
build:
dockerfile: ./docker/dockerfile
context: ../
target: daemon
init: true
ipc: private
volumes:
- type: bind
source: ../configs
target: /home/excel-project/configs
- type: bind
source: ../logs
target: /home/excel-project/logs
depends_on:
rabbitmq:
condition: service_healthy
networks:
- backend
restart: unless-stopped
psql:
profiles: ["prod", "dev", "local"]
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", "local"]
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
rabbitmq:
image: rabbitmq:3-management-alpine
profiles: ["prod", "dev", "local"]
container_name: rabbitmq_broker
restart: always
init: true
ipc: private
environment:
- RABBITMQ_DEFAULT_USER=${RABBITMQ_LOGIN}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
ports:
- "${RABBITMQ_PORT}:5672"
- "${RABBITMQ_PORT_UI}:15672"
volumes:
- type: bind
source: ../RTMQ
target: /var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
networks: networks:
backend: backend:
name: "${BACKEND_NETWORK:-backend_network}" name: "${BACKEND_NETWORK:-backend_network}"
+80 -10
View File
@@ -1,12 +1,13 @@
# --- Stage 1: Python Backend dev --- # --- Stage 1: Python Backend dev ---
FROM python:3.14-slim AS dev FROM python:3.14-slim AS dev
LABEL org.opencontainers.image.title="the-great-excel-project-dev" LABEL org.opencontainers.image.title="The-DisExcel-project-dev"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_Great_Excel_Project" LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
WORKDIR /home/excel-project WORKDIR /home/excel-project
COPY pyproject.toml poetry.lock docker/entrypoint.sh ./ COPY pyproject.toml poetry.lock docker/entrypoint.sh alembic.ini ./
RUN chmod +x ./entrypoint.sh RUN chmod +x ./entrypoint.sh
@@ -14,24 +15,47 @@ RUN pip install --no-cache-dir --break-system-packages poetry \
&& poetry config virtualenvs.create false \ && poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction && 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,web \
&& pip uninstall -y poetry poetry-core poetry-plugin-export
# --- Stage 2: Python Backend prod --- # --- Stage 2: Python Backend prod ---
FROM python:3.14-slim AS prod FROM python:3.14-slim AS prod
LABEL org.opencontainers.image.title="the-great-excel-project-prod" LABEL org.opencontainers.image.title="The-DisExcel-project-prod"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_Great_Excel_Project" LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
WORKDIR /home/excel-project WORKDIR /home/excel-project
COPY pyproject.toml poetry.lock main.py docker/entrypoint.sh ./ 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/ COPY src/ ./src/
RUN chmod +x ./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 --only main
RUN groupadd --gid 1000 appuser \ RUN groupadd --gid 1000 appuser \
&& useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser \ && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser \
&& chown -R appuser:appuser /home/excel-project && chown -R appuser:appuser /home/excel-project
@@ -39,3 +63,49 @@ RUN groupadd --gid 1000 appuser \
USER appuser USER appuser
ENTRYPOINT ["./entrypoint.sh", "--prod"] ENTRYPOINT ["./entrypoint.sh", "--prod"]
# --- Stage 1: Python daemon builder ---
FROM python:3.14-slim AS worker-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,daemon \
&& pip uninstall -y poetry poetry-core poetry-plugin-export
# --- Stage 2: Python daemons ---
FROM python:3.14-slim AS daemon
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=worker-builder /opt/venv /opt/venv
COPY pyproject.toml poetry.lock alembic.ini daemon_run.py ./
COPY src/ ./src/
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 ["python","-u", "daemon_run.py", "--all"]
+5
View File
@@ -21,6 +21,11 @@ if [ -z "$MODE" ]; then
exit 1 exit 1
fi fi
if ! alembic upgrade head; then
echo "Migration failed"
exit 1
fi
if [ "$MODE" = "dev" ]; then if [ "$MODE" = "dev" ]; then
WORKERS="${WORKERS:-1}" WORKERS="${WORKERS:-1}"
exec gunicorn \ exec gunicorn \
+26 -5
View File
@@ -1,23 +1,38 @@
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
# import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from src.cache.redis_client import redis_client
from src.database.users.crud import Seed
from src.logging.http_logger import LoggingMiddleware, ProcessingTimeMiddleware
from src.logging.logger import LogWriter
from src.messaging.rabbitmq_client import rabbitmq_client
from src.messaging.topology_setup import apply_topology
from src.models.rabbitmq_models.email import email_topology
from src.web.protected_routes.auth_routes import router as protected_router from src.web.protected_routes.auth_routes import router as protected_router
from src.web.protected_routes.protected_user_action_routes import ( from src.web.protected_routes.protected_user_action_routes import (
router as protected_user_action_routes, router as protected_user_action_routes,
) )
writer=LogWriter()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
create_dirs() create_dirs()
channel = await rabbitmq_client.get_channel()
await apply_topology(channel, email_topology)
await create_first_user()
writer_task=asyncio.create_task(writer.log_writer())
yield yield
print("shutting down") writer_task.cancel()
await redis_client.close()
await rabbitmq_client.close()
app=FastAPI(root_path="/", lifespan=lifespan) 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_router)
app.include_router(router=protected_user_action_routes) app.include_router(router=protected_user_action_routes)
@@ -27,12 +42,18 @@ async def root()->dict:
return {"root":"hello, this is root"} return {"root":"hello, this is root"}
def create_dirs(): def create_dirs()->None:
dirs_to_create=("./DB", dirs_to_create=("./DB",
"./uploads/upload", "./uploads/upload",
"./uploads/upload_bad", "./uploads/upload_bad",
"./uploads/upload_finished") "./uploads/upload_finished",
"./logs")
for x in dirs_to_create: for x in dirs_to_create:
Path(x).mkdir(parents=True, exist_ok=True) Path(x).mkdir(parents=True, exist_ok=True)
async def create_first_user()->None:
seed=Seed()
await seed.seed()
+11 -5
View File
@@ -3,6 +3,8 @@ ALLURE:=.venv/allure-2.44.0/bin/allure #linux&macos
#ALLURE=.venv\allure-2.44.0\bin\allure #Windows #ALLURE=.venv\allure-2.44.0\bin\allure #Windows
NUM_DOWN ?= 1 NUM_DOWN ?= 1
#make run-dev BUILD=--build
BUILD ?=
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
@@ -15,23 +17,27 @@ help:
## ##
.PHONY: run .PHONY: run
run: ## Run dev local application run: ## Run dev local application
${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile local 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 local down
.PHONY: run-dev .PHONY: run-dev
run-dev: ## Run dev application run-dev: ## Run dev application
docker compose -f docker/compose-dev.yaml --profile dev up -d docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile dev up -d ${BUILD}
.PHONY: run-prod .PHONY: run-prod
run-prod: ## Run prod application run-prod: ## Run prod application
docker compose -f docker/compose-dev.yaml --profile prod up -d docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile prod up -d ${BUILD}
.PHONY: down-dev .PHONY: down-dev
down-dev: ## Down dev application down-dev: ## Down dev application
docker compose -f docker/compose-dev.yaml --profile dev down docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile dev down
.PHONY: down-prod .PHONY: down-prod
down-prod: ## Down prod application down-prod: ## Down prod application
docker compose -f docker/compose-dev.yaml --profile prod down docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile prod down
## ##
## Migration section ## Migration section
Generated
+583 -273
View File
File diff suppressed because it is too large Load Diff
+28 -9
View File
@@ -7,24 +7,30 @@ authors = [
] ]
license = "MH.Dmitrii's project" license = "MH.Dmitrii's project"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13,<4.0"
dependencies = [ dependencies = [
"alembic (>=1.18.5,<2.0.0)", "alembic (>=1.18.5,<2.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[email] (>=2.13.4,<3.0.0)",
"pydantic-settings (>=2.14.2,<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)", "bcrypt (>=5.0.0,<6.0.0)",
"python-jose (>=3.5.0,<4.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)", "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)",
"aio-pika (>=10.0.1,<11.0.0)",
] ]
[tool.poetry.group.web.dependencies]
uvicorn = ">=0.51.0,<0.52.0"
gunicorn = ">=26.0.0,<27.0.0"
fastapi = ">=0.139.1,<0.140.0"
python-multipart = ">=0.0.32,<0.0.33"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = ">=9.1.1,<10.0.0" pytest = ">=9.1.1,<10.0.0"
pytest-cov = ">=7.1.0,<8.0.0" pytest-cov = ">=7.1.0,<8.0.0"
@@ -35,6 +41,10 @@ httpie = ">=3.2.4,<4.0.0"
pytest-asyncio = ">=1.4.0,<2.0.0" pytest-asyncio = ">=1.4.0,<2.0.0"
requests-async = ">=0.2.4,<0.3.0" requests-async = ">=0.2.4,<0.3.0"
[tool.poetry.group.daemon.dependencies]
aiosmtpd = ">=1.4.6,<2.0.0"
jinja2 = ">=3.1.6,<4.0.0"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
@@ -46,10 +56,19 @@ omit = [
"*/database/*", "*/database/*",
"*/errors/*", "*/errors/*",
"__init__.py", "__init__.py",
"*/docker/*" "*/docker/*",
"*/rate_limit.py",
"*/logger.py",
"*/daemons/*",
"*/topology_setup.py",
"*/logging/*",
"*/email_reset.py",
"*/email_welcome.py"
] ]
[tool.coverage.report] [tool.coverage.report]
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
] ]
[tool.ruff.lint]
ignore=["B008"]
+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()
View File
+9
View File
@@ -0,0 +1,9 @@
from abc import ABC, abstractmethod
class BaseDaemon(ABC):
name: str
@abstractmethod
async def run(self) -> None:
...
+19
View File
@@ -0,0 +1,19 @@
from src.daemons.base import BaseDaemon
from src.messaging.consumers.consumers import (
reset_email_consumer,
welcome_email_consumer,
)
class WelcomeEmailDaemon(BaseDaemon):
name = "welcome_email"
async def run(self) -> None:
await welcome_email_consumer.start_consuming()
class ResetEmailDaemon(BaseDaemon):
name = "reset_email"
async def run(self) -> None:
await reset_email_consumer.start_consuming()
+6
View File
@@ -0,0 +1,6 @@
from src.daemons.email_daemons import ResetEmailDaemon, WelcomeEmailDaemon
DAEMONS = {
"welcome_email": WelcomeEmailDaemon,
"reset_email": ResetEmailDaemon,
}
+32
View File
@@ -10,6 +10,7 @@ from src.models.database_models.model import (
engine, engine,
) )
from src.models.pydantic_models.model import UserOutDB from src.models.pydantic_models.model import UserOutDB
from src.service.auth.jwt import HashService
class UsersCrudActions: class UsersCrudActions:
@@ -122,3 +123,34 @@ class UsersCrudActions:
await session.flush() await session.flush()
return UserOutDB.model_validate(user_edit) 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")
-1
View File
@@ -1 +0,0 @@
'''fake data for tests'''
+15
View File
@@ -0,0 +1,15 @@
#logger decorators and middlewares
import logging
from .logger import LoggerDaemon, LoggerDB
sql_logger = logging.getLogger("sqlalchemy.engine")
sql_logger.setLevel(logging.INFO)
sql_logger.addHandler(LoggerDB())
sql_logger.addHandler(logging.StreamHandler())
daemon_logger=logging.getLogger("daemon")
daemon_logger.setLevel(logging.INFO)
daemon_logger.addHandler(LoggerDaemon())
daemon_logger.addHandler(logging.StreamHandler())
+63
View File
@@ -0,0 +1,63 @@
import json
from time import perf_counter
from typing import cast
from uuid import uuid4
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
from src.logging.logger import log_queue, request_id_ctx
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 build_log_line(self, request_id, method, path, status_code, detail, client_ip) -> str:
return f"[{request_id}] [{method}] [{path}] [{status_code}] [{detail}] [{client_ip}]"
async def dispatch(self, request: Request, call_next) -> Response:
request_id=str(uuid4())
request_id_ctx.set(request_id)
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
method = request.method
path=request.url.path
try:
response = await call_next(request)
except Exception as exc: # noqa: BLE001
line=await self.build_log_line(request_id=request_id, method=method, path=path, status_code=500, detail=repr(exc), client_ip=client_ip)
log_queue.put_nowait(("endpoints", line))
return JSONResponse(
status_code=500,
content={"detail": "Internal Server Error", "request_id": request_id}
)
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
line=await self.build_log_line(request_id=request_id, method=method, path=path, status_code=response.status_code, detail=body, client_ip=client_ip)
log_queue.put_nowait(("endpoints", line))
return response
+43
View File
@@ -0,0 +1,43 @@
import asyncio
import logging
from contextvars import ContextVar
from time import gmtime, strftime
import aiofiles
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
message_id_ctx: ContextVar[str] = ContextVar("message_id", default="-")
log_queue=asyncio.Queue()
class LogWriter:
def __init__(self) -> None:
pass
async def log_writer(self)->None:
while True:
log_type, msg=await log_queue.get()
await self._write(msg, log_type)
async def _write(self, msg:str, type: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/{type}_log_{file_time}.txt", "a") as f:
await f.write(f"[{current_time}] {msg}\n")
class LoggerDB(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
rid=request_id_ctx.get()
log_queue.put_nowait(("sql",f"[{rid}] {msg}"))
class LoggerDaemon(logging.Handler):
def emit(self, record: logging.LogRecord)->None:
msg= self.format(record)
mid=message_id_ctx.get()
log_queue.put_nowait(("daemon", f"[{mid}], {msg}"))
+1
View File
@@ -0,0 +1 @@
# rabbitmq code
+105
View File
@@ -0,0 +1,105 @@
import json
import smtplib
from uuid import uuid4
from src.logging import daemon_logger
from src.logging.logger import message_id_ctx
from src.messaging.rabbitmq_client import rabbitmq_client
from src.service.email.email_reset import ResetEmailSender
from src.service.email.email_welcome import DaemonEmailSender
class WelcomeEmailConsumer:
def __init__(self) -> None:
self.channel = None
self.queue = None
self.daemon=DaemonEmailSender()
async def setup(self) -> None:
self.channel = await rabbitmq_client.get_channel()
await self.channel.set_qos(prefetch_count=10)
self.queue = await self.channel.get_queue("queue_welcome_email")
async def process_message(self, message) -> None:
message_id_ctx.set(str(uuid4()))
async with message.process(ignore_processed=True):
data = json.loads(message.body)
daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}")
try:
await self.daemon.send_email(data.get("email"))
except (
smtplib.SMTPServerDisconnected,
smtplib.SMTPConnectError,
TimeoutError,
ConnectionRefusedError,
) as exc:
daemon_logger.exception(f"transient error, retrying: {exc!r}")
await message.nack(requeue=True)
except Exception as exc: # noqa: BLE001
daemon_logger.exception(f"permanent error, sending to DLQ: {exc!r}")
await message.nack(requeue=False)
async def start_consuming(self)->None:
if self.queue is None:
await self.setup()
queue = self.queue
if queue is None:
raise RuntimeError("Failed to set up RabbitMQ queue")
async with queue.iterator() as queue_iter:
async for message in queue_iter:
await self.process_message(message)
class ResetEmailConsumer:
def __init__(self) -> None:
self.channel = None
self.queue = None
self.daemon = ResetEmailSender()
async def setup(self) -> None:
self.channel = await rabbitmq_client.get_channel()
await self.channel.set_qos(prefetch_count=10)
self.queue = await self.channel.get_queue("queue_reset_email")
async def process_message(self, message) -> None:
message_id_ctx.set(str(uuid4()))
async with message.process(ignore_processed=True):
data = json.loads(message.body)
daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}")
try:
await self.daemon.send_email(data.get("email"), data.get("temp_password"))
except (
smtplib.SMTPServerDisconnected,
smtplib.SMTPConnectError,
TimeoutError,
ConnectionRefusedError,
) as exc:
daemon_logger.exception(f"transient error, retrying: {exc!r}")
await message.nack(requeue=True)
except Exception as exc: # noqa: BLE001
daemon_logger.exception(f"permanent error, sending to DLQ: {exc!r}")
await message.nack(requeue=False)
async def start_consuming(self)->None:
if self.queue is None:
await self.setup()
queue = self.queue
if queue is None:
raise RuntimeError("Failed to set up RabbitMQ queue")
async with queue.iterator() as queue_iter:
async for message in queue_iter:
await self.process_message(message)
welcome_email_consumer=WelcomeEmailConsumer()
reset_email_consumer=ResetEmailConsumer()
+41
View File
@@ -0,0 +1,41 @@
import json
import aio_pika
from src.messaging.rabbitmq_client import rabbitmq_client
class EmailProducer:
def __init__(self) -> None:
self.channel = None
self.exchange = None
async def setup(self) -> None:
self.channel = await rabbitmq_client.get_channel()
self.exchange = await self.channel.get_exchange("email")
async def send_welcome_email(self, email:str)->None:
await self._publish({"email":email}, routing_key="email.welcome")
async def send_reset_email(self, email: str, temp_password: str) -> None:
await self._publish({"email": email, "temp_password": temp_password}, routing_key="email.reset")
async def _publish(self,data:dict, routing_key:str)->None:
if self.exchange is None:
await self.setup()
if self.exchange is None:
raise RuntimeError("Failed to set up RabbitMQ exchange")
message = aio_pika.Message(
body=json.dumps(data).encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
)
await self.exchange.publish(message=message, routing_key=routing_key)
email_producer=EmailProducer()
+47
View File
@@ -0,0 +1,47 @@
import asyncio
import aio_pika
from aio_pika.abc import AbstractChannel, AbstractRobustConnection
from src.models.configs_read.env import env_settings
class RabbitMQClient:
def __init__(self) -> None:
self.connection: AbstractRobustConnection | None = None
self.channel: AbstractChannel | None = None
async def connect(self) -> None:
for attempt in range(1,6):
try:
if self.connection is None or self.connection.is_closed:
self.connection = await aio_pika.connect_robust(
host=env_settings.RABBITMQ_HOST,
port=env_settings.RABBITMQ_PORT,
login=env_settings.RABBITMQ_LOGIN,
password=env_settings.RABBITMQ_PASSWORD,
)
break
except Exception as exc:
if attempt==5:
raise
print(f"RabbitMQ not ready yet (attempt {attempt}/5): {exc!r}, retrying...")
await asyncio.sleep(2**attempt)
if self.connection is None:
raise RuntimeError("Failed to esablish RabbitMQ connection. Check the server!")
if self.channel is None or self.channel.is_closed:
self.channel = await self.connection.channel()
async def get_channel(self) -> AbstractChannel:
await self.connect()
if self.channel is None:
raise RuntimeError("Failed to establish RabbitMQ channel")
return self.channel
async def close(self) -> None:
if self.channel and not self.channel.is_closed:
await self.channel.close()
if self.connection and not self.connection.is_closed:
await self.connection.close()
rabbitmq_client = RabbitMQClient()
+22
View File
@@ -0,0 +1,22 @@
from src.models.rabbitmq_models.email import Topology
async def apply_topology(channel, topology: Topology) -> None:
dlx = await channel.declare_exchange("email.dlx", "direct")
for exch_spec in topology.exchanges:
exchange = await channel.declare_exchange(exch_spec.name, exch_spec.type)
for q in exch_spec.queues:
queue = await channel.declare_queue(
q.name,
durable=True,
arguments={
"x-queue-type": "quorum",
"x-dead-letter-exchange": "email.dlx",
},
)
await queue.bind(exchange, routing_key=q.routing_key)
if q.dlx_queue:
dlq = await channel.declare_queue(q.dlx_queue, durable=True)
await dlq.bind(dlx, routing_key=q.routing_key)
+1 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import create_engine, pool
from src.models.database_models import Model from src.models.database_models import Model
from src.models.database_models.model import engine from src.models.database_models.model import engine
sync_url = engine.url.render_as_string(hide_password=False).replace("+aiosqlite", "") sync_url = engine.url.render_as_string(hide_password=False).replace("+asyncpg", "")
sync_engine = create_engine(sync_url, poolclass=pool.NullPool) sync_engine = create_engine(sync_url, poolclass=pool.NullPool)
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
-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 ###
-46
View File
@@ -1,46 +0,0 @@
"""empty message
Revision ID: ebe80cd28822
Revises: 74814eb1b7f8
Create Date: 2026-08-21 13:52:49.788041
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ebe80cd28822'
down_revision: Union[str, Sequence[str], None] = '74814eb1b7f8'
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('user_direct_permissions', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('fk_user_direct_permissions_user_id_users'), type_='foreignkey')
batch_op.create_foreign_key(batch_op.f('fk_user_direct_permissions_user_id_users'), 'users', ['user_id'], ['id'], ondelete='CASCADE')
with op.batch_alter_table('user_group', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('fk_user_group_user_id_users'), type_='foreignkey')
batch_op.create_foreign_key(batch_op.f('fk_user_group_user_id_users'), 'users', ['user_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_group', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('fk_user_group_user_id_users'), type_='foreignkey')
batch_op.create_foreign_key(batch_op.f('fk_user_group_user_id_users'), 'users', ['user_id'], ['id'])
with op.batch_alter_table('user_direct_permissions', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('fk_user_direct_permissions_user_id_users'), type_='foreignkey')
batch_op.create_foreign_key(batch_op.f('fk_user_direct_permissions_user_id_users'), 'users', ['user_id'], ['id'])
# ### end Alembic commands ###
@@ -1,18 +1,18 @@
"""empty message """empty message
Revision ID: 5e60c8fbc553 Revision ID: f4018d3509eb
Revises: 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 typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.sql import text
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '5e60c8fbc553' revision: str = 'f4018d3509eb'
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: 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: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### 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', op.create_table('groups_of_permissions',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('group', sa.String(length=255), 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: 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) 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', op.create_table('permissions',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('permission', sa.String(length=255), 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: with op.batch_alter_table('permissions', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_permissions_id'), ['id'], unique=False) 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', op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False), 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_last_name'), ['last_name'], unique=False)
batch_op.create_index(batch_op.f('ix_users_middle_name'), ['middle_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', op.create_table('group_permission',
sa.Column('group_id', sa.Integer(), nullable=False), sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('permission_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')) sa.PrimaryKeyConstraint('group_id', 'permission_id', name=op.f('pk_group_permission'))
) )
op.create_table('refresh_tokens', 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('user_id', sa.Uuid(), nullable=False),
sa.Column('token_hash', sa.String(length=255), nullable=False), sa.Column('token_hash', sa.String(length=255), nullable=False),
sa.Column('device_info', 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('ip_address', sa.String(length=45), nullable=False),
sa.Column('is_revoked', sa.Boolean(), nullable=False), sa.Column('is_revoked', sa.Boolean(), nullable=False),
sa.Column('expires_at', sa.TIMESTAMP(), nullable=False), sa.Column('expires_at', sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('replaced_by', sa.Integer(), nullable=True), 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(['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.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.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_id'), ['id'], unique=False)
batch_op.create_index(batch_op.f('ix_refresh_tokens_user_id'), ['user_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', op.create_table('user_direct_permissions',
sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('permission_id', sa.Integer(), 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(['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')) sa.PrimaryKeyConstraint('user_id', 'permission_id', name=op.f('pk_user_direct_permissions'))
) )
op.create_table('user_group', op.create_table('user_group',
sa.Column('user_id', sa.Uuid(), nullable=False), sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('permission_group_id', sa.Integer(), 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(['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')) sa.PrimaryKeyConstraint('user_id', 'permission_group_id', name=op.f('pk_user_group'))
) )
# ### end Alembic commands ### # ### end Alembic commands ###
@@ -114,12 +182,28 @@ def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user_group') op.drop_table('user_group')
op.drop_table('user_direct_permissions') 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: 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_user_id'))
batch_op.drop_index(batch_op.f('ix_refresh_tokens_id')) batch_op.drop_index(batch_op.f('ix_refresh_tokens_id'))
op.drop_table('refresh_tokens') op.drop_table('refresh_tokens')
op.drop_table('group_permission') 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: 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_middle_name'))
batch_op.drop_index(batch_op.f('ix_users_last_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')) batch_op.drop_index(batch_op.f('ix_users_email'))
op.drop_table('users') 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: with op.batch_alter_table('permissions', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_permissions_id')) batch_op.drop_index(batch_op.f('ix_permissions_id'))
op.drop_table('permissions') 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: 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')) batch_op.drop_index(batch_op.f('ix_groups_of_permissions_id'))
op.drop_table('groups_of_permissions') 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 ### # ### end Alembic commands ###
+28
View File
@@ -0,0 +1,28 @@
from pydantic_settings import (
BaseSettings,
JsonConfigSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from src.models.configs_read.env import Base
class DaemonsConfig(Base):
daemons: list[str]
model_config = SettingsConfigDict(json_file="configs/daemons.json")
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (JsonConfigSettingsSource(settings_cls),)
daemons_config = DaemonsConfig() # type: ignore[call-arg]
+23
View File
@@ -10,6 +10,29 @@ class Env(Base):
ACCESS_TOKEN_EXPIRE_MINUTES:int ACCESS_TOKEN_EXPIRE_MINUTES:int
REFRESH_TOKEN_EXPIRE_DAYS: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
RABBITMQ_PASSWORD:str
RABBITMQ_LOGIN:str
RABBITMQ_HOST:str
RABBITMQ_PORT:int
RABBITMQ_PORT_UI:int
EMAIL_PORT:int
SMTP_SERVER:str
EMAIL_LOGIN:str
EMAIL_PASSWORD:str
PROD_MODE:bool
model_config=SettingsConfigDict(env_file="configs/.env", extra=None) model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
env_settings=Env() # type: ignore[call-arg] env_settings=Env() # type: ignore[call-arg]
+8 -1
View File
@@ -15,7 +15,14 @@ from sqlalchemy import (
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
engine = create_async_engine("sqlite+aiosqlite:///DB/database.db", echo=True) 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''' '''remember as a boilerplate, or just cp/pst'''
class Model(DeclarativeBase): class Model(DeclarativeBase):
+24
View File
@@ -0,0 +1,24 @@
from pydantic import BaseModel
class QueueSpec(BaseModel):
name:str
routing_key:str
dlx_queue:str|None = None
class ExchangeSpec(BaseModel):
name:str
type:str = "topic"
queues: list[QueueSpec]
class Topology(BaseModel):
exchanges:list[ExchangeSpec]
email_topology=Topology(exchanges=[
ExchangeSpec(name="email", queues=[
QueueSpec(name="queue_welcome_email", routing_key="email.welcome", dlx_queue="queue_welcome_email.dlq"),
QueueSpec(name="queue_reset_email", routing_key="email.reset", dlx_queue="queue_reset_email.dlq"),
]),
])
+25 -6
View File
@@ -4,6 +4,7 @@ from uuid import UUID
from fastapi import Request from fastapi import Request
from src.cache.redis_client import redis_client
from src.database.auth.refresh_tokens import JwtCrudActions from src.database.auth.refresh_tokens import JwtCrudActions
from src.database.users.crud import UsersCrudActions from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors from src.errors.http_errors.errors import Errors
@@ -52,7 +53,12 @@ class CurrentUserService:
async def get_current_user(self, token:str, *permissions: str)->UserOut: async def get_current_user(self, token:str, *permissions: str)->UserOut:
payload= await self.jwt_service.jwt_decode(token) payload= await self.jwt_service.jwt_decode(token)
sub=payload.get("sub") 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: try:
sub=UUID(sub) sub=UUID(sub)
@@ -167,21 +173,34 @@ class CurrentUserService:
async def logout(self, refresh_token:str)->bool: async def logout(self, refresh_token:str, access_token:str)->bool:
'''decode current refresh token''' '''decode current refresh token'''
payload=await self.jwt_service.jwt_decode(refresh_token) payload_refresh=await self.jwt_service.jwt_decode(refresh_token)
if (jti:=payload.get("jti")) is None: '''decode current access token'''
raise self.error.credentials_error(detail="Jwt token is incorrect") 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")
try: try:
jti=UUID(jti) jti_refresh=UUID(jti_refresh)
jti_access=UUID(jti_access)
except (ValueError, TypeError, AttributeError) as e: except (ValueError, TypeError, AttributeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e raise self.error.credentials_error(detail="Jwt token is incorrect") from e
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''' '''logout by assigning revoked flag'''
if await self.jwt_db_actions.logout(jti): if await self.jwt_db_actions.logout(jti_refresh):
return True return True
else: else:
raise self.error.not_found_error(detail="Refresh Token Not Found") raise self.error.not_found_error(detail="Refresh Token Not Found")
+4 -1
View File
@@ -35,14 +35,17 @@ class JwtService:
if not (data.get("sub")) or data.get("sub") == "": if not (data.get("sub")) or data.get("sub") == "":
raise self.error.credentials_error(detail="Jwt token is incorrect") raise self.error.credentials_error(detail="Jwt token is incorrect")
async def create_access_token(self, data:dict)->str: async def create_access_token(self, data:dict)->str:
user_info=data.copy() user_info=data.copy()
jti=str(uuid4())
await self._validate_sub(user_info) await self._validate_sub(user_info)
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES), user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"token_type":"access"}) "token_type":"access",
"jti":jti})
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM) return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
+37
View File
@@ -0,0 +1,37 @@
import smtplib
import ssl
from email.message import EmailMessage
from src.models.configs_read.env import env_settings
from src.service.email.jinja_env import jinja_env
class ResetEmailSender:
async def send_email(self, target_email: str, temp_password: str) -> None:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(
env_settings.SMTP_SERVER, env_settings.EMAIL_PORT, context=context
) as server:
server.login(env_settings.EMAIL_LOGIN, env_settings.EMAIL_PASSWORD)
msg = await self.build_email_message(target_email, temp_password)
server.send_message(msg)
async def build_email_message(self, target_email: str, temp_password: str) -> EmailMessage:
template = jinja_env.get_template("reset.html")
html_body = template.render(temp_password=temp_password)
text_body = (
"Пароль от вашей учётной записи в «The DisExcel» был сброшен.\n\n"
f"Временный пароль: {temp_password}\n\n"
"Рекомендуем сменить его на свой сразу после входа в аккаунт. "
"Если вы не запрашивали сброс пароля, срочно свяжитесь с поддержкой."
)
msg = EmailMessage()
msg["to"] = target_email
msg["from"] = env_settings.EMAIL_LOGIN
msg["subject"] = "Пароль сброшен"
msg.set_content(text_body)
msg.add_alternative(html_body, subtype="html")
return msg
+40
View File
@@ -0,0 +1,40 @@
import smtplib
import ssl
from email.message import EmailMessage
from src.models.configs_read.env import env_settings
from src.service.email.jinja_env import jinja_env
class DaemonEmailSender:
def __init__(self) -> None:
pass
async def send_email(self, target_email:str)->None:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(
env_settings.SMTP_SERVER, env_settings.EMAIL_PORT, context=context
) as server:
server.login(env_settings.EMAIL_LOGIN, env_settings.EMAIL_PASSWORD)
msg=await self.build_email_message(target_email)
server.send_message(msg)
async def build_email_message(self, target_email: str) -> EmailMessage:
template = jinja_env.get_template("welcome.html")
html_body = template.render(name=target_email)
text_body = (
f"Добро пожаловать, {target_email}!\n\n"
"Спасибо за регистрацию на проекте «The DisExcel». "
"Мы рады, что вы с нами — учётная запись уже готова к работе.\n\n"
"Если вы не регистрировались на The DisExcel, просто проигнорируйте это письмо."
)
msg = EmailMessage()
msg["to"] = target_email
msg["from"] = env_settings.EMAIL_LOGIN
msg["subject"] = "Добро пожаловать"
msg.set_content(text_body)
msg.add_alternative(html_body, subtype="html")
return msg
+6
View File
@@ -0,0 +1,6 @@
from jinja2 import Environment, FileSystemLoader
jinja_env = Environment(
loader=FileSystemLoader("src/service/email/templates"),
autoescape=True,
)
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Пароль сброшен</title>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding:40px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<tr>
<td style="background-color:#dc2626; padding:32px 40px; text-align:center;">
<h1 style="margin:0; color:#ffffff; font-size:22px; font-weight:600;">The DisExcel</h1>
</td>
</tr>
<tr>
<td style="padding:40px;">
<h2 style="margin:0 0 16px 0; color:#111827; font-size:20px;">Пароль был сброшен</h2>
<p style="margin:0 0 24px 0; color:#4b5563; font-size:15px; line-height:1.6;">
Пароль от вашей учётной записи в «The DisExcel» был сброшен. Ниже — временный пароль для входа. Рекомендуем сменить его на свой сразу после входа в аккаунт.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb; border:1px solid #e5e7eb; border-radius:8px;">
<tr>
<td style="padding:20px; text-align:center;">
<span style="display:block; margin:0 0 4px 0; color:#9ca3af; font-size:12px; text-transform:uppercase; letter-spacing:0.05em;">Новый пароль</span>
<span style="display:inline-block; font-family: 'Courier New', monospace; font-size:20px; font-weight:700; color:#111827; letter-spacing:0.05em;">{{ temp_password }}</span>
</td>
</tr>
</table>
<p style="margin:24px 0 0 0; color:#9ca3af; font-size:13px; line-height:1.5;">
Если вы не запрашивали сброс пароля, срочно свяжитесь с поддержкой — возможно, кто-то получил доступ к вашей учётной записи.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 40px; background-color:#f9fafb; border-top:1px solid #e5e7eb;">
<p style="margin:0; color:#9ca3af; font-size:12px; line-height:1.5;">
Это автоматическое уведомление от The DisExcel. Не пересылайте это письмо никому.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Добро пожаловать</title>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding:40px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<tr>
<td style="background-color:#4f46e5; padding:32px 40px; text-align:center;">
<h1 style="margin:0; color:#ffffff; font-size:22px; font-weight:600;">The DisExcel</h1>
</td>
</tr>
<tr>
<td style="padding:40px;">
<h2 style="margin:0 0 16px 0; color:#111827; font-size:20px;">Добро пожаловать, {{ name }}</h2>
<p style="margin:0 0 24px 0; color:#4b5563; font-size:15px; line-height:1.6;">
Спасибо за регистрацию на проекте «The DisExcel». Мы рады, что вы с нами — учётная запись уже готова к работе.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" align="center" style="margin:0 auto;">
<tr>
<td style="border-radius:8px; background-color:#4f46e5;">
<a href="#" style="display:inline-block; padding:12px 28px; color:#ffffff; font-size:14px; font-weight:600; text-decoration:none;">
Перейти в аккаунт
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:24px 40px; background-color:#f9fafb; border-top:1px solid #e5e7eb;">
<p style="margin:0; color:#9ca3af; font-size:12px; line-height:1.5;">
Если вы не регистрировались на The DisExcel, просто проигнорируйте это письмо.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+30 -10
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Cookie, Depends, Request, Response from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm 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.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import CurrentUserService, auth_service from src.service.auth.auth import CurrentUserService, auth_service
@@ -12,22 +13,32 @@ oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/pro
def require_permissions(*permissions: str): #permissions check dependency def require_permissions(*permissions: str): #permissions check dependency
async def checker( async def checker(
token: str = Depends(oauth2_schema), token: str = Depends(oauth2_schema),
auth: CurrentUserService = Depends(auth_service), #noqa: B008 auth: CurrentUserService = Depends(auth_service),
) -> UserOut: ) -> UserOut:
return UserOut.model_validate(await auth.get_current_user(token, *permissions)) return UserOut.model_validate(await auth.get_current_user(token, *permissions))
return checker return checker
@router.post("/token") @router.post("/token")
async def get_access_token(request: Request,response:Response,auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008 async def get_access_token(request: Request,
response:Response,
auth:CurrentUserService=Depends(auth_service),
form_data:OAuth2PasswordRequestForm=Depends(),
)->dict:
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request) 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( response.set_cookie(
key="refresh_token", key="refresh_token",
value=refresh_token, value=refresh_token,
httponly=True, httponly=True,
secure=True, secure=env_settings.PROD_MODE,
samesite="strict", samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
) )
@@ -35,7 +46,10 @@ async def get_access_token(request: Request,response:Response,auth:CurrentUserSe
@router.post("/refresh") @router.post("/refresh")
async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie(), auth:CurrentUserService=Depends(auth_service))->dict: # noqa: B008 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) access_token, refresh_token= await auth.refresh_token(refresh_token=refresh_token,request=request)
@@ -43,7 +57,7 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st
key="refresh_token", key="refresh_token",
value=refresh_token, value=refresh_token,
httponly=True, httponly=True,
secure=True, secure=env_settings.PROD_MODE,
samesite="strict", samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
) )
@@ -51,10 +65,16 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st
return {"access_token":access_token, "token_type": "bearer"} return {"access_token":access_token, "token_type": "bearer"}
@router.get("/logout") @router.get("/logout")
async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(require_permissions()))->bool: # noqa: B008 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") response.delete_cookie("refresh_token")
return await auth.logout(refresh_token) return await auth.logout(refresh_token, access_token)
@router.get("") @router.get("")
async def protected(current_user:UserOut=Depends(require_permissions()))->dict: # noqa: B008 async def protected(current_user:UserOut=Depends(require_permissions()))->dict:
return {"protected router": "Hello, this is a protected router"} return {"protected router": "Hello, this is a protected router"}
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from src.messaging.producers.producers import email_producer
from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate
from src.service.users_crud.users_crud import CrudService, crud_service from src.service.users_crud.users_crud import CrudService, crud_service
from src.web.protected_routes.auth_routes import require_permissions from src.web.protected_routes.auth_routes import require_permissions
@@ -7,22 +8,23 @@ from src.web.protected_routes.auth_routes import require_permissions
router=APIRouter(prefix="/user") router=APIRouter(prefix="/user")
@router.get("/get_by_email") @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: # noqa: B008 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) return await crud.get_user_by_email(email)
@router.post("/create_user") @router.post("/create_user")
async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008 async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
await email_producer.send_welcome_email(current_user.email)
return await crud.create_user(data) return await crud.create_user(data)
@router.post("/delete_user_soft") @router.post("/delete_user_soft")
async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 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) return await crud.delete_user_soft(email)
@router.post("/delete_user_hard") @router.post("/delete_user_hard")
async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008 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) return await crud.delete_user_hard(email, current_user)
@router.patch("/patch_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: #noqa: B008 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) return await crud.update_user(email, data)
+16
View File
@@ -1,6 +1,9 @@
import pytest_asyncio 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.auth.jwt import HashService, JwtService
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -12,3 +15,16 @@ async def jwt_service()->JwtService:
async def hash_service()->HashService: async def hash_service()->HashService:
hash_service=HashService() hash_service=HashService()
return hash_service 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()
+48 -8
View File
@@ -4,7 +4,7 @@ import requests_async
from httpx import HTTPStatusError from httpx import HTTPStatusError
class TestAuth: class TestPermissions:
async def test_get_access_token_positive(self, target_url:str)->None: async def test_get_access_token_positive(self, target_url:str)->None:
@@ -12,6 +12,7 @@ class TestAuth:
response = await requests_async.post(f"{target_url}/protected/token") response = await requests_async.post(f"{target_url}/protected/token")
response.raise_for_status() response.raise_for_status()
assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401 assert exc_info.value.response.status_code != 401
@@ -24,7 +25,7 @@ class TestAuth:
assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401 assert exc_info.value.response.status_code != 401
async def test_get_root_positive(self, target_url:str)->None: async def test_get_root_unauthorized(self, target_url:str)->None:
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info: with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
@@ -33,14 +34,53 @@ class TestAuth:
assert exc_info.value.response.status_code != 403 assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code == 401 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] @pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info: async def test_get_logout_positive(self, test_user_fixture, target_url: str) -> None:
session = test_user_fixture[0]
with allure.step("get_root"):
response = await session.get(f"{target_url}/protected/logout") response = await session.get(f"{target_url}/protected/logout")
response.raise_for_status() response.raise_for_status()
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401 assert response.status_code == 200
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
+10 -9
View File
@@ -1,22 +1,23 @@
import pytest_asyncio import pytest_asyncio
from fastapi import Request from fastapi import Request
from src.cache.redis_client import RedisClient
from src.service.auth.auth import CurrentUserService from src.service.auth.auth import CurrentUserService
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def current_user_service()->CurrentUserService: async def current_user_service(monkeypatch):
current_user_service=CurrentUserService()
return current_user_service test_redis = RedisClient()
monkeypatch.setattr("src.service.auth.auth.redis_client", test_redis)
service = CurrentUserService()
yield service
await test_redis.aclose()
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def requests(mocker): async def requests(mocker):
fake_request = mocker.MagicMock(spec=Request) fake_request = mocker.MagicMock(spec=Request)
fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"} fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"}
return fake_request return fake_request
@pytest_asyncio.fixture
async def crud_service()->CrudService:
crud_service=CrudService()
return crud_service
+6 -5
View File
@@ -118,7 +118,8 @@ class TestAuth:
with allure.step("create fake refresh token"): with allure.step("create fake refresh token"):
token=await jwt_service.create_refresh_token({"sub":str(uuid4())}) 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"): with allure.step("patching db call functions"):
@@ -126,7 +127,7 @@ class TestAuth:
with allure.step("test logout with fake data"): with allure.step("test logout with fake data"):
status=await current_user_service.logout(token[0]) status=await current_user_service.logout(refresh_token[0], access_token)
assert status is True assert status is True
@pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[ @pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[
@@ -148,11 +149,11 @@ class TestAuth:
with allure.step("create fake 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)}) 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: with allure.step("test logout with fake data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.logout(token) await current_user_service.logout(refresh_token, access_token)
if expected_exception is HTTPException: if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status assert exc_info.value.status_code==expected_status
-9
View File
@@ -1,9 +0,0 @@
import pytest_asyncio
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture
async def crud_service()->CrudService:
crud_service=CrudService()
return crud_service
+234
View File
@@ -0,0 +1,234 @@
import asyncio
import json
import smtplib
from unittest.mock import AsyncMock, MagicMock
import allure
import pytest
import src.messaging.rabbitmq_client as rabbitmq_client_module
from src.messaging.consumers.consumers import ResetEmailConsumer, WelcomeEmailConsumer
from src.messaging.rabbitmq_client import RabbitMQClient
@pytest.mark.unit
class TestRabbitMQClient:
@pytest.mark.parametrize("",[
pytest.param(id="Connect_retries_then_succeeds")
])
async def test_connect_retries_then_succeeds(self, monkeypatch):
with allure.step("Mocking connection to the rabbitmq"):
fake_connection = MagicMock()
fake_connection.is_closed = False
fake_connection.channel = AsyncMock(return_value=MagicMock())
mock_connect = AsyncMock(
side_effect=[ConnectionError(), ConnectionError(), fake_connection]
)
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
monkeypatch.setattr(asyncio, "sleep", AsyncMock()) # чтобы не ждать реально
with allure.step("Test client connection"):
client = RabbitMQClient()
await client.connect()
assert mock_connect.await_count == 3
assert client.connection is fake_connection
@pytest.mark.parametrize("",[
pytest.param(id="test_connect_gives_up_after_5_attempts")
])
async def test_connect_gives_up_after_5_attempts(self, monkeypatch):
with allure.step("Mocking connection to the rabbitmq"):
mock_connect = AsyncMock(side_effect=ConnectionError("still down"))
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
monkeypatch.setattr(asyncio, "sleep", AsyncMock())
with allure.step("Test client connection"):
client = RabbitMQClient()
with pytest.raises(ConnectionError):
await client.connect()
assert mock_connect.await_count == 5
@pytest.mark.parametrize("",[
pytest.param(id="test_get_channel_negative")
])
async def test_get_channel_negative(self, monkeypatch)->None:
with allure.step("Mocking connection to the rabbitmq"):
fake_connection = MagicMock()
fake_connection.is_closed = False
fake_connection.channel = AsyncMock(return_value=None)
mock_connect = AsyncMock(return_value=fake_connection)
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
with allure.step("Test client connection"):
client = RabbitMQClient()
with pytest.raises(RuntimeError):
await client.get_channel()
@pytest.mark.parametrize("",[
pytest.param(id="test_get_channel_positive")
])
async def test_get_channel_positive(self, monkeypatch)->None:
with allure.step("Mocking connection to the rabbitmq"):
fake_connection = MagicMock()
fake_connection.is_closed = False
fake_connection.channel = AsyncMock(return_value=MagicMock())
mock_connect = AsyncMock(return_value=fake_connection)
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
with allure.step("Test client connection"):
client = RabbitMQClient()
await client.get_channel()
assert client.channel is fake_connection.channel.return_value
def make_fake_message(body: dict, routing_key: str = "email.welcome") -> MagicMock:
"""Строим 'фальшивое' RabbitMQ-сообщение вручную, без реального брокера."""
message = MagicMock()
message.body = json.dumps(body).encode()
message.routing_key = routing_key
message.nack = AsyncMock() # запоминает, вызвали ли nack и с какими аргументами
# message.process(...) в реальном aio_pika — async context manager,
# поэтому нужен объект с асинхронными __aenter__/__aexit__.
process_cm = MagicMock()
process_cm.__aenter__ = AsyncMock(return_value=None)
process_cm.__aexit__ = AsyncMock(return_value=False)
message.process = MagicMock(return_value=process_cm)
return message
@pytest.mark.unit
class TestEmailConsumers:
@pytest.mark.parametrize("",[
pytest.param(id="test_start_consuming_negative")
])
async def test_start_consuming_negative(self, monkeypatch)->None:
with allure.step("Mocking connection to the rabbitmq"):
consumer = WelcomeEmailConsumer()
consumer.setup=AsyncMock()
with allure.step("Test client connection"), pytest.raises(RuntimeError):
await consumer.start_consuming()
@pytest.mark.parametrize("error", [
pytest.param(smtplib.SMTPServerDisconnected(), id="smtplib.SMTPServerDisconnected"),
pytest.param(smtplib.SMTPConnectError(421, "Service not available"), id="smtplib.SMTPConnectError"),
pytest.param(TimeoutError(), id="TimeoutError"),
pytest.param(ConnectionRefusedError(), id="ConnectionRefusedError"),
])
async def test_process_message_transient_errors_requeue(self, error) -> None:
consumer = WelcomeEmailConsumer()
consumer.daemon.send_email = AsyncMock(side_effect=error)
message = make_fake_message({"email": "user@example.com"})
await consumer.process_message(message)
message.nack.assert_awaited_once_with(requeue=True)
@pytest.mark.parametrize("",[
pytest.param(id="test_process_message_positive")
])
async def test_process_message_positive(self) -> None:
with allure.step("Mocking dependencies"):
consumer = WelcomeEmailConsumer()
consumer.daemon.send_email = AsyncMock()
with allure.step("Test process_message"):
message = make_fake_message({"email": "user@example.com"})
await consumer.process_message(message)
consumer.daemon.send_email.assert_awaited_once_with("user@example.com")
message.nack.assert_not_called()
@pytest.mark.unit
class TestResetEmailConsumer:
@pytest.mark.parametrize("",[
pytest.param(id="test_reset_start_consuming_negative")
])
async def test_start_consuming_negative(self, monkeypatch) -> None:
with allure.step("Mocking connection to the rabbitmq"):
consumer = ResetEmailConsumer()
consumer.setup = AsyncMock()
with allure.step("Test client connection"), pytest.raises(RuntimeError):
await consumer.start_consuming()
@pytest.mark.parametrize("error", [
pytest.param(smtplib.SMTPServerDisconnected(), id="smtplib.SMTPServerDisconnected"),
pytest.param(smtplib.SMTPConnectError(421, "Service not available"), id="smtplib.SMTPConnectError"),
pytest.param(TimeoutError(), id="TimeoutError"),
pytest.param(ConnectionRefusedError(), id="ConnectionRefusedError"),
])
async def test_process_message_transient_errors_requeue(self, error) -> None:
consumer = ResetEmailConsumer()
consumer.daemon.send_email = AsyncMock(side_effect=error)
message = make_fake_message(
{"email": "user@example.com", "temp_password": "Temp1234!"},
routing_key="email.reset",
)
await consumer.process_message(message)
message.nack.assert_awaited_once_with(requeue=True)
@pytest.mark.parametrize("",[
pytest.param(id="test_reset_process_message_permanent_error_goes_to_dlq")
])
async def test_process_message_permanent_error_goes_to_dlq(self) -> None:
consumer = ResetEmailConsumer()
consumer.daemon.send_email = AsyncMock(side_effect=ValueError("bad payload"))
message = make_fake_message(
{"email": "user@example.com", "temp_password": "Temp1234!"},
routing_key="email.reset",
)
await consumer.process_message(message)
message.nack.assert_awaited_once_with(requeue=False)
@pytest.mark.parametrize("",[
pytest.param(id="test_reset_process_message_positive")
])
async def test_process_message_positive(self) -> None:
with allure.step("Mocking dependencies"):
consumer = ResetEmailConsumer()
consumer.daemon.send_email = AsyncMock()
with allure.step("Test process_message"):
message = make_fake_message(
{"email": "user@example.com", "temp_password": "Temp1234!"},
routing_key="email.reset",
)
await consumer.process_message(message)
consumer.daemon.send_email.assert_awaited_once_with("user@example.com", "Temp1234!")
message.nack.assert_not_called()