Files
classifieds/app/services/listings.py
T
2026-07-13 16:52:00 -04:00

211 lines
6.7 KiB
Python

"""Listing business logic: creation/edit with tier enforcement, the
browse/search/radius query builder, and the expiry sweep.
"""
import re
from datetime import datetime, timedelta
from app.utils.time import utcnow
from app.extensions import db
from app.models.listing import Listing
from app.models.enums import ListingStatus, Lang
from app.utils.text import normalize
from app.services.geo import geocode_zip, bounding_box, haversine_mi
from app.services.field_schema import validate_attributes, hot_values
from app.services.settings import get_setting
class ListingError(ValueError):
def __init__(self, message, field_errors=None):
super().__init__(message)
self.field_errors = field_errors or {}
# --- tier limits ---
def _limit(user, key, default):
plan = user.tier
if plan is None:
return default
val = plan.limit(key, default)
return val
def active_count(user):
return Listing.query.filter(
Listing.user_id == user.id,
Listing.status == ListingStatus.active,
Listing.expires_at > utcnow(),
).count()
def can_create(user):
cap = _limit(user, "active_listings", 3)
if cap is None: # unlimited (business)
return True
return active_count(user) < cap
def image_cap(user):
return _limit(user, "images_per_listing", 3)
def _life_days(user):
return _limit(user, "listing_life_days", 14)
def _blocklist_hit(title, body):
"""Accent-insensitive check against the admin-configured keyword blocklist."""
blocklist = get_setting("keyword_blocklist", [])
if not blocklist:
return False
hay = normalize(f"{title} {body}")
for term in blocklist:
if not term:
continue
t = normalize(term).strip()
# word-boundary match so "ass" doesn't flag "class"; multi-word
# phrases still match as an internal-boundaried run.
if t and re.search(rf"\b{re.escape(t)}\b", hay):
return True
return False
# --- create / update ---
def create_listing(user, category, *, title, body, lang, price_cents,
zip_code, raw_attributes):
if not can_create(user):
raise ListingError("active listing limit reached for your plan")
cleaned, errors = validate_attributes(category, raw_attributes)
if errors:
raise ListingError("attribute validation failed", errors)
listing = Listing(
user_id=user.id,
category_id=category.id,
title=title.strip(),
title_norm=normalize(title),
body=body.strip(),
lang=Lang(lang) if lang in Lang._value2member_map_ else Lang.en,
price_cents=price_cents,
attributes=cleaned,
status=ListingStatus.active,
expires_at=utcnow() + timedelta(days=_life_days(user)),
**hot_values(cleaned),
)
if _blocklist_hit(title, body):
listing.status = ListingStatus.flagged
_apply_location(listing, zip_code)
db.session.add(listing)
db.session.commit()
return listing
def update_listing(listing, category, *, title, body, lang, price_cents,
zip_code, raw_attributes):
cleaned, errors = validate_attributes(category, raw_attributes)
if errors:
raise ListingError("attribute validation failed", errors)
listing.title = title.strip()
listing.title_norm = normalize(title)
listing.body = body.strip()
listing.lang = Lang(lang) if lang in Lang._value2member_map_ else listing.lang
listing.price_cents = price_cents
listing.attributes = cleaned
for col, val in hot_values(cleaned).items():
setattr(listing, col, val)
if listing.status == ListingStatus.active and _blocklist_hit(title, body):
listing.status = ListingStatus.flagged
_apply_location(listing, zip_code)
db.session.commit()
return listing
def _apply_location(listing, zip_code):
listing.zip = (zip_code or "").strip() or None
geo = geocode_zip(zip_code) if zip_code else None
if geo:
listing.lat, listing.lng, listing.city, listing.state, _metro = geo
else:
listing.lat = listing.lng = listing.city = listing.state = None
# --- browse / search / radius ---
def browse_query(*, category_id=None, q=None, state=None, min_price=None,
max_price=None, condition=None, job_type=None):
"""Base query of live listings with optional filters (no radius)."""
query = Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at > utcnow(),
)
if category_id:
query = query.filter(Listing.category_id == category_id)
if state:
query = query.filter(Listing.state == state.upper())
if min_price is not None:
query = query.filter(Listing.price_cents >= min_price)
if max_price is not None:
query = query.filter(Listing.price_cents <= max_price)
if condition:
query = query.filter(Listing.attr_condition == condition)
if job_type:
query = query.filter(Listing.attr_job_type == job_type)
if q:
term = f"%{normalize(q)}%"
# accent-insensitive on title_norm; body LIKE as a fallback.
query = query.filter(db.or_(
Listing.title_norm.like(term),
Listing.body.like(f"%{q.strip()}%"),
))
return query
def order_default(query):
"""Featured first, then most recently bumped/created."""
return query.order_by(
Listing.is_featured.desc(),
db.func.coalesce(Listing.bump_at, Listing.created_at).desc(),
)
def radius_filter(listings, lat, lng, radius_mi):
"""Refine an iterable of listings to those within radius (exact haversine).
Pair with a bounding-box DB prefilter for efficiency (see search_with_radius).
"""
out = []
for l in listings:
if l.lat is None or l.lng is None:
continue
d = haversine_mi(lat, lng, l.lat, l.lng)
if d <= radius_mi:
out.append((l, round(d, 1)))
out.sort(key=lambda t: t[1])
return out
def search_with_radius(base_query, lat, lng, radius_mi):
"""Apply a bounding-box prefilter in SQL, then exact-distance refine."""
min_lat, max_lat, min_lng, max_lng = bounding_box(lat, lng, radius_mi)
prefiltered = base_query.filter(
Listing.lat.between(min_lat, max_lat),
Listing.lng.between(min_lng, max_lng),
).all()
return radius_filter(prefiltered, lat, lng, radius_mi)
# --- expiry sweep (scheduler / CLI) ---
def expire_due_listings():
"""Flip active listings past expires_at to expired. Returns count."""
now = utcnow()
due = Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at <= now,
)
n = 0
for listing in due:
listing.status = ListingStatus.expired
n += 1
if n:
db.session.commit()
return n