Author SHA1 Message Date
Alena 6fb11a70f2 css dasboard 1.0 2026-11-01 21:13:33 +03:00
MH.Dmitrii acd669a942 css adjustments 2026-11-01 18:57:38 +03:00
MH.Dmitrii e99d21c6d3 standart env 2026-10-06 16:44:06 +03:00
MH.Dmitrii b80fe40852 dev, prod, local environment fixes 2026-10-06 11:21:09 +03:00
MH.Dmitrii 4a7ea33185 makefile add frontend 2026-10-05 09:41:59 +03:00
MH.Dmitrii 7ddc7157b5 permission context 2026-10-05 09:41:31 +03:00
MH.Dmitrii c2fd16e2d8 dashboard 2026-10-04 18:50:34 +03:00
MH.Dmitrii 64234f8639 fix odd if statement 2026-10-02 17:25:12 +03:00
MH.Dmitrii e99aafa36a rememberme, dashboard, logout features 2026-10-02 17:17:54 +03:00
MH.Dmitrii af151f254b fix layout for dashboard, add dashboard, save rememberme state in localstorage 2026-10-01 10:45:51 +03:00
MH.Dmitrii 1fbec06c23 revert the front 2026-09-30 16:18:36 +03:00
MH.Dmitrii bcfd4acfad save tokens localstorage, fix refresh sql query, added permissions 2026-09-30 14:33:20 +03:00
MH.Dmitrii 6a6a95e863 tests for temp_password 2026-09-28 09:56:59 +03:00
MH.Dmitrii 54c670ddec fix navigate from reset page 2026-09-26 19:35:29 +03:00
MH.Dmitrii bcdf2b5154 reset endpoint + frontend reset and login pages add 2026-09-26 19:26:57 +03:00
MH.Dmitrii 489014667c add password reset component 2026-09-24 16:19:38 +03:00
MH.Dmitrii 3e0e82f016 fix css 2026-09-23 15:23:14 +03:00
Alena f5035b0aa2 login page frontend 2026-09-22 21:55:33 +03:00
MH.Dmitrii 608e7ce0f0 frontend part 2026-09-22 15:21:18 +03:00
MH.Dmitrii 8d31078e3d add graphify, update readme, claude md and gitignore 2026-09-18 14:17:07 +03:00
MH.Dmitrii 962f250a8d readme, claude mds update info 2026-09-15 23:20:30 +03:00
MH.Dmitrii 7505f26c98 Merge pull request 'dev' (#16) from dev into main
Reviewed-on: #16
2026-09-15 14:09:05 +00:00
MH.Dmitrii 56e6476484 Merge pull request 'feature/rabbitmq' (#15) from feature/rabbitmq into dev
Reviewed-on: #15
2026-09-15 14:07:15 +00:00
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 99ab6ae3af Merge pull request 'docker multistage build' (#13) from feature/docker into dev
Reviewed-on: #13
2026-09-03 13:15:33 +00:00
128 changed files with 160552 additions and 308 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__"]
}
}
}
+5
View File
@@ -16,3 +16,8 @@ __pycache__/
# OS мусор
.DS_Store
Thumbs.db
# frontend
frontend/node_modules
frontend/dist
frontend/configs/.env
+8 -2
View File
@@ -17,11 +17,16 @@ __pycache__/
.DS_Store
Thumbs.db
#env
# env files
*.env
#db
# DB
*.db
DB/
# rabbitmq
RTMQ/
# logs
logs/
@@ -30,3 +35,4 @@ input/
output/
allure-results/
.coverage
graphify-out/
+318
View File
@@ -0,0 +1,318 @@
# 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.
- **CORS**: `CORSMiddleware` in `main.py` with `allow_origins=[env_settings.
FRONT_URL]` and `allow_credentials=True` (needed for the httpOnly
`refresh_token` cookie — with credentials, `["*"]` is rejected by browsers).
`FRONT_URL` is a required `Env` field with no default, so the backend fails
to start without it in `configs/.env` (e.g. `http://localhost:5173`). It is
added last, after the logging middlewares, so it wraps them and CORS
headers also appear on error responses (401/429).
## 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 (`RateLimit.rate_limit(ip, action, limit=5,
window=60)` — `INCR` + `EXPIRE ... NX` on first attempt, key
`rate_limit:{action}:{ip}`, so each action has its own counter); access-token
revoke blacklist (see above).
- Two rate-limited actions: `"login"` (5/60s) and `"reset"` (3/600s).
The login 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). `/protected/reset`
counts every call.
## 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")`.
- Reset-password flow mirrors welcome: `ResetEmailSender`
(`src/service/email/email_reset.py`) renders `templates/reset.html`
(`{{ temp_password }}`, no longer hardcoded) the same way
`DaemonEmailSender` does `welcome.html`. `ResetEmailConsumer.process_message`
reads both `email` and `temp_password` from the message body and uses the
same transient/permanent classification as `WelcomeEmailConsumer`.
`EmailProducer.send_reset_email(email, temp_password)` takes the password
as a second argument.
- **Reset route**: `POST /protected/reset?email=...` in `auth_routes.py` —
`email` is a plain `str` param, so FastAPI reads it from the **query
string**, not the JSON body. The route rate-limits (3/600s per IP), calls
`CrudService.reset_password(email)` (generates a 16-char temp password via
`HashService.generate_temp_password()` — which is `async` — hashes and
saves it through `update_user`), then publishes `{email, temp_password}` to
`email.reset`. A 404 (unknown email) is swallowed so the response is always
`{"detail": "New password was sent"}` — no user enumeration. Known rough
edge: the DB password is changed *before* the email is sent, so a message
that ends up in the DLQ leaves the user with a changed password and no
email; and anyone who knows an email can reset that account's password.
A token-link flow would avoid both.
## 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.
- `claude` group: `graphifyy` (the `/graphify` Claude Code skill) — dev
tooling only, never referenced by any Dockerfile stage, install with
`poetry install --with claude` when needed locally.
- 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.
## Codebase knowledge graph (graphify)
- `/graphify` (Claude Code skill, `graphifyy` package in the `claude`
Poetry group) builds a navigable knowledge graph of this repo into
`graphify-out/` (`graph.html`, `GRAPH_REPORT.md`, `graph.json`) —
gitignored, regenerable, never commit it.
- Its own interpreter/venv detection defaults to `uv tool`/pipx-style
installs; **this project has neither** — `graphifyy` is installed
straight into the project's own `.venv` via Poetry, so point graphify
at `.venv/bin/python3` directly rather than letting it search for `uv`.
- The sandbox's blanket `**/.env`/`**/.env.*` read-deny blocks graphify
from reading `configs/.env.example` too (not just the real `.env`) —
it shows up as `skipped_sensitive` in detection. This is a sandbox-level
block, not graphify's own sensitive-file heuristic; a real `.env` would
be excluded by graphify anyway, but `.env.example` (no real secrets)
would otherwise be safe to include if the sandbox allowed reading it.
- A `GRAPH HEALTH WARNING` (dangling-endpoint edges, collapsed edges) on
a fresh build most likely means the AST extractor's node-ID format
changed since a previous partial run — re-run with `graphify extract
--force` if it persists across rebuilds; a one-off warning on first
build isn't necessarily a problem.
## Frontend (`frontend/`)
- React 19 + TypeScript + Vite + `react-router-dom` 7, package manager is
**yarn** (`yarn.lock`). Run with `yarn dev` (Vite, default
`http://localhost:5173`). The user writes/edits frontend code themselves —
explain rather than edit unless asked (see memory).
- Layout: `src/features/auth/components/` (`AuthComponent`, `ResetComponent`,
each with its own `.module.css`), `src/pages/` (`LoginPage` at `/`, `ForgotPasswordPage`
at `/forgot-password` — thin wrappers), `src/layouts/AuthLayout` (shared
split-screen shell with the marketing block; takes `children` for the form
on the right), `src/api/` (`token.ts`, `LoginAPI/Auth.ts`,
`ResetAPI/Reset.ts`), `src/types/UserTypes.ts` (mirrors the Pydantic
`UserOut`/`UserCreate`). `index.css` = global reset/font, `App.css` = `#root`
frame.
- **Env**: Vite reads `.env*` from `frontend/configs/` (`envDir: "./configs"`
in `vite.config.ts`), not from `frontend/`. Only `VITE_`-prefixed variables
reach the browser code, via `import.meta.env`. Backend address is
`VITE_BACKEND_URL`, exported as `API_URL` from `src/api/token.ts`. Restart
the dev server after editing `.env`. There is **no** Vite `server.proxy`
(removed on purpose) — the browser calls the backend directly, so backend
CORS (see Auth) must allow the frontend origin.
- **Access token** lives in a module variable in `src/api/token.ts`
(`setAccessToken`/`getAccessToken`), not in `localStorage`; it is lost on
page reload (a `/protected/refresh` call on startup is not implemented
yet). `authFetch(path, init)` adds `Authorization: Bearer` and
`credentials: "include"`. Login (`POST /protected/token`) must be sent as
**form-data** (`URLSearchParams` with `username` = email and `password`),
because the route uses `OAuth2PasswordRequestForm`, and returns
`{access_token, token_type}`; the refresh token arrives as an httpOnly cookie.
- Forms are controlled inputs (`useState`) with hand-written validation:
per-field `errors` state, checked on blur and on submit; `<form noValidate>`
so browser bubbles don't preempt it. Invalid field gets `.inputError`, text
goes in an absolutely positioned `.errorText` under the field. Backend
errors are logged with `console.error` and shown as a short generic message.
- **CSS Modules gotchas**: only *class* selectors are hashed. Bare element
selectors (`form {}`, `input {}`) and `#id` selectors stay global and leak
between components (one module's `form { gap }` restyled the other form).
Scope them under the module's own class (`.wrapper_1 form {}`) — the
hashed ancestor makes the rule local. Classes with hyphens are accessed as
`styles["email-icon"]`. Class names shared between two modules are
independent copies (duplicate the rules, don't rely on sharing).
- React 19 types: `FormEvent` is deprecated; use `SubmitEvent<HTMLFormElement>`.
`<p>` must not contain `<div>` (hydration warning).
- Login currently redirects to `http://localhost:8000/docs` after success
(placeholder — no post-login page yet); `ResetComponent` copies that
placeholder redirect too.
## 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.
- `tests/unit/test_consumers.py` covers `RabbitMQClient`/`WelcomeEmailConsumer`/
`ResetEmailConsumer` entirely with mocks — no real broker involved.
Pattern: `monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", ...)`
patches the module attribute that `connect()` looks up at call time (not
the `rabbitmq_client` singleton's method — that's a bound method, it has
no attribute of its own to patch). `aio_pika.connect_robust`/`asyncio.sleep`
must both be mocked when testing the retry loop, or the test really
sleeps `2**attempt` seconds between attempts. `message.process(...)` is an
async context manager, not a plain awaitable — mocking it needs a
`MagicMock` with `__aenter__`/`__aexit__` set to `AsyncMock`s (see
`make_fake_message()` in that file), not just `AsyncMock()`. When
asserting on what a mocked async method returned, compare against
`mock.return_value` (or a variable captured before assigning it), never
against the mock itself — `some_mock is some_mock.return_value` is never
true, they're two different objects.
+19
View File
@@ -13,4 +13,23 @@ 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"
FRONT_HOST="change_me"
FRONT_PORT=change_me #int
VITE_BACKEND_HOST="change_me"
VITE_BACKEND_PORT="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)
+126 -4
View File
@@ -1,6 +1,7 @@
name: disexcel
services:
backend-dev:
profiles: ["dev"]
image: "${DOCKER_REGISTRY:-local}/excel-dev:${IMAGE_TAG:-local}"
@@ -13,6 +14,10 @@ services:
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
- FRONT_HOST=${FRONT_HOST}
- FRONT_PORT=${FRONT_PORT}
volumes:
- type: bind
source: ../src
@@ -32,6 +37,9 @@ services:
- type: bind
source: ../logs
target: /home/excel-project/logs
- type: bind
source: ../daemon_run.py
target: /home/excel-project/daemon_run.py
networks:
- backend
depends_on:
@@ -39,8 +47,10 @@ services:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
ports:
- "80:8000"
- "${VITE_BACKEND_PORT}:8000"
entrypoint: ["./entrypoint.sh", "--dev"]
backend-prod:
@@ -55,6 +65,10 @@ services:
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
environment:
- DB_HOST=psql
- RABBITMQ_HOST=rabbitmq
- REDIS_HOST=redis
- FRONT_HOST=${FRONT_HOST}
- FRONT_PORT=${FRONT_PORT}
volumes:
- type: bind
source: ../configs
@@ -76,11 +90,39 @@ services:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
ports:
- "80:8000"
- "${VITE_BACKEND_PORT}: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", "db"]
profiles: ["prod", "dev", "local"]
image: postgres:16-alpine
container_name: psql
init: true
@@ -106,7 +148,7 @@ services:
redis:
image: redis:latest
profiles: ["prod", "dev", "redis"]
profiles: ["prod", "dev", "local"]
container_name: redis
init: true
ipc: private
@@ -119,6 +161,86 @@ services:
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
nginx-dev:
image: "${DOCKER_REGISTRY:-local}/excel-frontend-dev:${IMAGE_TAG:-local}"
container_name: frontend-dev
profiles: ["dev"]
build:
dockerfile: ./docker/dockerfile
context: ../
target: frontend_dev
init: true
ipc: private
volumes:
- type: bind
source: ../frontend/src
target: /home/excel-project/src
- type: bind
source: ../frontend/public
target: /home/excel-project/public
- type: bind
source: ../frontend/index.html
target: /home/excel-project/index.html
- type: bind
source: ../frontend/vite.config.ts
target: /home/excel-project/vite.config.ts
- type: bind
source: ../configs
target: /home/configs
ports:
- "${FRONT_PORT}:5173"
networks:
- backend
restart: always
entrypoint: ["yarn", "dev", "--host", "0.0.0.0"]
nginx-prod:
image: "${DOCKER_REGISTRY:-local}/excel-frontend-prod:${IMAGE_TAG:-local}"
container_name: frontend-prod
profiles: ["prod"]
build:
dockerfile: ./docker/dockerfile
context: ../
target: frontend_prod
environment:
- BACKEND_HOST=backend-prod
- BACKEND_PORT=${VITE_BACKEND_PORT}
init: true
ipc: private
ports:
- "${FRONT_PORT}:80"
networks:
- backend
restart: always
networks:
+101 -1
View File
@@ -34,9 +34,10 @@ 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 \
&& poetry install --no-root --no-interaction --only main,web \
&& pip uninstall -y poetry poetry-core poetry-plugin-export
# --- Stage 2: Python Backend prod ---
FROM python:3.14-slim AS prod
@@ -63,3 +64,102 @@ RUN groupadd --gid 1000 appuser \
USER appuser
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"]
# --- Stage 1: Node Frontend builder ---
FROM node:24-alpine AS frontend-builder
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
COPY frontend/package.json frontend/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY frontend/ ./
RUN yarn build
# --- Stage 2: Node Frontend prod ---
FROM nginx:alpine AS frontend_prod
LABEL org.opencontainers.image.title="The-DisExcel-project-prod"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
COPY --from=frontend-builder /home/excel-project/dist /usr/share/nginx/html
COPY docker/nginx/nginx.conf /etc/nginx/templates/default.conf.template
# --- Stage 1: Node Frontend dev ---
FROM node:24-alpine AS frontend_dev
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
COPY frontend/package.json frontend/yarn.lock ./
RUN yarn install --frozen-lockfile
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
location /protected/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
approvedGitRepositories:
- "**"
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 0
View File
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.6",
"globals": "^17.12.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.69.0",
"vite": "^8.3.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
#root {
height: 100%;
}
+21
View File
@@ -0,0 +1,21 @@
import './App.css'
import { Route, Routes } from "react-router-dom";
import { LoginPage } from "./pages/LoginPage";
import { ForgotPasswordPage } from "./pages/ForgotPasswordPage";
import { DashboardPage } from './pages/Dashboard';
import { PermissionProvider } from './contexts/PermissionProvider';
function App() {
return (
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/dashboard" element={
<PermissionProvider>
<DashboardPage />
</PermissionProvider>} />
</Routes>
);
}
export default App
+16
View File
@@ -0,0 +1,16 @@
import { API_URL, setAccessToken } from "../token";
export async function login(email: string, password: string) {
const body = new URLSearchParams({ username: email, password });
const res = await fetch(`${API_URL}/protected/token`, {
method: "POST",
body,
credentials: "include",
});
if (!res.ok){
throw new Error("Неверный email или пароль");
};
const data = await res.json();
setAccessToken(data.access_token);
return data;
}
+13
View File
@@ -0,0 +1,13 @@
import { API_URL, setAccessToken } from "../token";
export async function refreshToken(): Promise<void> {
const response = await fetch(`${API_URL}/protected/refresh`, {
method: "POST",
credentials: "include",
});
if (!response.ok) {
throw new Error("Session update wasn't successful");
};
const data = await response.json();
setAccessToken(data.access_token);
}
+13
View File
@@ -0,0 +1,13 @@
export async function requestPasswordReset(email: string): Promise<void> {
const res = await fetch(`http://localhost:8000/protected/reset?${new URLSearchParams({ email })}`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const body = await res.json().catch(function(){
return null
});
throw new Error(typeof body?.detail === "string" ? body.detail : "Не удалось отправить запрос");
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { UserOut } from "../../types/UserTypes";
import { authFetch } from "../token";
export async function fetchUser(): Promise<UserOut> {
const response = await authFetch("/protected/me");
if (!response.ok) {
throw new Error("Not authenticated");
}
const data = await response.json()
console.log(data)
return data;
}
+57
View File
@@ -0,0 +1,57 @@
import { refreshToken } from "./RefreshAPI/Refresh";
const API_HOST = import.meta.env.VITE_BACKEND_HOST ?? "";
const API_PORT = import.meta.env.VITE_BACKEND_PORT ?? "";
export const API_URL = API_HOST ? `${API_HOST}:${API_PORT}` : "";
let accessToken: string | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null =null;
export function setAccessToken(token: string | null):void{
accessToken = token;
}
export function getAccessToken():string|null {
return accessToken;
}
export async function authFetch(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers);
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
};
return fetch(`${API_URL}${path}`, { ...init, headers, credentials: "include" });
}
export function startRefreshTimer(intervalms:number) {
stopRefreshTimer();
refreshTimer = setInterval(function () {
refreshToken().catch(function() {
});
}, intervalms);
}
export function stopRefreshTimer() {
if (refreshTimer) {
clearInterval(refreshTimer);
}
refreshTimer = null;
}
export async function logout() {
try {
await authFetch(`/protected/logout`);
} catch (err) {
throw new Error("Something went wrong", { cause: err });
}
setAccessToken(null);
stopRefreshTimer();
localStorage.removeItem("rememberMe");
sessionStorage.removeItem("rememberMe");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

@@ -0,0 +1,8 @@
import { createContext } from "react";
export type PermissionsState = {
permissions: Set<string>;
loading: boolean;
};
export const PermissionContext = createContext<PermissionsState | null>(null);
@@ -0,0 +1,31 @@
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { loadPermissions } from "../utils/permissions";
import { PermissionContext } from "./PermissionContext";
export function PermissionProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [permissions, setPermissions] = useState<Set<string>>(new Set());
useEffect(function () {
async function load() {
try {
const response = await loadPermissions();
setPermissions(response);
setLoading(false);
} catch {
navigate("/");
}
}
load();
}, [navigate]);
return (
<PermissionContext value={{ permissions, loading }}>
{children}
</PermissionContext>
);
}
@@ -0,0 +1,157 @@
.wrapper_1 form {
display: flex;
flex-direction: column;
background-color: whitesmoke;
width: 100%;
padding: 10%;
justify-content: space-between;
height: 100%;
}
.Title {
font-size: 32px;
font-weight: bold;
text-align: left;
line-height: 45px;
letter-spacing: 1.2px;
}
.Description{
font-size: medium;
font-size: 20px;
line-height: 30px;
}
.Fields {
line-height: 40px;
font-weight: bold;
font-size: 20px;
}
.wrapper_1 {
background-color: whitesmoke;
width: 40%;
}
.wrapper_1 input {
line-height: 40px;
border-radius: 16px;
border: 1px solid rgba(68, 68, 68, 0.3);
font-size: large;
}
.footer{
display: flex;
padding-top: 10%;
justify-content: space-between;
}
.submitbtn {
display: flex;
justify-content: center;
padding-top: 10%;
padding-bottom: 20px;
}
.submit {
background-color: #087A55;
height: 50px;
width: 100%;
color: white;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: larger;
font-weight: 500;
}
.submit-arrow {
width: 30px;
height: 30px;
}
.email-icon, .pass-icon {
height: 25px;
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
transition: opacity 0.2s ease;
}
.email_field, .pass_field {
position: relative;
width: 100%;
}
.email_field input, .pass_field input {
width: 100%;
padding: 12px 15px 12px 45px;
transition: padding-left 0.2s ease;
}
.pass_field input {
padding-right: 45px;
}
.toggle-password {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.toggle-password svg {
width: 22px;
height: 22px;
}
.email_field input:focus,
.email_field input:not(:placeholder-shown) {
padding-left: 15px;
}
.email_field input:focus + .email-icon,
.email_field input:not(:placeholder-shown) + .email-icon {
opacity: 0;
}
.pass_field input:focus,
.pass_field input:not(:placeholder-shown) {
padding-left: 15px;
}
.pass_field input:focus + .pass-icon,
.pass_field input:not(:placeholder-shown) + .pass-icon {
opacity: 0;
}
.forgot_link {
color: #087A55;
font-weight: 600;
}
.remember-me label {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
accent-color: #087A55;
}
.remember-me label input[type="checkbox"] {
width: 25px;
height: 25px;
border-radius: 10px;
}
.wrapper_1 .inputError {
border-color: #d93025;
background-color: #fdf2f1;
}
.errorText {
color: #d93025;
font-size: 14px;
padding-top: 6px;
position: absolute;
left: 4px;
top: 100%;
}
@@ -0,0 +1,132 @@
import { useState, type SubmitEvent, type ReactElement } from "react";
import { Link, useNavigate } from "react-router-dom";
import styles from "./AuthComponent.module.css";
import { login } from "../../../api/LoginAPI/Auth";
import { persistRememberMe } from "../../../utils/rememberMe";
export function Auth():ReactElement {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const navigate = useNavigate();
function validateEmail(value: string) {
if (!value) return "Введите email";
if (!/^\S+@\S+\.\S+$/.test(value)) return "Некорректный email";
return undefined;
}
function validatePassword(value: string) {
return value ? undefined : "Введите пароль";
}
async function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const next = { email: validateEmail(email), password: validatePassword(password) };
setErrors(next);
if (next.email || next.password) return;
try {
await login(email, password);
persistRememberMe(rememberMe);
navigate("/dashboard");
} catch (err) {
console.error(err);
setErrors({ password: "Неверный email или пароль" });
}
}
return (
<>
<div className={styles.wrapper_1}>
<form noValidate onSubmit={handleSubmit}>
<h1 className={styles.Title}> Добро Пожаловать <br /> в The DisExcel Project </h1>
<p className={styles.Description}>Войдите в систему, чтобы продолжить работу с вашими процессами</p>
<label className={styles.Fields} htmlFor="email">Email</label>
<div className={styles.email_field}>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setErrors((p) => ({ ...p, email: validateEmail(email) }))}
placeholder="Email"
aria-invalid={!!errors.email}
className={errors.email ? styles.inputError : undefined}
/>
<svg className={styles["email-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="5" width="18" height="14" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
<path d="m4.5 7 7.5 6 7.5-6" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{errors.email && <p className={styles.errorText}>{errors.email}</p>}
</div>
<label className={styles.Fields} htmlFor="password">Password</label>
<div className={styles.pass_field}>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onBlur={() => setErrors((p) => ({ ...p, password: validatePassword(password) }))}
placeholder="Password"
aria-invalid={!!errors.password}
className={errors.password ? styles.inputError : undefined}
/>
<svg className={styles["pass-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="4" y="10" width="16" height="11" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
<path d="M8 10V7a4 4 0 0 1 8 0v3" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round"/>
</svg>
<button
type="button"
className={styles["toggle-password"]}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3 3l18 18" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round"/>
<path d="M10.6 5.2A10.6 10.6 0 0 1 12 5c5 0 9 4 10 7-.4 1.1-1.2 2.4-2.3 3.6M6.4 6.4C4.4 7.8 2.9 9.7 2 12c1 3 5 7 10 7 1.4 0 2.7-.3 3.9-.8" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9.9 10a3 3 0 0 0 4.2 4.2" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2 12c1-3 5-7 10-7s9 4 10 7c-1 3-5 7-10 7s-9-4-10-7Z" stroke="#72879A" strokeWidth="1.8" strokeLinejoin="round"/>
<circle cx="12" cy="12" r="3" stroke="#72879A" strokeWidth="1.8"/>
</svg>
)}
</button>
{errors.password && <p className={styles.errorText}>{errors.password}</p>}
</div>
<div className={styles.footer}>
<div className={styles["remember-me"]}>
<label>
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
Запомнить меня
</label>
</div>
<Link to="/forgot-password" className={styles.forgot_link}>Забыли пароль?</Link>
</div>
<div className={styles.submitbtn}>
<button className={styles.submit} type="submit">
Войти
<svg className={styles["submit-arrow"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4 12h14" stroke="white" strokeWidth="2" strokeLinecap="round"/>
<path d="m13 6 6 6-6 6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
</form>
</div>
</>
);
}
@@ -0,0 +1,105 @@
.wrapper_1 form {
display: flex;
flex-direction: column;
background-color: whitesmoke;
width: 100%;
padding: 10%;
justify-content: flex-start;
gap: 24px;
height: 100%;
}
.wrapper_1 input {
line-height: 40px;
border-radius: 16px;
border: 1px solid rgba(68, 68, 68, 0.3);
font-size: large;
}
.Title {
font-size: 32px;
font-weight: bold;
text-align: left;
line-height: 45px;
letter-spacing: 1.2px;
}
.Description {
font-size: 20px;
line-height: 30px;
}
.Fields {
line-height: 40px;
font-weight: bold;
font-size: 20px;
}
.wrapper_1 {
background-color: whitesmoke;
width: 40%;
}
.email_field {
position: relative;
width: 100%;
}
.email_field input {
width: 100%;
padding: 12px 15px 12px 45px;
transition: padding-left 0.2s ease;
}
.email_field input:focus,
.email_field input:not(:placeholder-shown) {
padding-left: 15px;
}
.email_field input:focus + .email-icon,
.email_field input:not(:placeholder-shown) + .email-icon {
opacity: 0;
}
.email-icon {
height: 25px;
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
transition: opacity 0.2s ease;
}
.submitbtn {
display: flex;
justify-content: center;
padding-top: 10%;
padding-bottom: 20px;
}
.submit {
background-color: #087A55;
height: 50px;
width: 100%;
color: white;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: larger;
font-weight: 500;
}
.submit-arrow {
width: 30px;
height: 30px;
}
.footer {
display: flex;
justify-content: center;
}
.login_link {
color: #087A55;
font-weight: 600;
}
.wrapper_1 .inputError {
border-color: #d93025;
background-color: #fdf2f1;
}
.errorText {
color: #d93025;
font-size: 14px;
position: absolute;
top: 100%;
left: 4px;
}
@@ -0,0 +1,74 @@
import styles from "./ResetComponent.module.css";
import { useState, type ReactElement, type SubmitEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { requestPasswordReset } from "../../../api/ResetAPI/Reset";
export function Reset():ReactElement {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [errors, setErrors] = useState<{ email?: string;}>({});
function validateEmail(value: string) {
if (!value) return "Введите email";
if (!/^\S+@\S+\.\S+$/.test(value)) return "Некорректный email";
return undefined;
};
async function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const next = {
email: validateEmail(email),
};
setErrors(next);
if (next.email) return;
try {
await requestPasswordReset(email);
navigate("/");
} catch (err) {
console.error(err);
setErrors({ email: "Ошибка, попробуйте позже" });
}
};
return (
<>
<div className={styles.wrapper_1}>
<form noValidate onSubmit={handleSubmit}>
<h1 className={styles.Title}> Восстановление пароля </h1>
<p className={styles.Description}>Введите Email, и мы отправим на него новый пароль</p>
<label className={styles.Fields} htmlFor="email">Email</label>
<div className={styles.email_field}>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setErrors((p) => ({ ...p, email: validateEmail(email) }))}
placeholder="Email"
aria-invalid={!!errors.email}
className={errors.email ? styles.inputError : undefined}
/>
<svg className={styles["email-icon"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="5" width="18" height="14" rx="2.5" stroke="#72879A" strokeWidth="1.8"/>
<path d="m4.5 7 7.5 6 7.5-6" stroke="#72879A" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
{errors.email && <p className={styles.errorText}>{errors.email}</p>}
</div>
<div className={styles.submitbtn}>
<button className={styles.submit} type="submit">
Отправить новый пароль
<svg className={styles["submit-arrow"]} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4 12h14" stroke="white" strokeWidth="2" strokeLinecap="round"/>
<path d="m13 6 6 6-6 6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
<div className={styles.footer}>
<Link to="/" className={styles.login_link}>Вернуться ко входу</Link>
</div>
</form>
</div>
</>
);
}
@@ -0,0 +1,6 @@
.grid {
display: grid;
justify-content: center;
align-items: center;
width: 100%;
}
@@ -0,0 +1,13 @@
import type { ReactElement } from "react";
import styles from "./Grid.module.css"
export function Grid():ReactElement {
return (
<>
<div className={styles.grid}>
<p>Content is here</p>
</div>
</>
);
}
@@ -0,0 +1,55 @@
.profile {
display: grid;
justify-content: end;
align-items: center;
width: 100%;
height: 100%;
position: relative;
display: flex;
}
.profile_avatar {
display: flex;
align-items: center;
justify-content: center;
background-color: rgb(0, 66, 33);
border-radius: 50%;
width: 40px;
height: 40px;
margin-right: 15px;
}
.menu{
position: absolute;
top:100%;
right: 0;
background: white;
border: 1px solid #ddd;
padding: 8px;
list-style: none;
}
.menu button {
width: 100%;
text-align: left;
}
.profile_button {
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
}
.text_profile {
}
.profile_name {
text-align: start;
margin-bottom: 5px;
font-weight: 500;
}
.profile_post {
color: #444;
font-weight: 300;
}
.arrow_down {
width: 12px;
height: 12px;
margin: 20px;
}
@@ -0,0 +1,45 @@
import { useState, type ReactElement } from "react";
import styles from "./Header.module.css"
import { logout } from "../../../api/token";
import { useNavigate } from "react-router-dom";
export function Header():ReactElement {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
function toggleMenu() {
setOpen(!open);
}
async function handleLogout() {
await logout();
navigate("/");
}
return (
<>
<div className={styles.profile}>
<button onClick={toggleMenu} className={styles.profile_button}>
<div className={styles.profile_avatar}>
ИИ
</div>
<div className={styles.text_profile}>
<div className={styles.profile_name}>
Иван Иванов
</div>
<div className={styles.profile_post}>
Администратор
</div>
</div>
<div className={styles.arrow_down}>
<svg fill="#444" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 30.727 30.727" xml:space="preserve" stroke="#444"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M29.994,10.183L15.363,24.812L0.733,10.184c-0.977-0.978-0.977-2.561,0-3.536c0.977-0.977,2.559-0.976,3.536,0 l11.095,11.093L26.461,6.647c0.977-0.976,2.559-0.976,3.535,0C30.971,7.624,30.971,9.206,29.994,10.183z"></path> </g> </g></svg>
</div>
</button>
{open && (<ul className={styles.menu}>
<li>Settings</li>
<li><button onClick={handleLogout}>logout</button></li>
</ul>
)}
</div>
</>
);
}
@@ -0,0 +1,5 @@
.right_sidebar {
display: grid;
align-content: center;
justify-content: center;
}
@@ -0,0 +1,13 @@
import type { ReactElement } from "react";
import styles from "./Right_sidebar.module.css"
export function Right_sidebar():ReactElement {
return (
<>
<div className={styles.right_sidebar}>
<p>right_sidebar</p>
</div>
</>
);
}
@@ -0,0 +1,36 @@
.wrapper{
display: grid;
grid-template-areas:
"logo logo"
"elements elements";
}
.sidebar_elements{
display: grid;
grid-area: elements;
align-content: start;
gap: 15px;
}
.logo {
grid-area: logo;
display: grid;
grid-template-columns: 30% 1fr;
padding: 20px 20px 20px 5px;
}
.l_img {
width: 100%;
height: 100%;
}
.logo_name {
display: flex;
flex-direction: column;
margin-left: 10px;
justify-content: space-between;
padding: 6% 0;
}
.logo_title {
font-weight: 900;
}
.logo_heading {
color: #444;
font-weight: 500;
}
@@ -0,0 +1,32 @@
import type { ReactElement } from "react";
import styles from "./Sidebar.module.css"
import l_img from "../../../assets/LOGO.png";
export function Sidebar():ReactElement {
return (
<>
<div className={styles.wrapper}>
{/* ссылку на главную */}
<div className={styles.logo}>
<img className={styles.l_img} src={l_img} alt="Logo" />
<p className={styles.logo_name}>
<div className={styles.logo_title}>
The DisExcel
</div>
<div className={styles.logo_heading}>
Project
</div>
</p>
</div>
{/* до сюда */}
<div className={styles.sidebar_elements}>
<button>Dashboard</button>
<button>Excel</button>
<button>Papers</button>
</div>
</div>
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { useContext } from "react";
import { PermissionContext } from "../contexts/PermissionContext";
export function usePermissions() {
const ctx = useContext(PermissionContext);
if (!ctx) throw new Error("usePermissions must be used inside PermissionProvider");
return ctx;
}
+22
View File
@@ -0,0 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap');
*{padding: 0;margin: 0;border: 0;}
*,*:before,*:after{-moz-box-sizing: border-box;-webkit-box-sizing: border-box;box-sizing: border-box;}
:focus,:active{outline: none;}
a:focus,a:active{outline: none;}
nav,footer,header,aside{display: block;}
html,body{
font-family: 'Manrope', sans-serif;
user-select: none;
font-size:100%;line-height:1;-ms-text-size-adjust:100%;-moz-text-size-adjust:100%;-webkit-text-size-adjust:100%;}
input, button, textarea { font: inherit; }
input::-ms-clear{display: none;}
button{cursor: pointer;}
button::-moz-focus-inner{padding:0;border:0;}
a,a:visited{text-decoration: none;}
a:hover{text-decoration: none;}
ul li{list-style: none;}
img{vertical-align: top;}
h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight: inherit;}
a{color:inherit;}
@@ -0,0 +1,98 @@
.wrapper {
position: absolute;
inset: 30px;
display: flex;
flex-direction: row;
overflow: hidden;
box-shadow: 1px 1px 20px 1px rgba(68, 68, 68, 0.5);
border-radius: 40px;
border: 1px solid #444;
}
.left_side {
width: 60%;
background-image: url("../assets/bg_login.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-right: 1px solid rgba(68, 68, 68, 0.3);
}
.container {
padding: 40px 20px;
padding-bottom: 20px;
}
.img_logo {
width: 133px;
height: 82px;
}
.header_top {
display: flex;
flex-direction: row;
align-items: center;
}
.header_title {
font-size: 36px;
font-weight: 900;
}
.header_heading {
font-size: 28px;
color: rgba(0, 0, 0, 0.5);
}
.header_bottom {
font-size: 20px;
color: rgba(0, 0, 0, 0.8);
line-height: 30px;
padding-top: 20px;
padding-left: 30px;
}
.main_item_img {
width: 75%;
height: 70%;
}
.main_items {
display: flex;
justify-content: space-between;
}
.main_item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main_item_title {
font-size: 20px;
font-weight: 600;
}
.main_item_p {
padding-top: 10px;
font-size: 16px;
width: 50%;
text-align: center;
line-height: 25px;
}
.section_item > svg {
width: 40px;
height: 40px;
margin-bottom: 15px;
}
.section_items {
display: flex;
justify-content: space-between;
}
.section {
padding: 7% 40px 0px 0px;
}
.section_item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 25%;
line-height: 25px;
font-weight: 900;
font-size: 16px;
}
.section_line {
border: 1px solid rgba(0, 0, 0, 0.1);
}
+109
View File
@@ -0,0 +1,109 @@
import type { ReactNode } from "react";
import styles from "./AuthLayout.module.css";
import logo from "../../assets/LOGO.png";
import img_1 from "../../assets/paper.png";
import img_2 from "../../assets/PC.png";
import img_3 from "../../assets/graphics.png";
export function AuthLayout({ children }: { children: ReactNode }) {
return (
<div className={styles.wrapper}>
<div className={styles.left_side}>
<header className={styles.header}>
<div className={styles.container}>
<div className={styles.header_top}>
<img className={styles.img_logo} src={logo} alt="LOGO" />
<div className={styles.header_text}>
<div className={styles.header_title}>
The DisExcel
</div>
<div className={styles.header_heading}>
Project
</div>
</div>
</div>
<div className={styles.header_bottom}>
<p>
Избавляем от ручной работы. <br />
Переводим процессы из бумаги в Excel, <br />
а из Excel - в 1С и отчеты.
</p>
</div>
</div>
</header>
<main className={styles.main}>
<div className={styles.contain}>
<div className={styles.main_items}>
{/* 1 */}
<div className={styles.main_item}>
<img className={styles.main_item_img} src={img_1} alt="img" />
<div className={styles.main_item_title}>Бумага</div>
<div className={styles.main_item_p}>
Ручная работа, ошибки, потери
</div>
</div>
<div className={styles.main_i}></div>
{/* 2 */}
<div className={styles.main_item}>
<img className={styles.main_item_img} src={img_2} alt="img" />
<div className={styles.main_item_title}>Excel</div>
<div className={styles.main_item_p}>
Структура, формулы, автоматизация
</div>
</div>
<div className={styles.main_i}></div>
{/* 3 */}
<div className={styles.main_item}>
<img className={styles.main_item_img} src={img_3} alt="img" />
<div className={styles.main_item_title}>1С / Отчеты</div>
<div className={styles.main_item_p}>
Готовые процессы, аналитика, контроль
</div>
</div>
</div>
</div>
</main>
<section className={styles.section}>
<div className={styles.section_items}>
{/* 1 */}
<div className={styles.section_item}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="16" stroke="#087A55" strokeWidth="3"/>
<path d="M24 14v11l7 4" stroke="#087A55" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Экономим <br /> время
</div>
<div className={styles.section_line}></div>
{/* 2 */}
<div className={styles.section_item}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<path d="M24 6 38 11v11c0 9-5.8 15.2-14 20-8.2-4.8-14-11-14-20V11z" stroke="#087A55" strokeWidth="3" strokeLinejoin="round"/>
<path d="m17 24 5 5 10-11" stroke="#087A55" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Снижаем <br /> ошибки
</div>
<div className={styles.section_line}></div>
{/* 3 */}
<div className={styles.section_item}>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path stroke="#087A55" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.5.5 0 0 0 .12-.63l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.03 7.03 0 0 0-1.62-.94l-.36-2.54a.5.5 0 0 0-.5-.43h-3.84a.5.5 0 0 0-.5.43l-.36 2.54c-.59.24-1.13.56-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L2.66 8.85a.5.5 0 0 0 .12.63l2.03 1.58c-.05.3-.08.63-.08.94s.02.64.07.94l-2.03 1.58a.5.5 0 0 0-.12.63l1.92 3.32a.5.5 0 0 0 .6.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54a.5.5 0 0 0 .5.43h3.84a.5.5 0 0 0 .5-.43l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96a.5.5 0 0 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.63l-2.03-1.58Z"/>
<circle cx="12" cy="12" r="3.5" stroke="#087A55" strokeWidth="1.6"/>
</svg>
Автоматизируем <br /> процессы
</div>
<div className={styles.section_line}></div>
{/* 4 */}
<div className={styles.section_item}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<path d="M24 6 38 11v11c0 9-5.8 15.2-14 20-8.2-4.8-14-11-14-20V11z" stroke="#087A55" strokeWidth="3" strokeLinejoin="round"/>
<path d="m17 24 5 5 10-11" stroke="#087A55" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Делаем <br /> бизнес эффективнее
</div>
</div>
</section>
</div>
{children}
</div>
);
}
@@ -0,0 +1,51 @@
* {
outline: 1px solid red;
}
.wrapper {
display: grid;
grid-template-columns: 20% 1fr 20%;
grid-template-rows: 8.5% 1fr;
min-height: 100vh;
background-color:#F7FAF9;
grid-template-areas:
"left_sidebar header header"
"left_sidebar content right_sidebar";
}
.left_sidebar{
display: grid;
grid-area: left_sidebar;
align-content: start;
gap:10px;
background-color:#F1FAF7;
border-right: 1px solid #E1E9E6 ;
}
.header {
display: grid;
grid-area: header;
background: #FFFFFF;
border-bottom: 1px solid #E1E9E6;
}
.content {
display: grid;
grid-area: content;
}
/* .wrapper_1 {
display: grid;
grid-template-rows: 5vh 1fr;
height: 100%;
}
.wrapper_2 {
display: grid;
grid-template-columns: 70% 1fr;
height: 100%;
} */
.right_sidebar{
display: grid;
grid-area: right_sidebar;
background: #FFFFFF;
border-left: 1px solid #E1E9E6;
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import styles from "./Dashboard.module.css"
import { usePermissions } from "../../hooks/usePermissions";
export function MainLayout({sidebar, header, content, right_sidebar }: { sidebar: ReactNode;header: ReactNode; content: ReactNode; right_sidebar: ReactNode }) {
//const { permissions, loading } = usePermissions();
const { loading } = usePermissions();
if (loading) {
return null;
}
return (
<div className={styles.wrapper}>
<div className={styles.left_sidebar}>
{sidebar}
</div>
<div className={styles.header}>
{header}
{/* {permissions.has("admin") && <div><p>test</p></div>} */}
</div>
<div className={styles.content}>
{content}
</div>
<div className={styles.right_sidebar}>
{right_sidebar}
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { refreshToken } from './api/RefreshAPI/Refresh.ts'
import { getRememberMe } from './utils/rememberMe.ts'
import { startRefreshTimer } from './api/token.ts'
if (getRememberMe()) {
await refreshToken().catch(() => {});
console.log("Timer started");
startRefreshTimer(10*60*1000);
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
+16
View File
@@ -0,0 +1,16 @@
import { MainLayout } from "../layouts/Main/Dashboard";
import { Sidebar} from "../features/dashboard/components/Sidebar";
import { Header } from "../features/dashboard/components/Header";
import { Grid } from "../features/dashboard/components/Grid";
import { Right_sidebar } from "../features/dashboard/components/Right_sidebar";
export function DashboardPage() {
return (
<MainLayout
header={<Header />}
content={<Grid />}
sidebar={<Sidebar />}
right_sidebar={<Right_sidebar />}
>
</MainLayout>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Reset } from "../features/auth/components/ResetComponent";
import { AuthLayout } from "../layouts/Auth/AuthLayout";
export function ForgotPasswordPage() {
return (
<AuthLayout>
<Reset />
</AuthLayout>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useNavigate } from "react-router-dom";
import { Auth } from "../features/auth/components/AuthComponent";
import { AuthLayout } from "../layouts/Auth/AuthLayout";
import { useEffect, useState } from "react";
import { fetchUser } from "../api/UserAPI/User";
import { getAccessToken } from "../api/token";
export function LoginPage(){
const navigate = useNavigate();
const [checking, setChecking] = useState(function () {
return !!getAccessToken();
});
useEffect(function () {
if (!getAccessToken()) return;
async function check() {
try {
await fetchUser();
navigate("/dashboard");
} catch {
setChecking(false);
}
}
check();
}, [navigate]);
if (checking) {
return null;
}
return(
<AuthLayout>
<Auth />
</AuthLayout>
);
}
+25
View File
@@ -0,0 +1,25 @@
interface PermissionsOut {
id: number
permission: string
}
interface PermissionsGroupsOut {
id: number
group: string
permissions: PermissionsOut[]
}
export interface UserOut {
first_name: string
last_name: string
middle_name: string
email: string
direct_permissions: PermissionsOut[]
group: PermissionsGroupsOut[]
}
export interface UserIn {
email: string
plain_password:string
}
+16
View File
@@ -0,0 +1,16 @@
import { fetchUser } from "../api/UserAPI/User";
export async function loadPermissions(): Promise<Set<string>> {
const user = await fetchUser();
const permissions = new Set<string>();
for (const p of user.direct_permissions) {
permissions.add(p.permission);
}
for (const g of user.group) {
for (const p of g.permissions) {
permissions.add(p.permission);
}
}
return permissions;
}
+21
View File
@@ -0,0 +1,21 @@
export function persistRememberMe(value:boolean):void {
if (value) {
localStorage.setItem("rememberMe", "True");
sessionStorage.removeItem("rememberMe");
} else{
sessionStorage.setItem("rememberMe", "True");
localStorage.removeItem("rememberMe");
}
};
export function getRememberMe():boolean {
if (localStorage.getItem("rememberMe") || sessionStorage.getItem("rememberMe")) {
return true;
}
else {
return false;
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
envDir:"../configs",
})
+1285
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -1,31 +1,49 @@
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
# import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.cache.redis_client import redis_client
from src.database.users.crud import Seed
from src.logging.logger import LoggingMiddleware, ProcessingTimeMiddleware
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.configs_read.env import env_settings
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.protected_user_action_routes import (
router as protected_user_action_routes,
)
writer=LogWriter()
@asynccontextmanager
async def lifespan(app: FastAPI):
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
writer_task.cancel()
await redis_client.close()
await rabbitmq_client.close()
app=FastAPI(root_path="/", lifespan=lifespan)
app.add_middleware(LoggingMiddleware)
app.add_middleware(ProcessingTimeMiddleware)
app.include_router(router=protected_router)
app.include_router(router=protected_user_action_routes)
app.add_middleware(
CORSMiddleware,
allow_origins=[f"http://{env_settings.FRONT_HOST}:{env_settings.FRONT_PORT}"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("")
+11 -3
View File
@@ -1,4 +1,4 @@
VENV:=source .venv/bin/activate;
VENV:=source .venv/bin/activate &&
ALLURE:=.venv/allure-2.44.0/bin/allure #linux&macos
#ALLURE=.venv\allure-2.44.0\bin\allure #Windows
@@ -17,11 +17,11 @@ help:
##
.PHONY: run
run: ## Run dev local application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile local up -d && $(MAKE) -j2 backend frontend
.PHONY: down
down: ## Down dev local db
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db --profile redis down
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile local down
.PHONY: run-dev
run-dev: ## Run dev application
@@ -39,6 +39,14 @@ down-dev: ## Down dev application
down-prod: ## Down prod application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile prod down
.PHONY: backend
backend: ## Run backend only
${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload
.PHONY: frontend
frontend: ## Run frontend only
yarn --cwd ./frontend dev
##
## Migration section
##
Generated Vendored
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -x "$basedir/node" ]; then
"$basedir/node" "$basedir/../yarn/bin/yarn.js" "$@"
ret=$?
else
node "$basedir/../yarn/bin/yarn.js" "$@"
ret=$?
fi
exit $ret
Generated Vendored
+7
View File
@@ -0,0 +1,7 @@
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\yarn\bin\yarn.js" %*
) ELSE (
@SETLOCAL
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\yarn\bin\yarn.js" %*
)
Generated Vendored
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac
if [ -x "$basedir/node" ]; then
"$basedir/node" "$basedir/../yarn/bin/yarn.js" "$@"
ret=$?
else
node "$basedir/../yarn/bin/yarn.js" "$@"
ret=$?
fi
exit $ret
+7
View File
@@ -0,0 +1,7 @@
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\yarn\bin\yarn.js" %*
) ELSE (
@SETLOCAL
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\yarn\bin\yarn.js" %*
)
+16
View File
@@ -0,0 +1,16 @@
{
"systemParams": "win32-x64-137",
"modulesFolders": [
"node_modules"
],
"flags": [],
"linkedModules": [],
"topLevelPatterns": [
"yarn@^1.22.22"
],
"lockfileEntries": {
"yarn@^1.22.22": "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz"
},
"files": [],
"artifacts": {}
}
Generated Vendored
+26
View File
@@ -0,0 +1,26 @@
BSD 2-Clause License
For Yarn software
Copyright (c) 2016-present, Yarn Contributors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+60
View File
@@ -0,0 +1,60 @@
<p align="center">
<a href="https://yarnpkg.com/">
<img alt="Yarn" src="https://github.com/yarnpkg/assets/blob/master/yarn-kitten-full.png?raw=true" width="546">
</a>
</p>
<p align="center">
Fast, reliable, and secure dependency management.
</p>
<p align="center">
<a href="https://circleci.com/gh/yarnpkg/yarn"><img alt="Circle Status" src="https://circleci.com/gh/yarnpkg/yarn.svg?style=shield&circle-token=5f0a78473b0f440afb218bf2b82323cc6b3cb43f"></a>
<a href="https://ci.appveyor.com/project/kittens/yarn/branch/master"><img alt="Appveyor Status" src="https://ci.appveyor.com/api/projects/status/0xdv8chwe2kmk463?svg=true"></a>
<a href="https://dev.azure.com/yarnpkg/yarn/_build"><img alt="Azure Pipelines status" src="https://dev.azure.com/yarnpkg/yarn/_apis/build/status/Yarn%20Acceptance%20Tests"></a>
<a href="https://discord.gg/yarnpkg"><img alt="Discord Chat" src="https://img.shields.io/discord/226791405589233664.svg"></a>
<a href="http://commitizen.github.io/cz-cli/"><img alt="Commitizen friendly" src="https://img.shields.io/badge/commitizen-friendly-brightgreen.svg"></a>
</p>
---
**Fast:** Yarn caches every package it has downloaded, so it never needs to download the same package again. It also does almost everything concurrently to maximize resource utilization. This means even faster installs.
**Reliable:** Using a detailed but concise lockfile format and a deterministic algorithm for install operations, Yarn is able to guarantee that any installation that works on one system will work exactly the same on another system.
**Secure:** Yarn uses checksums to verify the integrity of every installed package before its code is executed.
## Features
* **Offline Mode.** If you've installed a package before, then you can install it again without an internet connection.
* **Deterministic.** The same dependencies will be installed in the same exact way on any machine, regardless of installation order.
* **Network Performance.** Yarn efficiently queues requests and avoids request waterfalls in order to maximize network utilization.
* **Network Resilience.** A single request that fails will not cause the entire installation to fail. Requests are automatically retried upon failure.
* **Flat Mode.** Yarn resolves mismatched versions of dependencies to a single version to avoid creating duplicates.
* **More emojis.** 🐈
## Installing Yarn
Read the [Installation Guide](https://yarnpkg.com/en/docs/install) on our website for detailed instructions on how to install Yarn.
## Using Yarn
Read the [Usage Guide](https://yarnpkg.com/en/docs/usage) on our website for detailed instructions on how to use Yarn.
## Contributing to Yarn
Contributions are always welcome, no matter how large or small. Substantial feature requests should be proposed as an [RFC](https://github.com/yarnpkg/rfcs). Before contributing, please read the [code of conduct](CODE_OF_CONDUCT.md).
See [Contributing](https://yarnpkg.com/org/contributing/).
## Prior art
Yarn wouldn't exist if it wasn't for excellent prior art. Yarn has been inspired by the following projects:
- [Bundler](https://github.com/bundler/bundler)
- [Cargo](https://github.com/rust-lang/cargo)
- [npm](https://github.com/npm/cli)
## Credits
Thanks to [Sam Holmes](https://github.com/samholmes) for donating the npm package name!
Generated Vendored
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
argv0=$(echo "$0" | sed -e 's,\\,/,g')
basedir=$(dirname "$(readlink "$0" || echo "$argv0")")
case "$(uname -s)" in
Darwin) basedir="$( cd "$( dirname "$argv0" )" && pwd )";;
Linux) basedir=$(dirname "$(readlink -f "$0" || echo "$argv0")");;
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
*MSYS*) basedir=`cygpath -w "$basedir"`;;
esac
command_exists() {
command -v "$1" >/dev/null 2>&1;
}
if command_exists node; then
if [ "$YARN_FORCE_WINPTY" = 1 ] || command_exists winpty && test -t 1; then
winpty node "$basedir/yarn.js" "$@"
else
exec node "$basedir/yarn.js" "$@"
fi
ret=$?
# Debian and Ubuntu use "nodejs" as the name of the binary, not "node", so we
# search for that too. See:
# https://lists.debian.org/debian-devel-announce/2012/07/msg00002.html
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=614907
elif command_exists nodejs; then
exec nodejs "$basedir/yarn.js" "$@"
ret=$?
else
>&2 echo 'Yarn requires Node.js 4.0 or higher to be installed.'
ret=1
fi
exit $ret
+2
View File
@@ -0,0 +1,2 @@
@echo off
node "%~dp0\yarn.js" %*
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/* eslint-disable no-var */
/* eslint-disable flowtype/require-valid-file-annotation */
'use strict';
var ver = process.versions.node;
var majorVer = parseInt(ver.split('.')[0], 10);
if (majorVer < 4) {
console.error('Node version ' + ver + ' is not supported, please use Node.js 4.0 or higher.');
process.exit(1); // eslint-disable-line no-process-exit
} else {
try {
require(__dirname + '/../lib/v8-compile-cache.js');
} catch (err) {
// We don't have/need this on legacy builds and dev builds
}
// Just requiring this package will trigger a yarn run since the
// `require.main === module` check inside `cli/index.js` will always
// be truthy when built with webpack :(
// `lib/cli` may be `lib/cli/index.js` or `lib/cli.js` depending on the build.
var cli = require(__dirname + '/../lib/cli');
if (!cli.autoRun) {
cli.default().catch(function(error) {
console.error(error.stack || error.message || error);
process.exitCode = 1;
});
}
}
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('./yarn.js');
+2
View File
@@ -0,0 +1,2 @@
@echo off
"%~dp0\yarn.cmd" %*
+154071
View File
File diff suppressed because one or more lines are too long
+351
View File
@@ -0,0 +1,351 @@
'use strict';
const Module = require('module');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const os = require('os');
const hasOwnProperty = Object.prototype.hasOwnProperty;
//------------------------------------------------------------------------------
// FileSystemBlobStore
//------------------------------------------------------------------------------
class FileSystemBlobStore {
constructor(directory, prefix) {
const name = prefix ? slashEscape(prefix + '.') : '';
this._blobFilename = path.join(directory, name + 'BLOB');
this._mapFilename = path.join(directory, name + 'MAP');
this._lockFilename = path.join(directory, name + 'LOCK');
this._directory = directory;
this._load();
}
has(key, invalidationKey) {
if (hasOwnProperty.call(this._memoryBlobs, key)) {
return this._invalidationKeys[key] === invalidationKey;
} else if (hasOwnProperty.call(this._storedMap, key)) {
return this._storedMap[key][0] === invalidationKey;
}
return false;
}
get(key, invalidationKey) {
if (hasOwnProperty.call(this._memoryBlobs, key)) {
if (this._invalidationKeys[key] === invalidationKey) {
return this._memoryBlobs[key];
}
} else if (hasOwnProperty.call(this._storedMap, key)) {
const mapping = this._storedMap[key];
if (mapping[0] === invalidationKey) {
return this._storedBlob.slice(mapping[1], mapping[2]);
}
}
}
set(key, invalidationKey, buffer) {
this._invalidationKeys[key] = invalidationKey;
this._memoryBlobs[key] = buffer;
this._dirty = true;
}
delete(key) {
if (hasOwnProperty.call(this._memoryBlobs, key)) {
this._dirty = true;
delete this._memoryBlobs[key];
}
if (hasOwnProperty.call(this._invalidationKeys, key)) {
this._dirty = true;
delete this._invalidationKeys[key];
}
if (hasOwnProperty.call(this._storedMap, key)) {
this._dirty = true;
delete this._storedMap[key];
}
}
isDirty() {
return this._dirty;
}
save() {
const dump = this._getDump();
const blobToStore = Buffer.concat(dump[0]);
const mapToStore = JSON.stringify(dump[1]);
try {
mkdirpSync(this._directory);
fs.writeFileSync(this._lockFilename, 'LOCK', {flag: 'wx'});
} catch (error) {
// Swallow the exception if we fail to acquire the lock.
return false;
}
try {
fs.writeFileSync(this._blobFilename, blobToStore);
fs.writeFileSync(this._mapFilename, mapToStore);
} catch (error) {
throw error;
} finally {
fs.unlinkSync(this._lockFilename);
}
return true;
}
_load() {
try {
this._storedBlob = fs.readFileSync(this._blobFilename);
this._storedMap = JSON.parse(fs.readFileSync(this._mapFilename));
} catch (e) {
this._storedBlob = Buffer.alloc(0);
this._storedMap = {};
}
this._dirty = false;
this._memoryBlobs = {};
this._invalidationKeys = {};
}
_getDump() {
const buffers = [];
const newMap = {};
let offset = 0;
function push(key, invalidationKey, buffer) {
buffers.push(buffer);
newMap[key] = [invalidationKey, offset, offset + buffer.length];
offset += buffer.length;
}
for (const key of Object.keys(this._memoryBlobs)) {
const buffer = this._memoryBlobs[key];
const invalidationKey = this._invalidationKeys[key];
push(key, invalidationKey, buffer);
}
for (const key of Object.keys(this._storedMap)) {
if (hasOwnProperty.call(newMap, key)) continue;
const mapping = this._storedMap[key];
const buffer = this._storedBlob.slice(mapping[1], mapping[2]);
push(key, mapping[0], buffer);
}
return [buffers, newMap];
}
}
//------------------------------------------------------------------------------
// NativeCompileCache
//------------------------------------------------------------------------------
class NativeCompileCache {
constructor() {
this._cacheStore = null;
this._previousModuleCompile = null;
}
setCacheStore(cacheStore) {
this._cacheStore = cacheStore;
}
install() {
const self = this;
this._previousModuleCompile = Module.prototype._compile;
Module.prototype._compile = function(content, filename) {
const mod = this;
function require(id) {
return mod.require(id);
}
require.resolve = function(request) {
return Module._resolveFilename(request, mod);
};
require.main = process.mainModule;
// Enable support to add extra extension types
require.extensions = Module._extensions;
require.cache = Module._cache;
const dirname = path.dirname(filename);
const compiledWrapper = self._moduleCompile(filename, content);
// We skip the debugger setup because by the time we run, node has already
// done that itself.
const args = [mod.exports, require, mod, filename, dirname, process, global];
return compiledWrapper.apply(mod.exports, args);
};
}
uninstall() {
Module.prototype._compile = this._previousModuleCompile;
}
_moduleCompile(filename, content) {
// https://github.com/nodejs/node/blob/v7.5.0/lib/module.js#L511
// Remove shebang
var contLen = content.length;
if (contLen >= 2) {
if (content.charCodeAt(0) === 35/*#*/ &&
content.charCodeAt(1) === 33/*!*/) {
if (contLen === 2) {
// Exact match
content = '';
} else {
// Find end of shebang line and slice it off
var i = 2;
for (; i < contLen; ++i) {
var code = content.charCodeAt(i);
if (code === 10/*\n*/ || code === 13/*\r*/) break;
}
if (i === contLen) {
content = '';
} else {
// Note that this actually includes the newline character(s) in the
// new output. This duplicates the behavior of the regular
// expression that was previously used to replace the shebang line
content = content.slice(i);
}
}
}
}
// create wrapper function
var wrapper = Module.wrap(content);
var invalidationKey = crypto
.createHash('sha1')
.update(content, 'utf8')
.digest('hex');
var buffer = this._cacheStore.get(filename, invalidationKey);
var script = new vm.Script(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true,
cachedData: buffer,
produceCachedData: true,
});
if (script.cachedDataProduced) {
this._cacheStore.set(filename, invalidationKey, script.cachedData);
} else if (script.cachedDataRejected) {
this._cacheStore.delete(filename);
}
var compiledWrapper = script.runInThisContext({
filename: filename,
lineOffset: 0,
columnOffset: 0,
displayErrors: true,
});
return compiledWrapper;
}
}
//------------------------------------------------------------------------------
// utilities
//
// https://github.com/substack/node-mkdirp/blob/f2003bb/index.js#L55-L98
// https://github.com/zertosh/slash-escape/blob/e7ebb99/slash-escape.js
//------------------------------------------------------------------------------
function mkdirpSync(p_) {
_mkdirpSync(path.resolve(p_), parseInt('0777', 8) & ~process.umask());
}
function _mkdirpSync(p, mode) {
try {
fs.mkdirSync(p, mode);
} catch (err0) {
if (err0.code === 'ENOENT') {
_mkdirpSync(path.dirname(p));
_mkdirpSync(p);
} else {
try {
const stat = fs.statSync(p);
if (!stat.isDirectory()) { throw err0; }
} catch (err1) {
throw err0;
}
}
}
}
function slashEscape(str) {
const ESCAPE_LOOKUP = {
'\\': 'zB',
':': 'zC',
'/': 'zS',
'\x00': 'z0',
'z': 'zZ',
};
return str.replace(/[\\:\/\x00z]/g, match => (ESCAPE_LOOKUP[match]));
}
function supportsCachedData() {
const script = new vm.Script('""', {produceCachedData: true});
// chakracore, as of v1.7.1.0, returns `false`.
return script.cachedDataProduced === true;
}
function getCacheDir() {
// Avoid cache ownership issues on POSIX systems.
const dirname = typeof process.getuid === 'function'
? 'v8-compile-cache-' + process.getuid()
: 'v8-compile-cache';
const version = typeof process.versions.v8 === 'string'
? process.versions.v8
: typeof process.versions.chakracore === 'string'
? 'chakracore-' + process.versions.chakracore
: 'node-' + process.version;
const cacheDir = path.join(os.tmpdir(), dirname, version);
return cacheDir;
}
function getParentName() {
// `module.parent.filename` is undefined or null when:
// * node -e 'require("v8-compile-cache")'
// * node -r 'v8-compile-cache'
// * Or, requiring from the REPL.
const parentName = module.parent && typeof module.parent.filename === 'string'
? module.parent.filename
: process.cwd();
return parentName;
}
//------------------------------------------------------------------------------
// main
//------------------------------------------------------------------------------
if (!process.env.DISABLE_V8_COMPILE_CACHE && supportsCachedData()) {
const cacheDir = getCacheDir();
const prefix = getParentName();
const blobStore = new FileSystemBlobStore(cacheDir, prefix);
const nativeCompileCache = new NativeCompileCache();
nativeCompileCache.setCacheStore(blobStore);
nativeCompileCache.install();
process.once('exit', code => {
if (blobStore.isDirty()) {
blobStore.save();
}
nativeCompileCache.uninstall();
});
}
module.exports.__TEST__ = {
FileSystemBlobStore,
NativeCompileCache,
mkdirpSync,
slashEscape,
supportsCachedData,
getCacheDir,
getParentName,
};
+28
View File
@@ -0,0 +1,28 @@
{
"name": "yarn",
"installationMethod": "tar",
"version": "1.22.22",
"packageManager": "yarn@1.22.17",
"license": "BSD-2-Clause",
"preferGlobal": true,
"description": "📦🐈 Fast, reliable, and secure dependency management.",
"resolutions": {
"sshpk": "^1.14.2"
},
"engines": {
"node": ">=4.0.0"
},
"repository": "yarnpkg/yarn",
"bin": {
"yarn": "./bin/yarn.js",
"yarnpkg": "./bin/yarn.js"
},
"scripts": {
"preinstall": ":; (node ./preinstall.js > /dev/null 2>&1 || true)"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}
+60
View File
@@ -0,0 +1,60 @@
// This file is a bit weird, so let me explain with some context: we're working
// to implement a tool called "Corepack" in Node. This tool will allow us to
// provide a Yarn shim to everyone using Node, meaning that they won't need to
// run `npm install -g yarn`.
//
// Still, we don't want to break the experience of people that already use `npm
// install -g yarn`! And one annoying thing with npm is that they install their
// binaries directly inside the Node bin/ folder. And Because of this, they
// refuse to overwrite binaries when they detect they don't belong to npm. Which
// means that, since the "yarn" Corepack symlink belongs to Corepack and not npm,
// running `npm install -g yarn` would crash by refusing to override the binary :/
//
// And thus we have this preinstall script, which checks whether Yarn is being
// installed as a global binary, and remove the existing symlink if it detects
// it belongs to Corepack. Since preinstall scripts run, in npm, before the global
// symlink is created, we bypass this way the ownership check.
//
// More info:
// https://github.com/arcanis/pmm/issues/6
if (process.env.npm_config_global) {
var cp = require('child_process');
var fs = require('fs');
var path = require('path');
try {
var targetPath = cp.execFileSync(process.execPath, [process.env.npm_execpath, 'bin', '-g'], {
encoding: 'utf8',
stdio: ['ignore', undefined, 'ignore'],
}).replace(/\n/g, '');
var manifest = require('./package.json');
var binNames = typeof manifest.bin === 'string'
? [manifest.name.replace(/^@[^\/]+\//, '')]
: typeof manifest.bin === 'object' && manifest.bin !== null
? Object.keys(manifest.bin)
: [];
binNames.forEach(function (binName) {
var binPath = path.join(targetPath, binName);
var binTarget;
try {
binTarget = fs.readlinkSync(binPath);
} catch (err) {
return;
}
if (binTarget.startsWith('../lib/node_modules/corepack/')) {
try {
fs.unlinkSync(binPath);
} catch (err) {
return;
}
}
});
} catch (err) {
// ignore errors
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "The_DisExcel_project",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"yarn": "^1.22.22"
}
},
"node_modules/yarn": {
"version": "1.22.22",
"resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz",
"integrity": "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg==",
"hasInstallScript": true,
"license": "BSD-2-Clause",
"bin": {
"yarn": "bin/yarn.js",
"yarnpkg": "bin/yarn.js"
},
"engines": {
"node": ">=4.0.0"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"yarn": "^1.22.22"
}
}
Generated
+1221 -196
View File
File diff suppressed because it is too large Load Diff
+21 -7
View File
@@ -7,27 +7,29 @@ authors = [
]
license = "MH.Dmitrii's project"
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.13,<4.0"
dependencies = [
"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-settings (>=2.14.2,<3.0.0)",
"sqlalchemy[asyncio] (>=2.0.52,<3.0.0)",
"pandas (>=3.0.3,<4.0.0)",
"bcrypt (>=5.0.0,<6.0.0)",
"python-jose (>=3.5.0,<4.0.0)",
"python-multipart (>=0.0.32,<0.0.33)",
"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]
pytest = ">=9.1.1,<10.0.0"
pytest-cov = ">=7.1.0,<8.0.0"
@@ -38,6 +40,13 @@ httpie = ">=3.2.4,<4.0.0"
pytest-asyncio = ">=1.4.0,<2.0.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"
[tool.poetry.group.claude.dependencies]
graphifyy = ">=0.9.63,<0.10.0"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
@@ -51,7 +60,12 @@ omit = [
"__init__.py",
"*/docker/*",
"*/rate_limit.py",
"*/logger.py"
"*/logger.py",
"*/daemons/*",
"*/topology_setup.py",
"*/logging/*",
"*/email_reset.py",
"*/email_welcome.py"
]
[tool.coverage.report]
+117 -3
View File
@@ -10,30 +10,84 @@ A FastAPI project combining Excel and digital data ("The Great Excel project tha
- PostgreSQL (asyncpg, psycopg2)
- Pydantic 2 / Pydantic Settings
- Poetry — dependency management
- Redis — caching, rate limiting, token revocation
- RabbitMQ (aio-pika) — background email workers
- Jinja2 — HTML email templates
- Frontend: React 19, TypeScript, Vite, react-router-dom, CSS Modules (yarn)
- Docker, Ansible — deployment
## Architecture
```
src/
├── cache/ # Redis client, rate limiting
├── daemons/ # background worker entrypoints (BaseDaemon, registry)
├── database/ # DB CRUD operations
├── errors/ # HTTP errors
├── logging/ # logging middleware
├── logging/ # queue-based logging infra + HTTP middleware
├── messaging/ # RabbitMQ client, producers, consumers, topology
├── migrations/ # Alembic migrations
├── models/ # Pydantic and SQLAlchemy models, configs
├── models/ # Pydantic and SQLAlchemy models, configs, RabbitMQ topology
├── reports/ # reports
├── service/ # business logic (auth, users_crud)
├── service/ # business logic (auth, users_crud, email sending)
└── web/ # routes (protected_routes)
frontend/ # React app (login / password-reset pages), see "Frontend"
```
Layers are connected top to bottom: `web → service → database → models`.
## Background workers (RabbitMQ)
Email sending (welcome / password-reset) runs as separate daemon processes,
decoupled from the web API via a RabbitMQ topic exchange:
```
main.py / daemon_run.py → apply_topology() → RabbitMQ ("email" exchange)
├── queue_welcome_email → WelcomeEmailConsumer
└── queue_reset_email → ResetEmailConsumer
```
Each queue has a matching dead-letter queue for messages that fail
permanently (bad data, non-retryable errors) instead of retrying forever.
Run a worker locally with:
```bash
python daemon_run.py welcome_email # single daemon
python daemon_run.py --all # all daemons enabled in configs/daemons.json
```
## 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()`
- Login: `POST /protected/token` (OAuth2 form-data: `username` = email, `password`); returns the access token and sets the refresh token as an httpOnly cookie. Failed logins are rate-limited (5 per minute per IP)
- Password reset: `POST /protected/reset?email=...` generates a temporary password, saves it and queues an email with it (3 requests per 10 minutes per IP). The response is the same whether or not the email exists
- CORS: the allowed frontend origin is taken from the `FRONT_URL` variable in `configs/.env` (required, e.g. `http://localhost:5173`)
## Frontend
React + Vite app in `frontend/` (login and password-reset pages).
```bash
cd frontend
yarn install
yarn dev # http://localhost:5173
```
Environment variables for the frontend live in `frontend/configs/.env`
(Vite is configured to read them from there). Only variables prefixed with
`VITE_` are exposed to the browser code:
```text
VITE_BACKEND_URL=http://localhost:8000
```
The frontend calls the backend directly (no dev proxy), so `FRONT_URL` in the
backend's `configs/.env` must match the address the frontend runs on.
Restart `yarn dev` after changing either file.
## Testing
@@ -46,12 +100,72 @@ tests/
Uses pytest, pytest-asyncio, pytest-cov, pytest-mock, allure-pytest.
### Allure report
`make allure` needs the Allure **command-line tool** (Java-based, not a pip
package) — `allure-pytest` only writes raw result files, the CLI turns them
into an HTML report.
1. Download Allure **2.44.0** from
[github.com/allure-framework/allure2/releases](https://github.com/allure-framework/allure2/releases).
2. Extract it into `.venv/allure-2.44.0/` so that `.venv/allure-2.44.0/bin/allure`
exists — this matches the `ALLURE` variable already set in `makefile`.
3. If you install it somewhere else (or on Windows), update the `ALLURE`
variable at the top of `makefile` to point to your actual `allure`
binary path — the Windows path is already there, commented out.
```bash
make test # runs pytest, writes results to tests/allure-results/reports
make allure # builds tests/allure-results/html/index.html from those results
```
## Installation
Requires Poetry itself to be installed first (it's a global tool, not a
project dependency). Recommended via [pipx](https://pipx.pypa.io/):
```bash
pipx install poetry
```
or via the official installer:
```bash
curl -sSL https://install.python-poetry.org | python3 -
```
Then install the project dependencies:
```bash
poetry install
```
## Codebase knowledge graph (graphify)
The repo can be explored as a navigable knowledge graph via the
[graphify](https://github.com/safishamsi/graphify) Claude Code skill —
useful for onboarding or tracing how a change ripples across modules.
It lives in its own Poetry group (`claude`) so it's never installed in
`web`/`daemon`/prod images:
```bash
poetry install --with claude
```
Then, inside a Claude Code session in this repo, run:
```text
/graphify
```
This builds `graphify-out/graph.html` (open directly in a browser),
`graphify-out/GRAPH_REPORT.md` (god nodes, surprising cross-module
connections, suggested questions), and `graphify-out/graph.json` (raw
graph data). Ask follow-up questions about the codebase directly — once
`graphify-out/graph.json` exists, Claude answers from the graph instead
of rebuilding it. `graphify-out/` is gitignored: it's regenerable local
output, not part of the codebase.
## Running migrations
```bash
+7 -7
View File
@@ -6,19 +6,19 @@ class RateLimit:
def __init__(self) -> None:
self.errors=Errors()
async def rate_limit(self, ip:str)->None:
async def rate_limit(self, ip:str, action:str, limit:int=5, window:int=60)->None:
key=f"action attempt {ip}"
key=f"rate_limit:{action}:{ip}"
attempts = await redis_client.incrby(key)
if attempts == 1:
await redis_client.expire(key, 60)
await redis_client.expire(key, window, nx=True)
if attempts>5:
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60)
if attempts>limit:
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=window)
async def check_rate_limit(self, client_ip:str) -> None:
await self.rate_limit(client_ip)
async def check_rate_limit(self, client_ip:str, action:str) -> None:
await self.rate_limit(client_ip, action)
rate_limiter=RateLimit()
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,
}
View File
+9 -6
View File
@@ -36,20 +36,23 @@ class JwtCrudActions:
async def create_and_update_token(self, data: dict, old_jti: UUID, new_jti: UUID) -> bool:
async with self.Session() as session, session.begin():
new_token = RefreshTokens(**data)
query = (
revoke_query = (
update(RefreshTokens)
.where(RefreshTokens.id == old_jti, RefreshTokens.is_revoked.is_(False))
.values(is_revoked=True, replaced_by=new_jti)
.values(is_revoked=True)
.returning(RefreshTokens.id)
)
updated_id = (await session.execute(query)).scalar_one_or_none()
updated_id = (await session.execute(revoke_query)).scalar_one_or_none()
if updated_id is None:
return False
session.add(new_token)
session.add(RefreshTokens(**data))
await session.flush()
await session.execute(
update(RefreshTokens).where(RefreshTokens.id == old_jti).values(replaced_by=new_jti)
)
return True
View File
View File
-1
View File
@@ -1 +0,0 @@
'''fake data for tests'''
+7 -1
View File
@@ -2,8 +2,14 @@
import logging
from .logger import LoggerDB
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
+24 -52
View File
@@ -1,71 +1,43 @@
import asyncio
import json
import logging
from time import gmtime, perf_counter, strftime
from typing import cast
from contextvars import ContextVar
from time import gmtime, strftime
import aiofiles
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.concurrency import iterate_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response, StreamingResponse
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
message_id_ctx: ContextVar[str] = ContextVar("message_id", default="-")
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
log_queue=asyncio.Queue()
class LogWriter:
def __init__(self) -> None:
pass
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
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())
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
try:
response = await call_next(request)
except Exception as exc: # noqa: BLE001
body = str(exc)
async with aiofiles.open(f"./logs/endpoints_log_{file_time}.txt", "a") as file:
await file.write(f"[{current_time}] [500] [{body}] [{client_ip}]\n")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
streaming_response = cast(StreamingResponse, response)
chunks = []
async for chunk in streaming_response.body_iterator:
chunks.append(chunk.encode() if isinstance(chunk, str) else bytes(chunk))
body_bytes = b"".join(chunks)
streaming_response.body_iterator = iterate_in_threadpool(iter([body_bytes]))
try:
parsed = json.loads(body_bytes)
body = parsed.get("detail", None) if not isinstance(parsed, bool) else None
except (json.JSONDecodeError, TypeError):
body = None
async with aiofiles.open(f"./logs/endpoints_log_{file_time}.txt", "a") as file:
await file.write(f"[{current_time}] [{response.status_code}] [{body}] [{client_ip}]\n")
return response
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)
asyncio.create_task(self._write(msg))
rid=request_id_ctx.get()
log_queue.put_nowait(("sql",f"[{rid}] {msg}"))
async def _write(self, msg: str) -> None:
file_time = strftime("%b_%Y", gmtime())
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
class LoggerDaemon(logging.Handler):
async with aiofiles.open(f"./logs/sql_log_{file_time}.txt", "a") as f:
await f.write(f"[{current_time}] {msg}\n")
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
View File
+106
View File
@@ -0,0 +1,106 @@
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"Обрабатываю reset для {data.get('email')}:, метка: {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()

Some files were not shown because too many files have changed in this diff Show More