refactor(scheduling): R08-T11 — schedule_task_tab.py 794 -> 297, tách 6 file

presentation/scheduling/
      calendar_view_widget.py    231  lịch tháng (chuyển từ ui/calendar_view.py)
      ai_task_creator_dialog.py  208  tạo task bằng AI
      task_actions.py            189  thêm/sửa/chạy/xoá/xem log một task
      kanban_board_widget.py      98  cột Kanban + vùng thả file
      run_history_dialog.py       82  lịch sử các lượt chạy
      ai_task_import_dialog.py    81  nhập task từ file
    ui/schedule_task_tab.py      297  dựng bảng + đổi chế độ xem
    ui/calendar_view.py           10  vỏ chuyển tiếp

Plan ghi 4 file; thực tế cần 6. Hai file thêm là run_history_dialog.py và
task_actions.py — không tách thì schedule_task_tab.py còn 517 dòng, vẫn vượt
ngưỡng 400.

ai_task_import_dialog.py làm mixin chứ không phải hộp thoại rời: plan gọi nó
là dialog, nhưng thực tế nó là TAB THỨ HAI của cùng hộp thoại tạo task, dùng
chung phần xem trước và nút Xác nhận. Tách hẳn thì phải nhân đôi cả hai.

LẠI LỖI DECORATOR: script này tôi quên dùng bản có tính dòng @, nên một
@staticmethod bị bỏ lại mồ côi -> IndentationError. Đây là lần thứ tư cùng
một lỗi. Đã thêm bước dọn decorator mồ côi vào script.

