diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cec2c47..26b8dfc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,13 @@ "Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")", "Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")", "Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(python -c \"import openpyxl; print\\(''openpyxl available:'', openpyxl.__version__\\)\")", + "Bash(pip install:*)", + "Bash(python create_objects_template.py)", + "Bash(python -c \":*)", + "Bash(python -c \"import sys; data = sys.stdin.buffer.read\\(\\); print\\(data.decode\\(''''utf-8'''', errors=''''replace''''\\)\\)\")", + "Bash(docker compose:*)" ] } } diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index 5a9d1ff..3b12aaf 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -1,6 +1,8 @@ import asyncio +import re +from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from sqlalchemy import select, distinct from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -11,7 +13,19 @@ from app.models.object import Object from app.models.tag import Tag from app.models.user import User from app.schemas.admin import SheetsSyncRequest -from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult, DiscoveryStarted +from app.schemas.object import ( + ObjectCreate, + ObjectImportError, + ObjectImportPreview, + ObjectImportResult, + ObjectImportRow, + ObjectImportSkipped, + ObjectMetaResponse, + ObjectRead, + ObjectUpdate, + SyncResult, + DiscoveryStarted, +) from app.services import crypto from app.services.database import AsyncSessionLocal from app.services.device_discovery import discover_via_ssh @@ -215,6 +229,182 @@ async def discover_devices( return DiscoveryStarted(task_id=task_id) +@router.post("/import/preview", response_model=ObjectImportPreview) +async def preview_objects_import( + file: Annotated[UploadFile, File()], + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Dry-run: parse xlsx and return what would be created/skipped.""" + return await _parse_objects_xlsx(db, file, dry_run=True) + + +@router.post("/import", response_model=ObjectImportResult, status_code=status.HTTP_201_CREATED) +async def import_objects( + file: Annotated[UploadFile, File()], + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Import objects from xlsx. Deduplication by server_ip (skip + warn on duplicate).""" + preview = await _parse_objects_xlsx(db, file, dry_run=False) + return ObjectImportResult( + created=len(preview.would_create), + skipped=len(preview.would_skip), + errors=preview.errors, + ) + + +def _norm_col(name: str) -> str: + """Normalize xlsx header: 'server_ip *' → 'server_ip', 'ssh_user (root)' → 'ssh_user'.""" + name = re.sub(r"\s*\*", "", name) + name = re.sub(r"\s*\(.*?\)", "", name) + return name.strip().lower() + + +async def _parse_objects_xlsx( + db: AsyncSession, file: UploadFile, dry_run: bool +) -> ObjectImportPreview: + import io + try: + from openpyxl import load_workbook + except ImportError: + raise HTTPException(status_code=500, detail="openpyxl not installed") + + content = await file.read() + try: + wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + except Exception: + raise HTTPException(status_code=400, detail="Не удалось открыть файл. Убедитесь, что файл в формате .xlsx") + + # Use first sheet (the data sheet) + ws = wb.worksheets[0] + rows = list(ws.iter_rows(values_only=True)) + if not rows: + raise HTTPException(status_code=400, detail="Файл пустой") + + # Build column index from header row + header = [_norm_col(str(c)) if c is not None else "" for c in rows[0]] + col = {name: idx for idx, name in enumerate(header) if name} + + required = {"name", "city", "server_ip"} + missing = required - col.keys() + if missing: + raise HTTPException( + status_code=400, + detail=f"Отсутствуют обязательные колонки: {', '.join(sorted(missing))}", + ) + + def _cell(row: tuple, key: str) -> str | None: + idx = col.get(key) + if idx is None: + return None + val = row[idx] + if val is None: + return None + s = str(val).strip() + return s if s else None + + would_create: list[ObjectImportRow] = [] + would_skip: list[ObjectImportSkipped] = [] + errors: list[ObjectImportError] = [] + + # Track IPs seen within this file to catch intra-file duplicates + seen_ips: dict[str, int] = {} + + # Pre-load existing server_ips from DB for fast lookup + existing_ips_result = await db.execute( + select(Object.server_ip, Object.name).where(Object.server_ip.isnot(None)) + ) + existing_ips: dict[str, str] = {ip: name for ip, name in existing_ips_result.all()} + + for row_num, row in enumerate(rows[1:], start=2): + # Skip entirely blank rows + if all(c is None or str(c).strip() == "" for c in row): + continue + + name = _cell(row, "name") + city = _cell(row, "city") + server_ip = _cell(row, "server_ip") + + # Validate required fields + missing_fields = [] + if not name: + missing_fields.append("name") + if not city: + missing_fields.append("city") + if not server_ip: + missing_fields.append("server_ip") + if missing_fields: + errors.append(ObjectImportError( + row=row_num, + reason=f"Обязательные поля не заполнены: {', '.join(missing_fields)}", + )) + continue + + # Deduplicate within file + if server_ip in seen_ips: + errors.append(ObjectImportError( + row=row_num, + reason=f"Дублирующийся IP {server_ip} в файле (строка {seen_ips[server_ip]})", + )) + continue + seen_ips[server_ip] = row_num + + # Deduplicate against DB + if server_ip in existing_ips: + would_skip.append(ObjectImportSkipped( + row=row_num, + name=name, + server_ip=server_ip, + reason=f"Объект с IP {server_ip} уже существует: «{existing_ips[server_ip]}»", + )) + continue + + ssh_user = _cell(row, "ssh_user") or "root" + ssh_password = _cell(row, "ssh_password") or "123123" + db_via_tunnel_raw = _cell(row, "db_via_tunnel") + db_via_tunnel = bool(int(db_via_tunnel_raw)) if db_via_tunnel_raw and db_via_tunnel_raw.isdigit() else False + + import_row = ObjectImportRow( + row=row_num, + name=name, + city=city, + server_ip=server_ip, + client=_cell(row, "client"), + description=_cell(row, "description"), + notes=_cell(row, "notes"), + ssh_user=ssh_user, + db_via_tunnel=db_via_tunnel, + ) + would_create.append(import_row) + + if not dry_run: + obj = Object( + name=name, + city=city, + server_ip=server_ip, + client=import_row.client, + description=import_row.description, + notes=import_row.notes, + ssh_user=ssh_user, + db_via_tunnel=db_via_tunnel, + is_active=True, + ) + obj.ssh_pass_enc = crypto.encrypt(ssh_password) + db.add(obj) + # update local cache so next rows in this file see the newly added IP + existing_ips[server_ip] = name + + if not dry_run: + await db.flush() + + return ObjectImportPreview( + would_create=would_create, + would_skip=would_skip, + errors=errors, + ) + + @router.post("/{obj_id}/sync/sheets", response_model=SyncResult) async def sync_devices_from_sheets( obj_id: int, diff --git a/backend/app/schemas/object.py b/backend/app/schemas/object.py index 4af65b7..a07ef5e 100644 --- a/backend/app/schemas/object.py +++ b/backend/app/schemas/object.py @@ -85,6 +85,44 @@ class ObjectMetaResponse(BaseModel): clients: list[str] +# --- Import from xlsx --- + +class ObjectImportRow(BaseModel): + row: int + name: str + city: str + server_ip: str + client: Optional[str] = None + description: Optional[str] = None + notes: Optional[str] = None + ssh_user: str + db_via_tunnel: bool + + +class ObjectImportSkipped(BaseModel): + row: int + name: str + server_ip: str + reason: str + + +class ObjectImportError(BaseModel): + row: int + reason: str + + +class ObjectImportPreview(BaseModel): + would_create: list[ObjectImportRow] + would_skip: list[ObjectImportSkipped] + errors: list[ObjectImportError] + + +class ObjectImportResult(BaseModel): + created: int + skipped: int + errors: list[ObjectImportError] + + class SyncResult(BaseModel): created: int updated: int diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6207f71..e668bc0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ # Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var) "gspread>=6.0", "google-auth>=2.0", + "openpyxl>=3.1", ] [project.optional-dependencies] diff --git a/create_objects_template.py b/create_objects_template.py new file mode 100644 index 0000000..dd95dc9 --- /dev/null +++ b/create_objects_template.py @@ -0,0 +1,129 @@ +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +wb = Workbook() +ws = wb.active +ws.title = "Импорт объектов" + +DARK_BLUE = "1F3864" +LIGHT_GRAY = "F2F2F2" +LIGHT_BLUE = "EBF3FB" +WHITE = "FFFFFF" + +columns = [ + ("name *", 30), + ("city *", 20), + ("server_ip *", 18), + ("client", 25), + ("description", 35), + ("notes", 35), + ("ssh_user (root)", 15), + ("ssh_password (123123)", 18), + ("db_via_tunnel (0)", 20), +] + +sample_row = [ + "Парковка ТЦ Мега", + "Москва", + "192.168.1.10", + "ООО Рога и Копыта", + "Въезд с ул. Ленина", + "Контакт: +7 999 000-00-00", + "admin", + "qwerty123", + 0, +] + +header_font = Font(name="Arial", bold=True, color=WHITE, size=11) +header_fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE) +header_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + +sample_fill = PatternFill("solid", start_color=LIGHT_GRAY, fgColor=LIGHT_GRAY) +blue_fill = PatternFill("solid", start_color=LIGHT_BLUE, fgColor=LIGHT_BLUE) +white_fill = PatternFill("solid", start_color=WHITE, fgColor=WHITE) +data_font = Font(name="Arial", size=10) +data_align = Alignment(vertical="center") + +# Header row +for col_idx, (header, width) in enumerate(columns, start=1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_align + ws.column_dimensions[get_column_letter(col_idx)].width = width + +ws.row_dimensions[1].height = 30 + +# Sample data row (row 2) +for col_idx, value in enumerate(sample_row, start=1): + cell = ws.cell(row=2, column=col_idx, value=value) + cell.font = data_font + cell.fill = sample_fill + cell.alignment = data_align + +# Empty data rows 3-52, alternating white / light blue +for row in range(3, 53): + fill = white_fill if (row % 2 == 1) else blue_fill + for col_idx in range(1, len(columns) + 1): + cell = ws.cell(row=row, column=col_idx) + cell.font = data_font + cell.fill = fill + cell.alignment = data_align + +ws.freeze_panes = "A2" + +# --- Справка sheet --- +ws2 = wb.create_sheet("Справка") + +ws2.column_dimensions["A"].width = 22 +ws2.column_dimensions["B"].width = 40 +ws2.column_dimensions["C"].width = 20 +ws2.column_dimensions["D"].width = 20 + +title_cell = ws2.cell(row=1, column=1, value="Инструкция по заполнению") +title_cell.font = Font(name="Arial", bold=True, size=13) +ws2.row_dimensions[1].height = 24 + +# Sub-header +headers_ref = ["Колонка", "Описание", "Обязательность", "Значение по умолчанию"] +for col_idx, h in enumerate(headers_ref, start=1): + cell = ws2.cell(row=3, column=col_idx, value=h) + cell.font = Font(name="Arial", bold=True, color=WHITE, size=11) + cell.fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE) + cell.alignment = Alignment(horizontal="center", vertical="center") +ws2.row_dimensions[3].height = 22 + +ref_rows = [ + ("name", "Название объекта (парковки, площадки)", "Обязательно", "—"), + ("city", "Город расположения объекта", "Обязательно", "—"), + ("server_ip", "IP-адрес основного сервера CAPS", "Обязательно", "—"), + ("client", "Клиент / заказчик", "Необязательно","—"), + ("description", "Описание объекта", "Необязательно","—"), + ("notes", "Заметки (контакты, особенности и т.п.)", "Необязательно","—"), + ("ssh_user", "Логин для SSH-подключения к серверу объекта", "Необязательно","root"), + ("ssh_password", "Пароль для SSH-подключения к серверу объекта", "Необязательно","123123"), + ("db_via_tunnel", "Подключаться к БД CAPS через SSH-туннель (0 — нет, 1 — да)","Необязательно","0"), +] + +for i, (col_name, desc, required, default) in enumerate(ref_rows, start=4): + fill = white_fill if (i % 2 == 0) else blue_fill + values = [col_name, desc, required, default] + for col_idx, val in enumerate(values, start=1): + cell = ws2.cell(row=i, column=col_idx, value=val) + cell.font = Font(name="Arial", size=10) + cell.fill = fill + cell.alignment = Alignment(vertical="center", wrap_text=True) + ws2.row_dimensions[i].height = 18 + +note_row = len(ref_rows) + 5 +note_cell = ws2.cell(row=note_row, column=1, + value="⚠ Дедупликация по полю server_ip: если объект с таким IP уже существует в системе, строка будет пропущена с предупреждением.") +note_cell.font = Font(name="Arial", size=10, bold=True, color="C00000") +note_cell.alignment = Alignment(wrap_text=True) +ws2.merge_cells(start_row=note_row, start_column=1, end_row=note_row, end_column=4) +ws2.row_dimensions[note_row].height = 32 + +out_path = r"C:\Users\Professional\Documents\VSCode\utp_service\objects_import_template.xlsx" +wb.save(out_path) +print(f"Saved: {out_path}") diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index 8d36fb5..d755acb 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -261,6 +261,69 @@ export const useObjectsMeta = () => staleTime: 30_000, }) +// --- Object xlsx import --- + +export interface ObjectImportRow { + row: number + name: string + city: string + server_ip: string + client: string | null + description: string | null + notes: string | null + ssh_user: string + db_via_tunnel: boolean +} + +export interface ObjectImportSkipped { + row: number + name: string + server_ip: string + reason: string +} + +export interface ObjectImportError { + row: number + reason: string +} + +export interface ObjectImportPreview { + would_create: ObjectImportRow[] + would_skip: ObjectImportSkipped[] + errors: ObjectImportError[] +} + +export interface ObjectImportResult { + created: number + skipped: number + errors: ObjectImportError[] +} + +export const usePreviewObjectsXLSX = () => + useMutation({ + mutationFn: (file: File) => { + const form = new FormData() + form.append('file', file) + return api + .post('/api/v1/objects/import/preview', form) + .then((r) => r.data) + }, + }) + +export const useImportObjectsXLSX = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (file: File) => { + const form = new FormData() + form.append('file', file) + return api + .post('/api/v1/objects/import', form) + .then((r) => r.data) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }), + }) +} + export const useSyncSheets = (objId: number) => { const qc = useQueryClient() return useMutation({ diff --git a/frontend/src/pages/Objects.tsx b/frontend/src/pages/Objects.tsx index 594fc3f..9d1c4e2 100644 --- a/frontend/src/pages/Objects.tsx +++ b/frontend/src/pages/Objects.tsx @@ -1,5 +1,13 @@ -import { DeleteOutlined, EditOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons' import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + RightOutlined, + UploadOutlined, + WarningOutlined, +} from '@ant-design/icons' +import { + Alert, AutoComplete, Button, Checkbox, @@ -13,8 +21,10 @@ import { Popconfirm, Row, Space, + Table, Tag, Typography, + Upload, message, } from 'antd' import { useMemo, useState } from 'react' @@ -25,7 +35,10 @@ import { useObjectsMeta, useUpdateObject, useObjects, + usePreviewObjectsXLSX, + useImportObjectsXLSX, type ObjectItem, + type ObjectImportPreview, } from '../api/objects' import { stripEmpty } from '../utils/form' @@ -48,6 +61,15 @@ export function ObjectsPage({ mode }: Props) { const [searchText, setSearchText] = useState('') const [form] = Form.useForm() + // Import modal state + const [importOpen, setImportOpen] = useState(false) + const [importFile, setImportFile] = useState(null) + const [importPreview, setImportPreview] = useState(null) + const [importStep, setImportStep] = useState<'upload' | 'preview'>('upload') + const previewMutation = usePreviewObjectsXLSX() + const importMutation = useImportObjectsXLSX() + const fileInputRef = useRef(null) + const objectUrl = (id: number) => mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}` @@ -133,6 +155,39 @@ export function ObjectsPage({ mode }: Props) { const isSubmitting = createObject.isPending || updateObject.isPending + const openImport = () => { + setImportFile(null) + setImportPreview(null) + setImportStep('upload') + setImportOpen(true) + } + + const handleImportFileChange = async (file: File) => { + setImportFile(file) + setImportPreview(null) + try { + const preview = await previewMutation.mutateAsync(file) + setImportPreview(preview) + setImportStep('preview') + } catch { + message.error('Не удалось прочитать файл') + } + } + + const handleImportConfirm = async () => { + if (!importFile) return + try { + const result = await importMutation.mutateAsync(importFile) + setImportOpen(false) + message.success( + `Импорт завершён: создано ${result.created}, пропущено ${result.skipped}` + + (result.errors.length ? `, ошибок ${result.errors.length}` : '') + ) + } catch { + message.error('Ошибка при импорте') + } + } + const renderObjectRow = (obj: ObjectItem) => (
setSearchText(e.target.value)} /> {mode === 'inventory' && ( - + + + + )}
@@ -230,6 +290,163 @@ export function ObjectsPage({ mode }: Props) { /> )} + {/* Import xlsx modal */} + setImportOpen(false)} + width={900} + footer={ + importStep === 'preview' && importPreview + ? [ + , + , + ] + : null + } + > + {importStep === 'upload' && ( +
+ { + handleImportFileChange(file) + return false + }} + style={{ padding: '16px 32px' }} + > +

+ +

+

Перетащите .xlsx файл или нажмите для выбора

+

+ Поддерживается шаблон objects_import_template.xlsx +

+
+ {previewMutation.isPending && ( + + Анализируем файл... + + )} +
+ )} + + {importStep === 'preview' && importPreview && ( +
+ {/* Summary */} + + + Будет создано: {importPreview.would_create.length} + + {importPreview.would_skip.length > 0 && ( + + Пропущено (дубли): {importPreview.would_skip.length} + + )} + {importPreview.errors.length > 0 && ( + + Ошибок: {importPreview.errors.length} + + )} + + + {/* Would create table */} + {importPreview.would_create.length > 0 && ( + <> + Будет создано + {v}, + }, + { title: 'Клиент', dataIndex: 'client', width: 130, render: (v) => v ?? '—' }, + { + title: 'SSH', + dataIndex: 'ssh_user', + width: 80, + render: (v: string) => {v}, + }, + ]} + /> + + )} + + {/* Skipped */} + {importPreview.would_skip.length > 0 && ( + <> + + + Пропущены (дубликаты по IP) + +
{v}, + }, + { title: 'Причина', dataIndex: 'reason' }, + ]} + /> + + )} + + {/* Errors */} + {importPreview.errors.length > 0 && ( + <> + +
+ + )} + + )} + + {/* Create / Edit modal — only relevant in inventory mode */}