384 lines
10 KiB
Python
384 lines
10 KiB
Python
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_today_tasks,
|
|
paginate_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)
|
|
raw_page = request.args.get("page", "1")
|
|
if not raw_page.isdigit() or int(raw_page) < 1:
|
|
abort(400)
|
|
pagination = paginate_tasks(
|
|
current_user.id,
|
|
status=selected_status,
|
|
page=int(raw_page),
|
|
)
|
|
if pagination.total and pagination.page > pagination.pages:
|
|
abort(404)
|
|
return render_template(
|
|
"tasks/list.html",
|
|
tasks=pagination.items,
|
|
pagination=pagination,
|
|
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("/<int:task_id>/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("/<int:task_id>")
|
|
@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("/<int:task_id>/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("/<int:task_id>/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("/<int:task_id>/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("/<int:task_id>/subtasks/<int:subtask_id>/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("/<int:task_id>/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("/<int:task_id>/chunks/<int:chunk_index>/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("/<int:task_id>/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/<uuid:batch_id>")
|
|
@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")
|