"""Ads service. Ad serving: get_ad(slot, lang, state) → Ad or None Picks a random active ad matching slot + optional lang/state targeting. Falls back from targeted → untargeted if no targeted ad found. Tracking: record_impression(ad_id) — increments ad.impressions record_click(ad_id) — increments ad.clicks Both are direct DB increments for now. Phase 7: batch to Redis counter, flush every N minutes. Promoted search: promoted_listings(keyword) → [Listing, ...] ordered by priority desc Inserted at the top of browse results when a keyword is searched. Sponsors: active_sponsors(tier, category_id) → [Sponsor, ...] """ import random from datetime import datetime from app.extensions import db from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.listing import Listing from app.utils.text import normalize def get_ad(slot: str, lang: str = None, state: str = None) -> "Ad | None": """Return one active ad for the slot, targeted then untargeted fallback.""" now = datetime.utcnow() base = Ad.query.filter( Ad.slot == slot, Ad.is_active == True, Ad.starts_at <= now, Ad.ends_at >= now, ) # try targeted first if lang or state: targeted = base if lang: targeted = targeted.filter(Ad.lang == lang) if state: targeted = targeted.filter(Ad.geo_state == state) candidates = targeted.all() if candidates: return random.choice(candidates) # untargeted fallback candidates = base.filter(Ad.lang.is_(None), Ad.geo_state.is_(None)).all() if candidates: return random.choice(candidates) # any active ad for this slot candidates = base.all() return random.choice(candidates) if candidates else None def record_impression(ad_id: int): """Increment impression counter. Safe to call on every page render.""" Ad.query.filter_by(id=ad_id).update( {Ad.impressions: Ad.impressions + 1}) db.session.commit() def record_click(ad_id: int): """Increment click counter.""" Ad.query.filter_by(id=ad_id).update( {Ad.clicks: Ad.clicks + 1}) db.session.commit() def promoted_listings(keyword: str) -> list: """Return active promoted listings for keyword, priority-ordered.""" if not keyword: return [] norm = normalize(keyword) now = datetime.utcnow() rows = (PromotedKeyword.query .filter(PromotedKeyword.expires_at > now) .order_by(PromotedKeyword.priority.desc()) .all()) # match any keyword that is a substring of the search term (normalized) matched = [r for r in rows if normalize(r.keyword) in norm or norm in normalize(r.keyword)] listings = [] seen = set() for r in matched: if r.listing_id not in seen and r.listing and r.listing.is_live: listings.append(r.listing) seen.add(r.listing_id) return listings def active_sponsors(tier: str = None, category_id: int = None) -> list: """Return running sponsors, optionally filtered by tier and category.""" now = datetime.utcnow() q = Sponsor.query.filter( Sponsor.is_active == True, Sponsor.starts_at <= now, Sponsor.ends_at >= now, ) if tier: q = q.filter(Sponsor.tier == tier) if category_id is not None: q = q.filter(Sponsor.category_id == category_id) return q.order_by(Sponsor.created_at.desc()).all() def expire_promoted_keywords() -> int: """Remove expired promoted keyword rows. Returns count.""" now = datetime.utcnow() expired = PromotedKeyword.query.filter( PromotedKeyword.expires_at <= now).all() n = len(expired) for row in expired: db.session.delete(row) if n: db.session.commit() return n