From 840d96da71916c31d6b867400d2c5e88ac3aebe2 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 3 Nov 2026 17:10:58 +0100 Subject: [PATCH] Added listing of subscribed feeds and refactored paths --- database.sql | 17 ++++++++------- src/main.py | 59 +++++++++++++++++++++++++++++++++++---------------- src/models.py | 21 +++++++++++++++++- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/database.sql b/database.sql index a5cdfa3..294659b 100644 --- a/database.sql +++ b/database.sql @@ -1,25 +1,26 @@ CREATE TABLE `rss_feeds` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `id` int unsigned NOT NULL AUTO_INCREMENT, `type` enum('royalroad','gitea') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `rss_feed` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `feed_name` varchar(255) DEFAULT NULL, `last_entry` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, `active` tinyint(1) NOT NULL DEFAULT '1', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uq_rss_feed` (`rss_feed`), KEY `idx_active_type` (`active`,`type`) -) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; CREATE TABLE `rss_feed_subscriptions` ( - `id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` bigint(20) NOT NULL, - `rss_feed_id` int(10) unsigned NOT NULL, - `feed_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `active` tinyint(1) NOT NULL DEFAULT '1', + `id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint NOT NULL, + `rss_feed_id` int unsigned NOT NULL, + `feed_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uq_user_feed` (`user_id`,`rss_feed_id`), KEY `idx_feed_active` (`rss_feed_id`,`active`), CONSTRAINT `fk_sub_feed` FOREIGN KEY (`rss_feed_id`) REFERENCES `rss_feeds` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_sub_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE -) \ No newline at end of file +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; \ No newline at end of file diff --git a/src/main.py b/src/main.py index d830ee9..bb52053 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,6 @@ import asyncio import os from contextlib import asynccontextmanager -from typing import assert_never import uvicorn from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request @@ -17,7 +16,7 @@ from feed_handler import ( ) from handlers import register_handlers from metrics_server import metrics_api -from models import FeedEntry, FeedEntryType +from models import FeedEntry, FeedEntryType, SubscribedFeed, SubscribedFeedList from secret_manager import cleanup_secret_files, get_secret from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError from service_auth import UserId @@ -97,17 +96,10 @@ def update_rss_feeds( sent = 0 failed = 0 - match rss_feed_type: - case FeedEntryType.GITEA: - title = online_information.link.split("/releases")[0].removeprefix("https://") - case FeedEntryType.ROYALROAD: - title = online_information.feed_title - case _: - assert_never(rss_feed_type) for user_id, feed_name in subscribers: try: request.app.state.notifier.send( - title=feed_name or title, + title=feed_name or online_information.feed_title, body=online_information.body_title, link=online_information.link, category=online_information.notification_type, @@ -129,8 +121,8 @@ def update_rss_feeds( continue with db.cursor() as cursor: cursor.execute( - "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", - (online_information.entry_id, feed_id), + "UPDATE rss_feeds SET last_entry = %s, feed_name = %s WHERE id = %s", + (online_information.entry_id, online_information.feed_title, feed_id), ) db.commit() logger.debug(f"[FastAPI] Notification sent and database updated for feed ID {feed_id}") @@ -155,7 +147,7 @@ def update_rss_feeds( logger.info(f"[FastAPI] {rss_feed_type.value} check complete: {results}") return results -@user_router.post("/add/feed") +@user_router.post("/feed/add") def add_feed( user_id: UserId, feed_entry: FeedEntry, @@ -169,20 +161,24 @@ def add_feed( known = cursor.fetchone() last_entry_id = None + feed_name = None + if known is None: ensure_feed_exists(url) logger.info(f"[FastApi] Adding new RSS feed, type={feed_entry.type}, rss_feed_url={url}") try: - last_entry_id = fetch_latest(feed_entry.type, url).entry_id + update = fetch_latest(feed_entry.type, url) + last_entry_id, feed_name = update.entry_id, update.feed_title except (ValueError, RequestException) as e: logger.warning(f"[FastAPI] Could not read latest entry for {url}") raise FeedCheckFailedError(url) from e + try: with db.cursor() as cursor: cursor.execute( - "INSERT into rss_feeds (type,rss_feed,last_entry) VALUES (%s,%s,%s) " - "ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), active = 1", - (feed_entry.type.value, url, last_entry_id), + "INSERT INTO rss_feeds (type, rss_feed, feed_name, last_entry) VALUES (%s, %s, %s, %s) " + "ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), active = 1, feed_name = COALESCE(VALUES(feed_name), feed_name)", + (feed_entry.type.value, url, feed_name, last_entry_id,), ) feed_id = cursor.lastrowid @@ -201,7 +197,7 @@ def add_feed( logger.info(f"[FastAPI] user_id={user_id} subscribed to feed_id={feed_id}, url={url}") return {"status":"ok","detail":"Subscribed to feed","feed_url":url} -@user_router.post("/delete/feed") +@user_router.post("/feed/delete") def delete_feed( user_id: UserId, feed_entry: FeedEntry, @@ -246,6 +242,33 @@ def delete_feed( logger.exception(f"[FastAPI] Failed to unsubscribe user_id={user_id} to {url}") raise HTTPException(status_code=500) from e return {"status":"ok","detail":"Unsubscribed to feed","feed_url":url} + +@user_router.get("/feed/list") +def list_subscribed_feeds(user_id: UserId, db=Depends(get_db)): + logger.info(f"[FastAPI] Received request to list feeds for User ID = {user_id}") + try: + with db.cursor() as cursor: + cursor.execute( + "SELECT s.feed_name as custom_name, r.feed_name as feed_name, r.type, r.rss_feed, s.active from rss_feed_subscriptions as s " + "JOIN rss_feeds as r on s.rss_feed_id = r.id " + "WHERE s.user_id = %s ORDER BY r.type", + (user_id,), + ) + rows = cursor.fetchall() + + feed_list = SubscribedFeedList() + for custom_name, feed_name, feed_type, rss_feed, is_active in rows: + entry = SubscribedFeed( + feed_name = custom_name or feed_name, + type = feed_type, + rss_feed = rss_feed, + ) + (feed_list.active if is_active else feed_list.inactive).append(entry) + except Exception as e: + logger.exception("[FastAPI] Failed to list feeds") + raise HTTPException(status_code=500) from e + return feed_list + api.include_router(user_router) api.include_router(scheduler_router) diff --git a/src/models.py b/src/models.py index 35409bc..3c20912 100644 --- a/src/models.py +++ b/src/models.py @@ -3,12 +3,31 @@ from enum import StrEnum from typing import ClassVar, Self from urllib.parse import urlsplit -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, computed_field, field_validator, model_validator from feed_errors import InvalidFeedUrlError, UnsupportedPathError, WrongHostError REPO_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") +class SubscribedFeed(BaseModel): + feed_name: str + type: str + rss_feed: str + +class Counts(BaseModel): + active: int + inactive: int + +class SubscribedFeedList(BaseModel): + active: list[SubscribedFeed] = Field(default_factory=list) + inactive: list[SubscribedFeed] = Field(default_factory=list) + + @computed_field + @property + def counts(self) -> Counts: + return Counts(active=len(self.active),inactive=len(self.inactive)) + + @dataclass(frozen=True) class FeedUpdate(): entry_id: str -- 2.43.0