diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c98b450..6baabc8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -77,14 +77,15 @@ ignore = [] python_version = "3.14" mypy_path = "src" explicit_package_bases = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +# Overrides must stay last: any key after a [[...overrides]] table belongs to it. [[tool.mypy.overrides]] # APScheduler 3.x ships no type stubs. module = "apscheduler.*" ignore_missing_imports = true -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/backend/src/api/main.py b/backend/src/api/main.py index 8f4deb4..d76d523 100644 --- a/backend/src/api/main.py +++ b/backend/src/api/main.py @@ -8,6 +8,7 @@ - Lifespan events for startup/shutdown """ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any @@ -51,7 +52,7 @@ @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app: FastAPI) -> AsyncIterator[None]: """ Application lifespan manager. @@ -201,7 +202,7 @@ async def lifespan(app: FastAPI): @app.get("/", tags=["root"]) -async def root(): +async def root() -> dict[str, Any]: """ Root endpoint. @@ -217,7 +218,7 @@ async def root(): @app.get("/health", tags=["root"]) -async def health_check(): +async def health_check() -> dict[str, str]: """ Simple health check endpoint. diff --git a/backend/src/api/routers/system.py b/backend/src/api/routers/system.py index 6200baf..7b94fa1 100644 --- a/backend/src/api/routers/system.py +++ b/backend/src/api/routers/system.py @@ -189,7 +189,7 @@ async def clear_logs( async def export_logs( notification_service: NotificationServiceDep, format: str = Query(default="json", description="Export format (json or csv)"), -): +) -> StreamingResponse: """ Export all activity logs. diff --git a/backend/src/api/routers/websocket.py b/backend/src/api/routers/websocket.py index 9cdd73e..8316a6d 100644 --- a/backend/src/api/routers/websocket.py +++ b/backend/src/api/routers/websocket.py @@ -12,7 +12,7 @@ @router.websocket("/events") -async def websocket_endpoint(websocket: WebSocket): +async def websocket_endpoint(websocket: WebSocket) -> None: """ WebSocket endpoint for real-time event streaming. diff --git a/backend/src/api/schemas/giveaway.py b/backend/src/api/schemas/giveaway.py index d154815..b92ab7f 100644 --- a/backend/src/api/schemas/giveaway.py +++ b/backend/src/api/schemas/giveaway.py @@ -5,6 +5,7 @@ """ from datetime import datetime +from typing import Any from pydantic import BaseModel, Field, field_serializer @@ -166,7 +167,7 @@ class GiveawayResponse(GiveawayBase): ) @field_serializer('end_time', 'discovered_at', 'entered_at', 'won_at', 'eligibility_checked_at') - def serialize_datetime(self, dt: datetime | None, _info) -> str | None: + def serialize_datetime(self, dt: datetime | None, _info: Any) -> str | None: """Serialize datetime with UTC timezone suffix.""" if dt is None: return None diff --git a/backend/src/api/schemas/settings.py b/backend/src/api/schemas/settings.py index 71a31d5..25c8444 100644 --- a/backend/src/api/schemas/settings.py +++ b/backend/src/api/schemas/settings.py @@ -6,7 +6,7 @@ from datetime import datetime -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, ValidationInfo, field_validator class SettingsBase(BaseModel): @@ -137,7 +137,7 @@ class SettingsBase(BaseModel): @field_validator("entry_delay_max") @classmethod - def validate_delay_range(cls, v, info): + def validate_delay_range(cls, v: int, info: ValidationInfo) -> int: """Validate that entry_delay_max >= entry_delay_min.""" if "entry_delay_min" in info.data and v < info.data["entry_delay_min"]: raise ValueError("entry_delay_max must be >= entry_delay_min") @@ -145,7 +145,7 @@ def validate_delay_range(cls, v, info): @field_validator("autojoin_stop_at") @classmethod - def validate_point_thresholds(cls, v, info): + def validate_point_thresholds(cls, v: int, info: ValidationInfo) -> int: """Validate that autojoin_stop_at <= autojoin_start_at.""" if "autojoin_start_at" in info.data and v > info.data["autojoin_start_at"]: raise ValueError("autojoin_stop_at must be <= autojoin_start_at") @@ -375,7 +375,7 @@ class SteamGiftsCredentials(BaseModel): @field_validator("phpsessid") @classmethod - def validate_phpsessid(cls, v): + def validate_phpsessid(cls, v: str) -> str: """Validate PHPSESSID is not empty after stripping.""" if not v or not v.strip(): raise ValueError("phpsessid cannot be empty") diff --git a/backend/src/core/events.py b/backend/src/core/events.py index 89b9064..e5851b0 100644 --- a/backend/src/core/events.py +++ b/backend/src/core/events.py @@ -44,7 +44,7 @@ class EventManager: active_connections: Set of currently connected WebSocket clients """ - def __init__(self): + def __init__(self) -> None: """Initialize EventManager with empty connection set.""" self.active_connections: set[WebSocket] = set() diff --git a/backend/src/db/session.py b/backend/src/db/session.py index 0fed8ac..ae17c08 100644 --- a/backend/src/db/session.py +++ b/backend/src/db/session.py @@ -75,7 +75,7 @@ async def init_db() -> None: from alembic import command - def run_migrations(): + def run_migrations() -> None: # Get the directory where alembic.ini is located src_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) alembic_ini = os.path.join(src_dir, "alembic.ini") diff --git a/backend/src/repositories/base.py b/backend/src/repositories/base.py index b241863..2387372 100644 --- a/backend/src/repositories/base.py +++ b/backend/src/repositories/base.py @@ -103,7 +103,7 @@ async def get_all( result = await self.session.execute(query) return list(result.scalars().all()) - async def create(self, **kwargs) -> ModelType: + async def create(self, **kwargs: Any) -> ModelType: """ Create and persist a new record. @@ -130,7 +130,7 @@ async def create(self, **kwargs) -> ModelType: await self.session.flush() # Flush to get auto-generated fields return instance - async def update(self, id_value: Any, **kwargs) -> ModelType | None: + async def update(self, id_value: Any, **kwargs: Any) -> ModelType | None: """ Update an existing record by primary key. @@ -247,7 +247,7 @@ async def bulk_create(self, items: list[dict[str, Any]]) -> list[ModelType]: await self.session.flush() return instances - async def filter_by(self, **kwargs) -> list[ModelType]: + async def filter_by(self, **kwargs: Any) -> list[ModelType]: """ Filter records by field values. @@ -267,7 +267,7 @@ async def filter_by(self, **kwargs) -> list[ModelType]: result = await self.session.execute(query) return list(result.scalars().all()) - async def get_one_or_none(self, **kwargs) -> ModelType | None: + async def get_one_or_none(self, **kwargs: Any) -> ModelType | None: """ Get a single record matching the filter criteria. diff --git a/backend/src/repositories/game.py b/backend/src/repositories/game.py index 8639649..11d4719 100644 --- a/backend/src/repositories/game.py +++ b/backend/src/repositories/game.py @@ -6,6 +6,7 @@ """ from datetime import timedelta +from typing import Any from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -67,7 +68,7 @@ async def get_by_app_id(self, app_id: int) -> Game | None: """ return await self.get_by_id(app_id) - async def get_by_ids(self, app_ids) -> dict[int, Game]: + async def get_by_ids(self, app_ids: list[int]) -> dict[int, Game]: """ Batch-fetch games by Steam App ID, keyed by id. @@ -270,7 +271,7 @@ async def bulk_mark_refreshed(self, app_ids: list[int]) -> None: for app_id in app_ids: await self.update(app_id, last_refreshed_at=now) - async def create_or_update(self, app_id: int, **kwargs) -> Game: + async def create_or_update(self, app_id: int, **kwargs: Any) -> Game: """ Create a new game or update existing one. diff --git a/backend/src/repositories/giveaway.py b/backend/src/repositories/giveaway.py index 0dbacc7..e48aa6f 100644 --- a/backend/src/repositories/giveaway.py +++ b/backend/src/repositories/giveaway.py @@ -6,6 +6,7 @@ """ from datetime import datetime, timedelta +from typing import Any from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -798,7 +799,7 @@ async def get_stats_since(self, since: datetime) -> dict: } async def create_or_update_by_code( - self, code: str, **kwargs + self, code: str, **kwargs: Any ) -> Giveaway: """ Create new giveaway or update existing by code (upsert). diff --git a/backend/src/repositories/settings.py b/backend/src/repositories/settings.py index 22a61cb..4667cd7 100644 --- a/backend/src/repositories/settings.py +++ b/backend/src/repositories/settings.py @@ -6,6 +6,8 @@ """ +from typing import Any + from sqlalchemy.ext.asyncio import AsyncSession from models.settings import Settings @@ -71,7 +73,7 @@ async def get_settings(self) -> Settings: return settings - async def update_settings(self, **kwargs) -> Settings: + async def update_settings(self, **kwargs: Any) -> Settings: """ Update the singleton settings record. diff --git a/backend/src/services/eligibility.py b/backend/src/services/eligibility.py index 8bb7f7c..96a4503 100644 --- a/backend/src/services/eligibility.py +++ b/backend/src/services/eligibility.py @@ -16,6 +16,7 @@ from dataclasses import dataclass from datetime import datetime +from typing import Any # === Reason codes (stored on Giveaway.eligibility_reason) === ELIGIBLE = "eligible" @@ -68,7 +69,7 @@ def needs_game_data(self) -> bool: ) -def evaluate_eligibility(giveaway, game, criteria: EligibilityCriteria, now: datetime) -> str: +def evaluate_eligibility(giveaway: Any, game: Any, criteria: EligibilityCriteria, now: datetime) -> str: """Return the reason code describing this giveaway's autojoin outcome. Conditions are checked in a fixed precedence so that a multi-failure giveaway diff --git a/backend/src/services/giveaway_entry.py b/backend/src/services/giveaway_entry.py new file mode 100644 index 0000000..44bb86f --- /dev/null +++ b/backend/src/services/giveaway_entry.py @@ -0,0 +1,272 @@ +"""Entry-side methods of GiveawayService: entering giveaways (with optional +inline safety check), safety scoring, hiding on SteamGifts, comments, points.""" + +import structlog +from sqlalchemy.ext.asyncio import AsyncSession + +from core.exceptions import SteamGiftsError +from models.entry import Entry +from repositories.entry import EntryRepository +from repositories.giveaway import GiveawayRepository +from utils.steamgifts_client import SteamGiftsClient + +logger = structlog.get_logger() + + +class GiveawayEntryMixin: + """Giveaway Entrymethods of GiveawayService (see services.giveaway_service). + + Mixin: expects the attributes declared below to be set by the composing + class's ``__init__`` (services.giveaway_service.GiveawayService). + """ + + session: AsyncSession + sg_client: SteamGiftsClient + giveaway_repo: GiveawayRepository + entry_repo: EntryRepository + + async def enter_giveaway( + self, giveaway_code: str, entry_type: str = "manual" + ) -> Entry | None: + """ + Enter a giveaway and record the entry. + + Args: + giveaway_code: Giveaway code to enter + entry_type: Type of entry ("manual", "auto", "wishlist") + + Returns: + Entry object if successful, None otherwise + + Example: + >>> entry = await service.enter_giveaway("AbCd1", entry_type="auto") + >>> if entry: + ... print(f"Entered! Spent {entry.points_spent} points") + """ + # Get giveaway + giveaway = await self.giveaway_repo.get_by_code(giveaway_code) + if not giveaway: + logger.warning("giveaway_not_found", code=giveaway_code) + return None + + # Check if already entered + existing_entry = await self.entry_repo.get_by_giveaway(giveaway.id) + if existing_entry: + logger.info("giveaway_already_entered", code=giveaway_code) + return existing_entry + + # Try to enter + try: + success = await self.sg_client.enter_giveaway(giveaway_code) + + if success: + # Mark as entered + await self.giveaway_repo.mark_entered(giveaway.id) + + # Create entry record + entry = await self.entry_repo.create( + giveaway_id=giveaway.id, + points_spent=giveaway.price, + entry_type=entry_type, + status="success", + ) + await self.session.commit() + + return entry + else: + # Entry failed + entry = await self.entry_repo.create( + giveaway_id=giveaway.id, + points_spent=0, + entry_type=entry_type, + status="failed", + error_message="SteamGifts returned failure", + ) + await self.session.commit() + + return None + + except SteamGiftsError as e: + # Record failed entry + entry = await self.entry_repo.create( + giveaway_id=giveaway.id, + points_spent=0, + entry_type=entry_type, + status="failed", + error_message=str(e), + ) + await self.session.commit() + + logger.error("giveaway_entry_error", code=giveaway_code, error=str(e)) + return None + + async def enter_giveaway_with_safety_check( + self, giveaway_code: str, entry_type: str = "auto" + ) -> Entry | None: + """ + Enter a giveaway with safety check. + + Checks if the giveaway is safe before entering. If unsafe, + the giveaway is hidden instead of entered. + + Args: + giveaway_code: Giveaway code to enter + entry_type: Type of entry ("manual", "auto", "wishlist") + + Returns: + Entry object if successful, None if failed or unsafe + + Example: + >>> entry = await service.enter_giveaway_with_safety_check("AbCd1") + >>> if entry: + ... print("Entered safely!") + """ + # First check safety + try: + safety = await self.check_giveaway_safety(giveaway_code) + + if not safety["is_safe"]: + logger.warning("giveaway_unsafe", code=giveaway_code, details=safety["details"]) + + # Try to hide it on SteamGifts + await self.hide_on_steamgifts(giveaway_code) + + # Record failed entry with reason + giveaway = await self.giveaway_repo.get_by_code(giveaway_code) + if giveaway: + await self.entry_repo.create( + giveaway_id=giveaway.id, + points_spent=0, + entry_type=entry_type, + status="failed", + error_message=f"Unsafe giveaway: {', '.join(safety['details'])}", + ) + await self.session.commit() + + return None + + except Exception as e: + logger.error("safety_check_failed", code=giveaway_code, error=str(e)) + # Continue without safety check if it fails + + # Proceed with normal entry + return await self.enter_giveaway(giveaway_code, entry_type) + + async def check_giveaway_safety(self, giveaway_code: str) -> dict: + """ + Check if a giveaway is safe to enter (trap detection). + + Analyzes the giveaway page content for warning signs that might + indicate a trap or scam giveaway (e.g., "don't enter", "ban", "fake"). + + Args: + giveaway_code: Giveaway code to check + + Returns: + Dictionary with safety check results: + - is_safe: True if giveaway appears safe + - safety_score: Confidence score (0-100) + - details: List of found warning words + + Example: + >>> safety = await service.check_giveaway_safety("AbCd1") + >>> if not safety['is_safe']: + ... print(f"Warning: {safety['details']}") + """ + # Check safety via client + safety_result = await self.sg_client.check_giveaway_safety(giveaway_code) + + # Update giveaway in database with safety info + giveaway = await self.giveaway_repo.get_by_code(giveaway_code) + if giveaway: + giveaway.is_safe = safety_result["is_safe"] + giveaway.safety_score = safety_result["safety_score"] + await self.session.commit() + + return safety_result + + async def hide_on_steamgifts(self, giveaway_code: str) -> bool: + """ + Hide a game on SteamGifts (removes from all future giveaway lists). + + This sends a request to SteamGifts to hide all giveaways for the + game associated with this giveaway. Also marks the giveaway as + hidden in the local database. + + Args: + giveaway_code: Giveaway code to hide + + Returns: + True if hidden successfully, False otherwise + + Example: + >>> success = await service.hide_on_steamgifts("AbCd1") + >>> if success: + ... print("Game hidden on SteamGifts") + """ + # Get the game_id for this giveaway + game_id = await self.sg_client.get_giveaway_game_id(giveaway_code) + + if not game_id: + logger.warning("game_id_lookup_failed", code=giveaway_code) + return False + + # Hide on SteamGifts + try: + await self.sg_client.hide_giveaway(game_id) + except SteamGiftsError as e: + logger.error("steamgifts_hide_failed", code=giveaway_code, error=str(e)) + return False + + # Also hide locally + giveaway = await self.giveaway_repo.get_by_code(giveaway_code) + if giveaway: + giveaway.is_hidden = True + await self.session.commit() + + return True + + async def post_comment( + self, giveaway_code: str, comment_text: str = "Thanks!" + ) -> bool: + """ + Post a comment on a giveaway. + + Args: + giveaway_code: Giveaway code + comment_text: Comment text to post + + Returns: + True if comment posted successfully, False otherwise + + Example: + >>> success = await service.post_comment("AbCd1", "Thanks!") + >>> if success: + ... print("Comment posted!") + """ + try: + return await self.sg_client.post_comment(giveaway_code, comment_text) + except SteamGiftsError as e: + logger.error("comment_post_failed", code=giveaway_code, error=str(e)) + return False + + async def get_current_points(self) -> int: + """ + Get current user points from SteamGifts. + + Returns: + Current points balance + + Raises: + SteamGiftsError: If unable to fetch points + + Example: + >>> points = await service.get_current_points() + >>> print(f"Current points: {points}P") + """ + try: + return await self.sg_client.get_user_points() + except SteamGiftsError as e: + # If we can't fetch points, return 0 rather than failing + logger.error("points_fetch_failed", error=str(e)) + return 0 diff --git a/backend/src/services/giveaway_query.py b/backend/src/services/giveaway_query.py new file mode 100644 index 0000000..0142047 --- /dev/null +++ b/backend/src/services/giveaway_query.py @@ -0,0 +1,497 @@ +"""Read-side methods of GiveawayService: listings, search, stats, autojoin +eligibility selection, and API-response enrichment.""" + +from collections import Counter + +import structlog +from sqlalchemy.ext.asyncio import AsyncSession + +from core.time import utcnow +from models.entry import Entry +from models.giveaway import Giveaway +from repositories.entry import EntryRepository +from repositories.giveaway import GiveawayRepository +from services.eligibility import ELIGIBLE, EligibilityCriteria, evaluate_eligibility +from services.game_service import GameService + +logger = structlog.get_logger() + + +class GiveawayQueryMixin: + """Giveaway Querymethods of GiveawayService (see services.giveaway_service). + + Mixin: expects the attributes declared below to be set by the composing + class's ``__init__`` (services.giveaway_service.GiveawayService). + """ + + session: AsyncSession + game_service: GameService + giveaway_repo: GiveawayRepository + entry_repo: EntryRepository + + async def get_won_giveaways( + self, limit: int = 50, offset: int = 0 + ) -> list[Giveaway]: + """ + Get all won giveaways from database. + + Args: + limit: Maximum number to return + offset: Number of records to skip + + Returns: + List of won giveaways, ordered by won_at (most recent first) + """ + return await self.giveaway_repo.get_won(limit=limit, offset=offset) + + async def get_win_count(self) -> int: + """ + Get total number of wins. + + Returns: + Total number of won giveaways + """ + return await self.giveaway_repo.count_won() + + async def get_eligible_giveaways( + self, + min_price: int = 0, + max_price: int | None = None, + min_score: int | None = None, + min_reviews: int | None = None, + max_game_age: int | None = None, + limit: int = 50, + ) -> list[Giveaway]: + """ + Get eligible giveaways based on criteria. + + Filters active giveaways that: + - Haven't been entered yet + - Aren't hidden + - Meet price criteria + - Meet game rating criteria (if specified) + - Meet game age criteria (if specified) + + Args: + min_price: Minimum giveaway price + max_price: Maximum giveaway price + min_score: Minimum game review score (0-10) + min_reviews: Minimum number of reviews + max_game_age: Maximum game age in years (None = no limit) + limit: Maximum results to return + + Returns: + List of eligible giveaways + + Example: + >>> eligible = await service.get_eligible_giveaways( + ... min_price=50, + ... max_price=200, + ... min_score=8, + ... max_game_age=5, + ... limit=10 + ... ) + """ + giveaways = await self.giveaway_repo.get_eligible( + min_price=min_price, + max_price=max_price, + min_score=min_score, + min_reviews=min_reviews, + max_game_age=max_game_age, + limit=limit, + ) + + return giveaways + + async def evaluate_and_get_eligible( + self, criteria: EligibilityCriteria, limit: int | None = None + ) -> list[Giveaway]: + """ + Evaluate every active candidate, record why each one did or didn't qualify, + and return the eligible giveaways (highest price first, optionally limited). + + This is the decision step for the automation/process cycle. Every active, + not-entered giveaway gets ``eligibility_reason`` and ``eligibility_checked_at`` + persisted, so the UI can show why a giveaway wasn't entered. The returned + set is identical to :meth:`get_eligible_giveaways` for the same criteria — + only the recorded reasons are new (see :mod:`services.eligibility`). + + Args: + criteria: the active autojoin thresholds. + limit: maximum number of eligible giveaways to return (None = all). + + Returns: + Eligible giveaways, ordered by price descending. + """ + now = utcnow() + candidates = await self.giveaway_repo.get_active_unentered() + + games = await self.game_service.repo.get_by_ids( + [g.game_id for g in candidates if g.game_id] + ) + + eligible: list[Giveaway] = [] + for giveaway in candidates: + game = games.get(giveaway.game_id) if giveaway.game_id else None + reason = evaluate_eligibility(giveaway, game, criteria, now) + giveaway.eligibility_reason = reason + giveaway.eligibility_checked_at = now + if reason == ELIGIBLE: + eligible.append(giveaway) + + await self.session.commit() + + # Developer observability (not the user-facing ActivityLog): a one-line + # breakdown of why the candidate pool did/didn't qualify this cycle. + counts = Counter(g.eligibility_reason or "unknown" for g in candidates) + logger.info("eligibility_evaluated", total=len(candidates), **dict(counts)) + + # candidates are already ordered by price desc, so eligible is too. + return eligible[:limit] if limit else eligible + + async def get_active_giveaways( + self, limit: int | None = None, offset: int = 0, min_score: int | None = None, + is_safe: bool | None = None + ) -> list[Giveaway]: + """ + Get all active (non-expired) giveaways. + + Args: + limit: Maximum number to return + offset: Number of records to skip (for pagination) + min_score: Minimum review score (0-10) to filter by + is_safe: Filter by safety status (True=safe only, False=unsafe only, None=all) + + Returns: + List of active giveaways + + Example: + >>> active = await service.get_active_giveaways(limit=20, offset=40, min_score=7, is_safe=True) + """ + return await self.giveaway_repo.get_active(limit=limit, offset=offset, min_score=min_score, is_safe=is_safe) + + async def get_all_giveaways( + self, limit: int | None = None, offset: int = 0 + ) -> list[Giveaway]: + """ + Get all giveaways (including expired ones). + + Args: + limit: Maximum number to return + offset: Number of records to skip (for pagination) + + Returns: + List of all giveaways + + Example: + >>> all_giveaways = await service.get_all_giveaways(limit=20, offset=0) + """ + return await self.giveaway_repo.get_all(limit=limit, offset=offset) + + async def get_entered_giveaways( + self, limit: int | None = None, active_only: bool = False + ) -> list[Giveaway]: + """ + Get entered giveaways. + + Args: + limit: Maximum number to return + active_only: If True, only return non-expired giveaways + + Returns: + List of entered giveaways + + Example: + >>> entered = await service.get_entered_giveaways(limit=20, active_only=True) + """ + return await self.giveaway_repo.get_entered(limit=limit, active_only=active_only) + + async def get_expiring_soon( + self, hours: int = 24, limit: int | None = None + ) -> list[Giveaway]: + """ + Get giveaways expiring within specified hours. + + Args: + hours: Number of hours + limit: Maximum number to return + + Returns: + List of giveaways expiring soon + + Example: + >>> expiring = await service.get_expiring_soon(hours=6, limit=10) + """ + return await self.giveaway_repo.get_expiring_soon(hours=hours, limit=limit) + + async def enrich_giveaways_with_game_data( + self, giveaways: list[Giveaway] + ) -> list[Giveaway]: + """ + Enrich giveaways with game data (thumbnail, reviews). + + For each giveaway with a game_id, fetches the Game data and populates: + - game_thumbnail: Steam header image URL + - game_review_score: Review score (0-10) + - game_total_reviews: Total number of reviews + - game_review_summary: Text summary ("Overwhelmingly Positive", etc.) + + Args: + giveaways: List of giveaway objects to enrich + + Returns: + The same list of giveaways, enriched with game data + + Example: + >>> giveaways = await service.get_active_giveaways(limit=10) + >>> enriched = await service.enrich_giveaways_with_game_data(giveaways) + """ + for giveaway in giveaways: + if not giveaway.game_id: + continue + + # Fetch game data (from cache or Steam API) + try: + game = await self.game_service.get_or_fetch_game(giveaway.game_id) + if not game: + continue + except Exception: + # Game fetch failed, skip enrichment for this giveaway + continue + + # Set thumbnail URL (from stored header_image or fallback to CDN URL) + giveaway.game_thumbnail = ( + game.header_image or + f"https://cdn.cloudflare.steamstatic.com/steam/apps/{game.id}/header.jpg" + ) + + # Set review data + giveaway.game_review_score = game.review_score + giveaway.game_total_reviews = game.total_reviews + + # Generate review summary based on score and review count + if game.review_score is not None and game.total_reviews is not None: + giveaway.game_review_summary = self._generate_review_summary( + game.review_score, game.total_reviews + ) + + return giveaways + + def _generate_review_summary( + self, review_score: int, total_reviews: int + ) -> str: + """ + Generate Steam-style review summary text. + + Args: + review_score: Review score (0-10 scale) + total_reviews: Total number of reviews + + Returns: + Summary text like "Overwhelmingly Positive", "Mixed", etc. + + Example: + >>> service._generate_review_summary(9, 50000) + 'Overwhelmingly Positive' + """ + # Convert 0-10 scale to percentage + percentage = review_score * 10 + + # Not enough reviews + if total_reviews < 10: + return "Not Enough Reviews" + + # Determine sentiment based on Steam's algorithm + # https://partner.steamgames.com/doc/store/reviews + if total_reviews >= 500: + # High review count - can be "Overwhelmingly" tier + if percentage >= 95: + return "Overwhelmingly Positive" + elif percentage >= 80: + return "Very Positive" + elif percentage >= 70: + return "Positive" + elif percentage >= 40: + return "Mixed" + elif percentage >= 20: + return "Negative" + else: + return "Overwhelmingly Negative" + else: + # Lower review count - regular tiers only + if percentage >= 80: + return "Very Positive" + elif percentage >= 70: + return "Positive" + elif percentage >= 40: + return "Mixed" + elif percentage >= 20: + return "Negative" + else: + return "Very Negative" + + async def hide_giveaway(self, giveaway_code: str) -> bool: + """ + Hide a giveaway from future recommendations. + + Args: + giveaway_code: Giveaway code to hide + + Returns: + True if hidden, False if not found + + Example: + >>> await service.hide_giveaway("AbCd1") + """ + giveaway = await self.giveaway_repo.get_by_code(giveaway_code) + if not giveaway: + return False + + await self.giveaway_repo.hide_giveaway(giveaway.id) + await self.session.commit() + return True + + async def unhide_giveaway(self, giveaway_code: str) -> bool: + """ + Unhide a previously hidden giveaway. + + Args: + giveaway_code: Giveaway code to unhide + + Returns: + True if unhidden, False if not found + + Example: + >>> await service.unhide_giveaway("AbCd1") + """ + giveaway = await self.giveaway_repo.get_by_code(giveaway_code) + if not giveaway: + return False + + await self.giveaway_repo.unhide_giveaway(giveaway.id) + await self.session.commit() + return True + + async def remove_entry(self, giveaway_code: str) -> bool: + """ + Remove an entry for a giveaway. + + This marks the giveaway as not entered and deletes the entry record. + + Args: + giveaway_code: Code of the giveaway to remove entry from + + Returns: + True if entry was removed, False if not found or not entered + + Example: + >>> removed = await service.remove_entry("AbCd1") + >>> if removed: + ... print("Entry removed successfully") + """ + giveaway = await self.giveaway_repo.get_by_code(giveaway_code) + if not giveaway: + return False + + if not giveaway.is_entered: + return False + + # Find the entry + entry = await self.entry_repo.get_by_giveaway(giveaway.id) + if entry: + # Delete the entry + await self.entry_repo.delete(entry.id) + + # Mark giveaway as not entered + giveaway.is_entered = False + giveaway.entered_at = None + await self.session.commit() + + return True + + async def search_giveaways( + self, query: str, limit: int | None = 20 + ) -> list[Giveaway]: + """ + Search giveaways by game name. + + Args: + query: Search query + limit: Maximum results to return + + Returns: + List of matching giveaways + + Example: + >>> results = await service.search_giveaways("portal") + """ + return await self.giveaway_repo.search_by_game_name(query, limit=limit) + + async def get_entry_history( + self, limit: int = 50, status: str | None = None + ) -> list[Entry]: + """ + Get entry history. + + Args: + limit: Maximum results to return + status: Filter by status ("success", "failed", "pending") + + Returns: + List of entries + + Example: + >>> history = await service.get_entry_history(limit=20) + >>> for entry in history: + ... print(f"Spent {entry.points_spent} points") + """ + if status: + return await self.entry_repo.get_by_status(status, limit=limit) + else: + return await self.entry_repo.get_recent(limit=limit) + + async def get_entry_stats(self) -> dict: + """ + Get comprehensive entry statistics. + + Returns: + Dictionary with entry statistics + + Example: + >>> stats = await service.get_entry_stats() + >>> print(f"Success rate: {stats['success_rate']:.1f}%") + """ + return await self.entry_repo.get_stats() + + async def get_giveaway_stats(self) -> dict: + """ + Get giveaway statistics. + + Returns: + Dictionary with giveaway statistics: + - total: Total giveaways in database + - active: Active (non-expired) giveaways + - entered: Giveaways we've entered + - hidden: Hidden giveaways + - wins: Total wins + - win_rate: Win rate percentage + + Example: + >>> stats = await service.get_giveaway_stats() + >>> print(f"Active giveaways: {stats['active']}") + """ + total = await self.giveaway_repo.count() + active = await self.giveaway_repo.count_active() + entered = await self.giveaway_repo.count_entered() + hidden = len(await self.giveaway_repo.get_hidden()) + wins = await self.giveaway_repo.count_won() + win_rate = (wins / entered * 100) if entered > 0 else 0.0 + + return { + "total": total, + "active": active, + "entered": entered, + "hidden": hidden, + "wins": wins, + "win_rate": win_rate, + } diff --git a/backend/src/services/giveaway_service.py b/backend/src/services/giveaway_service.py index 2273eb6..279e4db 100644 --- a/backend/src/services/giveaway_service.py +++ b/backend/src/services/giveaway_service.py @@ -1,28 +1,26 @@ """Giveaway service with business logic for giveaway management. This module provides the service layer for giveaway operations, coordinating -between repositories and external SteamGifts client. -""" +between repositories and the external SteamGifts client. The implementation is +split by concern across three mixins; this class is the single public facade: -from collections import Counter +- :class:`services.giveaway_sync.GiveawaySyncMixin` — scrape -> cache sync steps +- :class:`services.giveaway_entry.GiveawayEntryMixin` — entering + safety + hide +- :class:`services.giveaway_query.GiveawayQueryMixin` — listings, stats, eligibility +""" -import structlog from sqlalchemy.ext.asyncio import AsyncSession -from core.exceptions import SteamGiftsError -from core.time import utcnow -from models.entry import Entry -from models.giveaway import Giveaway from repositories.entry import EntryRepository from repositories.giveaway import GiveawayRepository -from services.eligibility import ELIGIBLE, EligibilityCriteria, evaluate_eligibility from services.game_service import GameService +from services.giveaway_entry import GiveawayEntryMixin +from services.giveaway_query import GiveawayQueryMixin +from services.giveaway_sync import GiveawaySyncMixin from utils.steamgifts_client import SteamGiftsClient -logger = structlog.get_logger() - -class GiveawayService: +class GiveawayService(GiveawaySyncMixin, GiveawayEntryMixin, GiveawayQueryMixin): """ Service for giveaway management. @@ -43,6 +41,7 @@ class GiveawayService: - Service layer handles business logic - Coordinates multiple repositories and services - All methods are async + - Implementation lives in the three mixins listed in the module docstring Usage: >>> async with AsyncSessionLocal() as session: @@ -80,946 +79,3 @@ def __init__( self.game_service = game_service self.giveaway_repo = GiveawayRepository(session) self.entry_repo = EntryRepository(session) - - async def sync_giveaways( - self, - pages: int = 1, - search_query: str | None = None, - giveaway_type: str | None = None, - dlc_only: bool = False, - min_copies: int | None = None, - ) -> tuple[int, int]: - """ - Sync giveaways from SteamGifts to database. - - Fetches giveaways from SteamGifts and caches them in database. - Also caches associated game data. - - Args: - pages: Number of pages to fetch (default: 1) - search_query: Optional search query - giveaway_type: Optional type filter ("wishlist", "recommended", "new", etc.) - dlc_only: If True, only fetch DLC giveaways - min_copies: Minimum number of copies (e.g., 2 for multi-copy) - - Returns: - Tuple of (new_count, updated_count) - - Example: - >>> new, updated = await service.sync_giveaways(pages=3) - >>> print(f"Added {new} new, updated {updated} existing") - - >>> # Sync wishlist giveaways - >>> new, updated = await service.sync_giveaways(pages=2, giveaway_type="wishlist") - - >>> # Sync DLC giveaways - >>> new, updated = await service.sync_giveaways(pages=2, dlc_only=True) - """ - new_count = 0 - updated_count = 0 - - for page in range(1, pages + 1): - try: - giveaways_data = await self.sg_client.get_giveaways( - page=page, - search_query=search_query, - giveaway_type=giveaway_type, - dlc_only=dlc_only, - min_copies=min_copies, - ) - - for ga_data in giveaways_data: - # Check if exists - existing = await self.giveaway_repo.get_by_code(ga_data["code"]) - - # Cache game data if we have game_id - if ga_data.get("game_id"): - try: - await self.game_service.get_or_fetch_game(ga_data["game_id"]) - except Exception as e: - logger.error("game_cache_failed", game_id=ga_data["game_id"], error=str(e)) - - if existing: - # Update existing giveaway - await self._update_giveaway(existing, ga_data) - updated_count += 1 - else: - # Create new giveaway - await self._create_giveaway(ga_data) - new_count += 1 - - except SteamGiftsError as e: - logger.error("giveaway_page_fetch_failed", page=page, error=str(e)) - break - - await self.session.commit() - return new_count, updated_count - - async def sync_wins(self, pages: int = 1) -> int: - """ - Sync won giveaways from SteamGifts to database. - - Fetches the /giveaways/won page and marks matching giveaways as won. - - Args: - pages: Number of pages to fetch (default: 1) - - Returns: - Number of newly detected wins - - Example: - >>> new_wins = await service.sync_wins(pages=2) - >>> print(f"Found {new_wins} new wins!") - """ - - new_wins = 0 - - for page in range(1, pages + 1): - try: - won_data = await self.sg_client.get_won_giveaways(page=page) - - for win in won_data: - # Look up giveaway by code - giveaway = await self.giveaway_repo.get_by_code(win["code"]) - - if giveaway and not giveaway.is_won: - # Mark as won - giveaway.is_won = True - giveaway.won_at = win.get("won_at") or utcnow() - new_wins += 1 - - elif not giveaway: - # Giveaway not in our database - create it as won - url = f"https://www.steamgifts.com/giveaway/{win['code']}/" - await self.giveaway_repo.create( - code=win["code"], - url=url, - game_name=win["game_name"], - price=0, # Unknown price for historical wins - game_id=win.get("game_id"), - is_entered=True, - is_won=True, - won_at=win.get("won_at") or utcnow(), - ) - new_wins += 1 - - except Exception as e: - logger.error("wins_page_fetch_failed", page=page, error=str(e)) - break - - await self.session.commit() - return new_wins - - async def sync_entered_giveaways(self, pages: int = 1) -> int: - """ - Sync entered giveaways from SteamGifts to database. - - Fetches the /giveaways/entered page and marks matching giveaways - as entered in the local database. This ensures our database - stays in sync with what's actually entered on SteamGifts. - - Args: - pages: Number of pages to fetch (default: 1) - - Returns: - Number of giveaways marked as entered - - Example: - >>> synced = await service.sync_entered_giveaways(pages=2) - >>> print(f"Synced {synced} entered giveaways") - """ - synced_count = 0 - - for page in range(1, pages + 1): - try: - entered_data = await self.sg_client.get_entered_giveaways(page=page) - - for entry in entered_data: - # Look up giveaway by code - giveaway = await self.giveaway_repo.get_by_code(entry["code"]) - - if giveaway and not giveaway.is_entered: - # Mark as entered - giveaway.is_entered = True - giveaway.entered_at = entry.get("entered_at") - synced_count += 1 - - elif not giveaway: - # Giveaway not in our database - create it as entered - url = f"https://www.steamgifts.com/giveaway/{entry['code']}/" - await self.giveaway_repo.create( - code=entry["code"], - url=url, - game_name=entry["game_name"], - price=entry.get("price", 0), - game_id=entry.get("game_id"), - end_time=entry.get("end_time"), - is_entered=True, - entered_at=entry.get("entered_at"), - ) - synced_count += 1 - - except Exception as e: - logger.error("entered_page_fetch_failed", page=page, error=str(e)) - break - - await self.session.commit() - return synced_count - - async def get_won_giveaways( - self, limit: int = 50, offset: int = 0 - ) -> list[Giveaway]: - """ - Get all won giveaways from database. - - Args: - limit: Maximum number to return - offset: Number of records to skip - - Returns: - List of won giveaways, ordered by won_at (most recent first) - """ - return await self.giveaway_repo.get_won(limit=limit, offset=offset) - - async def get_win_count(self) -> int: - """ - Get total number of wins. - - Returns: - Total number of won giveaways - """ - return await self.giveaway_repo.count_won() - - async def _create_giveaway(self, ga_data: dict) -> Giveaway: - """ - Create new giveaway from scraped data. - - Args: - ga_data: Giveaway data from SteamGifts - - Returns: - Created Giveaway object - """ - # Build URL from code - url = f"https://www.steamgifts.com/giveaway/{ga_data['code']}/" - - giveaway = await self.giveaway_repo.create( - code=ga_data["code"], - url=url, - game_name=ga_data["game_name"], - price=ga_data["price"], - copies=ga_data.get("copies", 1), - end_time=ga_data.get("end_time"), - game_id=ga_data.get("game_id"), - is_wishlist=ga_data.get("is_wishlist", False), - is_entered=ga_data.get("is_entered", False), - ) - return giveaway - - async def _update_giveaway(self, giveaway: Giveaway, ga_data: dict): - """ - Update existing giveaway from scraped data. - - Args: - giveaway: Existing giveaway object - ga_data: New data from SteamGifts - """ - # Update mutable fields - giveaway.end_time = ga_data.get("end_time", giveaway.end_time) - - # Update game_id if we found it and didn't have it before - if ga_data.get("game_id") and not giveaway.game_id: - giveaway.game_id = ga_data["game_id"] - - # Update wishlist flag (can change from False to True, but not back) - if ga_data.get("is_wishlist"): - giveaway.is_wishlist = True - - async def enter_giveaway( - self, giveaway_code: str, entry_type: str = "manual" - ) -> Entry | None: - """ - Enter a giveaway and record the entry. - - Args: - giveaway_code: Giveaway code to enter - entry_type: Type of entry ("manual", "auto", "wishlist") - - Returns: - Entry object if successful, None otherwise - - Example: - >>> entry = await service.enter_giveaway("AbCd1", entry_type="auto") - >>> if entry: - ... print(f"Entered! Spent {entry.points_spent} points") - """ - # Get giveaway - giveaway = await self.giveaway_repo.get_by_code(giveaway_code) - if not giveaway: - logger.warning("giveaway_not_found", code=giveaway_code) - return None - - # Check if already entered - existing_entry = await self.entry_repo.get_by_giveaway(giveaway.id) - if existing_entry: - logger.info("giveaway_already_entered", code=giveaway_code) - return existing_entry - - # Try to enter - try: - success = await self.sg_client.enter_giveaway(giveaway_code) - - if success: - # Mark as entered - await self.giveaway_repo.mark_entered(giveaway.id) - - # Create entry record - entry = await self.entry_repo.create( - giveaway_id=giveaway.id, - points_spent=giveaway.price, - entry_type=entry_type, - status="success", - ) - await self.session.commit() - - return entry - else: - # Entry failed - entry = await self.entry_repo.create( - giveaway_id=giveaway.id, - points_spent=0, - entry_type=entry_type, - status="failed", - error_message="SteamGifts returned failure", - ) - await self.session.commit() - - return None - - except SteamGiftsError as e: - # Record failed entry - entry = await self.entry_repo.create( - giveaway_id=giveaway.id, - points_spent=0, - entry_type=entry_type, - status="failed", - error_message=str(e), - ) - await self.session.commit() - - logger.error("giveaway_entry_error", code=giveaway_code, error=str(e)) - return None - - async def get_eligible_giveaways( - self, - min_price: int = 0, - max_price: int | None = None, - min_score: int | None = None, - min_reviews: int | None = None, - max_game_age: int | None = None, - limit: int = 50, - ) -> list[Giveaway]: - """ - Get eligible giveaways based on criteria. - - Filters active giveaways that: - - Haven't been entered yet - - Aren't hidden - - Meet price criteria - - Meet game rating criteria (if specified) - - Meet game age criteria (if specified) - - Args: - min_price: Minimum giveaway price - max_price: Maximum giveaway price - min_score: Minimum game review score (0-10) - min_reviews: Minimum number of reviews - max_game_age: Maximum game age in years (None = no limit) - limit: Maximum results to return - - Returns: - List of eligible giveaways - - Example: - >>> eligible = await service.get_eligible_giveaways( - ... min_price=50, - ... max_price=200, - ... min_score=8, - ... max_game_age=5, - ... limit=10 - ... ) - """ - giveaways = await self.giveaway_repo.get_eligible( - min_price=min_price, - max_price=max_price, - min_score=min_score, - min_reviews=min_reviews, - max_game_age=max_game_age, - limit=limit, - ) - - return giveaways - - async def evaluate_and_get_eligible( - self, criteria: EligibilityCriteria, limit: int | None = None - ) -> list[Giveaway]: - """ - Evaluate every active candidate, record why each one did or didn't qualify, - and return the eligible giveaways (highest price first, optionally limited). - - This is the decision step for the automation/process cycle. Every active, - not-entered giveaway gets ``eligibility_reason`` and ``eligibility_checked_at`` - persisted, so the UI can show why a giveaway wasn't entered. The returned - set is identical to :meth:`get_eligible_giveaways` for the same criteria — - only the recorded reasons are new (see :mod:`services.eligibility`). - - Args: - criteria: the active autojoin thresholds. - limit: maximum number of eligible giveaways to return (None = all). - - Returns: - Eligible giveaways, ordered by price descending. - """ - now = utcnow() - candidates = await self.giveaway_repo.get_active_unentered() - - games = await self.game_service.repo.get_by_ids( - [g.game_id for g in candidates if g.game_id] - ) - - eligible: list[Giveaway] = [] - for giveaway in candidates: - game = games.get(giveaway.game_id) if giveaway.game_id else None - reason = evaluate_eligibility(giveaway, game, criteria, now) - giveaway.eligibility_reason = reason - giveaway.eligibility_checked_at = now - if reason == ELIGIBLE: - eligible.append(giveaway) - - await self.session.commit() - - # Developer observability (not the user-facing ActivityLog): a one-line - # breakdown of why the candidate pool did/didn't qualify this cycle. - counts = Counter(g.eligibility_reason or "unknown" for g in candidates) - logger.info("eligibility_evaluated", total=len(candidates), **dict(counts)) - - # candidates are already ordered by price desc, so eligible is too. - return eligible[:limit] if limit else eligible - - async def get_active_giveaways( - self, limit: int | None = None, offset: int = 0, min_score: int | None = None, - is_safe: bool | None = None - ) -> list[Giveaway]: - """ - Get all active (non-expired) giveaways. - - Args: - limit: Maximum number to return - offset: Number of records to skip (for pagination) - min_score: Minimum review score (0-10) to filter by - is_safe: Filter by safety status (True=safe only, False=unsafe only, None=all) - - Returns: - List of active giveaways - - Example: - >>> active = await service.get_active_giveaways(limit=20, offset=40, min_score=7, is_safe=True) - """ - return await self.giveaway_repo.get_active(limit=limit, offset=offset, min_score=min_score, is_safe=is_safe) - - async def get_all_giveaways( - self, limit: int | None = None, offset: int = 0 - ) -> list[Giveaway]: - """ - Get all giveaways (including expired ones). - - Args: - limit: Maximum number to return - offset: Number of records to skip (for pagination) - - Returns: - List of all giveaways - - Example: - >>> all_giveaways = await service.get_all_giveaways(limit=20, offset=0) - """ - return await self.giveaway_repo.get_all(limit=limit, offset=offset) - - async def get_entered_giveaways( - self, limit: int | None = None, active_only: bool = False - ) -> list[Giveaway]: - """ - Get entered giveaways. - - Args: - limit: Maximum number to return - active_only: If True, only return non-expired giveaways - - Returns: - List of entered giveaways - - Example: - >>> entered = await service.get_entered_giveaways(limit=20, active_only=True) - """ - return await self.giveaway_repo.get_entered(limit=limit, active_only=active_only) - - async def get_expiring_soon( - self, hours: int = 24, limit: int | None = None - ) -> list[Giveaway]: - """ - Get giveaways expiring within specified hours. - - Args: - hours: Number of hours - limit: Maximum number to return - - Returns: - List of giveaways expiring soon - - Example: - >>> expiring = await service.get_expiring_soon(hours=6, limit=10) - """ - return await self.giveaway_repo.get_expiring_soon(hours=hours, limit=limit) - - async def enrich_giveaways_with_game_data( - self, giveaways: list[Giveaway] - ) -> list[Giveaway]: - """ - Enrich giveaways with game data (thumbnail, reviews). - - For each giveaway with a game_id, fetches the Game data and populates: - - game_thumbnail: Steam header image URL - - game_review_score: Review score (0-10) - - game_total_reviews: Total number of reviews - - game_review_summary: Text summary ("Overwhelmingly Positive", etc.) - - Args: - giveaways: List of giveaway objects to enrich - - Returns: - The same list of giveaways, enriched with game data - - Example: - >>> giveaways = await service.get_active_giveaways(limit=10) - >>> enriched = await service.enrich_giveaways_with_game_data(giveaways) - """ - for giveaway in giveaways: - if not giveaway.game_id: - continue - - # Fetch game data (from cache or Steam API) - try: - game = await self.game_service.get_or_fetch_game(giveaway.game_id) - if not game: - continue - except Exception: - # Game fetch failed, skip enrichment for this giveaway - continue - - # Set thumbnail URL (from stored header_image or fallback to CDN URL) - giveaway.game_thumbnail = ( - game.header_image or - f"https://cdn.cloudflare.steamstatic.com/steam/apps/{game.id}/header.jpg" - ) - - # Set review data - giveaway.game_review_score = game.review_score - giveaway.game_total_reviews = game.total_reviews - - # Generate review summary based on score and review count - if game.review_score is not None and game.total_reviews is not None: - giveaway.game_review_summary = self._generate_review_summary( - game.review_score, game.total_reviews - ) - - return giveaways - - def _generate_review_summary( - self, review_score: int, total_reviews: int - ) -> str: - """ - Generate Steam-style review summary text. - - Args: - review_score: Review score (0-10 scale) - total_reviews: Total number of reviews - - Returns: - Summary text like "Overwhelmingly Positive", "Mixed", etc. - - Example: - >>> service._generate_review_summary(9, 50000) - 'Overwhelmingly Positive' - """ - # Convert 0-10 scale to percentage - percentage = review_score * 10 - - # Not enough reviews - if total_reviews < 10: - return "Not Enough Reviews" - - # Determine sentiment based on Steam's algorithm - # https://partner.steamgames.com/doc/store/reviews - if total_reviews >= 500: - # High review count - can be "Overwhelmingly" tier - if percentage >= 95: - return "Overwhelmingly Positive" - elif percentage >= 80: - return "Very Positive" - elif percentage >= 70: - return "Positive" - elif percentage >= 40: - return "Mixed" - elif percentage >= 20: - return "Negative" - else: - return "Overwhelmingly Negative" - else: - # Lower review count - regular tiers only - if percentage >= 80: - return "Very Positive" - elif percentage >= 70: - return "Positive" - elif percentage >= 40: - return "Mixed" - elif percentage >= 20: - return "Negative" - else: - return "Very Negative" - - async def hide_giveaway(self, giveaway_code: str) -> bool: - """ - Hide a giveaway from future recommendations. - - Args: - giveaway_code: Giveaway code to hide - - Returns: - True if hidden, False if not found - - Example: - >>> await service.hide_giveaway("AbCd1") - """ - giveaway = await self.giveaway_repo.get_by_code(giveaway_code) - if not giveaway: - return False - - await self.giveaway_repo.hide_giveaway(giveaway.id) - await self.session.commit() - return True - - async def unhide_giveaway(self, giveaway_code: str) -> bool: - """ - Unhide a previously hidden giveaway. - - Args: - giveaway_code: Giveaway code to unhide - - Returns: - True if unhidden, False if not found - - Example: - >>> await service.unhide_giveaway("AbCd1") - """ - giveaway = await self.giveaway_repo.get_by_code(giveaway_code) - if not giveaway: - return False - - await self.giveaway_repo.unhide_giveaway(giveaway.id) - await self.session.commit() - return True - - async def remove_entry(self, giveaway_code: str) -> bool: - """ - Remove an entry for a giveaway. - - This marks the giveaway as not entered and deletes the entry record. - - Args: - giveaway_code: Code of the giveaway to remove entry from - - Returns: - True if entry was removed, False if not found or not entered - - Example: - >>> removed = await service.remove_entry("AbCd1") - >>> if removed: - ... print("Entry removed successfully") - """ - giveaway = await self.giveaway_repo.get_by_code(giveaway_code) - if not giveaway: - return False - - if not giveaway.is_entered: - return False - - # Find the entry - entry = await self.entry_repo.get_by_giveaway(giveaway.id) - if entry: - # Delete the entry - await self.entry_repo.delete(entry.id) - - # Mark giveaway as not entered - giveaway.is_entered = False - giveaway.entered_at = None - await self.session.commit() - - return True - - async def search_giveaways( - self, query: str, limit: int | None = 20 - ) -> list[Giveaway]: - """ - Search giveaways by game name. - - Args: - query: Search query - limit: Maximum results to return - - Returns: - List of matching giveaways - - Example: - >>> results = await service.search_giveaways("portal") - """ - return await self.giveaway_repo.search_by_game_name(query, limit=limit) - - async def get_entry_history( - self, limit: int = 50, status: str | None = None - ) -> list[Entry]: - """ - Get entry history. - - Args: - limit: Maximum results to return - status: Filter by status ("success", "failed", "pending") - - Returns: - List of entries - - Example: - >>> history = await service.get_entry_history(limit=20) - >>> for entry in history: - ... print(f"Spent {entry.points_spent} points") - """ - if status: - return await self.entry_repo.get_by_status(status, limit=limit) - else: - return await self.entry_repo.get_recent(limit=limit) - - async def get_entry_stats(self) -> dict: - """ - Get comprehensive entry statistics. - - Returns: - Dictionary with entry statistics - - Example: - >>> stats = await service.get_entry_stats() - >>> print(f"Success rate: {stats['success_rate']:.1f}%") - """ - return await self.entry_repo.get_stats() - - async def get_giveaway_stats(self) -> dict: - """ - Get giveaway statistics. - - Returns: - Dictionary with giveaway statistics: - - total: Total giveaways in database - - active: Active (non-expired) giveaways - - entered: Giveaways we've entered - - hidden: Hidden giveaways - - wins: Total wins - - win_rate: Win rate percentage - - Example: - >>> stats = await service.get_giveaway_stats() - >>> print(f"Active giveaways: {stats['active']}") - """ - total = await self.giveaway_repo.count() - active = await self.giveaway_repo.count_active() - entered = await self.giveaway_repo.count_entered() - hidden = len(await self.giveaway_repo.get_hidden()) - wins = await self.giveaway_repo.count_won() - win_rate = (wins / entered * 100) if entered > 0 else 0.0 - - return { - "total": total, - "active": active, - "entered": entered, - "hidden": hidden, - "wins": wins, - "win_rate": win_rate, - } - - async def get_current_points(self) -> int: - """ - Get current user points from SteamGifts. - - Returns: - Current points balance - - Raises: - SteamGiftsError: If unable to fetch points - - Example: - >>> points = await service.get_current_points() - >>> print(f"Current points: {points}P") - """ - try: - return await self.sg_client.get_user_points() - except SteamGiftsError as e: - # If we can't fetch points, return 0 rather than failing - logger.error("points_fetch_failed", error=str(e)) - return 0 - - async def check_giveaway_safety(self, giveaway_code: str) -> dict: - """ - Check if a giveaway is safe to enter (trap detection). - - Analyzes the giveaway page content for warning signs that might - indicate a trap or scam giveaway (e.g., "don't enter", "ban", "fake"). - - Args: - giveaway_code: Giveaway code to check - - Returns: - Dictionary with safety check results: - - is_safe: True if giveaway appears safe - - safety_score: Confidence score (0-100) - - details: List of found warning words - - Example: - >>> safety = await service.check_giveaway_safety("AbCd1") - >>> if not safety['is_safe']: - ... print(f"Warning: {safety['details']}") - """ - # Check safety via client - safety_result = await self.sg_client.check_giveaway_safety(giveaway_code) - - # Update giveaway in database with safety info - giveaway = await self.giveaway_repo.get_by_code(giveaway_code) - if giveaway: - giveaway.is_safe = safety_result["is_safe"] - giveaway.safety_score = safety_result["safety_score"] - await self.session.commit() - - return safety_result - - async def hide_on_steamgifts(self, giveaway_code: str) -> bool: - """ - Hide a game on SteamGifts (removes from all future giveaway lists). - - This sends a request to SteamGifts to hide all giveaways for the - game associated with this giveaway. Also marks the giveaway as - hidden in the local database. - - Args: - giveaway_code: Giveaway code to hide - - Returns: - True if hidden successfully, False otherwise - - Example: - >>> success = await service.hide_on_steamgifts("AbCd1") - >>> if success: - ... print("Game hidden on SteamGifts") - """ - # Get the game_id for this giveaway - game_id = await self.sg_client.get_giveaway_game_id(giveaway_code) - - if not game_id: - logger.warning("game_id_lookup_failed", code=giveaway_code) - return False - - # Hide on SteamGifts - try: - await self.sg_client.hide_giveaway(game_id) - except SteamGiftsError as e: - logger.error("steamgifts_hide_failed", code=giveaway_code, error=str(e)) - return False - - # Also hide locally - giveaway = await self.giveaway_repo.get_by_code(giveaway_code) - if giveaway: - giveaway.is_hidden = True - await self.session.commit() - - return True - - async def post_comment( - self, giveaway_code: str, comment_text: str = "Thanks!" - ) -> bool: - """ - Post a comment on a giveaway. - - Args: - giveaway_code: Giveaway code - comment_text: Comment text to post - - Returns: - True if comment posted successfully, False otherwise - - Example: - >>> success = await service.post_comment("AbCd1", "Thanks!") - >>> if success: - ... print("Comment posted!") - """ - try: - return await self.sg_client.post_comment(giveaway_code, comment_text) - except SteamGiftsError as e: - logger.error("comment_post_failed", code=giveaway_code, error=str(e)) - return False - - async def enter_giveaway_with_safety_check( - self, giveaway_code: str, entry_type: str = "auto" - ) -> Entry | None: - """ - Enter a giveaway with safety check. - - Checks if the giveaway is safe before entering. If unsafe, - the giveaway is hidden instead of entered. - - Args: - giveaway_code: Giveaway code to enter - entry_type: Type of entry ("manual", "auto", "wishlist") - - Returns: - Entry object if successful, None if failed or unsafe - - Example: - >>> entry = await service.enter_giveaway_with_safety_check("AbCd1") - >>> if entry: - ... print("Entered safely!") - """ - # First check safety - try: - safety = await self.check_giveaway_safety(giveaway_code) - - if not safety["is_safe"]: - logger.warning("giveaway_unsafe", code=giveaway_code, details=safety["details"]) - - # Try to hide it on SteamGifts - await self.hide_on_steamgifts(giveaway_code) - - # Record failed entry with reason - giveaway = await self.giveaway_repo.get_by_code(giveaway_code) - if giveaway: - await self.entry_repo.create( - giveaway_id=giveaway.id, - points_spent=0, - entry_type=entry_type, - status="failed", - error_message=f"Unsafe giveaway: {', '.join(safety['details'])}", - ) - await self.session.commit() - - return None - - except Exception as e: - logger.error("safety_check_failed", code=giveaway_code, error=str(e)) - # Continue without safety check if it fails - - # Proceed with normal entry - return await self.enter_giveaway(giveaway_code, entry_type) diff --git a/backend/src/services/giveaway_sync.py b/backend/src/services/giveaway_sync.py new file mode 100644 index 0000000..eb37758 --- /dev/null +++ b/backend/src/services/giveaway_sync.py @@ -0,0 +1,257 @@ +"""Scrape -> cache sync steps of GiveawayService (regular/wishlist/DLC pages, +wins, entered giveaways).""" + +import structlog +from sqlalchemy.ext.asyncio import AsyncSession + +from core.exceptions import SteamGiftsError +from core.time import utcnow +from models.giveaway import Giveaway +from repositories.giveaway import GiveawayRepository +from services.game_service import GameService +from utils.steamgifts_client import SteamGiftsClient + +logger = structlog.get_logger() + + +class GiveawaySyncMixin: + """Giveaway Syncmethods of GiveawayService (see services.giveaway_service). + + Mixin: expects the attributes declared below to be set by the composing + class's ``__init__`` (services.giveaway_service.GiveawayService). + """ + + session: AsyncSession + sg_client: SteamGiftsClient + game_service: GameService + giveaway_repo: GiveawayRepository + + async def sync_giveaways( + self, + pages: int = 1, + search_query: str | None = None, + giveaway_type: str | None = None, + dlc_only: bool = False, + min_copies: int | None = None, + ) -> tuple[int, int]: + """ + Sync giveaways from SteamGifts to database. + + Fetches giveaways from SteamGifts and caches them in database. + Also caches associated game data. + + Args: + pages: Number of pages to fetch (default: 1) + search_query: Optional search query + giveaway_type: Optional type filter ("wishlist", "recommended", "new", etc.) + dlc_only: If True, only fetch DLC giveaways + min_copies: Minimum number of copies (e.g., 2 for multi-copy) + + Returns: + Tuple of (new_count, updated_count) + + Example: + >>> new, updated = await service.sync_giveaways(pages=3) + >>> print(f"Added {new} new, updated {updated} existing") + + >>> # Sync wishlist giveaways + >>> new, updated = await service.sync_giveaways(pages=2, giveaway_type="wishlist") + + >>> # Sync DLC giveaways + >>> new, updated = await service.sync_giveaways(pages=2, dlc_only=True) + """ + new_count = 0 + updated_count = 0 + + for page in range(1, pages + 1): + try: + giveaways_data = await self.sg_client.get_giveaways( + page=page, + search_query=search_query, + giveaway_type=giveaway_type, + dlc_only=dlc_only, + min_copies=min_copies, + ) + + for ga_data in giveaways_data: + # Check if exists + existing = await self.giveaway_repo.get_by_code(ga_data["code"]) + + # Cache game data if we have game_id + if ga_data.get("game_id"): + try: + await self.game_service.get_or_fetch_game(ga_data["game_id"]) + except Exception as e: + logger.error("game_cache_failed", game_id=ga_data["game_id"], error=str(e)) + + if existing: + # Update existing giveaway + await self._update_giveaway(existing, ga_data) + updated_count += 1 + else: + # Create new giveaway + await self._create_giveaway(ga_data) + new_count += 1 + + except SteamGiftsError as e: + logger.error("giveaway_page_fetch_failed", page=page, error=str(e)) + break + + await self.session.commit() + return new_count, updated_count + + async def sync_wins(self, pages: int = 1) -> int: + """ + Sync won giveaways from SteamGifts to database. + + Fetches the /giveaways/won page and marks matching giveaways as won. + + Args: + pages: Number of pages to fetch (default: 1) + + Returns: + Number of newly detected wins + + Example: + >>> new_wins = await service.sync_wins(pages=2) + >>> print(f"Found {new_wins} new wins!") + """ + + new_wins = 0 + + for page in range(1, pages + 1): + try: + won_data = await self.sg_client.get_won_giveaways(page=page) + + for win in won_data: + # Look up giveaway by code + giveaway = await self.giveaway_repo.get_by_code(win["code"]) + + if giveaway and not giveaway.is_won: + # Mark as won + giveaway.is_won = True + giveaway.won_at = win.get("won_at") or utcnow() + new_wins += 1 + + elif not giveaway: + # Giveaway not in our database - create it as won + url = f"https://www.steamgifts.com/giveaway/{win['code']}/" + await self.giveaway_repo.create( + code=win["code"], + url=url, + game_name=win["game_name"], + price=0, # Unknown price for historical wins + game_id=win.get("game_id"), + is_entered=True, + is_won=True, + won_at=win.get("won_at") or utcnow(), + ) + new_wins += 1 + + except Exception as e: + logger.error("wins_page_fetch_failed", page=page, error=str(e)) + break + + await self.session.commit() + return new_wins + + async def sync_entered_giveaways(self, pages: int = 1) -> int: + """ + Sync entered giveaways from SteamGifts to database. + + Fetches the /giveaways/entered page and marks matching giveaways + as entered in the local database. This ensures our database + stays in sync with what's actually entered on SteamGifts. + + Args: + pages: Number of pages to fetch (default: 1) + + Returns: + Number of giveaways marked as entered + + Example: + >>> synced = await service.sync_entered_giveaways(pages=2) + >>> print(f"Synced {synced} entered giveaways") + """ + synced_count = 0 + + for page in range(1, pages + 1): + try: + entered_data = await self.sg_client.get_entered_giveaways(page=page) + + for entry in entered_data: + # Look up giveaway by code + giveaway = await self.giveaway_repo.get_by_code(entry["code"]) + + if giveaway and not giveaway.is_entered: + # Mark as entered + giveaway.is_entered = True + giveaway.entered_at = entry.get("entered_at") + synced_count += 1 + + elif not giveaway: + # Giveaway not in our database - create it as entered + url = f"https://www.steamgifts.com/giveaway/{entry['code']}/" + await self.giveaway_repo.create( + code=entry["code"], + url=url, + game_name=entry["game_name"], + price=entry.get("price", 0), + game_id=entry.get("game_id"), + end_time=entry.get("end_time"), + is_entered=True, + entered_at=entry.get("entered_at"), + ) + synced_count += 1 + + except Exception as e: + logger.error("entered_page_fetch_failed", page=page, error=str(e)) + break + + await self.session.commit() + return synced_count + + async def _create_giveaway(self, ga_data: dict) -> Giveaway: + """ + Create new giveaway from scraped data. + + Args: + ga_data: Giveaway data from SteamGifts + + Returns: + Created Giveaway object + """ + # Build URL from code + url = f"https://www.steamgifts.com/giveaway/{ga_data['code']}/" + + giveaway = await self.giveaway_repo.create( + code=ga_data["code"], + url=url, + game_name=ga_data["game_name"], + price=ga_data["price"], + copies=ga_data.get("copies", 1), + end_time=ga_data.get("end_time"), + game_id=ga_data.get("game_id"), + is_wishlist=ga_data.get("is_wishlist", False), + is_entered=ga_data.get("is_entered", False), + ) + return giveaway + + async def _update_giveaway(self, giveaway: Giveaway, ga_data: dict) -> None: + """ + Update existing giveaway from scraped data. + + Args: + giveaway: Existing giveaway object + ga_data: New data from SteamGifts + """ + # Update mutable fields + giveaway.end_time = ga_data.get("end_time", giveaway.end_time) + + # Update game_id if we found it and didn't have it before + if ga_data.get("game_id") and not giveaway.game_id: + giveaway.game_id = ga_data["game_id"] + + # Update wishlist flag (can change from False to True, but not back) + if ga_data.get("is_wishlist"): + giveaway.is_wishlist = True diff --git a/backend/src/services/settings_service.py b/backend/src/services/settings_service.py index ee50793..0d987d7 100644 --- a/backend/src/services/settings_service.py +++ b/backend/src/services/settings_service.py @@ -64,7 +64,7 @@ async def get_settings(self) -> Settings: """ return await self.repo.get_settings() - async def update_settings(self, **kwargs) -> Settings: + async def update_settings(self, **kwargs: Any) -> Settings: """ Update settings with validation. diff --git a/backend/src/utils/steam_client.py b/backend/src/utils/steam_client.py index b275527..a5ca305 100644 --- a/backend/src/utils/steam_client.py +++ b/backend/src/utils/steam_client.py @@ -52,7 +52,7 @@ def __init__(self, max_calls: int = 100, window_seconds: int = 60): self.calls: list[datetime] = [] self.lock = asyncio.Lock() - async def __aenter__(self): + async def __aenter__(self) -> RateLimiter: """Acquire rate limit (async context manager).""" async with self.lock: now = utcnow() @@ -77,7 +77,7 @@ async def __aenter__(self): return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """Exit rate limit context.""" pass @@ -166,7 +166,7 @@ def __init__( self._client: httpx.AsyncClient | None = None - async def start(self): + async def start(self) -> None: """ Start the client session. @@ -186,7 +186,7 @@ async def start(self): headers=headers ) - async def close(self): + async def close(self) -> None: """ Close the client session. @@ -199,12 +199,12 @@ async def close(self): await self._client.aclose() self._client = None - async def __aenter__(self): + async def __aenter__(self) -> SteamClient: """Start session (async context manager).""" await self.start() return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """Close session (async context manager).""" await self.close() @@ -267,7 +267,8 @@ async def _request( f"Steam API error: {response.status_code}" ) - return response.json() + payload: dict[str, Any] = response.json() + return payload except httpx.HTTPError as e: # Network/connection error - retry if possible @@ -304,7 +305,8 @@ async def get_app_details(self, app_id: int) -> dict[str, Any] | None: if not app_data or not app_data.get("success"): return None - return app_data.get("data") + details: dict[str, Any] | None = app_data.get("data") + return details except SteamAPINotFoundError: return None @@ -342,7 +344,8 @@ async def get_owned_games(self, steam_id: str) -> list[dict[str, Any]]: data = await self._request(url, params) response = data.get("response", {}) - return response.get("games", []) + games: list[dict[str, Any]] = response.get("games", []) + return games async def get_player_summary(self, steam_id: str) -> dict[str, Any] | None: """ diff --git a/backend/src/utils/steamgifts_client.py b/backend/src/utils/steamgifts_client.py index 045b1f7..e707e9c 100644 --- a/backend/src/utils/steamgifts_client.py +++ b/backend/src/utils/steamgifts_client.py @@ -4,6 +4,7 @@ including authentication, scraping giveaways, and entering giveaways. """ +import asyncio import re from datetime import datetime from typing import Any @@ -16,6 +17,7 @@ SteamGiftsError, SteamGiftsSessionExpiredError, ) +from utils.steam_client import RateLimiter logger = structlog.get_logger() @@ -86,6 +88,9 @@ def __init__( user_agent: str, xsrf_token: str | None = None, timeout_seconds: int = 30, + rate_limit_calls: int = 30, + rate_limit_window: int = 60, + max_retries: int = 3, ): """ Initialize SteamGifts client. @@ -95,6 +100,9 @@ def __init__( user_agent: User-Agent header to use xsrf_token: XSRF token (if known), otherwise will be extracted timeout_seconds: Request timeout in seconds + rate_limit_calls: Max requests per rate-limit window + rate_limit_window: Rate-limit window in seconds + max_retries: Retry attempts for transient failures Example: >>> client = SteamGiftsClient( @@ -106,10 +114,16 @@ def __init__( self.user_agent = user_agent self.xsrf_token = xsrf_token self.timeout_seconds = timeout_seconds + self.max_retries = max_retries + + self.rate_limiter = RateLimiter( + max_calls=rate_limit_calls, + window_seconds=rate_limit_window, + ) self._client: httpx.AsyncClient | None = None - async def start(self): + async def start(self) -> None: """ Start the client session. @@ -135,7 +149,7 @@ async def start(self): if not self.xsrf_token and self.phpsessid: await self._refresh_xsrf_token() - async def close(self): + async def close(self) -> None: """ Close the client session. @@ -148,16 +162,95 @@ async def close(self): await self._client.aclose() self._client = None - async def __aenter__(self): + async def __aenter__(self) -> SteamGiftsClient: """Start session (async context manager).""" await self.start() return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: """Close session (async context manager).""" await self.close() - async def _refresh_xsrf_token(self): + async def _get(self, url: str, **kwargs: Any) -> httpx.Response: + """Rate-limited GET with retry/backoff for transient failures. + + Retries on transport errors, 5xx responses and 429 (honoring a numeric + Retry-After header when present). + """ + if self._client is None: + raise RuntimeError("Client session not started. Call start() first.") + + attempt = 0 + while True: + async with self.rate_limiter: + try: + response = await self._client.get(url, **kwargs) + except httpx.TransportError as e: + if attempt >= self.max_retries: + raise SteamGiftsError( + f"Request failed after {attempt + 1} attempts: {e}", + code="SG_002", + details={"url": url}, + ) from e + delay = min(2.0**attempt, 30.0) + logger.warning( + "steamgifts_request_retry", + url=url, attempt=attempt + 1, delay=delay, error=str(e), + ) + await asyncio.sleep(delay) + attempt += 1 + continue + + if response.status_code == 429 or response.status_code >= 500: + if attempt >= self.max_retries: + return response # let the caller's status handling report it + delay = min(2.0**attempt, 30.0) + if response.status_code == 429: + retry_after = response.headers.get("Retry-After", "") + if retry_after.isdigit(): + delay = min(float(retry_after), 60.0) + logger.warning( + "steamgifts_request_retry", + url=url, attempt=attempt + 1, delay=delay, + status_code=response.status_code, + ) + await asyncio.sleep(delay) + attempt += 1 + continue + + return response + + async def _post(self, url: str, **kwargs: Any) -> httpx.Response: + """Rate-limited POST, retried only on connect errors. + + POSTs mutate state on SteamGifts (enter/hide/comment), so a request + that may have reached the server is never replayed; only failures to + connect at all are retried. + """ + if self._client is None: + raise RuntimeError("Client session not started. Call start() first.") + + attempt = 0 + while True: + async with self.rate_limiter: + try: + return await self._client.post(url, **kwargs) + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + if attempt >= self.max_retries: + raise SteamGiftsError( + f"Request failed after {attempt + 1} attempts: {e}", + code="SG_002", + details={"url": url}, + ) from e + delay = min(2.0**attempt, 30.0) + logger.warning( + "steamgifts_request_retry", + url=url, attempt=attempt + 1, delay=delay, error=str(e), + ) + await asyncio.sleep(delay) + attempt += 1 + + async def _refresh_xsrf_token(self) -> None: """ Refresh XSRF token by fetching homepage. @@ -170,7 +263,7 @@ async def _refresh_xsrf_token(self): if self._client is None: raise RuntimeError("Client session not started. Call start() first.") - response = await self._client.get(self.BASE_URL) + response = await self._get(self.BASE_URL) if response.status_code != 200: raise SteamGiftsSessionExpiredError( @@ -184,16 +277,17 @@ async def _refresh_xsrf_token(self): # XSRF token is in a hidden input or data attribute token_input = soup.find("input", {"name": "xsrf_token"}) if token_input: - self.xsrf_token = token_input.get("value") + value = token_input.get("value") + self.xsrf_token = str(value) if value else None return # Try to find it in data-form attribute - form_element = soup.find(attrs={"data-form": True}) + form_element = soup.find(lambda tag: tag.has_attr("data-form")) if form_element: # Token might be in a JSON-encoded string import json try: - form_data = json.loads(form_element["data-form"]) + form_data = json.loads(str(form_element["data-form"])) if "xsrf_token" in form_data: self.xsrf_token = form_data["xsrf_token"] return @@ -223,7 +317,7 @@ async def get_user_points(self) -> int: if self._client is None: raise RuntimeError("Client session not started. Call start() first.") - response = await self._client.get(self.BASE_URL) + response = await self._get(self.BASE_URL) if response.status_code != 200: raise SteamGiftsSessionExpiredError( @@ -272,7 +366,7 @@ async def get_user_info(self) -> dict[str, Any]: if self._client is None: raise RuntimeError("Client session not started. Call start() first.") - response = await self._client.get(self.BASE_URL) + response = await self._get(self.BASE_URL) if response.status_code != 200: raise SteamGiftsSessionExpiredError( @@ -420,7 +514,7 @@ async def get_giveaways( if min_copies: params["copy_min"] = str(min_copies) - response = await self._client.get(url, params=params) + response = await self._get(url, params=params) if response.status_code != 200: raise SteamGiftsError( @@ -453,7 +547,7 @@ async def get_giveaways( return giveaways - def _parse_giveaway_element(self, element) -> dict[str, Any] | None: + def _parse_giveaway_element(self, element: Any) -> dict[str, Any] | None: """ Parse giveaway data from HTML element. @@ -573,7 +667,7 @@ async def enter_giveaway(self, giveaway_code: str) -> bool: "code": giveaway_code, } - response = await self._client.post(url, data=data) + response = await self._post(url, data=data) if response.status_code != 200: raise SteamGiftsError( @@ -623,7 +717,7 @@ async def get_giveaway_details(self, giveaway_code: str) -> dict[str, Any]: raise RuntimeError("Client session not started. Call start() first.") url = f"{self.BASE_URL}/giveaway/{giveaway_code}/" - response = await self._client.get(url) + response = await self._get(url) if response.status_code == 404: raise SteamGiftsNotFoundError(f"Giveaway not found: {giveaway_code}") @@ -696,7 +790,7 @@ async def get_won_giveaways(self, page: int = 1) -> list[dict[str, Any]]: url = f"{self.BASE_URL}/giveaways/won" params: dict[str, str | int] = {"page": page} - response = await self._client.get(url, params=params) + response = await self._get(url, params=params) if response.status_code != 200: raise SteamGiftsError( @@ -723,7 +817,7 @@ async def get_won_giveaways(self, page: int = 1) -> list[dict[str, Any]]: return won_giveaways - def _parse_won_giveaway_row(self, row) -> dict[str, Any] | None: + def _parse_won_giveaway_row(self, row: Any) -> dict[str, Any] | None: """ Parse a won giveaway row from the /giveaways/won page. @@ -820,7 +914,7 @@ async def get_entered_giveaways(self, page: int = 1) -> list[dict[str, Any]]: url = f"{self.BASE_URL}/giveaways/entered" params: dict[str, str | int] = {"page": page} - response = await self._client.get(url, params=params) + response = await self._get(url, params=params) if response.status_code != 200: raise SteamGiftsError( @@ -847,7 +941,7 @@ async def get_entered_giveaways(self, page: int = 1) -> list[dict[str, Any]]: return entered_giveaways - def _parse_entered_giveaway_row(self, row) -> dict[str, Any] | None: + def _parse_entered_giveaway_row(self, row: Any) -> dict[str, Any] | None: """ Parse an entered giveaway row from the /giveaways/entered page. @@ -1028,7 +1122,7 @@ async def check_giveaway_safety(self, giveaway_code: str) -> dict[str, Any]: raise RuntimeError("Client session not started. Call start() first.") url = f"{self.BASE_URL}/giveaway/{giveaway_code}/" - response = await self._client.get(url) + response = await self._get(url) if response.status_code == 404: raise SteamGiftsNotFoundError(f"Giveaway not found: {giveaway_code}") @@ -1076,7 +1170,7 @@ async def hide_giveaway(self, game_id: int) -> bool: "do": "hide_giveaways_by_game_id", } - response = await self._client.post(url, data=data) + response = await self._post(url, data=data) if response.status_code != 200: raise SteamGiftsError( @@ -1110,7 +1204,7 @@ async def get_giveaway_game_id(self, giveaway_code: str) -> int | None: raise RuntimeError("Client session not started. Call start() first.") url = f"{self.BASE_URL}/giveaway/{giveaway_code}/" - response = await self._client.get(url) + response = await self._get(url) if response.status_code != 200: return None @@ -1161,7 +1255,7 @@ async def post_comment( "parent_id": "", } - response = await self._client.post(url, data=data) + response = await self._post(url, data=data) if response.status_code != 200: raise SteamGiftsError( diff --git a/backend/src/workers/scheduler.py b/backend/src/workers/scheduler.py index d45f554..8f0bc6c 100644 --- a/backend/src/workers/scheduler.py +++ b/backend/src/workers/scheduler.py @@ -312,7 +312,7 @@ def get_jobs(self) -> list[Job]: Returns: List of Job instances """ - return self.scheduler.get_jobs() + return list(self.scheduler.get_jobs()) def get_status(self) -> dict[str, Any]: """ diff --git a/backend/tests/unit/test_api_main.py b/backend/tests/unit/test_api_main.py index 9e429a1..1285ee4 100644 --- a/backend/tests/unit/test_api_main.py +++ b/backend/tests/unit/test_api_main.py @@ -94,12 +94,13 @@ def test_cors_headers(client): assert "access-control-allow-origin" in response.headers -@pytest.mark.skip(reason="Requires database setup - covered by e2e tests") -def test_settings_router_included(client): +def test_settings_router_included(): """Test that settings router is included.""" - # GET /api/v1/settings should exist (even if it returns error without DB) - response = client.get("/api/v1/settings") - # May return 500 if DB not set up, but route should exist + # No DB is set up here; the route must exist and any handler failure must + # surface as a 500 rather than a 404 (raise_server_exceptions=False keeps + # the TestClient from re-raising the handler's DB error). + with TestClient(app, raise_server_exceptions=False) as no_db_client: + response = no_db_client.get("/api/v1/settings") assert response.status_code in [200, 500] @@ -253,16 +254,16 @@ def test_404_not_found(client): assert response.status_code == 404 -@pytest.mark.skip(reason="Requires database setup - covered by e2e tests") -def test_api_prefix(client): +def test_api_prefix(): """Test that API endpoints use correct prefix.""" - # Settings endpoint - response = client.get("/api/v1/settings") - assert response.status_code in [200, 500] # Route exists + with TestClient(app, raise_server_exceptions=False) as no_db_client: + # Settings endpoint (needs a DB; route must exist even without one) + response = no_db_client.get("/api/v1/settings") + assert response.status_code in [200, 500] # Route exists - # System health endpoint - response = client.get("/api/v1/system/health") - assert response.status_code == 200 + # System health endpoint + response = no_db_client.get("/api/v1/system/health") + assert response.status_code == 200 def test_root_endpoint_fields(client): diff --git a/backend/tests/unit/test_utils_steamgifts_client.py b/backend/tests/unit/test_utils_steamgifts_client.py index efc1511..cd0db79 100644 --- a/backend/tests/unit/test_utils_steamgifts_client.py +++ b/backend/tests/unit/test_utils_steamgifts_client.py @@ -779,3 +779,115 @@ async def test_get_giveaways_dlc_and_min_copies(self, steamgifts_client): assert params["copy_min"] == "10" assert params["type"] == "wishlist" assert params["page"] == 2 + + +# ---------------------------------------------------------------------------- +# Retry / rate-limit request path (_get / _post) +# ---------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_retries_on_5xx_then_succeeds(steamgifts_client, monkeypatch): + """_get retries server errors with backoff and returns the eventual 200.""" + sleeps = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + monkeypatch.setattr("utils.steamgifts_client.asyncio.sleep", fake_sleep) + + error = MagicMock(status_code=500, headers={}) + ok = MagicMock(status_code=200) + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=[error, error, ok]) + steamgifts_client._client = mock_client + + response = await steamgifts_client._get("https://www.steamgifts.com/x") + + assert response is ok + assert mock_client.get.await_count == 3 + assert sleeps == [1.0, 2.0] # exponential backoff + + +@pytest.mark.asyncio +async def test_get_gives_up_after_max_retries(steamgifts_client, monkeypatch): + """After max_retries the last error response is returned to the caller.""" + monkeypatch.setattr("utils.steamgifts_client.asyncio.sleep", AsyncMock()) + + error = MagicMock(status_code=503, headers={}) + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=error) + steamgifts_client._client = mock_client + + response = await steamgifts_client._get("https://www.steamgifts.com/x") + + assert response is error + assert mock_client.get.await_count == steamgifts_client.max_retries + 1 + + +@pytest.mark.asyncio +async def test_get_raises_steamgifts_error_on_persistent_transport_failure( + steamgifts_client, monkeypatch +): + """Transport-level failures surface as SteamGiftsError after retries.""" + monkeypatch.setattr("utils.steamgifts_client.asyncio.sleep", AsyncMock()) + + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("boom")) + steamgifts_client._client = mock_client + + with pytest.raises(SteamGiftsError): + await steamgifts_client._get("https://www.steamgifts.com/x") + + assert mock_client.get.await_count == steamgifts_client.max_retries + 1 + + +@pytest.mark.asyncio +async def test_get_honors_retry_after_on_429(steamgifts_client, monkeypatch): + """A numeric Retry-After header overrides the exponential backoff.""" + sleeps = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + monkeypatch.setattr("utils.steamgifts_client.asyncio.sleep", fake_sleep) + + limited = MagicMock(status_code=429, headers={"Retry-After": "7"}) + ok = MagicMock(status_code=200) + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=[limited, ok]) + steamgifts_client._client = mock_client + + response = await steamgifts_client._get("https://www.steamgifts.com/x") + + assert response is ok + assert sleeps == [7.0] + + +@pytest.mark.asyncio +async def test_post_does_not_retry_on_5xx(steamgifts_client): + """POSTs mutate state, so server errors must not be replayed.""" + error = MagicMock(status_code=500, headers={}) + mock_client = MagicMock() + mock_client.post = AsyncMock(return_value=error) + steamgifts_client._client = mock_client + + response = await steamgifts_client._post("https://www.steamgifts.com/x") + + assert response is error + assert mock_client.post.await_count == 1 + + +@pytest.mark.asyncio +async def test_post_retries_on_connect_error(steamgifts_client, monkeypatch): + """A request that never reached the server is safe to retry.""" + monkeypatch.setattr("utils.steamgifts_client.asyncio.sleep", AsyncMock()) + + ok = MagicMock(status_code=200) + mock_client = MagicMock() + mock_client.post = AsyncMock(side_effect=[httpx.ConnectError("boom"), ok]) + steamgifts_client._client = mock_client + + response = await steamgifts_client._post("https://www.steamgifts.com/x") + + assert response is ok + assert mock_client.post.await_count == 2 diff --git a/docs/TOFIX.md b/docs/TOFIX.md index 7410cae..deba12d 100644 --- a/docs/TOFIX.md +++ b/docs/TOFIX.md @@ -6,13 +6,10 @@ This document tracks disabled warnings, skipped tests, and other technical debt ### Frontend (`frontend/eslint.config.js`) -| Rule | Reason | Files Affected | -|------|--------|----------------| -| `react-hooks/set-state-in-effect` | False positives for valid patterns (initializing form state from fetched data, countdown timers) | `Settings.tsx`, `Dashboard.tsx` | - -**Details:** -- `Settings.tsx`: Initializes form state when settings data is fetched - standard pattern for forms with async data -- `Dashboard.tsx`: Updates countdown timer state every second - valid use of setInterval in useEffect +None - `react-hooks/set-state-in-effect` was re-enabled after refactoring +`Settings.tsx` (form initializes from loaded data in an inner component, +remounted via `key` on refetch) and `Dashboard.tsx` (countdown derived during +render, driven by a tick interval). ## Skipped Tests @@ -20,18 +17,24 @@ This document tracks disabled warnings, skipped tests, and other technical debt | Test File | Tests Skipped | Reason | |-----------|---------------|--------| -| `test_api_main.py` | 2 | Requires database setup - covered by e2e tests | -| `test_scheduler_api.py` | 12 (entire file) | APScheduler causes event loop conflicts in test suite - covered by unit tests | | `integration/*` | All | Requires `--run-integration` flag and valid PHPSESSID | **Test counts (as of last run):** -- Backend: 751 passed, 2 skipped (integration tests excluded; they need `--run-integration` + a valid PHPSESSID) +- Backend: 759 passed, 0 skipped (integration tests excluded; they need `--run-integration` + a valid PHPSESSID) - Frontend: 170 passed, 0 skipped ## Future Improvements ### Code Quality -- [ ] Re-enable `react-hooks/set-state-in-effect` after refactoring affected components -- [ ] Fix APScheduler event loop conflicts to enable scheduler e2e tests in CI +- [x] Re-enable `react-hooks/set-state-in-effect` after refactoring affected components +- [x] Fix APScheduler event loop conflicts to enable scheduler e2e tests in CI (fixed by the automation-layer consolidation) - [ ] Add more integration test coverage with mocked external services + +### Dependency Notes + +- **APScheduler stays on 3.x** (evaluated 2026-07-12): 4.0 has never shipped a + stable release (PyPI latest is 3.11.x), and the event-loop conflicts that + motivated the upgrade were fixed by the automation-layer consolidation + (scheduler e2e tests run, 0 skips). The `apscheduler>=3.11.0,<4` pin in + `backend/pyproject.toml` is deliberate; revisit if a stable 4.x appears. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1ba5c90..b48146a 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -23,8 +23,6 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], - // Disable overly strict rules for valid patterns - 'react-hooks/set-state-in-effect': 'off', }, } ); \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d1b5319..bd159dc 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -518,44 +518,45 @@ interface JobCountdownProps { job: SchedulerJob; } -function JobCountdown({ job }: JobCountdownProps) { - const [countdown, setCountdown] = useState(''); - - useEffect(() => { - if (!job.next_run) { - setCountdown('Not scheduled'); - return; - } +function formatCountdown(nextRun: string | null | undefined): string { + if (!nextRun) { + return 'Not scheduled'; + } - const updateCountdown = () => { - const now = new Date(); - const nextRun = new Date(job.next_run!); - const diff = nextRun.getTime() - now.getTime(); + const diff = new Date(nextRun).getTime() - Date.now(); - if (diff <= 0) { - setCountdown('Running now...'); - return; - } + if (diff <= 0) { + return 'Running now...'; + } - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); - if (hours > 0) { - setCountdown(`${hours}h ${minutes}m ${seconds}s`); - } else if (minutes > 0) { - setCountdown(`${minutes}m ${seconds}s`); - } else { - setCountdown(`${seconds}s`); - } - }; + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} - updateCountdown(); - const interval = setInterval(updateCountdown, 1000); +function JobCountdown({ job }: JobCountdownProps) { + // Re-render once per second; the countdown itself is derived during render, + // so no state is written inside the effect body. + const [, setTick] = useState(0); + useEffect(() => { + if (!job.next_run) { + return; + } + const interval = setInterval(() => setTick((t) => t + 1), 1000); return () => clearInterval(interval); }, [job.next_run]); + const countdown = formatCountdown(job.next_run); + const jobLabel = job.name === 'scan_giveaways' ? 'Next scan' : job.name === 'process_giveaways' ? 'Next process' : job.name; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 0c33714..510d204 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Save, TestTube, AlertCircle, Eye, EyeOff, HelpCircle, ExternalLink } from 'lucide-react'; import { Card, Button, Input, Toggle, Loading } from '@/components/common'; import { useSettings, useUpdateSettings, useTestSession } from '@/hooks'; @@ -11,40 +11,62 @@ import type { Settings as SettingsType } from '@/types'; */ export function Settings() { const { data: settings, isLoading, error } = useSettings(); + + if (isLoading) { + return ( +
+

