"""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 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 > 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) 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}") return any(normalize(term) in hay for term in blocklist if term) # --- 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), ) 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 > 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