mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-24 07:38:13 +02:00
11 files changed
+58
-240
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.database import SessionLocal, engine
|
||||||
from app.limiter import limiter
|
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.scheduler_service import scheduled_refresh, scheduler
|
||||||
from app.services import auth_service, search_service
|
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")
|
app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshots")
|
||||||
|
|
||||||
# Routers
|
# 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")
|
app.include_router(router, prefix="/api")
|
||||||
|
|
||||||
# Search routers (already have /api prefix)
|
# Search routers (already have /api prefix)
|
||||||
|
|||||||
@@ -19,19 +19,7 @@ class User(Base):
|
|||||||
last_login: datetime | None = Column(DateTime, nullable=True) # type: ignore
|
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):
|
class Item(Base):
|
||||||
@@ -61,9 +49,6 @@ class Item(Base):
|
|||||||
is_refreshing: bool = Column(Boolean, default=False) # type: ignore
|
is_refreshing: bool = Column(Boolean, default=False) # type: ignore
|
||||||
last_error: str | None = Column(String, nullable=True) # 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")
|
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,25 +3,6 @@ from datetime import datetime
|
|||||||
from pydantic import BaseModel, ConfigDict
|
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):
|
class ItemCreate(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
name: str
|
name: str
|
||||||
@@ -30,8 +11,6 @@ class ItemCreate(BaseModel):
|
|||||||
check_interval_minutes: int = 60
|
check_interval_minutes: int = 60
|
||||||
tags: str | None = None
|
tags: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
notification_profile_id: int | None = None
|
|
||||||
category: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ItemResponse(ItemCreate):
|
class ItemResponse(ItemCreate):
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ class ItemService:
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
settings = {s.key: s.value for s in db.query(models.Settings).all()}
|
settings = {s.key: s.value for s in db.query(models.Settings).all()}
|
||||||
profile = item.notification_profile
|
|
||||||
|
|
||||||
item_data = {
|
item_data = {
|
||||||
"id": item.id,
|
"id": item.id,
|
||||||
@@ -90,7 +89,6 @@ class ItemService:
|
|||||||
"current_price": item.current_price,
|
"current_price": item.current_price,
|
||||||
"in_stock": item.in_stock,
|
"in_stock": item.in_stock,
|
||||||
"target_price": item.target_price,
|
"target_price": item.target_price,
|
||||||
"notification_profile": profile.__dict__ if profile else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
@@ -115,11 +113,7 @@ class ItemService:
|
|||||||
if item.is_refreshing:
|
if item.is_refreshing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
interval = (
|
interval = item.check_interval_minutes if item.check_interval_minutes else global_interval
|
||||||
item.notification_profile.check_interval_minutes
|
|
||||||
if item.notification_profile and item.notification_profile.check_interval_minutes
|
|
||||||
else global_interval
|
|
||||||
)
|
|
||||||
|
|
||||||
if not item.last_checked:
|
if not item.last_checked:
|
||||||
due_items.append((item.id, interval, -1))
|
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.ai_schema import AIExtractionMetadata, AIExtractionResponse
|
||||||
from app.services.ai_service import AIService
|
from app.services.ai_service import AIService
|
||||||
from app.services.item_service import ItemService
|
from app.services.item_service import ItemService
|
||||||
from app.services.notification_service import NotificationService
|
|
||||||
from app.services.scraper_service import ScraperService
|
from app.services.scraper_service import ScraperService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error in process_item_check: {e}")
|
logger.error(f"Error in process_item_check: {e}")
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ export function ItemModal({ item, onClose, onSaved, open }) {
|
|||||||
name: '',
|
name: '',
|
||||||
target_price: '',
|
target_price: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
description: '',
|
description: ''
|
||||||
notification_profile_id: ''
|
|
||||||
});
|
});
|
||||||
const [profiles, setProfiles] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -29,8 +28,7 @@ export function ItemModal({ item, onClose, onSaved, open }) {
|
|||||||
name: item.name || '',
|
name: item.name || '',
|
||||||
target_price: item.target_price || '',
|
target_price: item.target_price || '',
|
||||||
tags: item.tags || '',
|
tags: item.tags || '',
|
||||||
description: item.description || '',
|
description: item.description || ''
|
||||||
notification_profile_id: item.notification_profile_id ? item.notification_profile_id.toString() : ''
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -38,25 +36,19 @@ export function ItemModal({ item, onClose, onSaved, open }) {
|
|||||||
name: '',
|
name: '',
|
||||||
target_price: '',
|
target_price: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
description: '',
|
description: ''
|
||||||
notification_profile_id: ''
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, open]);
|
}, [item, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
axios.get(`${API_URL}/notification-profiles`).then(res => setProfiles(res.data)).catch(console.error);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...formData,
|
...formData,
|
||||||
target_price: formData.target_price ? parseFloat(formData.target_price) : null,
|
target_price: formData.target_price ? parseFloat(formData.target_price) : null
|
||||||
notification_profile_id: formData.notification_profile_id ? parseInt(formData.notification_profile_id) : null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -113,23 +105,7 @@ export function ItemModal({ item, onClose, onSaved, open }) {
|
|||||||
onChange={e => setFormData({ ...formData, target_price: e.target.value })}
|
onChange={e => setFormData({ ...formData, target_price: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tags">{t('itemModal.tags')}</Label>
|
<Label htmlFor="tags">{t('itemModal.tags')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -125,12 +125,7 @@ export function PriceHistoryModal({ open, onClose, item }) {
|
|||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center justify-between">
|
<DialogTitle>Historique des prix</DialogTitle>
|
||||||
<span>Historique des prix</span>
|
|
||||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
Reference in new issue
Block a user