Files
Priceflow/app/services/item_service.py
T

179 lines
6.9 KiB
Python

import logging
import os
from datetime import UTC, datetime
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app import models, schemas
from app.services.settings_service import SettingsService
from app.url_validation import URLValidationError, validate_url
logger = logging.getLogger(__name__)
class ItemService:
@staticmethod
def get_items(db: Session):
import glob
items = db.query(models.Item).all()
result = []
for item in items:
# Strategy: Look for the latest screenshot file on disk
# Format: item_{id}_{timestamp}.png
screenshot_url = None
try:
# Find all timestamped screenshots for this item
files = glob.glob(f"screenshots/item_{item.id}_*.png")
if files:
# Sort by timestamp valid in filename
# We expect format item_ID_TIMESTAMP.png
def get_timestamp(f):
try:
# Extract timestamp part: remove extension, split by _, take last part
ts_str = os.path.splitext(f)[0].split('_')[-1]
return float(ts_str)
except (ValueError, IndexError):
return 0.0
latest_file = max(files, key=get_timestamp)
screenshot_url = f"/screenshots/{os.path.basename(latest_file)}"
# Primary fallback: Legacy static file
elif os.path.exists(f"screenshots/item_{item.id}.png"):
screenshot_url = f"/screenshots/item_{item.id}.png"
# Secondary fallback: use DB history if for some reason file scan missed but DB has record
# (This is less likely to be useful if we assume files exist, but good for safety)
if not screenshot_url:
latest_history = (
db.query(models.PriceHistory)
.filter(models.PriceHistory.item_id == item.id)
.filter(models.PriceHistory.screenshot_path.isnot(None))
.order_by(models.PriceHistory.timestamp.desc())
.first()
)
if latest_history and latest_history.screenshot_path:
filename = os.path.basename(latest_history.screenshot_path)
# Verify it exists
if os.path.exists(f"screenshots/{filename}"):
screenshot_url = f"/screenshots/{filename}"
except Exception as e:
logger.error(f"Error determining screenshot for item {item.id}: {e}")
# Ultimate fallback
if os.path.exists(f"screenshots/item_{item.id}.png"):
screenshot_url = f"/screenshots/item_{item.id}.png"
result.append({**item.__dict__, "screenshot_url": screenshot_url})
return result
@staticmethod
def create_item(db: Session, item: schemas.ItemCreate):
logger.info(f"Creating item: {item.name} - {item.url}")
try:
validate_url(item.url)
except URLValidationError as e:
raise HTTPException(status_code=400, detail=f"Invalid URL: {e}") from e
db_item = models.Item(**item.model_dump())
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
@staticmethod
def update_item(db: Session, item_id: int, item_update: schemas.ItemCreate):
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
for key, value in item_update.model_dump().items():
setattr(db_item, key, value)
db.commit()
db.refresh(db_item)
return db_item
@staticmethod
def delete_item(db: Session, item_id: int):
import glob
item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
# Delete all associated screenshots (legacy and timestamped)
for file_path in glob.glob(f"screenshots/item_{item_id}*.png"):
try:
os.remove(file_path)
logger.info(f"Deleted screenshot: {file_path}")
except OSError as e:
logger.warning(f"Failed to delete {file_path}: {e}")
db.delete(item)
db.commit()
return {"ok": True}
@staticmethod
def get_item(db: Session, item_id: int):
return db.query(models.Item).filter(models.Item.id == item_id).first()
@staticmethod
def get_item_data_for_checking(db: Session, item_id: int):
item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not item:
return None, None
settings = {s.key: s.value for s in db.query(models.Settings).all()}
channel = item.notification_channel
item_data = {
"id": item.id,
"url": item.url,
"selector": item.selector,
"name": item.name,
"current_price": item.current_price,
"in_stock": item.in_stock,
"target_price": item.target_price,
"notification_channel": channel,
}
config = {
"smart_scroll": settings.get("smart_scroll_enabled", "false").lower() == "true",
"smart_scroll_pixels": int(settings.get("smart_scroll_pixels", "350")),
"text_context_enabled": settings.get("text_context_enabled", "false").lower() == "true",
"text_length": int(settings.get("text_context_length", "5000"))
if settings.get("text_context_enabled", "false").lower() == "true"
else 0,
"scraper_timeout": int(settings.get("scraper_timeout", "90000")),
}
return item_data, config
@staticmethod
def get_due_items(db: Session):
items = db.query(models.Item).filter(models.Item.is_active).all()
global_interval = int(SettingsService.get_setting_value(db, "refresh_interval_minutes", "60"))
due_items = []
now = datetime.now(UTC)
for item in items:
if item.is_refreshing:
continue
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))
continue
last_checked = (
item.last_checked.replace(tzinfo=UTC) if item.last_checked.tzinfo is None else item.last_checked
)
time_since = (now - last_checked).total_seconds() / 60
if time_since >= interval:
due_items.append((item.id, interval, int(time_since)))
return due_items