06/15 Phase 1 + 2 codes
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user