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.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)
-15
View File
@@ -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")
-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
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
name: str
@@ -30,8 +11,6 @@ class ItemCreate(BaseModel):
check_interval_minutes: int = 60
tags: str | None = None
description: str | None = None
notification_profile_id: int | None = None
category: str | None = None
class ItemResponse(ItemCreate):
+1 -7
View File
@@ -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))
-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.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
@@ -125,12 +125,7 @@ export function PriceHistoryModal({ open, onClose, item }) {
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Historique des prix</span>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
<DialogTitle>Historique des prix</DialogTitle>
</DialogHeader>
<div className="space-y-4">