from datetime import date from io import BytesIO from uuid import UUID from flask import ( abort, flash, redirect, render_template, request, send_file, url_for, ) from flask_login import current_user, login_required from app.core.authorization import TASK_EDITOR_ROLES, roles_required from . import bp from .forms import ( ActionForm, BackupImportForm, FocusSettingsForm, MarkdownImportForm, STATUS_LABELS, SubtaskForm, TaskForm, ) from .models import TASK_STATUSES from .services import ( TaskNotFoundError, add_subtask, calculate_forgiving_streak, clear_completed_tasks, complete_task, configure_focus, create_task, delete_task, get_task, list_tasks, list_today_tasks, toggle_chunk_session, toggle_subtask, undo_clear_completed, update_task, ) from .transfer import ( TransferValidationError, export_backup, import_backup, import_markdown, ) def _owned_task_or_404(task_id: int): try: return get_task(task_id, current_user.id) except TaskNotFoundError: abort(404) @bp.get("/") @login_required def index(): selected_status = request.args.get("status") or None if selected_status not in {None, *TASK_STATUSES}: abort(400) tasks = list_tasks(current_user.id, selected_status) return render_template( "tasks/list.html", tasks=tasks, selected_status=selected_status, status_labels=STATUS_LABELS, ) @bp.get("/today") @login_required def today(): tasks = list_today_tasks(current_user.id) return render_template("tasks/today.html", tasks=tasks) def _focus_settings_form(task): chunks = ", ".join( str(session.get("minutes", 0)) if isinstance(session, dict) else str(session) for session in task.chunk_sessions ) return FocusSettingsForm( timer_minutes=task.timer_seconds // 60 or None, context=task.context, chunk_plan=chunks, ) def _render_focus(task=None, *, celebrate: bool = False): return render_template( "tasks/focus.html", task=task, celebrate=celebrate, focus_form=_focus_settings_form(task) if task else None, action_form=ActionForm(), streak=calculate_forgiving_streak(current_user.id), ) @bp.get("/focus") @login_required def focus(): tasks = list_today_tasks(current_user.id, limit=1) return _render_focus( tasks[0] if tasks else None, celebrate=request.args.get("celebrate") == "1", ) @bp.get("//focus") @login_required def focus_task(task_id: int): task = _owned_task_or_404(task_id) return _render_focus(task) @bp.route("/new", methods=["GET", "POST"]) @login_required @roles_required(*TASK_EDITOR_ROLES) def create(): form = TaskForm() if form.validate_on_submit(): task = create_task( current_user.id, title=form.title.data, description=form.description.data, status=form.status.data, priority=form.priority.data, due_date=form.due_date.data, ) flash("Task captured.", "success") return redirect(url_for("tasks.detail", task_id=task.id)) return render_template("tasks/form.html", form=form, heading="New task") @bp.get("/") @login_required def detail(task_id: int): task = _owned_task_or_404(task_id) return render_template( "tasks/detail.html", task=task, subtask_form=SubtaskForm(), action_form=ActionForm(), ) @bp.route("//edit", methods=["GET", "POST"]) @login_required @roles_required(*TASK_EDITOR_ROLES) def edit(task_id: int): task = _owned_task_or_404(task_id) form = TaskForm(obj=task) if form.validate_on_submit(): update_task( task, title=form.title.data, description=form.description.data, status=form.status.data, priority=form.priority.data, due_date=form.due_date.data, ) flash("Task updated.", "success") return redirect(url_for("tasks.detail", task_id=task.id)) return render_template("tasks/form.html", form=form, heading="Edit task") @bp.post("//delete") @login_required @roles_required(*TASK_EDITOR_ROLES) def delete(task_id: int): task = _owned_task_or_404(task_id) form = ActionForm() if not form.validate_on_submit(): abort(400) delete_task(task) flash("Task deleted.", "info") return redirect(url_for("tasks.index")) @bp.post("//subtasks") @login_required @roles_required(*TASK_EDITOR_ROLES) def create_subtask(task_id: int): task = _owned_task_or_404(task_id) form = SubtaskForm() if form.validate_on_submit(): add_subtask(task, form.title.data) flash("Small step added.", "success") else: for errors in form.errors.values(): for error in errors: flash(error, "error") return redirect(url_for("tasks.detail", task_id=task.id)) @bp.post("//subtasks//toggle") @login_required @roles_required(*TASK_EDITOR_ROLES) def toggle_subtask_status(task_id: int, subtask_id: int): task = _owned_task_or_404(task_id) form = ActionForm() if not form.validate_on_submit(): abort(400) try: toggle_subtask(task, subtask_id) except TaskNotFoundError: abort(404) return redirect(url_for("tasks.detail", task_id=task.id)) @bp.post("//focus-settings") @login_required @roles_required(*TASK_EDITOR_ROLES) def update_focus_settings(task_id: int): task = _owned_task_or_404(task_id) form = FocusSettingsForm() if form.validate_on_submit(): configure_focus( task, timer_minutes=form.timer_minutes.data, context=form.context.data, chunk_minutes=form.parsed_chunks(), ) flash("Focus setup saved.", "success") else: for errors in form.errors.values(): for error in errors: flash(error, "error") return redirect(url_for("tasks.focus_task", task_id=task.id)) @bp.post("//chunks//toggle") @login_required @roles_required(*TASK_EDITOR_ROLES) def toggle_focus_chunk(task_id: int, chunk_index: int): task = _owned_task_or_404(task_id) form = ActionForm() if not form.validate_on_submit(): abort(400) try: toggle_chunk_session(task, chunk_index) except TaskNotFoundError: abort(404) return redirect(url_for("tasks.focus_task", task_id=task.id)) @bp.post("//complete") @login_required @roles_required(*TASK_EDITOR_ROLES) def complete(task_id: int): task = _owned_task_or_404(task_id) form = ActionForm() if not form.validate_on_submit(): abort(400) completed_title = task.title complete_task(task) flash(f"You completed {completed_title}. That counts.", "success") return redirect(url_for("tasks.focus", celebrate=1)) @bp.get("/transfer") @login_required def transfer(): undo_batch = None raw_undo = request.args.get("undo") if raw_undo: try: undo_batch = UUID(raw_undo) except ValueError: abort(400) return render_template( "tasks/transfer.html", markdown_form=MarkdownImportForm(), backup_form=BackupImportForm(), action_form=ActionForm(), undo_batch=undo_batch, ) @bp.post("/import/markdown") @login_required @roles_required(*TASK_EDITOR_ROLES) def import_markdown_file(): form = MarkdownImportForm() if not form.validate_on_submit(): _flash_form_errors(form) return redirect(url_for("tasks.transfer")) try: count = import_markdown(current_user.id, form.markdown_file.data.read()) except TransferValidationError as exc: flash(str(exc), "error") else: flash(f"Imported {count} tasks.", "success") return redirect(url_for("tasks.transfer")) @bp.get("/export/json") @login_required def export_json(): content = export_backup(current_user) return send_file( BytesIO(content), mimetype="application/json", as_attachment=True, download_name=f"steady-backup-{date.today().isoformat()}.json", max_age=0, ) @bp.post("/import/json") @login_required @roles_required(*TASK_EDITOR_ROLES) def import_json(): form = BackupImportForm() if not form.validate_on_submit(): _flash_form_errors(form) return redirect(url_for("tasks.transfer")) try: count = import_backup( current_user, form.backup_file.data.read(), replace_existing=form.replace_existing.data, ) except TransferValidationError as exc: flash(str(exc), "error") else: action = "Restored" if form.replace_existing.data else "Merged" flash(f"{action} {count} tasks from backup.", "success") return redirect(url_for("tasks.transfer")) @bp.post("/completed/clear") @login_required @roles_required(*TASK_EDITOR_ROLES) def clear_completed(): form = ActionForm() if not form.validate_on_submit(): abort(400) batch_id, count = clear_completed_tasks(current_user.id) if batch_id is None: flash("There are no completed tasks to clear.", "info") return redirect(url_for("tasks.transfer")) flash(f"Cleared {count} completed tasks. You can undo this below.", "info") return redirect(url_for("tasks.transfer", undo=batch_id)) @bp.post("/completed/undo/") @login_required @roles_required(*TASK_EDITOR_ROLES) def undo_completed_clear(batch_id): form = ActionForm() if not form.validate_on_submit(): abort(400) count = undo_clear_completed(current_user.id, str(batch_id)) if count: flash(f"Restored {count} completed tasks.", "success") else: flash("That clear operation is no longer available to undo.", "info") return redirect(url_for("tasks.transfer")) def _flash_form_errors(form) -> None: for errors in form.errors.values(): for error in errors: flash(error, "error")