Compare commits

...

131 Commits

Author SHA1 Message Date
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
342 changed files with 189308 additions and 394 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.

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

@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# 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)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_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"}

View File

@ -15,3 +15,4 @@ asyncpg==0.30.0
pytest==8.3.2 pytest==8.3.2
pytest-asyncio==0.24.0 pytest-asyncio==0.24.0
raisa-fastapi-protected-api==1.0.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"}

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

@ -0,0 +1,42 @@
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,
date_from=params.date_from,
date_to=params.date_to,
)
result = [audit_service.orm_log_to_response(log) for log in logs]
return BaseListResponse(result=result, count=count)

View File

@ -5,7 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings from src.core.config import settings
from src.core.security import verify_token from src.core.security import verify_token
from src.db.session import get_db 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 from src.repository.user_repository import UserRepository
security = HTTPBearer() security = HTTPBearer()
@ -13,11 +14,10 @@ security = HTTPBearer()
if settings.DEBUG: if settings.DEBUG:
async def get_current_user( async def get_user_by_token(
credentials: HTTPAuthorizationCredentials = Depends(security), token: str,
db: AsyncSession = Depends(get_db), db: AsyncSession,
) -> Users: ) -> AppUser:
token = credentials.credentials
payload = verify_token(token) payload = verify_token(token)
if payload is None: if payload is None:
raise HTTPException( raise HTTPException(
@ -46,13 +46,19 @@ if settings.DEBUG:
) )
return user return user
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)
else: else:
from raisa_fastapi_protected_api import UserInfo, get_user_dependency from raisa_fastapi_protected_api import UserInfo, get_user_dependency
async def get_current_user( async def get_current_user(
user: UserInfo = Depends(get_user_dependency), user: UserInfo = Depends(get_user_dependency),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Users: ) -> AppUser:
user_repo = UserRepository(db) user_repo = UserRepository(db)
db_user = await user_repo.get_by_email(user.email) db_user = await user_repo.get_by_email(user.email)
if db_user is None: if db_user is None:
@ -64,12 +70,24 @@ else:
return db_user 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 return current_user
def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users: async def get_current_active_user_with_set_db(
if current_user.role_id != UserRole.ADMIN: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав", detail="Недостаточно прав",
@ -77,11 +95,13 @@ def require_admin(current_user: Users = Depends(get_current_active_user)) -> Use
return current_user 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 [ if current_user.role_id not in [
UserRole.ADMIN, UserRoleEnum.ADMIN,
UserRole.EXECUTOR_DFIP, UserRoleEnum.EXECUTOR_DFIP,
UserRole.EXECUTOR_RF, UserRoleEnum.EXECUTOR_RF,
]: ]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@ -90,8 +110,10 @@ def require_executor(current_user: Users = Depends(get_current_active_user)) ->
return current_user return current_user
def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users: def require_executor_dfip(
if current_user.role_id not in [UserRole.ADMIN, UserRole.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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав", detail="Недостаточно прав",

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,113 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
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"])
@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)
phases = await fp_service.get_list(
budget_form_id=form_id,
user=current_user,
sheet=sheet,
phase_code=phase_code,
)
return BaseListResponse(
result=[FormPhaseResponse.model_validate(p) for p in phases],
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, "Форма не найдена")
fp_service = FormPhaseService(db)
phase = await fp_service.create(
budget_form=form,
body=body,
user=current_user,
)
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
@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, "Этап не найден")
return BaseSingleResponse(result=FormPhaseResponse.model_validate(phase))
@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, "Этап не найден")

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

@ -0,0 +1,391 @@
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"}
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}"
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))}")
budget_line_service = BudgetLineService(db)
budget_line = await budget_line_service.get(budget_line_id=cell_body.line_id, user=current_user)
if budget_line.budget_form_id != form_id:
raise HTTPException(404, f"Строка формы не найдена")
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}"
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))}")
budget_line_service = BudgetLineService(db)
budget_lines = await budget_line_service.get_list(
budget_line_ids=[cb.line_id for cb in cells_body.changes], user=current_user)
for budget_line in budget_lines:
if budget_line.budget_form_id != form_id:
raise HTTPException(404, f"Строка формы не найдена")
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}"
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}"
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}"
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}"
return BaseListResponse(
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],
count=len(new_forms),
)

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

@ -0,0 +1,125 @@
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, OrgUnitBaseSchema, 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: OrgUnitBaseSchema,
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,
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="ССП/РФ удалена")

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

@ -0,0 +1,247 @@
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
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}"
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}"
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,21 @@
from fastapi import APIRouter 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,
)
from src.api.v1 import websocket
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.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)

View File

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

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

@ -0,0 +1,183 @@
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,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_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,
location_form=location_form,
numbers_min=numbers_min,
numbers_max=numbers_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,
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, 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,
location_form=body.location_form,
numbers_min=body.numbers_min,
numbers_max=body.numbers_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"ВСП не найден",
)

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

