diff --git a/import_orgs.py b/import_orgs.py new file mode 100644 index 0000000..8fd4ee1 --- /dev/null +++ b/import_orgs.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 6537eba..c43e33f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +python-docx==1.2.0 annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.12.1 diff --git a/route/organizations_crud.py b/route/organizations_crud.py index c908dc9..d96f8e9 100644 --- a/route/organizations_crud.py +++ b/route/organizations_crud.py @@ -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, + } diff --git a/web/designer.py b/web/designer.py index a75e20c..88fe9e8 100644 --- a/web/designer.py +++ b/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__( diff --git a/web/views/content_users/profile_page.py b/web/views/content_users/profile_page.py index a2d8dfe..83323e4 100644 --- a/web/views/content_users/profile_page.py +++ b/web/views/content_users/profile_page.py @@ -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=[ diff --git a/web/views/reg_view.py b/web/views/reg_view.py index efaa5d3..b72dfe0 100644 --- a/web/views/reg_view.py +++ b/web/views/reg_view.py @@ -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="Загрузка...", ) diff --git a/МБОУ.docx b/МБОУ.docx new file mode 100644 index 0000000..4ce6931 Binary files /dev/null and b/МБОУ.docx differ