fix org
This commit is contained in:
110
import_orgs.py
Normal file
110
import_orgs.py
Normal 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()
|
||||
@@ -1,3 +1,4 @@
|
||||
python-docx==1.2.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.12.1
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
158
web/designer.py
158
web/designer.py
@@ -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__(
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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="Загрузка...",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user