360 lines
16 KiB
Python
360 lines
16 KiB
Python
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()
|