feat: add recurrence_rule validation and recurrence-preview endpoint in tasks routes
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
@@ -14,9 +15,32 @@ from fabledassistant.services.notes import (
|
|||||||
list_notes,
|
list_notes,
|
||||||
update_note,
|
update_note,
|
||||||
)
|
)
|
||||||
|
from fabledassistant.services.recurrence import calculate_next_due, validate_recurrence_rule
|
||||||
|
|
||||||
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
|
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
|
||||||
|
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_recurrence_rule(data: dict) -> tuple[object, tuple | None]:
|
||||||
|
"""Extract and validate recurrence_rule from request data.
|
||||||
|
|
||||||
|
Returns (rule_or_UNSET, error_response_or_None):
|
||||||
|
_UNSET → key not present, do nothing
|
||||||
|
None → key present and null, clear the rule
|
||||||
|
dict → key present and valid, set the rule
|
||||||
|
"""
|
||||||
|
if "recurrence_rule" not in data:
|
||||||
|
return _UNSET, None
|
||||||
|
rule = data["recurrence_rule"]
|
||||||
|
if rule is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
validate_recurrence_rule(rule)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _UNSET, (jsonify({"error": str(exc)}), 400)
|
||||||
|
return rule, None
|
||||||
|
|
||||||
|
|
||||||
@tasks_bp.route("", methods=["GET"])
|
@tasks_bp.route("", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -24,8 +48,8 @@ async def list_tasks_route():
|
|||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
q = request.args.get("q")
|
q = request.args.get("q")
|
||||||
tag = request.args.getlist("tag")
|
tag = request.args.getlist("tag")
|
||||||
status = request.args.get("status")
|
status = request.args.getlist("status") or None
|
||||||
priority = request.args.get("priority")
|
priority = request.args.getlist("priority") or None
|
||||||
sort = request.args.get("sort", "updated_at")
|
sort = request.args.get("sort", "updated_at")
|
||||||
order = request.args.get("order", "desc")
|
order = request.args.get("order", "desc")
|
||||||
limit, offset = parse_pagination()
|
limit, offset = parse_pagination()
|
||||||
@@ -80,6 +104,10 @@ async def create_task_route():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
|
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
|
||||||
|
|
||||||
|
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
|
||||||
|
if recurrence_err:
|
||||||
|
return recurrence_err
|
||||||
|
|
||||||
project_id = data.get("project_id")
|
project_id = data.get("project_id")
|
||||||
if project_id is None and data.get("project"):
|
if project_id is None and data.get("project"):
|
||||||
from fabledassistant.services.projects import get_project_by_title as _gpbt
|
from fabledassistant.services.projects import get_project_by_title as _gpbt
|
||||||
@@ -98,6 +126,7 @@ async def create_task_route():
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
milestone_id=data.get("milestone_id"),
|
milestone_id=data.get("milestone_id"),
|
||||||
parent_id=data.get("parent_id"),
|
parent_id=data.get("parent_id"),
|
||||||
|
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
|
||||||
)
|
)
|
||||||
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
|
||||||
if text:
|
if text:
|
||||||
@@ -163,6 +192,12 @@ async def update_task_route(task_id: int):
|
|||||||
if key in data:
|
if key in data:
|
||||||
fields[key] = data[key]
|
fields[key] = data[key]
|
||||||
|
|
||||||
|
recurrence_rule, recurrence_err = _parse_recurrence_rule(data)
|
||||||
|
if recurrence_err:
|
||||||
|
return recurrence_err
|
||||||
|
if recurrence_rule is not _UNSET:
|
||||||
|
fields["recurrence_rule"] = recurrence_rule
|
||||||
|
|
||||||
task = await update_note(uid, task_id, **fields)
|
task = await update_note(uid, task_id, **fields)
|
||||||
if task is None:
|
if task is None:
|
||||||
return not_found("Task")
|
return not_found("Task")
|
||||||
@@ -192,6 +227,27 @@ async def patch_task_status(task_id: int):
|
|||||||
return jsonify(task.to_dict())
|
return jsonify(task.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@tasks_bp.route("/<int:task_id>/recurrence-preview", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def recurrence_preview_route(task_id: int):
|
||||||
|
uid = get_current_user_id()
|
||||||
|
result = await get_note_for_user(uid, task_id)
|
||||||
|
if result is None:
|
||||||
|
return not_found("Task")
|
||||||
|
task, _ = result
|
||||||
|
if not task.recurrence_rule:
|
||||||
|
return jsonify({"error": "Task has no recurrence rule"}), 400
|
||||||
|
|
||||||
|
count = min(request.args.get("count", 5, type=int), 10)
|
||||||
|
base = task.due_date or date.today()
|
||||||
|
dates = []
|
||||||
|
current = base
|
||||||
|
for _ in range(count):
|
||||||
|
current = calculate_next_due(task.recurrence_rule, current)
|
||||||
|
dates.append(current.isoformat())
|
||||||
|
return jsonify({"dates": dates})
|
||||||
|
|
||||||
|
|
||||||
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
|
@tasks_bp.route("/<int:task_id>", methods=["DELETE"])
|
||||||
@login_required
|
@login_required
|
||||||
async def delete_task_route(task_id: int):
|
async def delete_task_route(task_id: int):
|
||||||
|
|||||||
Reference in New Issue
Block a user