mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-11 17:29:14 +02:00
feat: Implement scheduled item price tracking with AI analysis, core backend services, and a dashboard item management modal.
This commit is contained in:
1 parent
c9a5f91dd2
commit
b5e3b44e5a
10 files changed
+57
-232
No files matched your search
@@ -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'])
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile">{t('itemModal.notificationProfile')}</Label>
|
||||
<Select
|
||||
value={formData.notification_profile_id}
|
||||
onValueChange={(value) => setFormData({ ...formData, notification_profile_id: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('itemModal.selectProfile')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('itemModal.noProfile')}</SelectItem>
|
||||
{profiles.map(p => (
|
||||
<SelectItem key={p.id} value={p.id.toString()}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tags">{t('itemModal.tags')}</Label>
|
||||
<Input
|
||||
|
||||
Reference in new issue
Block a user