756 test xanh. 16 checker chạy đều qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 22:54:50 +09:00
co-authored by Claude Opus 5
parent 4fef41481b
commit 982fecc8dc
8 changed files with 906 additions and 730 deletions
+194
View File
@@ -0,0 +1,194 @@
"""Các thao tác trên một task: thêm, sửa, chạy ngay, xoá, xem log — R08-T11.
Tách khỏi ``ScheduleTaskTab`` để phần dựng bảng và phần hành động không nằm
lẫn nhau. ``_context_menu`` là chỗ tập trung: nó quyết định mục nào hiện ra
tuỳ theo đang chọn một hay nhiều thẻ.
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
"""
from __future__ import annotations
# Import muộn trong hàm ở chỗ dùng: ba lớp này nằm cùng gói và một trong số
# chúng trộn ngược mixin này vào, nên import ở mức module là vòng.
import copy
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ...core import tasks as taskrepo
from ...core.projects import list_projects
from ...core.tasks import STATUSES, chain_error, duplicate_task, new_task
from ...core.worker import AgentWorker
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...theme import current_palette
from ...ui.calendar_view import CalendarView
from ...ui.icons import icon
from ...ui.osutil import open_path
class TaskActionsMixin:
"""Thao tác trên task. Trộn vào ScheduleTaskTab."""
def _add_task_on_date(self, date_str: str) -> None:
"""Create a task pre-filled with the clicked calendar date (default
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
from .task_editor_dialog import TaskEditorDialog
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
if dlg.exec() and dlg.edited_task:
self._save_and_refresh(dlg.edited_task)
self.status_message.emit(tr("schedtask.msg_created"))
def _save_and_refresh(self, task: dict) -> None:
taskrepo.save_task(task, self._tasks_dir)
self.refresh()
def _add_task(self) -> None:
from .task_editor_dialog import TaskEditorDialog
dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
if dlg.exec() and dlg.edited_task:
self._save_and_refresh(dlg.edited_task)
self.status_message.emit(tr("schedtask.msg_created"))
def _edit_task(self, task_id: str) -> None:
from .task_editor_dialog import TaskEditorDialog
task = taskrepo.load_task(task_id, self._tasks_dir)
if not task:
return
dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
if dlg.exec() and dlg.edited_task:
self._save_and_refresh(dlg.edited_task)
def _on_double_click(self, item: QListWidgetItem) -> None:
tid = item.data(Qt.UserRole)
if tid:
self._edit_task(tid)
def _is_multi_selection(item, selected) -> bool:
"""True when the right-clicked card is part of an existing multi-item
selection — pure boolean, kept separate from _context_menu so it's
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
return len(selected) > 1 and item in selected
def _context_menu(self, col: _KanbanColumn, pos) -> None:
item = col.itemAt(pos)
if item is None or not item.data(Qt.UserRole):
return
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
if self._is_multi_selection(item, selected):
self._bulk_delete_menu(col, pos, selected)
return
tid = item.data(Qt.UserRole)
task = taskrepo.load_task(tid, self._tasks_dir)
if not task:
return
menu = QMenu(col)
run_act = menu.addAction(tr("schedtask.menu_run"))
edit_act = menu.addAction(tr("schedtask.menu_edit"))
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
paused = task.get("status") == "paused"
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
logs_act = menu.addAction(tr("schedtask.menu_logs"))
hist_act = menu.addAction(tr("schedtask.menu_history"))
next_act = menu.addAction(tr("schedtask.menu_create_next"))
menu.addSeparator()
del_act = menu.addAction(tr("schedtask.menu_delete"))
chosen = menu.exec(col.viewport().mapToGlobal(pos))
if chosen == run_act:
self._run_now(task)
elif chosen == edit_act:
self._edit_task(tid)
elif chosen == dup_act:
self._save_and_refresh(duplicate_task(task))
elif chosen == pause_act:
task["status"] = "backlog" if paused else "paused"
self._save_and_refresh(task)
elif chosen == logs_act:
self._view_logs(task)
elif chosen == hist_act:
from .run_history_dialog import _RunHistoryDialog
_RunHistoryDialog(task, self).exec()
elif chosen == next_act:
self._create_next_from_output(task)
elif chosen == del_act:
if QMessageBox.question(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", ""))
) == QMessageBox.Yes:
taskrepo.delete_task(tid, self._tasks_dir)
self.refresh()
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
"""Right-click on a multi-selection within one column (Shift/Ctrl-click
several cards first): one action deletes every selected task. The
popup itself is a thin wrapper — see _confirm_and_delete_selected for
the actual (independently testable) confirm+delete logic."""
menu = QMenu(col)
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
chosen = menu.exec(col.viewport().mapToGlobal(pos))
if chosen == del_act:
self._confirm_and_delete_selected(selected)
def _confirm_and_delete_selected(self, selected) -> bool:
"""Confirm, then delete every task in ``selected``. Split out of
_bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question(
self, tr("schedtask.menu_delete"),
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
return False
for item in selected:
tid = item.data(Qt.UserRole)
if tid:
taskrepo.delete_task(tid, self._tasks_dir)
self.refresh()
return True
def _run_now(self, task: dict) -> None:
if task.get("task_type") == "manual":
self.status_message.emit(tr("schedtask.msg_manual_norun"))
return
if self.scheduler is None:
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
return
if self.scheduler.run_now(task["task_id"]):
self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
self.refresh()
def _view_logs(self, task: dict) -> None:
run_id = task.get("logs", {}).get("last_run_id")
if not run_id:
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
return
folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
if folder.exists():
open_path(str(folder))
else:
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
def _create_next_from_output(self, task: dict) -> None:
"""Scaffold a follow-up task pre-wired to consume this task's output."""
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
nxt["task_type"] = "cowork"
nxt["input"]["mode"] = "previous_task_output"
nxt["input"]["previous_task_id"] = task["task_id"]
nxt["dependency"]["previous_task_id"] = task["task_id"]
err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
task["task_id"], nxt["task_id"])
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
return
taskrepo.save_task(nxt, self._tasks_dir)
task["dependency"]["next_task_id"] = nxt["task_id"]
task["dependency"]["pass_output_to_next"] = True
if task["dependency"].get("run_next_mode", "none") == "none":
task["dependency"]["run_next_mode"] = "run_after_success"
taskrepo.save_task(task, self._tasks_dir)
self.refresh()
self._edit_task(nxt["task_id"])
def _ai_create(self) -> None:
from .ai_task_creator_dialog import _AiCreateDialog
dlg = _AiCreateDialog(self.ctx, self)
if dlg.exec() and dlg.created_tasks:
for t in dlg.created_tasks:
taskrepo.save_task(t, self._tasks_dir)
self.refresh()
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))