06/15 Phase 1 + 2 codes

This commit is contained in:
2026-06-15 11:23:05 -04:00
commit c2064b84b4
62 changed files with 2937 additions and 0 deletions
View File
+34
View File
@@ -0,0 +1,34 @@
"""Email service. Sends via SMTP relay (Brevo); falls back to console log in dev."""
import smtplib
from email.message import EmailMessage
from flask import current_app
def send_email(to_addr: str, subject: str, body: str) -> bool:
cfg = current_app.config
server = cfg.get("MAIL_SERVER")
# Dev fallback: no SMTP configured -> log to console.
if not server:
current_app.logger.info(
"[DEV EMAIL] to=%s subject=%s\n%s", to_addr, subject, body
)
return True
msg = EmailMessage()
msg["From"] = f"{cfg.get('MAIL_FROM_NAME')} <{cfg.get('MAIL_FROM')}>"
msg["To"] = to_addr
msg["Subject"] = subject
msg.set_content(body)
try:
with smtplib.SMTP(server, cfg.get("MAIL_PORT", 587), timeout=15) as s:
if cfg.get("MAIL_USE_TLS"):
s.starttls()
if cfg.get("MAIL_USERNAME"):
s.login(cfg.get("MAIL_USERNAME"), cfg.get("MAIL_PASSWORD"))
s.send_message(msg)
return True
except Exception as exc: # noqa: BLE001
current_app.logger.error("Email send failed: %s", exc)
return False
+75
View File
@@ -0,0 +1,75 @@
"""Validate listing `attributes` against a category's `field_schema`.
Returns (cleaned_attributes, errors). Caller rejects on any errors.
Supported field types: text, number, select, bool.
"""
_HOT_COLUMNS = {"condition", "job_type", "salary_min", "salary_max"}
class SchemaError(ValueError):
pass
def validate_attributes(category, raw):
raw = raw or {}
cleaned, errors = {}, {}
for field in category.fields():
name = field["name"]
ftype = field.get("type", "text")
required = field.get("required", False)
val = raw.get(name)
if val in (None, "", []):
if required:
errors[name] = "required"
continue
try:
cleaned[name] = _coerce(field, ftype, val)
except SchemaError as e:
errors[name] = str(e)
return cleaned, errors
def _coerce(field, ftype, val):
if ftype == "text":
s = str(val).strip()
mx = field.get("max", 255)
if len(s) > mx:
raise SchemaError(f"max length {mx}")
return s
if ftype == "number":
try:
n = int(val) if str(val).lstrip("-").isdigit() else float(val)
except (TypeError, ValueError):
raise SchemaError("must be a number")
if "min" in field and n < field["min"]:
raise SchemaError(f"min {field['min']}")
if "max" in field and n > field["max"]:
raise SchemaError(f"max {field['max']}")
return n
if ftype == "select":
opts = field.get("options", [])
if val not in opts:
raise SchemaError("invalid option")
return val
if ftype == "bool":
return bool(val) if isinstance(val, bool) else str(val).lower() in ("1", "true", "on", "yes")
raise SchemaError("unknown field type")
def hot_values(cleaned):
"""Map cleaned attributes -> denormalized Listing hot columns."""
return {
"attr_condition": cleaned.get("condition"),
"attr_job_type": cleaned.get("job_type"),
"attr_salary_min": cleaned.get("salary_min"),
"attr_salary_max": cleaned.get("salary_max"),
}
+35
View File
@@ -0,0 +1,35 @@
"""Geo helpers: ZIP geocoding, haversine distance, bounding-box radius filter.
Portable across MySQL and SQLite (no spatial extension needed). For large-scale
deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade.
"""
import math
from app.models.geo import ZipGeo
EARTH_MI = 3958.7613 # mean earth radius, miles
def geocode_zip(zip_code):
"""Return (lat, lng, city, state, metro) or None."""
row = ZipGeo.query.get((zip_code or "").strip())
if row is None:
return None
return row.lat, row.lng, row.city, row.state, row.metro
def haversine_mi(lat1, lng1, lat2, lng2):
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(rlat1) * math.cos(rlat2) * math.sin(dlng / 2) ** 2)
return EARTH_MI * 2 * math.asin(math.sqrt(a))
def bounding_box(lat, lng, radius_mi):
"""Lat/lng min/max box enclosing the radius. Cheap indexed prefilter."""
lat_delta = radius_mi / 69.0
# guard against poles; cos(lat) shrinks lng degrees-per-mile
cos_lat = max(math.cos(math.radians(lat)), 0.01)
lng_delta = radius_mi / (69.172 * cos_lat)
return (lat - lat_delta, lat + lat_delta, lng - lng_delta, lng + lng_delta)
+76
View File
@@ -0,0 +1,76 @@
"""Image pipeline. Validates, re-encodes (drops EXIF), thumbnails, saves to media.
Re-encoding through Pillow strips metadata and neutralizes polyglot/malicious
payloads. Filenames are randomized. Returns a ListingImage (uncommitted).
"""
import io
import os
import secrets
from PIL import Image
from flask import current_app
from app.models.listing import ListingImage
MAX_BYTES = 8 * 1024 * 1024 # 8 MB per upload
MAX_DIM = 1600 # longest edge for the full image
THUMB_DIM = 400
ALLOWED = {"JPEG", "PNG", "WEBP"}
class ImageError(ValueError):
pass
def _media_root():
root = current_app.config.get("MEDIA_ROOT") or os.path.join(
current_app.instance_path, "media")
os.makedirs(root, exist_ok=True)
return root
def process_upload(file_storage, listing_id, sort_order=0):
data = file_storage.read()
if not data:
raise ImageError("empty file")
if len(data) > MAX_BYTES:
raise ImageError("file too large")
try:
img = Image.open(io.BytesIO(data))
img.verify() # detect truncated/corrupt
img = Image.open(io.BytesIO(data)) # re-open after verify
except Exception:
raise ImageError("not a valid image")
if img.format not in ALLOWED:
raise ImageError("unsupported format")
img = img.convert("RGB") # normalize, drop alpha/EXIF
img.thumbnail((MAX_DIM, MAX_DIM))
root = _media_root()
sub = os.path.join(root, str(listing_id))
os.makedirs(sub, exist_ok=True)
name = secrets.token_hex(16)
full_rel = os.path.join(str(listing_id), f"{name}.jpg")
thumb_rel = os.path.join(str(listing_id), f"{name}_t.jpg")
img.save(os.path.join(root, full_rel), "JPEG", quality=85, optimize=True)
thumb = img.copy()
thumb.thumbnail((THUMB_DIM, THUMB_DIM))
thumb.save(os.path.join(root, thumb_rel), "JPEG", quality=80, optimize=True)
return ListingImage(
listing_id=listing_id, path=full_rel, thumb_path=thumb_rel,
sort_order=sort_order, width=img.width, height=img.height,
)
def delete_image_files(image):
root = _media_root()
for rel in (image.path, image.thumb_path):
if rel:
try:
os.remove(os.path.join(root, rel))
except OSError:
pass
+186
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
"""Trust scoring. Append events, recompute score + tier.
Phase 1 wiring: email verification grants trust. Listing-survival and flag
penalties hook in during Phase 2/3. Thresholds are intentionally simple here.
"""
from app.extensions import db
from app.models.trust import TrustEvent
from app.models.enums import TrustEventType, TrustTier
# tier thresholds by cumulative score
_TIER_THRESHOLDS = [
(50, TrustTier.verified),
(20, TrustTier.trusted),
(5, TrustTier.basic),
(0, TrustTier.new),
]
def record_event(user, event_type: TrustEventType, delta: int):
"""Append a trust event, bump score, recompute tier. Caller commits."""
db.session.add(TrustEvent(user_id=user.id, type=event_type, delta=delta))
user.trust_score = max(0, (user.trust_score or 0) + delta)
user.trust_tier = _tier_for(user.trust_score)
return user
def _tier_for(score: int) -> TrustTier:
for threshold, tier in _TIER_THRESHOLDS:
if score >= threshold:
return tier
return TrustTier.new
+34
View File
@@ -0,0 +1,34 @@
"""Cloudflare Turnstile verification. Bypasses in dev when no secret is set."""
import requests
from flask import current_app, request
_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def turnstile_enabled() -> bool:
return bool(current_app.config.get("TURNSTILE_SECRET_KEY"))
def verify_turnstile() -> bool:
"""Validate the cf-turnstile-response token from the current request form."""
secret = current_app.config.get("TURNSTILE_SECRET_KEY")
if not secret:
return True # dev bypass
token = request.form.get("cf-turnstile-response", "")
if not token:
return False
try:
resp = requests.post(
_VERIFY_URL,
data={
"secret": secret,
"response": token,
"remoteip": request.remote_addr,
},
timeout=10,
)
return bool(resp.json().get("success"))
except Exception as exc: # noqa: BLE001
current_app.logger.error("Turnstile verify failed: %s", exc)
return False