diff --git a/src/apps/parsers/api_result_urls.py b/src/apps/parsers/api_result_urls.py index e5d55f8..8102c62 100644 --- a/src/apps/parsers/api_result_urls.py +++ b/src/apps/parsers/api_result_urls.py @@ -19,6 +19,9 @@ from drf_yasg.utils import swagger_auto_schema app_name = "parser_results" ROUTE_TITLES = { + "eis/contracts": "ЕИС Контракты", + "eis/procurements-44fz": "ЕИС Закупки 44-ФЗ", + "eis/procurements-223fz": "ЕИС Закупки 223-ФЗ", "zakupki": "ЕИС Закупки", "proverki": "Проверки Генпрокуратуры", } diff --git a/src/apps/parsers/source_registry.py b/src/apps/parsers/source_registry.py index 1ac923e..85410a7 100644 --- a/src/apps/parsers/source_registry.py +++ b/src/apps/parsers/source_registry.py @@ -140,7 +140,7 @@ PARSER_SOURCES: dict[str, ParserSourceDescriptor] = { access_method="eis_official_api", parser_strategy="eis_44fz_search", source_notes="Официальный поиск ЕИС; XML-выгрузки ЕИС требуют отдельного discovery по реестрам.", - api_route="zakupki", + api_route="eis/procurements-44fz", ), "procurements_223fz": ParserSourceDescriptor( key="procurements_223fz", @@ -155,7 +155,7 @@ PARSER_SOURCES: dict[str, ParserSourceDescriptor] = { access_method="eis_official_api", parser_strategy="eis_223fz_search", source_notes="Официальный реестр положений о закупках 223-ФЗ в ЕИС.", - api_route="zakupki", + api_route="eis/procurements-223fz", ), "contracts": ParserSourceDescriptor( key="contracts", @@ -169,7 +169,7 @@ PARSER_SOURCES: dict[str, ParserSourceDescriptor] = { upstream_url="https://zakupki.gov.ru/epz/contract/search/results.html", access_method="eis_official_api", parser_strategy="eis_contract_registry", - api_route="zakupki", + api_route="eis/contracts", ), "unfair_suppliers": ParserSourceDescriptor( key="unfair_suppliers", diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index b79ef92..f19a08b 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -1089,6 +1089,7 @@ const tokenKey = "mostovik_dashboard_access"; const refreshKey = "mostovik_dashboard_refresh"; let accessToken = localStorage.getItem(tokenKey) || ""; + let currentUser = null; let dashboardData = null; let activeMainTab = "analytics"; let activeDrawerTab = "jobs"; @@ -1316,6 +1317,20 @@ showMainTab(activeMainTab); } + function userCanManageExchange() { + const capabilities = currentUser?.capabilities || {}; + return Boolean(capabilities.can_manage_exchange || capabilities.can_manage_database_connection); + } + + async function loadCurrentUser() { + if (!accessToken) { + currentUser = null; + return null; + } + currentUser = await apiFetch("/api/v1/users/me/"); + return currentUser; + } + function showMainTab(tab) { activeMainTab = tab; document.querySelectorAll("[data-main-tab]").forEach((button) => { @@ -1599,6 +1614,7 @@ localStorage.removeItem(tokenKey); localStorage.removeItem(refreshKey); accessToken = ""; + currentUser = null; } function renderRegistryUploadPanel(registries) { @@ -1695,7 +1711,18 @@ ` : `
Расписание выгрузки во внешнюю БД еще не настроено.
`; } + function renderExchangeRestricted() { + setSelectOptions("exchangeCopyTable", []); + setSelectOptions("exchangeScheduleTable", []); + $("exchangeConnections").innerHTML = `
Внешняя БД доступна только пользователям с правами администратора.
`; + $("exchangeSchedules").innerHTML = ""; + } + async function refreshExchange() { + if (!userCanManageExchange()) { + renderExchangeRestricted(); + return; + } try { const [connections, tables, schedules] = await Promise.all([ apiFetch("/api/v1/exchange/connections/"), @@ -1864,6 +1891,7 @@ async function refreshDashboard() { if (!accessToken) return; try { + await loadCurrentUser(); const data = await apiFetch("/api/v1/parsers/dashboard/"); setAuthenticated(true); renderDashboard(data); @@ -1984,6 +2012,10 @@ $("exchangeConnectionForm").addEventListener("submit", async (event) => { event.preventDefault(); + if (!userCanManageExchange()) { + renderExchangeRestricted(); + return; + } await apiFetch("/api/v1/exchange/connections/", { method: "POST", body: JSON.stringify(formPayload(event.target)), @@ -1996,6 +2028,10 @@ $("exchangeCopyForm").addEventListener("submit", async (event) => { event.preventDefault(); + if (!userCanManageExchange()) { + renderExchangeRestricted(); + return; + } const data = formPayload(event.target); if (data.mode !== "single") delete data.table; await apiFetch("/api/v1/exchange/copy/", { @@ -2007,6 +2043,10 @@ $("exchangeScheduleForm").addEventListener("submit", async (event) => { event.preventDefault(); + if (!userCanManageExchange()) { + renderExchangeRestricted(); + return; + } const data = formPayload(event.target); if (data.mode !== "single") delete data.table; if (data.schedule_type === "interval") { @@ -2057,6 +2097,10 @@ } if (target.dataset.sourceDetail) openSourceDetail(target.dataset.sourceDetail); if (target.dataset.exchangeAction === "test") { + if (!userCanManageExchange()) { + renderExchangeRestricted(); + return; + } const data = formPayload($("exchangeConnectionForm")); await apiFetch("/api/v1/exchange/connections/test/", { method: "POST", @@ -2105,6 +2149,10 @@ await refreshDashboard(); } if (target.dataset.exchangeToggle) { + if (!userCanManageExchange()) { + renderExchangeRestricted(); + return; + } await apiFetch(`/api/v1/exchange/periodic-tasks/${target.dataset.exchangeToggle}/`, { method: "PATCH", body: JSON.stringify({ enabled: target.dataset.enabled === "true" }), @@ -2112,6 +2160,10 @@ await refreshExchange(); } if (target.dataset.exchangeDelete) { + if (!userCanManageExchange()) { + renderExchangeRestricted(); + return; + } await apiFetch(`/api/v1/exchange/periodic-tasks/${target.dataset.exchangeDelete}/`, { method: "DELETE" }); await refreshExchange(); } diff --git a/src/user/serializers.py b/src/user/serializers.py index 0238843..cafb65c 100644 --- a/src/user/serializers.py +++ b/src/user/serializers.py @@ -146,6 +146,7 @@ class FrontendUserWithProfileSerializer(serializers.ModelSerializer): profile = FrontendUserProfileSerializer(read_only=True) role = serializers.SerializerMethodField() role_label = serializers.SerializerMethodField() + capabilities = serializers.SerializerMethodField() class Meta: model = User @@ -157,6 +158,7 @@ class FrontendUserWithProfileSerializer(serializers.ModelSerializer): "is_active", "role", "role_label", + "capabilities", "profile", ) read_only_fields = fields @@ -167,6 +169,9 @@ class FrontendUserWithProfileSerializer(serializers.ModelSerializer): def get_role_label(self, obj) -> str: return UserService.get_role_label(self.get_role(obj)) + def get_capabilities(self, obj) -> dict: + return UserService.get_user_capabilities(obj) + class FrontendManagedUserSerializer(serializers.ModelSerializer): """Короткий сериализатор пользователя без вложенного профиля.""" diff --git a/tests/apps/parsers/test_views.py b/tests/apps/parsers/test_views.py index 4eb3aac..adaee0b 100644 --- a/tests/apps/parsers/test_views.py +++ b/tests/apps/parsers/test_views.py @@ -7,7 +7,13 @@ import os import tempfile from unittest.mock import Mock, patch -from apps.parsers.models import FinancialReport, FinancialReportLine, ProcurementRecord +from apps.parsers.models import ( + FinancialReport, + FinancialReportLine, + GenericParserRecord, + ParserLoadLog, + ProcurementRecord, +) from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from openpyxl import Workbook @@ -121,6 +127,36 @@ class ParsersViewSetTest(APITestCase): ) self.assertEqual(detail.status_code, status.HTTP_200_OK) + def test_eis_result_endpoint_uses_generic_records_without_breaking_old_zakupki_api( + self, + ): + old_record = _create_procurement_record() + generic_record = GenericParserRecord.objects.create( + load_batch=1, + source=ParserLoadLog.Source.PROCUREMENTS_44FZ, + external_id="eis-44fz-1", + inn=_digits(10), + organisation_name="EIS Customer", + title="EIS 44-FZ notice", + status="published", + payload={"registry": "44fz"}, + ) + self.client.force_authenticate(self.user) + + old_response = self.client.get(reverse("api_v1:zakupki:procurements-list")) + new_response = self.client.get("/api/v1/eis/procurements-44fz/") + detail_response = self.client.get( + f"/api/v1/eis/procurements-44fz/{generic_record.id}/" + ) + + self.assertEqual(old_response.status_code, status.HTTP_200_OK) + self.assertEqual(old_response.data["data"][0]["id"], old_record.id) + self.assertEqual(new_response.status_code, status.HTTP_200_OK) + self.assertEqual(new_response.data["meta"]["pagination"]["count"], 1) + self.assertEqual(new_response.data["data"][0]["id"], generic_record.id) + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(detail_response.data["data"]["payload"], {"registry": "44fz"}) + def test_dashboard_data_exposes_source_groups_for_page(self): self.client.force_authenticate(self.user) @@ -133,6 +169,19 @@ class ParsersViewSetTest(APITestCase): self.assertIn("uploads", payload["groups"]) self.assertEqual(payload["api_sources"], payload["groups"]["api"]) self.assertEqual(payload["file_sources"], payload["groups"]["uploads"]) + sources = {item["key"]: item for item in payload["sources"]} + self.assertEqual( + sources["procurements_44fz"]["result_list_url"], + "/api/v1/eis/procurements-44fz/", + ) + self.assertEqual( + sources["procurements_223fz"]["result_list_url"], + "/api/v1/eis/procurements-223fz/", + ) + self.assertEqual( + sources["contracts"]["result_list_url"], + "/api/v1/eis/contracts/", + ) def test_financial_reports_list_and_retrieve(self): report = FinancialReport.objects.create( diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index dad952c..aefcf79 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -179,12 +179,14 @@ class CurrentUserViewTest(APITestCase): "is_active", "role", "role_label", + "capabilities", "profile", }, ) self.assertEqual(response.data["id"], self.user.id) self.assertEqual(response.data["email"], self.user.email) self.assertEqual(response.data["role"], "user") + self.assertFalse(response.data["capabilities"]["can_manage_exchange"]) self.assertEqual( set(response.data["profile"].keys()), {"first_name", "middle_name", "last_name", "full_name"}, @@ -267,6 +269,7 @@ class AdminUserManagementViewTest(APITestCase): "is_active", "role", "role_label", + "capabilities", "profile", }, ) @@ -274,9 +277,16 @@ class AdminUserManagementViewTest(APITestCase): set(response.data["results"][0]["profile"].keys()), {"first_name", "middle_name", "last_name", "full_name"}, ) - usernames = {item["username"] for item in response.data["results"]} + users_by_name = {item["username"]: item for item in response.data["results"]} + usernames = set(users_by_name) self.assertIn(self.admin.username, usernames) self.assertIn(self.user.username, usernames) + self.assertTrue( + users_by_name[self.admin.username]["capabilities"]["can_manage_exchange"] + ) + self.assertFalse( + users_by_name[self.user.username]["capabilities"]["can_manage_exchange"] + ) def test_admin_can_search_users(self): ProfileFactory.create_profile(