Settings

+ +
+ ); + } + + if (error || !settings) { + return ( +
+

Settings

+ +
+ + Failed to load settings. Is the backend running? +
+
+
+ ); + } + + // Remount the form whenever the server state changes (e.g. after a save's + // refetch) so it re-initializes from fresh data without effect-driven state. + return ; +} + +function SettingsForm({ settings }: { settings: SettingsType }) { const updateSettings = useUpdateSettings(); const testSession = useTestSession(); - const [formData, setFormData] = useState>({}); + const [formData, setFormData] = useState>(() => ({ + phpsessid: settings.phpsessid ?? '', + user_agent: settings.user_agent, + dlc_enabled: settings.dlc_enabled, + safety_check_enabled: settings.safety_check_enabled, + auto_hide_unsafe: settings.auto_hide_unsafe, + autojoin_enabled: settings.autojoin_enabled, + autojoin_start_at: settings.autojoin_start_at, + autojoin_stop_at: settings.autojoin_stop_at, + autojoin_min_price: settings.autojoin_min_price, + autojoin_min_score: settings.autojoin_min_score, + autojoin_min_reviews: settings.autojoin_min_reviews, + autojoin_max_game_age: settings.autojoin_max_game_age, + scan_interval_minutes: settings.scan_interval_minutes, + max_entries_per_cycle: settings.max_entries_per_cycle, + automation_enabled: settings.automation_enabled, + max_scan_pages: settings.max_scan_pages, + entry_delay_min: settings.entry_delay_min, + entry_delay_max: settings.entry_delay_max, + })); const [hasChanges, setHasChanges] = useState(false); const [showPhpsessid, setShowPhpsessid] = useState(false); - // Initialize form when settings load - useEffect(() => { - if (settings) { - setFormData({ - phpsessid: settings.phpsessid ?? '', - user_agent: settings.user_agent, - dlc_enabled: settings.dlc_enabled, - safety_check_enabled: settings.safety_check_enabled, - auto_hide_unsafe: settings.auto_hide_unsafe, - autojoin_enabled: settings.autojoin_enabled, - autojoin_start_at: settings.autojoin_start_at, - autojoin_stop_at: settings.autojoin_stop_at, - autojoin_min_price: settings.autojoin_min_price, - autojoin_min_score: settings.autojoin_min_score, - autojoin_min_reviews: settings.autojoin_min_reviews, - autojoin_max_game_age: settings.autojoin_max_game_age, - scan_interval_minutes: settings.scan_interval_minutes, - max_entries_per_cycle: settings.max_entries_per_cycle, - automation_enabled: settings.automation_enabled, - max_scan_pages: settings.max_scan_pages, - entry_delay_min: settings.entry_delay_min, - entry_delay_max: settings.entry_delay_max, - }); - setHasChanges(false); - } - }, [settings]); - const handleChange = (field: keyof SettingsType, value: string | number | boolean | null) => { setFormData(prev => ({ ...prev, [field]: value })); setHasChanges(true); @@ -73,29 +95,6 @@ export function Settings() { } }; - if (isLoading) { - return ( -
-

Settings

- -
- ); - } - - if (error) { - return ( -
-

Settings

- -
- - Failed to load settings. Is the backend running? -
-
-
- ); - } - return (
@@ -111,7 +110,7 @@ export function Settings() {
{/* First-time Setup Guide */} - {!settings?.phpsessid && ( + {!settings.phpsessid && (