docs(statements): README и CLAUDE.md — парсер форм; интеграционный тест книги НПЗ через воркер
Тест ожидает needs_check (рубли при шапке «тыс. руб.», OQ-137). Refs #14 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
parent
6cd2ea8a7a
commit
3a370d4872
23
CLAUDE.md
23
CLAUDE.md
@ -92,3 +92,26 @@ property, покрытие словарей); без каталога `../fixtur
|
|||||||
с compose и сборка образа).
|
с compose и сборка образа).
|
||||||
|
|
||||||
Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR.
|
Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR.
|
||||||
|
|
||||||
|
## Добавлено в Срезе 2b (парсер ББ/ОФР; `openspec/changes/slice-2b-statement-parser/`)
|
||||||
|
|
||||||
|
**Модули:** `api/src/parser/book.py` (`BookParser`: лимиты, чтение, дедлайн, вид листа, статус и карта ошибок; документы —
|
||||||
|
ОСВ в порядке листов, затем формы) и `api/src/parser/statements/` (`detect` → `columns` → `header` → `lines` → `checks` →
|
||||||
|
`merge`/`statement`; подробности — `README.md`, раздел «Парсер форм (ББ/ОФР)»). `get_parser(settings)` теперь создаёт
|
||||||
|
`BookParser` (листы ОСВ по-прежнему разбирает `osv.py`).
|
||||||
|
|
||||||
|
**Правила расширения форм:**
|
||||||
|
- Новая форма или написание = строка в `api/rules/statement_*.yaml` (+ синтетика `st0x` в
|
||||||
|
`tests/synthetic/generate_statements.py` или реальный файл в `../fixtures/statements-xlsx/`). Не `if` под файл,
|
||||||
|
организацию или лист. Соотношения §7 — данные `statement_checks.yaml`.
|
||||||
|
- Координат в `src/parser/**` нет (кроме `grid.py`, `readers/`): тот же AST-линтер `test_no_coordinates.py`; слов формы в
|
||||||
|
коде нет — только в YAML. Деньги — `Decimal`; единицы не нормализуются, первичка не мутируется.
|
||||||
|
- Модули ОСВ (`header.py`, `classify.py`, `checks.py`, `hierarchy.py`) под формы не правятся.
|
||||||
|
|
||||||
|
**Тесты на реальных данных:** манифест ожиданий — `api/tests/statements_manifest.yaml` (18 файлов). Golden — два каталога
|
||||||
|
в `../fixtures/golden/`: `expected_osv_parse` и `expected_statement_parse`; правятся только `make golden-update` с
|
||||||
|
просмотром diff и отдельным коммитом в `fixtures`.
|
||||||
|
|
||||||
|
**Контракт v1.2:** `CONTRACT_VERSION = "1.2"`; `documents[]` получает `BALANCE`/`INCOME`/`DISCLOSURE` (`statementDocument`:
|
||||||
|
`header`, `lines`, `checks`); копия `api/contracts/` идентична `../docs/contracts/`. Интеграционный тест книги НПЗ ожидает
|
||||||
|
`needs_check` (рубли при шапке «тыс. руб.», OQ-137), а не `done`.
|
||||||
|
|||||||
72
README.md
72
README.md
@ -139,7 +139,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `done` | контрольные суммы §1.4 сошлись на всех листах |
|
| `done` | контрольные суммы §1.4 сошлись на всех листах |
|
||||||
| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
|
| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
|
||||||
| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) |
|
| `error` / `INVALID_DOCUMENT` | в книге нет листов ОСВ и форм (в сообщении — число просмотренных листов) |
|
||||||
| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
|
| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
|
||||||
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING |
|
| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING |
|
||||||
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») |
|
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») |
|
||||||
@ -147,7 +147,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
|
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
|
||||||
|
|
||||||
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
|
В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
|
||||||
(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
|
(`skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
|
||||||
сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
|
сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
|
||||||
|
|
||||||
### Контрольные суммы (§1.4)
|
### Контрольные суммы (§1.4)
|
||||||
@ -247,6 +247,68 @@ openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63
|
|||||||
Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы (они же —
|
Жёсткий лимит памяти контейнера, `defusedxml` или разбор в подпроцессе с `RLIMIT_AS` — решения платформы (они же —
|
||||||
настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.
|
настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.
|
||||||
|
|
||||||
|
## Парсер форм (ББ/ОФР)
|
||||||
|
|
||||||
|
Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через `statements/`. Документы
|
||||||
|
результата: сначала ОСВ в порядке листов, затем формы в порядке листов. Структура ищется по содержимому, как в ОСВ;
|
||||||
|
синонимы и соотношения §7 — данные `api/rules/statement_*.yaml`, в коде нет ни слова из формы и ни одной координаты.
|
||||||
|
|
||||||
|
| Модуль (`api/src/parser/statements/`) | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `detect.py` | блоки форм на листе и вид листа (форма / продолжение / прочее); заголовок формы или код ОКУД открывает блок |
|
||||||
|
| `columns.py` | заголовок таблицы, колонка кодов (не меньше 60 % кодов в блоке), колонка наименований, колонки-периоды |
|
||||||
|
| `header.py` | шапка: организация, ИНН (ИНН аудитора не берётся), ОКПО, ОКЕИ, единицы «как в файле», дата периода |
|
||||||
|
| `lines.py` | строки с кодом и значения в `Decimal`; пятизначные коды — по первым четырём цифрам; дубликаты кодов |
|
||||||
|
| `checks.py` | соотношения §7 по колонкам, допуск ±1 в единицах формы, отсутствующий код = 0, `abs_terms` для знака расходов |
|
||||||
|
| `merge.py` | черновик документа и присоединение листов-продолжений к последнему открытому документу |
|
||||||
|
| `statement.py` | конвейер и итоговый `statementDocument`, критерий `ok` |
|
||||||
|
|
||||||
|
Форма и продолжение: лист с заголовком формы или ОКУД — новая форма; лист без заголовка, но с маркером «Форма … с.2»,
|
||||||
|
подписью «Код» с периодами или колонкой кодов — продолжение, присоединяется к последнему открытому документу.
|
||||||
|
Книга только из листов пояснений даёт `done` с документами `DISCLOSURE` без строк.
|
||||||
|
|
||||||
|
Критерий `ok` документа: жёсткие соотношения сошлись, дубликатов кодов нет, колонка кодов найдена, есть колонка-период,
|
||||||
|
в шапке есть организация или ИНН, документ не одинокое продолжение. Статус книги — `done`, если `ok` у всех документов,
|
||||||
|
иначе `needs_check` (список нарушений — `documents[].checks`; `soft_relations_failed` на статус не влияют).
|
||||||
|
Новый синоним или соотношение — строка в YAML (`api/rules/README.md`), плюс файл-фикстура или синтетика, на которой она
|
||||||
|
срабатывает (`test_rules_coverage.py`).
|
||||||
|
|
||||||
|
### Ограничения
|
||||||
|
|
||||||
|
- Единицы не нормализуются: `amount` как в ячейке, `units` — текст шапки. При «тыс. руб.» и значениях с копейками
|
||||||
|
выдаётся предупреждение; такие файлы дают `needs_check` (OQ-137).
|
||||||
|
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
|
||||||
|
одиноким продолжением и даёт `needs_check`.
|
||||||
|
- Лист ОФР с одной строкой кода (O-9, меньше `min_codes`) пропускается как не форма.
|
||||||
|
|
||||||
|
### Реальные формы: файл → статус
|
||||||
|
|
||||||
|
Источник — `api/tests/statements_manifest.yaml` (18 файлов `fixtures/statements-xlsx/**`). Статусы сверены прогоном.
|
||||||
|
|
||||||
|
| Файл | Документы и статус | Причина |
|
||||||
|
|---|---|---|
|
||||||
|
| `unagrande-co/2024-FY/Б.Б Лист 1.xls` | BALANCE `needs_check` | только актив: `1600 = 1700` не сходится |
|
||||||
|
| `unagrande-co/2024-FY/Б.Б. Лист 2.xls` | BALANCE `needs_check` | страница 2 без шапки |
|
||||||
|
| `unagrande-co/2024-FY/ОФР. Лист 1.xls` | INCOME `needs_check` | рубли с копейками при шапке «тыс. руб.»: 2100/2200/2300 расходятся с суммой частей (OQ-137) |
|
||||||
|
| `unagrande-co/2024-FY/ОФР. Лист 2 .xls` | INCOME `needs_check` | страница 2 без шапки |
|
||||||
|
| `unagrande-group/2024-FY/ББ. Лист 1 .xls` | BALANCE `needs_check` | только актив |
|
||||||
|
| `unagrande-group/2024-FY/ББ. Лист 2 (2).xls` | BALANCE `needs_check` | страница 2 без шапки |
|
||||||
|
| `unagrande-group/2024-FY/ОФР. Лист 2.xls` | INCOME `needs_check` | страница 2 без шапки, страницы 1 в пакете нет |
|
||||||
|
| `unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls` | BALANCE `needs_check` | только актив |
|
||||||
|
| `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` | BALANCE `needs_check` | страница 2 без шапки |
|
||||||
|
| `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls` | INCOME `needs_check` | рубли при шапке «тыс. руб.»: расхождения 2100/2200/2300 (OQ-137) |
|
||||||
|
| `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки |
|
||||||
|
| `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `needs_check`, INCOME `needs_check` | рубли при шапке «тыс. руб.»: `1600 = 1700` расходится на 1.94/9.02/2.48 руб.; ОФР — 2100/2200/2300 (OQ-137); одностроечный лист с 2400 пропущен |
|
||||||
|
| `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся |
|
||||||
|
| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
|
||||||
|
| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE |
|
||||||
|
| `templates/ББ после 01.01.2025.xlsx` | BALANCE `needs_check` | шаблон |
|
||||||
|
| `templates/ОФР_до 01.01.2025.xlsx` | INCOME `needs_check` | шаблон |
|
||||||
|
| `templates/ОФР_после 01.01.2025.xlsx` | INCOME `needs_check` | шаблон |
|
||||||
|
|
||||||
|
Запуск на форме: `ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "<путь к файлу>"` из `api/` (см. «Запуск на файле»);
|
||||||
|
в stdout — файл результата v1.2, у форм в `documents[]` — `header`, `lines[]`, `checks`.
|
||||||
|
|
||||||
## Тесты на реальных данных и golden-снимки
|
## Тесты на реальных данных и golden-снимки
|
||||||
|
|
||||||
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию
|
Тесты с маркером `fixtures` читают файлы из соседнего репозитория `fixtures` (`ARM_KA_FIXTURES=<путь>`, по умолчанию
|
||||||
@ -264,6 +326,10 @@ git -C ../fixtures diff # просмотреть каждое из
|
|||||||
git -C ../fixtures add golden && git -C ../fixtures commit # снимки — отдельным коммитом в репозитории fixtures
|
git -C ../fixtures add golden && git -C ../fixtures commit # снимки — отдельным коммитом в репозитории fixtures
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для форм — второй каталог: `fixtures/golden/expected_statement_parse/` (файлы `fixtures/statements-xlsx/**`, включая
|
||||||
|
`synthetic/` — синтетика `st01`–`st06`, генератор `tests/synthetic/generate_statements.py`); ожидания по реальным формам —
|
||||||
|
манифест `api/tests/statements_manifest.yaml`. Снимки обоих каталогов обновляет один `make golden-update`.
|
||||||
|
|
||||||
Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update».
|
Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update».
|
||||||
Первый `make golden-update` для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff),
|
Первый `make golden-update` для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff),
|
||||||
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
|
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
|
||||||
@ -316,7 +382,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL
|
|||||||
читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше).
|
читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше).
|
||||||
Симптом без окружения — `ValidationError: OPENBAO … Field required` при запуске: задайте `ENV_FILE=.env.test`.
|
Симптом без окружения — `ValidationError: OPENBAO … Field required` при запуске: задайте `ENV_FILE=.env.test`.
|
||||||
|
|
||||||
В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под
|
В stdout печатается JSON файла результата контракта v1.2 — тот же, что воркер кладёт в S3 под
|
||||||
`results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без
|
`results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без
|
||||||
экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` —
|
экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` —
|
||||||
фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`,
|
фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`,
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
|
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
|
||||||
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
|
обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
|
||||||
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия»,
|
конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия»,
|
||||||
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO).
|
«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO),
|
||||||
|
«Реальные формы через воркер» (книга НПЗ → `needs_check`, документы BALANCE и INCOME, `result_schema_version 1.2`).
|
||||||
|
|
||||||
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
|
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
|
||||||
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
|
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
|
||||||
@ -46,6 +47,12 @@ from src.core.config import get_settings
|
|||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
|
REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
|
||||||
|
REAL_NPZ = (
|
||||||
|
Path("statements-xlsx")
|
||||||
|
/ "npz"
|
||||||
|
/ "2025-Q3"
|
||||||
|
/ "Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx"
|
||||||
|
)
|
||||||
|
|
||||||
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
|
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
|
||||||
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
|
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
|
||||||
@ -313,3 +320,32 @@ async def test_real_osv_through_worker_gives_done_and_valid_result_file(
|
|||||||
assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
|
assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
|
||||||
assert stored["documents"][0]["header"]["account_code_raw"] == "66"
|
assert stored["documents"][0]["header"]["account_code_raw"] == "66"
|
||||||
await core.assert_quiet(request)
|
await core.assert_quiet(request)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fixtures
|
||||||
|
async def test_real_statements_through_worker_give_needs_check_with_two_documents(
|
||||||
|
core: Core, uploads: Uploads, fixtures_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""«Реальные формы через воркер»: книга НПЗ → ack → `needs_check` (рубли при шапке «тыс. руб.», OQ-137)."""
|
||||||
|
request = _new_request(key_name="npz_2025_q3.xlsx")
|
||||||
|
request["file_type"] = "XLSX"
|
||||||
|
await uploads.put(request, (fixtures_dir / REAL_NPZ).read_bytes(), XLSX_CONTENT_TYPE)
|
||||||
|
|
||||||
|
await core.publish(request)
|
||||||
|
_assert_ack(await core.next_message(request), request)
|
||||||
|
body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}")
|
||||||
|
|
||||||
|
assert body["status"] == "needs_check", body
|
||||||
|
assert body["result_schema_version"] == "1.2"
|
||||||
|
result_ref = body["result_s3"]
|
||||||
|
uploads.keys.append((result_ref["bucket"], result_ref["key"]))
|
||||||
|
|
||||||
|
stored: dict[str, Any] = json.loads(await uploads.read(result_ref["bucket"], result_ref["key"]))
|
||||||
|
assert schema_errors(stored) == []
|
||||||
|
assert RecognitionResult.model_validate(stored).to_wire() == stored
|
||||||
|
assert stored["status"] == "needs_check"
|
||||||
|
balance, income = stored["documents"]
|
||||||
|
assert [balance["document_type"], income["document_type"]] == ["BALANCE", "INCOME"]
|
||||||
|
assert {item["relation"] for item in balance["checks"]["relations_failed"]} == {"1600 = 1700"}
|
||||||
|
assert income["checks"]["relations_failed"] != []
|
||||||
|
await core.assert_quiet(request)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user