From f585a26a46012b89d55ab64df3f11de3a3b643bd Mon Sep 17 00:00:00 2001 From: Frank Faulstich Date: Tue, 4 Aug 2026 13:15:04 +0200 Subject: [PATCH] Priorities in the Data Structure Fixes #519 --- TimeTrackerMCP_Server.py | 12 ++++ TimeTrackerREST_Server.py | 7 ++- TimeTrackerSOAP_Server.py | 20 ++++--- sl/SL_Menu.py | 88 ++++++++++++++++++++++------ tests/test_TimeTracker.py | 50 ++++++++++++++-- tests/test_TimeTrackerMCP_Server.py | 39 +++++++++++- tests/test_TimeTrackerREST_Server.py | 32 +++++++++- tests/test_TimeTrackerSOAP_Server.py | 30 +++++++++- tt/TimeTracker.py | 48 +++++++++------ 9 files changed, 269 insertions(+), 57 deletions(-) diff --git a/TimeTrackerMCP_Server.py b/TimeTrackerMCP_Server.py index db9bce9..c7b05fe 100644 --- a/TimeTrackerMCP_Server.py +++ b/TimeTrackerMCP_Server.py @@ -236,6 +236,7 @@ def add_task( recurring: bool = False, frequency: str = "daily", userdefined_days: int = 1, + priority: int = 0, ) -> str: """ Creates a new task inside an existing main project. @@ -251,7 +252,11 @@ def add_task( :param recurring: Whether the task repeats after it's marked done. :param frequency: 'daily', 'business_days', 'weekly', 'monthly', or 'userdefined'. Only used if recurring is true. :param userdefined_days: Number of days between occurrences. Only used if frequency is 'userdefined'. + :param priority: Priority from 0 (lowest, default) to 9 (highest). """ + if not (0 <= priority <= 9): + return "Error: priority must be between 0 and 9." + tracker = get_tracker() existing_project_names = [p['main_project_name'] for p in tracker.list_main_projects(status_filter='all')] if main_project_name not in existing_project_names: @@ -270,6 +275,7 @@ def add_task( recurring=recurring, frequency=frequency, userdefined_days=userdefined_days, + priority=priority, ) return f"Task '{task_name}' created in project '{main_project_name}'." @@ -369,6 +375,7 @@ def update_task( recurring: bool | None = None, frequency: str | None = None, userdefined_days: int | None = None, + priority: int | None = None, ) -> str: """ Updates one or more properties of an existing task in one call. Only the @@ -384,7 +391,11 @@ def update_task( :param recurring: Whether the task repeats after it's marked done. :param frequency: 'daily', 'business_days', 'weekly', 'monthly', or 'userdefined'. :param userdefined_days: Number of days between occurrences, for 'userdefined' frequency. + :param priority: Priority from 0 (lowest) to 9 (highest). Omit to keep the current one. """ + if priority is not None and not (0 <= priority <= 9): + return "Error: priority must be between 0 and 9." + tracker = get_tracker() tasks = tracker.list_tasks(main_project_name=main_project_name, status_filter='all') current_task = next((t for t in tasks if t['task_name'] == task_name), None) @@ -413,6 +424,7 @@ def update_task( recurring=recurring, frequency=frequency, userdefined_days=userdefined_days, + priority=priority, task_id=current_task.get('id'), ) if success: diff --git a/TimeTrackerREST_Server.py b/TimeTrackerREST_Server.py index 827b909..ea19665 100644 --- a/TimeTrackerREST_Server.py +++ b/TimeTrackerREST_Server.py @@ -8,7 +8,7 @@ # REST interface, the same way spyne is used for the SOAP interface. try: from fastapi import Depends, FastAPI, HTTPException - from pydantic import BaseModel + from pydantic import BaseModel, Field import uvicorn except ImportError: print("Fehler: Die benötigten Bibliotheken sind nicht installiert.") @@ -47,6 +47,7 @@ class Task(BaseModel): recurring: bool frequency: str userdefined_days: int + priority: int class InactiveProject(BaseModel): @@ -102,6 +103,7 @@ class AddTaskRequest(BaseModel): recurring: bool = False frequency: str = "daily" userdefined_days: int = 1 + priority: int = Field(default=0, ge=0, le=9) class UpdateTaskRequest(BaseModel): @@ -113,6 +115,7 @@ class UpdateTaskRequest(BaseModel): recurring: Optional[bool] = None frequency: Optional[str] = None userdefined_days: Optional[int] = None + priority: Optional[int] = Field(default=None, ge=0, le=9) class MoveTaskRequest(BaseModel): @@ -222,6 +225,7 @@ def add_task(main_project_name: str, body: AddTaskRequest, tracker: TimeTracker body.recurring, body.frequency, body.userdefined_days, + body.priority, ) return SuccessResult(success=created) @@ -288,6 +292,7 @@ def update_task(main_project_name: str, task_name: str, body: UpdateTaskRequest, body.recurring, body.frequency, body.userdefined_days, + body.priority, task_id=task_id, ) return SuccessResult(success=updated) diff --git a/TimeTrackerSOAP_Server.py b/TimeTrackerSOAP_Server.py index da00b9c..a087b51 100644 --- a/TimeTrackerSOAP_Server.py +++ b/TimeTrackerSOAP_Server.py @@ -43,6 +43,7 @@ class TaskModel(ComplexModel): recurring = Boolean frequency = Unicode userdefined_days = Integer + priority = Integer class InactiveProjectModel(ComplexModel): main_project = Unicode @@ -111,9 +112,9 @@ def list_completed_main_projects(ctx): # --- Task Management --- - @rpc(Unicode, Unicode, Unicode, Boolean, Unicode, Boolean, Unicode, Integer, _returns=Boolean) - def add_task(ctx, main_project_name, task_name, due_date=None, today=False, note="", recurring=False, frequency="daily", userdefined_days=1): - return ctx.udc.add_task(main_project_name, task_name, due_date, today, note, recurring, frequency, userdefined_days) + @rpc(Unicode, Unicode, Unicode, Boolean, Unicode, Boolean, Unicode, Integer, Integer, _returns=Boolean) + def add_task(ctx, main_project_name, task_name, due_date=None, today=False, note="", recurring=False, frequency="daily", userdefined_days=1, priority=0): + return ctx.udc.add_task(main_project_name, task_name, due_date, today, note, recurring, frequency, userdefined_days, priority) @rpc(Unicode, Unicode, Unicode, _returns=Array(TaskModel)) def list_tasks(ctx, main_project_name=None, status_filter='all', planning_filter=None): @@ -153,11 +154,16 @@ def rename_task(ctx, main_project_name, old_name, new_name, task_id=None): return ctx.udc.rename_task(main_project_name, old_name, new_name, task_id=task_id) return ctx.udc.rename_task(main_project_name, old_name, new_name) - @rpc(Unicode, Unicode, Unicode, Unicode, Boolean, Unicode, Unicode, Boolean, Unicode, Integer, Integer, _returns=Boolean) - def update_task(ctx, main_project_name, old_name, new_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, task_id=None): + @rpc(Unicode, Unicode, Unicode, Unicode, Boolean, Unicode, Unicode, Boolean, Unicode, Integer, Integer, Integer, _returns=Boolean) + def update_task(ctx, main_project_name, old_name, new_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, task_id=None, priority=None): + # priority is appended after task_id (rather than grouped with the + # other content fields before it) so existing positional callers that + # already pass task_id as the 11th argument aren't shifted - spyne + # dispatches @rpc args purely by position, so inserting a new + # parameter anywhere but the end would silently break them. if task_id is not None: - return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days, task_id=task_id) - return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days) + return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days, priority=priority, task_id=task_id) + return ctx.udc.update_task(main_project_name, old_name, new_name, due_date, today, note, status, recurring, frequency, userdefined_days, priority=priority) @rpc(Unicode, Unicode, Unicode, Unicode, _returns=OperationResultModel) def move_task(ctx, old_main, task_name, new_main, task_id=None): diff --git a/sl/SL_Menu.py b/sl/SL_Menu.py index c8a045a..0f58f59 100644 --- a/sl/SL_Menu.py +++ b/sl/SL_Menu.py @@ -659,13 +659,14 @@ def view_task_planning(): if is_active: display_name = f"**{display_name}**" today_info = " ⭐" if task.get('today') else "" recurring_info = " ↻" if task.get('recurring') else "" + priority_info = f" 🔺{task.get('priority', 0)}" if task.get('priority', 0) > 0 else "" if is_active: bullet = "🔨" elif is_done: bullet = "✔" else: bullet = "-" - st.markdown(f"{bullet} **{task['main_project_name']}**: {display_name}{today_info}{recurring_info}", unsafe_allow_html=True) + st.markdown(f"{bullet} **{task['main_project_name']}**: {display_name}{today_info}{recurring_info}{priority_info}", unsafe_allow_html=True) with col_start_btn: if st.button("▶", key=f"start_task_planning_weekly_{task['main_project_name']}_{task['task_name']}_{t_idx}", help=_("Start work on task"), disabled=is_active or task.get('status') == 'done'): st.session_state.tracker.start_work(task['main_project_name'], task_id=task.get('id')) @@ -744,13 +745,14 @@ def view_task_planning(): due_info = f" ({_('Due')}: {task['due_date']})" if task.get('due_date') else "" today_info = " ⭐" if task.get('today') else "" recurring_info = " ↻" if task.get('recurring') else "" + priority_info = f" 🔺{task.get('priority', 0)}" if task.get('priority', 0) > 0 else "" if is_active: bullet = "🔨" elif is_done: bullet = "✔" else: bullet = "-" - st.markdown(f"{bullet} {display_name}{due_info}{today_info}{recurring_info}", unsafe_allow_html=True) + st.markdown(f"{bullet} {display_name}{due_info}{today_info}{recurring_info}{priority_info}", unsafe_allow_html=True) with col_start_btn: if st.button("▶", key=f"start_task_planning_{main_proj_name}_{task['task_name']}_{t_idx}", help=_("Start work on task"), disabled=is_active or status == 'done'): st.session_state.tracker.start_work(task['main_project_name'], task_id=task.get('id')) @@ -826,6 +828,19 @@ def view_today_tasks(): key="today_show_only_open", ) st.session_state.today_show_only_open_value = show_only_open + + # Same session-state-mirroring reasoning as today_show_only_open_value + # above: a plain checkbox key would forget its value across a trip to + # the edit-task form and back. + if "today_sort_by_priority_value" not in st.session_state: + st.session_state.today_sort_by_priority_value = False + sort_by_priority = st.checkbox( + _("Sort by priority"), + value=st.session_state.today_sort_by_priority_value, + key="today_sort_by_priority", + ) + st.session_state.today_sort_by_priority_value = sort_by_priority + today_tasks = [t for t in today_tasks_all if t.get('status') != 'done'] if show_only_open else today_tasks_all if today_tasks: @@ -836,7 +851,15 @@ def view_today_tasks(): if main_proj not in today_tasks_grouped: today_tasks_grouped[main_proj] = [] today_tasks_grouped[main_proj].append(task) - + + if sort_by_priority: + # Sorted within each project group rather than flattened across + # all of them, so the existing per-project grouping/expanders + # stay intact - a stable sort keeps same-priority tasks in their + # original relative order. + for tasks_in_group in today_tasks_grouped.values(): + tasks_in_group.sort(key=lambda t: t.get('priority', 0), reverse=True) + # Each project's tasks are shown inside a collapsible expander so # projects with many tasks don't crowd out the rest of the list. # on_change="rerun" is required for st.expander to track its state in @@ -861,7 +884,7 @@ def view_today_tasks(): # of leaving it open on the way to/from editing a task. try: for t_idx, task in enumerate(sub_tasks): # Iterate through tasks in the group - col_task, col_start_btn, col_edit_btn, col_done_btn = st.columns([10, 1, 1, 1]) + col_task, col_priority, col_start_btn, col_edit_btn, col_done_btn = st.columns([7, 2, 1, 1, 1]) with col_task: name = task['task_name'] status = task.get('status') @@ -871,13 +894,33 @@ def view_today_tasks(): if is_active: display_name = f"**{display_name}**" due_info = f" ({_('Due')}: {task['due_date']})" if task.get('due_date') else "" recurring_info = " ↻" if task.get('recurring') else "" + priority_info = f" 🔺{task.get('priority', 0)}" if task.get('priority', 0) > 0 else "" if is_active: bullet = "🔨" elif is_done: bullet = "✔" else: bullet = "-" - st.markdown(f"{bullet} {display_name}{due_info}{recurring_info}", unsafe_allow_html=True) + st.markdown(f"{bullet} {display_name}{due_info}{recurring_info}{priority_info}", unsafe_allow_html=True) + with col_priority: + new_priority = st.number_input( + _("Priority"), min_value=0, max_value=9, + value=task.get('priority', 0), step=1, + key=f"today_priority_{task['main_project_name']}_{task['task_name']}_{t_idx}", + label_visibility="collapsed", help=_("0 (lowest) to 9 (highest)"), + ) + if new_priority != task.get('priority', 0): + st.session_state.tracker.update_task( + task['main_project_name'], + task['task_name'], + due_date=task.get('due_date'), + recurring=task.get('recurring'), + frequency=task.get('frequency'), + userdefined_days=task.get('userdefined_days'), + priority=new_priority, + task_id=task.get('id'), + ) + st.rerun() with col_start_btn: if st.button("▶", key=f"start_today_task_{task['main_project_name']}_{task['task_name']}_{t_idx}", help=_("Start work on task"), disabled=is_active or status == 'done'): st.session_state.tracker.start_work(task['main_project_name'], task_id=task.get('id')) @@ -1901,7 +1944,8 @@ def view_list_tasks(): status_text = f"({_('closed')})" if t['status'] == 'closed' else "" display_name = f"{name} (done)" if t['status'] == 'done' else name recurring_info = " ↻" if t.get('recurring') else "" - st.markdown(f"- {display_name} {status_text}{recurring_info}") + priority_info = f" 🔺{t.get('priority', 0)}" if t.get('priority', 0) > 0 else "" + st.markdown(f"- {display_name} {status_text}{recurring_info}{priority_info}") else: st.info(_("No tasks found for '{name}'.").format(name=selected_main)) @@ -2186,7 +2230,7 @@ def view_add_task_form(): if "new_task_note" not in st.session_state: st.session_state.new_task_note = "" - col_date, col_today, col_rec = st.columns([2, 1, 1]) + col_date, col_today, col_rec, col_prio = st.columns([2, 1, 1, 1]) with col_date: due_date = st.date_input(_("Due date"), value=datetime.now().date(), format="YYYY-MM-DD") with col_today: @@ -2195,6 +2239,8 @@ def view_add_task_form(): with col_rec: st.markdown("
", unsafe_allow_html=True) is_recurring = st.checkbox(_("Recurring")) + with col_prio: + priority = st.number_input(_("Priority"), min_value=0, max_value=9, value=0, step=1, help=_("0 (lowest) to 9 (highest)")) validation_error = is_recurring and not due_date if validation_error: @@ -2240,14 +2286,15 @@ def view_add_task_form(): elif not name: st.error(_("Please enter a name.")) elif st.session_state.tracker.add_task( - main_project, - name, - due_date.isoformat() if due_date else None, - today, + main_project, + name, + due_date.isoformat() if due_date else None, + today, st.session_state.new_task_note, recurring=is_recurring, frequency=final_freq, - userdefined_days=ud_days + userdefined_days=ud_days, + priority=priority ): set_feedback(_("Task '{sub_name}' added to '{main_name}'.").format(sub_name=name, main_name=main_project)) if "new_task_note" in st.session_state: del st.session_state.new_task_note @@ -2357,13 +2404,15 @@ def view_edit_task_form(): st.session_state.edit_due_date = None st.rerun() - col_today, col_done, col_rec = st.columns(3) + col_today, col_done, col_rec, col_prio = st.columns(4) with col_today: is_today = st.checkbox(_("Today"), value=task_details.get('today', False)) with col_done: is_done = st.checkbox(_("Done"), value=(task_details.get('status') == 'done')) with col_rec: is_recurring = st.checkbox(_("Recurring"), value=task_details.get('recurring', False)) + with col_prio: + priority = st.number_input(_("Priority"), min_value=0, max_value=9, value=task_details.get('priority', 0), step=1, help=_("0 (lowest) to 9 (highest)")) validation_error = is_recurring and not st.session_state.edit_due_date if validation_error: @@ -2410,16 +2459,17 @@ def view_edit_task_form(): new_status = 'done' if is_done else 'open' if st.session_state.tracker.update_task( - main_project, - task_name, - new_name, - final_due, - is_today, - st.session_state.edit_task_note, + main_project, + task_name, + new_name, + final_due, + is_today, + st.session_state.edit_task_note, new_status, recurring=is_recurring, frequency=final_freq, userdefined_days=ud_days, + priority=priority, task_id=task_id ): set_feedback(_("Task updated successfully.")) diff --git a/tests/test_TimeTracker.py b/tests/test_TimeTracker.py index 62ae9bf..14f7fc3 100644 --- a/tests/test_TimeTracker.py +++ b/tests/test_TimeTracker.py @@ -111,6 +111,9 @@ def test_migrate_data_structure_adds_new_fields(self): self.assertEqual(task.get("note"), "") self.assertIn("id", task) self.assertIsInstance(task["id"], int) + # priority is the lowest (0) by default, same as an old task that + # predates the field entirely. + self.assertEqual(task.get("priority"), 0) def test_format_duration(self): """Tests the _format_duration helper method.""" @@ -295,26 +298,41 @@ def test_add_task_with_new_fields(self): """Tests adding a task with due_date, today, and note.""" self.tracker.add_main_project("Main") self.tracker.add_task("Main", "Task", due_date="2025-12-31", today=True, note="Test Note") - + sub = self.tracker.list_tasks("Main")[0] self.assertEqual(sub["due_date"], "2025-12-31") self.assertEqual(sub["today"], True) self.assertEqual(sub["note"], "Test Note") + def test_add_task_default_priority_is_zero(self): + """A task added without an explicit priority defaults to 0 (the lowest).""" + self.tracker.add_main_project("Main") + self.tracker.add_task("Main", "Task") + sub = self.tracker.list_tasks("Main")[0] + self.assertEqual(sub["priority"], 0) + + def test_add_task_with_priority(self): + """Tests adding a task with an explicit priority.""" + self.tracker.add_main_project("Main") + self.tracker.add_task("Main", "Task", priority=9) + sub = self.tracker.list_tasks("Main")[0] + self.assertEqual(sub["priority"], 9) + def test_update_task_success(self): """Tests updating all new task properties.""" self.tracker.add_main_project("Main") self.tracker.add_task("Main", "OldName") - + success = self.tracker.update_task( - "Main", "OldName", + "Main", "OldName", new_task_name="NewName", due_date="2025-01-01", today=True, note="Updated Note", - status="done" + status="done", + priority=5 ) - + self.assertTrue(success) sub = self.tracker.list_tasks("Main", status_filter='all')[0] self.assertEqual(sub["task_name"], "NewName") @@ -322,6 +340,17 @@ def test_update_task_success(self): self.assertEqual(sub["today"], True) self.assertEqual(sub["note"], "Updated Note") self.assertEqual(sub["status"], "done") + self.assertEqual(sub["priority"], 5) + + def test_update_task_priority_unchanged_when_omitted(self): + """Omitting priority in update_task() must leave the existing value alone.""" + self.tracker.add_main_project("Main") + self.tracker.add_task("Main", "Task", priority=7) + + self.tracker.update_task("Main", "Task", note="Just a note change") + + sub = self.tracker.list_tasks("Main")[0] + self.assertEqual(sub["priority"], 7) def test_recurring_task_new_instance_today_flag(self): """Tests that a new instance of a recurring task is created with today=False.""" @@ -359,6 +388,17 @@ def test_recurring_task_new_instance_copies_note(self): # The NEW task MUST have the updated note self.assertEqual(open_task["note"], "Updated note content") + def test_recurring_task_new_instance_inherits_priority(self): + """A new instance of a recurring task carries over the previous instance's priority.""" + self.tracker.add_main_project("Recurring Priority Test") + self.tracker.add_task("Recurring Priority Test", "Daily Task", recurring=True, priority=8) + + self.tracker.update_task("Recurring Priority Test", "Daily Task", status="done") + + tasks = self.tracker.list_tasks("Recurring Priority Test", status_filter='all') + open_task = next(t for t in tasks if t["status"] == "open") + self.assertEqual(open_task["priority"], 8) + def test_list_tasks_done_status(self): """Tests that 'done' tasks are included when filtering for 'open'.""" self.tracker.add_main_project("Main") diff --git a/tests/test_TimeTrackerMCP_Server.py b/tests/test_TimeTrackerMCP_Server.py index 2ca9a1a..833cb5d 100644 --- a/tests/test_TimeTrackerMCP_Server.py +++ b/tests/test_TimeTrackerMCP_Server.py @@ -61,10 +61,26 @@ def test_add_task_creates_new(self): result = self.mcp_server.add_task("Acme", "Write docs") self.mock_tracker.add_task.assert_called_once_with( "Acme", "Write docs", due_date=None, today=False, note="", - recurring=False, frequency="daily", userdefined_days=1, + recurring=False, frequency="daily", userdefined_days=1, priority=0, ) self.assertIn("created", result) + def test_add_task_with_priority(self): + self.mock_tracker.list_main_projects.return_value = [{"main_project_name": "Acme", "status": "open"}] + self.mock_tracker.list_tasks.return_value = [] + result = self.mcp_server.add_task("Acme", "Write docs", priority=9) + self.mock_tracker.add_task.assert_called_once_with( + "Acme", "Write docs", due_date=None, today=False, note="", + recurring=False, frequency="daily", userdefined_days=1, priority=9, + ) + self.assertIn("created", result) + + def test_add_task_priority_out_of_range(self): + result = self.mcp_server.add_task("Acme", "Write docs", priority=10) + self.mock_tracker.add_task.assert_not_called() + self.assertIn("Error", result) + self.assertIn("priority", result) + def test_add_task_missing_project(self): self.mock_tracker.list_main_projects.return_value = [] result = self.mcp_server.add_task("Nonexistent", "Write docs") @@ -280,10 +296,29 @@ def test_update_task_preserves_due_date_when_unspecified(self): "Acme", "Write docs", new_task_name=None, due_date="2026-02-01", today=None, note="Updated note", status=None, recurring=None, - frequency=None, userdefined_days=None, task_id=1, + frequency=None, userdefined_days=None, priority=None, task_id=1, ) self.assertIn("updated", result) + def test_update_task_priority(self): + self.mock_tracker.list_tasks.return_value = [{"id": 1, "task_name": "Write docs"}] + self.mock_tracker.update_task.return_value = True + + result = self.mcp_server.update_task("Acme", "Write docs", priority=4) + + _args, kwargs = self.mock_tracker.update_task.call_args + self.assertEqual(kwargs["priority"], 4) + self.assertIn("updated", result) + + def test_update_task_priority_out_of_range(self): + self.mock_tracker.list_tasks.return_value = [{"id": 1, "task_name": "Write docs"}] + + result = self.mcp_server.update_task("Acme", "Write docs", priority=-1) + + self.mock_tracker.update_task.assert_not_called() + self.assertIn("Error", result) + self.assertIn("priority", result) + def test_update_task_clear_due_date(self): self.mock_tracker.list_tasks.return_value = [ {"id": 1, "task_name": "Write docs", "due_date": "2026-02-01"} diff --git a/tests/test_TimeTrackerREST_Server.py b/tests/test_TimeTrackerREST_Server.py index 2b533de..a4eb4d9 100644 --- a/tests/test_TimeTrackerREST_Server.py +++ b/tests/test_TimeTrackerREST_Server.py @@ -146,16 +146,29 @@ def test_add_task(self): }) self.assertEqual(r.status_code, 200) self.mock_tracker.add_task.assert_called_once_with( - "Main", "Sub", "2025-12-24", True, "Note", False, "daily", 1 + "Main", "Sub", "2025-12-24", True, "Note", False, "daily", 1, 0 ) self.assertEqual(r.json(), {"success": True}) + def test_add_task_with_priority(self): + self.mock_tracker.add_task.return_value = True + r = self.client.post("/projects/Main/tasks", json={"task_name": "Sub", "priority": 9}) + self.assertEqual(r.status_code, 200) + self.mock_tracker.add_task.assert_called_once_with( + "Main", "Sub", None, False, "", False, "daily", 1, 9 + ) + + def test_add_task_priority_out_of_range_rejected(self): + r = self.client.post("/projects/Main/tasks", json={"task_name": "Sub", "priority": 10}) + self.assertEqual(r.status_code, 422) + self.mock_tracker.add_task.assert_not_called() + def test_list_tasks(self): self.mock_tracker.list_tasks.return_value = [ { 'id': 1, 'main_project_name': 'Main', 'task_name': 'Sub 1', 'status': 'open', 'due_date': None, 'today': False, 'note': '', 'recurring': False, - 'frequency': 'daily', 'userdefined_days': 1, + 'frequency': 'daily', 'userdefined_days': 1, 'priority': 0, } ] r = self.client.get("/tasks", params={"main_project_name": "Main", "status_filter": "open"}) @@ -227,10 +240,23 @@ def test_update_task(self): }) self.assertEqual(r.status_code, 200) self.mock_tracker.update_task.assert_called_once_with( - "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, task_id=None + "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, None, task_id=None ) self.assertEqual(r.json(), {"success": True}) + def test_update_task_priority(self): + self.mock_tracker.update_task.return_value = True + r = self.client.patch("/projects/Main/tasks/Old", json={"priority": 3}) + self.assertEqual(r.status_code, 200) + self.mock_tracker.update_task.assert_called_once_with( + "Main", "Old", None, None, None, None, None, None, None, None, 3, task_id=None + ) + + def test_update_task_priority_out_of_range_rejected(self): + r = self.client.patch("/projects/Main/tasks/Old", json={"priority": -1}) + self.assertEqual(r.status_code, 422) + self.mock_tracker.update_task.assert_not_called() + def test_move_task(self): self.mock_tracker.move_task.return_value = (True, "Moved successfully") r = self.client.post("/projects/Main/tasks/Sub/move", json={"new_main_project_name": "Other"}) diff --git a/tests/test_TimeTrackerSOAP_Server.py b/tests/test_TimeTrackerSOAP_Server.py index 188932c..663700a 100644 --- a/tests/test_TimeTrackerSOAP_Server.py +++ b/tests/test_TimeTrackerSOAP_Server.py @@ -67,7 +67,17 @@ def test_add_task(self): self.ctx, "Main", "Sub", "2025-12-24", True, "Note" ) self.mock_tracker.add_task.assert_called_with( - "Main", "Sub", "2025-12-24", True, "Note", False, "daily", 1 + "Main", "Sub", "2025-12-24", True, "Note", False, "daily", 1, 0 + ) + self.assertTrue(result) + + def test_add_task_with_priority(self): + self.mock_tracker.add_task.return_value = True + result = self.soap_server.TimeControlService.add_task( + self.ctx, "Main", "Sub", "2025-12-24", True, "Note", False, "daily", 1, 9 + ) + self.mock_tracker.add_task.assert_called_with( + "Main", "Sub", "2025-12-24", True, "Note", False, "daily", 1, 9 ) self.assertTrue(result) @@ -118,7 +128,23 @@ def test_update_task(self): self.ctx, "Main", "Old", "New", "2025-01-01", True, "Note", "done" ) self.mock_tracker.update_task.assert_called_with( - "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None + "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, priority=None + ) + self.assertTrue(result) + + def test_update_task_with_priority(self): + """ + priority is appended after task_id in the @rpc signature (not + grouped with the other content fields before it), so existing + positional callers that already pass task_id aren't shifted - see + TimeTrackerSOAP_Server.py's update_task comment for why. + """ + self.mock_tracker.update_task.return_value = True + result = self.soap_server.TimeControlService.update_task( + self.ctx, "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, 7, 4 + ) + self.mock_tracker.update_task.assert_called_with( + "Main", "Old", "New", "2025-01-01", True, "Note", "done", None, None, None, priority=4, task_id=7 ) self.assertTrue(result) diff --git a/tt/TimeTracker.py b/tt/TimeTracker.py index 7643a54..662906a 100644 --- a/tt/TimeTracker.py +++ b/tt/TimeTracker.py @@ -38,7 +38,7 @@ class TimeTracker: The data is loaded from and saved to a JSON file. """ - VERSION = "3.27" + VERSION = "3.28" STATUS_OPEN = "open" STATUS_CLOSED = "closed" STATUS_DONE = "done" @@ -201,7 +201,10 @@ def _migrate_data_structure(self): if "userdefined_days" not in task: task["userdefined_days"] = 1 data_changed = True - + if "priority" not in task: + task["priority"] = 0 + data_changed = True + # Assign an integer ID if missing or not an integer (e.g. legacy GUID) task_id = task.get("id") if task_id is None or not isinstance(task_id, int): @@ -464,7 +467,7 @@ def reopen_main_project(self, main_project_name): return True return False - def add_task(self, main_project_name, task_name, due_date=None, today=False, note="", recurring=False, frequency="daily", userdefined_days=1): + def add_task(self, main_project_name, task_name, due_date=None, today=False, note="", recurring=False, frequency="daily", userdefined_days=1, priority=0): """ Adds a new task to a specified main project. @@ -481,6 +484,8 @@ def add_task(self, main_project_name, task_name, due_date=None, today=False, not :param recurring: Whether the task is recurring. :param frequency: Freq (daily, on all business days, weekly, monthly, userdefined). :param userdefined_days: Number of days for userdefined frequency. + :param priority: Priority from 0 (lowest, default) to 9 (highest). + :type priority: int :return: True if the task was added successfully, otherwise False (if main project not found). :rtype: bool """ @@ -496,7 +501,8 @@ def add_task(self, main_project_name, task_name, due_date=None, today=False, not "note": note, "recurring": recurring, "frequency": frequency, - "userdefined_days": userdefined_days + "userdefined_days": userdefined_days, + "priority": priority } self.data["next_id"] += 1 project["tasks"].append(new_task) @@ -584,7 +590,8 @@ def list_tasks(self, main_project_name=None, status_filter='all', planning_filte "note": task.get("note", ""), "recurring": task.get("recurring", False), "frequency": task.get("frequency", "daily"), - "userdefined_days": task.get("userdefined_days", 1) + "userdefined_days": task.get("userdefined_days", 1), + "priority": task.get("priority", 0) }) return results @@ -735,7 +742,7 @@ def rename_task(self, main_project_name, old_task_name, new_task_name, task_id=N return True return False - def update_task(self, main_project_name, old_task_name, new_task_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, task_id=None): + def update_task(self, main_project_name, old_task_name, new_task_name=None, due_date=None, today=None, note=None, status=None, recurring=None, frequency=None, userdefined_days=None, priority=None, task_id=None): """ Updates a task's properties. @@ -749,6 +756,7 @@ def update_task(self, main_project_name, old_task_name, new_task_name=None, due_ :param recurring: Recurring status (optional, bool). :param frequency: Frequency (optional, str). :param userdefined_days: Days for userdefined frequency (optional, int). + :param priority: Priority from 0 (lowest) to 9 (highest) (optional, int). :param task_id: Unique ID of the task (optional). :return: True if successful. """ @@ -759,47 +767,50 @@ def update_task(self, main_project_name, old_task_name, new_task_name=None, due_ # Handle recurring task generation is_completing = (status == self.STATUS_DONE and task.get("status") != self.STATUS_DONE) is_recurring = recurring if recurring is not None else task.get("recurring", False) - + if is_completing and is_recurring: - self._create_next_recurring_instance(project, task, due_date, recurring, frequency, userdefined_days, note) + self._create_next_recurring_instance(project, task, due_date, recurring, frequency, userdefined_days, note, priority) if new_task_name: task["task_name"] = new_task_name - + # Update due_date (always update to what's provided) task["due_date"] = due_date - + # Update today status if provided if today is not None: task["today"] = today - + # Update note if provided if note is not None: task["note"] = note - + # Update status if provided if status is not None: task["status"] = status - + if recurring is not None: task["recurring"] = recurring if frequency is not None: task["frequency"] = frequency if userdefined_days is not None: task["userdefined_days"] = userdefined_days - + if priority is not None: + task["priority"] = priority + self._save_data() return True return False - def _create_next_recurring_instance(self, project, task, due_date_param, recurring_param, freq_param, ud_days_param, note_param=None): + def _create_next_recurring_instance(self, project, task, due_date_param, recurring_param, freq_param, ud_days_param, note_param=None, priority_param=None): freq = freq_param if freq_param is not None else task.get("frequency", "daily") ud_days = ud_days_param if ud_days_param is not None else task.get("userdefined_days", 1) base_due = due_date_param if due_date_param is not None else task.get("due_date") note = note_param if note_param is not None else task.get("note", "") - + priority = priority_param if priority_param is not None else task.get("priority", 0) + next_due = self._calculate_next_due_date(base_due, freq, ud_days) - + new_task = { "id": self.data["next_id"], "task_name": task["task_name"], @@ -810,7 +821,8 @@ def _create_next_recurring_instance(self, project, task, due_date_param, recurri "note": note, "recurring": True, "frequency": freq, - "userdefined_days": ud_days + "userdefined_days": ud_days, + "priority": priority } self.data["next_id"] += 1 project["tasks"].append(new_task)