summaryrefslogtreecommitdiff
path: root/Biz/PodcastItLater
diff options
context:
space:
mode:
Diffstat (limited to 'Biz/PodcastItLater')
-rw-r--r--Biz/PodcastItLater/Web.py215
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,
],
)