Merge pull request #49 from R0m1k3/antigravity

Antigravity
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-23 22:52:56 +01:00
commit 8d38857e30
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
View File
@@ -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)
-15
View File
@@ -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")
-36
View File
@@ -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)
-29
View File
@@ -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)
-21
View File
@@ -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):
+1 -7
View File
@@ -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))
-89
View File
@@ -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}",
)
+1 -4
View File
@@ -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">