From fcb875aaf9fb17eb61ae36cba861b5aaab8716cf Mon Sep 17 00:00:00 2001 From: Sami Abuzakuk Date: Sun, 12 Oct 2025 14:54:37 +0200 Subject: [PATCH] Add backend support for notifications --- backend/backend.py | 181 ++++++++++++++++++++++++++++++++++- backend/get_notifications.py | 107 +++++++++++++++++++++ backend/model.py | 50 +++++++++- 3 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 backend/get_notifications.py diff --git a/backend/backend.py b/backend/backend.py index fab8e8d..1534e1a 100644 --- a/backend/backend.py +++ b/backend/backend.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.exceptions import HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from model import Log, SessionLocal, Script, Settings +from model import Log, SessionLocal, Script, Settings, Subscription, Notification from run_scripts import run_scripts, update_requirements, update_environment import uvicorn @@ -52,11 +52,183 @@ def hello(): return {"message": "Welcome to the Project Monitor API"} +# Subscriptions API Endpoints +@app.get("/subscriptions") +def list_subscriptions(): + db = SessionLocal() + subscriptions = db.query(Subscription).all() + db.close() + return subscriptions + + +class SubscriptionCreate(BaseModel): + topic: str + + +class SubscriptionResponse(BaseModel): + id: int + topic: str + created_at: datetime + + model_config = {"from_attributes": True} + + +@app.get("/subscriptions/{subscription_id}", response_model=SubscriptionResponse) +def get_subscription(subscription_id: int): + db = SessionLocal() + subscription = ( + db.query(Subscription).filter(Subscription.id == subscription_id).first() + ) + if not subscription: + db.close() + raise HTTPException(status_code=404, detail="Subscription not found") + db.close() + return subscription + + +@app.post("/subscriptions") +def add_subscription(subscription: SubscriptionCreate): + db = SessionLocal() + existing_subscription = ( + db.query(Subscription).filter(Subscription.topic == subscription.topic).first() + ) + if existing_subscription: + db.close() + raise HTTPException(status_code=400, detail="Subscription already exists") + new_subscription = Subscription(topic=subscription.topic) + db.add(new_subscription) + db.commit() + db.refresh(new_subscription) + db.close() + return new_subscription + + +@app.delete("/subscriptions/{subscription_id}") +def remove_subscription(subscription_id: int): + db = SessionLocal() + subscription = ( + db.query(Subscription).filter(Subscription.id == subscription_id).first() + ) + if not subscription: + db.close() + raise HTTPException(status_code=404, detail="Subscription not found") + db.delete(subscription) + db.commit() + db.close() + return {"message": "Subscription removed"} + + +@app.get("/subscriptions/{subscription_id}/notifications") +def list_subscription_notifications(subscription_id: int): + db = SessionLocal() + notifications = ( + db.query(Notification) + .filter(Notification.subscription_id == subscription_id) + .all() + ) + db.close() + return [ + NotificationResponse.model_validate(notification) + for notification in notifications + ] + + +@app.get("/notifications") +def list_notifications(): + db = SessionLocal() + notifications = db.query(Notification).all() + db.close() + return [ + NotificationResponse.model_validate(notification) + for notification in notifications + ] + + +@app.delete("/notifications/{notification_id}") +def remove_notification(notification_id: int): + db = SessionLocal() + notification = ( + db.query(Notification).filter(Notification.id == notification_id).first() + ) + if not notification: + db.close() + raise HTTPException(status_code=404, detail="Notification not found") + db.delete(notification) + db.commit() + db.close() + return {"message": "Notification removed"} + + +class NotificationCreate(BaseModel): + subscription_id: int + title: str + message: str + priority: int + + +class NotificationUpdate(BaseModel): + subscription_id: int | None = None + title: str | None = None + message: str | None = None + priority: int | None = None + viewed: bool | None = None + + +class NotificationResponse(NotificationCreate): + id: int + created_at: datetime + viewed: bool + + model_config = {"from_attributes": True} + + +@app.put("/notifications/{notification_id}", response_model=NotificationResponse) +def update_notification(notification_id: int, notification: NotificationUpdate): + db = SessionLocal() + existing_notification = ( + db.query(Notification).filter(Notification.id == notification_id).first() + ) + if not existing_notification: + db.close() + raise HTTPException(status_code=404, detail="Notification not found") + if notification.subscription_id is not None: + existing_notification.subscription_id = notification.subscription_id + if notification.title is not None: + existing_notification.title = notification.title + if notification.message is not None: + existing_notification.message = notification.message + if notification.priority is not None: + existing_notification.priority = notification.priority + if notification.viewed is not None: + existing_notification.viewed = notification.viewed + db.commit() + db.refresh(existing_notification) + db.close() + return existing_notification + + +@app.post("/notifications", response_model=NotificationResponse) +def create_notification(notification: NotificationCreate): + db = SessionLocal() + new_notification = Notification( + subscription_id=notification.subscription_id, + title=notification.title, + message=notification.message, + priority=notification.priority, + ) + db.add(new_notification) + db.commit() + db.refresh(new_notification) + db.close() + return new_notification + + # Define Pydantic models for Settings class SettingsBase(BaseModel): requirements: str environment: str user: str + ntfy_url: str class SettingsUpdate(SettingsBase): @@ -107,14 +279,17 @@ def update_setting(settings_id: int, settings: SettingsUpdate): if not existing_setting: raise HTTPException(status_code=404, detail="Setting not found") - if existing_setting.requirements != settings.requirements: + if settings.requirements and existing_setting.requirements != settings.requirements: existing_setting.requirements = settings.requirements update_requirements(settings) - if existing_setting.environment != settings.environment: + if settings.environment and existing_setting.environment != settings.environment: existing_setting.environment = settings.environment update_environment(settings) + if settings.ntfy_url is not None: + existing_setting.ntfy_url = settings.ntfy_url + db.commit() db.refresh(existing_setting) db.close() diff --git a/backend/get_notifications.py b/backend/get_notifications.py new file mode 100644 index 0000000..145857e --- /dev/null +++ b/backend/get_notifications.py @@ -0,0 +1,107 @@ +import os +import requests +from datetime import datetime +from model import SessionLocal, Subscription, Settings, Notification +import json + +# Constants + +NTFY_TOKEN = os.getenv("NTFY_TOKEN") or "tk_cdmwd6ix255g3qgo4dx3r0gakw4y3" + + +def fetch_ntfy_notifications(base_url, subscriptions): + """Fetch notifications from the ntfy.sh server for the given subscriptions using streaming.""" + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {NTFY_TOKEN}" if NTFY_TOKEN else None, + } + + notifications = [] + for subscription in subscriptions: + topic = subscription.topic + last_message_id = subscription.last_message_id + since_param = "all" if last_message_id is None else last_message_id + url = f"{base_url}/{topic}/json?poll=1&since={since_param}" + response = requests.get(url, headers=headers, stream=True) + + response.raise_for_status() + + for line in response.iter_lines(): + if line: + notification = json.loads(line) + if notification.get("event") == "message": + notifications.append(notification) + + print(f"Fetched {len(notifications)} notifications") + print(notifications) + + return notifications + + +def save_notifications_to_db(notifications, topic_to_subscription, db): + """Save the fetched notifications to the database and update last_message_id.""" + db = SessionLocal() + last_message_ids = {} + for notification in notifications: + topic = notification["topic"] + last_message_ids[topic] = notification["id"] + subscription_id = topic_to_subscription.get(notification["topic"]) + if subscription_id: + new_notification = Notification( + title=notification.get("title", "No Title"), + message=notification.get("message", ""), + priority=notification.get("priority", 3), + created_at=datetime.fromtimestamp(notification["time"]), + subscription_id=subscription_id, + ) + db.add(new_notification) + for topic, message_id in last_message_ids.items(): + subscription_id = topic_to_subscription.get(topic) + if subscription_id: + subscription = ( + db.query(Subscription) + .filter(Subscription.id == subscription_id) + .first() + ) + if subscription: + subscription.last_message_id = message_id + db.commit() + db.close() + + +def main(): + """Main function to fetch and save notifications.""" + db = SessionLocal() + + # Get the ntfy base URL from settings + settings = db.query(Settings).filter(Settings.user == "default").first() + if not settings: + print("Default user settings not found.") + return + + ntfy_url = settings.ntfy_url + + if not ntfy_url: + print("Ntfy URL not found in settings.") + return + + # Get all subscribed topics + subscriptions = db.query(Subscription).all() + topic_to_subscription = { + subscription.topic: subscription.id for subscription in subscriptions + } + topic_to_subscription = { + subscription.topic: subscription.id for subscription in subscriptions + } + + db.close() + + # Fetch notifications from ntfy.sh + notifications = fetch_ntfy_notifications(ntfy_url, subscriptions) + + # Save notifications to the database + save_notifications_to_db(notifications, topic_to_subscription, db) + + +if __name__ == "__main__": + main() diff --git a/backend/model.py b/backend/model.py index 803cf8b..f95b5eb 100644 --- a/backend/model.py +++ b/backend/model.py @@ -1,9 +1,8 @@ -from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey +from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, Boolean from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy.sql.functions import func from sqlalchemy.sql.sqltypes import DateTime -from sqlalchemy.types import Boolean # Initialize the database DATABASE_URL = "sqlite:///./project_monitor.db" @@ -50,7 +49,54 @@ class Settings(Base): requirements = Column(String, nullable=False) environment = Column(String, nullable=False) user = Column(String, nullable=False) + ntfy_url = Column(String, nullable=True) + + +class Subscription(Base): + __tablename__ = "subscriptions" + + id = Column(Integer, primary_key=True, index=True) + topic = Column(String, nullable=False, unique=True) + last_message_id = Column(String, nullable=True) + created_at = Column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + message = Column(Text, nullable=False) + priority = Column(Integer, nullable=False, default=3) + viewed = Column(Boolean, default=False) + sent = Column(Boolean, default=False) + + subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False) + created_at = Column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) # Create the database tables Base.metadata.create_all(bind=engine) + + +# Ensure a default setting line exists +def ensure_default_setting(): + db = SessionLocal() + default_setting = db.query(Settings).filter(Settings.user == "default").first() + if not default_setting: + new_setting = Settings( + requirements="", + environment="", + user="default", + ntfy_url="https://ntfy.abzk.fr", + ) + db.add(new_setting) + db.commit() + db.close() + + +ensure_default_setting()