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(