"""Schedule Task — Excel template export + task import. ``export_template(path)`` writes an .xlsx the user fills in; ``import_tasks(path)`` turns its rows back into task dicts (NOT yet saved — the Import tab previews them and only saves after the user confirms). The "Depends on" column references OTHER ROWS' Title values (semicolon- separated) so a whole parallel fan-in graph can be described in one file: those references resolve to real task ids after all rows are created. """ from __future__ import annotations from pathlib import Path from typing import Any, Dict, List from .tasks import ( PRIORITIES, REPEAT_TYPES, TASK_TYPES, new_task, parse_run_at, ) HEADERS = [ "Title", "Description", "Type", "Priority", "Script command", "Schedule enabled", "Run at (YYYY-MM-DD HH:MM)", "Repeat", "Cron expression", "Depends on (titles, ;-separated)", "Use previous output as input", "Requires approval", # Newer optional columns — blank falls back to the app's Settings default. "Provider", "Model", "Skill (slug)", "Reminder (none/teams/outlook)", "Reminder email", ] _EXAMPLE = [ "Generate CAE report", "Đọc dữ liệu CAE mới và tạo báo cáo markdown", "co4e_code", "high", "", "yes", "2026-07-06 09:00", "weekly", "", "", "no", "no", "", "", "", "none", "", ] _EXAMPLE2 = [ "Draft team email", "Soạn email draft từ báo cáo", "cowork", "medium", "", "no", "", "none", "", "Generate CAE report", "yes", "no", "", "", "", "outlook", "boss@fpt.com", ] _TRUE = {"yes", "y", "true", "1", "x", "có", "co"} def _bool(value) -> bool: """Đọc giá trị đúng/sai từ ô Excel, chấp nhận nhiều cách ghi.""" return str(value or "").strip().lower() in _TRUE def _clamp(value, allowed, default): """Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định.""" v = str(value or "").strip().lower() return v if v in allowed else default def export_template(path: str | Path) -> Path: """Write the fill-in template (headers + 2 linked example rows + notes). Every column whose value is one of a fixed set (Type, Priority, Repeat, the yes/no flags, Reminder channel) gets an in-cell DROPDOWN so the user just picks a valid value instead of typing it — fewer import errors.""" from openpyxl import Workbook from openpyxl.styles import Font, PatternFill from openpyxl.worksheet.datavalidation import DataValidation wb = Workbook() ws = wb.active ws.title = "Tasks" ws.append(HEADERS) for cell in ws[1]: cell.font = Font(bold=True, color="FFFFFF") cell.fill = PatternFill("solid", fgColor="F37021") ws.append(_EXAMPLE) ws.append(_EXAMPLE2) for col, header in enumerate(HEADERS, 1): ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = \ max(18, len(header) + 2) # In-cell dropdowns for the list/enum columns (1-indexed to HEADERS order). _YESNO = ["yes", "no"] dropdowns = { 3: list(TASK_TYPES), # Type 4: list(PRIORITIES), # Priority 6: _YESNO, # Schedule enabled 8: list(REPEAT_TYPES), # Repeat 11: _YESNO, # Use previous output as input 12: _YESNO, # Requires approval 16: ["none", "teams", "outlook"], # Reminder channel } for col_idx, options in dropdowns.items(): letter = ws.cell(row=1, column=col_idx).column_letter dv = DataValidation(type="list", formula1='"' + ",".join(options) + '"', allow_blank=True, showDropDown=False) dv.prompt = "Chọn một giá trị từ danh sách" dv.error = "Giá trị không hợp lệ — chọn từ danh sách." ws.add_data_validation(dv) dv.add(f"{letter}2:{letter}500") # apply to the fill-in rows notes = wb.create_sheet("README") notes.append(["Điền mỗi task một dòng trong sheet 'Tasks' (2 dòng ví dụ có sẵn — xoá hoặc sửa)."]) notes.append([f"Type: {', '.join(TASK_TYPES)} · Priority: {', '.join(PRIORITIES)}"]) notes.append([f"Repeat: {', '.join(REPEAT_TYPES)} (cron → điền thêm cột Cron expression)"]) notes.append(["Depends on: tên (Title) các dòng khác, cách nhau dấu ';' — task này chỉ chạy khi các task đó Done."]) notes.append(["Use previous output as input = yes → output các task Depends-on tự thành input task này."]) path = Path(path) wb.save(str(path)) return path def task_from_cells(cells: List[Any]): """Turn one row (a list in HEADERS order) into ``(task, depends_titles)`` or ``(None, None)`` for a blank row. Shared by the Excel and CSV importers so both apply exactly the same field mapping and enum fallbacks.""" cells = list(cells) + [None] * (len(HEADERS) - len(cells)) title = str(cells[0] or "").strip() if not title: return None, None t = new_task(title) t["description"] = str(cells[1] or "").strip() t["task_type"] = _clamp(cells[2], TASK_TYPES, "manual") t["priority"] = _clamp(cells[3], PRIORITIES, "medium") t["script_command"] = str(cells[4] or "").strip() t["schedule"]["enabled"] = _bool(cells[5]) run_at = str(cells[6] or "").strip()[:16] t["schedule"]["run_at"] = run_at if parse_run_at(run_at) else None t["schedule"]["repeat_type"] = _clamp(cells[7], REPEAT_TYPES, "none") t["schedule"]["cron_expression"] = str(cells[8] or "").strip() or None if t["schedule"]["enabled"] and (t["schedule"]["run_at"] or t["schedule"]["repeat_type"] == "cron"): t["status"] = "scheduled" else: t["schedule"]["enabled"] = False # no usable time → stays Backlog if _bool(cells[10]): t["input"]["mode"] = "previous_task_output" t["execution"]["requires_approval"] = _bool(cells[11]) # Newer optional columns (blank → keep new_task defaults / Settings model). t["provider"] = str(cells[12] or "").strip() t["model"] = str(cells[13] or "").strip() t["skill_slug"] = str(cells[14] or "").strip() channel = str(cells[15] or "").strip().lower() t["execution"]["notify_channel"] = channel if channel in ("teams", "outlook") else "none" t["execution"]["notify_email"] = str(cells[16] or "").strip() if t["execution"]["notify_channel"] != "none": t["execution"]["notify_on_complete"] = True t["execution"]["notify_on_error"] = True depends = [s.strip() for s in str(cells[9] or "").split(";") if s.strip()] return t, depends def resolve_depends(tasks: List[Dict[str, Any]], depends_raw: List[List[str]]) -> None: """Resolve each row's "Depends on" titles → ids (rows in this same file only) and wire output-passing for previous-output inputs. Mutates in place.""" by_title = {t["title"]: t for t in tasks} for t, wants in zip(tasks, depends_raw): ids = [by_title[w]["task_id"] for w in wants if w in by_title and by_title[w] is not t] t["dependency"]["depends_on"] = ids if ids and t["input"]["mode"] == "previous_task_output": for pid in ids: # predecessors pass their output forward by_id_task = next(x for x in tasks if x["task_id"] == pid) by_id_task["dependency"]["pass_output_to_next"] = True def import_tasks(path: str | Path) -> List[Dict[str, Any]]: """Parse a filled template into task dicts with dependencies resolved. Raises ValueError with a human message on unusable files; skips blank rows; bad enum cells fall back to safe defaults instead of failing.""" from openpyxl import load_workbook try: wb = load_workbook(str(path), data_only=True) except Exception as exc: # noqa: BLE001 raise ValueError(f"Cannot read Excel file: {exc}") from exc ws = wb["Tasks"] if "Tasks" in wb.sheetnames else wb.active rows = list(ws.iter_rows(min_row=2, values_only=True)) tasks: List[Dict[str, Any]] = [] depends_raw: List[List[str]] = [] for row in rows: t, depends = task_from_cells(list(row)) if t is None: continue depends_raw.append(depends) tasks.append(t) if not tasks: raise ValueError("No task rows found — fill in the 'Tasks' sheet first.") resolve_depends(tasks, depends_raw) return tasks