187 lines
5.9 KiB
Python
187 lines
5.9 KiB
Python
"""Listing business logic: creation/edit with tier enforcement, the
|
|
browse/search/radius query builder, and the expiry sweep.
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
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
|
|
|
|
|
|
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 > datetime.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)
|
|
|
|
|
|
# --- 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=datetime.utcnow() + timedelta(days=_life_days(user)),
|
|
**hot_values(cleaned),
|
|
)
|
|
_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)
|
|
_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 > datetime.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 = datetime.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
|