Files
2026-07-13 16:52:00 -04:00

122 lines
3.8 KiB
Python

"""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.utils.time import utcnow
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 = 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 = 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 = 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 = 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