diff options
| -rw-r--r-- | Biz/PodcastItLater/Web.py | 215 |
1 files changed, 208 insertions, 7 deletions
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py index 036dd45..8586aaf 100644 --- a/Biz/PodcastItLater/Web.py +++ b/Biz/PodcastItLater/Web.py @@ -312,14 +312,44 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): "pending": "#ffa500", "processing": "#007cba", "error": "#dc3545", + "cancelled": "#6c757d", }.get(item["status"], "#6c757d") queue_items.append( html.div( - html.strong(f"#{item['id']} "), - html.span( - item["status"].upper(), - style={"color": status_color, "font-weight": "bold"}, + html.div( + html.strong(f"#{item['id']} "), + html.span( + item["status"].upper(), + style={ + "color": status_color, + "font-weight": "bold", + }, + ), + # Add cancel button for pending jobs + html.button( + "Cancel", + hx_post=f"/queue/{item['id']}/cancel", + hx_trigger="click", + hx_on=( + "htmx:afterRequest: " + "if(event.detail.successful) " + "htmx.trigger('body', 'queue-updated')" + ), + style={ + "margin-left": "10px", + "padding": "2px 8px", + "background": "#dc3545", + "color": "white", + "border": "none", + "cursor": "pointer", + "border-radius": "3px", + "font-size": "12px", + }, + ) + if item["status"] == "pending" + else "", + style={"display": "flex", "align-items": "center"}, ), html.br(), # Add title and author if available @@ -371,7 +401,7 @@ class QueueStatus(Component[AnyChildren, QueueStatusAttrs]): html.h3("Queue Status"), *queue_items, hx_get="/status", - hx_trigger="every 30s", + hx_trigger="every 1s, queue-updated from:body", hx_swap="outerHTML", ) @@ -979,6 +1009,7 @@ class AdminView(Component[AnyChildren, AdminViewAttrs]): "processing": "#007cba", "completed": "#28a745", "error": "#dc3545", + "cancelled": "#6c757d", }.get(status, "#6c757d") @@ -1372,9 +1403,18 @@ def rss_feed(request: Request, token: str) -> Response: # noqa: ARG001 @app.get("/status") -def queue_status(request: Request) -> QueueStatus: # noqa: ARG001 +def queue_status(request: Request) -> QueueStatus: """Return HTMX endpoint for live queue updates.""" - queue_items = Core.Database.get_queue_status(get_database_path()) + # Check if user is logged in + user_id = request.session.get("user_id") + if not user_id: + return QueueStatus(items=[]) + + # Get user-specific queue items + queue_items = Core.Database.get_user_queue_status( + user_id, + get_database_path(), + ) return QueueStatus(items=queue_items) @@ -1846,6 +1886,45 @@ def retry_queue_item(request: Request, job_id: int) -> Response: ) +@app.post("/queue/{job_id}/cancel") +def cancel_queue_item(request: Request, job_id: int) -> Response: + """Cancel a pending queue item.""" + try: + # Check if user is logged in + user_id = request.session.get("user_id") + if not user_id: + return Response("Unauthorized", status_code=401) + + # Get job and verify ownership + job = Core.Database.get_job_by_id(job_id, get_database_path()) + if job is None or job.get("user_id") != user_id: + return Response("Forbidden", status_code=403) + + # Only allow canceling pending jobs + if job.get("status") != "pending": + return Response("Can only cancel pending jobs", status_code=400) + + # Update status to cancelled + Core.Database.update_job_status( + job_id, + "cancelled", + error="Cancelled by user", + db_path=get_database_path(), + ) + + # Return success with HTMX trigger to refresh + return Response( + "", + status_code=200, + headers={"HX-Trigger": "queue-updated"}, + ) + except Exception as e: # noqa: BLE001 + return Response( + f"Error cancelling job: {e!s}", + status_code=500, + ) + + @app.delete("/queue/{job_id}") def delete_queue_item(request: Request, job_id: int) -> Response: """Delete a queue item.""" @@ -2260,6 +2339,127 @@ class TestAdminInterface(BaseWebTest): self.assertIn("PROCESSING: 1", response.text) +class TestJobCancellation(BaseWebTest): + """Test job cancellation functionality.""" + + def setUp(self) -> None: + """Set up test client with logged-in user and pending job.""" + super().setUp() + + # Create and login user + self.user_id, _ = Core.Database.create_user( + "test@example.com", + get_database_path(), + ) + self.client.post("/login", data={"email": "test@example.com"}) + + # Create pending job + self.job_id = Core.Database.add_to_queue( + "https://example.com/test", + "test@example.com", + self.user_id, + get_database_path(), + ) + + def test_cancel_pending_job(self) -> None: + """Successfully cancel a pending job.""" + response = self.client.post(f"/queue/{self.job_id}/cancel") + + self.assertEqual(response.status_code, 200) + self.assertIn("HX-Trigger", response.headers) + self.assertEqual(response.headers["HX-Trigger"], "queue-updated") + + # Verify job status is cancelled + job = Core.Database.get_job_by_id(self.job_id, get_database_path()) + self.assertIsNotNone(job) + if job is not None: + self.assertEqual(job["status"], "cancelled") + self.assertIn("Cancelled by user", job["error_message"]) + + def test_cannot_cancel_processing_job(self) -> None: + """Prevent cancelling jobs that are already processing.""" + # Set job to processing + Core.Database.update_job_status( + self.job_id, + "processing", + db_path=get_database_path(), + ) + + response = self.client.post(f"/queue/{self.job_id}/cancel") + + self.assertEqual(response.status_code, 400) + self.assertIn("Can only cancel pending jobs", response.text) + + def test_cannot_cancel_completed_job(self) -> None: + """Prevent cancelling completed jobs.""" + # Set job to completed + Core.Database.update_job_status( + self.job_id, + "completed", + db_path=get_database_path(), + ) + + response = self.client.post(f"/queue/{self.job_id}/cancel") + + self.assertEqual(response.status_code, 400) + + def test_cannot_cancel_other_users_job(self) -> None: + """Prevent users from cancelling other users' jobs.""" + # Create another user's job + user2_id, _ = Core.Database.create_user( + "other@example.com", + get_database_path(), + ) + other_job_id = Core.Database.add_to_queue( + "https://example.com/other", + "other@example.com", + user2_id, + get_database_path(), + ) + + # Try to cancel it + response = self.client.post(f"/queue/{other_job_id}/cancel") + + self.assertEqual(response.status_code, 403) + + def test_cancel_without_auth(self) -> None: + """Require authentication to cancel jobs.""" + # Logout + self.client.get("/logout") + + response = self.client.post(f"/queue/{self.job_id}/cancel") + + self.assertEqual(response.status_code, 401) + + def test_cancel_button_visibility(self) -> None: + """Cancel button only shows for pending jobs.""" + # Create jobs with different statuses + processing_job = Core.Database.add_to_queue( + "https://example.com/processing", + "test@example.com", + self.user_id, + get_database_path(), + ) + Core.Database.update_job_status( + processing_job, + "processing", + db_path=get_database_path(), + ) + + # Get status view + response = self.client.get("/status") + + # Should have cancel button for pending job + self.assertIn(f'hx-post="/queue/{self.job_id}/cancel"', response.text) + self.assertIn("Cancel", response.text) + + # Should NOT have cancel button for processing job + self.assertNotIn( + f'hx-post="/queue/{processing_job}/cancel"', + response.text, + ) + + def test() -> None: """Run all tests for the web module.""" Test.run( @@ -2269,6 +2469,7 @@ def test() -> None: TestArticleSubmission, TestRSSFeed, TestAdminInterface, + TestJobCancellation, ], ) |
