This commit is contained in:
2026-04-09 16:43:59 +05:00
parent 0b7a5bc454
commit 3a690ecf39
7 changed files with 379 additions and 6 deletions

110
import_orgs.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Скрипт для массового импорта организаций из .docx файла через API.
Использование:
python import_orgs.py МБОУ.docx
Требования:
pip install python-docx httpx
Настройки ниже (или через переменные окружения):
API_URL — адрес API (по умолчанию http://localhost:8000)
ADMIN_KEY — значение переменной ADMIN_KEY из .env
"""
import sys
import os
import httpx
from docx import Document
API_URL = os.getenv("API_URL", "http://localhost:8000")
ADMIN_KEY = os.getenv("ADMIN_KEY", "")
def extract_names_from_docx(path: str) -> list[str]:
"""Извлекает непустые строки из параграфов и ячеек таблиц docx."""
doc = Document(path)
names = []
# Параграфы
for para in doc.paragraphs:
text = para.text.strip()
if text:
names.append(text)
# Таблицы (если список оформлен таблицей)
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
text = cell.text.strip()
if text and text not in names:
names.append(text)
return names
def import_organizations(names: list[str]) -> None:
if not ADMIN_KEY:
print("ОШИБКА: задайте ADMIN_KEY=... перед запуском")
print(" Пример: ADMIN_KEY=mykey python import_orgs.py МБОУ.docx")
sys.exit(1)
headers = {"X-Admin-Key": ADMIN_KEY, "Content-Type": "application/json"}
added = 0
skipped = 0
errors = 0
with httpx.Client(base_url=API_URL, headers=headers, timeout=30) as client:
for name in names:
try:
resp = client.post("/organizations/", json={"name_organization": name})
if resp.status_code == 201:
added += 1
print(f" [+] {name}")
elif resp.status_code == 400:
skipped += 1
print(f" [=] УЖЕ ЕСТЬ: {name}")
else:
errors += 1
print(f" [!] ОШИБКА {resp.status_code}: {name}{resp.text}")
except Exception as e:
errors += 1
print(f" [!] ОШИБКА соединения: {name}{e}")
print(f"\nГотово: добавлено {added}, пропущено {skipped}, ошибок {errors}")
def main():
if len(sys.argv) < 2:
print("Использование: python import_orgs.py <файл.docx>")
sys.exit(1)
docx_path = sys.argv[1]
if not os.path.exists(docx_path):
print(f"Файл не найден: {docx_path}")
sys.exit(1)
print(f"Читаю: {docx_path}")
names = extract_names_from_docx(docx_path)
print(f"Найдено строк: {len(names)}")
print()
# Показываем первые 10 для проверки
print("Первые 10 записей:")
for n in names[:10]:
print(f"{n}")
if len(names) > 10:
print(f" ... и ещё {len(names) - 10}")
print()
confirm = input("Начать импорт? (y/n): ").strip().lower()
if confirm != "y":
print("Отменено.")
sys.exit(0)
print()
import_organizations(names)
if __name__ == "__main__":
main()

View File

@@ -1,3 +1,4 @@
python-docx==1.2.0
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
import uuid
import io
from bd import make_engine
@@ -102,3 +103,106 @@ def delete_organization(org_id: str):
session.delete(org)
session.commit()
return {}
def _extract_names_from_docx(content: bytes) -> list[str]:
"""Извлекает уникальные непустые строки из docx.
Для таблиц берётся только ПЕРВЫЙ столбец каждой строки.
Параграфы вне таблиц используются как запасной источник (если таблиц нет).
"""
from docx import Document as DocxDocument
doc = DocxDocument(io.BytesIO(content))
names: list[str] = []
seen: set[str] = set()
def _add(text: str):
t = text.strip()
if t and t not in seen:
seen.add(t)
names.append(t)
# Если есть таблицы — берём только первый столбец
if doc.tables:
for table in doc.tables:
for row in table.rows:
if row.cells:
_add(row.cells[0].text)
else:
# Нет таблиц — берём параграфы
for para in doc.paragraphs:
_add(para.text)
return names
@router.post("/import/docx/preview", dependencies=[Depends(require_admin_key)])
async def preview_import_from_docx(file: UploadFile = File(...)):
"""Предпросмотр: возвращает список строк, которые будут импортированы, БЕЗ записи в БД.
Используйте перед /import/docx чтобы убедиться, что извлекаются нужные данные.
"""
if not file.filename or not file.filename.lower().endswith(".docx"):
raise HTTPException(status_code=400, detail="Ожидается файл .docx")
try:
from docx import Document as DocxDocument # noqa: F401
except ImportError:
raise HTTPException(status_code=500, detail="python-docx не установлен на сервере")
content = await file.read()
try:
names = _extract_names_from_docx(content)
except Exception:
raise HTTPException(status_code=400, detail="Не удалось открыть файл")
return {"total": len(names), "names": names}
@router.post("/import/docx", dependencies=[Depends(require_admin_key)])
async def import_organizations_from_docx(file: UploadFile = File(...)):
"""Массовый импорт организаций из .docx файла.
Каждая непустая строка (параграф или ячейка таблицы) становится отдельной организацией.
Уже существующие названия пропускаются без ошибки.
Перед использованием проверьте результат через /import/docx/preview.
"""
if not file.filename or not file.filename.lower().endswith(".docx"):
raise HTTPException(status_code=400, detail="Ожидается файл .docx")
try:
from docx import Document as DocxDocument # noqa: F401
except ImportError:
raise HTTPException(status_code=500, detail="python-docx не установлен на сервере")
content = await file.read()
try:
names = _extract_names_from_docx(content)
except Exception:
raise HTTPException(status_code=400, detail="Не удалось открыть файл. Убедитесь, что это корректный .docx")
if not names:
raise HTTPException(status_code=400, detail="В файле не найдено ни одной строки")
Session = get_session()
added: list[str] = []
skipped: list[str] = []
with Session() as session:
for name in names:
existing = session.query(Organization).filter(
Organization.name_organization == name
).first()
if existing:
skipped.append(name)
continue
org = Organization(name_organization=name)
session.add(org)
added.append(name)
session.commit()
return {
"added": len(added),
"skipped": len(skipped),
"added_names": added,
"skipped_names": skipped,
}

View File

@@ -170,6 +170,164 @@ class CastomDropdown(ft.Dropdown):
self.on_select = on_change
class SearchableDropdown(ft.Container):
"""Выпадающий список с поисковой строкой — замена CastomDropdown для больших наборов данных."""
def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None):
self._label_str = label
self._hint_str = hint_text
self._options: list = options or []
self.on_select = on_select
self._value = None
self._value_text = ft.Text(
hint_text,
color=colors.border,
size=s(14),
italic=True,
expand=True,
no_wrap=True,
overflow=ft.TextOverflow.ELLIPSIS,
)
super().__init__(
content=ft.Column(
spacing=s(2),
tight=True,
controls=[
ft.Text(
label,
color=colors.text_secondary,
size=s(13),
weight=ft.FontWeight.W_500,
),
ft.Row(
controls=[
self._value_text,
ft.Icon(ft.Icons.ARROW_DROP_DOWN, color=colors.text_secondary, size=s(20)),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
],
),
bgcolor=colors.surface,
border=ft.border.all(1, colors.border),
border_radius=s(10),
padding=ft.padding.symmetric(horizontal=s(14), vertical=s(12)),
on_click=self._open_dialog,
ink=True,
)
@property
def value(self):
return self._value
@value.setter
def value(self, val):
self._value = val
if val is None:
self._value_text.value = self._hint_str
self._value_text.color = colors.border
self._value_text.italic = True
else:
for opt in self._options:
if opt.key == val:
self._value_text.value = opt.text
self._value_text.color = colors.text_primary
self._value_text.italic = False
break
if self.page:
self.update()
@property
def options(self):
return self._options
@options.setter
def options(self, opts):
self._options = opts or []
@property
def hint_text(self):
return self._hint_str
@hint_text.setter
def hint_text(self, val):
self._hint_str = val
if self._value is None:
self._value_text.value = val
if self.page:
self.update()
def _open_dialog(self, e):
search_field = CastomTextField_input(
label="Поиск",
hint_text="Введите для поиска...",
prefix_icon=ft.Icons.SEARCH,
autofocus=True,
)
items_list = ft.ListView(spacing=0, expand=True)
def _build_items(query: str = ""):
q = query.lower()
items_list.controls = [
ft.ListTile(
title=ft.Text(opt.text, color=colors.text_primary, size=s(14)),
hover_color=colors.primary_light,
on_click=lambda ev, o=opt: _select(o),
)
for opt in self._options
if not q or q in opt.text.lower()
]
def _on_search(ev):
_build_items(search_field.value or "")
items_list.update()
def _select(option):
self._value = option.key
self._value_text.value = option.text
self._value_text.color = colors.text_primary
self._value_text.italic = False
dlg.open = False
self.page.update()
self.update()
if self.on_select:
self.on_select(type("_E", (), {"control": self})())
search_field.on_change = _on_search
_build_items()
dlg = ft.AlertDialog(
title=ft.Text(
self._label_str,
color=colors.text_primary,
size=s(16),
weight=ft.FontWeight.W_600,
),
content=ft.Container(
content=ft.Column(
controls=[
search_field,
ft.Divider(color=colors.border),
items_list,
],
spacing=s(8),
tight=True,
),
height=s(350),
width=s(400),
),
bgcolor=colors.surface,
shape=ft.RoundedRectangleBorder(radius=s(12)),
)
self.page.overlay.append(dlg)
dlg.open = True
self.page.update()
class CastomSwitch(ft.Switch):
def __init__(self, label, on_change):
super().__init__(

View File

@@ -21,19 +21,19 @@ class ProfilePage(ft.Column):
prefix_icon=ft.Icons.PERSON_OUTLINE,
)
self._org_dropdown = ds.CastomDropdown(
self._org_dropdown = ds.SearchableDropdown(
label="Организация",
hint_text="Загрузка...",
)
self._org_dropdown.on_select = self._on_org_change
self._group_dropdown = ds.CastomDropdown(
self._group_dropdown = ds.SearchableDropdown(
label="Группа",
hint_text="Загрузка...",
)
# ── Выбор темы ─────────────────────────────────────────────────
self._theme_dropdown = ds.CastomDropdown(
self._theme_dropdown = ds.SearchableDropdown(
label="Тема интерфейса",
hint_text="Выберите тему...",
options=[

View File

@@ -35,13 +35,13 @@ class RegView(ft.View):
can_reveal_password=True,
)
self._org_dropdown = ds.CastomDropdown(
self._org_dropdown = ds.SearchableDropdown(
label="Организация",
hint_text="Загрузка...",
)
self._org_dropdown.on_select = self._on_org_change
self._group_dropdown = ds.CastomDropdown(
self._group_dropdown = ds.SearchableDropdown(
label="Группа",
hint_text="Загрузка...",
)

BIN
МБОУ.docx Normal file

Binary file not shown.