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