import flet as ft import designer as d import api_client as api from views.dashboard import _admin_appbar, _sidebar class VkSourcesView: def __init__(self, page: ft.Page): self.page = page self._sources: list = [] self._api_ok = False self._editing_id: str | None = None # ID источника в режиме редактирования # Статус бейдж self._status_row = ft.Row(spacing=6) # Уведомление (добавляется в page.overlay при load) self._snack = ft.SnackBar(content=ft.Text(""), open=False) # Таблица источников self._table = ft.DataTable( columns=[ ft.DataColumn(ft.Text("Группа", weight=ft.FontWeight.W_600, size=13)), ft.DataColumn(ft.Text("ID", weight=ft.FontWeight.W_600, size=13)), ft.DataColumn(ft.Text("Последний импорт", weight=ft.FontWeight.W_600, size=13)), ft.DataColumn(ft.Text("Статус", weight=ft.FontWeight.W_600, size=13)), ft.DataColumn(ft.Text("Действия", weight=ft.FontWeight.W_600, size=13)), ], rows=[], border=ft.border.all(1, d.DIVIDER), border_radius=8, vertical_lines=ft.BorderSide(1, d.DIVIDER), heading_row_color=ft.colors.with_opacity(0.04, ft.colors.BLACK), data_row_max_height=56, column_spacing=16, ) # Форма добавления / редактирования self._form_gid = ft.TextField( label="ID группы (числовой)", hint_text="95503797", width=220, dense=True, ) self._form_gname = ft.TextField( label="Название группы", hint_text="ЮУрГК", width=240, dense=True, ) self._form_enabled = ft.Checkbox(label="Активна", value=True) self._form_save_btn = ft.ElevatedButton( "Добавить", icon=ft.icons.ADD, bgcolor=d.PRIMARY, color=ft.colors.WHITE, on_click=lambda e: self.page.run_task(self._save_source), ) self._form_cancel_btn = ft.TextButton( "Отмена", visible=False, on_click=lambda e: self._cancel_edit(), ) # ── Build ────────────────────────────────────────────────────────────────── def build_page_view(self) -> ft.View: token_info = ft.Container( content=ft.Column([ self._status_row, ft.Text( "Без токена VK API — HTML-парсинг не работает (ВК перешёл на JS-рендеринг).\n" "Получи бесплатный сервисный ключ: vk.com/dev → Мои приложения → Создать → " "Standalone → вкладка «Настройки» → «Сервисный ключ доступа».\n" "Затем добавь в .env: VK_ACCESS_TOKEN=<ключ>", size=12, color=d.TEXT_SECONDARY, selectable=True, ), ], spacing=6), bgcolor=ft.colors.with_opacity(0.04, ft.colors.BLACK), border_radius=8, padding=14, border=ft.border.all(1, d.DIVIDER), ) form = ft.Container( content=ft.Column([ d.headline("Добавить / редактировать группу", size=15), ft.Row( [self._form_gid, self._form_gname, self._form_enabled], spacing=10, wrap=True, ), ft.Row( [self._form_save_btn, self._form_cancel_btn], spacing=10, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), ], spacing=10), bgcolor=d.SURFACE, border_radius=10, padding=16, border=ft.border.all(1, d.DIVIDER), ) import_btns = ft.Row([ ft.ElevatedButton( "Импорт новых постов", icon=ft.icons.SYNC, bgcolor=d.PRIMARY, color=ft.colors.WHITE, tooltip="Загрузить новые посты со всех активных групп", on_click=lambda e: self.page.run_task(self._import_recent), ), ft.ElevatedButton( "Импорт за год (все)", icon=ft.icons.HISTORY, bgcolor=d.WARNING, color=ft.colors.WHITE, tooltip="Пагинированный импорт за 365 дней. Запускается в фоне.", on_click=lambda e: self.page.run_task(self._import_history_all), ), ], spacing=10, wrap=True) content = ft.Column([ d.headline("ВКонтакте — источники", size=22), token_info, import_btns, ft.Divider(height=4, color="transparent"), form, ft.Divider(height=4, color="transparent"), d.headline("Группы в базе", size=15), ft.Container( content=self._table, border_radius=8, clip_behavior=ft.ClipBehavior.HARD_EDGE, ), ], scroll=ft.ScrollMode.AUTO, spacing=12, expand=True) return ft.View( route="/admin/vk", bgcolor=d.BACKGROUND, appbar=_admin_appbar(self.page, "ВКонтакте"), controls=[ ft.Row([ _sidebar(self.page, active="/admin/vk"), ft.Container(content=content, expand=True, padding=24), ], expand=True, spacing=0), ], ) # ── Load ─────────────────────────────────────────────────────────────────── async def load(self): self.page.overlay.append(self._snack) token = self.page.session.get("token") status = await api.admin_vk_status(token) self._api_ok = status.get("configured", False) self._status_row.controls = [ ft.Icon( ft.icons.CHECK_CIRCLE if self._api_ok else ft.icons.WARNING_AMBER_ROUNDED, color=d.SUCCESS if self._api_ok else d.WARNING, size=18, ), ft.Text( "API-токен подключён — VK API активен" if self._api_ok else "Токен не задан — парсинг недоступен", size=13, weight=ft.FontWeight.W_600, color=d.SUCCESS if self._api_ok else d.WARNING, ), ] self._sources = await api.admin_vk_list_sources(token) self._render_table() self.page.update() # ── Table ────────────────────────────────────────────────────────────────── def _render_table(self): if not self._sources: self._table.rows = [ ft.DataRow(cells=[ ft.DataCell(ft.Text("Групп пока нет", color=d.TEXT_SECONDARY, italic=True)), ft.DataCell(ft.Text("")), ft.DataCell(ft.Text("")), ft.DataCell(ft.Text("")), ft.DataCell(ft.Text("")), ]) ] return self._table.rows = [self._build_row(s) for s in self._sources] def _build_row(self, src: dict) -> ft.DataRow: last_run = src.get("last_run") if last_run: try: from datetime import datetime dt = datetime.fromisoformat(last_run.replace("Z", "+00:00")) last_run_str = dt.strftime("%d.%m.%Y %H:%M") except Exception: last_run_str = last_run[:16] else: last_run_str = "никогда" enabled = src.get("enabled", True) return ft.DataRow( cells=[ ft.DataCell(ft.Text(src["group_name"], size=13, weight=ft.FontWeight.W_500)), ft.DataCell(ft.Text(src["group_id"], size=13, color=d.TEXT_SECONDARY, selectable=True)), ft.DataCell(ft.Text(last_run_str, size=12, color=d.TEXT_SECONDARY)), ft.DataCell( ft.Container( content=ft.Text( "Активна" if enabled else "Отключена", size=11, color=ft.colors.WHITE, ), bgcolor=d.SUCCESS if enabled else d.TEXT_SECONDARY, border_radius=4, padding=ft.padding.symmetric(horizontal=8, vertical=3), ) ), ft.DataCell( ft.Row([ ft.IconButton( ft.icons.EDIT_OUTLINED, icon_size=18, icon_color=d.PRIMARY, tooltip="Редактировать", on_click=lambda e, s=src: self._start_edit(s), ), ft.IconButton( ft.icons.TOGGLE_ON if enabled else ft.icons.TOGGLE_OFF, icon_size=22, icon_color=d.SUCCESS if enabled else d.TEXT_SECONDARY, tooltip="Вкл / Выкл", on_click=lambda e, s=src: self.page.run_task(self._toggle, s), ), ft.IconButton( ft.icons.HISTORY, icon_size=18, icon_color=d.WARNING, tooltip="Импорт за год для этой группы", on_click=lambda e, s=src: self.page.run_task( self._import_history_one, s["id"], s["group_name"] ), ), ft.IconButton( ft.icons.DELETE_OUTLINE, icon_size=18, icon_color=d.ERROR, tooltip="Удалить группу", on_click=lambda e, s=src: self.page.run_task(self._delete, s["id"]), ), ], spacing=0), ), ] ) # ── Edit form helpers ────────────────────────────────────────────────────── def _start_edit(self, src: dict): self._editing_id = src["id"] self._form_gid.value = src["group_id"] self._form_gname.value = src["group_name"] self._form_enabled.value = src.get("enabled", True) self._form_save_btn.text = "Сохранить" self._form_save_btn.icon = ft.icons.SAVE self._form_cancel_btn.visible = True self.page.update() def _cancel_edit(self): self._editing_id = None self._form_gid.value = "" self._form_gname.value = "" self._form_enabled.value = True self._form_save_btn.text = "Добавить" self._form_save_btn.icon = ft.icons.ADD self._form_cancel_btn.visible = False self.page.update() # ── CRUD actions ─────────────────────────────────────────────────────────── async def _save_source(self): gid = self._form_gid.value.strip() gname = self._form_gname.value.strip() if not gid or not gname: self._notify("Заполни ID и название", d.ERROR) return token = self.page.session.get("token") if self._editing_id: result = await api.admin_vk_update_source( token, self._editing_id, gid, gname, self._form_enabled.value ) self._notify("Группа обновлена" if result else "Ошибка обновления", d.SUCCESS if result else d.ERROR) else: result = await api.admin_vk_add_source(token, gid, gname, self._form_enabled.value) if result and "error" not in result: self._notify(f"Группа «{gname}» добавлена", d.SUCCESS) else: err = result.get("error") if result else "Ошибка запроса" self._notify(str(err), d.ERROR) return self._cancel_edit() self._sources = await api.admin_vk_list_sources(token) self._render_table() self.page.update() async def _toggle(self, src: dict): token = self.page.session.get("token") result = await api.admin_vk_update_source( token, src["id"], src["group_id"], src["group_name"], not src["enabled"] ) if result: self._sources = await api.admin_vk_list_sources(token) self._render_table() self.page.update() async def _delete(self, source_id: str): token = self.page.session.get("token") ok = await api.admin_vk_delete_source(token, source_id) self._notify("Группа удалена" if ok else "Ошибка удаления", d.TEXT_SECONDARY if ok else d.ERROR) self._sources = await api.admin_vk_list_sources(token) self._render_table() self.page.update() async def _import_recent(self): self._notify("Запускаю импорт новых постов…", d.PRIMARY) token = self.page.session.get("token") result = await api.admin_vk_import(token) msg = result.get("message", "Запущен") if result else "Ошибка" self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR) async def _import_history_all(self): self._notify("Запускаю исторический импорт в фоне…", d.WARNING) token = self.page.session.get("token") result = await api.admin_vk_import_history(token) msg = result.get("message", "Запущен") if result else "Ошибка" self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR) async def _import_history_one(self, source_id: str, name: str): self._notify(f"Запускаю год для «{name}»…", d.WARNING) token = self.page.session.get("token") result = await api.admin_vk_import_history(token, source_id=source_id) msg = result.get("message", "Запущен") if result else "Ошибка" self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR) def _notify(self, text: str, color: str = d.SUCCESS): self._snack.content = ft.Text(text, color=ft.colors.WHITE) self._snack.bgcolor = color self._snack.open = True self.page.update()