From b5e3b44e5ab4ced06779fd162628a50d351e0255 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 23 Nov 2025 22:48:01 +0100 Subject: [PATCH] feat: Implement scheduled item price tracking with AI analysis, core backend services, and a dashboard item management modal. --- app/alembic/versions/drop_notifications.py | 46 ++++++++++ app/main.py | 4 +- app/models.py | 15 ---- app/notification_sender.py | 36 -------- app/routers/notifications.py | 29 ------ app/schemas.py | 19 ---- app/services/item_service.py | 8 +- app/services/notification_service.py | 89 ------------------- app/services/scheduler_service.py | 5 +- .../src/components/dashboard/ItemModal.jsx | 38 ++------ 10 files changed, 57 insertions(+), 232 deletions(-) create mode 100644 app/alembic/versions/drop_notifications.py delete mode 100644 app/notification_sender.py delete mode 100644 app/routers/notifications.py delete mode 100644 app/services/notification_service.py diff --git a/app/alembic/versions/drop_notifications.py b/app/alembic/versions/drop_notifications.py new file mode 100644 index 0000000..40df459 --- /dev/null +++ b/app/alembic/versions/drop_notifications.py @@ -0,0 +1,46 @@ +"""drop notification tables and columns + +Revision ID: drop_notifications +Revises: +Create Date: 2025-11-23 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'drop_notifications' +down_revision = None # Update this if there are other migrations +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop foreign key constraint and column from items table + with op.batch_alter_table('items', schema=None) as batch_op: + batch_op.drop_constraint('items_notification_profile_id_fkey', type_='foreignkey') + batch_op.drop_column('notification_profile_id') + + # Drop notification_profiles table + op.drop_table('notification_profiles') + + +def downgrade() -> None: + # Recreate notification_profiles table + op.create_table('notification_profiles', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('name', sa.VARCHAR(), nullable=True), + sa.Column('apprise_url', sa.VARCHAR(), nullable=True), + sa.Column('notify_on_price_drop', sa.BOOLEAN(), nullable=True), + sa.Column('notify_on_target_price', sa.BOOLEAN(), nullable=True), + sa.Column('price_drop_threshold_percent', sa.FLOAT(), nullable=True), + sa.Column('notify_on_stock_change', sa.BOOLEAN(), nullable=True), + sa.Column('check_interval_minutes', sa.INTEGER(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + # Add column back to items table + with op.batch_alter_table('items', schema=None) as batch_op: + batch_op.add_column(sa.Column('notification_profile_id', sa.INTEGER(), nullable=True)) + batch_op.create_foreign_key('items_notification_profile_id_fkey', 'notification_profiles', ['notification_profile_id'], ['id']) diff --git a/app/main.py b/app/main.py index 824aada..69ad6f0 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from sqlalchemy import text from app.database import SessionLocal, engine from app.limiter import limiter -from app.routers import auth, items, jobs, notifications, openrouter, search, search_sites, settings +from app.routers import auth, items, jobs, openrouter, search, search_sites, settings from app.services.scheduler_service import scheduled_refresh, scheduler from app.services import auth_service, search_service @@ -163,7 +163,7 @@ os.makedirs("screenshots", exist_ok=True) app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshots") # Routers -for router in [notifications.router, items.router, settings.router, jobs.router, openrouter.router]: +for router in [items.router, settings.router, jobs.router, openrouter.router]: app.include_router(router, prefix="/api") # Search routers (already have /api prefix) diff --git a/app/models.py b/app/models.py index 9f3803d..567ee21 100644 --- a/app/models.py +++ b/app/models.py @@ -19,19 +19,7 @@ class User(Base): last_login: datetime | None = Column(DateTime, nullable=True) # type: ignore -class NotificationProfile(Base): - __tablename__ = "notification_profiles" - id = Column(Integer, primary_key=True, index=True) - name = Column(String, unique=True, index=True) - apprise_url = Column(String) - notify_on_price_drop = Column(Boolean, default=True) - notify_on_target_price = Column(Boolean, default=True) - price_drop_threshold_percent = Column(Float, default=10.0) - notify_on_stock_change = Column(Boolean, default=True) - check_interval_minutes = Column(Integer, default=60) - - items = relationship("Item", back_populates="notification_profile") class Item(Base): @@ -61,9 +49,6 @@ class Item(Base): is_refreshing: bool = Column(Boolean, default=False) # type: ignore last_error: str | None = Column(String, nullable=True) # type: ignore - notification_profile_id: int | None = Column(Integer, ForeignKey("notification_profiles.id"), nullable=True) # type: ignore - notification_profile = relationship("NotificationProfile", back_populates="items") - price_history = relationship("PriceHistory", back_populates="item", cascade="all, delete-orphan") diff --git a/app/notification_sender.py b/app/notification_sender.py deleted file mode 100644 index da6d77b..0000000 --- a/app/notification_sender.py +++ /dev/null @@ -1,36 +0,0 @@ -import asyncio -import logging - -import apprise - -logger = logging.getLogger(__name__) - - -def _send_sync(urls: list, title: str, body: str): - """ - Synchronous notification sending. - """ - if not urls: - return - - apobj = apprise.Apprise() - - for url in urls: - apobj.add(url) - - try: - apobj.notify( - body=body, - title=title, - ) - logger.info(f"Notification sent: {title}") - except Exception as e: - logger.error(f"Error sending notification: {e}") - - -async def send_notification(urls: list, title: str, body: str): - """ - Async wrapper for sending notifications. - """ - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, _send_sync, urls, title, body) diff --git a/app/routers/notifications.py b/app/routers/notifications.py deleted file mode 100644 index c497a59..0000000 --- a/app/routers/notifications.py +++ /dev/null @@ -1,29 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session - -from app import database, schemas -from app.services.notification_service import NotificationService - -router = APIRouter(prefix="/notification-profiles", tags=["notifications"]) - - -@router.get("", response_model=list[schemas.NotificationProfileResponse]) -def get_notification_profiles(db: Session = Depends(database.get_db)): - return NotificationService.get_notification_profiles(db) - - -@router.post("", response_model=schemas.NotificationProfileResponse) -def create_notification_profile(profile: schemas.NotificationProfileCreate, db: Session = Depends(database.get_db)): - return NotificationService.create_notification_profile(db, profile) - - -@router.delete("/{profile_id}") -def delete_notification_profile(profile_id: int, db: Session = Depends(database.get_db)): - return NotificationService.delete_notification_profile(db, profile_id) - - -@router.put("/{profile_id}", response_model=schemas.NotificationProfileResponse) -def update_notification_profile( - profile_id: int, profile: schemas.NotificationProfileUpdate, db: Session = Depends(database.get_db) -): - return NotificationService.update_notification_profile(db, profile_id, profile) diff --git a/app/schemas.py b/app/schemas.py index 9cb2d03..7b6d3dd 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -3,24 +3,6 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict -class NotificationProfileCreate(BaseModel): - name: str - apprise_url: str - notify_on_price_drop: bool = True - notify_on_target_price: bool = True - price_drop_threshold_percent: float = 10.0 - notify_on_stock_change: bool = True - check_interval_minutes: int = 60 - - -class NotificationProfileUpdate(NotificationProfileCreate): - pass - - -class NotificationProfileResponse(NotificationProfileCreate): - id: int - model_config = ConfigDict(from_attributes=True) - class ItemCreate(BaseModel): url: str @@ -30,7 +12,6 @@ class ItemCreate(BaseModel): check_interval_minutes: int = 60 tags: str | None = None description: str | None = None - notification_profile_id: int | None = None class ItemResponse(ItemCreate): diff --git a/app/services/item_service.py b/app/services/item_service.py index fb948ce..a80632a 100644 --- a/app/services/item_service.py +++ b/app/services/item_service.py @@ -80,7 +80,6 @@ class ItemService: return None, None settings = {s.key: s.value for s in db.query(models.Settings).all()} - profile = item.notification_profile item_data = { "id": item.id, @@ -90,7 +89,6 @@ class ItemService: "current_price": item.current_price, "in_stock": item.in_stock, "target_price": item.target_price, - "notification_profile": profile.__dict__ if profile else None, } config = { @@ -115,11 +113,7 @@ class ItemService: if item.is_refreshing: continue - interval = ( - item.notification_profile.check_interval_minutes - if item.notification_profile and item.notification_profile.check_interval_minutes - else global_interval - ) + interval = item.check_interval_minutes if item.check_interval_minutes else global_interval if not item.last_checked: due_items.append((item.id, interval, -1)) diff --git a/app/services/notification_service.py b/app/services/notification_service.py deleted file mode 100644 index 79f6988..0000000 --- a/app/services/notification_service.py +++ /dev/null @@ -1,89 +0,0 @@ -from fastapi import HTTPException -from sqlalchemy.orm import Session - -from app import models, notification_sender, schemas - - -class NotificationService: - @staticmethod - def get_notification_profiles(db: Session): - return db.query(models.NotificationProfile).all() - - @staticmethod - def create_notification_profile(db: Session, profile: schemas.NotificationProfileCreate): - db_profile = models.NotificationProfile(**profile.model_dump()) - db.add(db_profile) - db.commit() - db.refresh(db_profile) - return db_profile - - @staticmethod - def delete_notification_profile(db: Session, profile_id: int): - profile = db.query(models.NotificationProfile).filter(models.NotificationProfile.id == profile_id).first() - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - db.delete(profile) - db.commit() - db.delete(profile) - db.commit() - return {"ok": True} - - @staticmethod - def update_notification_profile(db: Session, profile_id: int, profile_data: schemas.NotificationProfileUpdate): - profile = db.query(models.NotificationProfile).filter(models.NotificationProfile.id == profile_id).first() - if not profile: - raise HTTPException(status_code=404, detail="Profile not found") - - for key, value in profile_data.model_dump().items(): - setattr(profile, key, value) - - db.commit() - db.refresh(profile) - return profile - - @staticmethod - async def send_item_notifications( - item_data: dict, - price: float | None, - old_price: float | None, - in_stock: bool | None, - old_stock: bool | None, - ): - profile = item_data["notification_profile"] - if not profile: - return - - if profile["notify_on_price_drop"] and price is not None and old_price is not None: - if price < old_price: - drop_percent = ((old_price - price) / old_price) * 100 - if drop_percent >= profile["price_drop_threshold_percent"]: - await notification_sender.send_notification( - [profile["apprise_url"]], - f"Price Drop Alert: {item_data['name']}", - f"Price dropped by {drop_percent:.1f}%! Now ${price} (was ${old_price})", - ) - - if ( - profile["notify_on_target_price"] - and price is not None - and item_data["target_price"] - and price <= item_data["target_price"] - ): - await notification_sender.send_notification( - [profile["apprise_url"]], - f"Target Price Alert: {item_data['name']}", - f"Price is ${price} (Target: ${item_data['target_price']})", - ) - - if ( - profile["notify_on_stock_change"] - and in_stock is not None - and old_stock is not None - and in_stock != old_stock - ): - status = "In Stock" if in_stock else "Out of Stock" - await notification_sender.send_notification( - [profile["apprise_url"]], - f"Stock Alert: {item_data['name']}", - f"Item is now {status}", - ) diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py index 951b677..2786d96 100644 --- a/app/services/scheduler_service.py +++ b/app/services/scheduler_service.py @@ -8,7 +8,6 @@ from app import database, models from app.ai_schema import AIExtractionMetadata, AIExtractionResponse from app.services.ai_service import AIService from app.services.item_service import ItemService -from app.services.notification_service import NotificationService from app.services.scraper_service import ScraperService logger = logging.getLogger(__name__) @@ -134,9 +133,7 @@ async def process_item_check(item_id: int): None, _update_db_result, item_id, extraction, metadata, thresholds, screenshot_path ) - await NotificationService.send_item_notifications( - item_data, extraction.price, old_price, extraction.in_stock, old_stock - ) + except Exception as e: logger.error(f"Error in process_item_check: {e}") diff --git a/frontend/src/components/dashboard/ItemModal.jsx b/frontend/src/components/dashboard/ItemModal.jsx index 02a7b90..d3f1a98 100644 --- a/frontend/src/components/dashboard/ItemModal.jsx +++ b/frontend/src/components/dashboard/ItemModal.jsx @@ -17,10 +17,9 @@ export function ItemModal({ item, onClose, onSaved, open }) { name: '', target_price: '', tags: '', - description: '', - notification_profile_id: '' + description: '' }); - const [profiles, setProfiles] = useState([]); + useEffect(() => { if (item) { @@ -29,8 +28,7 @@ export function ItemModal({ item, onClose, onSaved, open }) { name: item.name || '', target_price: item.target_price || '', tags: item.tags || '', - description: item.description || '', - notification_profile_id: item.notification_profile_id ? item.notification_profile_id.toString() : '' + description: item.description || '' }); } else { setFormData({ @@ -38,25 +36,19 @@ export function ItemModal({ item, onClose, onSaved, open }) { name: '', target_price: '', tags: '', - description: '', - notification_profile_id: '' + description: '' }); } }, [item, open]); - useEffect(() => { - if (open) { - axios.get(`${API_URL}/notification-profiles`).then(res => setProfiles(res.data)).catch(console.error); - } - }, [open]); + const handleSubmit = async (e) => { e.preventDefault(); try { const payload = { ...formData, - target_price: formData.target_price ? parseFloat(formData.target_price) : null, - notification_profile_id: formData.notification_profile_id ? parseInt(formData.notification_profile_id) : null + target_price: formData.target_price ? parseFloat(formData.target_price) : null }; if (item) { @@ -113,23 +105,7 @@ export function ItemModal({ item, onClose, onSaved, open }) { onChange={e => setFormData({ ...formData, target_price: e.target.value })} /> -
- - -
+