Added listing of subscribed feeds and refactored paths #9
No files matched your search
+9
-8
@@ -1,25 +1,26 @@
|
|||||||
CREATE TABLE `rss_feeds` (
|
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,
|
`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,
|
`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,
|
`last_entry` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
`active` tinyint(1) NOT NULL DEFAULT '1',
|
`active` tinyint(1) NOT NULL DEFAULT '1',
|
||||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uq_rss_feed` (`rss_feed`),
|
UNIQUE KEY `uq_rss_feed` (`rss_feed`),
|
||||||
KEY `idx_active_type` (`active`,`type`)
|
KEY `idx_active_type` (`active`,`type`)
|
||||||
)
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
CREATE TABLE `rss_feed_subscriptions` (
|
CREATE TABLE `rss_feed_subscriptions` (
|
||||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
`user_id` bigint(20) NOT NULL,
|
`user_id` bigint NOT NULL,
|
||||||
`rss_feed_id` int(10) unsigned NOT NULL,
|
`rss_feed_id` int unsigned NOT NULL,
|
||||||
`feed_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
`feed_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
`active` tinyint(1) NOT NULL DEFAULT '1',
|
`active` tinyint(1) NOT NULL DEFAULT '1',
|
||||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uq_user_feed` (`user_id`,`rss_feed_id`),
|
UNIQUE KEY `uq_user_feed` (`user_id`,`rss_feed_id`),
|
||||||
KEY `idx_feed_active` (`rss_feed_id`,`active`),
|
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_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
|
CONSTRAINT `fk_sub_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE
|
||||||
)
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
+41
-18
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import assert_never
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||||||
@@ -17,7 +16,7 @@ from feed_handler import (
|
|||||||
)
|
)
|
||||||
from handlers import register_handlers
|
from handlers import register_handlers
|
||||||
from metrics_server import metrics_api
|
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 secret_manager import cleanup_secret_files, get_secret
|
||||||
from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError
|
from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError
|
||||||
from service_auth import UserId
|
from service_auth import UserId
|
||||||
@@ -97,17 +96,10 @@ def update_rss_feeds(
|
|||||||
|
|
||||||
sent = 0
|
sent = 0
|
||||||
failed = 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:
|
for user_id, feed_name in subscribers:
|
||||||
try:
|
try:
|
||||||
request.app.state.notifier.send(
|
request.app.state.notifier.send(
|
||||||
title=feed_name or title,
|
title=feed_name or online_information.feed_title,
|
||||||
body=online_information.body_title,
|
body=online_information.body_title,
|
||||||
link=online_information.link,
|
link=online_information.link,
|
||||||
category=online_information.notification_type,
|
category=online_information.notification_type,
|
||||||
@@ -129,8 +121,8 @@ def update_rss_feeds(
|
|||||||
continue
|
continue
|
||||||
with db.cursor() as cursor:
|
with db.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE rss_feeds SET last_entry = %s WHERE id = %s",
|
"UPDATE rss_feeds SET last_entry = %s, feed_name = %s WHERE id = %s",
|
||||||
(online_information.entry_id, feed_id),
|
(online_information.entry_id, online_information.feed_title, feed_id),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.debug(f"[FastAPI] Notification sent and database updated for feed ID {feed_id}")
|
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}")
|
logger.info(f"[FastAPI] {rss_feed_type.value} check complete: {results}")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@user_router.post("/add/feed")
|
@user_router.post("/feed/add")
|
||||||
def add_feed(
|
def add_feed(
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
feed_entry: FeedEntry,
|
feed_entry: FeedEntry,
|
||||||
@@ -169,20 +161,24 @@ def add_feed(
|
|||||||
known = cursor.fetchone()
|
known = cursor.fetchone()
|
||||||
|
|
||||||
last_entry_id = None
|
last_entry_id = None
|
||||||
|
feed_name = None
|
||||||
|
|
||||||
if known is None:
|
if known is None:
|
||||||
ensure_feed_exists(url)
|
ensure_feed_exists(url)
|
||||||
logger.info(f"[FastApi] Adding new RSS feed, type={feed_entry.type}, rss_feed_url={url}")
|
logger.info(f"[FastApi] Adding new RSS feed, type={feed_entry.type}, rss_feed_url={url}")
|
||||||
try:
|
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:
|
except (ValueError, RequestException) as e:
|
||||||
logger.warning(f"[FastAPI] Could not read latest entry for {url}")
|
logger.warning(f"[FastAPI] Could not read latest entry for {url}")
|
||||||
raise FeedCheckFailedError(url) from e
|
raise FeedCheckFailedError(url) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with db.cursor() as cursor:
|
with db.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"INSERT into rss_feeds (type,rss_feed,last_entry) VALUES (%s,%s,%s) "
|
"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",
|
"ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), active = 1, feed_name = COALESCE(VALUES(feed_name), feed_name)",
|
||||||
(feed_entry.type.value, url, last_entry_id),
|
(feed_entry.type.value, url, feed_name, last_entry_id,),
|
||||||
)
|
)
|
||||||
feed_id = cursor.lastrowid
|
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}")
|
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}
|
return {"status":"ok","detail":"Subscribed to feed","feed_url":url}
|
||||||
|
|
||||||
@user_router.post("/delete/feed")
|
@user_router.post("/feed/delete")
|
||||||
def delete_feed(
|
def delete_feed(
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
feed_entry: FeedEntry,
|
feed_entry: FeedEntry,
|
||||||
@@ -246,6 +242,33 @@ def delete_feed(
|
|||||||
logger.exception(f"[FastAPI] Failed to unsubscribe user_id={user_id} to {url}")
|
logger.exception(f"[FastAPI] Failed to unsubscribe user_id={user_id} to {url}")
|
||||||
raise HTTPException(status_code=500) from e
|
raise HTTPException(status_code=500) from e
|
||||||
return {"status":"ok","detail":"Unsubscribed to feed","feed_url":url}
|
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(user_router)
|
||||||
api.include_router(scheduler_router)
|
api.include_router(scheduler_router)
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -3,12 +3,31 @@ from enum import StrEnum
|
|||||||
from typing import ClassVar, Self
|
from typing import ClassVar, Self
|
||||||
from urllib.parse import urlsplit
|
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
|
from feed_errors import InvalidFeedUrlError, UnsupportedPathError, WrongHostError
|
||||||
|
|
||||||
REPO_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-")
|
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)
|
@dataclass(frozen=True)
|
||||||
class FeedUpdate():
|
class FeedUpdate():
|
||||||
entry_id: str
|
entry_id: str
|
||||||
|
|||||||
Reference in new issue
Block a user