Compare commits

...

275 Commits

Author SHA1 Message Date
PotapovaA
e06b2dc4d4 aurora-1194 2026-07-14 17:27:29 +03:00
PotapovaA
2f505c7051 AURORA-1173 2026-07-14 15:26:30 +03:00
PotapovaA
580d9db311 AURORA-865 2026-07-14 14:51:51 +03:00
569138530d Merge pull request 'timezones: таймзоны для этапов, правки в бд для формы 3' (#31) from timezones into test
Reviewed-on: #31
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-14 10:04:38 +03:00
b664a39469 timezones: таймзоны для этапов, правки в бд для формы 3 2026-07-14 09:06:25 +03:00
857827813b Merge pull request 'filenames-fix: пофиксил названия файлов, вызывающих ошибку при рендере таблицы' (#30) from filenames-fix into test
Reviewed-on: #30
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-09 16:04:31 +03:00
ec73ab9a2b filenames-fix: пофиксил названия файлов, вызывающих ошибку при рендере таблицы 2026-07-09 14:46:05 +03:00
5537b8229e Merge pull request 'AURORA-1174-73' (#29) from AURORA-1174-73 into test
Reviewed-on: #29
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-07-09 10:30:22 +03:00
Raykov-MS
5eb73ddb37 clean 004 migration 2026-07-08 12:54:17 +03:00
Raykov-MS
4d5a39ef12 Merge remote-tracking branch 'origin/test' into AURORA-1174-73 2026-07-08 12:50:26 +03:00
Raykov-MS
ccba554b86 solve bugs 2026-07-08 12:50:21 +03:00
c4dd35b6bd Merge pull request 'project-sheets: год опциональный с null' (#28) from project-sheets into test
Reviewed-on: #28
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 15:50:43 +03:00
ffe4ff9edb Merge pull request 'Выпадает всп с не наступившей датой закрытия' (#26) from AURORA-1166 into test
Reviewed-on: #26
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-07-03 15:42:57 +03:00
0a99aeed0c project-sheets: год опциональный с null 2026-07-03 15:35:20 +03:00
8a280781bb Merge pull request 'front-projects' (#27) from front-projects into test
Reviewed-on: #27
2026-07-03 15:08:03 +03:00
PotapovaA
88e12d1a8b сделала год в проект оптимальным 2026-07-03 15:07:44 +03:00
PotapovaA
6f0b3eb0cc Merge branch 'test' into front-projects 2026-07-03 13:31:43 +03:00
PotapovaA
97934a3404 Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-03 13:31:24 +03:00
PotapovaA
0b33ba2b40 Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-03 13:31:21 +03:00
Raykov-MS
5ddf7479a4 Выпадает всп с не наступившей датой закрытия 2026-07-03 13:28:39 +03:00
PotapovaA
bc4e5122c6 Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-03 13:27:51 +03:00
PotapovaA
acd4645a4e fix aurora-1149 2026-07-03 12:46:05 +03:00
bdb4c389ce Merge pull request 'form3-permissions: фазы для 3 фрмы, правки фикстуры' (#22) from form3-permissions into test
Reviewed-on: #22
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 08:04:36 +03:00
5e6ad47128 Merge pull request 'websocket-projects: возвращается line_id, ошибка при удалении строки' (#23) from websocket-projects into test
Reviewed-on: #23
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 08:04:12 +03:00
19dd443146 Merge pull request 'projects-sheets: года в списке проектов, опциональный год при создании проекта' (#24) from project-sheets into test
Reviewed-on: #24
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-07-03 08:03:52 +03:00
PotapovaA
2cd80343ab fix aurora-1149 2026-07-02 19:07:58 +03:00
PotapovaA
c0e186baef Merge branch 'front-projects' of https://git.aepif.ru/RSHB/DFiP_Budget_planing into front-projects 2026-07-02 18:15:16 +03:00
PotapovaA
1df748fb03 fix-vsp rename fields 2026-07-02 18:13:55 +03:00
PotapovaA
2cc53b6846 fix-vsp rename fields 2026-07-02 18:10:35 +03:00
PotapovaA
944fbe4d7e fix-setting panel 2026-07-02 17:57:35 +03:00
155888f7fc Merge pull request 'front-projects' (#25) from front-projects into test
Reviewed-on: #25
2026-07-02 17:46:09 +03:00
PotapovaA
019b93092c fix-websocket 2026-07-02 17:45:00 +03:00
PotapovaA
a23a2dffc6 865 2026-07-02 17:31:23 +03:00
2e781ca809 projects-sheets: года в списке проектов, опциональный год при создании проекта 2026-07-02 16:44:45 +03:00
PotapovaA
cc62551c92 1134 2026-07-02 16:07:44 +03:00
PotapovaA
40c373b5ed projects 2026-07-02 15:35:44 +03:00
4ec4936a0f websocket-projects: возвращается line_id, ошибка при удалении строки 2026-07-02 14:51:40 +03:00
88a9713fb3 form3-permissions: фазы для 3 фрмы, правки фикстуры 2026-07-02 10:07:27 +03:00
PotapovaA
4e7cc9b3bd Merge branch 'test' into project-front 2026-07-01 11:29:30 +03:00
PotapovaA
c8cc1afec1 перенос constants.js в папку constants 2026-07-01 11:29:07 +03:00
047cabe14b Merge pull request 'Оказывается location_form это и есть placement_type, убрал location_form' (#21) from location_from_to_placement_type into test
Reviewed-on: #21
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-07-01 11:22:00 +03:00
7fabb22c45 Merge pull request 'smeta-fix: поправил ддл функии сметы первой формы, поправил фикстуры для теста' (#20) from smeta-fix into test
Reviewed-on: #20
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-30 17:25:41 +03:00
Raykov-MS
5d16915bad Оказывается location_form это и есть placement_type, убрал location_form 2026-06-30 17:24:08 +03:00
66846c6afa smeta-fix: поправил ддл функии сметы первой формы, поправил фикстуры для теста 2026-06-30 15:04:15 +03:00
b283bda007 Merge pull request 'rename vsp.number to vsp.staff_count' (#19) from fix-vsp-filters into test
Reviewed-on: #19
2026-06-30 12:37:56 +03:00
PotapovaA
4f8393bedc rename vsp.number to vsp.staff_count 2026-06-30 12:37:07 +03:00
796640a491 Merge pull request 'Убрал numbres...Э' (#18) from fix-tests-and-functions into test
Reviewed-on: #18
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-06-30 11:54:09 +03:00
Raykov-MS
03ea64d6b5 del numbers from schema 2026-06-30 11:52:39 +03:00
e91c5e1846 Merge pull request 'mig2-fix: поправил sql' (#17) from mig2-fix into test
Reviewed-on: #17
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-30 11:22:56 +03:00
Raykov-MS
8869423ea6 Убрал numbres...Э 2026-06-30 11:19:37 +03:00
c1059736fb mig2-fix: поправил sql 2026-06-30 11:14:23 +03:00
7a3ef892cf Merge pull request 'Поправил в бд функции' (#16) from fix-tests-and-functions into test
Reviewed-on: #16
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-06-30 11:04:27 +03:00
Raykov-MS
0c10be7eb1 del space 2026-06-30 10:56:23 +03:00
Raykov-MS
48c7ab3eb1 del print 2026-06-30 10:54:56 +03:00
Raykov-MS
20d0520b7d Поправил ошибки в тестах и функцию в бд 2026-06-30 10:54:09 +03:00
2e5cf3d2af Merge pull request 'vsp_fixes' (#15) from vsp_fixes into test
Reviewed-on: #15
2026-06-30 09:56:54 +03:00
Raykov-MS
3b7c93f5a7 del mig4 2026-06-30 09:51:17 +03:00
62a2f9bae4 Merge pull request 'fix-ssp-title: нельзя создать орг юнит с пустым названием' (#13) from fix-ssp-title into test
Reviewed-on: #13
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-30 09:34:32 +03:00
PotapovaA
e28f6a3887 список проектов 2026-06-29 19:22:41 +03:00
Raykov-MS
0652ba4aee 'alembic' 2026-06-29 17:29:35 +03:00
Raykov-MS
7da934399e Исправлена ошибка в схемах и логах 2026-06-29 17:17:28 +03:00
3c1722293c Merge pull request 'модалка для выбора всп' (#11) from AURORA-894 into test
Reviewed-on: #11
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
2026-06-29 14:29:35 +03:00
905fa98ca5 Merge pull request '[AURORA-1148] - стили для таблицы' (#12) from AURORA-1148 into test
Reviewed-on: #12
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-06-29 11:48:28 +03:00
9c725e9678 fix-ssp-title: нельзя создать орг юнит с пустым названием 2026-06-29 11:37:23 +03:00
f26691805a [AURORA-1148] - стили для таблицы 2026-06-29 11:10:14 +03:00
2343b5c9b1 Merge pull request 'logs: логируем время' (#2) from logs into test
Reviewed-on: #2
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-29 11:01:45 +03:00
23c5f053bf Merge pull request 'sql-funcs2-fix: добавил правки по sql-функциям в смете' (#9) from sql-funcs2-fix into test
Reviewed-on: #9
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-29 11:01:25 +03:00
723a110035 Merge pull request 'fix-vsp-form-edit: не проверяем существование line_id, так как в листах сателлитах логика отличается' (#10) from fix-vsp-form-edit into test
Reviewed-on: #10
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-29 11:01:10 +03:00
PotapovaA
8c88acab49 модалка для выбора всп 2026-06-26 17:48:22 +03:00
5bfcf1302f fix-vsp-form-edit: не проверяем существование line_id, так как в листах сателлитах логика отличается 2026-06-26 16:21:02 +03:00
ee12939515 sql-funcs2-fix: добавил правки по sql-функциям в смете 2026-06-26 14:19:29 +03:00
e2a16989ae Merge pull request 'front-logs-fix' (#8) from front-logs-fix into test
Reviewed-on: #8
Reviewed-by: Veronika <veronika.amilonn@gmail.com>
2026-06-26 12:04:54 +03:00
ecb2708b4e Merge pull request '[AURORA-1132] - ошибка закрепления колонок' (#7) from AURORA-1132 into test
Reviewed-on: #7
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-06-26 11:23:07 +03:00
b3b5574d86 Merge pull request '[AURORA-1147] - фикс расстояния между строчками при изменении масштаба' (#5) from AURORA-1147 into test
Reviewed-on: #5
Reviewed-by: PotapovaA <potapovaanna113@mail.ru>
2026-06-26 11:22:22 +03:00
4cc2c120e5 [AURORA-1132] - ошибка закрепления колонок 2026-06-26 10:24:24 +03:00
bac2a7a780 logs: логируем время 2026-06-26 10:00:27 +03:00
e71864c37f Merge pull request '[AURORA-1131] - исправлен темный цвет текста на темном фоне в нередактируемой ячейке' (#6) from AURORA-1131 into test
Reviewed-on: #6
2026-06-26 09:47:47 +03:00
19dbc49dc0 Merge pull request 'sql-funcs-fix: поправил функции, агрегирующие данные не в том дирекшне' (#1) from sql-funcs-fix into test
Reviewed-on: #1
2026-06-26 09:45:30 +03:00
873f0d84b3 Merge pull request 'export-optimization: не красим обычные ячейки' (#4) from export-optimization into test
Reviewed-on: #4
2026-06-26 09:45:19 +03:00
f1bb6dbf6f [AURORA-1147] - фикс расстояния между строчками при имзенеии масшаба 2026-06-26 09:32:32 +03:00
PotapovaA
761a435b98 AURORA-1134 2026-06-25 18:54:32 +03:00
PotapovaA
3f9595928e этапы 2026-06-25 11:20:29 +03:00
59f9170e9f export-optimization: не красим обычные ячейки 2026-06-25 10:29:02 +03:00
da07e527e9 [AURORA-1132] - исправлен темный цвет текста на темном фоне в нередактируемой ячейке 2026-06-24 15:52:13 +03:00
230090ae22 Merge pull request 'vsp-dropdown-form: добавил фильтрацию по form_id в dropdown всп' (#73) from vsp-dropdown-form into test
Reviewed-on: #73
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-24 14:56:32 +03:00
840d5f5f13 sql-funcs-fix: поправил функции, агрегирующие данные не в том дирекшне 2026-06-24 14:41:56 +03:00
ab21b0e496 vsp-dropdown-form: добавил фильтрацию по form_id в dropdown всп 2026-06-24 13:22:17 +03:00
PotapovaA
b27ee53a9e Merge pull request 'скрыть отчет 9' (#72) from fix-front into test
Reviewed-on: #72
2026-06-24 12:27:47 +03:00
PotapovaA
cc766238f2 скрыть отчет 9 2026-06-24 12:27:29 +03:00
63ae001a23 Merge pull request 'migrations: закомментил все что связано с раисовской авторизацией при debug=false' (#70) from migrations into test
Reviewed-on: #70
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-24 11:42:22 +03:00
PotapovaA
04ee7b7fb2 Merge pull request 'fix этапов' (#71) from fix-front into test
Reviewed-on: #71
2026-06-24 11:08:58 +03:00
PotapovaA
c6502e59a7 fix этапов 2026-06-24 11:08:40 +03:00
fbf02d80d9 Merge pull request 'migrations: alembic миграции, пофиксил ошибки получения списков при пустых таблицах, добавил флаг --init_db для возможности заведения отдельной базы …' (#69) from migrations into test
Reviewed-on: #69
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-24 10:50:51 +03:00
c5c7963b5a migrations: закомментил все что связано с раисовской авторизацией при debug=false 2026-06-24 10:50:15 +03:00
2581589756 migrations: alembic миграции, пофиксил ошибки получения списков при пустых таблицах, добавил флаг --init_db для возможности заведения отдельной базы для тестов 2026-06-23 17:52:20 +03:00
PotapovaA
80c5b35299 Merge pull request 'fix-front' (#68) from fix-front into test
Reviewed-on: #68
2026-06-23 12:42:42 +03:00
PotapovaA
3aa9c96f25 fix 2026-06-23 12:42:22 +03:00
PotapovaA
6d0b48bc8d fix 2026-06-23 12:39:56 +03:00
PotapovaA
a33ba86ee5 fix цветов 2026-06-23 11:42:01 +03:00
PotapovaA
e5d2857e49 Merge pull request 'fix-front' (#67) from fix-front into test
Reviewed-on: #67
2026-06-23 11:32:55 +03:00
PotapovaA
c4d1a10fb8 удаление console.log 2026-06-23 11:32:33 +03:00
PotapovaA
1797044a2b fix accessorKey 2026-06-23 11:32:01 +03:00
PotapovaA
02aa4e8899 Merge branch 'test' into fix-front 2026-06-23 11:26:26 +03:00
PotapovaA
c7a8eb2e3e fix форм 2026-06-23 11:25:59 +03:00
eaad37ded7 Merge pull request 'websocket-self-flag: флаг присваивается по websocket, а не user_id' (#66) from websocket-self-flag into test
Reviewed-on: #66
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-23 11:16:44 +03:00
aceb1d38c1 websocket-self-flag: флаг присваивается по websocket, а не user_id 2026-06-23 09:33:15 +03:00
PotapovaA
41ccbe43d7 Merge pull request 'fix-front' (#65) from fix-front into test
Reviewed-on: #65
2026-06-23 04:00:11 +03:00
PotapovaA
6dc31a43b3 готово 2026-06-23 03:59:43 +03:00
PotapovaA
57eba099c1 скролл с виртуализацией fix 2026-06-23 02:47:53 +03:00
PotapovaA
2fcaea1d8b Merge pull request 'fix-front' (#64) from fix-front into test
Reviewed-on: #64
2026-06-22 20:34:14 +03:00
PotapovaA
0686b3ae8a нащвания форм + измененые формы 2026-06-22 20:33:55 +03:00
PotapovaA
c8165e8ca9 блокировка ячейки веб сокеты 2026-06-22 18:35:16 +03:00
PotapovaA
596ec13943 Merge branch 'test' into fix-front 2026-06-22 17:30:23 +03:00
d9e1cc4263 Merge pull request 'websocket-self-flag: добавил флаг is_self в сообщениях вебсокетов' (#63) from websocket-self-flag into test
Reviewed-on: #63
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-22 17:19:04 +03:00
PotapovaA
d138fdcfdb Merge branch 'test' into fix-front 2026-06-22 17:16:52 +03:00
PotapovaA
aa18a8e19c web сокеты для начала/конца редактирования ячейки 2026-06-22 17:16:29 +03:00
a2841d8c83 websocket-self-flag: добавил флаг is_self в сообщениях вебсокетов 2026-06-22 16:48:51 +03:00
PotapovaA
361eeb24bb Merge pull request 'ширина столбцов по умолчанию' (#62) from fix-front into test
Reviewed-on: #62
2026-06-22 13:57:42 +03:00
PotapovaA
ca11d55228 ширина столбцов по умолчанию 2026-06-22 13:57:24 +03:00
PotapovaA
2df64fb020 Merge pull request 'финансовый формат для ячеек' (#61) from fix-front into test
Reviewed-on: #61
2026-06-22 13:47:13 +03:00
PotapovaA
bb74f511af финансовый формат для ячеек 2026-06-22 13:46:56 +03:00
PotapovaA
66c1f09df6 Merge pull request 'формула для исполнителей' (#60) from fix-front into test
Reviewed-on: #60
2026-06-22 13:33:49 +03:00
PotapovaA
bafae2ca58 формула для исполнителей 2026-06-22 13:33:32 +03:00
27d698383e Merge pull request 'reload-fix: добавил локальный сваггер и обработку перезагрузки страницы фронтом' (#58) from reload-fix into test
Reviewed-on: #58
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-22 10:44:49 +03:00
PotapovaA
d2b1b3a4d0 Merge pull request 'фикс плывущей таблицы' (#59) from fix-front into test
Reviewed-on: #59
2026-06-22 10:20:15 +03:00
PotapovaA
d1d1f90268 фикс плывущей таблицы 2026-06-22 10:19:12 +03:00
b64120241c Merge pull request 'test-stand-fix: секреты для теста больше не нужно костылить, нужный урл для вебсокетов в контуре' (#57) from test-stand-fix into test
Reviewed-on: #57
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 17:06:31 +03:00
115c652887 reload-fix: добавил локальный сваггер и обработку перезагрузки страницы фронтом 2026-06-19 17:04:16 +03:00
75fa232bc6 test-stand-fix: секреты для теста больше не нужно костылить, нужный урл для вебсокетов в контуре 2026-06-19 14:44:47 +03:00
5cc07c7704 Merge pull request 'excel-empty-fix: убрал пустые колонки в экспорте эксель' (#56) from excel-empty-fix into test
Reviewed-on: #56
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 13:59:23 +03:00
6aa703e0f7 excel-empty-fix: убрал пустые колонки в экспорте эксель 2026-06-19 11:56:35 +03:00
3af55f09e2 Merge pull request 'requirements-fix: убрал зависимости из основного requirements для раисы' (#55) from requirements-fix into test
Reviewed-on: #55
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 11:38:01 +03:00
68ee519c39 requirements-fix: убрал зависимости из основного requirements для раисы 2026-06-19 10:28:49 +03:00
d99908c9b5 Merge pull request 'AURORA-1096-back: поправил фильтры в журнале аудита' (#54) from AURORA-1096-back into test
Reviewed-on: #54
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-19 08:23:05 +03:00
b9d109bd23 AURORA-1096-back: поправил фильтры в журнале аудита 2026-06-18 16:42:41 +03:00
PotapovaA
1e50ce6acb Merge pull request 'new stage' (#53) from new-stage-front into test
Reviewed-on: #53
2026-06-18 15:57:09 +03:00
PotapovaA
10909a1122 new stage 2026-06-18 15:55:15 +03:00
PotapovaA
70bf9fa5b5 Merge pull request 'front-logs' (#51) from front-logs into test
Reviewed-on: #51
2026-06-17 13:56:09 +03:00
5b6cda18b2 Merge pull request 'ws-user-id-fix: пишем для сессии бд пользователя' (#50) from ws-user-id-fix into test
Reviewed-on: #50
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-17 12:05:03 +03:00
c2c48789a1 Merge pull request 'expense-item-handler: получение expense-item по id' (#49) from expense-item-handler into test
Reviewed-on: #49
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-17 11:56:55 +03:00
7f7c9ab3eb ws-user-id-fix: пишем для сессии бд пользователя 2026-06-17 11:55:42 +03:00
PotapovaA
8a02913f2e Merge branch 'test' into front-logs 2026-06-17 11:22:56 +03:00
PotapovaA
9eced76d4b часть логов готова 2026-06-16 19:11:16 +03:00
8e5c92a132 expense-item-handler: получение expense-item по id 2026-06-16 17:12:13 +03:00
d292b2fbb0 Merge pull request 'excel-fix: пофиксил экспорт первой формы' (#48) from excel-fix into test
Reviewed-on: #48
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-16 15:49:05 +03:00
56cc1d6f87 Merge pull request 'Поправил некорректное отбраженеи ошибок' (#47) from fix_errors into test
Reviewed-on: #47
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-06-16 15:23:30 +03:00
Raykov-MS
5210f1d6f7 еще правки 2026-06-16 15:22:39 +03:00
c247536278 excel-fix: пофиксил экспорт первой формы 2026-06-16 14:56:55 +03:00
Raykov-MS
2f5cb21dc0 Поправил некорректное отбраженеи ошибок 2026-06-16 12:52:39 +03:00
4f7b4aaae5 Merge pull request 'vsp-edit-field-fix: поправил название изменяемого исполнителем поля для проверки' (#46) from vsp-edit-field-fix into test
Reviewed-on: #46
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-10 12:08:16 +03:00
f2016e3eb6 vsp-edit-field-fix: поправил название изменяемого исполнителем поля для проверки 2026-06-10 10:10:08 +03:00
f079423c0f Merge pull request 'fix-error-message: вывод ошибки с бэка при работе со справочниками' (#45) from fix-error-message into test
Reviewed-on: #45
2026-06-09 15:29:15 +03:00
0d16a002b9 fix-error-message: вывод ошибки с бэка при работе со справочниками 2026-06-09 15:28:00 +03:00
27cefec47b Merge pull request '[AURORA-1041] - работа со справочниками' (#44) from AURORA-1041-front into test
Reviewed-on: #44
2026-06-09 14:35:15 +03:00
d3d1babe76 [AURORA-1041] - работа со справочниками 2026-06-09 14:32:33 +03:00
8ef3b1cca1 Merge pull request 'vsp-fix-back: фикс багов по всп' (#43) from vsp-fix-back into test
Reviewed-on: #43
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-09 12:49:51 +03:00
b328afb52d vsp-fix-back: фикс багов по всп 2026-06-09 12:06:54 +03:00
b5a6114389 Merge pull request 'vsp-back: перенес функциональность справочников всп' (#40) from vsp-back into test
Reviewed-on: #40
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-08 17:23:10 +03:00
95d48bb2a0 vsp-back: перенес функциональность справочников всп 2026-06-08 17:15:38 +03:00
96477df9e3 Merge pull request '[AURORA-1043] - создание задач' (#42) from AURORA-1043 into test
Reviewed-on: #42
2026-06-08 16:48:55 +03:00
75f810da07 [AURORA-1043] - передача ССП списком 2026-06-08 16:47:09 +03:00
ef335cf107 Merge branch 'test' into AURORA-1043 2026-06-08 16:42:07 +03:00
d43b6faa2a Merge pull request 'create-form-back: в ручку создания задач передается список ссп, поправил падающий тест' (#41) from create-form-back into test
Reviewed-on: #41
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-08 16:38:15 +03:00
098efcb497 create-form-back: в ручку создания задач передается список ссп, поправил падающий тест 2026-06-08 16:09:11 +03:00
c3a4c9436a [AURORA-1043] - создание задачи 2026-06-08 15:48:52 +03:00
4308e11bc9 Merge pull request 'create-form: создание задачи' (#39) from create-form-back into test
Reviewed-on: #39
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-08 12:57:04 +03:00
2073569765 create-form: создание задачи 2026-06-08 12:42:36 +03:00
e7e8bc0df2 Merge pull request '[AURORA-1058] - Отсутствует кнопка для возврата к предыдущему экрану/странице' (#38) from AURORA-1058 into test
Reviewed-on: #38
2026-06-08 10:54:21 +03:00
32a7c001eb [AURORA-1058] - Отсутствует кнопка для возврата к предыдущему экрану/странице 2026-06-08 10:52:56 +03:00
a206bb7191 Merge pull request 'fix-stages: поправлены ручки по этапам и отображение данных' (#37) from fix-stages into test
Reviewed-on: #37
2026-06-05 17:00:24 +03:00
3970fe5c35 fix-stages: поправлены ручки по этапам и отображение данных 2026-06-05 16:57:41 +03:00
ee6fafb84f Merge pull request '[AURORA-1057] - экспорт форм' (#36) from AURORA-1057 into test
Reviewed-on: #36
2026-06-05 13:51:52 +03:00
e2bd55e5b1 [AURORA-1057] - добавлен экспорт таблиц 2026-06-05 13:04:47 +03:00
b27b8f3143 Merge branch 'test' into AURORA-1057 2026-06-05 12:03:41 +03:00
61a80a9e7d Merge pull request '[AURORA-1070] - Ошибка редактирования количества пользователей в Подразделении СПП/РФ' (#35) from AURORA-1070 into test
Reviewed-on: #35
2026-06-05 12:02:17 +03:00
d6a8f5ae58 Merge pull request '[AURORA-1060] - Вирутальный рендеринг' (#34) from AURORA-1060 into test
Reviewed-on: #34
2026-06-05 12:01:35 +03:00
e20461a0ff [AURORA-1060] - попраивла отображение лоадера 2026-06-05 11:17:10 +03:00
a47d2e58d0 [AURORA-1060] - добавлен лоадер для таблицы 2026-06-05 10:34:34 +03:00
67ce43e524 Merge branch 'test' into AURORA-1057 2026-06-04 17:39:35 +03:00
3bf44c50d4 Merge pull request 'export-direction-fix: убрал обязательный для первой формы direction при экспорте формы' (#33) from export-direction-fix into test
Reviewed-on: #33
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 17:35:46 +03:00
71deb55485 export-direction-fix: убрал обязательный для первой формы direction при экспорте формы 2026-06-04 17:29:59 +03:00
9fc054acd4 [AURORA-1057] - Ошибка экспорта задач 2026-06-04 17:16:05 +03:00
ed31f6b519 Merge pull request 'change-role-fix: обновленное значение ссп при смене роли' (#32) from change-role-fix into test
Reviewed-on: #32
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 16:05:17 +03:00
7d34376abe change-role-fix: обновленное значение ссп при смене роли 2026-06-04 16:01:13 +03:00
6419b5ad1d [AURORA-1070] - Ошибка редактирования количества пользователей в Подразделении СПП/РФ 2026-06-04 15:11:15 +03:00
e47200733e Merge pull request 'org-unit-users-fix: список пользователей с id' (#31) from org-unit-users-fix into test
Reviewed-on: #31
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 14:39:16 +03:00
758b4d1315 org-unit-users-fix: список пользователей с id 2026-06-04 14:30:53 +03:00
14d2271eb9 Merge pull request '[AURORA-1061] - Подключить админку к новому бэку' (#30) from AURORA-1061 into test
Reviewed-on: #30
2026-06-04 14:11:15 +03:00
8966829ad9 [AURORA-1061] - Подключить админку к новому бэку 2026-06-04 12:04:22 +03:00
b6a7ae509e Merge pull request 'audit-fix: загрузка пользователя в журнале аудита' (#29) from audit-fix into test
Reviewed-on: #29
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-06-04 10:28:07 +03:00
8e07135a16 audit-fix: загрузка пользователя в журнале аудита 2026-06-04 10:25:13 +03:00
bf3896f2f0 Merge pull request 'org-archive: в ручке обновления org доступно in_active, фильтры в get на is_active и is_ssp' (#28) from org-archive into test
Reviewed-on: #28
2026-06-03 17:40:23 +03:00
1db445d63d org-archive: в ручке обновления org доступно in_active, фильтры в get на is_active и is_ssp 2026-06-03 17:39:10 +03:00
1a533f46f1 Merge pull request 'org-unit-user-create: список ССП и РФ при создании и обновлении юзеров' (#27) from org-unit-user-create into test
Reviewed-on: #27
2026-06-03 16:10:55 +03:00
f3eb75cf51 org-unit-user-create: список ССП и РФ при создании и обновлении юзеров 2026-06-03 16:07:35 +03:00
43703866aa [AURORA-1060] - Вирутальный рендеринг 2026-06-03 11:49:06 +03:00
e0dc613c3c Merge pull request 'del-row-fix: поправил дисконнект при удалении строки' (#26) from del_row_fix into test
Reviewed-on: #26
2026-06-02 10:09:38 +03:00
35ab5e7b5f del-row-fix: поправил дисконнект при удалении строки 2026-06-02 10:02:52 +03:00
7f837c34a7 Merge pull request 'поменял получение cell_key для блокировки ячеек' (#25) from cell_key_fix into test
Reviewed-on: #25
2026-06-01 13:13:53 +03:00
d95465abe4 поменял получение cell_key для блокировки ячеек 2026-06-01 12:42:28 +03:00
PotapovaA
75f86e6ef7 Merge pull request 'del-row' (#24) from del-row into test
Reviewed-on: #24
2026-05-29 18:39:31 +03:00
PotapovaA
56833f454b исправила иерархию, блокировка ячеек, виртуализация строк 2026-05-29 18:38:05 +03:00
30e3426408 Merge pull request 'блокировки ячеек для websockets' (#23) from websocket_locks into test
Reviewed-on: #23
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-29 14:02:32 +03:00
38b0033c4a блокировки ячеек для websockets 2026-05-29 13:52:20 +03:00
PotapovaA
fcece07c64 удаление строки 2026-05-29 13:51:47 +03:00
PotapovaA
f6be77d889 Merge pull request 'front' (#22) from front into test
Reviewed-on: #22
2026-05-29 11:26:03 +03:00
PotapovaA
f93b4811a5 Merge branch 'test' into front 2026-05-29 11:23:45 +03:00
PotapovaA
99e7a445cf добавление строки 2026-05-29 11:23:25 +03:00
023f2974df Merge pull request 'id новой строки в ответе при вставке в вебсокетах' (#20) from new_line_id into test
Reviewed-on: #20
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-29 11:15:49 +03:00
5f5ed32cd8 id новой строки в ответе при вставке в вебсокетах 2026-05-29 10:06:39 +03:00
PotapovaA
52de655149 ws для ячеек 2026-05-28 12:12:16 +03:00
PotapovaA
079e719f71 ws - в процессе 2026-05-27 16:21:04 +03:00
PotapovaA
8b16901f1b Merge branch 'test' into front 2026-05-26 19:06:27 +03:00
PotapovaA
93881c7194 ws 2026-05-26 19:06:01 +03:00
3f6d59ba06 Merge pull request 'admin-back: ручки для админки' (#19) from admin-back into test
Reviewed-on: #19
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-26 17:32:31 +03:00
b7557f7b3d admin-back: ручки для админки 2026-05-26 17:07:46 +03:00
PotapovaA
bc05bc8154 Merge branch 'test' into front 2026-05-26 15:24:49 +03:00
PotapovaA
58d67d219f форма 2026-05-26 15:24:28 +03:00
aca03d59b7 Merge pull request 'Export' (#18) from export into test
Reviewed-on: #18
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-25 18:46:39 +03:00
Raykov-MS
fc55a7b042 Правки 2026-05-25 18:46:11 +03:00
47bb32da56 Merge pull request 'Исправил null в логах' (#17) from future/AURORA-1012 into test
Reviewed-on: #17
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-25 17:53:37 +03:00
Raykov-MS
ad6eae9c0b Export 2026-05-25 17:31:29 +03:00
Raykov-MS
d14d39cd27 Исправил null в логах 2026-05-22 18:02:06 +03:00
PotapovaA
a92df7ae14 форма 1 2026-05-22 17:30:28 +03:00
e37eb09e0a Merge pull request 'Логи с уходом от орм' (#16) from future/AURORA-1012 into test
Reviewed-on: #16
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-22 09:57:27 +03:00
Raykov-MS
d8e260cc5b add tests 2026-05-21 17:24:45 +03:00
Raykov-MS
03cd022dac None to bool 2026-05-21 16:19:54 +03:00
Raykov-MS
ed4c02589b Merge branch 'future/AURORA-1012' of ssh://git.aepif.ru:2222/RSHB/DFiP_Budget_planing into future/AURORA-1012 2026-05-21 14:32:47 +03:00
PotapovaA
76ce3702f5 Merge branch 'test' into front 2026-05-21 12:40:19 +03:00
PotapovaA
499664511a стили 2026-05-21 12:39:42 +03:00
2191e7d998 Merge pull request 'form-fix: ручка для получения формы по id' (#15) from form-fix into test
Reviewed-on: #15
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-21 11:57:09 +03:00
73c61918f7 form-fix: ручка для получения формы по id 2026-05-21 11:44:28 +03:00
Raykov-MS
e129f562dc Ушел от try except и вынес rollback на уровень сессии 2026-05-21 11:20:44 +03:00
PotapovaA
b580e38117 Merge branch 'test' into front 2026-05-21 11:10:01 +03:00
PotapovaA
b8a1427cf6 стили 2026-05-21 11:09:31 +03:00
Raykov-MS
411773b6e9 Ушли от уровня орм к уровню бд, позже добавлю функции в rnd репо 2026-05-21 11:08:36 +03:00
d2559d6bb7 Merge pull request 'ws: вебсокеты на новой модели данных' (#13) from ws into test
Reviewed-on: #13
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-21 11:03:58 +03:00
c247d13829 Merge pull request 'ssp-fix: org_unit в списке форм' (#14) from ssp-fix into test
Reviewed-on: #14
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-21 11:03:34 +03:00
33055d6695 ws: вебсокеты на новой модели данных 2026-05-21 11:02:44 +03:00
772afae276 ssp-fix: org_unit в списке форм 2026-05-21 10:39:33 +03:00
06ad5e0e93 Merge pull request 'Логи' (#12) from future/AURORA-1012 into test
Reviewed-on: #12
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-21 10:25:21 +03:00
Raykov-MS
831f220dcb Add annotation 2026-05-20 17:33:38 +03:00
Raykov-MS
8e43920deb фикс правок ревью 2026-05-20 17:30:40 +03:00
Raykov-MS
4a7ac50ca7 fix tests 2026-05-20 11:18:05 +03:00
Raykov-MS
816f0e06bb solve merge conflicts 2026-05-20 10:44:45 +03:00
Raykov-MS
bea8eb6240 Add audit read api and remove python audit writes 2026-05-19 14:37:43 +03:00
PotapovaA
188189534b Merge branch 'test' into front 2026-05-19 10:35:28 +03:00
PotapovaA
ff2ae30623 form 2026-05-19 10:35:06 +03:00
2dff2a1f21 Merge pull request 'add integrated tests' (#11) from future/AURORA-1011 into test
Reviewed-on: #11
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-19 10:25:36 +03:00
PotapovaA
24a59fb853 front new 2026-05-18 17:33:45 +03:00
Raykov-MS
5cd7232eb2 add integrated tests 2026-05-18 16:12:16 +03:00
7e9fc08056 Merge pull request 'add and fix validations on forms' (#10) from future/AURORA-1009 into test
Reviewed-on: #10
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-18 14:27:43 +03:00
Raykov-MS
a718827c12 add and fix validations on forms 2026-05-15 18:42:05 +03:00
21d474464d Merge pull request 'stages-back: круды для этапов' (#9) from stages-back into test
Reviewed-on: #9
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-15 15:18:41 +03:00
8299b35d07 stages-back: круды для этапов 2026-05-15 15:17:05 +03:00
47fac8acbc Merge pull request 'future/AURORA-1008' (#8) from future/AURORA-1008 into test
Reviewed-on: #8
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-15 10:23:52 +03:00
Raykov-MS
f1f8f5c7b6 resolve src.db.models 2026-05-14 17:40:51 +03:00
Raykov-MS
3d83a55ad8 conflicts 2026-05-14 17:37:27 +03:00
Raykov-MS
2a3827ff91 Add services endpoints 2026-05-14 17:36:35 +03:00
05d1f2afb0 Merge pull request 'refactor tests for src.db.models... and for new code, message, field error response' (#7) from future/AURORA-1007 into test
Reviewed-on: #7
2026-05-14 17:05:56 +03:00
Raykov-MS
13db21c4af refactor tests for src.db.models... and for new code, message, field error response 2026-05-14 16:22:59 +03:00
748d81ef06 Merge pull request 'future/AURORA-1007' (#4) from future/AURORA-1007 into test
Reviewed-on: #4
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-14 16:13:28 +03:00
Raykov-MS
312c8db04f conflicts 2026-05-14 16:10:32 +03:00
Raykov-MS
0f46157cb5 Merge branch 'future/AURORA-1007' of ssh://git.aepif.ru:2222/RSHB/DFiP_Budget_planing into future/AURORA-1007 2026-05-14 16:05:06 +03:00
Raykov-MS
de000905b3 Add Constants 2026-05-14 16:04:39 +03:00
bdea6f9f4b Merge pull request 'form_124' (#6) from form_124 into test
Reviewed-on: #6
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-14 15:56:09 +03:00
a3540e66c0 Merge pull request 'form_124_write: запись в формы 1 2 4 и тесты' (#5) from form_124_write into form_124
Reviewed-on: #5
Reviewed-by: Raykov-MS <RaykovMS@avt.rshb.ru>
2026-05-14 15:31:55 +03:00
0e639ac433 form_124_write: запись в формы 1 2 4 и тесты 2026-05-14 15:04:25 +03:00
79ad4a444c Merge pull request 'F3 Read/Write endpoints' (#2) from form_3 into form_124
Reviewed-on: #2
Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
2026-05-14 11:26:54 +03:00
Raykov-MS
7b78a817cd delete models.py 2026-05-14 11:25:53 +03:00
Raykov-MS
539bd8c0f9 Refactor for specs requirements 2026-05-14 11:21:26 +03:00
Raykov-MS
280dabfd95 Refactor contract of errors 2026-05-14 11:16:35 +03:00
Raykov-MS
52c0d1806a F3 Read/Write endpoints 2026-05-13 12:28:00 +03:00
Raykov-MS
3d134fb7f9 srez 2026-05-08 13:16:31 +03:00
c67c94ba1f WIP: READ ручки для форм 1 2 4 2026-05-08 12:33:05 +03:00
378 changed files with 208281 additions and 516 deletions

141
api/alembic.ini Normal file
View File

@ -0,0 +1,141 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
api/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

66
api/alembic/env.py Normal file
View File

@ -0,0 +1,66 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from src.core.config import settings
from src.db.base import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_schemas=True,
version_table_schema="v3",
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
include_schemas=True,
version_table_schema="v3",
)
with context.begin_transaction():
context.execute(f"CREATE SCHEMA IF NOT EXISTS v3")
context.run_migrations()
async def run_async_migrations() -> None:
connectable = create_async_engine(
settings.DATABASE_URL,
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,79 @@
import os
import re
from typing import List, Optional
from alembic import op
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> Optional[str]:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> List[str]:
statements: List[str] = []
current: List[str] = []
in_dollar = False
dollar_tag: Optional[str] = None
for line in sql.split("\n"):
stripped = line.strip()
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0002_functions.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

View File

@ -0,0 +1,30 @@
import os
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
has_data = conn.execute(sa.text("SELECT 1 FROM v3.role LIMIT 1")).scalar()
if has_data:
return
sql_path = os.path.join(os.path.dirname(__file__), "sql", "0003_initial_data.sql")
with open(sql_path) as f:
content = f.read()
for stmt in content.split(";\n"):
stripped = stmt.strip()
if not stripped:
continue
op.execute(stripped)
def downgrade() -> None:
pass

View File

@ -0,0 +1,63 @@
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.del_vsp(p_vsp_id integer, p_deleted_by integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_old RECORD;
v_usr RECORD;
BEGIN
SELECT vsp.id, vsp.branch_id
INTO v_old
FROM v3.vsp vsp WHERE vsp.id = p_vsp_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'vsp #% не существует', p_vsp_id;
END IF;
SELECT usr.id
INTO v_usr
FROM v3.app_user usr WHERE usr.id = p_deleted_by;
IF NOT FOUND THEN
RAISE EXCEPTION 'user #% не существует', p_deleted_by;
END IF;
UPDATE v3.vsp
SET is_deleted = true,
is_active = false,
updated_by = p_deleted_by
WHERE id = p_vsp_id;
UPDATE v3.vsp
SET closed_at = CURRENT_DATE
WHERE id = p_vsp_id
AND closed_at IS NULL;
PERFORM v3.log_event(
'DELETE_VSP', 'VSP',
jsonb_build_object(
'vsp_id', p_vsp_id,
'entity_id', p_vsp_id,
'branch_id', v_old.branch_id,
'updated_by', p_deleted_by
),
null, null, v_old.branch_id
);
RETURN p_vsp_id;
END;
$function$;
"""
)
def downgrade() -> None:
pass

View File

@ -0,0 +1,330 @@
from alembic import op
import sqlalchemy as sa
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN opens_at TYPE timestamp USING opens_at::timestamp')
op.execute('ALTER TABLE v3.phase_template ALTER COLUMN closes_at TYPE timestamp USING closes_at::timestamp')
op.execute("ALTER TABLE v3.org_unit ADD COLUMN utc_offset VARCHAR(3) DEFAULT '+03' NOT NULL")
# заводим фазу с нужной таймзоной
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.copy_template_to_form(p_form_id integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_type VARCHAR;
v_inserted INTEGER;
v_utc_offset VARCHAR(3);
v_org_unit INTEGER;
BEGIN
SELECT form_type_code, org_unit_id INTO v_form_type, v_org_unit
FROM v3.budget_form
WHERE id = p_form_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit;
INSERT INTO v3.form_phase
(budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at)
SELECT p_form_id, pt.sheet, pt.phase_code, pt.role,
pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz,
(pt.closes_at::text || v_utc_offset)::timestamptz
FROM v3.phase_template pt
WHERE pt.form_type = v_form_type
ON CONFLICT (budget_form_id, sheet, phase_code) DO NOTHING;
GET DIAGNOSTICS v_inserted = ROW_COUNT;
RETURN v_inserted;
END;
$function$
;
"""
)
# Создавая фазу добавляем таймзону орг юнита
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp, p_closes_at timestamp)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_phase v3.form_phase;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
BEGIN
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
SELECT utc_offset INTO v_utc_offset
FROM v3.org_unit
WHERE id = v_org_unit_id;
INSERT INTO v3.form_phase(
budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at
)
VALUES (
p_budget_form_id,
p_sheet,
p_phase_code,
p_role,
p_column_keys,
CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END,
CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END
)
RETURNING * INTO v_phase;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'form_phase',
'entity_id', p_budget_form_id,
'action', 'create',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'role', v_phase.role,
'column_keys', v_phase.column_keys,
'opens_at', v_phase.opens_at,
'closes_at', v_phase.closes_at
),
p_budget_form_id, null, v_org_unit_id
);
RETURN v_phase;
END;
$function$
;
"""
)
# Обнволяя фазу добавляем таймзону орг юнита
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone)
RETURNS v3.form_phase
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.form_phase;
v_new v3.form_phase;
v_is_extend BOOLEAN := FALSE;
v_has_non_extend_change BOOLEAN := FALSE;
v_has_window_change BOOLEAN := FALSE;
v_changes JSONB := '{}'::jsonb;
v_org_unit_id INT;
v_org_unit_title VARCHAR;
v_form_type_code VARCHAR;
v_year INT;
v_task_name TEXT;
v_phase_id TEXT;
v_utc_offset VARCHAR(3);
v_opens_at timestamptz;
v_closes_at timestamptz;
BEGIN
SELECT * INTO v_old
FROM v3.form_phase
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code;
IF NOT FOUND THEN
RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)',
p_budget_form_id, p_sheet, p_phase_code;
END IF;
SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset
INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset
FROM v3.budget_form bf
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.id = p_budget_form_id;
v_task_name := trim(concat(
COALESCE(v_form_type_code, ''),
CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END,
CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END
));
IF p_opens_at is not null THEN
v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz;
END IF;
IF p_closes_at is not null THEN
v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz;
END IF;
UPDATE v3.form_phase
SET role = COALESCE(p_role, role),
column_keys = COALESCE(p_column_keys, column_keys),
opens_at = COALESCE(v_opens_at, opens_at),
closes_at = COALESCE(v_closes_at, closes_at)
WHERE budget_form_id = p_budget_form_id
AND sheet = p_sheet
AND phase_code = p_phase_code
RETURNING * INTO v_new;
v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code);
v_is_extend := v_new.closes_at > v_old.closes_at;
v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role
OR v_old.column_keys IS DISTINCT FROM v_new.column_keys
OR v_old.opens_at IS DISTINCT FROM v_new.opens_at;
v_has_window_change := v_has_non_extend_change
OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend);
IF v_old.role IS DISTINCT FROM v_new.role THEN
v_changes := v_changes || jsonb_build_object(
'role', jsonb_build_object('before', v_old.role, 'after', v_new.role)
);
END IF;
IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN
v_changes := v_changes || jsonb_build_object(
'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys)
);
END IF;
IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN
v_changes := v_changes || jsonb_build_object(
'opens_at', jsonb_build_object(
'before', v_old.opens_at,
'after', v_new.opens_at
)
);
END IF;
IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN
v_changes := v_changes || jsonb_build_object(
'closes_at', jsonb_build_object(
'before', v_old.closes_at,
'after', v_new.closes_at
)
);
END IF;
IF v_is_extend THEN
PERFORM v3.log_event(
'ACCESS_EXTEND', 'ACCESS',
jsonb_build_object(
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'closes_at_before', v_old.closes_at,
'closes_at_after', v_new.closes_at
),
p_budget_form_id
);
END IF;
IF v_has_window_change THEN
PERFORM v3.log_event(
'ACCESS_WINDOW_CHANGE', 'ACCESS',
jsonb_build_object(
'entity_type', 'form_phase',
'entity_id', p_budget_form_id,
'action', 'update',
'phase_id', v_phase_id,
'phase_name', p_phase_code,
'task_id', p_budget_form_id,
'task_name', NULLIF(v_task_name, ''),
'form_id', p_budget_form_id,
'budget_form_id', p_budget_form_id,
'org_unit_id', v_org_unit_id,
'org_unit_name', v_org_unit_title,
'sheet', p_sheet,
'phase_code', p_phase_code,
'changes', v_changes
),
p_budget_form_id, null, v_org_unit_id
);
END IF;
RETURN v_new;
END;
$function$
;
"""
)
# при обновлении таймзоны орг юнита обновляем актуальные фазы, связанные с ними
op.execute(
"""
CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz varchar(3), p_new_tz varchar(3))
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_result integer;
BEGIN
IF p_old_tz = p_new_tz THEN
RETURN 0;
END IF;
WITH updated AS (
UPDATE v3.form_phase
SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz,
closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz
WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id)
AND (
opens_at >= CURRENT_TIMESTAMP
OR closes_at >= CURRENT_TIMESTAMP
)
RETURNING *
)
SELECT COUNT(*) INTO v_result FROM updated;
RETURN v_result;
END;
$function$
;
"""
)
# op.add_column(
# 'org_unit',
# sa.Column(
# 'utc_offset',
# sa.String(3),
# nullable=False,
# server_default='+03',
# )
# )
def downgrade() -> None:
pass

View File

@ -0,0 +1,79 @@
import os
import re
from typing import List, Optional
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
_DOLLAR_TAG_RE = re.compile(r"\$\w+\$")
def _find_dollar_tag(line: str) -> Optional[str]:
m = _DOLLAR_TAG_RE.search(line.strip())
return m.group(0) if m else None
def _split_statements(sql: str) -> List[str]:
statements: List[str] = []
current: List[str] = []
in_dollar = False
dollar_tag: Optional[str] = None
for line in sql.split("\n"):
stripped = line.strip()
if not in_dollar:
tag = _find_dollar_tag(stripped)
if tag and tag.endswith("$") and tag.startswith("$"):
dollar_tag = tag
in_dollar = True
current.append(line)
continue
if in_dollar and dollar_tag and stripped.startswith(dollar_tag):
after = stripped[len(dollar_tag):].strip()
if after == ";" or after == "":
in_dollar = False
dollar_tag = None
if after == ";":
current.append(line)
statements.append("\n".join(current))
current = []
continue
if not in_dollar and stripped.rstrip().endswith(";"):
current.append(line)
statements.append("\n".join(current))
current = []
continue
current.append(line)
remaining = "\n".join(current).strip()
if remaining:
statements.append(remaining)
return statements
def upgrade() -> None:
ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0006_form3.sql")
with open(ddl_path) as f:
content = f.read()
statements = _split_statements(content)
for stmt in statements:
stripped = stmt.strip().rstrip(";").strip()
if not stripped:
continue
if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")):
continue
op.execute(stripped)
def downgrade() -> None:
pass

View File

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -14,3 +14,4 @@ aiosqlite==0.20.0
asyncpg==0.30.0
pytest==8.3.2
pytest-asyncio==0.24.0
openpyxl==3.1.5

View File

@ -10,8 +10,6 @@ python-multipart==0.0.9
python-dotenv==1.0.0
httpx==0.27.0
alembic==1.16.1
aiosqlite==0.20.0
asyncpg==0.30.0
pytest==8.3.2
pytest-asyncio==0.24.0
raisa-fastapi-protected-api==1.0.0
openpyxl

25
api/src/api/v1/admin.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from src.api.v1.deps import require_admin
from src.db.base import engine
from src.db.models.app_user import AppUser
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/refresh-tree")
async def refresh_tree(current_user: AppUser = Depends(require_admin)):
"""Обновляет MV дерева статей расходов."""
try:
async with engine.connect() as conn:
# CONCURRENTLY требует autocommit-режим.
conn = await conn.execution_options(isolation_level="AUTOCOMMIT")
await conn.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY v3.mv_expense_item_tree"))
except SQLAlchemyError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Не удалось обновить mv_expense_item_tree: {exc}",
) from exc
return {"status": "ok", "message": "mv_expense_item_tree refreshed"}

46
api/src/api/v1/audit.py Normal file
View File

@ -0,0 +1,46 @@
from datetime import datetime
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import require_admin
from src.db.session import get_db
# from src.domain.models import Users
from src.db.models.app_user import AppUser
from src.domain.schemas import AuditLog, AuditLogQueryParams, BaseListResponse
from src.services.auditlog_service import AuditLogService
router = APIRouter(tags=["audit"])
@router.get(
"/audit-logs",
response_model=BaseListResponse[AuditLog],
status_code=status.HTTP_200_OK,
summary="Получение журнала аудита",
description="Возвращает список записей аудита с возможностью фильтрации. Доступно только администраторам.",
)
async def get_audit_logs(
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
params: AuditLogQueryParams = Depends(),
):
offset = (params.page - 1) * params.limit
audit_service = AuditLogService(db)
logs, count = await audit_service.get_all(
user=current_user,
limit=params.limit,
offset=offset,
user_id=params.user_id,
org_unit_id=params.org_unit_id,
task_id=params.task_id,
form_id=params.form_id,
event_type=params.event_type,
event=params.event,
date_from=datetime.combine(params.date_from, datetime.min.time()) if params.date_from else None,
date_to=datetime.combine(params.date_to, datetime.max.time()) if params.date_to else None,
)
result = [audit_service.orm_log_to_response(log) for log in logs]
return BaseListResponse(result=result, count=count)

View File

@ -6,46 +6,46 @@ from src.db.session import get_db
from src.domain.schemas import LoginRequest, RefreshRequest, Token
from src.services.auth_service import AuthService
if not settings.DEBUG:
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
# if not settings.DEBUG:
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
router = APIRouter(prefix="/auth", tags=["auth"])
if settings.DEBUG:
# if settings.DEBUG:
@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user(login_data.username, login_data.password)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
return token
@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user(login_data.username, login_data.password)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
return token
else:
# else:
@router.post("/login", response_model=Token)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db),
user: UserInfo = Depends(get_user_dependency),
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user_via_email(user.email)
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
return token
# @router.post("/login", response_model=Token)
# async def login(
# login_data: LoginRequest,
# db: AsyncSession = Depends(get_db),
# user: UserInfo = Depends(get_user_dependency),
# ):
# auth_service = AuthService(db)
# token = await auth_service.authenticate_user_via_email(user.email)
# if not token:
# raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Неверное имя пользователя или пароль",
# headers={"WWW-Authenticate": "Bearer"},
# )
# return token
@router.post("/login-form", response_model=Token)

View File

@ -5,71 +5,89 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings
from src.core.security import verify_token
from src.db.session import get_db
from src.domain.models import UserRole, Users
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.repository.user_repository import UserRepository
security = HTTPBearer()
if settings.DEBUG:
# if settings.DEBUG:
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> Users:
token = credentials.credentials
payload = verify_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_user_by_token(
token: str,
db: AsyncSession,
) -> AppUser:
payload = verify_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
username: str | None = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
username: str | None = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
user_repo = UserRepository(db)
user = await user_repo.get_by_username(username)
if user is None:
user = await user_repo.get_by_email(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",
headers={"WWW-Authenticate": "Bearer"},
)
return user
user_repo = UserRepository(db)
user = await user_repo.get_by_username(username)
if user is None:
user = await user_repo.get_by_email(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",
headers={"WWW-Authenticate": "Bearer"},
)
return user
else:
from raisa_fastapi_protected_api import UserInfo, get_user_dependency
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> AppUser:
return await get_user_by_token(token=credentials.credentials, db=db)
async def get_current_user(
user: UserInfo = Depends(get_user_dependency),
db: AsyncSession = Depends(get_db),
) -> Users:
user_repo = UserRepository(db)
db_user = await user_repo.get_by_email(user.email)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден",
headers={"WWW-Authenticate": "Bearer"},
)
return db_user
# else:
# from raisa_fastapi_protected_api import UserInfo, get_user_dependency
# async def get_current_user(
# user: UserInfo = Depends(get_user_dependency),
# db: AsyncSession = Depends(get_db),
# ) -> AppUser:
# user_repo = UserRepository(db)
# db_user = await user_repo.get_by_email(user.email)
# if db_user is None:
# raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Пользователь не найден",
# headers={"WWW-Authenticate": "Bearer"},
# )
# return db_user
async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users:
async def get_current_active_user(current_user: AppUser = Depends(get_current_user)) -> AppUser:
return current_user
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users:
if current_user.role_id != UserRole.ADMIN:
async def get_current_active_user_with_set_db(
current_user: AppUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> AppUser:
user_repo = UserRepository(db)
await user_repo.set_app_user_id(current_user.id)
return current_user
def require_admin(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> AppUser:
if current_user.role_id != UserRoleEnum.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав",
@ -77,11 +95,13 @@ def require_admin(current_user: Users = Depends(get_current_active_user)) -> Use
return current_user
def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users:
def require_executor(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> AppUser:
if current_user.role_id not in [
UserRole.ADMIN,
UserRole.EXECUTOR_DFIP,
UserRole.EXECUTOR_RF,
UserRoleEnum.ADMIN,
UserRoleEnum.EXECUTOR_DFIP,
UserRoleEnum.EXECUTOR_RF,
]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -90,8 +110,10 @@ def require_executor(current_user: Users = Depends(get_current_active_user)) ->
return current_user
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users:
if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]:
def require_executor_dfip(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> AppUser:
if current_user.role_id not in [UserRoleEnum.ADMIN, UserRoleEnum.EXECUTOR_DFIP]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав",

View File

@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.services.expense_item_service import ExpenseItemService
from src.db.models.app_user import AppUser
from src.api.v1.deps import require_admin
from src.db.session import get_db
from src.domain.schemas import (
BaseSingleResponse,
ExpenseItemResponseSchema,
)
router = APIRouter(prefix="/expense-item", tags=["expense-item"])
@router.get("/{item_id}", response_model=BaseSingleResponse[ExpenseItemResponseSchema])
async def get_expense_item_by_id(
item_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
"""Получение записи expense item по ID."""
service = ExpenseItemService(db)
result = await service.get(item_id, user=current_user)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Expense Item не найден",
)
return BaseSingleResponse(
success=True,
message="Запись Expense Item",
result=ExpenseItemResponseSchema.model_validate(result),
)

155
api/src/api/v1/export.py Normal file
View File

@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user_with_set_db
from src.db.models.app_user import AppUser
from src.db.session import get_db
from src.domain.schemas import DirectionSchemaEnum, ExportBulkRequest
from src.services.export_service import ExportService
router = APIRouter(prefix="/export", tags=["export"])
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
def _parse_sections_csv(sections: str | None) -> list[str] | None:
if sections is None:
return None
raw = [section.strip() for section in sections.split(",") if section.strip()]
normalized: list[str] = []
seen: set[str] = set()
quarter_tokens = {"q1", "q2", "q3", "q4", "totals"}
for section in raw:
if "." in section:
base_section, nested_section = section.split(".", 1)
nested_section = nested_section.strip()
tokens = [base_section.strip()]
if nested_section in quarter_tokens:
tokens.append(nested_section)
for token in tokens:
if token and token not in seen:
seen.add(token)
normalized.append(token)
continue
if section not in seen:
seen.add(section)
normalized.append(section)
return normalized or None
def _parse_form3_sections_csv(sections: str | None) -> list[str] | None:
if not sections:
return None
parsed = [section.strip() for section in sections.split(",") if section.strip()]
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
if invalid:
raise ValueError(f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
return parsed
@router.get("/form/{form_id}")
async def export_form(
form_id: int,
direction: DirectionSchemaEnum | None = None,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_form_payload(
form_id=form_id,
current_user=current_user,
direction=direction.value if direction else None,
sections=_parse_sections_csv(sections),
ignore_non_applicable_query_params=True,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)
@router.get("/form/{form_id}/sheet/{sheet}")
async def export_form_sheet(
form_id: int,
sheet: str,
direction: DirectionSchemaEnum | None = None,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_form_payload(
form_id=form_id,
current_user=current_user,
sheet=sheet,
direction=direction.value if direction else None,
sections=_parse_sections_csv(sections),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)
@router.post("/bulk")
async def export_forms_bulk(
payload: ExportBulkRequest,
direction: DirectionSchemaEnum | None = None,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_bulk_payload(
form_ids=payload.form_ids,
current_user=current_user,
skip_failed=payload.skip_failed,
direction=direction.value if direction else None,
sections=_parse_sections_csv(sections),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)
@router.get("/project/{project_id}/report/{year}/{report_type}")
async def export_project_report(
project_id: int,
year: int,
report_type: str,
sections: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
export_service = ExportService(db)
try:
stream, filename, media_type = await export_service.export_project_report_payload(
project_id=project_id,
year=year,
report_type=report_type,
current_user=current_user,
sections=_parse_form3_sections_csv(sections),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return StreamingResponse(
stream,
media_type=media_type,
headers={"Content-Disposition": export_service.build_content_disposition(filename)},
)

View File

@ -0,0 +1,170 @@
import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from src.services.org_unit_service import OrgUnitService
from src.db.models.app_user import AppUser
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
FormPhaseCreate,
FormPhaseResponse,
FormPhaseUpdate,
)
from src.services.budget_form_service import BudgetFormService
from src.services.form_phase_service import FormPhaseService
from src.api.v1.deps import (
get_current_active_user_with_set_db,
require_admin,
)
from src.db.session import get_db
router = APIRouter(prefix="/stages", tags=["stages"])
def timezone_from_offset(offset: str) -> datetime.timezone | None:
if not offset:
return None
hours = int(offset[1:])
return datetime.timezone(
datetime.timedelta(
hours=hours if offset[0] == '+' else -hours
)
)
@router.get("/form/{form_id}")
async def get_form_phases(
form_id: int,
sheet: str | None = None,
phase_code: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[FormPhaseResponse]:
fp_service = FormPhaseService(db)
org_unit_service = OrgUnitService(db)
bf_service = BudgetFormService(db)
phases = await fp_service.get_list(
budget_form_id=form_id,
user=current_user,
sheet=sheet,
phase_code=phase_code,
)
if phases:
form = await bf_service.get(user=current_user, budget_form_id=form_id)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
result = [FormPhaseResponse.model_validate(p) for p in phases]
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
for element in result:
if element.opens_at:
element.opens_at = element.opens_at.astimezone(tz)
if element.closes_at:
element.closes_at = element.closes_at.astimezone(tz)
else:
result = []
for element in result:
pass
return BaseListResponse(
result=result,
count=len(phases),
)
@router.post("/form/{form_id}")
async def create_form_phase(
form_id: int,
body: FormPhaseCreate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, "Форма не найдена")
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
fp_service = FormPhaseService(db)
phase = await fp_service.create(
budget_form=form,
body=body,
user=current_user,
)
result = FormPhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
return BaseSingleResponse(result=result)
@router.patch("/form/{form_id}/{sheet}/{phase_code}")
async def update_form_phase(
form_id: int,
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[FormPhaseResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
phase = await fp_service.update(
budget_form=form,
sheet=sheet,
phase_code=phase_code,
body=body,
user=current_user,
)
if not phase:
raise HTTPException(404, "Этап не найден")
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.get(user=current_user, org_unit_id=form.org_unit_id)
result = FormPhaseResponse.model_validate(phase)
if org_unit.utc_offset:
tz = timezone_from_offset(org_unit.utc_offset)
if result.opens_at:
result.opens_at = result.opens_at.astimezone(tz)
if result.closes_at:
result.closes_at = result.closes_at.astimezone(tz)
return BaseSingleResponse(result=result)
@router.delete("/form/{form_id}/{sheet}/{phase_code}")
async def delete_form_phase(
form_id: int,
sheet: str,
phase_code: str,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user)
if not form:
raise HTTPException(404, "Форма не найдена")
fp_service = FormPhaseService(db)
deleted = await fp_service.delete(
budget_form_id=form_id,
sheet=sheet,
phase_code=phase_code,
user=current_user,
)
if not deleted:
raise HTTPException(404, "Этап не найден")

468
api/src/api/v1/forms.py Normal file
View File

@ -0,0 +1,468 @@
import logging
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from src.services.org_unit_service import OrgUnitService
from src.services.budget_line_service import BudgetLineService
from src.db.models.form_type import FormTypeEnum
from src.services.sheet_service import SheetService
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
from src.db.models.app_user import AppUser
from src.services.budget_form_service import BudgetFormService
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
from src.db.session import get_db
router = APIRouter(prefix="/form", tags=["forms"])
SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
logger = logging.getLogger()
def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]:
return [
SheetResponse(
row_type=el[0],
depth=el[1],
sort_order=el[2],
data=el[3],
) for el in result
]
def _parse_sections_csv(sections: Optional[str]) -> Optional[list[str]]:
if sections is None:
return None
parsed = [section.strip() for section in sections.split(",") if section.strip()]
return parsed or None
def _validate_sheet_query_params(
form_type_code: FormTypeEnum,
sheet: str,
direction: Optional[DirectionSchemaEnum],
sections: Optional[list[str]],
) -> None:
if form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS and not direction:
raise HTTPException(400, "direction обязателен для FORM_1 листов AHR/CAP")
if direction and not (
form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
):
raise HTTPException(400, "direction допустим только для FORM_1 листов AHR/CAP")
if sections and sheet not in SHEETS_WITH_SECTIONS:
raise HTTPException(400, f"sections не поддерживается для листа {sheet}")
@router.get("/")
async def get_forms(
offset: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[BudgetFormResponse]:
"""Все строки v3.budget_form для UI-выпадашки."""
bf_service = BudgetFormService(db)
count, forms = await bf_service.get_list(
user=current_user,
with_count=True,
offset=offset,
limit=limit,
load_org=True,
)
return BaseListResponse(
result = [
BudgetFormResponse.model_validate(form) for form in forms
],
count=count,
)
@router.get("/{form_id}")
async def get_form(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_org=True,
)
return BaseSingleResponse(
result = BudgetFormResponse.model_validate(form),
)
@router.get("/{form_id}/sheets")
async def get_form_sheets(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[SheetFormTypeResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(budget_form_id=form_id, user=current_user, load_form_type=True)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
return BaseSingleResponse(
result=SheetFormTypeResponse(
form_type=form.form_type.code,
sheets=form.form_type.sheet_list,
all_sheets=form.form_type.sheet_list_with_directions,
)
)
@router.get("/{form_id}/sheet/{sheet}")
async def get_sheet(
form_id: int,
sheet: str,
response: Response,
direction: Optional[DirectionSchemaEnum] = None,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
sections = _parse_sections_csv(sections)
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
result = await sheet_service.get(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "get_sheet",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.patch("/{form_id}/sheet/{sheet}/cell")
async def update_cell(
form_id: int,
sheet: str,
response: Response,
cell_body: CellPatch,
direction: Optional[DirectionSchemaEnum] = None,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
sections = _parse_sections_csv(sections)
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
result = await sheet_service.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
line_id=cell_body.line_id,
column=cell_body.column,
value=cell_body.value,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "update_form_cell",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
"line_id": cell_body.line_id,
"column": cell_body.column,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.patch("/{form_id}/sheet/{sheet}/cells")
async def update_cells(
form_id: int,
sheet: str,
response: Response,
cells_body: CellsPatch,
direction: Optional[DirectionSchemaEnum] = None,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
sections = _parse_sections_csv(sections)
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, sections)
if sections and (rem_sects := set(sections) - set(form.form_type.section_list)):
raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}")
result = await sheet_service.update_cells(
form_id=form_id,
sheet=sheet,
direction=direction.value if direction else None,
sections=sections,
changes=[c.model_dump() for c in cells_body.changes],
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "update_form_cells",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"user": current_user.id,
"count": len(cells_body.changes)
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.post("/{form_id}/sheet/{sheet}/line")
async def add_line(
form_id: int,
sheet: str,
body: AddLineSchema,
response: Response,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
sheet_service = SheetService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, body.direction, None)
result = await sheet_service.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=body.expense_item_id,
item_id=body.item_id,
section_code=body.section_code,
direction=body.direction.value if body.direction else None,
name=body.name,
internal_order=body.internal_order,
vsp_id=body.vsp_id,
project_id=body.project_id,
justification=body.justification,
contract_number=body.contract_number,
contract_end_date=body.contract_end_date,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "add_form_line",
"form_id": form_id,
"sheet": sheet,
"direction": body.direction,
"user": current_user.id,
"expense_item_id": body.expense_item_id
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.delete("/{form_id}/sheet/{sheet}/line/{row_id}")
async def delete_line(
form_id: int,
sheet: str,
row_id: int,
response: Response,
direction: Optional[DirectionSchemaEnum] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
):
t0 = time.perf_counter()
sheet_service = SheetService(db)
bf_service = BudgetFormService(db)
form = await bf_service.get(
user=current_user,
budget_form_id=form_id,
load_form_type=True,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
if sheet not in form.form_type.sheet_list:
raise HTTPException(404, f"Лист {sheet} формы {form_id} не найден")
_validate_sheet_query_params(form.form_type.code, sheet, direction, None)
result = await sheet_service.delete_line(
form_id=form_id,
sheet=sheet,
row_id=row_id,
direction=direction.value if direction else None,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "del_form_line",
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"user": current_user.id,
"line_id": row_id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
count=len(result),
result=_rows_to_sheet_response(result),
)
@router.post("/")
async def add_form(
response: Response,
form: FormCreateSchema,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseListResponse[BudgetFormResponse]:
t0 = time.perf_counter()
bf_service = BudgetFormService(db)
org_service = OrgUnitService(db)
org_units = await org_service.get_list(user=current_user, org_unit_id=form.org_unit_ids)
org_unit_ids = [o.id for o in org_units]
if len(org_units) != len(form.org_unit_ids):
not_found_ids = ", ".join(
[str(oid) for oid in set(form.org_unit_ids) - set(org_unit_ids)],
)
raise HTTPException(404, f"ССП {not_found_ids} не найдены")
new_forms = await bf_service.bulk_create(
form_type_code=form.form_type_code,
year=form.year,
org_unit_ids=org_unit_ids,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "add_form",
"form_type_code": form.form_type_code,
"user": current_user.id,
"count_orgs": len(form.org_unit_ids),
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],
count=len(new_forms),
)

126
api/src/api/v1/org_unit.py Normal file
View File

@ -0,0 +1,126 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException
from src.api.v1.deps import get_current_active_user, require_admin
from src.db.models.app_user import AppUser
from src.db.session import get_db
from src.domain.schemas import BaseListResponse, BaseSingleResponse, OrgUnitCreateSchema, OrgUnitListSchema, OrgUnitSchema, OrgUnitUpdateSchema, ResponseBase
from src.services.org_unit_service import OrgUnitService
router = APIRouter(prefix="/org-unit", tags=["ssp"])
@router.get("/")
async def org_unit_list(
offset: int = 0,
limit: int = 100,
is_active: bool | None = None,
is_ssp: bool | None = None,
load_users_count: bool = False,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseListResponse[OrgUnitListSchema]:
org_service = OrgUnitService(db)
count, units = await org_service.get_list(
user=current_user,
with_count=True,
offset=offset,
limit=limit,
load_users=load_users_count,
is_active=is_active,
is_ssp=is_ssp,
)
return BaseListResponse(
result=[
OrgUnitListSchema.model_validate(unit) for unit in units
],
count=count,
)
@router.get("/{org_unit_id}")
async def org_unit_id(
org_unit_id: int,
load_users: bool = False,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[OrgUnitSchema]:
org_service = OrgUnitService(db)
unit = await org_service.get(
user=current_user,
org_unit_id=org_unit_id,
load_users=load_users,
)
if not unit:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
return BaseSingleResponse(
result = OrgUnitSchema.model_validate(unit)
)
@router.post("/")
async def create_ssp(
org_data: OrgUnitCreateSchema,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[OrgUnitSchema]:
"""Создание ССП/РФ."""
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.create(
title=org_data.title,
is_ssp=org_data.is_ssp,
utc_offset=org_data.utc_offset,
user=current_user,
)
return BaseSingleResponse(
message="ССП/РФ создана",
result=OrgUnitSchema.model_validate(org_unit),
)
@router.patch("/{org_unit_id}")
async def update_ssp(
org_unit_id: int,
org_data: OrgUnitUpdateSchema,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[OrgUnitSchema]:
"""Редактирование ССП/РФ."""
org_unit_service = OrgUnitService(db)
org_unit = await org_unit_service.update(
org_unit_id=org_unit_id,
user=current_user,
**org_data.model_dump(exclude_unset=True),
)
if not org_unit:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
return BaseSingleResponse(
message="ССП/РФ обновлена",
result=OrgUnitSchema.model_validate(org_unit),
)
@router.delete("/{org_unit_id}")
async def delete_ssp(
org_unit_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> ResponseBase:
"""Удаление ССП/РФ."""
org_unit_service = OrgUnitService(db)
result = await org_unit_service.logical_delete(
org_unit_id=org_unit_id,
user=current_user,
is_active=True,
)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ССП не найден")
return ResponseBase(success=True, message="ССП/РФ удалена")

278
api/src/api/v1/projects.py Normal file
View File

@ -0,0 +1,278 @@
import logging
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user_with_set_db
from src.db.models.app_user import AppUser
from src.db.session import get_db
from src.domain.schemas import (
AddForm3LineBody,
AddProjectBody,
BaseListResponse,
BaseSingleResponse,
CellPatch,
CellsPatch,
SheetResponse,
UpdProjectBody,
)
from src.services.project_service import ProjectService
logger = logging.getLogger()
router = APIRouter(tags=["forms"])
FORM3_ALLOWED_SECTIONS = {"q1", "q2", "q3", "q4", "year"}
def _parse_sections(sections: Optional[str]) -> Optional[list[str]]:
if not sections:
return None
parsed = [s.strip() for s in sections.split(",") if s.strip()]
invalid = sorted(set(parsed) - FORM3_ALLOWED_SECTIONS)
if invalid:
raise HTTPException(400, f"Недопустимые sections для FORM_3: {', '.join(invalid)}")
return parsed
def _rows_to_payload(rows: list[tuple]) -> list[dict]:
return [
{"row_type": row[0], "depth": row[1], "sort_order": row[2], "data": row[3]}
for row in rows
]
@router.get("/projects")
async def get_projects(
offset: int = 0,
limit: int = 100,
year: Optional[int] = None,
branch_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[dict]:
project_service = ProjectService(db)
count, projects = await project_service.get_list(
user=current_user,
with_count=True,
year=year,
branch_id=branch_id,
offset=offset,
limit=limit,
)
return BaseListResponse(result=projects, count=count)
@router.get("/projects/{project_id}")
async def get_project(
project_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseSingleResponse[dict]:
project_service = ProjectService(db)
project = await project_service.get(project_id=project_id, user=current_user)
if not project:
raise HTTPException(404, "Project not found")
return BaseSingleResponse(result=project)
@router.get("/projects/{project_id}/report/{year}/{report_type}")
async def get_project_report(
project_id: int,
year: int,
report_type: str,
response: Response,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
project_service = ProjectService(db)
t0 = time.perf_counter()
rows = await project_service.get_report_rows(
project_id=project_id,
year=year,
report_type=report_type,
sections=_parse_sections(sections),
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "get_project_report",
"project_id": project_id,
"year": year,
"report_type": report_type,
"sections": sections,
"user": current_user.id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))
@router.get("/rf-rollup/{branch_id}/{year}")
async def get_rf_rollup(
branch_id: int,
year: int,
response: Response,
sections: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[SheetResponse]:
project_service = ProjectService(db)
t0 = time.perf_counter()
rows = await project_service.get_rf_rollup_rows(
branch_id=branch_id,
year=year,
sections=_parse_sections(sections),
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}"
extra={
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"handler": "get_project_report",
"branch_id": branch_id,
"year": year,
"sections": sections,
"user": current_user.id,
}
logger.info(
f"db_time: {extra}",
extra=extra,
)
return BaseListResponse(count=len(rows), result=_rows_to_payload(rows))
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cell")
async def upd_form3_cell(
project_id: int,
year: int,
report_type: str,
body: CellPatch,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.upd_form3_cell(
project_id=project_id,
year=year,
report_type=report_type,
line_id=body.line_id,
column=body.column,
value=body.value,
user=current_user,
)
return _rows_to_payload(rows)
@router.patch("/projects/{project_id}/report/{year}/{report_type}/cells")
async def upd_form3_cells(
project_id: int,
year: int,
report_type: str,
body: CellsPatch,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.upd_form3_cells(
project_id=project_id,
year=year,
report_type=report_type,
changes=[change.model_dump() for change in body.changes],
user=current_user,
)
return _rows_to_payload(rows)
@router.post("/projects/{project_id}/report/{year}/{report_type}/line")
async def add_form3_line(
project_id: int,
year: int,
report_type: str,
body: AddForm3LineBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.add_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
expense_item_id=body.expense_item_id,
user=current_user,
)
return _rows_to_payload(rows)
@router.delete("/projects/{project_id}/report/{year}/{report_type}/line/{line_id}")
async def del_form3_line(
project_id: int,
year: int,
report_type: str,
line_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> list[SheetResponse]:
project_service = ProjectService(db)
rows = await project_service.del_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
line_id=line_id,
user=current_user,
)
return _rows_to_payload(rows)
@router.patch("/project/{project_id}")
async def upd_project(
project_id: int,
body: UpdProjectBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
result = await project_service.upd_project(
project_id=project_id,
column=body.column,
value=body.value,
user=current_user,
)
return result
@router.post("/projects")
async def add_project(
body: AddProjectBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
project_id, limit_report_id, current_expenses_report_id = await project_service.add_project(
name=body.name,
year=body.year,
org_unit_id=body.branch_id,
level=body.level,
parent_id=body.parent_id,
project_type=body.project_type,
vsp_format=body.vsp_format,
placement_type=body.placement_type,
object_address=body.object_address,
staff_count=body.staff_count,
total_area=body.total_area,
)
return {
"project_id": project_id,
"limit_report_id": limit_report_id,
"current_expenses_report_id": current_expenses_report_id,
}

View File

@ -1,7 +1,22 @@
from fastapi import APIRouter
from src.api.v1 import auth, users
from src.api.v1 import (
auth, users, admin, audit, forms, form_phases, projects, export, org_unit, vsp, expense_item
)
from src.api.v1 import websocket
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(admin.router)
api_router.include_router(audit.router)
api_router.include_router(forms.router)
api_router.include_router(projects.router)
api_router.include_router(form_phases.router)
api_router.include_router(export.router)
api_router.include_router(websocket.router)
api_router.include_router(org_unit.router)
api_router.include_router(vsp.router)
api_router.include_router(expense_item.router)

View File

@ -1,10 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.deps import get_current_active_user, require_admin, require_executor_dfip
from src.api.v1.deps import (
get_current_active_user,
require_admin,
require_executor_dfip,
)
from src.db.session import get_db
from src.domain.models import Users
from src.domain.schemas import User as UserSchema
from src.db.models.app_user import AppUser
from src.domain.schemas import BaseListResponse, BaseSingleResponse, User as UserSchema, UserAdminListResponse
from src.domain.schemas import (
SSPIDList,
ResponseBase,
@ -20,28 +24,48 @@ from src.services.user_service import UserService
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserSchema)
async def get_current_user_info(current_user: Users = Depends(get_current_active_user)):
return current_user
@router.get("/me", response_model=UserAdminListResponse)
async def get_current_user_info(
current_user: AppUser = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
load_orgs: bool = False,
):
if load_orgs:
user_service = UserService(db)
result = await user_service.get(user_id=current_user.id, load_orgs=True)
return UserAdminListResponse.model_validate(result)
else:
return current_user
@router.get("/", response_model=UserListResponse)
@router.get("/")
async def get_users(
skip: int = 0,
limit: int = 100,
load_orgs: bool = False,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
):
current_user: AppUser = Depends(require_admin),
) -> BaseListResponse[UserAdminListResponse]:
user_service = UserService(db)
users = await user_service.get_all(current_user, skip=skip, limit=limit)
return UserListResponse(success=True, message="Список пользователей", result=users)
count, users = await user_service.get_all(
current_user,
skip=skip,
limit=limit,
load_orgs=load_orgs,
with_count=True
)
return BaseListResponse(
count=count,
result=[UserAdminListResponse.model_validate(user) for user in users]
)
@router.get("/dfip-many-ssp/", response_model=UserSSPLinkResponse)
async def get_many_ssp(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_executor_dfip),
current_user: AppUser = Depends(require_executor_dfip),
):
user_service = UserService(db)
ssp_list = await user_service.get_many_ssp(user_id, current_user)
@ -57,7 +81,7 @@ async def set_many_ssp(
user_id: int,
ssp_ids_data: SSPIDList,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user)
@ -72,7 +96,7 @@ async def unset_many_ssp(
user_id: int,
ssp_ids_data: SSPIDList,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
success = await user_service.unset_many_ssp(
@ -84,39 +108,50 @@ async def unset_many_ssp(
)
@router.get("/{user_id}", response_model=UserSchema)
@router.get("/{user_id}")
async def get_user(
user_id: int,
load_orgs: bool = False,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
):
current_user: AppUser = Depends(require_admin),
) -> UserAdminListResponse:
user_service = UserService(db)
user = await user_service.get(user_id, current_user)
user = await user_service.get(
user_id=user_id,
current_user=current_user,
load_orgs=load_orgs,
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Пользователь не найден",
)
return user
return UserAdminListResponse.model_validate(user)
@router.put("/", response_model=UserResponse)
@router.post("/", response_model=BaseSingleResponse[UserAdminListResponse])
async def create_user(
user_data: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
user = await user_service.create_user(user_data=user_data, creator=current_user)
return UserResponse(success=True, message="Пользователь создан", result=user)
result = UserAdminListResponse.model_validate(user)
if user_data.load_orgs:
result.org_units = []
return BaseSingleResponse(success=True, message="Пользователь создан", result=result)
@router.patch("/{user_id}", response_model=UserResponse)
@router.patch("/{user_id}", response_model=BaseSingleResponse[UserAdminListResponse])
async def update_user(
user_id: int,
user_data: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
user = await user_service.update_user(
@ -124,14 +159,15 @@ async def update_user(
user_data=user_data,
user=current_user,
)
return UserResponse(success=True, message="Пользователь обновлен", result=user)
result = UserAdminListResponse.model_validate(user)
return UserResponse(success=True, message="Пользователь обновлен", result=result)
@router.delete("/{user_id}", response_model=UserResponse)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: Users = Depends(require_admin),
current_user: AppUser = Depends(require_admin),
):
user_service = UserService(db)
await user_service.delete_user(user_id=user_id, user=current_user)

184
api/src/api/v1/vsp.py Normal file
View File

@ -0,0 +1,184 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.api.v1.deps import require_admin, require_executor
from src.db.session import get_db
from src.domain.schemas import (
BaseListResponse,
BaseSingleResponse,
VSPCreate,
VSPExportRequest,
VSPInDB,
VSPUpdate,
)
from src.services.vsp_service import VSPService
router = APIRouter(prefix="/dict/info", tags=["info"])
@router.get("", response_model=BaseListResponse[VSPInDB])
async def get_vsp(
registration_number: str | None = None,
address: str | None = None,
ssp_id: int | None = None,
open_date_start: date | None = None,
open_date_end: date | None = None,
placement_type: str | None = None,
staff_count_min: int | None = None,
staff_count_max: int | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Получение записей справочника INFO."""
service = VSPService(db)
result = await service.get_list(
user=current_user,
registration_number=registration_number,
address=address,
ssp_id=ssp_id,
open_date_start=open_date_start,
open_date_end=open_date_end,
placement_type=placement_type,
staff_count_min=staff_count_min,
staff_count_max=staff_count_max,
load_org_unit=True,
with_count=True,
)
return BaseListResponse(
success=True,
message="Список INFO",
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
count=result[0]
)
@router.get("/dropdown", response_model=BaseListResponse[VSPInDB])
async def get_vsp_dropdown(
ssp_id: int | None = None,
form_id: int | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Получение записей для выпадающего списка INFO."""
service = VSPService(db)
result = await service.get_dropdown(user=current_user, ssp_id=ssp_id, form_id=form_id, with_count=True)
return BaseListResponse(
success=True,
message="Список INFO для выпадающего списка",
result=[VSPInDB.model_validate(vsp) for vsp in result[1]],
count=result[0],
)
@router.post("/export")
async def export_info(
body: VSPExportRequest,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Экспорт справочника INFO."""
service = VSPService(db)
stream, filename = await service.export_xlsx(
user=current_user,
registration_number=body.registration_number,
address=body.address,
ssp_ids=body.ssp_ids,
open_date_start=body.open_date_start,
open_date_end=body.open_date_end,
placement_type=body.placement_type,
staff_count_min=body.staff_count_min,
staff_count_max=body.staff_count_max,
)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("", response_model=BaseSingleResponse[VSPInDB], status_code=201)
async def add_vsp(
body: VSPCreate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
"""Добавление строки в справочник INFO."""
service = VSPService(db)
result = await service.create(
body,
current_user,
load_org_unit=True,
)
return BaseSingleResponse(
success=True,
message="Запись INFO создана",
result=VSPInDB.model_validate(result),
)
@router.get("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
async def get_vsp_by_id(
vsp_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Получение записи справочника INFO по ID."""
service = VSPService(db)
result = await service.get(vsp_id, user=current_user, load_org_unit=True)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ВСП не найден",
)
return BaseSingleResponse(
success=True,
message="Запись INFO",
result=VSPInDB.model_validate(result),
)
@router.put("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
async def update_vsp(
vsp_id: int,
body: VSPUpdate,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_executor),
):
"""Редактирование строки справочника INFO."""
service = VSPService(db)
result = await service.update(
vsp_id,
body,
current_user,
load_org_unit=True,
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ВСП не найден",
)
return BaseSingleResponse(
success=True,
message="Запись INFO обновлена",
result=VSPInDB.model_validate(result),
)
@router.delete("/{vsp_id}", response_model=BaseSingleResponse[VSPInDB])
async def delete_vsp(
vsp_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
):
"""Удаление строки справочника INFO."""
service = VSPService(db)
if await service.logical_delete(vsp_id, current_user):
return BaseSingleResponse(success=True, message="Запись INFO удалена")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"ВСП не найден",
)

914
api/src/api/v1/websocket.py Normal file
View File

@ -0,0 +1,914 @@
import asyncio
from contextlib import asynccontextmanager
from dataclasses import asdict
import dataclasses
import enum
from json import dumps, loads
import logging
import time
from typing import Any, Optional
#
from asyncpg import UniqueViolationError
from sqlalchemy.exc import IntegrityError
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.services.rf_project_report_service import RfProjectReportService
from src.services.rf_project_report_line_service import RfProjectReportLineService
from src.repository.user_repository import UserRepository
from src.services.budget_line_service import BudgetLineService
from src.api.v1.deps import get_user_by_token
from src.db.models.app_user import AppUser
from src.db.models.form_type import FormTypeEnum
from src.services.budget_form_service import BudgetFormService
from src.services.project_service import ProjectService
from src.services.sheet_service import SheetService
from src.core.errors import BasicAppException, ValidationsError
from src.db.session import SessionLocal
from src.services.user_service import UserService
logger = logging.getLogger()
@asynccontextmanager
async def get_db_session(user_id: int | None = None):
"""Контекстный менеджер для получения сессии базы данных."""
db = SessionLocal()
try:
if user_id is not None:
user_repo = UserRepository(db)
await user_repo.set_app_user_id(user_id=user_id)
yield db
finally:
await db.close()
router = APIRouter(prefix="/ws", tags=["websocket"])
@dataclasses.dataclass
class ConnectionInfo:
ws: WebSocket
user_id: int | None
@dataclasses.dataclass
class FormConnectionInfo(ConnectionInfo):
form_id: int | None = None
sheet: str | None = None
direction: str | None = None
@dataclasses.dataclass
class ProjectConnectionInfo(ConnectionInfo):
project_id: int | None = None
year: int | None = None
report_type: str | None = None
class ConnectionKeyEnum(str, enum.Enum):
FORM = "FORM"
PROJECT = "PROJECT"
class ConnectionManager:
con_info_mapping = {
ConnectionKeyEnum.FORM: FormConnectionInfo,
ConnectionKeyEnum.PROJECT: ProjectConnectionInfo,
}
def __init__(self):
self.connections: dict[int, dict[ConnectionKeyEnum, list[ConnectionInfo]]] = {
ConnectionKeyEnum.FORM: {},
ConnectionKeyEnum.PROJECT: {},
}
self.cell_locks: dict[Any, int] = {}
async def connect(
self,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
**kwargs,
# form_id: int,
# sheet: str,
# direction: str | None = None,
):
await websocket.accept()
key = frozenset(kwargs.items())
cls = self.con_info_mapping[con_key]
if key not in self.connections[con_key]:
self.connections[con_key][key] = [
cls(
ws=websocket,
user_id=None,
**kwargs,
)
]
else:
self.connections[con_key][key].append(
cls(
ws=websocket,
user_id=None,
**kwargs,
)
)
def set_user(
self,
websocket: WebSocket,
user_id: int,
con_key: ConnectionKeyEnum,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
for con_info in self.connections[con_key][key]:
if con_info.ws == websocket:
con_info.user_id = user_id
async def disconnect(
self,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
code: int = status.WS_1008_POLICY_VIOLATION,
reason: str = "Ошибка",
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
for con_info in self.connections[con_key][key]:
if con_info.ws == websocket:
self.connections[con_key][key].remove(con_info)
if not len(self.connections[con_key][key]):
del self.connections[con_key][key]
self.release_locks_for_user(con_key=con_key, user_id=con_info.user_id, form_key=kwargs)
await websocket.close(
code=code, reason=reason,
)
async def broadcast_to_other(
self,
data: dict,
websocket: WebSocket,
con_key: ConnectionKeyEnum,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
data = data.copy()
data["is_self"] = False
message = dumps(data, default=_convert_error, ensure_ascii=False)
for con_info in self.connections[con_key][key]:
if con_info.ws != websocket:
try:
await con_info.ws.send_text(message)
except:
pass # Игнорируем недоступные соединения
async def broadcast_to_all(
self,
data: dict,
con_key: ConnectionKeyEnum,
websocket: WebSocket | None = None,
**kwargs,
):
key = frozenset(kwargs.items())
if key not in self.connections[con_key]:
return
data = data.copy()
if websocket is None:
data["is_self"] = None
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
self_message = None
else:
data["is_self"] = False
default_message = dumps(data, default=_convert_error, ensure_ascii=False)
data["is_self"] = True
self_message = dumps(data, default=_convert_error, ensure_ascii=False)
for con_info in self.connections[con_key][key]:
try:
if websocket is not None and con_info.ws == websocket:
await con_info.ws.send_text(self_message)
else:
await con_info.ws.send_text(default_message)
except:
pass # Игнорируем недоступные соединения
async def send_back(self, data: dict, websocket: WebSocket):
data = data.copy()
data["is_self"] = True
message = dumps(data, default=_convert_error, ensure_ascii=False)
await websocket.send_text(message)
async def get_data(self, websocket: WebSocket) -> dict:
return loads(await websocket.receive_text())
def acquire_cell_lock(
self,
con_key: ConnectionKeyEnum,
user_id: int,
cell_key: dict,
form_key: dict,
) -> bool:
if not cell_key:
return False
cell_key = frozenset(cell_key.items())
lock_key = (con_key, frozenset(form_key.items()), cell_key)
lock_owner_id = self.cell_locks.get(lock_key)
if lock_owner_id is not None and lock_owner_id != user_id:
return False
self.cell_locks[lock_key] = user_id
return True
def release_cell_lock(
self,
con_key: ConnectionKeyEnum,
user_id: int,
cell_key: dict,
form_key: dict,
):
if not cell_key:
return
cell_key = frozenset(cell_key.items())
lock_key = (con_key, frozenset(form_key.items()), cell_key)
lock_owner_id = self.cell_locks.get(lock_key)
if lock_owner_id == user_id:
self.cell_locks.pop(lock_key, None)
def release_locks_for_user(
self,
con_key: ConnectionKeyEnum,
user_id: int,
form_key: dict
):
filter_key = (con_key, frozenset(form_key.items()))
lock_keys = [
lock_key
for lock_key, lock_owner_id in self.cell_locks.items()
if lock_key[:-1] == filter_key and lock_owner_id == user_id
]
for lock_key in lock_keys:
self.cell_locks.pop(lock_key, None)
def is_locked_by_other(
self,
con_key: ConnectionKeyEnum,
user_id: int,
cell_key: dict,
form_key,
) -> bool:
if not cell_key:
return False
cell_key = frozenset(cell_key.items())
lock_owner_id = self.cell_locks.get((con_key, frozenset(form_key.items()), cell_key))
return lock_owner_id is not None and lock_owner_id != user_id
def is_row_locked_by_other(
self,
con_key: ConnectionKeyEnum,
user_id: int,
line_id: int,
) -> bool:
if line_id is None:
return False
for (dict_con_key, form_key, cell_key), lock_owner_id in self.cell_locks.items():
if dict_con_key != con_key:
continue
if form_key != form_key or lock_owner_id == user_id:
continue
for k, v in cell_key:
if k == "line_id" and v == line_id:
return True
return False
def release_locks_for_row(
self,
con_key: ConnectionKeyEnum,
user_id: int,
line_id: int,
) -> bool:
if line_id is None:
return False
lock_keys = []
for lock_key, lock_owner_id in self.cell_locks.items():
dict_con_key, form_key, cell_key = lock_key
if dict_con_key != con_key:
continue
if form_key != form_key or lock_owner_id == user_id:
continue
for k, v in cell_key.items():
if k == "line_id" and v == line_id:
lock_keys.append(lock_key)
for lock_key in lock_keys:
self.cell_locks.pop(lock_key, None)
class FormEventProcess:
def __init__(self, db: AsyncSession):
self.sheet_service: SheetService = SheetService(db)
self.bf_service: BudgetFormService = BudgetFormService(db)
self.bl_service: BudgetLineService = BudgetLineService(db)
self.user_service: UserService = UserService(db)
async def process(
self,
event_data: dict,
form_id: int,
user_id: int,
sheet: str,
direction: str | None = None,
) -> int | bool | dict:
curr_user: AppUser = await self.user_service.get(user_id)
match event_data["event"]:
case "cell_updated":
return await self.__update_cell(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case "row_added":
return await self.__add_row(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case "row_deleted":
return await self.__del_row(
event_data=event_data["data"],
form_id=form_id,
user=curr_user,
sheet=sheet,
direction=direction,
)
case _:
pass
async def __update_cell(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None = None,
) -> list[tuple]:
"""
event_data: {
"line_id": int,
"column": str,
"value": any,
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
return await self.sheet_service.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=None,
line_id=event_data["line_id"],
column=event_data["column"],
value=event_data["value"],
user=user,
)
async def __add_row(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None,
) -> list[tuple]:
"""
data = {
expense_item_id: Optional[int] = None
item_id: Optional[str] = None
section_code: Optional[str] = None
name: Optional[str] = None
internal_order: Optional[str] = None
vsp_id: Optional[int] = None
project_id: Optional[int] = None
justification: Optional[str] = None
contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = None
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
ids = await self.bl_service.get_ids(budget_form_id=form_id)
result = await self.sheet_service.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=event_data.get("expense_item_id"),
item_id=event_data.get("item_id"),
section_code=event_data.get("section_code"),
direction=direction,
name=event_data.get("name"),
internal_order=event_data.get("internal_order"),
vsp_id=event_data.get("vsp_id"),
project_id=event_data.get("project_id"),
justification=event_data.get("justification"),
contract_number=event_data.get("contract_number"),
contract_end_date=event_data.get("contract_end_date"),
user=user,
)
final_result = {
"data": result,
"new_line_id": None,
}
for el in result:
if el[3]["line_id"] and el[3]["line_id"] not in ids:
final_result["new_line_id"] = el[3]["line_id"]
return final_result
return final_result
async def __del_row(
self,
event_data: dict,
form_id: int,
sheet: str,
user: AppUser,
direction: str | None,
) -> list[tuple]:
"""
data = {
"row_id": int,
}
"""
form = await self.bf_service.get(budget_form_id=form_id, user=user)
if not form:
return None
result = await self.sheet_service.delete_line(
form_id=form_id,
sheet=sheet,
row_id=event_data["row_id"],
direction=direction,
user=user,
)
return result
class ProjectEventProcess:
def __init__(self, db: AsyncSession):
self.project_service: ProjectService = ProjectService(db)
self.prl_service: RfProjectReportLineService = RfProjectReportLineService(db)
self.pr_service: RfProjectReportService = RfProjectReportService(db)
self.user_service: UserService = UserService(db)
async def process(
self,
event_data: dict,
project_id: int,
user_id: int,
year: int,
report_type: str,
) -> int | bool | dict:
curr_user: AppUser = await self.user_service.get(user_id)
match event_data["event"]:
case "cell_updated":
return await self.__update_cell(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case "row_added":
return await self.__add_row(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case "row_deleted":
return await self.__del_row(
event_data=event_data["data"],
project_id=project_id,
year=year,
report_type=report_type,
user=curr_user,
)
case _:
pass
async def __update_cell(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
event_data: {
"line_id": int,
"column": str,
"value": any,
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return await self.project_service.upd_form3_cell(
project_id=project_id,
year=year,
report_type=report_type,
line_id=event_data["line_id"],
column=event_data["column"],
value=event_data["value"],
user=user,
)
async def __add_row(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
expense_item_id: Optional[int] = None
}
"""
project_report = await self.pr_service.get(
year=year,
report_type=report_type,
project_id=project_id,
)
if not project_report:
return None
ids = await self.prl_service.get_ids(project_report_id=project_report.id)
result = await self.project_service.add_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
expense_item_id=event_data["expense_item_id"],
user=user,
)
final_result = {
"data": result,
"new_line_id": None,
}
for el in result:
if el[3]["line_id"] and el[3]["line_id"] not in ids:
final_result["new_line_id"] = el[3]["line_id"]
return final_result
return final_result
async def __del_row(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
"row_id": int,
}
"""
project = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return await self.project_service.del_form3_line(
project_id=project_id,
year=year,
report_type=report_type,
line_id=event_data["row_id"],
user=user,
)
manager = ConnectionManager()
def _convert_error(o):
try:
return asdict(o)
except TypeError:
return o
async def login(websocket: WebSocket, **kwargs) -> int:
user_data = await manager.get_data(websocket=websocket)
if user_data.get("event") != "user_login":
return None
async with get_db_session() as db:
user = await get_user_by_token(token=user_data["data"].get("token"), db=db)
if not user:
return None
manager.set_user(
websocket=websocket,
user_id=user.id,
**kwargs,
)
return user.id
def resolve_cell_key(event_data: dict) -> dict | None:
data = event_data.get("data")
if not data or "line_id" not in data or "column" not in data:
return None
return {
"line_id": data["line_id"],
"column": data["column"],
}
async def process_websocket(
websocket: WebSocket,
processor_cls,
**kwargs,
):
await manager.connect(
websocket=websocket,
**kwargs,
)
kwargs_process = kwargs.copy()
if "con_key" in kwargs_process:
kwargs_process.pop("con_key")
con_key = kwargs.get("con_key")
try:
user_id = await login(
websocket=websocket,
**kwargs,
)
if user_id is None:
await manager.disconnect(
websocket=websocket,
reason="Ошибка авторизации",
**kwargs,
)
return
while True:
data = loads(await websocket.receive_text())
try:
event_name = data.get("event")
cell_key = resolve_cell_key(data)
match event_name:
case "cell_edit_start":
if not manager.acquire_cell_lock(
con_key=con_key,
user_id=user_id,
cell_key=cell_key,
form_key=kwargs_process,
):
data["error"] = "Ячейка уже редактируется другим пользователем"
await manager.send_back(
data,
websocket,
)
continue
await manager.send_back(
data,
websocket,
)
await manager.broadcast_to_other(
data=data,
# user_id=user_id,
websocket=websocket,
con_key=con_key,
**kwargs_process,
)
continue
case "cell_edit_end":
manager.release_cell_lock(
con_key=con_key,
user_id=user_id,
cell_key=cell_key,
form_key=kwargs_process,
)
await manager.send_back(
data,
websocket,
)
await manager.broadcast_to_other(
data=data,
# user_id=user_id,
websocket=websocket,
con_key=con_key,
**kwargs_process,
)
continue
case "cell_updated":
if manager.is_locked_by_other(
con_key=con_key,
user_id=user_id,
cell_key=cell_key,
form_key=kwargs_process,
):
data["error"] = "Ячейка уже редактируется другим пользователем"
await manager.send_back(
data,
websocket,
)
continue
if cell_key and not manager.acquire_cell_lock(
con_key=con_key,
user_id=user_id,
cell_key=cell_key,
form_key=kwargs_process,
):
data["error"] = "Ячейка уже редактируется другим пользователем"
await manager.send_back(
data,
websocket,
)
continue
case "row_deleted":
if manager.is_row_locked_by_other(
con_key=con_key,
user_id=user_id,
line_id=data["data"]["row_id"]
):
data["error"] = (
"Нельзя удалить строку: в этой строке есть редактируемые ячейки"
)
await manager.send_back(
data,
websocket,
)
continue
case _:
pass
async with get_db_session(user_id=user_id) as db:
processor = processor_cls(db)
t0 = time.perf_counter()
data_old = data.copy()
data["result"] = await processor.process(
event_data=data,
user_id=user_id,
**kwargs_process
)
await db.commit()
db_ms = (time.perf_counter() - t0) * 1000
extra = {
"type": "db_time",
"time_ms": f"{db_ms:.2f}",
"event": data.get("event"),
}
extra.update(data_old)
logger.info(
f"db_time: {extra}",
extra=extra,
)
if data.get("event") == "row_deleted":
manager.release_locks_for_row(
con_key=con_key,
user_id=user_id,
line_id=data["data"]["row_id"]
)
if data.get("event") in [
"cell_updated",
"row_added",
"row_deleted",
]:
await manager.broadcast_to_all(
data=data,
websocket=websocket,
# user_id=user_id,
**kwargs
)
else:
await manager.broadcast_to_other(
data=data,
# user_id=user_id,
websocket=websocket,
**kwargs,
)
except BasicAppException as e:
data["error"] = e.description or "Неизвестная ошибка"
await manager.send_back(
data=data,
websocket=websocket,
)
except IntegrityError as e:
data["error"] = str(e)
await manager.send_back(
data=data,
websocket=websocket,
)
except Exception as e:
tp = type(e)
handlers = websocket.app.exception_handlers
if tp in handlers:
data["error"] = loads((await handlers[tp](request=None, exc=e)).body)
await manager.send_back(
data=data,
websocket=websocket,
)
else:
raise e
except WebSocketDisconnect:
await manager.disconnect(
websocket=websocket,
**kwargs
)
except Exception as e:
# Логируем ошибку, но не бросаем HTTPException — это WebSocket
print(f"Error: {e}")
await manager.disconnect(
websocket=websocket,
**kwargs,
)
@router.websocket("/form/{form_id}/sheet/{sheet}")
async def websocket_form(
websocket: WebSocket,
form_id: int,
sheet: str,
direction: Optional[str] = None,
):
await process_websocket(
websocket=websocket,
form_id=form_id,
sheet=sheet,
direction=direction,
processor_cls=FormEventProcess,
con_key=ConnectionKeyEnum.FORM,
)
@router.websocket("/projects/{project_id}/report/{year}/{report_type}")
async def websocket_project(
websocket: WebSocket,
project_id: int,
year: int,
report_type: str,
):
await process_websocket(
websocket=websocket,
project_id=project_id,
year=year,
report_type=report_type,
processor_cls=ProjectEventProcess,
con_key=ConnectionKeyEnum.PROJECT,
)

31
api/src/core/CONSTANTS.py Normal file
View File

@ -0,0 +1,31 @@
VALIDATION_PREFIXES = (
"computed_field:",
"normative_field:",
"unknown_column:",
"key_field:",
"structural_field:",
"bad_column_format:",
"bad_booking_year:",
"bad_booking_quarter:",
"unsupported_scope:",
)
PG_404_ERRORS = (
"не существует",
"не принадлежит",
"not found",
)
PG_409_ERRORS = (
"unique",
"duplicate",
"foreign key",
)
PG_400_ERRORS = (
"violates check constraint",
)
PG_403_ERRORS = (
"permission denied",
"insufficient privilege",
"role_not_allowed:",
"window_closed:",
)

View File

@ -4,10 +4,7 @@ from typing import List
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
if os.environ.get("APP_ENV", "dev") == "test":
prefix = "OPENBAO__SETTINGS_TEST"
else:
prefix = "OPENBAO__SETTINGS"
prefix = "OPENBAO__SETTINGS"
class Settings(BaseSettings):
@ -105,6 +102,11 @@ class Settings(BaseSettings):
description="Размер батча для очистки аудита",
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE",
)
AUDIT_LOG_CLEANUP_INTERVAL_SECONDS: int = Field(
default=86400,
description="Интервал автозапуска очистки аудита в секундах",
alias="AUDIT_LOG_CLEANUP_INTERVAL_SECONDS",
)
model_config = SettingsConfigDict(
env_file=".env",

View File

@ -1,6 +1,8 @@
from pydantic import BaseModel, Field
class ValidationError(BaseModel):
field: str = Field(...)
description: str = Field(...)
@ -10,6 +12,14 @@ class BasicAppException(Exception):
description: str | None = None
class ValidationsError(BasicAppException):
errors: dict[str, list[str]]
def __init__(self, errors, *args):
super().__init__(*args)
self.errors = errors
class AccessDeniedException(BasicAppException):
description = "Доступ запрещен"

View File

@ -1,5 +1,7 @@
from fastapi import FastAPI, status
from fastapi import FastAPI, HTTPException, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError
from src.core.errors import (
AccessDeniedException,
@ -7,41 +9,176 @@ from src.core.errors import (
UserNotFoundException,
UsernameConflictException,
ValidationException,
ValidationsError,
)
from src.core.CONSTANTS import (
VALIDATION_PREFIXES,
PG_404_ERRORS,
PG_409_ERRORS,
PG_400_ERRORS,
PG_403_ERRORS,
)
def _error_payload(code: int, message: str, field: str = "") -> dict:
"""Формирует структурированный payload ошибки по спеке."""
return {
"code": code,
"field": field,
"message": message,
}
def _parse_prefixed_validation_error(message: str) -> str:
for prefix in VALIDATION_PREFIXES:
if message.lower().startswith(prefix):
field = message.split(":", 1)[1].strip() if ":" in message else ""
return field or ""
return ""
def _first_db_message_line(message: str) -> str:
"""Возвращает первую непустую строку текста ошибки БД."""
for line in message.splitlines():
line = line.strip()
if line:
return line
return message.strip()
def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, int, str, str]:
"""Маппинг ошибок SQLAlchemy в HTTP-статус + code/message/field."""
raw = str(getattr(exc, "orig", exc) or exc).strip()
message = _first_db_message_line(raw) or "Database error"
low = message.lower()
if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
field = _parse_prefixed_validation_error(message)
return status_code, status_code, message, field
if any(error in low for error in PG_404_ERRORS):
status_code = status.HTTP_404_NOT_FOUND
return status_code, status_code, message, ""
if any(error in low for error in PG_403_ERRORS):
status_code = status.HTTP_403_FORBIDDEN
return status_code, status_code, message, ""
if isinstance(exc, IntegrityError):
if any(error in low for error in PG_409_ERRORS):
status_code = status.HTTP_409_CONFLICT
return status_code, status_code, message, ""
if any(error in low for error in PG_400_ERRORS):
status_code = status.HTTP_400_BAD_REQUEST
return status_code, status_code, message, ""
status_code = status.HTTP_400_BAD_REQUEST
return status_code, status_code, message, ""
if isinstance(exc, OperationalError):
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return status_code, status_code, "Database unavailable", ""
status_code = status.HTTP_400_BAD_REQUEST
return status_code, status_code, message, ""
def map_http_exception(exc: HTTPException) -> tuple[int, int, str, str]:
"""Маппинг HTTPException в единый формат ошибки API."""
status_code = int(exc.status_code)
detail = exc.detail
if isinstance(detail, dict):
message = str(detail.get("message") or detail.get("detail") or "HTTP error")
field = detail.get("field")
field_str = str(field) if field is not None else ""
return status_code, status_code, message, field_str
message = str(detail) if detail else "HTTP error"
return status_code, status_code, message, ""
def map_request_validation_error(exc: RequestValidationError) -> tuple[int, int, str, str]:
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
errors = exc.errors()
if not errors:
return status_code, status_code, "Validation error", ""
first_error = errors[0]
message = str(first_error.get("msg") or "Validation error")
loc = first_error.get("loc") or ()
field = ".".join(str(part) for part in loc if part not in ("body", "query", "path", "header", "cookie"))
return status_code, status_code, message, field
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(request, exc: RequestValidationError):
status_code, code, message, field = map_request_validation_error(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc: HTTPException):
status_code, code, message, field = map_http_exception(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
headers=exc.headers,
)
@app.exception_handler(AccessDeniedException)
async def access_denied_exception_handler(request, exc: AccessDeniedException):
status_code = status.HTTP_403_FORBIDDEN
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description),
)
@app.exception_handler(ValidationException)
async def validation_exception_handler(request, exc: ValidationException):
status_code = status.HTTP_400_BAD_REQUEST
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description, field=exc.field or ""),
)
@app.exception_handler(UserNotFoundException)
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
status_code = status.HTTP_404_NOT_FOUND
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description),
)
@app.exception_handler(UsernameConflictException)
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
status_code = status.HTTP_409_CONFLICT
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": exc.description},
status_code=status_code,
content=_error_payload(status_code, exc.description),
)
@app.exception_handler(BasicAppException)
async def basic_app_exception_handler(request, exc: BasicAppException):
message = exc.description or "Application error"
status_code = status.HTTP_400_BAD_REQUEST
return JSONResponse(
status_code=status_code,
content=_error_payload(status_code, message),
)
@app.exception_handler(SQLAlchemyError)
@app.exception_handler(DBAPIError)
async def sqlalchemy_exception_handler(request, exc: SQLAlchemyError):
status_code, code, message, field = map_sqlalchemy_error(exc)
return JSONResponse(
status_code=status_code,
content=_error_payload(code, message, field=field),
)
@app.exception_handler(ValidationsError)
async def validations_error_handler(request, exc: ValidationsError):
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.description or "Application error"},
content=exc.errors,
)

View File

@ -7,12 +7,15 @@ connect_args = {}
if settings.DATABASE_SCHEMA:
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False
engine = create_async_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
connect_args=connect_args,
)

View File

@ -0,0 +1,32 @@
from src.db.models.allocation import Allocation
from src.db.models.app_user import AppUser
from src.db.models.audit_log import AuditLog
from src.db.models.booking import Booking
from src.db.models.budget_form import BudgetForm
from src.db.models.budget_line import BudgetLine
from src.db.models.budget_line_quarter import BudgetLineQuarter
from src.db.models.ckk import Ckk
from src.db.models.collegial_approval import CollegialApproval
from src.db.models.contract_detail import ContractDetail
from src.db.models.contract_summary import ContractSummary
from src.db.models.expense_item import ExpenseItem
from src.db.models.form3_phase import Form3Phase
from src.db.models.form_limit import FormLimit
from src.db.models.form_phase import FormPhase
from src.db.models.form_type import FormType
from src.db.models.limit_template import LimitTemplate
from src.db.models.org_unit import OrgUnit
from src.db.models.phase_template import PhaseTemplate
from src.db.models.plan import Plan
from src.db.models.project import Project
from src.db.models.rent_detail import RentDetail
from src.db.models.reserve import Reserve
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.db.models.rf_project_report_quarter import RfProjectReportQuarter
from src.db.models.role import Role, UserRoleEnum
from src.db.models.security_detail import SecurityDetail
from src.db.models.sequestration import Sequestration
from src.db.models.user_org import UserOrg
from src.db.models.utility_detail import UtilityDetail
from src.db.models.vsp import Vsp

View File

@ -0,0 +1,19 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Allocation(Base):
__tablename__ = "allocation"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
internal_order: Mapped[str | None] = mapped_column(String)
property_object: Mapped[str | None] = mapped_column(String)
contract_ref: Mapped[str | None] = mapped_column(String)
allocation_purpose: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AppUser(Base):
__tablename__ = "app_user"
__table_args__ = (
Index(
"ix_v4_app_user_active",
"is_active",
postgresql_where="is_active",
),
Index("ix_v4_app_user_role", "role_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String)
username: Mapped[str] = mapped_column(String)
hashed_password: Mapped[str] = mapped_column(String)
full_name: Mapped[str | None] = mapped_column(String)
role_id: Mapped[int] = mapped_column(ForeignKey("v3.role.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# role: Mapped["Role"] = relationship("Role", back_populates="users") # type: ignore
# created_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="creator", foreign_keys="BudgetForm.created_by"
# )
# updated_budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="updater", foreign_keys="BudgetForm.updated_by"
# )
# created_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="creator",
# foreign_keys="RfProjectReport.created_by",
# )
# updated_rf_reports: Mapped[list["RfProjectReport"]] = relationship( # type: ignore
# "RfProjectReport",
# back_populates="updater",
# foreign_keys="RfProjectReport.updated_by",
# )
audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
"AuditLog", back_populates="user"
)
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="user")
org_units: Mapped[list["OrgUnit"]] = relationship("OrgUnit", secondary="v3.user_org", viewonly=True)

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
JSON,
String,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_log"
__table_args__ = (
Index("ix_v3_audit_log_dt", text("event_dt DESC")),
Index("ix_v3_audit_log_event", "event"),
Index(
"ix_v3_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v3_audit_log_user", "user_id"),
Index("ix_v4_audit_log_dt", text("event_dt DESC")),
Index("ix_v4_audit_log_event", "event"),
Index(
"ix_v4_audit_log_form",
"form_id",
postgresql_where="form_id IS NOT NULL",
),
Index("ix_v4_audit_log_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.app_user.id", ondelete="SET NULL")
)
org_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("v3.org_unit.id", ondelete="SET NULL")
)
task_id: Mapped[int | None] = mapped_column()
form_id: Mapped[int | None] = mapped_column()
event_dt: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
event: Mapped[str] = mapped_column(String)
event_type: Mapped[str] = mapped_column(String)
event_data: Mapped[dict | None] = mapped_column(JSON)
user: Mapped["AppUser | None"] = relationship(
"AppUser", back_populates="audit_logs"
)
# org_unit: Mapped["OrgUnit | None"] = relationship(
# "OrgUnit", back_populates="audit_logs"
# )

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Booking(Base):
__tablename__ = "booking"
__table_args__ = (
CheckConstraint("quarter BETWEEN 1 AND 4", name="ck_v3_booking_quarter"),
CheckConstraint("source IN ('CKK','CONTRACT')", name="ck_v3_booking_source"),
Index("ix_v3_booking_line", "line_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
quarter: Mapped[int] = mapped_column(SmallInteger)
source: Mapped[str] = mapped_column(String)
booked_amount_ckk: Mapped[float | None] = mapped_column(Numeric)
booked_amount_contract: Mapped[float | None] = mapped_column(Numeric)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,61 @@
from datetime import datetime
import typing
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.models.form_type import FormTypeEnum
from src.db.base import Base
class BudgetForm(Base):
__tablename__ = "budget_form"
__table_args__ = {"schema": "v3"}
id: Mapped[int] = mapped_column(primary_key=True)
form_type_code: Mapped[FormTypeEnum] = mapped_column( #type: ignore
String, ForeignKey("v3.form_type.code")
)
year: Mapped[int | None] = mapped_column(Integer)
created_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_created_by")
)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, server_default=func.now()
)
updated_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id", name="fk_v3_budget_form_updated_by")
)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime,
default=func.now(),
onupdate=func.now(),
)
org_unit_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
)
form_type: Mapped["FormType"] = relationship(
"FormType", #back_populates="budget_forms"
)
# creator: Mapped[typing.Optional["AppUser"]] = relationship(
# "AppUser", back_populates="created_budget_forms", foreign_keys=[created_by]
# )
# updater: Mapped[typing.Optional["AppUser"]] = relationship(
# "AppUser", back_populates="updated_budget_forms", foreign_keys=[updated_by]
# )
org_unit: Mapped[typing.Optional["OrgUnit"]] = relationship(
"OrgUnit", #back_populates="budget_forms"
)
# budget_lines: Mapped[list["BudgetLine"]] = relationship(
# "BudgetLine", back_populates="budget_form"
# )
# fixed_asset_reports: Mapped[list["FixedAssetReport"]] = relationship(
# "FixedAssetReport", back_populates="budget_form"
# )
# form_phases: Mapped[list["FormPhase"]] = relationship(
# "FormPhase", back_populates="budget_form"
# )
# form_limits: Mapped[list["FormLimit"]] = relationship(
# "FormLimit", back_populates="budget_form"
# )

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLine(Base):
__tablename__ = "budget_line"
__table_args__ = (
Index("ix_v3_budget_line_form_item", "budget_form_id", "expense_item_id"),
Index("ix_v3_budget_line_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id"))
expense_item_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
name: Mapped[str | None] = mapped_column(String)
internal_order: Mapped[str | None] = mapped_column(String)
vsp_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
project_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
justification: Mapped[str | None] = mapped_column(String)
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
direction: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm", back_populates="budget_lines")
# expense_item = relationship("ExpenseItem", back_populates="budget_lines")
# vsp = relationship("Vsp", back_populates="budget_lines")
# project = relationship("Project", back_populates="budget_lines")
# ckk = relationship("Ckk", uselist=False)
# collegial_approval = relationship("CollegialApproval", uselist=False)
# contract_detail = relationship("ContractDetail", uselist=False)
# contract_summary = relationship("ContractSummary", uselist=False)
# plan = relationship("Plan", uselist=False)
# reserve = relationship("Reserve", uselist=False)
# allocation = relationship("Allocation", uselist=False)
# quarters: Mapped[list["BudgetLineQuarter"]] = relationship("BudgetLineQuarter", back_populates="line") # type: ignore
# sequestration_records: Mapped[list["Sequestration"]] = relationship("Sequestration", back_populates="budget_line") # type: ignore
# booking_records: Mapped[list["Booking"]] = relationship("Booking", back_populates="budget_line") # type: ignore
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="budget_line") # type: ignore
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="budget_line") # type: ignore
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="budget_line") # type: ignore

View File

@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, SmallInteger, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class BudgetLineQuarter(Base):
__tablename__ = "budget_line_quarter"
__table_args__ = (
CheckConstraint("quarter >= 1 AND quarter <= 4", name="chk_budget_line_quarter_quarter"),
{"schema": "v3"},
)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
quarter: Mapped[int] = mapped_column(SmallInteger, primary_key=True)
adj_current: Mapped[float | None] = mapped_column(Numeric)
adj_ssp: Mapped[float | None] = mapped_column(Numeric)
adj_rf: Mapped[float | None] = mapped_column(Numeric)
adj_reserve: Mapped[float | None] = mapped_column(Numeric)
adj_comment: Mapped[str | None] = mapped_column(String)
target_change: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction: Mapped[float | None] = mapped_column(Numeric)
base_plan_correction_comment: Mapped[str | None] = mapped_column(String)
payment_date: Mapped[date | None] = mapped_column(Date)
payment_amount: Mapped[float | None] = mapped_column(Numeric)
payment_amount_ho: Mapped[float | None] = mapped_column(Numeric)
payment_amount_rf: Mapped[float | None] = mapped_column(Numeric)
payment_comment: Mapped[str | None] = mapped_column(String)
payment_act: Mapped[str | None] = mapped_column(String)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q2: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q3: Mapped[float | None] = mapped_column(Numeric)
transfer_to_q4: Mapped[float | None] = mapped_column(Numeric)
transfer_to_economy: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_acts: Mapped[float | None] = mapped_column(Numeric)
transfer_delay_procurement: Mapped[float | None] = mapped_column(Numeric)
transfer_economy_rf: Mapped[float | None] = mapped_column(Numeric)
transfer_next_comment: Mapped[str | None] = mapped_column(String)
transfer_far_comment: Mapped[str | None] = mapped_column(String)
booking_amount: Mapped[float | None] = mapped_column(Numeric)
plan_revision_eco_change: Mapped[float | None] = mapped_column(Numeric)
plan_revision_item_adj: Mapped[float | None] = mapped_column(Numeric)
plan_revision_increase: Mapped[float | None] = mapped_column(Numeric)
plan_revision_sequester: Mapped[float | None] = mapped_column(Numeric)
plan_revision_comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", back_populates="quarters")

29
api/src/db/models/ckk.py Normal file
View File

@ -0,0 +1,29 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Ckk(Base):
__tablename__ = "ckk"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_next_year_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
delivery_deadline: Mapped[str | None] = mapped_column(String)
procurement_plan: Mapped[str | None] = mapped_column(String)
procurement_method: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,18 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class CollegialApproval(Base):
__tablename__ = "collegial_approval"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
approved_amount: Mapped[float | None] = mapped_column(Numeric)
protocol_reference: Mapped[str | None] = mapped_column(String)
note: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,34 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractDetail(Base):
__tablename__ = "contract_detail"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
addenda: Mapped[str | None] = mapped_column(String)
subject: Mapped[str | None] = mapped_column(String)
currency: Mapped[str | None] = mapped_column(String)
ceiling_amount: Mapped[float | None] = mapped_column(Numeric)
expenses_q1: Mapped[float | None] = mapped_column(Numeric)
expenses_q2: Mapped[float | None] = mapped_column(Numeric)
expenses_q3: Mapped[float | None] = mapped_column(Numeric)
expenses_q4: Mapped[float | None] = mapped_column(Numeric)
rf_schedule: Mapped[str | None] = mapped_column(String)
vat_rate: Mapped[str | None] = mapped_column(String)
exchange_rate: Mapped[float | None] = mapped_column(Numeric)
amount_foreign: Mapped[float | None] = mapped_column(Numeric)
deadline: Mapped[str | None] = mapped_column(String)
payment_scheme: Mapped[str | None] = mapped_column(String)
act: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
contract_date: Mapped[str | None] = mapped_column(Date)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class ContractSummary(Base):
__tablename__ = "contract_summary"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
total_amount: Mapped[float | None] = mapped_column(Numeric)
counterparty: Mapped[str | None] = mapped_column(String)
reference: Mapped[str | None] = mapped_column(String)
deadline: Mapped[str | None] = mapped_column(String)
comment: Mapped[str | None] = mapped_column(String)
future_payments_y1: Mapped[float | None] = mapped_column(Numeric)
future_payments_y2: Mapped[float | None] = mapped_column(Numeric)
other_ssp_amount: Mapped[float | None] = mapped_column(Numeric)
centralized_flag: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class ExpenseItem(Base):
__tablename__ = "expense_item"
__table_args__ = (
CheckConstraint("direction IN ('Support', 'Development')", name="expense_item_direction_check"),
CheckConstraint("sheet IN ('AHR', 'CAP', 'OPER')", name="expense_item_sheet_check"),
Index("ix_v3_expense_item_parent", "parent_id"),
Index("ix_v3_expense_item_sheet", "sheet"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
section_code: Mapped[str | None] = mapped_column(String)
item_id: Mapped[str | None] = mapped_column(String)
num_group_id: Mapped[str | None] = mapped_column(String)
name: Mapped[str | None] = mapped_column(String)
sheet: Mapped[str | None] = mapped_column(String)
direction: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
depth: Mapped[int | None] = mapped_column(Integer, default=0)

View File

@ -0,0 +1,27 @@
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class Form3Phase(Base):
__tablename__ = "form3_phase"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v3_form3_phase_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v3_form3_phase_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v3_form3_phase_window"),
Index("ix_v3_form3_phase_active", "rf_project_report_id", "role", "opens_at", "closes_at"),
Index("ix_v3_form3_phase_columns_gin", "column_keys", postgresql_using="gin"),
{"schema": "v3"},
)
rf_project_report_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.rf_project_report.id", ondelete="CASCADE"), primary_key=True
)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class FormLimit(Base):
__tablename__ = "form_limit"
__table_args__ = (
Index("ix_v3_form_limit_form", "budget_form_id"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True)
template_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.limit_template.id", ondelete="CASCADE"), primary_key=True)
qty_q1: Mapped[int | None] = mapped_column(Integer)
qty_q2: Mapped[int | None] = mapped_column(Integer)
qty_q3: Mapped[int | None] = mapped_column(Integer)
qty_q4: Mapped[int | None] = mapped_column(Integer)
comment: Mapped[str | None] = mapped_column(String)
# budget_form = relationship("BudgetForm")
# template = relationship("LimitTemplate")

View File

@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from src.db.base import Base
class FormPhase(Base):
__tablename__ = "form_phase"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_form_phase_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_form_phase_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_form_phase_window"),
{"schema": "v3"},
)
budget_form_id: Mapped[int] = mapped_column(
ForeignKey("v3.budget_form.id", ondelete="CASCADE"), primary_key=True
)
sheet: Mapped[str] = mapped_column(String, primary_key=True)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View File

@ -0,0 +1,79 @@
import enum
from sqlalchemy import CheckConstraint, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class FormTypeEnum(str, enum.Enum):
FORM_1 = "FORM_1"
FORM_2 = "FORM_2"
FORM_3 = "FORM_3"
FORM_4 = "FORM_4"
class FormType(Base):
__tablename__ = "form_type"
__table_args__ = (
CheckConstraint("scope IN ('GO','PD','RF')", name="ck_form_type_scope"),
CheckConstraint(
"storage_entity IN ('BUDGET_FORM','RF_PROJECT_REPORT')",
name="ck_form_type_storage_entity",
),
{"schema": "v3"},
)
code: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str | None] = mapped_column(String)
scope: Mapped[str | None] = mapped_column(String)
storage_entity: Mapped[str | None] = mapped_column(String)
SHEETS_BY_FORM_TYPE = {
FormTypeEnum.FORM_1: ["AHR", "CAP", "OPER", "SMETA"],
FormTypeEnum.FORM_2: ["AHR", "CAP", "OPER", "AHR_LIMIT", "AHR_RENT",
"AHR_UTILITY", "AHR_SECURITY", "OTCH9F", "SMETA"],
FormTypeEnum.FORM_3: ["LIMIT", "CURRENT_EXPENSES"],
FormTypeEnum.FORM_4: ["AHR", "CAP", "OPER", "STRUCTURE", "SMETA"],
}
SECTIONS_BY_FORM_TYPE = {
FormTypeEnum.FORM_1: ["plan", "contract_summary", "allocation", "sequestration", "reserve", "approved", "collegial", "ckk", "contract_detail", "q1", "q2", "q3", "q4", "totals"],
FormTypeEnum.FORM_2: ["plan", "seq_dfip", "seq_ssp", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals"],
FormTypeEnum.FORM_3: ["q1", "q2", "q3", "q4", "year"],
FormTypeEnum.FORM_4: ["plan", "seq_dfip", "approved", "contract", "booking", "q1", "q2", "q3", "q4", "totals", "contract_summary", "allocation", "reserve", "collegial", "ckk"],
}
_sheet_list_with_directions = {
FormTypeEnum.FORM_1: [
{
"sheet_name": sheet_name,
"direction": None,
} for sheet_name in ["OPER", "SMETA"]
] + [
{
"sheet_name": sheet_name,
"direction": direction
} for sheet_name in ["AHR", "CAP"] for direction in ["Support", "Development"]
]
}
@property
def sheet_list_with_directions(self) -> dict:
return self._sheet_list_with_directions.get(
self.code,
[
{
"sheet_name": sheet_name,
"direction": None,
} for sheet_name in self.sheet_list
]
)
@property
def sheet_list(self):
return self.SHEETS_BY_FORM_TYPE.get(self.code, [])
@property
def section_list(self):
return self.SECTIONS_BY_FORM_TYPE.get(self.code, [])

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class LimitTemplate(Base):
__tablename__ = "limit_template"
__table_args__ = (
CheckConstraint(
"row_type IN ('SECTION','GROUP','LEAF')",
name="ck_v3_limit_template_row_type",
),
Index("ix_v3_limit_template_parent", "parent_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.limit_template.id"))
sort_order: Mapped[int] = mapped_column(Integer)
row_type: Mapped[str] = mapped_column(String)
section_no: Mapped[str | None] = mapped_column(String)
expense_item_code: Mapped[str | None] = mapped_column(String)
name: Mapped[str] = mapped_column(String)
unit: Mapped[str | None] = mapped_column(String)
limit_with_vat: Mapped[float | None] = mapped_column(Numeric)
limit_without_vat: Mapped[float | None] = mapped_column(Numeric)
# parent = relationship("LimitTemplate", remote_side="LimitTemplate.id")
# children: Mapped[list[LimitTemplate]] = relationship("LimitTemplate")
# form_limits: Mapped[list[FormLimit]] = relationship("FormLimit")

View File

@ -0,0 +1,42 @@
from __future__ import annotations
from sqlalchemy import Boolean, Index, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class OrgUnit(Base):
__tablename__ = "org_unit"
__table_args__ = (
Index(
"ix_v4_org_unit_active",
"is_active",
postgresql_where="is_active",
),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_ssp: Mapped[bool] = mapped_column(Boolean)
utc_offset: Mapped[str] = mapped_column(String(3), default="+03")
@property
def users_count(self) -> int | None:
return len(self.users) if self.users is not None else None
# budget_forms: Mapped[list["BudgetForm"]] = relationship( # type: ignore
# "BudgetForm", back_populates="org_unit"
# )
# audit_logs: Mapped[list["AuditLog"]] = relationship( # type: ignore
# "AuditLog", back_populates="org_unit"
# )
# projects: Mapped[list["Project"]] = relationship( # type: ignore
# "Project", back_populates="org_unit"
# )
vsps: Mapped[list["Vsp"]] = relationship("Vsp", back_populates="org_unit") # type: ignore
user_orgs: Mapped[list["UserOrg"]] = relationship("UserOrg", back_populates="org_unit")
users: Mapped[list["AppUser"]] = relationship("AppUser", secondary="v3.user_org", back_populates="org_units")

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import ARRAY, CheckConstraint, DateTime, ForeignKey, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class PhaseTemplate(Base):
__tablename__ = "phase_template"
__table_args__ = (
CheckConstraint(text("cardinality(column_keys) > 0"), name="chk_v4_phase_template_columns"),
CheckConstraint(text("role != 'ADMIN'"), name="chk_v4_phase_template_no_admin"),
CheckConstraint(text("closes_at > opens_at"), name="chk_v4_phase_template_window"),
{"schema": "v3"},
)
form_type: Mapped[str] = mapped_column(String, ForeignKey("v3.form_type.code"), primary_key=True)
sheet: Mapped[str] = mapped_column(String, primary_key=True)
phase_code: Mapped[str] = mapped_column(String, primary_key=True)
role: Mapped[str] = mapped_column(String, ForeignKey("v3.role.code"))
column_keys: Mapped[list[str]] = mapped_column(ARRAY(Text))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=False))
# form_type_rel = relationship("FormType", foreign_keys=[form_type])
# role_rel = relationship("Role", foreign_keys=[role], back_populates="phase_templates")

20
api/src/db/models/plan.py Normal file
View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Plan(Base):
__tablename__ = "plan"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,49 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Project(Base):
__tablename__ = "project"
__table_args__ = (
CheckConstraint("level IN ('program', 'project')", name="project_level_check"),
CheckConstraint(
"placement_type IS NULL OR placement_type IN ('own', 'rent', 'other')",
name="project_placement_type_check",
),
CheckConstraint(
"project_type IS NULL OR project_type IN ('current', 'development')",
name="project_project_type_check",
),
CheckConstraint(
"vsp_format IS NULL OR vsp_format IN ('office', 'standalone', 'atm', 'other')",
name="project_vsp_format_check",
),
CheckConstraint(
text("name IS NOT NULL AND name <> ''"),
name="chk_v3_project_name",
),
Index("ix_v3_project_ssp", "org_unit_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str | None] = mapped_column(String)
level: Mapped[str | None] = mapped_column(String)
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.project.id"))
project_type: Mapped[str | None] = mapped_column(String)
vsp_format: Mapped[str | None] = mapped_column(String)
placement_type: Mapped[str | None] = mapped_column(String)
object_address: Mapped[str | None] = mapped_column(String)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
# org_unit = relationship("OrgUnit", back_populates="projects")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore
# rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RentDetail(Base):
__tablename__ = "rent_detail"
__table_args__ = (
Index("ix_v3_rent_detail_line", "line_id"),
Index("ix_v3_rent_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,20 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, Date, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Reserve(Base):
__tablename__ = "reserve"
__table_args__ = {"schema": "v3"}
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"), primary_key=True)
amount_q1: Mapped[float | None] = mapped_column(Numeric)
amount_q2: Mapped[float | None] = mapped_column(Numeric)
amount_q3: Mapped[float | None] = mapped_column(Numeric)
amount_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine", uselist=False)

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReport(Base):
__tablename__ = "rf_project_report"
__table_args__ = (
UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"),
Index("ix_v3_rf_project_report_proj_year", "project_id", "year"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
year: Mapped[int] = mapped_column(Integer)
report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')"))
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
# project: Mapped[Project] = relationship("Project", back_populates="rf_project_reports")
# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by])
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportLine(Base):
__tablename__ = "rf_project_report_line"
__table_args__ = (
UniqueConstraint(
"rf_project_report_id", "expense_item_id", name="uq_v3_rf_project_report_line"
),
Index("ix_v3_rf_project_report_line_rep", "rf_project_report_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rf_project_report_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_report.id"))
expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
# report: Mapped[RfProjectReport] = relationship("RfProjectReport", back_populates="lines")
# expense_item: Mapped[ExpenseItem] = relationship("ExpenseItem")
# quarters: Mapped[list[RfProjectReportQuarter]] = relationship(
# "RfProjectReportQuarter", back_populates="line"
# )

View File

@ -0,0 +1,30 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Numeric, SmallInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectReportQuarter(Base):
__tablename__ = "rf_project_report_quarter"
__table_args__ = {"schema": "v3"}
rf_project_report_line_id: Mapped[int] = mapped_column(
Integer, ForeignKey("v3.rf_project_report_line.id"), primary_key=True
)
quarter: Mapped[int] = mapped_column(
SmallInteger,
CheckConstraint("quarter >= 1 AND quarter <= 4"),
primary_key=True,
)
adj_by_items: Mapped[float | None] = mapped_column(Numeric)
adj_increase: Mapped[float | None] = mapped_column(Numeric)
actual_m1: Mapped[float | None] = mapped_column(Numeric)
actual_m2: Mapped[float | None] = mapped_column(Numeric)
actual_m3: Mapped[float | None] = mapped_column(Numeric)
actual_spod: Mapped[float | None] = mapped_column(Numeric)
# line: Mapped[RfProjectReportLine] = relationship(
# "RfProjectReportLine", back_populates="quarters"
# )

31
api/src/db/models/role.py Normal file
View File

@ -0,0 +1,31 @@
import enum
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserRoleEnum(int, enum.Enum):
ADMIN = 1
EXECUTOR_DFIP = 2
EXECUTOR_RF = 3
class Role(Base):
__tablename__ = "role"
__table_args__ = (
UniqueConstraint("code", name="uq_role_code"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String)
# users: Mapped[list["AppUser"]] = relationship("AppUser", back_populates="role") # type: ignore
# form_phases: Mapped[list["FormPhase"]] = relationship( # type: ignore
# "FormPhase", back_populates="role"
# )
# phase_templates: Mapped[list["PhaseTemplate"]] = relationship( # type: ignore
# "PhaseTemplate", back_populates="role"
# )

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class SecurityDetail(Base):
__tablename__ = "security_detail"
__table_args__ = (
Index("ix_v3_security_detail_line", "line_id"),
Index("ix_v3_security_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

View File

@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Sequestration(Base):
__tablename__ = "sequestration"
__table_args__ = (
CheckConstraint("actor IN ('DFIP','SSP_GO')", name="ck_v3_sequestration_actor"),
UniqueConstraint("line_id", "actor", name="uq_v3_sequestration_line_actor"),
Index("ix_v3_sequestration_line_actor", "line_id", "actor"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
actor: Mapped[str] = mapped_column(String)
adj_q1: Mapped[float | None] = mapped_column(Numeric)
adj_q2: Mapped[float | None] = mapped_column(Numeric)
adj_q3: Mapped[float | None] = mapped_column(Numeric)
adj_q4: Mapped[float | None] = mapped_column(Numeric)
justification: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UserOrg(Base):
__tablename__ = "user_org"
__table_args__ = (
UniqueConstraint("user_id", "org_unit_id"),
Index("ix_v4_user_org_org", "org_unit_id"),
Index("ix_v4_user_org_user", "user_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.app_user.id", ondelete="CASCADE"))
org_unit_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id", ondelete="CASCADE"))
user: Mapped[AppUser] = relationship("AppUser", back_populates="user_orgs", lazy="selectin")
org_unit: Mapped[OrgUnit] = relationship("OrgUnit", back_populates="user_orgs", lazy="selectin")

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import date
from sqlalchemy import Date, ForeignKey, Index, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class UtilityDetail(Base):
__tablename__ = "utility_detail"
__table_args__ = (
Index("ix_v3_utility_detail_line", "line_id"),
Index("ix_v3_utility_detail_vsp", "vsp_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
line_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.budget_line.id"))
vsp_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.vsp.id"))
contract_number: Mapped[str | None] = mapped_column(String)
contract_end_date: Mapped[date | None] = mapped_column(Date)
plan_q1: Mapped[float | None] = mapped_column(Numeric)
plan_q2: Mapped[float | None] = mapped_column(Numeric)
plan_q3: Mapped[float | None] = mapped_column(Numeric)
plan_q4: Mapped[float | None] = mapped_column(Numeric)
actual_jan: Mapped[float | None] = mapped_column(Numeric)
actual_feb: Mapped[float | None] = mapped_column(Numeric)
actual_mar: Mapped[float | None] = mapped_column(Numeric)
actual_apr: Mapped[float | None] = mapped_column(Numeric)
actual_may: Mapped[float | None] = mapped_column(Numeric)
actual_jun: Mapped[float | None] = mapped_column(Numeric)
actual_jul: Mapped[float | None] = mapped_column(Numeric)
actual_aug: Mapped[float | None] = mapped_column(Numeric)
actual_sep: Mapped[float | None] = mapped_column(Numeric)
actual_oct: Mapped[float | None] = mapped_column(Numeric)
actual_nov: Mapped[float | None] = mapped_column(Numeric)
actual_dec: Mapped[float | None] = mapped_column(Numeric)
comment: Mapped[str | None] = mapped_column(String)
# line = relationship("BudgetLine")
# vsp = relationship("Vsp")

57
api/src/db/models/vsp.py Normal file
View File

@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Boolean, CheckConstraint, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Vsp(Base):
__tablename__ = "vsp"
__table_args__ = (
UniqueConstraint(
"reg_number", name="vsp_reg_number_unique",
),
CheckConstraint(
"closed_at IS NULL OR opened_at IS NULL OR closed_at >= opened_at",
name="chk_closed_after_opened",
),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(primary_key=True)
branch_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
reg_number: Mapped[str | None] = mapped_column(String)
address: Mapped[str | None] = mapped_column(String)
format: Mapped[str | None] = mapped_column(String)
opened_at: Mapped[date | None] = mapped_column(Date)
placement_type: Mapped[str | None] = mapped_column(String)
staff_count: Mapped[int | None] = mapped_column(Integer)
total_area: Mapped[float | None] = mapped_column(Numeric)
closed_at: Mapped[date | None] = mapped_column(Date)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
system_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
updated_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.app_user.id"), nullable=True,
)
vsp_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
rent_contract_num: Mapped[str | None] = mapped_column(String, nullable=True)
rent_end_date : Mapped[date | None]= mapped_column(Date, nullable=True)
org_unit = relationship("OrgUnit", back_populates="vsps")
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="vsp") # type: ignore
# rent_details: Mapped[list["RentDetail"]] = relationship("RentDetail", back_populates="vsp")
# security_details: Mapped[list["SecurityDetail"]] = relationship("SecurityDetail", back_populates="vsp")
# utility_details: Mapped[list["UtilityDetail"]] = relationship("UtilityDetail", back_populates="vsp")

View File

@ -5,6 +5,8 @@ from alembic import command
from alembic.config import Config
from src.core.config import settings
from typing import AsyncGenerator
from src.db.base import SessionLocal
@ -12,6 +14,10 @@ async def get_db() -> AsyncGenerator:
db = SessionLocal()
try:
yield db
await db.commit()
except Exception:
await db.rollback()
raise
finally:
await db.close()

View File

@ -1,8 +1,23 @@
import enum
#ToDo: вероятно это теперь AuditEvent. А еще он нигде не юзается больше, вероятно, выпилить
class AuditEventType(str, enum.Enum):
LOGIN = "LOGIN"
USER_CREATE = "USER_CREATE"
USER_UPDATE = "USER_UPDATE"
USER_DELETE = "USER_DELETE"
ROW_CREATE = "ROW_CREATE"
ROW_DELETE = "ROW_DELETE"
ACCESS_WINDOW_CHANGE = "ACCESS_WINDOW_CHANGE"
ACCESS_EXTEND = "ACCESS_EXTEND"
USER_ROLE_CHANGE = "USER_ROLE_CHANGE"
CREATE_TASK = "CREATE_TASK"
UPDATE_TASK = "UPDATE_TASK"
DELETE_TASK = "DELETE_TASK"
VSP_CREATE = "VSP_CREATE"
VSP_UPDATE = "VSP_UPDATE"
PROJECT_CREATE = "PROJECT_CREATE"
PROJECT_DELETE = "PROJECT_DELETE"
USER_ACCESS_GRANTED = "USER_ACCESS_GRANTED"
USER_ACCESS_REVOKED = "USER_ACCESS_REVOKED"

View File

@ -1,70 +0,0 @@
import enum
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
UniqueConstraint)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from src.db.base import Base
class UserRole(int, enum.Enum):
ADMIN = 1
EXECUTOR_DFIP = 2
EXECUTOR_RF = 3
class Roles(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
role = Column(String, nullable=False)
user_role = relationship("Users", back_populates="role", lazy="select")
class Users(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=True)
full_name = Column(String, nullable=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
role = relationship(
"Roles",
foreign_keys=[role_id],
back_populates="user_role",
lazy="selectin",
)
user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select")
class SSP(Base):
__tablename__ = "ssp"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
is_ssp = Column(Boolean, default=True, nullable=False, server_default="true")
user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select")
class UserSSPLink(Base):
__tablename__ = "user_ssp_link"
__table_args__ = (
UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select")

View File

@ -1,41 +1,124 @@
from datetime import datetime
from typing import Optional
from datetime import datetime, date
import enum
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
from sqlalchemy.exc import MissingGreenlet
from pydantic import BaseModel, ConfigDict, EmailStr, Field
FormPhaseRole = Literal["DFIP", "EXECUTOR_RF"]
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
T = TypeVar("T")
ProjectTypeLiteral = Literal[
"Открытие ВСП",
"Закрытие ВСП",
"Переезд ВСП",
"Реновация РФ",
"Открытие УРМ",
]
VspFormatLiteral = Literal[
"Флагманский",
"Типовой",
"Розничный",
"МСБ",
"Лёгкий",
"Мини",
"Розничный-киоск",
"МБО",
"Офис самообслуживания",
"Другое",
]
PlacementTypeLiteral = Literal[
"Собственность",
"Аренда",
"Субаренда",
]
# def serialize_missing_greenlet(cls, obj):
# result = {}
# for attr in cls.model_fields:
# try:
# result[attr] = getattr(obj, attr)
# except MissingGreenlet:
# result[attr] = None
# return result
class ResponseBase(BaseModel):
success: Optional[bool] = Field(None)
message: Optional[str] = Field(None)
success: Optional[bool] = Field(True)
message: Optional[str] = None
class BaseSingleResponse(ResponseBase, Generic[T]):
result: Optional[T] = None
class BaseListResponse(ResponseBase, Generic[T]):
result: Optional[list[T]] = None
count: Optional[int] = None
class UserBase(BaseModel):
email: EmailStr
email: EmailStr = Field(..., examples=["user@example.com"])
username: str = Field(
...,
min_length=3,
max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$",
examples=["user_1"],
)
full_name: Optional[str] = Field(
None,
max_length=255,
pattern=r"^[^<>]*$",
examples=["Иван Иванов"],
)
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
role_id: int = 3
model_config = ConfigDict(from_attributes=True)
class UserCreate(UserBase):
password: str | None = None
password: str | None = Field(default=None, examples=["pass123"])
load_orgs: bool = Field(default=False)
model_config = ConfigDict(
json_schema_extra={
"example": {
"email": "user@example.com",
"username": "user_1",
"full_name": "Иван Иванов",
"role_id": 3,
"password": "pass123",
"load_orgs": True,
}
}
)
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
username: Optional[str] = Field(
None,
min_length=3,
max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$",
examples=["user_1"],
)
full_name: Optional[str] = Field(
None,
max_length=255,
pattern=r"^[^<>]*$",
examples=["Иван Иванов"],
)
full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$")
role_id: Optional[int] = None
is_active: Optional[bool] = True
load_orgs: bool = Field(default=False)
class UserInDB(UserBase):
@ -51,6 +134,37 @@ class User(UserInDB):
pass
class UserAdminListResponse(UserInDB):
org_units: Optional[list["OrgUnitSchema"]] = None
@model_validator(mode='before')
@classmethod
def safe_orm_handler(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = {
"org_units": None,
"id": data.id,
"created_at": data.created_at,
"updated_at": data.updated_at,
"is_active": data.is_active,
"email": data.email,
"username": data.username,
"full_name": data.full_name,
"role_id": data.role_id,
}
try:
result["org_units"] = [
ou for ou in data.org_units
if ou.is_active
]
except MissingGreenlet:
pass
return result
class UserResponse(ResponseBase):
result: Optional[UserInDB] = None
@ -97,3 +211,436 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel):
refresh_token: str
class FormTypeSchemaEnum(str, enum.Enum):
FORM_1 = "FORM_1"
FORM_2 = "FORM_2"
FORM_3 = "FORM_3"
FORM_4 = "FORM_4"
class FormCreateSchema(BaseModel):
year: int
form_type_code: FormTypeSchemaEnum
org_unit_ids: list[int] = Field(..., min_length=1)
class OrgUnitBaseSchema(BaseModel):
title: str
is_ssp: bool
model_config = ConfigDict(from_attributes=True)
class OrgUnitCreateSchema(OrgUnitBaseSchema):
title: str = Field(..., min_length=1)
utc_offset: str = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
class OrgUnitUpdateSchema(OrgUnitBaseSchema):
title: Optional[str] = None
is_ssp: Optional[bool] = None
is_active: Optional[bool] = None
utc_offset: Optional[str] = Field('+03', min_length=3, pattern=r'^[+]\d\d$')
model_config = ConfigDict(from_attributes=True)
class OrgUnitResponseSchema(OrgUnitBaseSchema):
id: int
is_active: bool
utc_offset: str
users_count: int | None = None
model_config = ConfigDict(from_attributes=True)
@model_validator(mode='before')
@classmethod
def safe_orm_handler(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = {
"users_count": None,
"id": data.id,
"title": data.title,
"is_ssp": data.is_ssp,
"is_ssp": data.is_ssp,
"is_active": data.is_active,
"utc_offset": data.utc_offset
}
try:
result["users_count"] = data.users_count
except MissingGreenlet:
pass
return result
class OrgUnitListSchema(OrgUnitResponseSchema):
pass
class OrgUnitSchema(OrgUnitResponseSchema):
users: list[UserInDB] | None = None
@model_validator(mode='before')
@classmethod
def safe_orm_handler(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = super().safe_orm_handler(data)
result["users"] = None
try:
result["users"] = data.users
except MissingGreenlet:
pass
return result
class BudgetFormResponse(BaseModel):
id: int
created_at: datetime
created_by: int | None
updated_at: datetime
form_type_code: FormTypeSchemaEnum
year: int
org_unit_id: int
org_unit: OrgUnitSchema | None = None
model_config = ConfigDict(from_attributes=True)
class FullSheetSchema(BaseModel):
sheet_name: str
direction: str | None = None
class SheetFormTypeResponse(BaseModel):
form_type: FormTypeSchemaEnum
sheets: list[str]
all_sheets: list[FullSheetSchema] | None = None
class DirectionSchemaEnum(str, enum.Enum):
SUPPORT = "Support"
DEVELOPMENT = "Development"
class SheetResponse(BaseModel):
row_type: str
depth: int
data: dict
sort_order: int | None = None
class CellPatch(BaseModel):
line_id: int
column: str
value: Any = None
class CellsPatch(BaseModel):
changes: list[CellPatch]
class ExportBulkRequest(BaseModel):
form_ids: list[int] = Field(min_length=1)
skip_failed: bool = True
class AddForm3LineBody(BaseModel):
expense_item_id: int
class UpdProjectBody(BaseModel):
column: str
value: Any = None
class AddProjectBody(BaseModel):
name: str = Field(
...,
max_length=30,
pattern=r"^[^+\-\/\\=&*\s]{1,30}$",
)
year: int = Field(default_factory=lambda: datetime.now().year)
branch_id: int
level: Literal["project", "program"] = "project"
parent_id: Optional[int] = None
ssp_id: Optional[int] = None
project_type: Optional[ProjectTypeLiteral] = None
vsp_format: Optional[VspFormatLiteral] = None
placement_type: Optional[PlacementTypeLiteral] = None
object_address: Optional[str] = None
staff_count: Optional[int] = None
total_area: Optional[float] = None
@field_validator("year", mode="before")
@classmethod
def optional_year(cls, v: Any) -> Any:
return v or datetime.now().year
class AddLineSchema(BaseModel):
expense_item_id: Optional[int] = None
item_id: Optional[str] = None
section_code: Optional[str] = None
direction: Optional[DirectionSchemaEnum] = None
name: Optional[str] = None
internal_order: Optional[str] = None
vsp_id: Optional[int] = None
project_id: Optional[int] = None
justification: Optional[str] = None
contract_number: Optional[str] = None
contract_end_date: Optional[datetime] = None
class FormPhaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
budget_form_id: int
sheet: str
phase_code: str
role: str
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseCreate(BaseModel):
sheet: str
phase_code: str
role: FormPhaseRole
column_keys: list[str]
opens_at: datetime
closes_at: datetime
class FormPhaseUpdate(BaseModel):
role: FormPhaseRole | None = None
column_keys: list[str] | None = None
opens_at: datetime | None = None
closes_at: datetime | None = None
class AuditLogBase(BaseModel):
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
entity: str = Field(..., description="Тип сущности")
entity_id: Optional[int] = Field(None, description="ID сущности")
action: str = Field(..., description="Действие")
payload_json: Optional[Dict[str, Any]] = None
class AuditLogInDB(AuditLogBase):
"""Схема записи аудита в базе данных."""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="ID записи")
user_id: Optional[int] = Field(None, description="ID пользователя")
at: datetime = Field(..., description="Дата/время события")
class AuditLog(AuditLogInDB):
"""Схема записи аудита для ответа API."""
user: Optional[User] = None
class AuditLogListResponse(ResponseBase):
"""Схема всех записей аудита для ответа API."""
result: List[AuditLog] = Field(..., description="Вывод записей аудита")
class AuditLogQueryParams(BaseModel):
"""Query-параметры для фильтрации журнала аудита."""
page: int = Field(1, ge=1, description="Номер страницы")
limit: int = Field(
20, ge=1, le=100, description="Количество записей на странице"
)
user_id: Optional[int] = Field(None, description="ID пользователя")
org_unit_id: Optional[int] = Field(None, description="ID ССП")
task_id: Optional[int] = Field(None, description="ID задачи")
form_id: Optional[int] = Field(None, description="ID формы")
event_type: Optional[str] = Field(None, description="Тип события")
event: Optional[str] = Field(None, description="Cобытиу")
date_from: Optional[date] = Field(None, description="Дата начала (ISO 8601)")
date_to: Optional[date] = Field(None, description="Дата окончания (ISO 8601)")
class VSPBase(BaseModel):
system_code: Optional[str] = Field(None, description="Системный код ВСП")
vsp_type: Optional[str] = Field(None, description="Вид ВСП")
registration_number: str = Field(
...,
description="Значение для выпадающего списка",
validation_alias='reg_number',
serialization_alias='registration_number',
)
address: str = Field(..., description="Автоподставляемый адрес")
open_date: Optional[date] = Field(
None,
description="Дата открытия",
validation_alias='opened_at',
serialization_alias='open_date',
)
close_date: Optional[date] = Field(
None,
description="Дата закрытия",
validation_alias='closed_at',
serialization_alias='close_date',
)
approved_format: Optional[str] = Field(
None,
description="Утвержденный формат",
validation_alias='format',
serialization_alias='approved_format',
)
notes: Optional[str] = Field(None, description="Примечания")
placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность")
area: Optional[float] = Field(
None,
description="Арендная площадь",
validation_alias='total_area',
serialization_alias='area',
)
rent_contract_num: Optional[str] = Field(None, description="Номер договора аренды")
rent_end_date: Optional[date] = Field(
None, description="Срок окончания договора аренды"
)
ssp_id: int = Field(
...,
description="ID ССП",
validation_alias="branch_id",
serialization_alias="ssp_id",
)
class VSPEditBase(BaseModel):
system_code: Optional[str] = Field(None, description="Системный код ВСП")
vsp_type: Optional[str] = Field(None, description="Вид ВСП")
reg_number: str = Field(
...,
description="Значение для выпадающего списка",
validation_alias='registration_number',
serialization_alias='reg_number',
)
address: str = Field(..., description="Автоподставляемый адрес")
opened_at: Optional[date] = Field(
None,
description="Дата открытия",
validation_alias='open_date',
serialization_alias='opened_at',
)
closed_at: Optional[date] = Field(
None,
description="Дата закрытия",
validation_alias='close_date',
serialization_alias='closed_at',
)
format: Optional[str] = Field(
None,
description="Утвержденный формат",
validation_alias='approved_format',
serialization_alias='format',
)
notes: Optional[str] = Field(None, description="Примечания")
placement_type: Optional[str] = Field(None, description="Тип размещения")
staff_count: Optional[int] = Field(None, description="Штатная численность")
total_area: Optional[float] = Field(
None,
description="Арендная площадь",
validation_alias='area',
serialization_alias='total_area',
)
rent_contract_num: Optional[str] = Field(None, description="Номер договора аренды")
rent_end_date: Optional[date] = Field(
None, description="Срок окончания договора аренды"
)
branch_id: int = Field(
...,
description="ID ССП",
validation_alias="ssp_id",
serialization_alias="branch_id",
)
class VSPCreate(VSPEditBase):
pass
class VSPUpdate(VSPEditBase):
reg_number: str = Field(
None,
description="Значение для выпадающего списка",
validation_alias='registration_number',
serialization_alias='reg_number',
)
address: str = Field(None, description="Автоподставляемый адрес")
branch_id: int = Field(
None,
description="ID ССП",
validation_alias="ssp_id",
serialization_alias="branch_id",
)
class VSPInDB(VSPBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
is_deleted: bool = False
created_by: int
updated_by: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
regional_branch: Optional[str] = Field(None, description="Региональный филиал")
@model_validator(mode='before')
@classmethod
def get_regional_branch(cls, data: Any) -> Any:
if isinstance(data, dict):
return data
result = vars(data)
result["regional_branch"] = None
try:
result["regional_branch"] = data.org_unit.title
except MissingGreenlet:
pass
return result
class VSPExportRequest(BaseModel):
registration_number: Optional[str] = None
address: Optional[str] = None
ssp_ids: Optional[List[int]] = None
open_date_start: Optional[date] = None
open_date_end: Optional[date] = None
placement_type: Optional[str] = None
staff_count_min: Optional[int] = None
staff_count_max: Optional[int] = None
class ExpenseItemBaseSchema(BaseModel):
section_code: Optional[str] = None
item_id: Optional[str] = None
num_group_id: Optional[str] = None
name: Optional[str] = None
sheet: Optional[str] = None
direction: Optional[str] = None
parent_id: Optional[int] = None
depth: Optional[int] = Field(default=0)
model_config = {
"from_attributes": True
}
class ExpenseItemResponseSchema(ExpenseItemBaseSchema):
id: int

View File

@ -1,9 +1,16 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from contextlib import suppress
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_swagger_ui_html
from starlette.exceptions import HTTPException as StarletteHTTPException
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
@ -14,31 +21,104 @@ from src.core.exception_handlers import register_exception_handlers
from src.db.base import engine
from src.db.session import create_tables
if not settings.DEBUG:
from raisa_fastapi_protected_api import (
AuthorizationMiddleware,
OpenEndpoint,
ProtectedOAuthSettings,
ProtectedRolesSettings,
ProtectedSettings,
SearchType,
)
logging.basicConfig(level=logging.INFO)
# if not settings.DEBUG:
# from raisa_fastapi_protected_api import (
# AuthorizationMiddleware,
# OpenEndpoint,
# ProtectedOAuthSettings,
# ProtectedRolesSettings,
# ProtectedSettings,
# SearchType,
# )
_AUDIT_CLEANUP_LOCK_KEY = 21987431
async def _cleanup_audit_log_once() -> int:
if "postgresql" not in settings.DATABASE_URL:
return 0
total_deleted = 0
async with engine.begin() as conn:
lock_ok = (
await conn.execute(
text("SELECT pg_try_advisory_lock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
).scalar_one()
if not lock_ok:
return 0
try:
while True:
deleted = (
await conn.execute(
text(
"""
WITH doomed AS (
SELECT id
FROM v3.audit_log
WHERE event_dt < now() - make_interval(days => :retention_days)
ORDER BY id
LIMIT :batch_size
)
DELETE FROM v3.audit_log a
USING doomed d
WHERE a.id = d.id
RETURNING a.id
"""
),
{
"retention_days": settings.AUDIT_LOG_RETENTION_DAYS,
"batch_size": settings.AUDIT_LOG_CLEANUP_BATCH_SIZE,
},
)
).rowcount
if not deleted:
break
total_deleted += deleted
finally:
await conn.execute(
text("SELECT pg_advisory_unlock(:k)"),
{"k": _AUDIT_CLEANUP_LOCK_KEY},
)
return total_deleted
async def _audit_cleanup_loop() -> None:
# Первый прогон сразу после старта, дальше — по интервалу.
while True:
try:
await _cleanup_audit_log_once()
except Exception:
pass
await asyncio.sleep(max(60, settings.AUDIT_LOG_CLEANUP_INTERVAL_SECONDS))
@asynccontextmanager
async def lifespan(app: FastAPI):
cleanup_task: asyncio.Task | None = None
if not settings.DEBUG:
await create_tables()
ProtectedSettings(
ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
ProtectedRolesSettings(
APP_NAMESPACE=settings.APP_NAMESPACE,
APP_NAME=settings.APP_NAME,
),
)
# ProtectedSettings(
# ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL),
# ProtectedRolesSettings(
# APP_NAMESPACE=settings.APP_NAMESPACE,
# APP_NAME=settings.APP_NAME,
# ),
# )
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
try:
yield
finally:
if cleanup_task:
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
await engine.dispose()
@ -69,32 +149,77 @@ async def healthcheck2():
"port": settings.PORT,
}
@app.get("/healthz", status_code=200)
async def healthz():
return {"status": "ok", "uptime": UP_TIME, "version": VERSION}
if not settings.DEBUG:
app.add_middleware(
AuthorizationMiddleware,
open_endpoints=[
OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
OpenEndpoint(
path=settings.ROOT_PATH + "/healthcheck",
type_search=SearchType.ABSOLUTE,
),
OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/openapi.json",
type_search=SearchType.START,
),
OpenEndpoint(path="/docs", type_search=SearchType.START),
OpenEndpoint(
path=settings.ROOT_PATH + "/docs",
type_search=SearchType.START,
),
],
)
@app.get("/readyz")
async def readyz(response: Response):
result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"}
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
if "postgresql" in settings.DATABASE_URL:
my_exists = (
await conn.execute(
text(
"""
SELECT 1
FROM pg_matviews
WHERE schemaname = 'v3'
AND matviewname = 'mv_expense_item_tree'
"""
)
)
).scalar_one_or_none()
if my_exists is None:
result["status"] = "fail"
result["mv_expense_item_tree"] = "missing"
response.status_code = 503
except SQLAlchemyError as exc:
result["status"] = "fail"
result["db"] = f"error: {exc.__class__.__name__}"
response.status_code = 503
return result
# if not settings.DEBUG:
# app.add_middleware(
# AuthorizationMiddleware,
# open_endpoints=[
# OpenEndpoint(path="", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/healthcheck",
# type_search=SearchType.ABSOLUTE,
# ),
# OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/healthz",
# type_search=SearchType.ABSOLUTE,
# ),
# OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/readyz",
# type_search=SearchType.ABSOLUTE,
# ),
# OpenEndpoint(path="/openapi.json", type_search=SearchType.START),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/openapi.json",
# type_search=SearchType.START,
# ),
# OpenEndpoint(path="/docs", type_search=SearchType.START),
# OpenEndpoint(
# path=settings.ROOT_PATH + "/docs",
# type_search=SearchType.START,
# ),
# ],
# )
app.add_middleware(
CORSMiddleware,
@ -108,4 +233,43 @@ app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
os.makedirs("web", exist_ok=True)
app.include_router(api_router, prefix="/api/v1")
@app.get("/docs-local", include_in_schema=False)
async def custom_swagger_ui_html():
return get_swagger_ui_html(
openapi_url=f"{settings.ROOT_PATH}{app.openapi_url}", # Путь к OpenAPI схеме (обычно /openapi.json)
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
# Указываем пути к локальным файлам
swagger_js_url=f"{settings.ROOT_PATH}/back-static/swagger-ui-bundle.js",
swagger_css_url=f"{settings.ROOT_PATH}/back-static/swagger-ui.css",
)
def sep_static(path, stat_dir):
sep_path = path.split("/")
index = sep_path.index(stat_dir)
return "/".join(sep_path[index:])
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except (HTTPException, StarletteHTTPException) as ex:
if ex.status_code == 404:
if "images" in path:
return await super().get_response(sep_static(path, "images"), scope)
if "static" in path:
return await super().get_response(sep_static(path, "static"), scope)
return await super().get_response("index.html", scope)
else:
raise ex
app.mount("/back-static", StaticFiles(directory="back_static"), name="back-static")
app.mount("/", SPAStaticFiles(directory="web", html=True), name="web")
app.mount("/", StaticFiles(directory="web", html=True), name="web")

View File

@ -0,0 +1,87 @@
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.functions import func
from sqlalchemy.orm import joinedload
# from sqlalchemy.orm import selectinload
# from app.domain.models import AuditLog
from src.db.models.audit_log import AuditLog
class AuditLogRepository:
"""Репозиторий для работы с журналом аудита."""
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, audit_log_id: int) -> Optional[AuditLog]:
"""Получение записи аудита по ID."""
return (
(
await self.db.execute(
select(AuditLog).where(AuditLog.id == audit_log_id).limit(1)
)
)
.scalars()
.first()
)
async def get_all(
self,
limit: int | None = None,
offset: int | None = None,
user_id: int | None = None,
org_unit_id: int | None = None,
task_id: int | None = None,
form_id: int | None = None,
event_type: str | None = None,
event: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[Iterable[AuditLog], int]:
"""Получение всех записей аудита с опциональной фильтрацией."""
query = select(AuditLog)
query_count = select(func.count(AuditLog.id))
if user_id is not None:
query = query.where(AuditLog.user_id == user_id)
query_count = query_count.where(AuditLog.user_id == user_id)
if org_unit_id is not None:
query = query.where(AuditLog.org_unit_id == org_unit_id)
query_count = query_count.where(AuditLog.org_unit_id == org_unit_id)
if task_id is not None:
query = query.where(AuditLog.task_id == task_id)
query_count = query_count.where(AuditLog.task_id == task_id)
if form_id is not None:
query = query.where(AuditLog.form_id == form_id)
query_count = query_count.where(AuditLog.form_id == form_id)
if event_type is not None:
query = query.where(AuditLog.event_type == event_type)
query_count = query_count.where(AuditLog.event_type == event_type)
if event is not None:
query = query.where(AuditLog.event == event)
query_count = query_count.where(AuditLog.event == event)
if date_from is not None:
query = query.where(AuditLog.event_dt >= date_from)
query_count = query_count.where(AuditLog.event_dt >= date_from)
if date_to is not None:
query = query.where(AuditLog.event_dt <= date_to)
query_count = query_count.where(AuditLog.event_dt <= date_to)
query = query.options(joinedload(AuditLog.user))
query = query.order_by(AuditLog.event_dt.desc())
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
return (await self.db.execute(query)).scalars().all(), (
await self.db.execute(query_count)
).scalar_one()

View File

@ -0,0 +1,116 @@
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.budget_form import BudgetForm
from sqlalchemy.orm import joinedload
class BudgetFormRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
offset: int | None = None,
limit: int | None = None,
org_unit: int | list[int] | None = None,
with_count: bool = False,
load_org: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if with_count:
query = select(func.count().over().label("total_count"), BudgetForm)
else:
query = select(BudgetForm)
where = []
if org_unit is not None:
if isinstance(org_unit, int):
where.append(BudgetForm.org_unit_id == org_unit)
else:
where.append(BudgetForm.org_unit_id.in_(org_unit))
query = query.where(*where)
if load_org:
query = query.options(
joinedload(BudgetForm.org_unit)
)
query = query.order_by(BudgetForm.id)
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
if with_count:
result = (await self.db.execute(query)).all()
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(BudgetForm.id))
if query.whereclause is not None:
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()
async def get(
self,
budget_form_id: int,
org_unit: int | list[int] | None = None,
load_form_type: bool = False,
load_org: bool = False,
) -> BudgetForm | None:
query = select(BudgetForm)
where = [BudgetForm.id == budget_form_id]
if org_unit is not None:
if isinstance(org_unit, int):
where.append(BudgetForm.org_unit_id == org_unit)
else:
where.append(BudgetForm.org_unit_id.in_(org_unit))
query = query.where(*where)
if load_org:
query = query.options(
joinedload(BudgetForm.org_unit)
)
if load_form_type:
query = query.options(
joinedload(BudgetForm.form_type)
)
return (await self.db.execute(query)).scalars().first()
async def create(
self,
form_type_code: str,
year: int,
org_unit_id: int,
) -> BudgetForm | None:
form_id = (
await self.db.execute(
text(
"""
SELECT *
FROM v3.add_budget_form(
:form_type,
:year,
:org_unit_id
)
"""
),
{
"form_type": form_type_code,
"year": year,
"org_unit_id": org_unit_id,
}
)
).scalar_one_or_none()
if form_id is None:
return None
return await self.get(
budget_form_id=form_id,
)

View File

@ -0,0 +1,26 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.budget_line import BudgetLine
class BudgetLineRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, budget_line_id: int) -> BudgetLine | None:
query = select(BudgetLine).where(BudgetLine.id == budget_line_id).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(self, budget_line_ids: list[int]) -> list[BudgetLine]:
query = select(BudgetLine).where(BudgetLine.id.in_(budget_line_ids))
return (await self.db.execute(query)).scalars().all()
async def get_ids(
self,
budget_form_id: int,
) -> list[int]:
query = select(BudgetLine.id).where(BudgetLine.budget_form_id == budget_form_id)
return (await self.db.execute(query)).scalars().all()

View File

@ -0,0 +1,13 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.expense_item import ExpenseItem
class ExpenseItemRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, item_id: int) -> ExpenseItem | None:
query = select(ExpenseItem).where(ExpenseItem.id == item_id).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()

View File

@ -0,0 +1,123 @@
from datetime import datetime
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_phase import FormPhase
class FormPhaseRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
budget_form_id: int,
sheet: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
if sheet is not None:
query = query.where(FormPhase.sheet == sheet)
if phase_code is not None:
query = query.where(FormPhase.phase_code == phase_code)
return (await self.db.execute(query)).scalars().all()
async def get(
self, budget_form_id: int, sheet: str, phase_code: str
) -> FormPhase | None:
query = (
select(FormPhase)
.where(FormPhase.budget_form_id == budget_form_id)
.where(FormPhase.sheet == sheet)
.where(FormPhase.phase_code == phase_code)
.limit(1)
)
return (await self.db.execute(query)).scalar_one_or_none()
async def create(
self,
budget_form_id: int,
sheet: str,
phase_code: str,
role: str,
column_keys: list[str],
opens_at: datetime,
closes_at: datetime,
) -> FormPhase:
result = await self.db.execute(
text(
"""
SELECT
(v3.add_form_phase(
:budget_form_id,
:sheet,
:phase_code,
:role,
:column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).budget_form_id
"""
),
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"phase_code": phase_code,
"role": role,
"column_keys": column_keys,
"opens_at": opens_at,
"closes_at": closes_at,
},
)
if result.scalar_one_or_none() is None:
return None
return await self.get(budget_form_id, sheet, phase_code)
async def update(
self, budget_form_id: int, sheet: str, phase_code: str, data: dict
) -> FormPhase | None:
result = await self.db.execute(
text(
"""
SELECT
(v3.upd_form_phase(
:budget_form_id,
:sheet,
:phase_code,
:role,
:column_keys,
CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE),
CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE)
)).budget_form_id
"""
),
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"phase_code": phase_code,
"role": data.get("role"),
"column_keys": data.get("column_keys"),
"opens_at": data.get("opens_at"),
"closes_at": data.get("closes_at"),
},
)
if result.scalar_one_or_none() is None:
return None
return await self.get(budget_form_id, sheet, phase_code)
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str
) -> bool:
result = await self.db.execute(
text(
"SELECT v3.del_form_phase(:budget_form_id, :sheet, :phase_code)"
),
{
"budget_form_id": budget_form_id,
"sheet": sheet,
"phase_code": phase_code,
},
)
deleted = result.scalar_one_or_none()
return bool(deleted)

View File

@ -0,0 +1,138 @@
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit
from sqlalchemy.orm import joinedload
class OrgUnitRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
offset: int | None = None,
limit: int | None = None,
is_ssp: bool | None = None,
is_active: bool | None = None,
org_unit_id: int | list[int] | None = None,
with_count: bool = False,
load_users: bool = False,
) -> list[OrgUnit] | tuple[int, list[OrgUnit]]:
if with_count:
query = select(func.count().over().label("total_count"), OrgUnit)
else:
query = select(OrgUnit)
where = []
if is_ssp is not None:
where.append(OrgUnit.is_ssp == is_ssp)
if is_active is not None:
where.append(OrgUnit.is_active == is_active)
if org_unit_id is not None:
if isinstance(org_unit_id, int):
where.append(OrgUnit.id == org_unit_id)
else:
where.append(OrgUnit.id.in_(org_unit_id))
query = query.where(*where)
if load_users:
query = query.options(
joinedload(OrgUnit.users)
)
query = query.order_by(OrgUnit.id)
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
if with_count:
result = (await self.db.execute(query)).unique().all()
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(OrgUnit.id))
if query.whereclause is not None:
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().unique().all()
async def get(
self,
org_unit_id: int,
load_users: bool = False
) -> OrgUnit | None:
query = select(OrgUnit).where(OrgUnit.id == org_unit_id)
if load_users:
query = query.options(
joinedload(OrgUnit.users)
)
return (await self.db.execute(query)).scalars().first()
async def create(
self,
title: str,
is_ssp: bool,
utc_offset: str,
) -> OrgUnit:
"""Создание SSP."""
ssp = OrgUnit(title=title, is_ssp=is_ssp, utc_offset=utc_offset)
self.db.add(ssp)
await self.db.flush()
# await self.db.refresh(ssp)
return ssp
async def update(
self, org_unit: OrgUnit, **kwargs,
) -> OrgUnit | None:
if not org_unit:
return None
old_offset = org_unit.utc_offset
for field, value in kwargs.items():
setattr(org_unit, field, value)
await self.db.flush()
if org_unit.utc_offset != old_offset:
await self.db.execute(
text(
"""
SELECT v3.upd_phases_timezones(
:org_unit_id,
:old_tz,
:new_tz
)
"""
),
{
"org_unit_id": org_unit.id,
"old_tz": old_offset,
"new_tz": org_unit.utc_offset,
},
)
# await self.db.refresh(ssp)
return org_unit
async def logical_delete(self, org_unit_id: int, is_active: bool | None = None) -> bool:
"""Логическое удаление SSP."""
query = update(OrgUnit).where(OrgUnit.id == org_unit_id)
if is_active is not None:
query = query.where(OrgUnit.is_active == is_active)
query = query.values(is_active=False)
res = await self.db.execute(query)
await self.db.flush()
return res.rowcount > 0

View File

@ -0,0 +1,370 @@
import json
from sqlalchemy import exists, false, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit
from src.db.models.project import Project
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
class ProjectRepository:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _serialize_project(project: Project, org_unit_name: str | None, report_count: int, years: list[InterruptedError] | None = None) -> dict:
return {
"id": project.id,
"name": project.name,
"level": project.level,
"parent_id": project.parent_id,
"project_type": project.project_type,
"vsp_format": project.vsp_format,
"placement_type": project.placement_type,
"object_address": project.object_address,
"staff_count": project.staff_count,
"total_area": float(project.total_area) if project.total_area is not None else None,
"org_unit_id": project.org_unit_id,
"org_unit_name": org_unit_name,
"report_count": int(report_count),
"years": years,
}
@staticmethod
def _apply_project_filters(query, year: int | None, branch_id: int | None, org_unit_ids: list[int] | None):
if year is not None:
query = query.where(
exists(
select(1).where(
RfProjectReport.project_id == Project.id,
RfProjectReport.year == year,
)
)
)
if branch_id is not None:
query = query.where(Project.org_unit_id == branch_id)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
query = query.where(false())
else:
query = query.where(Project.org_unit_id.in_(org_unit_ids))
return query
async def get_list(
self,
year: int | None = None,
branch_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
org_unit_ids: list[int] | None = None,
) -> list[dict] | tuple[int, list[dict]]:
report_count_subq = (
select(func.count(RfProjectReport.id))
.where(RfProjectReport.project_id == Project.id)
.scalar_subquery()
)
report_years_subq = (
select(RfProjectReport.year)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
func.array(report_years_subq).label("years"),
)
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.order_by(Project.id)
)
query = self._apply_project_filters(query, year, branch_id, org_unit_ids)
if offset is not None:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
rows = (await self.db.execute(query)).all()
payload = [self._serialize_project(row[0], row[1], row[2], row[3]) for row in rows]
if not with_count:
return payload
count_query = select(func.count(Project.id))
count_query = self._apply_project_filters(count_query, year, branch_id, org_unit_ids)
total = int((await self.db.execute(count_query)).scalar() or 0)
return total, payload
async def get(
self,
project_id: int,
org_unit_ids: list[int] | None = None,
) -> dict | None:
report_count_subq = (
select(func.count(RfProjectReport.id))
.where(RfProjectReport.project_id == Project.id)
.scalar_subquery()
)
report_years_subq = (
select(RfProjectReport.year)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
func.array(report_years_subq).label("years"),
)
.outerjoin(OrgUnit, OrgUnit.id == Project.org_unit_id)
.where(Project.id == project_id)
)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
return None
query = query.where(Project.org_unit_id.in_(org_unit_ids))
row = (await self.db.execute(query)).first()
if not row:
return None
return self._serialize_project(row[0], row[1], row[2], row[3])
async def get_reports(self, project_id: int) -> list[dict]:
line_count_subq = (
select(func.count(RfProjectReportLine.id))
.where(RfProjectReportLine.rf_project_report_id == RfProjectReport.id)
.scalar_subquery()
)
query = (
select(
RfProjectReport.id,
RfProjectReport.year,
RfProjectReport.report_type,
RfProjectReport.created_by,
RfProjectReport.created_at,
RfProjectReport.updated_by,
RfProjectReport.updated_at,
line_count_subq.label("line_count"),
)
.where(RfProjectReport.project_id == project_id)
.order_by(RfProjectReport.year, RfProjectReport.report_type)
)
rows = (await self.db.execute(query)).all()
reports: list[dict] = []
for row in rows:
reports.append(
{
"id": row[0],
"year": row[1],
"report_type": row[2],
"created_by": row[3],
"created_at": row[4].isoformat() if row[4] is not None else None,
"updated_by": row[5],
"updated_at": row[6].isoformat() if row[6] is not None else None,
"line_count": int(row[7]),
}
)
return reports
async def resolve_report_id(
self,
project_id: int,
year: int,
report_type: str,
org_unit_ids: list[int] | None = None,
) -> int | None:
query = (
select(RfProjectReport.id)
.join(Project, Project.id == RfProjectReport.project_id)
.where(
Project.id == project_id,
RfProjectReport.year == year,
RfProjectReport.report_type == report_type,
)
)
if org_unit_ids is not None:
if len(org_unit_ids) == 0:
return None
query = query.where(Project.org_unit_id.in_(org_unit_ids))
return (await self.db.execute(query)).scalar_one_or_none()
async def get_report_rows(self, report_id: int, sections: list[str] | None = None) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.v_form3_report_jsonb(CAST(:report_id AS INT), CAST(:sections AS TEXT[]))
"""
)
return (await self.db.execute(query, {"report_id": report_id, "sections": sections})).all()
async def get_rf_rollup_rows(
self,
branch_id: int,
year: int,
sections: list[str] | None = None,
) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.v_form3_rf_rollup_jsonb(CAST(:branch_id AS INT), CAST(:year AS INT), CAST(:sections AS TEXT[]))
"""
)
return (
await self.db.execute(
query,
{"branch_id": branch_id, "year": year, "sections": sections},
)
).all()
async def upd_form3_cell(
self,
report_id: int,
line_id: int,
column: str,
value,
) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.upd_form3_cell(
CAST(:report_id AS INT),
CAST(:line_id AS INT),
CAST(:column AS TEXT),
CAST(:value AS JSONB)
)
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"line_id": line_id,
"column": column,
"value": json.dumps(value),
},
)
).all()
return [tuple(r) for r in rows]
async def upd_form3_cells(self, report_id: int, changes: list[dict]) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.upd_form3_cells(CAST(:report_id AS INT), CAST(:changes AS JSONB))
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"changes": json.dumps(changes),
},
)
).all()
return [tuple(r) for r in rows]
async def add_form3_line(self, report_id: int, expense_item_id: int) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.add_form3_line(CAST(:report_id AS INT), CAST(:expense_item_id AS INT))
"""
)
rows = (
await self.db.execute(
query,
{
"report_id": report_id,
"expense_item_id": expense_item_id,
},
)
).all()
return [tuple(r) for r in rows]
async def del_form3_line(self, line_id: int) -> list[tuple]:
query = text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.del_form3_line(CAST(:line_id AS INT))
"""
)
rows = (await self.db.execute(query, {"line_id": line_id})).all()
return [tuple(r) for r in rows]
async def upd_project(self, project_id: int, column: str, value):
query = text(
"""
SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB))
"""
)
result = (
await self.db.execute(
query,
{
"project_id": project_id,
"column": column,
"value": json.dumps(value),
},
)
).scalar_one()
return result
async def add_project(
self,
name: str,
year: int,
org_unit_id: int,
level: str = "project",
parent_id: int | None = None,
project_type: str | None = None,
vsp_format: str | None = None,
placement_type: str | None = None,
object_address: str | None = None,
staff_count: int | None = None,
total_area: float | None = None,
) -> tuple[int, int, int]:
query = text(
"""
SELECT project_id, limit_report_id, current_expenses_report_id
FROM v3.add_project(
CAST(:name AS VARCHAR),
CAST(:year AS INT),
CAST(:org_unit_id AS INT),
CAST(:level AS VARCHAR),
CAST(:parent_id AS INT),
CAST(:project_type AS VARCHAR),
CAST(:vsp_format AS VARCHAR),
CAST(:placement_type AS VARCHAR),
CAST(:object_address AS VARCHAR),
CAST(:staff_count AS INT),
CAST(:total_area AS NUMERIC)
)
"""
)
row = (
await self.db.execute(
query,
{
"name": name,
"year": year,
"org_unit_id": org_unit_id,
"level": level,
"parent_id": parent_id,
"project_type": project_type,
"vsp_format": vsp_format,
"placement_type": placement_type,
"object_address": object_address,
"staff_count": staff_count,
"total_area": total_area,
},
)
).first()
return int(row[0]), int(row[1]), int(row[2])

View File

@ -0,0 +1,39 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report_line import RfProjectReportLine
class RfProjectReportLineRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, line_id: int) -> RfProjectReportLine | None:
query = select(
RfProjectReportLine
).where(
RfProjectReportLine.id == line_id
).limit(1)
return (await self.db.execute(query)).scalar_one_or_none()
async def get_list(self, line_ids: list[int]) -> list[RfProjectReportLine]:
query = select(
RfProjectReportLine
).where(
RfProjectReportLine.id.in_(line_ids)
)
return (await self.db.execute(query)).scalars().all()
async def get_ids(
self,
project_report_id: int,
) -> list[int]:
query = select(
RfProjectReportLine.id
).where(
RfProjectReportLine.rf_project_report_id == project_report_id
)
return (await self.db.execute(query)).scalars().all()

View File

@ -0,0 +1,33 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.rf_project_report import RfProjectReport
class RfProjectReportRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
report_id: int | None = None,
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
) -> RfProjectReport | None:
assert report_id is not None or all((el is not None for el in (year, report_type, project_id)))
query = select(
RfProjectReport
)
if report_id is not None:
query = query.where(RfProjectReport.id == report_id)
else:
query = query.where(
RfProjectReport.year == year,
RfProjectReport.report_type == report_type,
RfProjectReport.project_id == project_id
)
query = query.limit(1)
return (await self.db.execute(query)).scalar_one_or_none()

View File

@ -0,0 +1,245 @@
import enum
import itertools
import json
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormType
class DirectionEnum(str, enum.Enum):
SUPPORT = "Support"
DEVELOPMENT = "Development"
ACCEPTABLE_SHEETS = set(itertools.chain(*FormType.SHEETS_BY_FORM_TYPE.values()))
ACCEPTABLE_SECTIONS = set(itertools.chain(*FormType.SECTIONS_BY_FORM_TYPE.values()))
class SheetValidationError(Exception):
def __init__(self, message, *args):
super().__init__(*args)
self.message = message
class SheetRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
if direction:
func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)"
else:
func_query = "v3.v_form_view(:form_id, :sheet, :sections)"
return (
await self.db.execute(
text(
f"""
SELECT row_type, depth, sort_order, data
FROM {func_query}
"""
),
{
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
}
)
).all()
async def update_cell(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None,
line_id: int,
column: str,
value: str | int | float | None = None,
user_id: int = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
if user_id:
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :user_id, :direction, :sections)"
else:
func_query = "v3.upd_form_cell(:form_id, :sheet, :line_id, :column, :value, :direction, :sections)"
return [
tuple(r) for r in (
await self.db.execute(
text(
f"""
SELECT row_type, depth, sort_order, data
FROM {func_query}
"""
),
{
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"line_id": line_id,
"column": column,
"value": json.dumps(value),
"user_id": user_id,
}
)
).all()
]
async def update_cells(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None,
changes: list[dict],
user_id: int = None,
) -> list[tuple]:
if any(
[
not isinstance(form_id, int),
sheet not in ACCEPTABLE_SHEETS,
direction is not None and direction not in [s.value for s in DirectionEnum],
sections and (set(sections) - ACCEPTABLE_SECTIONS),
]
):
raise SheetValidationError("Некорректные данные")
changes = json.dumps(changes)
if user_id:
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :user_id, :direction, :sections)"
else:
func_query = "v3.upd_form_cells(:form_id, :sheet, :changes, :direction, :sections)"
return [
tuple(r) for r in (
await self.db.execute(
text(
f"""
SELECT row_type, depth, sort_order, data
FROM {func_query}
"""
),
{
"form_id": form_id,
"sheet": sheet,
"direction": direction,
"sections": sections,
"changes": changes,
"user_id": user_id,
}
)
).all()
]
async def add_line(
self,
form_id: int,
sheet: str,
expense_item_id: int | None = None,
item_id: str | None = None,
section_code: str | None = None,
direction: str | None = None,
name: str | None = None,
internal_order: str | None = None,
vsp_id: int | None = None,
project_id: int | None = None,
justification: str | None = None,
contract_number: str | None = None,
contract_end_date: str | None = None,
user_id: int | None = None,
) -> list[tuple]:
return [
tuple(r) for r in (
await self.db.execute(
text(
"""
SELECT row_type, depth, sort_order, data
FROM v3.add_budget_line(
:p_form_id,
:p_expense_item_id,
:p_sheet,
:p_item_id,
:p_section_code,
:p_direction,
:p_name,
:p_internal_order,
:p_vsp_id,
:p_project_id,
:p_justification,
:p_contract_number,
:p_contract_end_date
)
"""
),
{
"p_form_id": form_id,
"p_expense_item_id": expense_item_id,
"p_sheet": sheet,
"p_item_id": item_id,
"p_section_code": section_code,
"p_direction": direction,
"p_name": name,
"p_internal_order": internal_order,
"p_vsp_id": vsp_id,
"p_project_id": project_id,
"p_justification": justification,
"p_contract_number": contract_number,
"p_contract_end_date": contract_end_date,
}
)
).all()
]
async def delete_line(
self,
sheet: str,
row_id: int,
direction: str | None = None,
) -> list[tuple]:
return [
tuple(r) for r in (
await self.db.execute(
text(
"SELECT row_type, depth, sort_order, data "
"FROM v3.del_budget_line(:p_row_id, :p_direction, :p_sheet)"
),
{
"p_row_id": row_id,
"p_direction": direction,
"p_sheet": sheet,
}
)
).all()
]

View File

@ -1,96 +1,120 @@
from typing import Optional
from sqlalchemy import delete, select, update
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from src.db.models.org_unit import OrgUnit
from src.db.models.role import Role
from src.db.models.user_org import UserOrg
from src.db.models.app_user import AppUser
from src.core.security import get_password_hash, verify_password
from src.domain.models import Roles, SSP, UserSSPLink, Users
class UserRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, user_id: int) -> Optional[Users]:
async def get(self, user_id: int, load_orgs: bool = False) -> Optional[AppUser]:
query = select(AppUser).where(AppUser.id == user_id)
if load_orgs:
query = query.options(
selectinload(AppUser.org_units),
)
return (
(
await self.db.execute(
select(Users).where(Users.id == user_id).limit(1)
query.limit(1)
)
)
.scalars()
.first()
)
async def get_by_email(self, email: str) -> Optional[Users]:
async def get_by_email(self, email: str) -> Optional[AppUser]:
return (
(
await self.db.execute(
select(Users).where(Users.email == email).limit(1)
select(AppUser).where(AppUser.email == email).limit(1)
)
)
.scalars()
.first()
)
async def get_by_username(self, username: str) -> Optional[Users]:
async def get_by_username(self, username: str) -> Optional[AppUser]:
return (
(
await self.db.execute(
select(Users).where(Users.username == username).limit(1)
select(AppUser).where(AppUser.username == username).limit(1)
)
)
.scalars()
.first()
)
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]:
query = (
select(Users)
.where(Users.is_active.is_(True))
.offset(skip)
.limit(limit)
.order_by(Users.id)
)
return (await self.db.execute(query)).scalars().all()
async def set_app_user_id(self, user_id: int) -> None:
if isinstance(user_id, int):
await self.db.execute(
text(
f"SET LOCAL app.user_id = {user_id}"
),
)
async def create(self, user_data: dict) -> Users:
hashed_password = (
get_password_hash(user_data["password"]) if user_data.get("password") else None
)
db_user = Users(
email=user_data["email"],
username=user_data["username"],
hashed_password=hashed_password,
full_name=user_data.get("full_name"),
role_id=user_data.get("role_id"),
)
self.db.add(db_user)
await self.db.commit()
await self.db.refresh(db_user)
return db_user
async def get_list(
self,
skip: int = 0,
limit: int = 100,
with_count: bool = False,
load_orgs: bool = False,
) -> list[AppUser] | tuple[int, list[AppUser]]:
where = [AppUser.is_active.is_(True)]
if with_count:
query = select(func.count().over().label("total_count"), AppUser)
else:
query = select(AppUser)
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
user = await self.get(user_id)
if not user:
return None
query = query.where(*where)
if load_orgs:
query = query.options(
joinedload(AppUser.org_units)
)
query = query.order_by(AppUser.id)
for field, value in user_data.items():
if field == "password" and value:
value = get_password_hash(value)
setattr(user, field, value)
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
await self.db.commit()
await self.db.refresh(user)
return user
if with_count:
result = (await self.db.execute(query)).unique().all()
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(AppUser.id))
if query.whereclause is not None:
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().unique().all()
async def logical_delete(self, user_id: int) -> bool:
query = update(Users).where(Users.id == user_id).values(is_active=False)
query = update(AppUser).where(AppUser.id == user_id).values(is_active=False)
result = await self.db.execute(query)
await self.db.commit()
return result.rowcount > 0
async def authenticate(self, username: str, password: str) -> Optional[Users]:
async def authenticate(self, username: str, password: str) -> Optional[AppUser]:
user = await self.get_by_username(username)
if not user:
return None
@ -98,52 +122,122 @@ class UserRepository:
return None
return user
async def authenticate_via_email(self, email: str) -> Optional[Users]:
async def authenticate_via_email(self, email: str) -> Optional[AppUser]:
return await self.get_by_email(email)
async def get_roles(self) -> list[Roles]:
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
async def get_roles(self) -> list[Role]:
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
async def get_many_ssp(self, user_id: int) -> list[SSP]:
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
UserSSPLink.user_id == user_id
async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
query = select(OrgUnit).join(UserOrg, UserOrg.org_unit_id == OrgUnit.id).where(
UserOrg.user_id == user_id
)
return (await self.db.execute(query)).scalars().all()
async def get_many_ssp_ids(self, user_id: int) -> list[int]:
query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id)
query = select(UserOrg.org_unit_id).where(UserOrg.user_id == user_id)
return (await self.db.execute(query)).scalars().all()
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids:
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id)
self.db.add(link)
await self.db.commit()
return True
except Exception:
await self.db.rollback()
return False
for ssp_id in ssp_ids:
await self.db.execute(
text(
"SELECT v3.grant_user_org_access(:user_id, :org_unit_id)"
),
{"user_id": user_id, "org_unit_id": ssp_id},
)
await self.db.commit()
return True
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids:
query = delete(UserSSPLink).where(
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id)
)
await self.db.execute(query)
await self.db.commit()
return True
except Exception:
await self.db.rollback()
return False
for ssp_id in ssp_ids:
await self.db.execute(
text(
"SELECT v3.revoke_user_org_access(:user_id, :org_unit_id)"
),
{"user_id": user_id, "org_unit_id": ssp_id},
)
await self.db.commit()
return True
async def clear_many_ssp(self, user_id: int) -> bool:
try:
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id)
await self.db.execute(query)
await self.db.commit()
return True
except Exception:
await self.db.rollback()
return False
async def clear_many_ssp(self, user_id: int) -> None:
org_unit_ids = await self.get_many_ssp_ids(user_id)
await self.db.execute(
text(
"SELECT v3.revoke_many_user_org_access(:user_id, :org_unit_ids)"
),
{"user_id": user_id, "org_unit_ids": org_unit_ids},
)
await self.db.flush()
async def create(self, user_data: dict) -> AppUser:
hashed_password = (
get_password_hash(user_data["password"]) if user_data.get("password") else None
)
user_id = (
await self.db.execute(
text(
"""
SELECT (v3.add_user(
:email,
:username,
:hashed_password,
:full_name,
:role_id
)).id
"""
),
{
"email": user_data["email"],
"username": user_data["username"],
"hashed_password": hashed_password,
"full_name": user_data.get("full_name"),
"role_id": user_data.get("role_id"),
},
)
).scalar_one()
await self.db.flush()
return await self.get(user_id)
async def update(
self,
user_id: int,
user_data: dict,
load_orgs: bool = False,
) -> Optional[AppUser]:
user = await self.get(user_id)
if not user:
return None
hashed_password = None
if "password" in user_data and user_data["password"]:
hashed_password = get_password_hash(user_data["password"])
await self.db.execute(
text(
"""
SELECT (v3.upd_user(
:user_id,
:email,
:username,
:hashed_password,
:full_name,
:role_id,
:is_active
)).id
"""
),
{
"user_id": user_id,
"email": user_data.get("email"),
"username": user_data.get("username"),
"hashed_password": hashed_password,
"full_name": user_data.get("full_name"),
"role_id": user_data.get("role_id"),
"is_active": user_data.get("is_active"),
},
)
await self.db.flush()
self.db.expire(user)
result = await self.get(user_id, load_orgs=load_orgs)
return result

View File

@ -0,0 +1,240 @@
from datetime import date
from typing import Optional
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from src.db.models import Vsp
from src.domain.schemas import VSPCreate
class VSPRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get(self, vsp_id: int, is_active: bool | None = None, load_org_unit: bool = False) -> Optional[Vsp]:
queries = [Vsp.id == vsp_id, Vsp.is_deleted.is_(False)]
if is_active is not None:
queries.append(Vsp.is_active == is_active)
if load_org_unit:
return (
(await self.db.execute(
select(Vsp).where(*queries).options(joinedload(Vsp.org_unit)).limit(1))
).scalars().first()
)
else:
return (
(await self.db.execute(select(Vsp).where(*queries).limit(1))).scalars().first()
)
async def get_list(
self,
registration_number: str | None = None,
address: str | None = None,
ssp_ids: list[int] | None = None,
open_date_start: date | None = None,
open_date_end: date | None = None,
placement_type: str | None = None,
staff_count_min: int | None = None,
staff_count_max: int | None = None,
is_active: bool | None = True,
close_date_is_null: bool | None = None,
load_org_unit: bool = False,
with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]:
if with_count:
query = select(func.count().over().label("total_count"), Vsp)
else:
query = select(Vsp)
queries = [Vsp.is_deleted.is_(False)]
filters = [
("registration_number", registration_number),
("address", address),
("placement_type", placement_type),
("open_date_start", open_date_start),
("open_date_end", open_date_end),
("staff_count_min", staff_count_min),
("staff_count_max", staff_count_max),
("is_active", is_active),
]
for filter_name, filter_value in filters:
# print(filter_name, filter_value)
match filter_name, filter_value:
case ("registration_number", str(value)) if value:
queries.append(Vsp.reg_number.ilike(f"%{value}%"))
case ("address", str(value)) if value:
queries.append(Vsp.address.ilike(f"%{value}%"))
case ("placement_type", str(value)) if value:
queries.append(Vsp.placement_type.ilike(f"%{value}%"))
case ("open_date_start", value) if value is not None:
queries.append(Vsp.opened_at >= value)
case ("open_date_end", value) if value is not None:
queries.append(Vsp.opened_at <= value)
case ("staff_count_min", value) if value is not None:
queries.append(Vsp.staff_count >= value)
case ("staff_count_max", value) if value is not None:
queries.append(Vsp.staff_count <= value)
case ("is_active", value) if value is not None:
queries.append(Vsp.is_active == value)
case _:
continue
if close_date_is_null is True:
queries.append(Vsp.closed_at.is_(None))
elif close_date_is_null is False:
queries.append(Vsp.closed_at.is_not(None))
if ssp_ids is not None:
if not ssp_ids:
if with_count:
return 0, []
return []
queries.append(Vsp.branch_id.in_(ssp_ids))
query = query.where(*queries)
if load_org_unit:
query = query.options(joinedload(Vsp.org_unit))
query = query.order_by(Vsp.id)
if with_count:
result = (await self.db.execute(query)).all()
if result:
return result[0][0], [res[1] for res in result]
else:
count_query = select(func.count(Vsp.id))
if query.whereclause is not None:
count_query = count_query.where(
query.whereclause
)
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()
return (await self.db.execute(query.order_by(Vsp.id))).scalars().all()
async def create(self, payload: VSPCreate, created_by: int, load_org_unit: bool = False) -> Vsp:
data = payload.model_dump()
vsp_id = (
await self.db.execute(
text("""
SELECT v3.add_vsp(
:p_branch_id, :p_reg_number, :p_address, :p_format,
:p_opened_at, :p_placement_type, :p_staff_count,
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
:p_system_code, :p_created_by, :p_vsp_type, :p_notes,
:p_rent_contract_num, :p_rent_end_date
)
"""),
{
"p_branch_id": data.get("branch_id"),
"p_reg_number": data.get("reg_number"),
"p_address": data.get("address"),
"p_format": data.get("format"),
"p_opened_at": data.get("opened_at"),
"p_placement_type": data.get("placement_type"),
"p_staff_count": data.get("staff_count"),
"p_total_area": data.get("total_area"),
"p_closed_at": data.get("closed_at"),
"p_is_active": data.get("is_active", True),
"p_is_deleted": data.get("is_deleted", False),
"p_system_code": data.get("system_code"),
"p_created_by": created_by,
"p_vsp_type": data.get("vsp_type"),
"p_notes": data.get("notes"),
"p_rent_contract_num": data.get("rent_contract_num"),
"p_rent_end_date": data.get("rent_end_date"),
}
)
).scalar()
await self.db.flush()
if load_org_unit:
return (
await self.db.execute(
select(Vsp).where(Vsp.id == vsp_id).options(joinedload(Vsp.org_unit)).limit(1)
)
).scalars().first()
else:
return (
await self.db.execute(
select(Vsp).where(Vsp.id == vsp_id).limit(1)
)
).scalars().first()
async def update(self, vsp: Vsp, data: dict, updated_by: int, load_org_unit: bool = False) -> Vsp:
FIELD_TO_PARAM = {
"system_code": "p_system_code",
"vsp_type": "p_vsp_type",
"reg_number": "p_reg_number",
"address": "p_address",
"opened_at": "p_opened_at",
"closed_at": "p_closed_at",
"format": "p_format",
"placement_type": "p_placement_type",
"staff_count": "p_staff_count",
"notes": "p_notes",
"total_area": "p_total_area",
"rent_contract_num": "p_rent_contract_num",
"rent_end_date": "p_rent_end_date",
"branch_id": "p_branch_id",
"is_active": "p_is_active",
"is_deleted": "p_is_deleted",
}
params = {"p_vsp_id": vsp.id, "p_updated_by": updated_by}
for field, param in FIELD_TO_PARAM.items():
params[param] = data.get(field)
await self.db.execute(
text("""
SELECT v3.upd_vsp(
:p_vsp_id, :p_branch_id, :p_reg_number, :p_address,
:p_format, :p_opened_at, :p_placement_type, :p_staff_count,
:p_total_area, :p_closed_at, :p_is_active, :p_is_deleted,
:p_system_code, :p_updated_by, :p_vsp_type, :p_notes,
:p_rent_contract_num, :p_rent_end_date
)
"""),
params,
)
await self.db.flush()
await self.db.refresh(vsp)
if load_org_unit:
return (
await self.db.execute(
select(Vsp).where(Vsp.id == vsp.id).options(joinedload(Vsp.org_unit)).limit(1)
)
).scalars().first()
else:
return vsp
async def deactivate_by_close_date(self, as_of: date) -> int:
result = await self.db.execute(
update(Vsp)
.where(
Vsp.is_active.is_(True),
Vsp.closed_at.is_not(None),
Vsp.closed_at <= as_of,
)
.values(is_active=False, updated_at=func.now())
)
await self.db.commit()
return result.rowcount or 0
async def logical_delete(self, vsp_id: int, updated_by: int) -> bool:
result = (
await self.db.execute(
text("SELECT v3.del_vsp(:p_vsp_id, :p_deleted_by)"),
{"p_vsp_id": vsp_id, "p_deleted_by": updated_by},
)
).scalar()
await self.db.flush()
return result is not None

View File

@ -0,0 +1,46 @@
import enum
import typing
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.user_repository import UserRepository
class AccessEnum(int, enum.Enum):
RESTRICT = 0
VIEW = 1
EDIT = 2
class AccessControlService:
def __init__(self, db: AsyncSession, user: AppUser):
self.db = db
self.user_repo = UserRepository(db)
async def _check_budget_form(
self,
user: AppUser,
budget_form: BudgetForm,
) -> AccessEnum:
if user.role_id == UserRoleEnum.ADMIN:
return AccessEnum.EDIT
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
if budget_form.org_unit_id in [ou.id for ou in user.org_units]:
return AccessEnum.EDIT
return AccessEnum.RESTRICT
async def check_object_access(
self,
user: AppUser,
object: typing.Any
) -> AccessEnum:
if isinstance(object, BudgetForm):
return await self._check_budget_form(user=user,budget_form=object)
return AccessEnum.EDIT

View File

@ -0,0 +1,98 @@
from datetime import datetime
from typing import Any, Dict, Iterable, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException
# from src.domain.models import AuditLog, UserRole, Users
from src.db.models.audit_log import AuditLog
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.repository.auditlog_repository import AuditLogRepository
from src.domain.schemas import AuditLog as AuditLogSchema
class AuditLogService:
"""Сервис для работы с журналом аудита."""
def __init__(self, db: AsyncSession):
self.db = db
self.audit_repo = AuditLogRepository(db)
async def get(self, audit_log_id: int, user: AppUser) -> Optional[AuditLog]:
"""Получение записи аудита по ID."""
if not self._can_view_audit_logs(user):
raise AccessDeniedException(
"Недостаточно прав для просмотра журнала аудита"
)
return await self.audit_repo.get(audit_log_id)
async def get_all(
self,
user: AppUser,
limit: int | None = None,
offset: int | None = None,
user_id: int | None = None,
org_unit_id: int | None = None,
task_id: int | None = None,
form_id: int | None = None,
event_type: str | None = None,
event: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> tuple[Iterable[AuditLog], int]:
"""Получение всех записей аудита с опциональной фильтрацией."""
if not self._can_view_audit_logs(user):
raise AccessDeniedException(
"Недостаточно прав для просмотра журнала аудита"
)
return await self.audit_repo.get_all(
limit=limit,
offset=offset,
user_id=user_id,
org_unit_id=org_unit_id,
task_id=task_id,
form_id=form_id,
event_type=event_type,
date_from=date_from,
date_to=date_to,
event=event,
)
def _can_view_audit_logs(self, user: AppUser) -> bool:
"""Проверяет, может ли пользователь просматривать журнал аудита."""
return user.role_id == UserRoleEnum.ADMIN.value
def orm_log_to_response(self, log: AuditLog) -> AuditLogSchema:
"""Маппинг записи ORM audit_log в формат ответа API (entity, entity_id, action, at, payload_json).
model_validate(orm) не подходит: в БД поля event_dt/event/event_type/event_data, в API at/action/entity/entity_id; entity и entity_id из event_data JSON.
"""
event_data: Optional[Dict[str, Any]] = getattr(log, "event_data", None) or {}
entity = (
event_data.get("entity_type") if isinstance(event_data, dict) else None
) or getattr(log, "event_type", "unknown")
entity_id = (
event_data.get("entity_id") if isinstance(event_data, dict) else None
)
if entity_id is not None and not isinstance(entity_id, int):
try:
entity_id = int(entity_id)
except (TypeError, ValueError):
entity_id = None
action = getattr(log, "event", "")
at = getattr(log, "event_dt", None)
return AuditLogSchema(
entity=entity,
entity_id=entity_id,
action=action,
payload_json=event_data if isinstance(event_data, dict) else None,
id=log.id,
user_id=getattr(log, "user_id", None),
at=at,
user=getattr(log, "user", None),
)

View File

@ -0,0 +1,98 @@
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.user_repository import UserRepository
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
class BudgetFormService:
def __init__(self, db: AsyncSession):
self.db = db
self.bf_repo = BudgetFormRepository(db)
self.user_repo = UserRepository(db)
async def get_list(
self,
user: AppUser,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
load_org: bool = False,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if user.role_id == UserRoleEnum.ADMIN:
return await self.bf_repo.get_list(
offset=offset,
limit=limit,
with_count=with_count,
load_org=load_org,
)
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
return await self.bf_repo.get_list(
offset=offset,
limit=limit,
org_unit=[ou.id for ou in user.org_units],
with_count=with_count,
load_org=load_org,
)
async def get(
self,
budget_form_id: int,
user: AppUser,
load_form_type: bool = False,
load_org: bool = False,
) -> BudgetForm | None:
if user.role_id == UserRoleEnum.ADMIN:
return await self.bf_repo.get(
budget_form_id=budget_form_id,
load_form_type=load_form_type,
load_org=load_org,
)
user = await self.user_repo.get(
user_id=user.id,
load_orgs=True,
)
return await self.bf_repo.get(
budget_form_id=budget_form_id,
org_unit=[ou.id for ou in user.org_units],
load_form_type=load_form_type,
load_org=load_org,
)
async def create(
self,
form_type_code: str,
year: int,
org_unit_id: int,
) -> BudgetForm | None:
return await self.bf_repo.create(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
)
async def bulk_create(
self,
form_type_code: str,
year: int,
org_unit_ids: list[int],
) -> BudgetForm | None:
return await asyncio.gather(
*[
self.create(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
)
for org_unit_id in org_unit_ids
]
)

View File

@ -0,0 +1,35 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_line import BudgetLine
from src.repository.budget_line_repository import BudgetLineRepository
class BudgetLineService:
def __init__(self, db: AsyncSession):
self.db = db
self.bl_repo = BudgetLineRepository(db)
async def get(
self,
user: AppUser,
budget_line_id: int,
) -> BudgetLine | None:
return await self.bl_repo.get(budget_line_id=budget_line_id)
async def get_list(
self,
user: AppUser,
budget_line_ids: list[int],
) -> list[BudgetLine]:
return await self.bl_repo.get_list(budget_line_ids=budget_line_ids)
async def get_ids(
self,
budget_form_id: int,
) -> list[int]:
return await self.bl_repo.get_ids(budget_form_id=budget_form_id)

View File

@ -0,0 +1,16 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.db.models.expense_item import ExpenseItem
from src.repository.expense_item_repository import ExpenseItemRepository
class ExpenseItemService:
def __init__(self, db: AsyncSession):
self.repo = ExpenseItemRepository(db)
async def get(self, item_id: int, user: AppUser | None = None) -> ExpenseItem | None:
if not user or user.role_id == UserRoleEnum.ADMIN:
return await self.repo.get(item_id=item_id)
return None

View File

@ -0,0 +1 @@
from .export_service import ExportService

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,283 @@
from __future__ import annotations
import json
from typing import Any
SMETA_VALUE_PATHS: list[tuple[str, tuple[str, ...]]] = [
("section_code", ("section_code",)),
("name", ("name",)),
("plan_support_q1", ("plan", "support", "q1")),
("plan_support_q2", ("plan", "support", "q2")),
("plan_support_q3", ("plan", "support", "q3")),
("plan_support_q4", ("plan", "support", "q4")),
("plan_support_year", ("plan", "support", "year")),
("plan_development_q1", ("plan", "development", "q1")),
("plan_development_q2", ("plan", "development", "q2")),
("plan_development_q3", ("plan", "development", "q3")),
("plan_development_q4", ("plan", "development", "q4")),
("plan_development_year", ("plan", "development", "year")),
("plan_total_year", ("plan", "total_year")),
("approved_support_q1", ("approved", "support", "q1")),
("approved_support_q2", ("approved", "support", "q2")),
("approved_support_q3", ("approved", "support", "q3")),
("approved_support_q4", ("approved", "support", "q4")),
("approved_support_year", ("approved", "support", "year")),
("approved_development_q1", ("approved", "development", "q1")),
("approved_development_q2", ("approved", "development", "q2")),
("approved_development_q3", ("approved", "development", "q3")),
("approved_development_q4", ("approved", "development", "q4")),
("approved_development_year", ("approved", "development", "year")),
("approved_total_year", ("approved", "total_year")),
("fact_support_q1", ("fact", "support", "q1")),
("fact_support_q2", ("fact", "support", "q2")),
("fact_support_q3", ("fact", "support", "q3")),
("fact_support_q4", ("fact", "support", "q4")),
("fact_support_year", ("fact", "support", "year")),
("fact_development_q1", ("fact", "development", "q1")),
("fact_development_q2", ("fact", "development", "q2")),
("fact_development_q3", ("fact", "development", "q3")),
("fact_development_q4", ("fact", "development", "q4")),
("fact_development_year", ("fact", "development", "year")),
("fact_total_year", ("fact", "total_year")),
("corrected_support_q2", ("corrected", "support", "q2")),
("corrected_support_q3", ("corrected", "support", "q3")),
("corrected_support_q4", ("corrected", "support", "q4")),
("corrected_development_q2", ("corrected", "development", "q2")),
("corrected_development_q3", ("corrected", "development", "q3")),
("corrected_development_q4", ("corrected", "development", "q4")),
]
QUARTER_KEYS = ("q1", "q2", "q3", "q4", "totals")
FORM1_SECTION_BLOCK_KEYS = (
"sequestration",
"allocation",
"reserve",
"approved",
"collegial",
"ckk",
"contract",
"plan",
)
FORM1_METRIC_BLOCK_KEYS = (
"plan",
"reserve",
"approved",
"collegial",
"allocation",
"sequestration",
"contract",
"ckk",
"contract_summary",
)
class ExcelValueSerializer:
def to_excel_value(self, value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
for preferred_key in ("name", "title", "label", "value"):
preferred_value = value.get(preferred_key)
if isinstance(preferred_value, (str, int, float, bool)) and preferred_value is not None:
return preferred_value
if len(value) == 1:
only_value = next(iter(value.values()))
if isinstance(only_value, (str, int, float, bool)) or only_value is None:
return only_value
return self._dict_to_readable_text(value)
if isinstance(value, (list, tuple)):
if all(isinstance(item, (str, int, float, bool)) or item is None for item in value):
return ", ".join("" if item is None else str(item) for item in value)
return json.dumps(value, ensure_ascii=False)
return str(value)
def _dict_to_readable_text(self, value: dict[str, Any]) -> Any:
parts: list[str] = []
for key, raw in value.items():
normalized = self.to_excel_value(raw)
if self._is_empty_excel_value(normalized):
continue
parts.append(f"{key}={normalized}")
if not parts:
return None
return "; ".join(parts)
def dict_to_readable_text(self, value: dict[str, Any]) -> Any:
return self._dict_to_readable_text(value)
@staticmethod
def _is_empty_excel_value(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str) and value.strip() == "":
return True
return False
class Form1RowNormalizer:
def __init__(self, serializer: ExcelValueSerializer):
self.serializer = serializer
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
normalized = dict(data)
self._merge_header_fields_into_row(normalized=normalized, original=data)
self._promote_quarter_values_from_section_blocks(normalized=normalized)
self._collapse_metric_blocks_to_scalar(normalized=normalized)
self._fill_contract_fields(normalized=normalized)
return normalized
def _merge_header_fields_into_row(self, normalized: dict[str, Any], original: dict[str, Any]) -> None:
header = original.get("header")
if isinstance(header, dict):
if "section" in header and "section" not in normalized:
normalized["section"] = header.get("section")
if "item_id" in header and "item_id" not in normalized:
normalized["item_id"] = header.get("item_id")
if "name" in header and "name" not in normalized:
normalized["name"] = header.get("name")
normalized.pop("header", None)
normalized.pop("line_id", None)
def _promote_quarter_values_from_section_blocks(self, normalized: dict[str, Any]) -> None:
for section_key in FORM1_SECTION_BLOCK_KEYS:
section_payload = normalized.get(section_key)
if not isinstance(section_payload, dict):
continue
for quarter_key in QUARTER_KEYS:
if normalized.get(quarter_key) is None and section_payload.get(quarter_key) is not None:
normalized[quarter_key] = section_payload.get(quarter_key)
def _collapse_metric_blocks_to_scalar(self, normalized: dict[str, Any]) -> None:
for block_key in FORM1_METRIC_BLOCK_KEYS:
block_value = normalized.get(block_key)
if isinstance(block_value, dict):
normalized[block_key] = self._extract_preferred_metric(block_value)
def _fill_contract_fields(self, normalized: dict[str, Any]) -> None:
contract_summary = normalized.get("contract_summary")
contract_detail = normalized.get("contract_detail")
if normalized.get("contract") is None:
normalized["contract"] = self._extract_contract_label(contract_detail, contract_summary)
if isinstance(contract_detail, dict):
normalized["contract_sum"] = self._extract_preferred_metric(contract_detail)
def _extract_preferred_metric(self, payload: dict[str, Any]) -> Any:
priority_keys = (
"total_year",
"year",
"totals",
"total",
"sum",
"amount",
"value",
"approved_amount",
"reserved_amount",
"allocated_amount",
"booked_amount",
)
for key in priority_keys:
if payload.get(key) is not None:
return payload.get(key)
for quarter_key in ("q1", "q2", "q3", "q4"):
if payload.get(quarter_key) is not None:
return payload.get(quarter_key)
return self.serializer.dict_to_readable_text(payload)
@staticmethod
def _extract_contract_label(contract_detail: Any, contract_summary: Any) -> Any:
for payload in (contract_detail, contract_summary):
if not isinstance(payload, dict):
continue
for key in ("reference", "contract_ref", "counterparty", "subject"):
value = payload.get(key)
if value not in (None, ""):
return value
return None
class Form1DepthRowNormalizer:
def __init__(self, serializer: ExcelValueSerializer):
self.serializer = serializer
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
normalized = dict()
self._merge_header_fields_into_row(normalized=normalized, original=data)
self._normilize_depth(normalized=normalized, original=data)
return normalized
def _merge_header_fields_into_row(self, normalized: dict[str, Any], original: dict[str, Any]) -> None:
header = original.get("header")
if isinstance(header, dict):
if "section" in header and "section" not in normalized:
normalized["section"] = header.get("section")
if "item_id" in header and "item_id" not in normalized:
normalized["item_id"] = header.get("item_id")
if "name" in header and "name" not in normalized:
normalized["name"] = header.get("name")
if "num_group" in header and "num_group" not in normalized:
normalized["num_group"] = header.get("num_group")
if "internal_order" in header and "internal_order" not in normalized:
normalized["internal_order"] = header.get("internal_order")
normalized.pop("header", None)
normalized.pop("line_id", None)
def _normilize_depth(self, normalized: dict, original: dict, prefix: str = "") -> None:
for key, value in original.items():
if isinstance(value, dict):
self._normilize_depth(normalized=normalized, original=value, prefix=f"{prefix}{key}.")
else:
normalized[f"{prefix}{key}"] = value
class SmetaRowNormalizer:
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
return {
field: self._pick_nested_value(data, *path)
for field, path in SMETA_VALUE_PATHS
}
@staticmethod
def _pick_nested_value(container: Any, *path: str) -> Any:
cur = container
for key in path:
if not isinstance(cur, dict):
return None
cur = cur.get(key)
return cur
class Form3ReportRowNormalizer:
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
normalized = dict(data)
header = data.get("header")
if isinstance(header, dict):
if "section_code" in header and "section" not in normalized:
normalized["section"] = header.get("section_code")
if "item_id" in header and "item_id" not in normalized:
normalized["item_id"] = header.get("item_id")
if "name" in header and "name" not in normalized:
normalized["name"] = header.get("name")
for quarter_key in ("q1", "q2", "q3", "q4"):
normalized[quarter_key] = self._extract_quarter_metric(normalized.get(quarter_key))
normalized["totals"] = self._extract_totals_metric(normalized.get("totals"))
normalized.pop("header", None)
normalized.pop("line_id", None)
return normalized
@staticmethod
def _extract_quarter_metric(value: Any) -> Any:
if not isinstance(value, dict):
return value
for key in ("quarter_actual", "total_corr", "economy"):
if value.get(key) is not None:
return value.get(key)
return None
@staticmethod
def _extract_totals_metric(value: Any) -> Any:
if not isinstance(value, dict):
return value
for key in ("total_actual", "total_corr", "economy"):
if value.get(key) is not None:
return value.get(key)
return None

View File

@ -0,0 +1,449 @@
from __future__ import annotations
import io
import json
import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from urllib.parse import quote
from openpyxl import Workbook
from openpyxl.worksheet.worksheet import Worksheet
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.form_type import FormTypeEnum
from src.services.budget_form_service import BudgetFormService
from src.services.export_service.export_normalizers import (
ExcelValueSerializer,
Form1DepthRowNormalizer,
Form3ReportRowNormalizer,
SmetaRowNormalizer,
)
from src.services.export_service.export_mappings import (
FORM1_COLORS,
FORM1_DEPTH_COLUMNS,
FORM3_REPORT_COLUMNS,
SMETA_COLUMNS,
)
from src.services.export_service.export_writers import (
DepthSheetWriter,
TabularSheetWriter,
)
from src.services.project_service import ProjectService
from src.services.sheet_service import SheetService
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
ZIP_MEDIA_TYPE = "application/zip"
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
@dataclass(frozen=True)
class ExportSheetPlan:
sheet_name: str
effective_direction: str | None
effective_sections: list[str] | None
validation_direction: str | None
validation_sections: list[str] | None
class ExportService:
def __init__(self, db: AsyncSession):
self.budget_form_service = BudgetFormService(db)
self.sheet_service = SheetService(db)
self.project_service = ProjectService(db)
self.value_serializer = ExcelValueSerializer()
self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer)
self.form3_report_row_normalizer = Form3ReportRowNormalizer()
self.smeta_row_normalizer = SmetaRowNormalizer()
self.sheet_writers: dict[str, TabularSheetWriter] = {
"__default__": DepthSheetWriter(
columns_dict=FORM1_DEPTH_COLUMNS,
row_normalizer=self.form1_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
),
"SMETA": TabularSheetWriter(
columns=SMETA_COLUMNS,
row_normalizer=self.smeta_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
),
"OPER": DepthSheetWriter(
columns_dict=FORM1_DEPTH_COLUMNS,
row_normalizer=self.form1_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
colors_config=FORM1_COLORS["OPER"],
),
"AHR": DepthSheetWriter(
columns_dict=FORM1_DEPTH_COLUMNS,
row_normalizer=self.form1_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
colors_config=FORM1_COLORS["AHR"],
),
"CAP": DepthSheetWriter(
columns_dict=FORM1_DEPTH_COLUMNS,
row_normalizer=self.form1_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
colors_config=FORM1_COLORS["CAP"],
),
}
self.form3_report_writer = TabularSheetWriter(
columns=FORM3_REPORT_COLUMNS,
row_normalizer=self.form3_report_row_normalizer.normalize,
value_serializer=self.value_serializer.to_excel_value,
)
async def export_form_payload(
self,
form_id: int,
current_user: AppUser,
sheet: str | None = None,
direction: str | None = None,
sections: list[str] | None = None,
ignore_non_applicable_query_params: bool = False,
) -> tuple[io.BytesIO, str, str]:
stream, filename = await self.export_form_to_xlsx(
form_id=form_id,
current_user=current_user,
sheet=sheet,
direction=direction,
sections=sections,
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
)
return stream, filename, XLSX_MEDIA_TYPE
async def export_project_report_payload(
self,
project_id: int,
year: int,
report_type: str,
current_user: AppUser,
sections: list[str] | None = None,
) -> tuple[io.BytesIO, str, str]:
stream, filename = await self.export_project_report_to_xlsx(
project_id=project_id,
year=year,
report_type=report_type,
current_user=current_user,
sections=sections,
)
return stream, filename, XLSX_MEDIA_TYPE
async def export_bulk_payload(
self,
form_ids: list[int],
current_user: AppUser,
skip_failed: bool = True,
direction: str | None = None,
sections: list[str] | None = None,
ignore_non_applicable_query_params: bool = False,
) -> tuple[io.BytesIO, str, str]:
archive_stream = io.BytesIO()
exported_count = 0
errors: list[dict[str, Any]] = []
used_names: set[str] = set()
with zipfile.ZipFile(
archive_stream,
mode="w",
compression=zipfile.ZIP_DEFLATED,
) as archive:
for form_id in self._deduplicate_ids(form_ids):
try:
form_stream, filename = await self.export_form_to_xlsx(
form_id=form_id,
current_user=current_user,
direction=direction,
sections=sections,
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
)
archive_name = self._make_unique_filename(filename, used_names)
archive.writestr(archive_name, form_stream.getvalue())
exported_count += 1
except ValueError as exc:
if not skip_failed:
raise
errors.append({"form_id": form_id, "error": str(exc)})
if errors:
archive.writestr(
"errors.json",
json.dumps({"errors": errors}, ensure_ascii=False, indent=2).encode("utf-8"),
)
if exported_count == 0:
raise ValueError("Не удалось экспортировать ни одной формы.")
archive_stream.seek(0)
filename = f"export_forms_{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.zip"
return archive_stream, filename, ZIP_MEDIA_TYPE
async def export_form_to_xlsx(
self,
form_id: int,
current_user: AppUser,
sheet: str | None = None,
direction: str | None = None,
sections: list[str] | None = None,
ignore_non_applicable_query_params: bool = False,
) -> tuple[io.BytesIO, str]:
form = await self._get_form_for_export(form_id=form_id, current_user=current_user)
sheets = self._resolve_requested_sheets(form=form, requested_sheet=sheet)
self._validate_sections_for_form(form=form, sections=sections)
if direction:
plans = self._build_sheet_plans(
form=form,
sheets=sheets,
direction=direction,
sections=sections,
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
)
else:
sheets_by_directions = self._resolve_sheets_by_directions(form=form, requested_sheet=sheet)
plans = []
for dr, shts in sheets_by_directions.items():
plans += self._build_sheet_plans(
form=form,
sheets=shts,
direction=dr,
sections=sections,
ignore_non_applicable_query_params=ignore_non_applicable_query_params,
)
workbook = self._create_workbook_with_metadata(form=form)
exported_sheets = 0
for plan in plans:
self._validate_sheet_query_params(
form_type_code=form.form_type.code,
sheet=plan.sheet_name,
direction=plan.validation_direction,
sections=plan.validation_sections,
)
rows = await self.sheet_service.get(
form_id=form_id,
sheet=plan.sheet_name,
direction=plan.effective_direction,
sections=plan.effective_sections,
)
worksheet = workbook.create_sheet(title=self._safe_sheet_name(plan.sheet_name))
self._write_sheet_rows(
worksheet=worksheet,
rows=rows,
sheet_name=plan.sheet_name,
)
exported_sheets += 1
if exported_sheets == 0:
raise ValueError("Не удалось экспортировать листы формы.")
stream = io.BytesIO()
workbook.save(stream)
workbook.close()
stream.seek(0)
filename = self._build_filename(form_id=form.id, form_type_code=form.form_type.code, year=form.year, sheet=sheet)
return stream, filename
async def export_project_report_to_xlsx(
self,
project_id: int,
year: int,
report_type: str,
current_user: AppUser,
sections: list[str] | None = None,
) -> tuple[io.BytesIO, str]:
rows = await self.project_service.get_report_rows(
project_id=project_id,
year=year,
report_type=report_type,
sections=sections,
user=current_user,
)
report_type_upper = report_type.upper()
workbook = Workbook()
meta_sheet = workbook.active
meta_sheet.title = "metadata"
metadata_rows = [
("Тип формы", "FORM_3"),
("Project ID", project_id),
("Year", year),
("Report Type", report_type_upper),
("Sections", ", ".join(sections) if sections else None),
("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
]
for row_idx, (key, value) in enumerate(metadata_rows, start=1):
meta_sheet.cell(row=row_idx, column=1, value=key)
meta_sheet.cell(row=row_idx, column=2, value=value)
worksheet = workbook.create_sheet(title=self._safe_sheet_name(report_type_upper))
self.form3_report_writer.write(worksheet, rows)
stream = io.BytesIO()
workbook.save(stream)
workbook.close()
stream.seek(0)
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
filename = f"{year}_FORM_3_{project_id}_{report_type_upper}_{date_str}.xlsx"
return stream, filename
@staticmethod
def build_content_disposition(filename: str) -> str:
encoded = quote(filename)
return f"attachment; filename*=UTF-8''{encoded}"
def _fill_metadata(self, sheet: Worksheet, form: Any) -> None:
metadata_rows = [
("Форма ID", form.id),
("Тип формы", form.form_type_code),
("Год", form.year),
("Org Unit ID", form.org_unit_id),
("Org Unit", form.org_unit.title if form.org_unit else None),
("Экспортировано", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")),
]
for row_idx, (key, value) in enumerate(metadata_rows, start=1):
sheet.cell(row=row_idx, column=1, value=key)
sheet.cell(row=row_idx, column=2, value=value)
def _write_sheet_rows(
self,
worksheet: Worksheet,
rows: list[tuple],
sheet_name: str,
) -> None:
writer = self.sheet_writers.get(sheet_name, self.sheet_writers["__default__"])
writer.write(worksheet, rows)
async def _get_form_for_export(self, form_id: int, current_user: AppUser) -> Any:
form = await self.budget_form_service.get(
budget_form_id=form_id,
user=current_user,
load_form_type=True,
load_org=True,
)
if not form:
raise ValueError(f"Форма {form_id} не найдена")
return form
def _resolve_requested_sheets(self, form: Any, requested_sheet: str | None) -> list[str]:
if requested_sheet and requested_sheet not in form.form_type.sheet_list:
raise ValueError(f"Лист {requested_sheet} формы {form.id} не найден")
if requested_sheet:
return [requested_sheet]
return list(form.form_type.sheet_list)
def _resolve_sheets_by_directions(self, form: Any, requested_sheet: str | None) -> dict[str | None, str]:
result = {}
for sheet in form.form_type.sheet_list_with_directions:
if requested_sheet and sheet["sheet_name"] != requested_sheet:
continue
if sheet["direction"] in result:
result[sheet["direction"]].append(sheet["sheet_name"])
else:
result[sheet["direction"]] = [sheet["sheet_name"]]
return result
@staticmethod
def _validate_sections_for_form(form: Any, sections: list[str] | None) -> None:
if not sections:
return
invalid = sorted(set(sections) - set(form.form_type.section_list))
if invalid:
raise ValueError(f"Недопустимые sections: {', '.join(invalid)}")
def _create_workbook_with_metadata(self, form: Any) -> Workbook:
workbook = Workbook()
meta_sheet = workbook.active
meta_sheet.title = "metadata"
self._fill_metadata(meta_sheet, form)
return workbook
def _build_sheet_plans(
self,
form: Any,
sheets: list[str],
direction: str | None,
sections: list[str] | None,
ignore_non_applicable_query_params: bool,
) -> list[ExportSheetPlan]:
plans: list[ExportSheetPlan] = []
for sheet_name in sheets:
effective_direction = direction if self._requires_direction(form.form_type.code, sheet_name) else None
effective_sections = sections if sheet_name in SHEETS_WITH_SECTIONS else None
validation_direction = effective_direction if ignore_non_applicable_query_params else direction
validation_sections = effective_sections if ignore_non_applicable_query_params else sections
plans.append(
ExportSheetPlan(
sheet_name=sheet_name,
effective_direction=effective_direction,
effective_sections=effective_sections,
validation_direction=validation_direction,
validation_sections=validation_sections,
)
)
return plans
def _validate_sheet_query_params(
self,
form_type_code: FormTypeEnum,
sheet: str,
direction: str | None,
sections: list[str] | None,
) -> None:
if form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS and not direction:
raise ValueError("direction обязателен для FORM_1 листов AHR/CAP")
if direction and not self._requires_direction(form_type_code, sheet):
raise ValueError("direction допустим только для FORM_1 листов AHR/CAP")
if sections and sheet not in SHEETS_WITH_SECTIONS:
raise ValueError(f"sections не поддерживается для листа {sheet}")
@staticmethod
def _requires_direction(form_type_code: FormTypeEnum, sheet: str) -> bool:
return form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
@staticmethod
def _safe_sheet_name(value: str) -> str:
safe = value
for char in ("[", "]", ":", "*", "?", "/", "\\"):
safe = safe.replace(char, "_")
safe = safe.strip()
return (safe or "Sheet")[:31]
@staticmethod
def _build_filename(form_id: int, form_type_code: str, year: int, sheet: str | None = None) -> str:
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if sheet:
return f"{year}_{form_type_code}_{form_id}_{sheet}_{date_str}.xlsx"
return f"{year}_{form_type_code}_{form_id}_{date_str}.xlsx"
@staticmethod
def _deduplicate_ids(form_ids: list[int]) -> list[int]:
seen: set[int] = set()
result: list[int] = []
for form_id in form_ids:
if form_id not in seen:
seen.add(form_id)
result.append(form_id)
return result
@staticmethod
def _make_unique_filename(filename: str, used_names: set[str]) -> str:
if filename not in used_names:
used_names.add(filename)
return filename
if "." in filename:
base, ext = filename.rsplit(".", 1)
ext = f".{ext}"
else:
base, ext = filename, ""
suffix = 2
while True:
candidate = f"{base}_{suffix}{ext}"
if candidate not in used_names:
used_names.add(candidate)
return candidate
suffix += 1

View File

@ -0,0 +1,209 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Callable
from openpyxl.styles import Alignment, Border, Color, Font, PatternFill, Side
from openpyxl.worksheet.worksheet import Worksheet
from openpyxl.utils import get_column_letter
from src.services.export_service.export_mappings import PALETTE
RowNormalizer = Callable[[dict[str, Any]], dict[str, Any]]
ValueSerializer = Callable[[Any], Any]
class TabularSheetWriter:
def __init__(
self,
columns: list[tuple[str, str]],
row_normalizer: RowNormalizer,
value_serializer: ValueSerializer,
):
self.columns = columns
self.row_normalizer = row_normalizer
self.value_serializer = value_serializer
def write(self, worksheet: Worksheet, rows: list[tuple]) -> None:
worksheet.append([title for _, title in self.columns])
for row in rows:
raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {}
normalized = self.row_normalizer(raw_data)
worksheet.append(
[self.value_serializer(normalized.get(key)) for key, _ in self.columns]
)
class DepthSheetWriter:
alignment = Alignment(horizontal='center', vertical='center')
thin_border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
def __init__(
self,
columns_dict: dict,
row_normalizer: RowNormalizer,
value_serializer: ValueSerializer,
colors_config: dict | None = None,
):
self.columns_dict = columns_dict
self._flat_columns_to_depth = {}
self.columns = self._columns_dict_to_flat(columns_dict)
self.row_normalizer = row_normalizer
self.value_serializer = value_serializer
self.colors_config = colors_config
self.palette = None
if colors_config:
self._set_palette()
def _columns_dict_to_flat(
self,
columns_dict: dict | None = None,
prefix: str = "",
depth_prefix: str = "",
) -> None:
if columns_dict is None:
columns_dict = self.columns_dict
result = []
for key, sub_dict in columns_dict.items():
if "key" in sub_dict:
new_key = sub_dict['key']
else:
new_key = key
if "children" not in sub_dict:
if new_key:
add_key = f"{prefix}{new_key}"
result.append(add_key)
self._flat_columns_to_depth[add_key] = f"{depth_prefix}{key}"
else:
result += self._columns_dict_to_flat(
sub_dict["children"],
prefix=f"{prefix}{new_key}." if new_key else prefix,
depth_prefix=f"{depth_prefix}{key}.",
)
return result
def _set_palette(self) -> None:
self.palette = {}
for key in self.columns:
full_key = None
for sub_key in self._flat_columns_to_depth[key].split("."):
if full_key is None:
full_key = sub_key
else:
full_key = f"{full_key}.{sub_key}"
if full_key in self.colors_config and "palette" in self.colors_config[full_key]:
self.palette[key] = self.colors_config[full_key]["palette"]
def _get_header_max_row(self, column_dict: dict) -> int:
max_depth = 0
for sub_dict in column_dict.values():
if "children" in sub_dict:
max_depth = max(max_depth, self._get_header_max_row(sub_dict["children"]))
return max_depth + 1
def _write_header(
self,
worksheet: Worksheet,
columns_dict: dict | None = None,
current_column: int = 1,
current_row: int = 1,
max_row: int | None = None,
full_key: str | None = None
) -> int:
if columns_dict is None:
columns_dict = self.columns_dict
max_row = self._get_header_max_row(self.columns_dict)
for key, sub_dict in columns_dict.items():
if full_key:
key = f"{full_key}.{key}"
if "children" not in sub_dict:
cell = worksheet.cell(row=current_row, column=current_column, value=sub_dict["name"])
cell.border = self.thin_border
if max_row != current_row:
worksheet.merge_cells(
start_row=current_row,
start_column=current_column,
end_row=max_row,
end_column=current_column,
)
current_column += 1
else:
new_column = self._write_header(
worksheet=worksheet,
columns_dict=sub_dict["children"],
current_column=current_column,
current_row=current_row+1,
max_row=max_row,
full_key=key,
)
cell = worksheet.cell(row=current_row, column=current_column, value=sub_dict["name"])
if new_column - current_column > 1:
worksheet.merge_cells(
start_row=current_row,
start_column=current_column,
end_row=current_row,
end_column=new_column - 1,
)
cell.border = self.thin_border
current_column = new_column
cell.alignment = self.alignment
if self.colors_config and (color := self.colors_config.get(key)):
if "background" in color:
cell.fill = PatternFill(
start_color=Color(rgb=color["background"]),
fill_type="solid",
)
if "color" in color:
cell.font = Font(color=color["color"])
return current_column
def _set_widths(self, worksheet: Worksheet):
for col in worksheet.columns:
max_len = 0
col_letter = get_column_letter(col[0].column) # Get alphabetical column name (e.g., 'A')
for cell in col:
if cell.value is not None:
cell_len = len(str(cell.value))
if cell_len > max_len:
max_len = cell_len
adjusted_width = max(max_len + 3, 10)
worksheet.column_dimensions[col_letter].width = adjusted_width
def _set_color_to_row(self, ws: Worksheet, item_type: str) -> None:
if not self.palette or item_type in ("SUB_ITEM", "INPUT"):
return
row = ws.max_row
for i, key in enumerate(self.columns):
if key not in self.palette:
continue
cell = ws.cell(column=i+1, row=row)
if (palette := self.palette.get(key)):
cell.fill = PatternFill(
start_color=Color(rgb=PALETTE[palette][item_type]),
fill_type="solid",
)
def write(self, worksheet: Worksheet, rows: list[tuple]) -> None:
self._write_header(worksheet)
for row in rows:
raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {}
normalized = self.row_normalizer(raw_data)
worksheet.append(
[self.value_serializer(normalized.get(key)) for key in self.columns]
)
if len(row) > 3:
self._set_color_to_row(ws=worksheet, item_type=row[0])
self._set_widths(worksheet)

View File

@ -0,0 +1,139 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException, ValidationsError
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
from src.db.models.app_user import AppUser
from src.db.models.form_phase import FormPhase
from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate
from src.repository.form_phase_repository import FormPhaseRepository
class FormPhaseService:
def __init__(self, db: AsyncSession):
self.db = db
self.fp_repo = FormPhaseRepository(db)
self.bf_repo = BudgetFormRepository(db)
async def get_list(
self,
budget_form_id: int,
user: AppUser,
sheet: str | None = None,
phase_code: str | None = None,
) -> list[FormPhase]:
return await self.fp_repo.get_list(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)
async def get(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
) -> FormPhase | None:
return await self.fp_repo.get(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)
def _validate_form_phase(
self,
form_phase: FormPhaseCreate | FormPhaseUpdate,
budget_form: BudgetForm,
sheet: str | None = None,
) -> None:
errors = {}
if budget_form.form_type.sheet_list:
sheet = sheet or form_phase.sheet
if sheet not in budget_form.form_type.sheet_list:
errors["sheet"] = [f"У формы нет листа {sheet}"]
if form_phase.column_keys and budget_form.form_type.section_list:
for column_key in form_phase.column_keys:
section = column_key.split(".")
if section[0] not in budget_form.form_type.section_list:
if "column_keys" in errors:
errors["column_keys"].append(f"Колонка {column_key} некорректна")
else:
errors["column_keys"] = [f"Колонка {column_key} некорректна"]
if errors:
raise ValidationsError(errors=errors)
async def create(
self,
body: FormPhaseCreate,
user: AppUser,
budget_form_id: int | None = None,
budget_form: BudgetForm | None = None,
) -> FormPhase:
assert budget_form or budget_form_id
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
if not budget_form or budget_form.form_type is None:
budget_form = await self.bf_repo.get(
budget_form_id=budget_form_id or budget_form.id,
load_form_type=True,
)
self._validate_form_phase(
form_phase=body,
budget_form=budget_form,
)
return await self.fp_repo.create(
budget_form_id=budget_form_id or budget_form.id,
sheet=body.sheet,
phase_code=body.phase_code,
role=body.role,
column_keys=body.column_keys,
opens_at=body.opens_at,
closes_at=body.closes_at,
)
async def update(
self,
sheet: str,
phase_code: str,
body: FormPhaseUpdate,
user: AppUser,
budget_form_id: int | None = None,
budget_form: BudgetForm | None = None,
) -> FormPhase:
assert budget_form or budget_form_id
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException
if not budget_form or budget_form.form_type is None:
budget_form = await self.bf_repo.get(
budget_form_id=budget_form_id or budget_form.id,
load_form_type=True,
)
self._validate_form_phase(
form_phase=body,
budget_form=budget_form,
sheet=sheet,
)
data = body.model_dump(exclude_unset=True)
if not data:
return await self.fp_repo.get(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
phase_code=phase_code,
)
return await self.fp_repo.update(
budget_form_id=budget_form_id or budget_form.id,
sheet=sheet,
phase_code=phase_code,
data=data,
)
async def delete(
self, budget_form_id: int, sheet: str, phase_code: str, user: AppUser
) -> bool:
return await self.fp_repo.delete(
budget_form_id=budget_form_id,
sheet=sheet,
phase_code=phase_code,
)

View File

@ -0,0 +1,106 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException
from src.db.models.app_user import AppUser
from src.db.models.org_unit import OrgUnit
from src.db.models.role import UserRoleEnum
from src.repository.org_unit_repository import OrgUnitRepository
class OrgUnitService:
def __init__(self, db: AsyncSession):
self.db = db
self.org_repo = OrgUnitRepository(db)
async def get_list(
self,
user: AppUser,
offset: int | None = None,
limit: int | None = None,
is_ssp: bool | None = None,
is_active: bool | None = None,
org_unit_id: int | list[int] | None = None,
with_count: bool = False,
load_users: bool = False,
) -> list[OrgUnit] | tuple[int, list[OrgUnit]]:
if user.role_id != UserRoleEnum.ADMIN:
return (0, []) if with_count else []
return await self.org_repo.get_list(
offset=offset,
limit=limit,
with_count=with_count,
is_ssp=is_ssp,
is_active=is_active,
load_users=load_users,
org_unit_id=org_unit_id,
)
async def get(
self,
org_unit_id: int,
user: AppUser,
load_users: bool = False,
) -> OrgUnit | None:
if user.role_id != UserRoleEnum.ADMIN:
return None
return await self.org_repo.get(
org_unit_id=org_unit_id,
load_users=load_users,
)
async def create(
self,
title: str,
is_ssp: bool,
utc_offset: str,
user: AppUser,
) -> OrgUnit:
"""Создание нового ССП/РФ."""
if not self._can_create_ssp(user):
raise AccessDeniedException()
return await self.org_repo.create(
title=title,
is_ssp=is_ssp,
utc_offset=utc_offset,
)
async def update(
self, org_unit_id: int, user: AppUser, **kwargs
) -> OrgUnit | None:
"""Редактирование ССП/РФ"""
#ToDo: писать логи
if not self._can_edit_ssp(user):
raise AccessDeniedException()
org_unit = await self.org_repo.get(org_unit_id=org_unit_id)
if not org_unit:
return None
return await self.org_repo.update(
org_unit=org_unit, **kwargs,
)
async def logical_delete(
self,
org_unit_id: int,
user: AppUser,
is_active: bool | None = None,
) -> bool:
"""Логическое удаление ССП/РФ"""
if not self._can_delete_ssp(user):
raise AccessDeniedException()
return bool(await self.org_repo.logical_delete(org_unit_id=org_unit_id, is_active=is_active))
def _can_create_ssp(self, user: AppUser) -> bool:
return user.role_id == UserRoleEnum.ADMIN
def _can_edit_ssp(self, user: AppUser) -> bool:
return user.role_id == UserRoleEnum.ADMIN
def _can_delete_ssp(self, user: AppUser) -> bool:
return user.role_id == UserRoleEnum.ADMIN

View File

@ -0,0 +1,191 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.core.errors import AccessDeniedException, ValidationException
from src.repository.project_repository import ProjectRepository
from src.repository.user_repository import UserRepository
class ProjectService:
def __init__(self, db: AsyncSession):
self.db = db
self.project_repo = ProjectRepository(db)
self.user_repo = UserRepository(db)
async def get_list(
self,
user: AppUser,
year: int | None = None,
branch_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
with_org_unit: bool = False,
) -> list[dict] | tuple[int, list[dict]]:
org_unit_ids = await self._allowed_org_unit_ids(user)
return await self.project_repo.get_list(
year=year,
branch_id=branch_id,
offset=offset,
limit=limit,
with_count=with_count,
org_unit_ids=org_unit_ids,
)
async def get(self, project_id: int, user: AppUser) -> dict | None:
org_unit_ids = await self._allowed_org_unit_ids(user)
project = await self.project_repo.get(project_id=project_id, org_unit_ids=org_unit_ids)
if not project:
return None
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
return project
async def resolve_report_id(
self,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> int | None:
normalized_type = report_type.upper()
if normalized_type not in ("LIMIT", "CURRENT_EXPENSES"):
raise ValidationException("report_type должен быть LIMIT или CURRENT_EXPENSES")
org_unit_ids = await self._allowed_org_unit_ids(user)
return await self.project_repo.resolve_report_id(
project_id=project_id,
year=year,
report_type=normalized_type,
org_unit_ids=org_unit_ids,
)
async def get_report_rows(
self,
project_id: int,
year: int,
report_type: str,
sections: list[str] | None,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.get_report_rows(report_id=report_id, sections=sections)
async def get_rf_rollup_rows(
self,
branch_id: int,
year: int,
sections: list[str] | None,
user: AppUser,
) -> list[tuple]:
org_unit_ids = await self._allowed_org_unit_ids(user)
if org_unit_ids is not None and branch_id not in org_unit_ids:
raise AccessDeniedException()
return await self.project_repo.get_rf_rollup_rows(
branch_id=branch_id,
year=year,
sections=sections,
)
async def upd_form3_cell(
self,
project_id: int,
year: int,
report_type: str,
line_id: int,
column: str,
value,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.upd_form3_cell(report_id, line_id, column, value)
async def upd_form3_cells(
self,
project_id: int,
year: int,
report_type: str,
changes: list[dict],
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.upd_form3_cells(report_id, changes)
async def add_form3_line(
self,
project_id: int,
year: int,
report_type: str,
expense_item_id: int,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.add_form3_line(report_id, expense_item_id)
async def del_form3_line(
self,
project_id: int,
year: int,
report_type: str,
line_id: int,
user: AppUser,
) -> list[tuple]:
report_id = await self.resolve_report_id(project_id, year, report_type, user)
if not report_id:
raise ValidationException("Отчёт не найден")
return await self.project_repo.del_form3_line(line_id)
async def upd_project(
self,
project_id: int,
column: str,
value,
user: AppUser,
):
project = await self.get(project_id, user)
if not project:
raise ValidationException("Проект не найден")
return await self.project_repo.upd_project(project_id, column, value)
async def add_project(
self,
name: str,
year: int,
org_unit_id: int,
level: str = "project",
parent_id: int | None = None,
project_type: str | None = None,
vsp_format: str | None = None,
placement_type: str | None = None,
object_address: str | None = None,
staff_count: int | None = None,
total_area: float | None = None,
) -> tuple[int, int, int]:
return await self.project_repo.add_project(
name=name,
year=year,
org_unit_id=org_unit_id,
level=level,
parent_id=parent_id,
project_type=project_type,
vsp_format=vsp_format,
placement_type=placement_type,
object_address=object_address,
staff_count=staff_count,
total_area=total_area,
)
async def _allowed_org_unit_ids(self, user: AppUser) -> list[int] | None:
if user.role_id == UserRoleEnum.ADMIN:
return None
db_user = await self.user_repo.get(user_id=user.id, load_orgs=True)
if not db_user:
return []
return [ou.id for ou in db_user.org_units]

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