@ -0,0 +1,844 @@
from contextlib import asynccontextmanager
from dataclasses import asdict
import dataclasses
import enum
from json import dumps, loads
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.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
@asynccontextmanager
async def get_db_session():
"""Контекстный менеджер для получения сессии базы данных."""
db = SessionLocal()
try:
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,
message: str,
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.user_id != user_id:
try:
await con_info.ws.send_text(message)
except:
pass # Игнорируем недоступные соединения
async def broadcast_to_all(
self,
message: str,
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]:
try:
await con_info.ws.send_text(message)
except:
pass # Игнорируем недоступные соединения
async def send_back(self, message: str, websocket: WebSocket):
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.bf_service: BudgetFormService = BudgetFormService(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 = await self.project_service.get(project_id=project_id, user=user)
if not project:
return None
return 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,
)
async def __del_row(
self,
event_data: dict,
project_id: int,
year: int,
report_type: str,
user: AppUser,
) -> list[tuple]:
"""
data = {
"line_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["line_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(
dumps(data, default=_convert_error, ensure_ascii=False),
websocket,
)
continue
await manager.send_back(
dumps(data, default=_convert_error, ensure_ascii=False),
websocket,
)
await manager.broadcast_to_other(
message=dumps(data, default=_convert_error, ensure_ascii=False),
user_id=user_id,
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(
dumps(data, default=_convert_error, ensure_ascii=False),
websocket,
)
await manager.broadcast_to_other(
message=dumps(data, default=_convert_error, ensure_ascii=False),
user_id=user_id,
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(
dumps(data, default=_convert_error, ensure_ascii=False),
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(
dumps(data, default=_convert_error, ensure_ascii=False),
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(
dumps(data, default=_convert_error, ensure_ascii=False),
websocket,
)
continue
case _:
pass
async with get_db_session() as db:
processor = processor_cls(db)
data["result"] = await processor.process(
event_data=data,
user_id=user_id,
**kwargs_process
)
await db.commit()
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(
message=dumps(data, default=_convert_error),
**kwargs
)
else:
await manager.broadcast_to_other(
message=dumps(data, default=_convert_error),
user_id=user_id,
**kwargs,
)
except BasicAppException as e:
data["error"] = e.description or "Неизвестная ошибка"
await manager.send_back(
message=dumps(data, default=_convert_error),
websocket=websocket,
)
except IntegrityError as e:
data["error"] = str(e)
await manager.send_back(
message=dumps(data, default=_convert_error),
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(
message=dumps(data, default=_convert_error),
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

@ -105,6 +105,11 @@ class Settings(BaseSettings):
description="Размер батча для очистки аудита", description="Размер батча для очистки аудита",
alias="AUDIT_LOG_CLEANUP_BATCH_SIZE", 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View File

@ -1,6 +1,8 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class ValidationError(BaseModel): class ValidationError(BaseModel):
field: str = Field(...) field: str = Field(...)
description: str = Field(...) description: str = Field(...)
@ -10,6 +12,14 @@ class BasicAppException(Exception):
description: str | None = None 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): class AccessDeniedException(BasicAppException):
description = "Доступ запрещен" 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 fastapi.responses import JSONResponse
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError
from src.core.errors import ( from src.core.errors import (
AccessDeniedException, AccessDeniedException,
@ -7,41 +9,176 @@ from src.core.errors import (
UserNotFoundException, UserNotFoundException,
UsernameConflictException, UsernameConflictException,
ValidationException, 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: 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) @app.exception_handler(AccessDeniedException)
async def access_denied_exception_handler(request, exc: AccessDeniedException): async def access_denied_exception_handler(request, exc: AccessDeniedException):
status_code = status.HTTP_403_FORBIDDEN
return JSONResponse( return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN, status_code=status_code,
content={"detail": exc.description}, content=_error_payload(status_code, exc.description),
) )
@app.exception_handler(ValidationException) @app.exception_handler(ValidationException)
async def validation_exception_handler(request, exc: ValidationException): async def validation_exception_handler(request, exc: ValidationException):
status_code = status.HTTP_400_BAD_REQUEST
return JSONResponse( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status_code,
content={"detail": exc.description}, content=_error_payload(status_code, exc.description, field=exc.field or ""),
) )
@app.exception_handler(UserNotFoundException) @app.exception_handler(UserNotFoundException)
async def user_not_found_exception_handler(request, exc: UserNotFoundException): async def user_not_found_exception_handler(request, exc: UserNotFoundException):
status_code = status.HTTP_404_NOT_FOUND
return JSONResponse( return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND, status_code=status_code,
content={"detail": exc.description}, content=_error_payload(status_code, exc.description),
) )
@app.exception_handler(UsernameConflictException) @app.exception_handler(UsernameConflictException)
async def username_conflict_exception_handler(request, exc: UsernameConflictException): async def username_conflict_exception_handler(request, exc: UsernameConflictException):
status_code = status.HTTP_409_CONFLICT
return JSONResponse( return JSONResponse(
status_code=status.HTTP_409_CONFLICT, status_code=status_code,
content={"detail": exc.description}, content=_error_payload(status_code, exc.description),
) )
@app.exception_handler(BasicAppException) @app.exception_handler(BasicAppException)
async def basic_app_exception_handler(request, exc: 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( return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, 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: if settings.DATABASE_SCHEMA:
connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA} connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA}
if "sqlite" in settings.DATABASE_URL: if "sqlite" in settings.DATABASE_URL:
connect_args["check_same_thread"] = False connect_args["check_same_thread"] = False
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=5,
max_overflow=10,
connect_args=connect_args, connect_args=connect_args,
) )

View File

@ -0,0 +1,30 @@
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.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,57 @@
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)
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,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,41 @@
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)
@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=True))
closes_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
# 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")

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

@ -0,0 +1,59 @@
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)
location_form: Mapped[str | None] = mapped_column(String, nullable=True)
numbers: Mapped[int | None] = mapped_column(Integer, 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 alembic.config import Config
from src.core.config import settings from src.core.config import settings
from typing import AsyncGenerator
from src.db.base import SessionLocal from src.db.base import SessionLocal
@ -12,6 +14,10 @@ async def get_db() -> AsyncGenerator:
db = SessionLocal() db = SessionLocal()
try: try:
yield db yield db
await db.commit()
except Exception:
await db.rollback()
raise
finally: finally:
await db.close() await db.close()

View File

@ -1,8 +1,23 @@
import enum import enum
#ToDo: вероятно это теперь AuditEvent. А еще он нигде не юзается больше, вероятно, выпилить
class AuditEventType(str, enum.Enum): class AuditEventType(str, enum.Enum):
LOGIN = "LOGIN" LOGIN = "LOGIN"
USER_CREATE = "USER_CREATE" USER_CREATE = "USER_CREATE"
USER_UPDATE = "USER_UPDATE" USER_UPDATE = "USER_UPDATE"
USER_DELETE = "USER_DELETE" 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 datetime import datetime, date
from typing import Optional 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): class ResponseBase(BaseModel):
success: Optional[bool] = Field(None) success: Optional[bool] = Field(True)
message: Optional[str] = Field(None) 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): class UserBase(BaseModel):
email: EmailStr email: EmailStr = Field(..., examples=["user@example.com"])
username: str = Field( username: str = Field(
..., ...,
min_length=3, min_length=3,
max_length=64, max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$", 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 role_id: int = 3
model_config = ConfigDict(from_attributes=True)
class UserCreate(UserBase): 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): class UserUpdate(BaseModel):
email: Optional[EmailStr] = None email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
username: Optional[str] = Field( username: Optional[str] = Field(
None, None,
min_length=3, min_length=3,
max_length=64, max_length=64,
pattern=r"^[A-Za-z0-9_.-]+$", 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 role_id: Optional[int] = None
is_active: Optional[bool] = True is_active: Optional[bool] = True
load_orgs: bool = Field(default=False)
class UserInDB(UserBase): class UserInDB(UserBase):
@ -51,6 +134,37 @@ class User(UserInDB):
pass 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): class UserResponse(ResponseBase):
result: Optional[UserInDB] = None result: Optional[UserInDB] = None
@ -97,3 +211,405 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str 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 OrgUnitUpdateSchema(OrgUnitBaseSchema):
title: Optional[str] = None
is_ssp: Optional[bool] = None
is_active: Optional[bool] = None
model_config = ConfigDict(from_attributes=True)
class OrgUnitResponseSchema(OrgUnitBaseSchema):
id: int
is_active: bool
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,
}
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
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
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="Тип события")
date_from: Optional[datetime] = Field(None, description="Дата начала (ISO 8601)")
date_to: Optional[datetime] = 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="Примечания")
location_form: Optional[str] = Field(None, description="Форма расположения")
numbers: 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="Примечания")
location_form: Optional[str] = Field(None, description="Форма расположения")
numbers: 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
location_form: Optional[str] = None
numbers_min: Optional[int] = None
numbers_max: Optional[int] = None

View File

@ -1,9 +1,13 @@
import asyncio
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from contextlib import suppress
from fastapi import FastAPI from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
@ -25,8 +29,72 @@ if not settings.DEBUG:
) )
_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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
cleanup_task: asyncio.Task | None = None
if not settings.DEBUG: if not settings.DEBUG:
await create_tables() await create_tables()
ProtectedSettings( ProtectedSettings(
@ -36,9 +104,14 @@ async def lifespan(app: FastAPI):
APP_NAME=settings.APP_NAME, APP_NAME=settings.APP_NAME,
), ),
) )
cleanup_task = asyncio.create_task(_audit_cleanup_loop())
try: try:
yield yield
finally: finally:
if cleanup_task:
cleanup_task.cancel()
with suppress(asyncio.CancelledError):
await cleanup_task
await engine.dispose() await engine.dispose()
@ -69,6 +142,41 @@ async def healthcheck2():
"port": settings.PORT, "port": settings.PORT,
} }
@app.get("/healthz", status_code=200)
async def healthz():
return {"status": "ok", "uptime": UP_TIME, "version": VERSION}
@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: if not settings.DEBUG:
app.add_middleware( app.add_middleware(
@ -83,6 +191,16 @@ if not settings.DEBUG:
path=settings.ROOT_PATH + "/healthcheck", path=settings.ROOT_PATH + "/healthcheck",
type_search=SearchType.ABSOLUTE, 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="/openapi.json", type_search=SearchType.START),
OpenEndpoint( OpenEndpoint(
path=settings.ROOT_PATH + "/openapi.json", path=settings.ROOT_PATH + "/openapi.json",

View File

@ -0,0 +1,83 @@
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,
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 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,115 @@
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:
if query.whereclause is not None:
count_query = select(func.count(BudgetForm.id)).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]) -> BudgetLine | None:
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,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,
:opens_at,
:closes_at
)).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,
:opens_at,
:closes_at
)).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,117 @@
from sqlalchemy import func, select, 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:
if query.whereclause is not None:
count_query = select(func.count(OrgUnit.id)).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,
) -> OrgUnit:
"""Создание SSP."""
ssp = OrgUnit(title=title, is_ssp=is_ssp)
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
for field, value in kwargs.items():
setattr(org_unit, field, value)
await self.db.flush()
# 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,354 @@
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) -> 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),
}
@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()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
)
.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]) 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()
)
query = (
select(
Project,
OrgUnit.title.label("org_unit_name"),
report_count_subq.label("report_count"),
)
.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])
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,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,119 @@
from typing import Optional 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.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.core.security import get_password_hash, verify_password
from src.domain.models import Roles, SSP, UserSSPLink, Users
class UserRepository: class UserRepository:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db 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 ( return (
( (
await self.db.execute( await self.db.execute(
select(Users).where(Users.id == user_id).limit(1) query.limit(1)
) )
) )
.scalars() .scalars()
.first() .first()
) )
async def get_by_email(self, email: str) -> Optional[Users]: async def get_by_email(self, email: str) -> Optional[AppUser]:
return ( return (
( (
await self.db.execute( await self.db.execute(
select(Users).where(Users.email == email).limit(1) select(AppUser).where(AppUser.email == email).limit(1)
) )
) )
.scalars() .scalars()
.first() .first()
) )
async def get_by_username(self, username: str) -> Optional[Users]: async def get_by_username(self, username: str) -> Optional[AppUser]:
return ( return (
( (
await self.db.execute( await self.db.execute(
select(Users).where(Users.username == username).limit(1) select(AppUser).where(AppUser.username == username).limit(1)
) )
) )
.scalars() .scalars()
.first() .first()
) )
async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]: async def set_app_user_id(self, user_id: int) -> None:
query = ( if isinstance(user_id, int):
select(Users) await self.db.execute(
.where(Users.is_active.is_(True)) text(
.offset(skip) f"SET LOCAL app.user_id = {user_id}"
.limit(limit) ),
.order_by(Users.id)
) )
return (await self.db.execute(query)).scalars().all()
async def create(self, user_data: dict) -> Users:
hashed_password = ( async def get_list(
get_password_hash(user_data["password"]) if user_data.get("password") else None 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)
query = query.where(*where)
if load_orgs:
query = query.options(
joinedload(AppUser.org_units)
) )
db_user = Users(
email=user_data["email"], query = query.order_by(AppUser.id)
username=user_data["username"],
hashed_password=hashed_password, if skip is not None:
full_name=user_data.get("full_name"), query = query.offset(skip)
role_id=user_data.get("role_id"), 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:
if query.whereclause is not None:
count_query = select(func.count(AppUser.id)).where(
query.whereclause
) )
self.db.add(db_user) return (await self.db.execute(count_query)).scalar(), []
await self.db.commit() else:
await self.db.refresh(db_user) return (await self.db.execute(query)).scalars().unique().all()
return db_user
async def update(self, user_id: int, user_data: dict) -> Optional[Users]:
user = await self.get(user_id)
if not user:
return None
for field, value in user_data.items():
if field == "password" and value:
value = get_password_hash(value)
setattr(user, field, value)
await self.db.commit()
await self.db.refresh(user)
return user
async def logical_delete(self, user_id: int) -> bool: 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) result = await self.db.execute(query)
await self.db.commit() await self.db.commit()
return result.rowcount > 0 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) user = await self.get_by_username(username)
if not user: if not user:
return None return None
@ -98,52 +121,122 @@ class UserRepository:
return None return None
return user 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) return await self.get_by_email(email)
async def get_roles(self) -> list[Roles]: async def get_roles(self) -> list[Role]:
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all() return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
async def get_many_ssp(self, user_id: int) -> list[SSP]: async def get_many_ssp(self, user_id: int) -> list[OrgUnit]:
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where( query = select(OrgUnit).join(UserOrg, UserOrg.org_unit_id == OrgUnit.id).where(
UserSSPLink.user_id == user_id UserOrg.user_id == user_id
) )
return (await self.db.execute(query)).scalars().all() return (await self.db.execute(query)).scalars().all()
async def get_many_ssp_ids(self, user_id: int) -> list[int]: 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() return (await self.db.execute(query)).scalars().all()
async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids: for ssp_id in ssp_ids:
link = UserSSPLink(user_id=user_id, ssp_id=ssp_id) await self.db.execute(
self.db.add(link) 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() await self.db.commit()
return True return True
except Exception:
await self.db.rollback()
return False
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool:
try:
for ssp_id in ssp_ids: for ssp_id in ssp_ids:
query = delete(UserSSPLink).where( await self.db.execute(
(UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id) text(
"SELECT v3.revoke_user_org_access(:user_id, :org_unit_id)"
),
{"user_id": user_id, "org_unit_id": ssp_id},
) )
await self.db.execute(query)
await self.db.commit() await self.db.commit()
return True return True
except Exception:
await self.db.rollback()
return False
async def clear_many_ssp(self, user_id: int) -> bool: async def clear_many_ssp(self, user_id: int) -> None:
try: org_unit_ids = await self.get_many_ssp_ids(user_id)
query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id) await self.db.execute(
await self.db.execute(query) text(
await self.db.commit() "SELECT v3.revoke_many_user_org_access(:user_id, :org_unit_ids)"
return True ),
except Exception: {"user_id": user_id, "org_unit_ids": org_unit_ids},
await self.db.rollback() )
return False 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,243 @@
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,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_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),
("location_form", location_form),
("open_date_start", open_date_start),
("open_date_end", open_date_end),
("numbers_min", numbers_min),
("numbers_max", numbers_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 ("location_form", str(value)) if value:
queries.append(Vsp.location_form.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 ("numbers_min", value) if value is not None:
queries.append(Vsp.numbers >= value)
case ("numbers_max", value) if value is not None:
queries.append(Vsp.numbers <= 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:
if query.whereclause is not None:
count_query = select(func.count(Vsp.id)).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_location_form, :p_numbers, :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_location_form": data.get("location_form"),
"p_numbers": data.get("numbers"),
"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",
"notes": "p_notes",
"location_form": "p_location_form",
"numbers": "p_numbers",
"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)
params.setdefault("p_placement_type", data.get("placement_type"))
params.setdefault("p_staff_count", data.get("staff_count"))
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_location_form, :p_numbers, :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,96 @@
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,
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,
)
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 @@
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:
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,104 @@
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,
user: AppUser,
) -> OrgUnit:
"""Создание нового ССП/РФ."""
if not self._can_create_ssp(user):
raise AccessDeniedException()
return await self.org_repo.create(
title=title,
is_ssp=is_ssp,
)
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]

View File

@ -0,0 +1,139 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.app_user import AppUser
from src.db.models.budget_form import BudgetForm
from src.repository.sheet_repository import SheetRepository
class SheetService:
def __init__(self, db: AsyncSession):
self.db = db
self.sheet_repo = SheetRepository(db)
async def get(
self,
sheet: str,
direction: str | None,
sections: list[str] | None = None,
form_id: int | None = None,
form: BudgetForm | None = None
) -> list[tuple]:
return await self.sheet_repo.get(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
)
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: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
line_id=line_id,
column=column,
value=value,
user_id=user.id if user else None,
)
async def update_cells(
self,
form_id: int,
sheet: str,
direction: str | None,
sections: list[str] | None,
changes: list[dict],
user: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.update_cells(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
changes=changes,
user_id=user.id if user else None,
)
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: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.update_cell(
form_id=form_id,
sheet=sheet,
direction=direction,
sections=sections,
line_id=line_id,
column=column,
value=value,
user_id=user.id if user else None,
)
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: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.add_line(
form_id=form_id,
sheet=sheet,
expense_item_id=expense_item_id,
item_id=item_id,
section_code=section_code,
direction=direction,
name=name,
internal_order=internal_order,
vsp_id=vsp_id,
project_id=project_id,
justification=justification,
contract_number=contract_number,
contract_end_date=contract_end_date,
user_id=user.id if user else None,
)
async def delete_line(
self,
form_id: int,
sheet: str,
row_id: int,
direction: str | None = None,
user: AppUser | None = None,
) -> list[tuple]:
return await self.sheet_repo.delete_line(
sheet=sheet,
row_id=row_id,
direction=direction,
)

View File

@ -1,12 +1,13 @@
from sqlalchemy.ext.asyncio import AsyncSession 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 ( from src.core.errors import (
AccessDeniedException, AccessDeniedException,
UserNotFoundException, UserNotFoundException,
ValidationException, ValidationException,
UsernameConflictException, UsernameConflictException,
) )
from src.domain.models import UserRole, Users
from src.domain.schemas import UserCreate, UserUpdate from src.domain.schemas import UserCreate, UserUpdate
from src.repository.user_repository import UserRepository from src.repository.user_repository import UserRepository
@ -16,23 +17,40 @@ class UserService:
self.db = db self.db = db
self.user_repo = UserRepository(db) self.user_repo = UserRepository(db)
async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100): async def get_all(
if current_user.role_id != UserRole.ADMIN: self,
current_user: AppUser,
skip: int = 0,
limit: int = 100,
with_count: bool = False,
load_orgs: bool = False,
) -> list[AppUser] | tuple[int, list[AppUser]]:
if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get_list(skip=skip, limit=limit) return await self.user_repo.get_list(
skip=skip,
limit=limit,
with_count=with_count,
load_orgs=load_orgs,
)
async def get(self, user_id: int, current_user: Users): async def get(
if current_user.role_id != UserRole.ADMIN: self,
user_id: int,
current_user: AppUser | None = None,
load_orgs: bool = False,
):
if current_user is not None and current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get(user_id) return await self.user_repo.get(user_id=user_id, load_orgs=load_orgs)
async def create_user(self, user_data: UserCreate, creator: Users): async def create_user(self, user_data: UserCreate, creator: AppUser):
if creator.role_id != UserRole.ADMIN: if creator.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
role_id = user_data.role_id or UserRole.EXECUTOR_RF.value role_id = user_data.role_id or UserRoleEnum.EXECUTOR_RF.value
try: try:
role = UserRole(role_id) role = UserRoleEnum(role_id)
except ValueError: except ValueError:
raise ValidationException("Роль не найдена", field="role_id") raise ValidationException("Роль не найдена", field="role_id")
user_data.role_id = role.value user_data.role_id = role.value
@ -43,8 +61,8 @@ class UserService:
payload = user_data.model_dump(mode="json") payload = user_data.model_dump(mode="json")
return await self.user_repo.create(payload) return await self.user_repo.create(payload)
async def update_user(self, user_id: int, user_data: UserUpdate, user: Users): async def update_user(self, user_id: int, user_data: UserUpdate, user: AppUser):
if user.role_id != UserRole.ADMIN: if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
target = await self.user_repo.get(user_id) target = await self.user_repo.get(user_id)
@ -57,7 +75,7 @@ class UserService:
role = None role = None
if role_id is not None: if role_id is not None:
try: try:
role = UserRole(role_id) role = UserRoleEnum(role_id)
except ValueError: except ValueError:
raise ValidationException("Роль не найдена", field="role_id") raise ValidationException("Роль не найдена", field="role_id")
@ -72,47 +90,56 @@ class UserService:
if conflict: if conflict:
raise UsernameConflictException() raise UsernameConflictException()
updated = await self.user_repo.update(user_id, update_payload) updated = await self.user_repo.update(
user_id=user_id,
user_data=update_payload,
load_orgs=user_data.load_orgs,
)
if role_id is not None and role is not None: if role_id is not None and role is not None:
had_many_ssp_role = previous_role_id in ( had_many_ssp_role = previous_role_id in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
) )
has_many_ssp_role_now = role.value in ( has_many_ssp_role_now = role.value in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
) )
is_executor_to_dfip_transition = ( is_executor_to_dfip_transition = (
previous_role_id == UserRole.EXECUTOR_RF.value previous_role_id == UserRoleEnum.EXECUTOR_RF.value
and role.value == UserRole.EXECUTOR_DFIP.value and role.value == UserRoleEnum.EXECUTOR_DFIP.value
) )
if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition: if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition:
await self.user_repo.clear_many_ssp(user_id) await self.user_repo.clear_many_ssp(user_id)
if user_data.load_orgs:
self.db.expire(updated)
updated = await self.user_repo.get(
user_id=user_id,
load_orgs=user_data.load_orgs,
)
return updated return updated
async def delete_user(self, user_id: int, user: Users): async def delete_user(self, user_id: int, user: AppUser):
if user.role_id != UserRole.ADMIN: if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
deleted = await self.user_repo.logical_delete(user_id) deleted = await self.user_repo.logical_delete(user_id)
if not deleted: if not deleted:
raise UserNotFoundException() raise UserNotFoundException()
async def get_roles(self, current_user: Users): async def get_roles(self, current_user: AppUser):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get_roles() return await self.user_repo.get_roles()
async def get_many_ssp(self, user_id: int, current_user: Users): async def get_many_ssp(self, user_id: int, current_user: AppUser):
if current_user.role_id == UserRole.ADMIN: if current_user.role_id == UserRoleEnum.ADMIN:
return await self.user_repo.get_many_ssp(user_id) return await self.user_repo.get_many_ssp(user_id)
if current_user.id != user_id: if current_user.id != user_id:
raise AccessDeniedException() raise AccessDeniedException()
return await self.user_repo.get_many_ssp(user_id) return await self.user_repo.get_many_ssp(user_id)
async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
user = await self.user_repo.get(user_id) user = await self.user_repo.get(user_id)
@ -121,9 +148,9 @@ class UserService:
target_role_id = self._normalize_role_id(user.role_id) target_role_id = self._normalize_role_id(user.role_id)
if target_role_id not in ( if target_role_id not in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
UserRole.EXECUTOR_RF.value, UserRoleEnum.EXECUTOR_RF.value,
): ):
err = AccessDeniedException() err = AccessDeniedException()
err.description = ( err.description = (
@ -132,7 +159,7 @@ class UserService:
raise err raise err
curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id) curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id)
if target_role_id == UserRole.EXECUTOR_RF.value: if target_role_id == UserRoleEnum.EXECUTOR_RF.value:
merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids) merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids)
if len(merged_ssp_ids) > 1: if len(merged_ssp_ids) > 1:
raise ValidationException( raise ValidationException(
@ -141,8 +168,8 @@ class UserService:
new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids] new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids]
return await self.user_repo.set_many_ssp(user.id, new_ssp_ids) return await self.user_repo.set_many_ssp(user.id, new_ssp_ids)
async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: AppUser):
if current_user.role_id != UserRole.ADMIN: if current_user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException() raise AccessDeniedException()
user = await self.user_repo.get(user_id) user = await self.user_repo.get(user_id)
@ -151,9 +178,9 @@ class UserService:
target_role_id = self._normalize_role_id(user.role_id) target_role_id = self._normalize_role_id(user.role_id)
if target_role_id not in ( if target_role_id not in (
UserRole.ADMIN.value, UserRoleEnum.ADMIN.value,
UserRole.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DFIP.value,
UserRole.EXECUTOR_RF.value, UserRoleEnum.EXECUTOR_RF.value,
): ):
err = AccessDeniedException() err = AccessDeniedException()
err.description = ( err.description = (
@ -163,5 +190,5 @@ class UserService:
return await self.user_repo.unset_many_ssp(user_id, ssp_ids) return await self.user_repo.unset_many_ssp(user_id, ssp_ids)
@staticmethod @staticmethod
def _normalize_role_id(role_id: int | UserRole) -> int: def _normalize_role_id(role_id: int | UserRoleEnum) -> int:
return role_id.value if isinstance(role_id, UserRole) else int(role_id) return role_id.value if isinstance(role_id, UserRoleEnum) else int(role_id)

View File

@ -0,0 +1,283 @@
import asyncio
import io
from datetime import date, datetime
from typing import Optional
from openpyxl import Workbook
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.errors import AccessDeniedException, ValidationException
from src.db.models import AppUser, UserRoleEnum, Vsp
from src.domain.schemas import VSPCreate, VSPUpdate
from src.repository.user_repository import UserRepository
from src.repository.vsp_repository import VSPRepository
_last_info_close_sync_run: Optional[date] = None
_info_close_sync_lock = asyncio.Lock()
class VSPService:
EXECUTOR_EDITABLE_FIELDS = {
"location_form",
"numbers",
"total_area",
"rent_contract_num",
"rent_end_date",
}
def __init__(self, db: AsyncSession):
self.db = db
self.vsp_repo = VSPRepository(db)
self.user_repo = UserRepository(db)
async def get(self, vsp_id: int, user: AppUser, load_org_unit: bool = False) -> Optional[Vsp]:
vsp = await self.vsp_repo.get(vsp_id=vsp_id, load_org_unit=load_org_unit)
if not vsp:
return None
if user.role_id == UserRoleEnum.ADMIN:
return vsp
ssp_ids = await self._get_scoped_ssp_ids(user=user)
if not ssp_ids or vsp.branch_id not in set(ssp_ids):
raise AccessDeniedException()
return vsp
async def get_list(
self,
user: AppUser,
registration_number: str | None = None,
address: str | None = None,
ssp_id: int | None = None,
ssp_ids: list[int] | None = None,
open_date_start: date | None = None,
open_date_end: date | None = None,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_max: int | None = None,
is_active: bool | None = None,
load_org_unit: bool = False,
with_count: bool = False,
) -> list[Vsp] | tuple[int, list[Vsp]]:
await self._sync_closed_records()
scoped_ssp_ids = await self._get_scoped_ssp_ids(user=user)
requested_ssp_ids = list(dict.fromkeys(ssp_ids or []))
if ssp_id is not None and ssp_id not in requested_ssp_ids:
requested_ssp_ids.append(ssp_id)
effective_ssp_ids = scoped_ssp_ids
if requested_ssp_ids:
if scoped_ssp_ids is None:
effective_ssp_ids = requested_ssp_ids
else:
scoped_set = set(scoped_ssp_ids)
effective_ssp_ids = [item for item in requested_ssp_ids if item in scoped_set]
if not effective_ssp_ids:
if with_count:
return 0, []
return []
return await self.vsp_repo.get_list(
registration_number=registration_number,
address=address,
ssp_ids=effective_ssp_ids,
open_date_start=open_date_start,
open_date_end=open_date_end,
location_form=location_form,
numbers_min=numbers_min,
numbers_max=numbers_max,
is_active=is_active,
load_org_unit=load_org_unit,
with_count=with_count,
)
async def get_dropdown(
self,
user: AppUser,
ssp_id: int | None = None,
with_count: bool = False,
) -> list[Vsp]:
ssp_ids = await self._get_scoped_ssp_ids(user=user)
if ssp_id is not None:
if ssp_ids is None:
ssp_ids = [ssp_id]
elif ssp_id not in set(ssp_ids):
if with_count:
return 0, []
return []
else:
ssp_ids = [ssp_id]
return await self.vsp_repo.get_list(
ssp_ids=ssp_ids,
is_active=True,
close_date_is_null=True,
with_count=with_count,
)
async def create(self, payload: VSPCreate, user: AppUser, load_org_unit: bool = False) -> Vsp:
if not self._can_modify(user):
raise AccessDeniedException()
try:
created = await self.vsp_repo.create(payload=payload, created_by=user.id, load_org_unit=load_org_unit)
if created.closed_at is not None and created.closed_at <= date.today():
created = await self.vsp_repo.update(
vsp=created,
data={"is_active": False},
updated_by=user.id,
load_org_unit=load_org_unit,
)
return created
except IntegrityError as exc:
message = str(getattr(exc, "orig", exc) or exc).strip()
if "vsp_reg_number_unique" in message:
raise ValidationException(description="Указанный номер уже существует")
if "chk_closed_after_opened" in message:
raise ValidationException(description="Дата закрытия не может быть меньше даты открытия")
raise ValidationException(description="Некорректные данные для записи ВСП") from exc
async def export_xlsx(
self,
user: AppUser,
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,
location_form: str | None = None,
numbers_min: int | None = None,
numbers_max: int | None = None,
) -> tuple[io.BytesIO, str]:
data = await self.get_list(
user=user,
registration_number=registration_number,
address=address,
ssp_ids=ssp_ids,
open_date_start=open_date_start,
open_date_end=open_date_end,
location_form=location_form,
numbers_min=numbers_min,
numbers_max=numbers_max,
is_active=None,
load_org_unit=True,
)
workbook = Workbook()
sheet = workbook.active
sheet.title = "INFO"
sheet.append(
[
"id",
"ssp_id",
"system_code",
"regional_branch",
"vsp_type",
"registration_number",
"address",
"approved_format",
"open_date",
"close_date",
"notes",
"location_form",
"numbers",
"area",
"rent_contract_num",
"rent_end_date",
"is_active",
"is_deleted",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
)
for item in data:
sheet.append(
[
item.id,
item.branch_id,
item.system_code or None,
item.org_unit.title or None,
item.vsp_type or None,
item.reg_number,
item.address,
item.format or None,
item.opened_at.isoformat() if item.opened_at else None,
item.closed_at.isoformat() if item.closed_at else None,
item.notes or None,
item.location_form or None,
item.numbers if item.numbers is not None else None,
float(item.total_area) if item.total_area is not None else None,
item.rent_contract_num or None,
item.rent_end_date.isoformat() if item.rent_end_date else None,
item.is_active,
item.is_deleted,
item.created_at.isoformat() if item.created_at else None,
item.updated_at.isoformat() if item.updated_at else None,
item.created_by,
item.updated_by if item.updated_by is not None else None,
]
)
stream = io.BytesIO()
workbook.save(stream)
workbook.close()
stream.seek(0)
file_name = f"info_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return stream, file_name
async def update(self, vsp_id: int, payload: VSPUpdate, user: AppUser, load_org_unit: bool = False) -> Vsp | None:
vsp = await self.vsp_repo.get(vsp_id=vsp_id, is_active=None)
if not vsp:
return None
data = payload.model_dump(exclude_unset=True)
if not data:
return vsp
if user.role_id != UserRoleEnum.ADMIN:
ssp_ids = await self._get_scoped_ssp_ids(user=user)
if not ssp_ids or vsp.branch_id not in set(ssp_ids):
raise AccessDeniedException()
if not set(data.keys()).issubset(self.EXECUTOR_EDITABLE_FIELDS):
raise AccessDeniedException()
effective_close_date = data.get("close_date", vsp.closed_at)
if effective_close_date is not None and effective_close_date <= date.today():
data["is_active"] = False
try:
return await self.vsp_repo.update(
vsp=vsp, data=data, updated_by=user.id, load_org_unit=load_org_unit,
)
except IntegrityError as exc:
message = str(getattr(exc, "orig", exc) or exc).strip()
if "vsp_reg_number_unique" in message:
raise ValidationException(description="Указанный номер уже существует")
if "chk_closed_after_opened" in message:
raise ValidationException(description="Дата закрытия не может быть меньше даты открытия")
raise ValidationException(description="Некорректные данные для записи ВСП") from exc
async def logical_delete(self, vsp_id: int, user: AppUser) -> bool:
if not self._can_modify(user):
raise AccessDeniedException()
return await self.vsp_repo.logical_delete(vsp_id=vsp_id, updated_by=user.id)
def _can_modify(self, user: AppUser) -> bool:
return user.role_id == UserRoleEnum.ADMIN
async def _get_scoped_ssp_ids(self, user: AppUser) -> list[int] | None:
if user.role_id == UserRoleEnum.ADMIN:
return None
return list(set(await self.user_repo.get_many_ssp_ids(user_id=user.id)))
async def _sync_closed_records(self) -> None:
global _last_info_close_sync_run
today = date.today()
if _last_info_close_sync_run is not None and _last_info_close_sync_run >= today:
return
async with _info_close_sync_lock:
if _last_info_close_sync_run is not None and _last_info_close_sync_run >= today:
return
await self.vsp_repo.deactivate_by_close_date(as_of=today)
_last_info_close_sync_run = today

View File

@ -1,49 +1,81 @@
import asyncio
from contextlib import contextmanager
import os import os
from pathlib import Path
import pytest import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
# Keep a single sqlite target for app + alembic during tests.
TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db"
os.environ["DATABASE_URL"] = TEST_DATABASE_URL
os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL
os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL
os.environ["OPENBAO__SETTINGS__DEBUG"] = "true"
os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true"
from src.db.base import SessionLocal
from src.db.session import get_db
from src.main import app from src.main import app
@pytest.fixture(scope="session", autouse=True)
def migrate_db():
db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")]
for db_file in db_files:
if db_file.exists():
os.remove(db_file)
alembic_cfg = Config("alembic.ini")
command.upgrade(alembic_cfg, "head")
yield
for db_file in db_files:
if db_file.exists():
os.remove(db_file)
@pytest.fixture @pytest.fixture
def client(): def client():
db_ref = SessionLocal()
loop_ref = None
async def _get_db():
nonlocal loop_ref
loop_ref = asyncio.get_running_loop()
try:
yield db_ref
await db_ref.flush()
except:
await db_ref.rollback()
raise
app.dependency_overrides[get_db] = _get_db
with TestClient(app) as test_client: with TestClient(app) as test_client:
try:
yield test_client yield test_client
finally:
if db_ref is not None and loop_ref:
async def cleanup():
await db_ref.rollback()
await db_ref.close()
future = asyncio.run_coroutine_threadsafe(cleanup(), loop_ref)
future.result()
app.dependency_overrides.clear()
@pytest.fixture @pytest.fixture
def admin_tokens(client): def admin_password() -> str:
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
return os.getenv("OPENBAO__TEST_ADMIN_PASSWORD", "admin123")
@pytest.fixture
def isp_password() -> str:
# Пароль админа берём из окружения, чтобы не хардкодить локальные отличия.
return os.getenv("OPENBAO__TEST_EXECUTOR_PASSWORD", "admin123")
@pytest.fixture
def admin_tokens(client, admin_password: str):
response = client.post( response = client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "admin"}, json={"username": "admin", "password": admin_password},
)
assert response.status_code == 200
return response.json()
@pytest.fixture
def auth_headers():
def _build(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
return _build
@pytest.fixture
def isp_tokens(client, isp_password: str):
response = client.post(
"/api/v1/auth/login",
json={"username": "isp1", "password": isp_password},
) )
assert response.status_code == 200 assert response.status_code == 200
return response.json() return response.json()

View File

@ -0,0 +1,67 @@
# import uuid
#
#
# def _create_executor_and_tokens(client, admin_tokens, auth_headers) -> tuple[int, dict]:
# suffix = uuid.uuid4().hex[:8]
# username = f"audit_exec_{suffix}"
# password = "pass123"
# created = client.put(
# "/api/v1/users/",
# json={
# "email": f"{username}@example.com",
# "username": username,
# "password": password,
# "full_name": "Audit Executor",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# assert created.status_code == 200
# user_id = created.json()["result"]["id"]
#
# login = client.post(
# "/api/v1/auth/login",
# json={"username": username, "password": password},
# )
# assert login.status_code == 200
# return user_id, login.json()
def test_audit_logs_admin_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/audit-logs", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], list)
def test_audit_logs_requires_auth(client):
response = client.get("/api/v1/audit-logs")
assert response.status_code == 403
# Тест временно отключён: внутри создаётся пользователь.
# def test_audit_logs_forbidden_for_non_admin(client, admin_tokens, auth_headers):
# user_id, executor_tokens = _create_executor_and_tokens(
# client, admin_tokens, auth_headers
# )
# try:
# response = client.get(
# "/api/v1/audit-logs",
# headers=auth_headers(executor_tokens),
# )
# assert response.status_code == 403
# finally:
# client.delete(
# f"/api/v1/users/{user_id}",
# headers=auth_headers(admin_tokens),
# )
def test_audit_logs_query_validation(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/audit-logs?limit=101",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 422

View File

@ -0,0 +1,276 @@
# # закоментированы для избежания переполнения бд
# from datetime import datetime, timedelta
# import uuid
# import pytest
# def _find_event(
# client,
# admin_tokens: dict,
# auth_headers,
# event_type: str,
# predicate,
# limit: int = 100,
# ):
# response = client.get(
# f"/api/v1/audit-logs?event_type={event_type}&limit={limit}",
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200
# result = response.json().get("result", [])
# for item in result:
# payload = item.get("payload_json") or {}
# if predicate(payload):
# return item
# return None
# def _event_count(client, admin_tokens: dict, auth_headers, event_type: str) -> int:
# response = client.get(
# f"/api/v1/audit-logs?event_type={event_type}&limit=1",
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200
# return int(response.json().get("count") or 0)
# def _pick_target_user_id(client, admin_tokens: dict, auth_headers) -> int:
# me_resp = client.get(
# "/api/v1/users/me",
# headers=auth_headers(admin_tokens),
# )
# assert me_resp.status_code == 200
# me = me_resp.json() or {}
# assert me.get("id") is not None
# return int(me["id"])
# def _pick_org_unit_id(client, admin_tokens: dict, auth_headers, user_id: int) -> int:
# forms_resp = client.get(
# "/api/v1/form/?limit=100",
# headers=auth_headers(admin_tokens),
# )
# assert forms_resp.status_code == 200
# forms = forms_resp.json().get("result") or []
# candidate_ids = [f.get("org_unit_id") for f in forms if f.get("org_unit_id") is not None]
# assert candidate_ids
# assigned_resp = client.get(
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
# headers=auth_headers(admin_tokens),
# )
# assert assigned_resp.status_code == 200
# assigned = {
# int(item["id"])
# for item in (assigned_resp.json().get("result") or {}).get("ssp", [])
# if item.get("id") is not None
# }
# for org_unit_id in candidate_ids:
# if int(org_unit_id) not in assigned:
# return int(org_unit_id)
# raise AssertionError("Не найден org_unit_id, который можно прикрепить пользователю")
# def _create_temp_user(client, admin_tokens: dict, auth_headers, role_id: int = 2) -> int:
# suffix = uuid.uuid4().hex[:8]
# username = f"audit_u_{suffix}"
# response = client.put(
# "/api/v1/users/",
# json={
# "email": f"{username}@example.com",
# "username": username,
# "password": "pass123",
# "full_name": "Audit User",
# "role_id": role_id,
# },
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200, response.text
# result = response.json().get("result") or {}
# return int(result["id"])
# def _create_project(client, admin_tokens: dict, auth_headers) -> int:
# response = client.post(
# "/api/v1/projects",
# json={"name": f"PT_{uuid.uuid4().hex[:10]}", "year": 2026, "branch_id": 1},
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200, response.text
# return int(response.json()["project_id"])
# def _add_project_line(client, admin_tokens: dict, auth_headers, project_id: int) -> int:
# for expense_item_id in range(1, 80):
# response = client.post(
# f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
# json={"expense_item_id": expense_item_id},
# headers=auth_headers(admin_tokens),
# )
# if response.status_code != 200:
# continue
# rows = response.json()
# input_row = next(
# (
# row
# for row in rows
# if row.get("row_type") == "INPUT" and row.get("data", {}).get("line_id")
# ),
# None,
# )
# if input_row:
# return int(input_row["data"]["line_id"])
# raise AssertionError("Не удалось добавить строку в проектный отчёт для ROW_* тестов")
# def _create_stage(client, admin_tokens, auth_headers, form_id: int, sheet: str) -> dict:
# phase_code = f"autotest_{uuid.uuid4().hex[:8]}"
# opens_at = datetime.utcnow().replace(microsecond=0)
# closes_at = opens_at + timedelta(days=2)
# response = client.post(
# f"/api/v1/stages/form/{form_id}",
# json={
# "sheet": sheet,
# "phase_code": phase_code,
# "role": "DFIP",
# "column_keys": ["plan.q1"],
# "opens_at": opens_at.isoformat() + "Z",
# "closes_at": closes_at.isoformat() + "Z",
# },
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200, response.text
# result = response.json().get("result") or {}
# assert result.get("phase_code") == phase_code
# return result
# def test_audit_user_access_granted_event(client, admin_tokens, auth_headers):
# user_id = _pick_target_user_id(client, admin_tokens, auth_headers)
# org_unit_id = _pick_org_unit_id(client, admin_tokens, auth_headers, user_id)
# response = client.put(
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
# json={"ssp_ids": [org_unit_id]},
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200
# assert response.json().get("success") is True
# event = _find_event(
# client,
# admin_tokens,
# auth_headers,
# "USER_ACCESS_GRANTED",
# lambda payload: int(payload.get("user_id", -1)) == user_id
# and int(payload.get("org_unit_id", -1)) == org_unit_id,
# )
# assert event is not None
# def test_audit_user_access_revoked_event(client, admin_tokens, auth_headers):
# user_id = _pick_target_user_id(client, admin_tokens, auth_headers)
# assigned_resp = client.get(
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
# headers=auth_headers(admin_tokens),
# )
# assert assigned_resp.status_code == 200
# assigned = (assigned_resp.json().get("result") or {}).get("ssp", [])
# if assigned:
# org_unit_id = int(assigned[0]["id"])
# else:
# org_unit_id = _pick_org_unit_id(client, admin_tokens, auth_headers, user_id)
# attach_resp = client.put(
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
# json={"ssp_ids": [org_unit_id]},
# headers=auth_headers(admin_tokens),
# )
# assert attach_resp.status_code == 200
# assert attach_resp.json().get("success") is True
# response = client.request(
# "DELETE",
# f"/api/v1/users/dfip-many-ssp/?user_id={user_id}",
# json={"ssp_ids": [org_unit_id]},
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200
# assert response.json().get("success") is True
# event = _find_event(
# client,
# admin_tokens,
# auth_headers,
# "USER_ACCESS_REVOKED",
# lambda payload: int(payload.get("user_id", -1)) == user_id
# and int(payload.get("org_unit_id", -1)) == org_unit_id,
# )
# assert event is not None
# def test_audit_user_create_event(client, admin_tokens, auth_headers):
# before = _event_count(client, admin_tokens, auth_headers, "USER_CREATE")
# user_id = _create_temp_user(client, admin_tokens, auth_headers, role_id=2)
# after = _event_count(client, admin_tokens, auth_headers, "USER_CREATE")
# assert after > before
# delete_resp = client.delete(
# f"/api/v1/users/{user_id}",
# headers=auth_headers(admin_tokens),
# )
# assert delete_resp.status_code == 200
# def test_audit_user_role_change_event(client, admin_tokens, auth_headers):
# user_id = _create_temp_user(client, admin_tokens, auth_headers, role_id=2)
# before = _event_count(client, admin_tokens, auth_headers, "USER_ROLE_CHANGE")
# update_resp = client.patch(
# f"/api/v1/users/{user_id}",
# json={"role_id": 3},
# headers=auth_headers(admin_tokens),
# )
# assert update_resp.status_code == 200, update_resp.text
# after = _event_count(client, admin_tokens, auth_headers, "USER_ROLE_CHANGE")
# assert after > before
# delete_resp = client.delete(
# f"/api/v1/users/{user_id}",
# headers=auth_headers(admin_tokens),
# )
# assert delete_resp.status_code == 200
# def test_audit_project_create_event(client, admin_tokens, auth_headers):
# before = _event_count(client, admin_tokens, auth_headers, "PROJECT_CREATE")
# _create_project(client, admin_tokens, auth_headers)
# after = _event_count(client, admin_tokens, auth_headers, "PROJECT_CREATE")
# assert after > before
# def test_audit_row_create_event(client, admin_tokens, auth_headers):
# project_id = _create_project(client, admin_tokens, auth_headers)
# before = _event_count(client, admin_tokens, auth_headers, "ROW_CREATE")
# _add_project_line(client, admin_tokens, auth_headers, project_id)
# after = _event_count(client, admin_tokens, auth_headers, "ROW_CREATE")
# assert after > before
# def test_audit_row_delete_event(client, admin_tokens, auth_headers):
# project_id = _create_project(client, admin_tokens, auth_headers)
# line_id = _add_project_line(client, admin_tokens, auth_headers, project_id)
# before = _event_count(client, admin_tokens, auth_headers, "ROW_DELETE")
# delete_resp = client.delete(
# f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
# headers=auth_headers(admin_tokens),
# )
# assert delete_resp.status_code == 200, delete_resp.text
# after = _event_count(client, admin_tokens, auth_headers, "ROW_DELETE")
# assert after > before

View File

@ -1,7 +1,7 @@
def test_login_success(client): def test_login_success(client, admin_password):
response = client.post( response = client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "admin"}, json={"username": "admin", "password": admin_password},
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -9,10 +9,10 @@ def test_login_success(client):
assert "refresh_token" in data assert "refresh_token" in data
def test_login_invalid_password(client): def test_login_invalid_password(client, admin_password):
response = client.post( response = client.post(
"/api/v1/auth/login", "/api/v1/auth/login",
json={"username": "admin", "password": "wrong"}, json={"username": "admin", "password": f"{admin_password}_wrong"},
) )
assert response.status_code == 401 assert response.status_code == 401

View File

@ -0,0 +1,332 @@
import uuid
import pytest
def _pick_form_and_sheet(client, admin_tokens, auth_headers) -> tuple[int, str, str | None]:
response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
assert response.status_code == 200
forms = response.json().get("result") or []
for form in forms:
form_id = form.get("id")
form_type = form.get("form_type_code")
if not form_id or not form_type:
continue
sheets_response = client.get(
f"/api/v1/form/{form_id}/sheets",
headers=auth_headers(admin_tokens),
)
if sheets_response.status_code != 200:
continue
sheets = (sheets_response.json().get("result") or {}).get("sheets") or []
if "AHR" not in sheets:
continue
direction = "Support" if form_type == "FORM_1" else None
return int(form_id), "AHR", direction
pytest.skip("В тестовой БД не найдена форма с листом AHR")
def _pick_form_with_sheet(
client, admin_tokens, auth_headers, target_sheet: str, form_type_code: str | None = None
) -> tuple[int, str]:
response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
assert response.status_code == 200
forms = response.json().get("result") or []
for form in forms:
form_id = form.get("id")
if not form_id:
continue
if form_type_code and form.get("form_type_code") != form_type_code:
continue
sheets_response = client.get(
f"/api/v1/form/{form_id}/sheets",
headers=auth_headers(admin_tokens),
)
if sheets_response.status_code != 200:
continue
sheets = (sheets_response.json().get("result") or {}).get("sheets") or []
if target_sheet in sheets:
return int(form_id), target_sheet
pytest.skip(f"Не найдена форма с листом {target_sheet}")
def _pick_input_line_id(
client, admin_tokens, auth_headers, form_id: int, sheet: str, direction: str | None
) -> int:
url = f"/api/v1/form/{form_id}/sheet/{sheet}"
params = []
if direction:
params.append(f"direction={direction}")
if params:
url = f"{url}?{'&'.join(params)}"
response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200
rows = response.json().get("result") or []
for row in rows:
data = row.get("data") or {}
if row.get("row_type") == "INPUT" and data.get("line_id"):
return int(data["line_id"])
pytest.skip("Не найден INPUT line_id для проверки upd_form_cell")
def test_backend_calls_v_form_view_via_sheet_endpoint(client, admin_tokens, auth_headers):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}"
if direction:
url = f"{url}?direction={direction}"
response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert isinstance(payload.get("result"), list)
if payload["result"]:
first_row = payload["result"][0]
assert "row_type" in first_row
assert "data" in first_row
def test_backend_calls_upd_form_cell_and_maps_sql_validation_error(
client, admin_tokens, auth_headers
):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
line_id = _pick_input_line_id(
client, admin_tokens, auth_headers, form_id, sheet, direction
)
url = f"/api/v1/form/{form_id}/sheet/{sheet}/cell"
if direction:
url = f"{url}?direction={direction}"
response = client.patch(
url,
json={"line_id": line_id, "column": "bad.scope", "value": 1},
headers=auth_headers(admin_tokens),
)
# SQL-функция отдает prefixed validation error.
assert response.status_code in (400, 422)
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
assert isinstance(payload["message"], str)
def test_backend_calls_form3_add_project_and_add_line_functions(
client, admin_tokens, auth_headers
):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_DB_FUNC_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
project_payload = create.json()
assert "project_id" in project_payload
assert "limit_report_id" in project_payload
assert "current_expenses_report_id" in project_payload
project_id = int(project_payload["project_id"])
add_line = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": 1},
headers=auth_headers(admin_tokens),
)
assert add_line.status_code == 200
rows = add_line.json()
assert isinstance(rows, list)
def test_forms_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
form_id, sheet = _pick_form_with_sheet(client, admin_tokens, auth_headers, "AHR")
response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?sections=bad_section",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_forms_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
form_id, sheet, direction = _pick_form_and_sheet(client, admin_tokens, auth_headers)
url = f"/api/v1/form/{form_id}/sheet/{sheet}?sections= , , "
if direction:
url += f"&direction={direction}"
response = client.get(url, headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert isinstance(payload.get("result"), list)
def test_forms_validation_direction_required_for_form1_ahr(
client, admin_tokens, auth_headers
):
form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
)
response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
assert "direction" in payload["message"].lower()
def test_forms_validation_direction_case_sensitive(client, admin_tokens, auth_headers):
form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_1"
)
response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=support",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 422
def test_forms_validation_direction_forbidden_on_non_form1(
client, admin_tokens, auth_headers
):
form_id, sheet = _pick_form_with_sheet(
client, admin_tokens, auth_headers, "AHR", form_type_code="FORM_2"
)
response = client.get(
f"/api/v1/form/{form_id}/sheet/{sheet}?direction=Support",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
assert "direction" in payload["message"].lower()
def test_form3_validation_invalid_sections_returns_400(client, admin_tokens, auth_headers):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_F3_SECT_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
project_id = int(create.json()["project_id"])
response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q5",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form3_validation_empty_sections_csv_is_ignored(client, admin_tokens, auth_headers):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_F3_EMPTY_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
project_id = int(create.json()["project_id"])
response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections= , , ",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
assert isinstance(response.json().get("result"), list)
def test_form3_validation_duplicate_sections_is_allowed(client, admin_tokens, auth_headers):
create = client.post(
"/api/v1/projects",
json={"name": f"ITEST_F3_DUP_{uuid.uuid4().hex[:6]}", "year": 2026, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert create.status_code == 200
project_id = int(create.json()["project_id"])
response = client.get(
f"/api/v1/projects/{project_id}/report/2026/LIMIT?sections=q1,q1",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
assert isinstance(response.json().get("result"), list)
def test_form3_add_project_invalid_name_returns_422(client, admin_tokens, auth_headers):
# Пробел запрещён regex-правилом AddProjectBody.
response = client.post(
"/api/v1/projects",
json={"name": "INVALID NAME", "year": 2026, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 422
def test_health_and_ready_endpoints(client):
healthz = client.get("/healthz")
assert healthz.status_code == 200
health_payload = healthz.json()
assert health_payload.get("status") == "ok"
readyz = client.get("/readyz")
assert readyz.status_code in (200, 503)
ready_payload = readyz.json()
assert "status" in ready_payload
assert "db_status" in ready_payload
assert "mv_expense_item_tree" in ready_payload
# Тест временно отключён: внутри создаётся пользователь.
# def test_admin_refresh_tree_requires_admin_role(client, admin_tokens, auth_headers):
# admin_resp = client.post(
# "/api/v1/admin/refresh-tree",
# headers=auth_headers(admin_tokens),
# )
# assert admin_resp.status_code in (200, 503)
#
# uname = f"itest_non_admin_{uuid.uuid4().hex[:6]}"
# create_user = client.put(
# "/api/v1/users/",
# json={
# "email": f"{uname}@example.com",
# "username": uname,
# "password": "pass123",
# "full_name": "Integration User",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# if create_user.status_code != 200:
# pytest.skip("Не удалось создать non-admin пользователя в текущей БД")
#
# login_resp = client.post("/api/v1/auth/login", json={"username": uname, "password": "pass123"})
# if login_resp.status_code != 200:
# pytest.skip("Не удалось залогинить non-admin пользователя в текущей БД")
# user_tokens = login_resp.json()
#
# non_admin_resp = client.post(
# "/api/v1/admin/refresh-tree",
# headers=auth_headers(user_tokens),
# )
# assert non_admin_resp.status_code == 403
#
# # Что бы не было переполнения бд
# deleted = client.delete(
# f"/api/v1/users/{create_user.json()['result']['id']}",
# headers=auth_headers(admin_tokens),
# )
# assert deleted.status_code == 200
def test_admin_refresh_tree_requires_auth(client):
response = client.post("/api/v1/admin/refresh-tree")
assert response.status_code in (401, 403)

View File

@ -0,0 +1,38 @@
import pytest
def _get_any_project_report(client, admin_tokens, auth_headers) -> tuple[int, int, str]:
list_response = client.get(
"/api/v1/projects?limit=50",
headers=auth_headers(admin_tokens),
)
assert list_response.status_code == 200
projects = list_response.json().get("result") or []
for project in projects:
project_id = int(project["id"])
details_response = client.get(
f"/api/v1/projects/{project_id}",
headers=auth_headers(admin_tokens),
)
if details_response.status_code != 200:
continue
reports = (details_response.json().get("result") or {}).get("reports") or []
if reports:
report = reports[0]
return project_id, int(report["year"]), str(report["report_type"])
pytest.skip("Не найден проект FORM_3 с отчетами для smoke-экспорта")
def test_export_project_report_smoke(client, admin_tokens, auth_headers):
project_id, report_year, report_type = _get_any_project_report(client, admin_tokens, auth_headers)
response = client.get(
f"/api/v1/export/project/{project_id}/report/{report_year}/{report_type}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
assert response.headers["content-type"] == (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
assert "attachment; filename*=UTF-8''" in response.headers["content-disposition"]
assert len(response.content) > 0

View File

@ -0,0 +1,140 @@
import pytest
def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_form_phases_list_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/form/1", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_form_phases_list_filter_smoke(client, admin_tokens):
response = client.get(
"/api/v1/stages/form/1", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
count = payload["count"]
response = client.get(
"/api/v1/stages/form/1?sheet=AHR&phase_code=test", headers=_auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "count" in payload
assert payload["count"] <= count
def test_form_phases_create_forbidden(client, isp_tokens):
response = client.post(
"/api/v1/stages/form/1",
json={
"sheet": "AHR",
"phase_code": "new_phase",
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_update_forbidden(client, isp_tokens):
response = client.patch(
"/api/v1/stages/form/1/AHR/test",
json={
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
},
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_delete_forbidden(client, isp_tokens):
response = client.delete(
"/api/v1/stages/form/1/AHR/test",
headers=_auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_form_phases_create(client, admin_tokens):
body = {
"budget_form_id": 1,
"sheet": "AHR",
"phase_code": "new_phase",
"role": "DFIP",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
}
response = client.post(
"/api/v1/stages/form/1",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"] == body
def test_form_phases_update(client, admin_tokens):
body = {
"budget_form_id": 1,
"sheet": "AHR",
"phase_code": "test",
"role": "EXECUTOR_RF",
"column_keys": ["plan.q1"],
"opens_at": "2026-01-01T00:00:00Z",
"closes_at": "2026-12-31T00:00:00Z",
}
response = client.patch(
"/api/v1/stages/form/1/AHR/test",
json=body,
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "success" in payload
assert payload["success"]
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"] == body
def test_form_phases_delete(client, admin_tokens):
response = client.delete(
"/api/v1/stages/form/1/AHR/test",
headers=_auth_headers(admin_tokens),
)
assert response.status_code == 200

View File

@ -0,0 +1,185 @@
import pytest
def test_forms_list_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/form/", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_form_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/form/1", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], dict)
def test_forms_sheets_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/form/999/sheets", headers=auth_headers(admin_tokens)
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
assert isinstance(payload["message"], str)
def test_forms_sheet_get_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/form/999/sheet/AHR", headers=auth_headers(admin_tokens)
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_forms_cell_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch(
"/api/v1/form/999/sheet/AHR/cell",
json={"line_id": 1, "column": "q1.m1", "value": 100},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_forms_cell_update(client, admin_tokens, auth_headers):
response = client.patch(
"/api/v1/form/1/sheet/AHR/cell?direction=Support&sections=plan,contract_summary",
json={
"line_id": 1,
"column": "plan.q1",
"value": 225
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, dict)
assert "result" in payload
def test_forms_cells_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch(
"/api/v1/form/999/sheet/AHR/cells",
json={"changes": [{"line_id": 1, "column": "q1.m1", "value": 100}]},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_forms_cells_update(client, admin_tokens, auth_headers):
response = client.patch(
"/api/v1/form/1/sheet/AHR/cells?direction=Support&sections=plan,contract_summary",
json={
"changes": [
{
"line_id": 1,
"column": "plan.q1",
"value": 226
}
],
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, dict)
assert "result" in payload
def test_forms_add_line_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/999/sheet/AHR/line",
json={},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_forms_add_line(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/1/sheet/AHR/line?direction=Support&sections=plan,contract_summary",
json={
"expense_item_id": 120,
"direction": "Support"
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload, dict)
assert "result" in payload
def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.delete(
"/api/v1/form/999/sheet/AHR/line/1",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_forms_add_form_success(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/",
json={"year": 2025, "form_type_code": "FORM_1", "org_unit_ids": [1]},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], list)
assert len(payload["result"]) == 1
assert "year" in payload["result"][0]
assert payload["result"][0]["year"] == 2025
assert "id" in payload["result"][0]
form_id = payload["result"][0]["id"]
response = client.get(
f"/api/v1/form/{form_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
def test_forms_add_form_org_unit_not_found(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/",
json={"year": 2025, "form_type_code": "FORM_1", "org_unit_ids": [1, 999]},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_forms_add_form_forbidden_not_admin(client, isp_tokens, auth_headers):
response = client.post(
"/api/v1/form/",
json={"year": 2025, "form_type_code": "FORM_1", "org_unit_ids": [1]},
headers=auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload

View File

@ -0,0 +1,111 @@
import pydantic_core
import pytest
def test_org_units_list_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/org-unit/", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_org_units_list_with_users_count(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/org-unit/?load_users_count=true",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], list)
if payload["result"]:
assert "users_count" in payload["result"][0]
def test_org_units_get(client, admin_tokens, auth_headers):
response = client.get("/api/v1/org-unit/1", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert payload["result"]["id"] == 1
def test_org_units_get_not_found(client, admin_tokens, auth_headers):
response = client.get("/api/v1/org-unit/99999", headers=auth_headers(admin_tokens))
assert response.status_code == 404
# def test_org_units_create_smoke(client, admin_tokens, auth_headers):
# suffix = uuid.uuid4().hex[:8]
# title = f"Test Org {suffix}"
# response = client.post(
# "/api/v1/org-unit/",
# json={"title": title, "is_ssp": True},
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200
# payload = response.json()
# assert payload["result"]["title"] == title
# assert payload["result"]["is_ssp"] is True
# def test_org_units_create_non_ssp(client, admin_tokens, auth_headers):
# suffix = uuid.uuid4().hex[:8]
# title = f"Test RF {suffix}"
# response = client.post(
# "/api/v1/org-unit/",
# json={"title": title, "is_ssp": False},
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200
# payload = response.json()
# assert payload["result"]["title"] == title
# assert payload["result"]["is_ssp"] is False
# def test_org_units_update_smoke(client, admin_tokens, auth_headers):
# new_title = f"Updated_{uuid.uuid4().hex[:8]}"
# response = client.patch(
# "/api/v1/org-unit/1",
# json={"title": new_title},
# headers=auth_headers(admin_tokens),
# )
# assert response.status_code == 200
# payload = response.json()
# assert payload["result"]["title"] == new_title
# assert payload["result"]["id"] == 1
def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.patch(
"/api/v1/org-unit/99999",
json={"title": "No Matter"},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "code" in payload
assert "message" in payload
def test_org_units_delete_not_found_error_shape(client, admin_tokens, auth_headers):
response = client.delete(
"/api/v1/org-unit/99999", headers=auth_headers(admin_tokens)
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "code" in payload
assert "message" in payload
def test_org_units_access_denied_for_non_admin(client, isp_tokens, auth_headers):
response = client.get("/api/v1/org-unit/", headers=auth_headers(isp_tokens))
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "code" in payload
assert "message" in payload

View File

@ -0,0 +1,119 @@
import uuid
import pytest
def test_projects_list_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/projects", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def _create_project(client, admin_tokens, auth_headers) -> tuple[int, str]:
name = f"PT_{uuid.uuid4().hex[:10]}"
response = client.post(
"/api/v1/projects",
json={"name": name, "year": 2026, "branch_id": 1},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
return payload["project_id"], name
def _add_line(client, admin_tokens, auth_headers, project_id: int) -> int:
for expense_item_id in range(1, 80):
response = client.post(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line",
json={"expense_item_id": expense_item_id},
headers=auth_headers(admin_tokens),
)
if response.status_code != 200:
continue
rows = response.json()
input_row = next(
(
row
for row in rows
if row.get("row_type") == "INPUT" and row.get("data", {}).get("line_id")
),
None,
)
if input_row:
return int(input_row["data"]["line_id"])
pytest.skip("Не удалось подобрать expense_item_id для add_form3_line в текущей БД")
def test_projects_write_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(client, admin_tokens, auth_headers)
upd_project_response = client.patch(
f"/api/v1/project/{project_id}",
json={"column": "name", "value": f"{name}_U"},
headers=auth_headers(admin_tokens),
)
assert upd_project_response.status_code == 200
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
upd_cell_response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=auth_headers(admin_tokens),
)
assert upd_cell_response.status_code == 200
assert isinstance(upd_cell_response.json(), list)
upd_cells_response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/cells",
json={
"changes": [
{"line_id": line_id, "column": "q1.m2", "value": 200},
{"line_id": line_id, "column": "q1.m3", "value": 300},
]
},
headers=auth_headers(admin_tokens),
)
assert upd_cells_response.status_code == 200
assert isinstance(upd_cells_response.json(), list)
del_line_response = client.delete(
f"/api/v1/projects/{project_id}/report/2026/LIMIT/line/{line_id}",
headers=auth_headers(admin_tokens),
)
assert del_line_response.status_code == 200
def test_projects_write_invalid_report_type_error_shape(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers)
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
response = client.patch(
f"/api/v1/projects/{project_id}/report/2026/BAD_TYPE/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
assert isinstance(payload["message"], str)
def test_projects_write_report_not_found_error_shape(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers)
line_id = _add_line(client, admin_tokens, auth_headers, project_id)
response = client.patch(
f"/api/v1/projects/{project_id}/report/2099/LIMIT/cell",
json={"line_id": line_id, "column": "q1.m1", "value": 100},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert isinstance(payload, dict)
assert payload.get("message") == "Отчёт не найден"

View File

@ -1,30 +1,85 @@
def _auth_headers(tokens: dict) -> dict: import uuid
return {"Authorization": f"Bearer {tokens['access_token']}"}
def test_users_me(client, admin_tokens): def test_users_me(client, admin_tokens, auth_headers):
response = client.get("/api/v1/users/me", headers=_auth_headers(admin_tokens)) response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["username"] == "admin" assert response.json()["username"] == "admin"
def test_users_create_and_list(client, admin_tokens): def test_users_list(client, admin_tokens, auth_headers):
create_payload = { response = client.get("/api/v1/users/", headers=auth_headers(admin_tokens))
"email": "user1@example.com", assert response.status_code == 200
"username": "user1", result = response.json()
"password": "pass123", assert "result" in result
"full_name": "User One", assert isinstance(result["result"], list)
"role_id": 2,
}
created = client.put(
"/api/v1/users/",
json=create_payload,
headers=_auth_headers(admin_tokens),
)
assert created.status_code == 200
listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens))
assert listed.status_code == 200 def test_users_org_list(client, admin_tokens, auth_headers):
usernames = [row["username"] for row in listed.json()["result"]] response = client.get("/api/v1/users/?load_orgs=true", headers=auth_headers(admin_tokens))
assert "admin" in usernames assert response.status_code == 200
assert "user1" in usernames result = response.json()
assert "result" in result
assert isinstance(result["result"], list)
assert len(result["result"]) > 0
assert "org_units" in result["result"][0]
assert len(result["result"][0]["org_units"]) > 0
def test_users_get(client, admin_tokens, auth_headers):
response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
assert response.status_code == 200
user = response.json()
assert "id" in user
user_id = user["id"]
response = client.get(f"/api/v1/users/{user_id}", headers=auth_headers(admin_tokens))
assert response.status_code == 200
def test_user_get_org_list(client, admin_tokens, auth_headers):
response = client.get("/api/v1/users/me", headers=auth_headers(admin_tokens))
assert response.status_code == 200
user = response.json()
assert "id" in user
user_id = user["id"]
response = client.get(f"/api/v1/users/{user_id}?load_orgs=true", headers=auth_headers(admin_tokens))
assert response.status_code == 200
result = response.json()
assert "org_units" in result
assert len(result["org_units"]) > 0
# Задокуменировано что бы не было переполнения бд, так как юзер удаляется только логически
# def test_users_create_smoke(client, admin_tokens, auth_headers):
# suffix = uuid.uuid4().hex[:8]
# username = f"user_{suffix}"
# # create_payload = {
# # "email": f"{username}@example.com",
# # "username": username,
# # "password": "pass123",
# # "full_name": "User One",
# # "role_id": 2,
# # }
# created = client.put(
# "/api/v1/users/",
# json={
# "email": f"{username}@example.com",
# "username": username,
# "password": "pass123",
# "full_name": "User One",
# "role_id": 2,
# },
# headers=auth_headers(admin_tokens),
# )
# assert created.status_code == 200
# assert created.json()["result"]["username"] == username
# # Что бы не было переполнения бд
# deleted = client.delete(
# f"/api/v1/users/{created.json()['result']['id']}",
# headers=auth_headers(admin_tokens),
# )
# assert deleted.status_code == 200

View File

@ -0,0 +1,228 @@
import uuid
import pytest
def _unique_reg_number() -> str:
return f"TEST_{uuid.uuid4().hex[:10]}"
def test_vsp_list_smoke(client, admin_tokens, auth_headers):
response = client.get("/api/v1/dict/info", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_vsp_list_with_filters(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/dict/info?ssp_id=1",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload["result"], list)
def test_vsp_dropdown_smoke(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/dict/info/dropdown", headers=auth_headers(admin_tokens)
)
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert "count" in payload
assert isinstance(payload["result"], list)
def test_vsp_dropdown_with_ssp_filter(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/dict/info/dropdown?ssp_id=1",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert isinstance(payload["result"], list)
def test_vsp_get_by_id(client, admin_tokens, auth_headers):
response = client.get("/api/v1/dict/info/1", headers=auth_headers(admin_tokens))
assert response.status_code == 200
payload = response.json()
assert "result" in payload
assert isinstance(payload["result"], dict)
assert payload["result"]["id"] == 1
def test_vsp_get_not_found(client, admin_tokens, auth_headers):
response = client.get(
"/api/v1/dict/info/99999", headers=auth_headers(admin_tokens)
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_vsp_create_smoke(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number()
response = client.post(
"/api/v1/dict/info",
json={
"registration_number": reg_number,
"address": "Test Address",
"ssp_id": 1,
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 201
payload = response.json()
assert payload["success"] is True
assert payload["result"]["registration_number"] == reg_number
assert payload["result"]["address"] == "Test Address"
assert payload["result"]["ssp_id"] == 1
def test_vsp_create_with_all_fields(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number()
response = client.post(
"/api/v1/dict/info",
json={
"registration_number": reg_number,
"address": "Full Address",
"ssp_id": 1,
"system_code": "SYS001",
"vsp_type": "Тип 1",
"open_date": "2024-01-15",
"close_date": "2025-12-31",
"approved_format": "Стандарт",
"notes": "Тестовое примечание",
"location_form": "Встроенное",
"numbers": 10,
"area": 150.5,
"rent_contract_num": "Д-123/24",
"rent_end_date": "2025-12-31",
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 201
payload = response.json()
assert payload["success"] is True
assert payload["result"]["registration_number"] == reg_number
assert payload["result"]["system_code"] == "SYS001"
assert payload["result"]["vsp_type"] == "Тип 1"
assert payload["result"]["open_date"] == "2024-01-15"
assert payload["result"]["close_date"] == "2025-12-31"
assert payload["result"]["approved_format"] == "Стандарт"
assert payload["result"]["location_form"] == "Встроенное"
assert payload["result"]["numbers"] == 10
assert payload["result"]["area"] == 150.5
assert payload["result"]["rent_contract_num"] == "Д-123/24"
assert payload["result"]["rent_end_date"] == "2025-12-31"
def test_vsp_create_forbidden_for_executor(client, isp_tokens, auth_headers):
response = client.post(
"/api/v1/dict/info",
json={
"registration_number": _unique_reg_number(),
"address": "Should Fail",
"ssp_id": 1,
},
headers=auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_vsp_update_smoke(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number()
created = client.post(
"/api/v1/dict/info",
json={
"registration_number": reg_number,
"address": "Original Address",
"ssp_id": 1,
},
headers=auth_headers(admin_tokens),
)
assert created.status_code == 201
vsp_id = created.json()["result"]["id"]
new_address = f"Updated Address {uuid.uuid4().hex[:4]}"
response = client.put(
f"/api/v1/dict/info/{vsp_id}",
json={"address": new_address},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["result"]["address"] == new_address
def test_vsp_update_not_found(client, admin_tokens, auth_headers):
response = client.put(
"/api/v1/dict/info/99999",
json={"address": "No Matter"},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_vsp_delete_smoke(client, admin_tokens, auth_headers):
reg_number = _unique_reg_number()
created = client.post(
"/api/v1/dict/info",
json={
"registration_number": reg_number,
"address": "To Be Deleted",
"ssp_id": 1,
},
headers=auth_headers(admin_tokens),
)
assert created.status_code == 201
vsp_id = created.json()["result"]["id"]
response = client.delete(
f"/api/v1/dict/info/{vsp_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "Запись INFO удалена"
def test_vsp_delete_not_found(client, admin_tokens, auth_headers):
response = client.delete(
"/api/v1/dict/info/99999",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 404
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_vsp_delete_forbidden_for_executor(client, isp_tokens, auth_headers):
response = client.delete(
"/api/v1/dict/info/1",
headers=auth_headers(isp_tokens),
)
assert response.status_code == 403
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
def test_vsp_requires_auth(client):
response = client.get("/api/v1/dict/info")
assert response.status_code == 403

View File

@ -0,0 +1,78 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.auditlog_repository import AuditLogRepository
def _result_with_scalars(items):
result = MagicMock()
scalars = MagicMock()
scalars.first.return_value = items[0] if items else None
scalars.all.return_value = items
result.scalars.return_value = scalars
return result
def _result_with_scalar_one(value):
result = MagicMock()
result.scalar_one.return_value = value
return result
@pytest.fixture
def mock_db():
return AsyncMock(spec=AsyncSession)
@pytest.fixture
def repository(mock_db):
return AuditLogRepository(mock_db)
@pytest.mark.asyncio
async def test_get_returns_audit_log(repository, mock_db):
row = MagicMock(id=10, user_id=1, event_type="WRITE")
mock_db.execute.return_value = _result_with_scalars([row])
result = await repository.get(10)
assert result is row
mock_db.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_returns_none_for_missing_id(repository, mock_db):
mock_db.execute.return_value = _result_with_scalars([])
result = await repository.get(999999)
assert result is None
@pytest.mark.asyncio
async def test_get_all_applies_filters(repository, mock_db):
row = MagicMock(id=11, user_id=2, org_unit_id=7, event_type="UPDATE")
mock_db.execute.side_effect = [
_result_with_scalars([row]),
_result_with_scalar_one(1),
]
result, count = await repository.get_all(
limit=10,
offset=5,
user_id=2,
org_unit_id=7,
task_id=3,
form_id=4,
event_type="UPDATE",
date_from=datetime(2026, 1, 1, tzinfo=timezone.utc),
date_to=datetime(2026, 12, 31, tzinfo=timezone.utc),
)
assert result == [row]
assert count == 1
assert mock_db.execute.await_count == 2

View File

@ -0,0 +1,91 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.core.errors import AccessDeniedException
from src.db.models.role import UserRoleEnum
from src.services.auditlog_service import AuditLogService
@pytest.fixture
def service_with_mock_repo(monkeypatch):
mock_repo = MagicMock()
mock_repo.get = AsyncMock()
mock_repo.get_all = AsyncMock()
repo_cls = MagicMock(return_value=mock_repo)
monkeypatch.setattr("src.services.auditlog_service.AuditLogRepository", repo_cls)
service = AuditLogService(AsyncMock())
return service, mock_repo
@pytest.fixture
def admin_user():
return SimpleNamespace(
id=1,
role_id=UserRoleEnum.ADMIN.value,
email="admin@example.com",
)
@pytest.fixture
def non_admin_user():
return SimpleNamespace(
id=2,
role_id=UserRoleEnum.EXECUTOR_DFIP.value,
email="executor@example.com",
)
@pytest.mark.asyncio
async def test_get_all_allowed_for_admin(service_with_mock_repo, admin_user):
service, mock_repo = service_with_mock_repo
mock_row = SimpleNamespace(id=10)
mock_repo.get_all.return_value = [mock_row]
result = await service.get_all(admin_user, org_unit_id=77, event_type="form")
assert result == [mock_row]
mock_repo.get_all.assert_awaited_once_with(
limit=None,
offset=None,
user_id=None,
org_unit_id=77,
task_id=None,
form_id=None,
event_type="form",
date_from=None,
date_to=None,
)
@pytest.mark.asyncio
async def test_get_all_denied_for_non_admin(service_with_mock_repo, non_admin_user):
service, mock_repo = service_with_mock_repo
with pytest.raises(AccessDeniedException):
await service.get_all(non_admin_user)
mock_repo.get_all.assert_not_called()
def test_orm_log_to_response_maps_entity_and_entity_id(service_with_mock_repo):
service, _ = service_with_mock_repo
log = SimpleNamespace(
id=55,
user_id=7,
event="UPDATE",
event_type="fallback_entity",
event_dt="2026-05-19T12:00:00Z",
event_data={"entity_type": "form_line", "entity_id": "42", "x": 1},
user=None,
)
dto = service.orm_log_to_response(log)
assert dto.entity == "form_line"
assert dto.entity_id == 42
assert dto.action == "UPDATE"
assert dto.user_id == 7

View File

@ -0,0 +1,159 @@
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
from src.core.exception_handlers import (
_error_payload,
map_http_exception,
map_sqlalchemy_error,
)
def test_map_sqlalchemy_error_unique_conflict():
exc = IntegrityError(
statement="INSERT INTO users ...",
params={},
orig=Exception("duplicate key value violates unique constraint users_username_key"),
)
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_409_CONFLICT
assert code == status.HTTP_409_CONFLICT
assert "duplicate key value" in message
assert field == ""
def test_map_sqlalchemy_error_check_constraint_is_400():
exc = IntegrityError(
statement="UPDATE v3.project ...",
params={},
orig=Exception("new row for relation violates check constraint chk_v3_project_name"),
)
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_400_BAD_REQUEST
assert code == status.HTTP_400_BAD_REQUEST
assert "violates check constraint" in message
assert field == ""
def test_map_sqlalchemy_error_validation_prefix():
exc = SQLAlchemyError()
exc.orig = Exception("unknown_column: q5.plan")
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert message == "unknown_column: q5.plan"
assert field == "q5.plan"
def test_map_sqlalchemy_error_not_found():
exc = SQLAlchemyError()
exc.orig = Exception("budget_form #99 не существует")
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_404_NOT_FOUND
assert code == status.HTTP_404_NOT_FOUND
assert "не существует" in message
assert field == ""
def test_map_sqlalchemy_error_role_window_prefixes():
role_exc = SQLAlchemyError()
role_exc.orig = Exception("role_not_allowed: q1.m1")
status_code, code, message, field = map_sqlalchemy_error(role_exc)
assert status_code == status.HTTP_403_FORBIDDEN
assert code == status.HTTP_403_FORBIDDEN
assert message == "role_not_allowed: q1.m1"
assert field == ""
window_exc = SQLAlchemyError()
window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)")
status_code, code, message, field = map_sqlalchemy_error(window_exc)
assert status_code == status.HTTP_403_FORBIDDEN
assert code == status.HTTP_403_FORBIDDEN
assert message.startswith("window_closed:")
assert field == ""
def test_map_sqlalchemy_error_operational():
exc = OperationalError(
statement="SELECT 1",
params={},
orig=Exception("connection refused"),
)
status_code, code, message, field = map_sqlalchemy_error(exc)
assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE
assert code == status.HTTP_503_SERVICE_UNAVAILABLE
assert message == "Database unavailable"
assert field == ""
def test_error_payload_matches_spec_shape():
payload = _error_payload(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"unknown_column: q5.plan",
"q5.plan",
)
assert payload == {
"code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"field": "q5.plan",
"message": "unknown_column: q5.plan",
}
def test_map_http_exception_validation_prefix():
exc = HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="unknown_column: q5.plan",
)
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert message == "unknown_column: q5.plan"
assert field == ""
def test_map_http_exception_from_structured_detail():
exc = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "validation_error", "message": "bad input", "field": "username"},
)
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_400_BAD_REQUEST
assert code == status.HTTP_400_BAD_REQUEST
assert message == "bad input"
assert field == "username"
def test_map_http_exception_unauthorized():
exc = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен")
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_401_UNAUTHORIZED
assert code == status.HTTP_401_UNAUTHORIZED
assert message == "Недействительный токен"
assert field == ""
def test_map_http_exception_not_found_maps_code_from_status():
exc = HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_404_NOT_FOUND
assert code == status.HTTP_404_NOT_FOUND
assert message == "not found"
assert field == ""

View File

@ -0,0 +1,84 @@
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from sqlalchemy.exc import SQLAlchemyError
from src.core.exception_handlers import register_exception_handlers
def _make_app() -> FastAPI:
app = FastAPI()
register_exception_handlers(app)
@app.get("/http-401")
async def http_401():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
)
@app.get("/http-422-prefixed")
async def http_422_prefixed():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="unknown_column: q5.plan",
)
@app.get("/sql-error")
async def sql_error():
exc = SQLAlchemyError()
exc.orig = Exception("budget_form #99 не существует")
raise exc
@app.get("/form/{form_id}")
async def form_by_id(form_id: int):
return {"form_id": form_id}
return app
def test_http_exception_returns_structured_payload():
client = TestClient(_make_app())
response = client.get("/http-401")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"code": status.HTTP_401_UNAUTHORIZED,
"field": "",
"message": "Недействительный токен",
}
def test_http_validation_exception_extracts_field():
client = TestClient(_make_app())
response = client.get("/http-422-prefixed")
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.json() == {
"code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"field": "",
"message": "unknown_column: q5.plan",
}
def test_sqlalchemy_exception_returns_structured_payload():
client = TestClient(_make_app())
response = client.get("/sql-error")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"code": status.HTTP_404_NOT_FOUND,
"field": "",
"message": "budget_form #99 не существует",
}
def test_request_validation_422_returns_structured_payload():
client = TestClient(_make_app())
response = client.get("/form/not-int")
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.json() == {
"code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"field": "form_id",
"message": "Input should be a valid integer, unable to parse string as an integer",
}

View File

@ -0,0 +1,75 @@
from src.services.export_service.export_normalizers import (
ExcelValueSerializer,
Form1RowNormalizer,
Form3ReportRowNormalizer,
SmetaRowNormalizer,
)
def test_form1_normalizer_merges_header_and_contract_fields():
serializer = ExcelValueSerializer()
normalizer = Form1RowNormalizer(serializer)
row = {
"header": {"section": "1.00.0.", "item_id": 1, "name": "A"},
"contract_detail": {"reference": "DOG-1", "amount": 10},
"plan": {"q1": 7},
}
normalized = normalizer.normalize(row)
assert normalized["section"] == "1.00.0."
assert normalized["item_id"] == 1
assert normalized["name"] == "A"
assert normalized["q1"] == 7
assert normalized["contract"] == "DOG-1"
assert normalized["contract_sum"] == 10
assert "header" not in normalized
def test_smeta_normalizer_extracts_nested_values():
normalizer = SmetaRowNormalizer()
row = {
"section_code": "1",
"name": "SMETA row",
"plan": {"support": {"q1": 5}, "total_year": 20},
"approved": {"development": {"year": 8}},
}
normalized = normalizer.normalize(row)
assert normalized["section_code"] == "1"
assert normalized["name"] == "SMETA row"
assert normalized["plan_support_q1"] == 5
assert normalized["plan_total_year"] == 20
assert normalized["approved_development_year"] == 8
def test_excel_value_serializer_formats_collections():
serializer = ExcelValueSerializer()
assert serializer.to_excel_value(["a", 1, None]) == "a, 1, "
assert serializer.to_excel_value({"name": "X"}) == "X"
assert serializer.to_excel_value({"k": {"nested": 1}}) == "k=1"
def test_form3_normalizer_flattens_quarters_and_totals_to_scalars():
normalizer = Form3ReportRowNormalizer()
row = {
"header": {"section_code": "1.00.0.", "item_id": "R001", "name": "Строка"},
"q1": {"quarter_actual": 10, "total_corr": 12},
"q2": {"total_corr": 22},
"q3": {"economy": 1.5},
"q4": {"m1": 100},
"totals": {"total_actual": 40, "economy": 2.1},
}
normalized = normalizer.normalize(row)
assert normalized["section"] == "1.00.0."
assert normalized["item_id"] == "R001"
assert normalized["name"] == "Строка"
assert normalized["q1"] == 10
assert normalized["q2"] == 22
assert normalized["q3"] == 1.5
assert normalized["q4"] is None
assert normalized["totals"] == 40

View File

@ -0,0 +1,140 @@
import io
from types import SimpleNamespace
from unittest.mock import AsyncMock, call
import pytest
from src.db.models.form_type import FormTypeEnum
from src.domain.schemas import DirectionSchemaEnum
from src.services.export_service import ExportService
def _build_form(
*,
form_type_code: FormTypeEnum = FormTypeEnum.FORM_1,
sheet_list: list[str] | None = None,
section_list: list[str] | None = None,
sheet_list_with_directions: list[dict] | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
id=1,
year=2026,
form_type_code=form_type_code.value,
org_unit_id=10,
org_unit=SimpleNamespace(title="Org"),
form_type=SimpleNamespace(
code=form_type_code,
sheet_list=sheet_list or ["AHR", "SMETA"],
section_list=section_list or ["plan", "q1"],
sheet_list_with_directions=sheet_list_with_directions or [
{
"sheet_name": "AHR",
"direction": "Support",
},
{
"sheet_name": "AHR",
"direction": "Development",
},
{
"sheet_name": "SMETA",
"direction": None,
},
],
),
)
@pytest.mark.asyncio
async def test_export_sheet_rejects_direction_for_unsupported_sheet():
service = ExportService(AsyncMock())
service.budget_form_service.get = AsyncMock(
return_value=_build_form(sheet_list=["SMETA"])
)
service.sheet_service.get = AsyncMock(return_value=[])
with pytest.raises(ValueError, match="direction допустим только"):
await service.export_form_to_xlsx(
form_id=1,
current_user=SimpleNamespace(),
sheet="SMETA",
direction=DirectionSchemaEnum.SUPPORT.value,
)
@pytest.mark.asyncio
async def test_export_sheet_rejects_sections_for_unsupported_sheet():
service = ExportService(AsyncMock())
service.budget_form_service.get = AsyncMock(
return_value=_build_form(
sheet_list=["SMETA"],
sheet_list_with_directions=[
{
"sheet_name": "SMETA",
"direction": None,
}
],
)
)
service.sheet_service.get = AsyncMock(return_value=[])
with pytest.raises(ValueError, match="sections не поддерживается"):
await service.export_form_to_xlsx(
form_id=1,
current_user=SimpleNamespace(),
sheet="SMETA",
sections=["q1"],
)
@pytest.mark.asyncio
async def test_export_full_form_ignores_non_applicable_params_for_other_sheets():
service = ExportService(AsyncMock())
service.budget_form_service.get = AsyncMock(return_value=_build_form())
service.sheet_service.get = AsyncMock(return_value=[])
stream, filename = await service.export_form_to_xlsx(
form_id=1,
current_user=SimpleNamespace(),
direction=DirectionSchemaEnum.SUPPORT.value,
sections=["q1"],
ignore_non_applicable_query_params=True,
)
assert stream.getbuffer().nbytes > 0
assert filename.endswith(".xlsx")
assert service.sheet_service.get.await_args_list == [
call(
form_id=1,
sheet="AHR",
direction=DirectionSchemaEnum.SUPPORT.value,
sections=["q1"],
),
call(
form_id=1,
sheet="SMETA",
direction=None,
sections=None,
),
]
@pytest.mark.asyncio
async def test_export_form_payload_forwards_ignore_flag():
service = ExportService(AsyncMock())
service.export_form_to_xlsx = AsyncMock(return_value=(io.BytesIO(), "demo.xlsx"))
await service.export_form_payload(
form_id=1,
current_user=SimpleNamespace(),
ignore_non_applicable_query_params=True,
)
assert service.export_form_to_xlsx.await_count == 1
assert (
service.export_form_to_xlsx.await_args.kwargs[
"ignore_non_applicable_query_params"
]
is True
)

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