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
+43
View File
@@ -0,0 +1,43 @@
# --- Core ---
FLASK_CONFIG=dev # dev | prod
SECRET_KEY=change-me-to-a-long-random-string
SERVER_NAME= # e.g. classifieds.example.com (prod)
# --- Database (MySQL 8.0) ---
DB_USER=classifieds
DB_PASSWORD=change-me
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=classifieds
# Dev override: leave DATABASE_URL empty to build from parts above.
# For a quick local smoke test without MySQL you may set:
# DATABASE_URL=sqlite:///dev.db
DATABASE_URL=
# --- Redis (sessions, rate-limit, cache, queue) ---
REDIS_URL=redis://127.0.0.1:6379/0
# --- i18n ---
DEFAULT_LOCALE=en
SUPPORTED_LOCALES=en,vi,es
# --- Email (Brevo smart relay / SMTP) ---
MAIL_SERVER=
MAIL_PORT=587
MAIL_USE_TLS=true
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM=no-reply@example.com
MAIL_FROM_NAME=Classifieds
# --- Media (uploaded images); default instance/media ---
MEDIA_ROOT=
# --- Cloudflare Turnstile (CAPTCHA) ---
# Leave both blank in dev to bypass CAPTCHA verification.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# --- Security tokens ---
TOKEN_VERIFY_MAX_AGE=86400 # 24h email-verify link life (seconds)
TOKEN_RESET_MAX_AGE=3600 # 1h password-reset link life (seconds)
+8
View File
@@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
.env
dev.db
*.mo
instance/
.DS_Store
+161
View File
@@ -0,0 +1,161 @@
# Classifieds — Phase 1 (Foundation)
App factory, config, extensions, `users`/`plans` schema, full auth
(register / login / logout / email-verify / password-reset), RBAC,
i18n scaffold (EN/VI/ES) with accent-insensitive normalizer, Turnstile hook.
## Stack
Flask · MySQL 8.0 · Redis · Gunicorn · systemd · Nginx · Ubuntu 22.04
---
## Local setup
```bash
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # edit secrets
```
### Quick smoke test (no MySQL/Redis needed)
A committed test exercises the whole Phase-1 surface against a temp SQLite file
with the limiter in-memory:
```bash
python -m tests.test_smoke # or: pytest -q
```
It checks: plan seeding, accent normalizer (phở→pho, ñandú→nandu), register →
email-verify (+5 trust) → login/logout, password reset round-trip, duplicate-email
and bad-password rejection, home/healthz/404.
> Note: it runs over `https://localhost` with a `Referer` header on purpose —
> production sets `PREFERRED_URL_SCHEME=https`, so Flask-WTF enforces the secure
> referrer CSRF check. Real browsers send `Referer`; the test mirrors that.
### Manual run with the dev server
> Flask-SQLAlchemy resolves a *relative* `sqlite:///dev.db` against the **instance/**
> folder, not the CWD. To reset, delete `instance/dev.db` (or use an absolute
> `DATABASE_URL=sqlite:////abs/path/dev.db`).
Set in `.env`: `DATABASE_URL=sqlite:////tmp/classifieds_dev.db` (absolute), then:
```bash
export FLASK_APP=wsgi:app
flask db init
flask db migrate -m "phase1 users+plans+trust"
flask db upgrade
python seed.py --admin admin@example.com 'StrongPass123'
flask run
```
Open http://127.0.0.1:5000
### Real MySQL 8.0
```sql
CREATE DATABASE classifieds CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'classifieds'@'127.0.0.1' IDENTIFIED BY 'change-me';
GRANT ALL PRIVILEGES ON classifieds.* TO 'classifieds'@'127.0.0.1';
FLUSH PRIVILEGES;
```
Leave `DATABASE_URL` blank in `.env` (URI is built from `DB_*`). Then run the
same `flask db ...` + `seed.py` sequence.
---
## i18n (translations)
```bash
pybabel extract -F babel.cfg -o messages.pot .
pybabel init -i messages.pot -d app/translations -l vi
pybabel init -i messages.pot -d app/translations -l es
# ...translate the .po files...
pybabel compile -d app/translations
```
Re-extract + `pybabel update` after adding new `_()` strings.
---
## What works now
- Register → email verify link (dev: printed to console) → verify grants +5 trust.
- Login / logout, remember-me, rate-limited, suspended-account block.
- Password reset (enumeration-safe), signed expiring tokens.
- Language switch persists to session + profile.
- Argon2 hashing, CSRF on all forms, RBAC decorators ready.
- Free plan auto-assigned on registration.
## Notes / Phase-1 limits
- Turnstile bypassed when keys blank (dev). Set keys in `.env` for prod.
- Email prints to console until SMTP (Brevo) configured.
- `requirements.txt` uses PyMySQL. For the SQLite smoke test no driver needed.
---
## Production deploy (outline)
1. Code → `/opt/classifieds`, venv, `pip install -r requirements.txt`.
2. `.env` with prod secrets, `FLASK_CONFIG=prod`, real `SECRET_KEY`.
3. MySQL DB + user (above). `flask db upgrade && python seed.py`.
4. `deploy/classifieds.service``/etc/systemd/system/`, `systemctl enable --now classifieds`.
5. `deploy/nginx.conf.sample` → adapt, enable site, add TLS (certbot), reload Nginx.
6. Redis running for sessions + rate-limit + (later) queue.
---
## Phase 2 — Listings core (built)
New models: `Category` (self-referential subcategories + `field_schema` JSON),
`Listing` (+ `title_norm`, denormalized hot columns, lat/lng, status, expiry),
`ListingImage`, `ZipGeo`, `Metro`.
Features:
- Listing CRUD with owner/moderator authorization; mark-sold; my-listings.
- Category-specific fields driven by `field_schema` (text/number/select/bool),
validated server-side; dynamic form toggles fields by selected category.
- Tier enforcement: active-listing cap, images-per-listing cap, listing life
(`expires_at`) — all read from `plans.config`.
- Image pipeline (Pillow): validate, re-encode to JPEG (strips EXIF/metadata),
thumbnail, randomized filenames, per-listing media folder.
- Browse + filters (category, state, price range, condition/job_type hot fields).
- Accent-insensitive keyword search via `title_norm` (phở matches "pho").
- Radius search: ZIP geocode → bounding-box SQL prefilter → exact haversine refine.
- Expiry sweep: `flask expire-listings` (wire the systemd timer below).
### Expiry sweep (systemd timer)
```bash
cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/
systemctl enable --now classifieds-expire.timer
```
### Seed
`python seed.py` now also seeds the 6 categories + subcategories and a small
sample of `zip_geo` rows. **Replace the ZIP sample with the full dataset**
(SimpleMaps US ZIP or Census ZCTA, ~42k rows) before production.
---
## MySQL upgrades (optional, post-Phase-2)
The Phase-2 code is portable (runs on SQLite for tests, MySQL in prod). Three
spots can be upgraded to native MySQL features when you want them:
1. **Spatial radius** — replace lat/lng bounding-box + haversine with a generated
`POINT` column + `SPATIAL INDEX` and `ST_Distance_Sphere`:
```sql
ALTER TABLE listings ADD COLUMN geo POINT
GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED,
ADD SPATIAL INDEX spx_listings_geo (geo);
```
2. **Generated hot columns** — instead of app-maintained `attr_*` columns, derive
them from JSON:
```sql
ALTER TABLE listings ADD COLUMN attr_condition VARCHAR(40)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(attributes,'$.condition'))) STORED,
ADD INDEX ix_attr_condition (attr_condition);
```
3. **Full-text search** — add a FULLTEXT index and switch the keyword filter to
`MATCH ... AGAINST` for relevance + speed:
```sql
ALTER TABLE listings ADD FULLTEXT INDEX ftx_listings (title, body);
```
(Keep `title_norm` for accent-insensitive matching; combine as needed.)
---
## Next: Phase 3 — Messaging + favorites
conversations, user-to-user messages, saved listings, contact masking gated by
trust tier.
+100
View File
@@ -0,0 +1,100 @@
"""Application factory."""
from flask import Flask, render_template
from app.config import get_config
from app.extensions import (db, migrate, login_manager, csrf, babel, limiter)
def create_app(config_object=None):
app = Flask(__name__)
app.config.from_object(config_object or get_config())
_init_extensions(app)
_init_login(app)
_init_babel(app)
_register_blueprints(app)
_register_errorhandlers(app)
_register_context(app)
_register_cli(app)
return app
def _init_extensions(app):
# Honor X-Forwarded-* from the Nginx reverse proxy (scheme, host, client IP).
# Correct is_secure, secure cookies, and rate-limit keying behind the proxy.
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
db.init_app(app)
migrate.init_app(app, db)
csrf.init_app(app)
limiter.init_app(app)
# ensure models are imported so migrations see them
from app import models # noqa: F401
def _init_login(app):
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id):
from app.models.user import User
return User.query.get(int(user_id))
def _init_babel(app):
from app.blueprints.i18n.routes import select_locale
babel.init_app(app, locale_selector=select_locale)
def _register_blueprints(app):
from app.blueprints.main.routes import main_bp
from app.blueprints.auth.routes import auth_bp
from app.blueprints.i18n.routes import i18n_bp
from app.blueprints.listings.routes import listings_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(i18n_bp)
app.register_blueprint(listings_bp)
def _register_errorhandlers(app):
@app.errorhandler(403)
def forbidden(e):
return render_template("errors/403.html"), 403
@app.errorhandler(404)
def not_found(e):
return render_template("errors/404.html"), 404
@app.errorhandler(500)
def server_error(e):
return render_template("errors/500.html"), 500
def _register_context(app):
from flask_babel import get_locale
from flask import request
def merge_query(**overrides):
merged = request.args.to_dict()
merged.update({k: v for k, v in overrides.items() if v is not None})
return merged
@app.context_processor
def inject_globals():
return {
"get_locale": get_locale,
"merge_query": merge_query,
"SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"],
"TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""),
}
def _register_cli(app):
@app.cli.command("expire-listings")
def expire_listings():
"""Sweep: flip past-due active listings to expired."""
from app.services.listings import expire_due_listings
n = expire_due_listings()
print(f"Expired {n} listing(s).")
View File
View File
+39
View File
@@ -0,0 +1,39 @@
"""Auth forms (Flask-WTF). CSRF automatic. Labels wrapped for i18n."""
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
from flask_babel import lazy_gettext as _l
class RegisterForm(FlaskForm):
display_name = StringField(_l("Display name"),
validators=[DataRequired(), Length(2, 80)])
email = StringField(_l("Email"),
validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField(_l("Password"),
validators=[DataRequired(), Length(min=8, max=128)])
confirm = PasswordField(_l("Confirm password"),
validators=[DataRequired(), EqualTo("password",
message=_l("Passwords must match"))])
submit = SubmitField(_l("Create account"))
class LoginForm(FlaskForm):
email = StringField(_l("Email"), validators=[DataRequired(), Email()])
password = PasswordField(_l("Password"), validators=[DataRequired()])
remember = BooleanField(_l("Remember me"))
submit = SubmitField(_l("Sign in"))
class ResetRequestForm(FlaskForm):
email = StringField(_l("Email"), validators=[DataRequired(), Email()])
submit = SubmitField(_l("Send reset link"))
class ResetForm(FlaskForm):
password = PasswordField(_l("New password"),
validators=[DataRequired(), Length(min=8, max=128)])
confirm = PasswordField(_l("Confirm password"),
validators=[DataRequired(), EqualTo("password",
message=_l("Passwords must match"))])
submit = SubmitField(_l("Reset password"))
+148
View File
@@ -0,0 +1,148 @@
"""Auth flows: register, login, logout, email verification, password reset."""
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, current_app, abort)
from flask_login import login_user, logout_user, login_required, current_user
from flask_babel import gettext as _
from app.extensions import db, limiter
from app.models.user import User
from app.models.plan import Plan
from app.models.enums import Role, TrustEventType
from app.utils.security import generate_token, read_token
from app.services.email import send_email
from app.services.turnstile import verify_turnstile, turnstile_enabled
from app.services.trust import record_event
from app.blueprints.auth.forms import (RegisterForm, LoginForm,
ResetRequestForm, ResetForm)
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
_VERIFY_SALT = "email-verify"
_RESET_SALT = "password-reset"
def _send_verify_email(user):
token = generate_token(user.id, _VERIFY_SALT)
link = url_for("auth.verify_email", token=token, _external=True)
send_email(user.email, _("Verify your email"),
_("Confirm your account: %(link)s", link=link))
@auth_bp.route("/register", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = RegisterForm()
if form.validate_on_submit():
if not verify_turnstile():
flash(_("Captcha verification failed."), "danger")
return render_template("auth/register.html", form=form,
turnstile=turnstile_enabled())
existing = User.query.filter_by(email=form.email.data.lower()).first()
if existing:
flash(_("An account with that email already exists."), "danger")
return render_template("auth/register.html", form=form,
turnstile=turnstile_enabled())
free_plan = Plan.query.filter_by(slug="free").first()
user = User(
email=form.email.data.lower(),
display_name=form.display_name.data.strip(),
role=Role.free,
tier_id=free_plan.id if free_plan else None,
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
_send_verify_email(user)
flash(_("Account created. Check your email to verify."), "success")
return redirect(url_for("auth.login"))
return render_template("auth/register.html", form=form,
turnstile=turnstile_enabled())
@auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("20 per hour", methods=["POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first()
if user is None or not user.check_password(form.password.data):
flash(_("Invalid email or password."), "danger")
return render_template("auth/login.html", form=form)
if not user.is_active:
flash(_("This account is suspended."), "danger")
return render_template("auth/login.html", form=form)
login_user(user, remember=form.remember.data)
user.last_login_at = datetime.utcnow()
db.session.commit()
nxt = request.args.get("next")
if nxt and nxt.startswith("/"):
return redirect(nxt)
return redirect(url_for("main.index"))
return render_template("auth/login.html", form=form)
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
flash(_("Signed out."), "info")
return redirect(url_for("main.index"))
@auth_bp.route("/verify/<token>")
def verify_email(token):
user_id = read_token(token, _VERIFY_SALT,
current_app.config["TOKEN_VERIFY_MAX_AGE"])
if user_id is None:
flash(_("Verification link is invalid or expired."), "danger")
return redirect(url_for("auth.login"))
user = User.query.get(user_id)
if user is None:
abort(404)
if not user.email_verified:
user.email_verified = True
record_event(user, TrustEventType.verified_email, 5)
db.session.commit()
flash(_("Email verified. You're all set."), "success")
return redirect(url_for("auth.login"))
@auth_bp.route("/reset", methods=["GET", "POST"])
@limiter.limit("5 per hour", methods=["POST"])
def reset_request():
form = ResetRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first()
if user:
token = generate_token(user.id, _RESET_SALT)
link = url_for("auth.reset_password", token=token, _external=True)
send_email(user.email, _("Reset your password"),
_("Reset link: %(link)s", link=link))
# Always show success to avoid email enumeration.
flash(_("If that email exists, a reset link has been sent."), "info")
return redirect(url_for("auth.login"))
return render_template("auth/reset_request.html", form=form)
@auth_bp.route("/reset/<token>", methods=["GET", "POST"])
def reset_password(token):
user_id = read_token(token, _RESET_SALT,
current_app.config["TOKEN_RESET_MAX_AGE"])
if user_id is None:
flash(_("Reset link is invalid or expired."), "danger")
return redirect(url_for("auth.reset_request"))
user = User.query.get(user_id)
if user is None:
abort(404)
form = ResetForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash(_("Password updated. Sign in."), "success")
return redirect(url_for("auth.login"))
return render_template("auth/reset.html", form=form, token=token)
View File
+38
View File
@@ -0,0 +1,38 @@
"""i18n: locale resolution + a route to switch UI language.
Order: explicit session choice -> authenticated user.locale -> Accept-Language -> default.
"""
from flask import Blueprint, session, redirect, request, current_app, url_for
from flask_login import current_user
i18n_bp = Blueprint("i18n", __name__)
def select_locale():
supported = current_app.config["SUPPORTED_LOCALES"]
# 1. explicit session override
lang = session.get("lang")
if lang in supported:
return lang
# 2. authenticated user's stored preference
if current_user.is_authenticated and current_user.locale in supported:
return current_user.locale
# 3. browser preference
best = request.accept_languages.best_match(supported)
if best:
return best
# 4. default
return current_app.config["DEFAULT_LOCALE"]
@i18n_bp.route("/lang/<code>")
def set_lang(code):
if code in current_app.config["SUPPORTED_LOCALES"]:
session["lang"] = code
# persist to profile when logged in
if current_user.is_authenticated:
from app.extensions import db
current_user.locale = code
db.session.commit()
target = request.referrer or url_for("main.index")
return redirect(target)
View File
+33
View File
@@ -0,0 +1,33 @@
"""Listing forms. Core fields only; category-specific fields are rendered and
parsed dynamically from the category field_schema (prefix `attr_`)."""
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import (StringField, TextAreaField, SelectField, DecimalField,
SubmitField, MultipleFileField)
from wtforms.validators import DataRequired, Length, Optional, NumberRange
from flask_babel import lazy_gettext as _l
class ListingForm(FlaskForm):
category_id = SelectField(_l("Category"), coerce=int,
validators=[DataRequired()])
title = StringField(_l("Title"), validators=[DataRequired(), Length(3, 140)])
body = TextAreaField(_l("Description"),
validators=[DataRequired(), Length(10, 8000)])
lang = SelectField(_l("Language"),
choices=[("en", "English"), ("vi", "Tiếng Việt"),
("es", "Español")], default="en")
price = DecimalField(_l("Price (USD)"), places=2,
validators=[Optional(), NumberRange(min=0)])
zip = StringField(_l("ZIP code"), validators=[Optional(), Length(3, 12)])
images = MultipleFileField(_l("Photos"),
validators=[FileAllowed(["jpg", "jpeg", "png", "webp"],
_l("Images only"))])
submit = SubmitField(_l("Publish"))
class ImageUploadForm(FlaskForm):
image = FileField(_l("Photo"),
validators=[DataRequired(),
FileAllowed(["jpg", "jpeg", "png", "webp"])])
submit = SubmitField(_l("Add photo"))
+267
View File
@@ -0,0 +1,267 @@
"""Listings: browse/search/detail + owner CRUD + image management + dev media."""
import os
from datetime import datetime
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, abort, current_app, send_from_directory)
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db, limiter
from app.models.category import Category
from app.models.listing import Listing, ListingImage
from app.models.enums import ListingStatus
from app.services import listings as svc
from app.services.geo import geocode_zip
from app.services.images import process_upload, delete_image_files, ImageError
from app.blueprints.listings.forms import ListingForm, ImageUploadForm
listings_bp = Blueprint("listings", __name__)
PER_PAGE = 20
def _category_choices():
cats = Category.query.filter_by(is_active=True).order_by(
Category.sort_order, Category.name).all()
return cats
def _parse_attributes(category):
"""Pull attr_<name> fields from the submitted form."""
raw = {}
for f in category.fields():
raw[f["name"]] = request.form.get("attr_" + f["name"])
return raw
# --- browse + search ---
@listings_bp.route("/listings")
def browse():
page = request.args.get("page", 1, type=int)
category_id = request.args.get("category", type=int)
q = request.args.get("q", type=str)
state = request.args.get("state", type=str)
zip_code = request.args.get("zip", type=str)
radius = request.args.get("radius", type=int)
min_price = request.args.get("min_price", type=int)
max_price = request.args.get("max_price", type=int)
condition = request.args.get("condition", type=str)
base = svc.browse_query(category_id=category_id, q=q, state=state,
min_price=(min_price * 100 if min_price else None),
max_price=(max_price * 100 if max_price else None),
condition=condition)
base = svc.order_default(base)
near = None
if zip_code and radius:
geo = geocode_zip(zip_code)
if geo:
lat, lng = geo[0], geo[1]
results = svc.search_with_radius(base, lat, lng, radius)
# paginate manually for radius results
total = len(results)
start = (page - 1) * PER_PAGE
items = results[start:start + PER_PAGE]
near = {"zip": zip_code, "radius": radius, "total": total}
return render_template("listings/browse.html",
results=items, near=near,
categories=_category_choices(),
filters=request.args, page=page,
has_next=start + PER_PAGE < total)
flash(_("ZIP not found; showing all results."), "warning")
pagination = base.paginate(page=page, per_page=PER_PAGE, error_out=False)
results = [(l, None) for l in pagination.items]
return render_template("listings/browse.html", results=results, near=None,
categories=_category_choices(), filters=request.args,
page=page, has_next=pagination.has_next)
# --- detail ---
@listings_bp.route("/listings/<int:listing_id>")
def detail(listing_id):
listing = Listing.query.get_or_404(listing_id)
is_owner = current_user.is_authenticated and listing.user_id == current_user.id
if not listing.is_live and not (is_owner or
(current_user.is_authenticated
and current_user.is_moderator)):
abort(404)
if not is_owner:
listing.view_count = (listing.view_count or 0) + 1
db.session.commit()
return render_template("listings/detail.html", listing=listing,
is_owner=is_owner)
# --- create ---
@listings_bp.route("/listings/new", methods=["GET", "POST"])
@login_required
@limiter.limit("30 per hour", methods=["POST"])
def create():
if not svc.can_create(current_user):
flash(_("You've reached your plan's active-listing limit."), "warning")
return redirect(url_for("listings.mine"))
form = ListingForm()
cats = _category_choices()
form.category_id.choices = [(c.id, c.name) for c in cats]
category = None
if request.method == "POST" and form.category_id.data:
category = Category.query.get(form.category_id.data)
if form.validate_on_submit() and category:
raw_attrs = _parse_attributes(category)
price_cents = int(form.price.data * 100) if form.price.data is not None else None
try:
listing = svc.create_listing(
current_user, category,
title=form.title.data, body=form.body.data, lang=form.lang.data,
price_cents=price_cents, zip_code=form.zip.data,
raw_attributes=raw_attrs)
except svc.ListingError as e:
for fld, msg in (e.field_errors or {}).items():
flash(_("%(f)s: %(m)s", f=fld, m=msg), "danger")
if not e.field_errors:
flash(str(e), "danger")
return render_template("listings/form.html", form=form,
categories=cats, category=category,
listing=None)
# process any uploaded images, honoring per-plan cap
_save_images(form.images.data, listing)
flash(_("Listing published."), "success")
return redirect(url_for("listings.detail", listing_id=listing.id))
return render_template("listings/form.html", form=form, categories=cats,
category=category, listing=None)
# --- edit ---
@listings_bp.route("/listings/<int:listing_id>/edit", methods=["GET", "POST"])
@login_required
def edit(listing_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id and not current_user.is_moderator:
abort(403)
cats = _category_choices()
form = ListingForm(obj=None)
form.category_id.choices = [(c.id, c.name) for c in cats]
if request.method == "GET":
form.category_id.data = listing.category_id
form.title.data = listing.title
form.body.data = listing.body
form.lang.data = listing.lang.value
form.price.data = (listing.price_cents / 100) if listing.price_cents else None
form.zip.data = listing.zip
category = Category.query.get(form.category_id.data or listing.category_id)
if form.validate_on_submit() and category:
raw_attrs = _parse_attributes(category)
price_cents = int(form.price.data * 100) if form.price.data is not None else None
try:
svc.update_listing(listing, category, title=form.title.data,
body=form.body.data, lang=form.lang.data,
price_cents=price_cents, zip_code=form.zip.data,
raw_attributes=raw_attrs)
except svc.ListingError as e:
for fld, msg in (e.field_errors or {}).items():
flash(_("%(f)s: %(m)s", f=fld, m=msg), "danger")
return render_template("listings/form.html", form=form,
categories=cats, category=category,
listing=listing)
_save_images(form.images.data, listing)
flash(_("Listing updated."), "success")
return redirect(url_for("listings.detail", listing_id=listing.id))
return render_template("listings/form.html", form=form, categories=cats,
category=category, listing=listing)
# --- delete ---
@listings_bp.route("/listings/<int:listing_id>/delete", methods=["POST"])
@login_required
def delete(listing_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id and not current_user.is_moderator:
abort(403)
for img in list(listing.images):
delete_image_files(img)
db.session.delete(listing)
db.session.commit()
flash(_("Listing deleted."), "info")
return redirect(url_for("listings.mine"))
# --- mark sold ---
@listings_bp.route("/listings/<int:listing_id>/sold", methods=["POST"])
@login_required
def mark_sold(listing_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id:
abort(403)
listing.status = ListingStatus.sold
db.session.commit()
flash(_("Marked as sold."), "success")
return redirect(url_for("listings.detail", listing_id=listing.id))
# --- my listings ---
@listings_bp.route("/my/listings")
@login_required
def mine():
items = (Listing.query.filter_by(user_id=current_user.id)
.order_by(Listing.created_at.desc()).all())
cap = svc._limit(current_user, "active_listings", 3)
return render_template("listings/mine.html", items=items,
active=svc.active_count(current_user), cap=cap)
# --- image delete ---
@listings_bp.route("/listings/<int:listing_id>/images/<int:image_id>/delete",
methods=["POST"])
@login_required
def delete_image(listing_id, image_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id and not current_user.is_moderator:
abort(403)
img = ListingImage.query.get_or_404(image_id)
if img.listing_id != listing.id:
abort(404)
delete_image_files(img)
db.session.delete(img)
db.session.commit()
flash(_("Photo removed."), "info")
return redirect(url_for("listings.edit", listing_id=listing.id))
# --- dev media serving (Nginx serves /media in prod) ---
@listings_bp.route("/media/<path:rel>")
def media(rel):
root = current_app.config.get("MEDIA_ROOT") or os.path.join(
current_app.instance_path, "media")
return send_from_directory(root, rel)
def _save_images(file_list, listing):
if not file_list:
return
cap = svc.image_cap(current_user)
existing = len(listing.images)
saved = 0
for fs in file_list:
if not getattr(fs, "filename", ""):
continue
if cap is not None and existing + saved >= cap:
flash(_("Photo limit (%(n)s) reached for your plan.", n=cap), "warning")
break
try:
img = process_upload(fs, listing.id, sort_order=existing + saved)
db.session.add(img)
saved += 1
except ImageError as e:
flash(_("Image skipped: %(m)s", m=str(e)), "warning")
if saved:
db.session.commit()
View File
+14
View File
@@ -0,0 +1,14 @@
"""Main blueprint: landing page and health check."""
from flask import Blueprint, render_template, jsonify
main_bp = Blueprint("main", __name__)
@main_bp.route("/")
def index():
return render_template("index.html")
@main_bp.route("/healthz")
def healthz():
return jsonify(status="ok")
+85
View File
@@ -0,0 +1,85 @@
"""Application configuration. Env-driven, Dev/Prod classes."""
import os
from dotenv import load_dotenv
load_dotenv()
def _bool(val, default=False):
if val is None:
return default
return str(val).strip().lower() in ("1", "true", "yes", "on")
class BaseConfig:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-change-me")
# --- Database ---
@staticmethod
def _database_uri():
url = os.environ.get("DATABASE_URL")
if url:
return url
user = os.environ.get("DB_USER", "classifieds")
pwd = os.environ.get("DB_PASSWORD", "")
host = os.environ.get("DB_HOST", "127.0.0.1")
port = os.environ.get("DB_PORT", "3306")
name = os.environ.get("DB_NAME", "classifieds")
return f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{name}?charset=utf8mb4"
SQLALCHEMY_DATABASE_URI = _database_uri.__func__()
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 280}
# --- Redis ---
REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/0")
RATELIMIT_STORAGE_URI = REDIS_URL
# --- i18n ---
DEFAULT_LOCALE = os.environ.get("DEFAULT_LOCALE", "en")
SUPPORTED_LOCALES = [
s.strip() for s in os.environ.get("SUPPORTED_LOCALES", "en,vi,es").split(",") if s.strip()
]
BABEL_DEFAULT_LOCALE = DEFAULT_LOCALE
BABEL_TRANSLATION_DIRECTORIES = "translations"
# --- Email ---
MAIL_SERVER = os.environ.get("MAIL_SERVER", "")
MAIL_PORT = int(os.environ.get("MAIL_PORT", "587"))
MAIL_USE_TLS = _bool(os.environ.get("MAIL_USE_TLS"), True)
MAIL_USERNAME = os.environ.get("MAIL_USERNAME", "")
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD", "")
MAIL_FROM = os.environ.get("MAIL_FROM", "no-reply@example.com")
MAIL_FROM_NAME = os.environ.get("MAIL_FROM_NAME", "Classifieds")
# --- Turnstile ---
TURNSTILE_SITE_KEY = os.environ.get("TURNSTILE_SITE_KEY", "")
TURNSTILE_SECRET_KEY = os.environ.get("TURNSTILE_SECRET_KEY", "")
# --- Token lifetimes ---
TOKEN_VERIFY_MAX_AGE = int(os.environ.get("TOKEN_VERIFY_MAX_AGE", "86400"))
TOKEN_RESET_MAX_AGE = int(os.environ.get("TOKEN_RESET_MAX_AGE", "3600"))
# --- Media (uploaded images) ---
MEDIA_ROOT = os.environ.get("MEDIA_ROOT") or None # default: instance/media
# --- Session cookie hardening ---
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
PREFERRED_URL_SCHEME = "https"
class DevConfig(BaseConfig):
DEBUG = True
SESSION_COOKIE_SECURE = False
class ProdConfig(BaseConfig):
DEBUG = False
SESSION_COOKIE_SECURE = True
SERVER_NAME = os.environ.get("SERVER_NAME") or None
def get_config():
name = os.environ.get("FLASK_CONFIG", "dev").lower()
return ProdConfig if name == "prod" else DevConfig
+18
View File
@@ -0,0 +1,18 @@
"""Extension singletons. Initialized in the app factory."""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_wtf import CSRFProtect
from flask_babel import Babel
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
csrf = CSRFProtect()
babel = Babel()
limiter = Limiter(key_func=get_remote_address)
login_manager.login_view = "auth.login"
login_manager.login_message_category = "warning"
+10
View File
@@ -0,0 +1,10 @@
"""Model exports. Import order matters for relationship resolution."""
from app.models.plan import Plan
from app.models.user import User
from app.models.trust import TrustEvent
from app.models.category import Category
from app.models.listing import Listing, ListingImage
from app.models.geo import ZipGeo, Metro
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro"]
+55
View File
@@ -0,0 +1,55 @@
"""Category model. Self-referential (parent_id) for subcategories.
`field_schema` is admin-editable JSON describing category-specific listing
fields. Shape:
{
"fields": [
{"name":"condition","label":"Condition","type":"select",
"required":true,"options":["new","like_new","good","fair"],"hot":true},
{"name":"brand","label":"Brand","type":"text","required":false,"max":80},
{"name":"salary_min","label":"Min salary","type":"number","required":false,"hot":true}
]
}
Supported types: text, number, select, bool. `hot:true` marks a field whose
value is denormalized onto an indexed Listing column for fast filtering.
"""
from datetime import datetime
from sqlalchemy import JSON
from app.extensions import db
class Category(db.Model):
__tablename__ = "categories"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
slug = db.Column(db.String(60), unique=True, nullable=False, index=True)
name = db.Column(db.String(80), nullable=False)
parent_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("categories.id"), nullable=True, index=True)
field_schema = db.Column(JSON, nullable=False, default=dict)
icon = db.Column(db.String(40), nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
sponsor_id = db.Column(db.BigInteger, nullable=True) # FK wired in Phase 5
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
children = db.relationship("Category", backref=db.backref("parent",
remote_side=[id]), lazy="selectin")
listings = db.relationship("Listing", back_populates="category", lazy="dynamic")
@property
def is_subcategory(self):
return self.parent_id is not None
def fields(self):
return (self.field_schema or {}).get("fields", [])
def hot_fields(self):
return [f for f in self.fields() if f.get("hot")]
def __repr__(self):
return f"<Category {self.slug}>"
+44
View File
@@ -0,0 +1,44 @@
"""Shared enumerations used across models."""
import enum
class Role(str, enum.Enum):
free = "free"
subscriber = "subscriber"
moderator = "moderator"
admin = "admin"
class UserStatus(str, enum.Enum):
active = "active"
suspended = "suspended"
banned = "banned"
class TrustTier(str, enum.Enum):
new = "new"
basic = "basic"
trusted = "trusted"
verified = "verified"
class TrustEventType(str, enum.Enum):
account_age = "account_age"
listing_survived = "listing_survived"
flag_received = "flag_received"
verified_email = "verified_email"
payment = "payment"
class ListingStatus(str, enum.Enum):
active = "active"
flagged = "flagged"
sold = "sold"
expired = "expired"
removed = "removed"
class Lang(str, enum.Enum):
en = "en"
vi = "vi"
es = "es"
+36
View File
@@ -0,0 +1,36 @@
"""Geocoding reference tables.
ZipGeo: offline US ZIP -> lat/lng/city/state/metro (source the full ~42k-row
dataset from SimpleMaps US ZIP or Census ZCTA; a small sample is seeded for tests).
Metro: SEO landing-page anchor (pages built in Phase 7; column wired now).
"""
from app.extensions import db
class ZipGeo(db.Model):
__tablename__ = "zip_geo"
zip = db.Column(db.String(12), primary_key=True)
city = db.Column(db.String(80), nullable=False)
state = db.Column(db.String(2), nullable=False, index=True)
lat = db.Column(db.Float, nullable=False)
lng = db.Column(db.Float, nullable=False)
metro = db.Column(db.String(80), nullable=True, index=True)
def __repr__(self):
return f"<ZipGeo {self.zip} {self.city},{self.state}>"
class Metro(db.Model):
__tablename__ = "metros"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
name = db.Column(db.String(120), nullable=False)
state = db.Column(db.String(2), nullable=True)
center_lat = db.Column(db.Float, nullable=True)
center_lng = db.Column(db.Float, nullable=True)
def __repr__(self):
return f"<Metro {self.slug}>"
+109
View File
@@ -0,0 +1,109 @@
"""Listing model.
Portability notes (see README "MySQL upgrades"):
- Location stored as lat/lng floats; radius search uses a bounding-box prefilter
+ haversine refine. On MySQL you may add a generated POINT + SPATIAL INDEX.
- Hot filter fields (condition/job_type/salary) are denormalized onto indexed
columns, populated in the service layer from `attributes`. On MySQL these can
instead be GENERATED ALWAYS columns over the JSON.
- Free-text search uses the accent-insensitive `title_norm` + body LIKE. On
MySQL add a FULLTEXT(title, body) index and switch to MATCH ... AGAINST.
"""
from datetime import datetime
from sqlalchemy import JSON, Index
from app.extensions import db
from app.models.enums import ListingStatus, Lang
class Listing(db.Model):
__tablename__ = "listings"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False, index=True)
category_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("categories.id"), nullable=False, index=True)
title = db.Column(db.String(140), nullable=False)
title_norm = db.Column(db.String(140), nullable=False, index=True) # accent-stripped
body = db.Column(db.Text, nullable=False)
lang = db.Column(db.Enum(Lang), nullable=False, default=Lang.en)
price_cents = db.Column(db.Integer, nullable=True, index=True)
# location
zip = db.Column(db.String(12), nullable=True, index=True)
city = db.Column(db.String(80), nullable=True)
state = db.Column(db.String(2), nullable=True, index=True)
lat = db.Column(db.Float, nullable=True)
lng = db.Column(db.Float, nullable=True)
# category-specific values
attributes = db.Column(JSON, nullable=False, default=dict)
# denormalized hot filter columns (populated from attributes on save)
attr_condition = db.Column(db.String(40), nullable=True, index=True)
attr_job_type = db.Column(db.String(40), nullable=True, index=True)
attr_salary_min = db.Column(db.Integer, nullable=True, index=True)
attr_salary_max = db.Column(db.Integer, nullable=True, index=True)
status = db.Column(db.Enum(ListingStatus), nullable=False,
default=ListingStatus.active, index=True)
is_featured = db.Column(db.Boolean, nullable=False, default=False)
bump_at = db.Column(db.DateTime, nullable=True)
flag_count = db.Column(db.Integer, nullable=False, default=0)
view_count = db.Column(db.Integer, nullable=False, default=0)
expires_at = db.Column(db.DateTime, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
user = db.relationship("User", backref=db.backref("listings", lazy="dynamic"))
category = db.relationship("Category", back_populates="listings")
images = db.relationship("ListingImage", back_populates="listing",
order_by="ListingImage.sort_order",
cascade="all, delete-orphan", lazy="selectin")
__table_args__ = (
Index("ix_listing_browse", "status", "expires_at"),
Index("ix_listing_sort", "is_featured", "bump_at"),
)
@property
def is_live(self):
return (self.status == ListingStatus.active
and self.expires_at > datetime.utcnow())
@property
def price_display(self):
if self.price_cents is None:
return None
return f"${self.price_cents / 100:,.2f}"
@property
def cover(self):
return self.images[0] if self.images else None
def __repr__(self):
return f"<Listing {self.id} {self.title[:24]!r}>"
class ListingImage(db.Model):
__tablename__ = "listing_images"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("listings.id"), nullable=False, index=True)
path = db.Column(db.String(255), nullable=False)
thumb_path = db.Column(db.String(255), nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
listing = db.relationship("Listing", back_populates="images")
def __repr__(self):
return f"<ListingImage {self.id} of L{self.listing_id}>"
+30
View File
@@ -0,0 +1,30 @@
"""Plan model. Tier limits live in `config` JSON, editable in admin without redeploy."""
from datetime import datetime
from sqlalchemy import JSON
from app.extensions import db
class Plan(db.Model):
__tablename__ = "plans"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
slug = db.Column(db.String(40), unique=True, nullable=False) # free|basic|pro|business
name = db.Column(db.String(80), nullable=False)
price_monthly_cents = db.Column(db.Integer, nullable=False, default=0)
stripe_price_id = db.Column(db.String(80), nullable=True)
config = db.Column(JSON, nullable=False, default=dict) # limits (Section 6)
is_active = db.Column(db.Boolean, nullable=False, default=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
users = db.relationship("User", back_populates="tier", lazy="dynamic")
def limit(self, key, default=None):
"""Read a single limit from config JSON."""
return (self.config or {}).get(key, default)
def __repr__(self):
return f"<Plan {self.slug}>"
+19
View File
@@ -0,0 +1,19 @@
"""TrustEvent model. Append-only events that adjust a user's trust score."""
from datetime import datetime
from app.extensions import db
from app.models.enums import TrustEventType
class TrustEvent(db.Model):
__tablename__ = "trust_events"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False, index=True)
type = db.Column(db.Enum(TrustEventType), nullable=False)
delta = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship("User", back_populates="trust_events")
def __repr__(self):
return f"<TrustEvent u{self.user_id} {self.type.value} {self.delta:+d}>"
+67
View File
@@ -0,0 +1,67 @@
"""User model. Argon2 password hashing, role/trust fields, Flask-Login mixin."""
from datetime import datetime
from flask_login import UserMixin
from app.extensions import db
from app.models.enums import Role, UserStatus, TrustTier
from app.utils.security import hash_password, verify_password
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
display_name = db.Column(db.String(80), nullable=False)
role = db.Column(db.Enum(Role), nullable=False, default=Role.free)
status = db.Column(db.Enum(UserStatus), nullable=False, default=UserStatus.active)
locale = db.Column(db.String(5), nullable=False, default="en")
tier_id = db.Column(db.BigInteger, db.ForeignKey("plans.id"), nullable=True)
tier = db.relationship("Plan", back_populates="users")
trust_score = db.Column(db.Integer, nullable=False, default=0)
trust_tier = db.Column(db.Enum(TrustTier), nullable=False, default=TrustTier.new)
verified = db.Column(db.Boolean, nullable=False, default=False) # identity/badge
email_verified = db.Column(db.Boolean, nullable=False, default=False)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
trust_events = db.relationship("TrustEvent", back_populates="user",
lazy="dynamic", cascade="all, delete-orphan")
# --- password ---
def set_password(self, raw):
self.password_hash = hash_password(raw)
def check_password(self, raw):
return verify_password(self.password_hash, raw)
# --- RBAC helpers ---
@property
def is_admin(self):
return self.role == Role.admin
@property
def is_moderator(self):
return self.role in (Role.moderator, Role.admin)
@property
def is_subscriber(self):
return self.role == Role.subscriber
def has_role(self, *roles):
return self.role in roles
@property
def is_active(self):
# Flask-Login uses this to permit login.
return self.status == UserStatus.active
def __repr__(self):
return f"<User {self.email}>"
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
+101
View File
@@ -0,0 +1,101 @@
:root{
--bg:#f6f7f9; --fg:#1c2430; --muted:#6b7785; --line:#e3e7ec;
--brand:#1f6feb; --brand-d:#1a5fd0; --ok:#1f9d55; --warn:#b7791f;
--danger:#d64545; --info:#2b6cb0; --card:#fff;
}
*{box-sizing:border-box}
body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
background:var(--bg);color:var(--fg)}
.wrap{max-width:960px;margin:0 auto;padding:0 16px}
a{color:var(--brand);text-decoration:none}
a:hover{text-decoration:underline}
.site-header{background:var(--card);border-bottom:1px solid var(--line)}
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;height:56px}
.brand{font-weight:700;font-size:18px;color:var(--fg)}
.nav{display:flex;align-items:center;gap:14px}
.nav .hi{color:var(--muted)}
.langs{display:flex;gap:6px;margin-left:8px}
.langs a{font-size:12px;color:var(--muted);border:1px solid var(--line);
padding:2px 6px;border-radius:4px}
.langs a.on{background:var(--brand);color:#fff;border-color:var(--brand)}
.btn{display:inline-block;background:var(--brand);color:#fff;border:0;
padding:9px 16px;border-radius:8px;cursor:pointer;font-size:15px}
.btn:hover{background:var(--brand-d);text-decoration:none}
.btn-lg{padding:12px 22px;font-size:17px}
main.wrap{padding-top:24px;padding-bottom:48px}
.hero{text-align:center;padding:48px 0}
.hero h1{font-size:32px;margin:0 0 8px}
.hero p{color:var(--muted);margin:0 0 24px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:24px}
.card.narrow{max-width:420px;margin:0 auto}
.card h2{margin-top:0}
.field{margin-bottom:14px;display:flex;flex-direction:column;gap:4px}
.field label{font-size:14px;color:var(--muted)}
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px}
.input:focus{outline:2px solid var(--brand);border-color:var(--brand)}
.check{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);
margin-bottom:14px}
.errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px}
.muted{color:var(--muted);font-size:14px;margin-top:14px}
.flashes{margin-bottom:18px;display:flex;flex-direction:column;gap:8px}
.flash{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}
.flash-success{background:#e8f6ee;border-color:#bfe3cd;color:var(--ok)}
.flash-danger{background:#fdeaea;border-color:#f4c4c4;color:var(--danger)}
.flash-warning{background:#fdf4e3;border-color:#f0dcae;color:var(--warn)}
.flash-info{background:#e9f1fb;border-color:#cadcf3;color:var(--info)}
.site-footer{border-top:1px solid var(--line);color:var(--muted);
font-size:13px;padding:18px 0}
.cf-turnstile{margin:0 0 14px}
/* --- Phase 2: listings --- */
.btn.ghost{background:#fff;color:var(--brand);border:1px solid var(--brand)}
.btn.danger{background:var(--danger)}
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-wide{max-width:640px;margin:0 auto}
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start}
.filters h3{margin-top:0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}
.tile{padding:0;overflow:hidden;display:block;color:var(--fg)}
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)}
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4}
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
.tile-body{padding:10px}
.tile-title{font-weight:600;font-size:14px;line-height:1.3;margin-bottom:4px}
.tile-meta{display:flex;gap:8px;align-items:center}
.price{color:var(--ok);font-weight:700}
.price.big{font-size:24px}
.small{font-size:12px}
.badge{display:inline-block;font-size:11px;padding:2px 7px;border-radius:10px;background:#eef1f4;color:var(--muted)}
.badge.ok{background:#e8f6ee;color:var(--ok)}
.badge.warn{background:#fdf4e3;color:var(--warn)}
.badge.cat{background:#e9f1fb;color:var(--info)}
.pager{margin-top:18px;display:flex;gap:16px}
.detail{display:grid;grid-template-columns:1fr 280px;gap:20px;align-items:start}
.detail-meta{display:flex;gap:10px;align-items:center;margin:8px 0}
.gallery{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}
.gallery img{max-width:220px;border-radius:8px}
.body{margin:14px 0;line-height:1.6}
.attrs{border-collapse:collapse;width:100%}
.attrs th,.attrs td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)}
.attrs th{color:var(--muted);font-weight:600;width:40%}
.detail-side .btn{display:block;margin-bottom:8px;text-align:center}
.detail-side form{margin:0}
.seller{margin-bottom:14px}
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.mine-head .btn{margin-left:auto}
table.list{width:100%;border-collapse:collapse}
table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.thumbs{display:flex;gap:10px;flex-wrap:wrap}
.thumb-wrap{position:relative}
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px}
.thumb-wrap form{position:absolute;top:2px;right:2px;margin:0}
@media(max-width:760px){.browse,.detail{grid-template-columns:1fr}}
+18
View File
@@ -0,0 +1,18 @@
{% macro field(f) %}
<div class="field">
{{ f.label }}
{{ f(class="input") }}
{% if f.errors %}
<ul class="errors">
{% for e in f.errors %}<li>{{ e }}</li>{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
{% macro turnstile_widget(enabled) %}
{% if enabled and TURNSTILE_SITE_KEY %}
<div class="cf-turnstile" data-sitekey="{{ TURNSTILE_SITE_KEY }}"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
{% endmacro %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Sign in') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Sign in') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.email) }}
{{ field(form.password) }}
<label class="check">{{ form.remember() }} {{ form.remember.label.text }}</label>
{{ form.submit(class="btn") }}
</form>
<p class="muted">
<a href="{{ url_for('auth.reset_request') }}">{{ _('Forgot password?') }}</a> ·
<a href="{{ url_for('auth.register') }}">{{ _('Create account') }}</a>
</p>
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field, turnstile_widget %}
{% block title %}{{ _('Register') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Create account') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.display_name) }}
{{ field(form.email) }}
{{ field(form.password) }}
{{ field(form.confirm) }}
{{ turnstile_widget(turnstile) }}
{{ form.submit(class="btn") }}
</form>
<p class="muted"><a href="{{ url_for('auth.login') }}">{{ _('Already have an account? Sign in') }}</a></p>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Set new password') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Set new password') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.password) }}
{{ field(form.confirm) }}
{{ form.submit(class="btn") }}
</form>
</div>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Reset password') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Reset password') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.email) }}
{{ form.submit(class="btn") }}
</form>
</div>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="{{ get_locale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Classifieds{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="brand" href="{{ url_for('main.index') }}">Classifieds</a>
<nav class="nav">
<a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('listings.create') }}">{{ _('Post') }}</a>
<a href="{{ url_for('listings.mine') }}">{{ _('My listings') }}</a>
<span class="hi">{{ current_user.display_name }}</span>
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a>
{% else %}
<a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a>
<a class="btn" href="{{ url_for('auth.register') }}">{{ _('Register') }}</a>
{% endif %}
<span class="langs">
{% for code in SUPPORTED_LOCALES %}
<a href="{{ url_for('i18n.set_lang', code=code) }}"
class="{{ 'on' if get_locale()|string == code else '' }}">{{ code|upper }}</a>
{% endfor %}
</span>
</nav>
</div>
</header>
<main class="wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, msg in messages %}
<div class="flash flash-{{ category }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="wrap">© Classifieds — Phase 1 scaffold</div>
</footer>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}403{% endblock %}
{% block content %}<div class="card narrow"><h2>403</h2><p>{{ _('Forbidden — you do not have access.') }}</p><p><a href="{{ url_for('main.index') }}">{{ _('Back home') }}</a></p></div>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}404{% endblock %}
{% block content %}<div class="card narrow"><h2>404</h2><p>{{ _('Not found.') }}</p><p><a href="{{ url_for('main.index') }}">{{ _('Back home') }}</a></p></div>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}500{% endblock %}
{% block content %}<div class="card narrow"><h2>500</h2><p>{{ _('Something went wrong.') }}</p><p><a href="{{ url_for('main.index') }}">{{ _('Back home') }}</a></p></div>{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ _('Classifieds — Home') }}{% endblock %}
{% block content %}
<section class="hero">
<h1>{{ _('Find what you need. Post what you offer.') }}</h1>
<p>{{ _('Buy, sell, request, hire, and connect across your community.') }}</p>
<a class="btn btn-lg" href="{{ url_for('listings.browse') }}">{{ _('Browse listings') }}</a>
{% if not current_user.is_authenticated %}
<a class="btn btn-lg ghost" href="{{ url_for('auth.register') }}">{{ _('Get started') }}</a>
{% endif %}
</section>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}{{ _('Browse listings') }}{% endblock %}
{% block content %}
<div class="browse">
<aside class="filters card">
<h3>{{ _('Filter') }}</h3>
<form method="get" action="{{ url_for('listings.browse') }}">
<div class="field">
<label>{{ _('Keyword') }}</label>
<input class="input" name="q" value="{{ filters.get('q','') }}">
</div>
<div class="field">
<label>{{ _('Category') }}</label>
<select class="input" name="category">
<option value="">{{ _('All') }}</option>
{% for c in categories %}
<option value="{{ c.id }}" {{ 'selected' if filters.get('category')|string == c.id|string }}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>{{ _('State') }}</label>
<input class="input" name="state" maxlength="2" value="{{ filters.get('state','') }}"></div>
<div class="row2">
<div class="field"><label>{{ _('Min $') }}</label>
<input class="input" name="min_price" type="number" value="{{ filters.get('min_price','') }}"></div>
<div class="field"><label>{{ _('Max $') }}</label>
<input class="input" name="max_price" type="number" value="{{ filters.get('max_price','') }}"></div>
</div>
<div class="row2">
<div class="field"><label>{{ _('Near ZIP') }}</label>
<input class="input" name="zip" value="{{ filters.get('zip','') }}"></div>
<div class="field"><label>{{ _('Radius (mi)') }}</label>
<input class="input" name="radius" type="number" value="{{ filters.get('radius','') }}"></div>
</div>
<button class="btn" type="submit">{{ _('Apply') }}</button>
</form>
</aside>
<section class="results">
{% if near %}<p class="muted">{{ _('%(n)s results within %(r)s mi of %(z)s', n=near.total, r=near.radius, z=near.zip) }}</p>{% endif %}
{% if not results %}<p class="muted">{{ _('No listings found.') }}</p>{% endif %}
<div class="grid">
{% for l, dist in results %}
<a class="tile card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
{% if l.cover %}<img class="thumb" src="{{ url_for('listings.media', rel=l.cover.thumb_path) }}" alt="">
{% else %}<div class="thumb noimg">{{ _('No photo') }}</div>{% endif %}
<div class="tile-body">
<div class="tile-title">{{ l.title }}</div>
<div class="tile-meta">
{% if l.price_display %}<span class="price">{{ l.price_display }}</span>{% endif %}
{% if l.is_featured %}<span class="badge">{{ _('Featured') }}</span>{% endif %}
</div>
<div class="muted small">
{% if l.city %}{{ l.city }}, {{ l.state }}{% endif %}
{% if dist is not none %} · {{ dist }} mi{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
<div class="pager">
{% if page > 1 %}<a href="{{ url_for('listings.browse', **merge_query(page=page-1)) }}">&larr; {{ _('Prev') }}</a>{% endif %}
{% if has_next %}<a href="{{ url_for('listings.browse', **merge_query(page=page+1)) }}">{{ _('Next') }} &rarr;</a>{% endif %}
</div>
</section>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}{{ listing.title }}{% endblock %}
{% block content %}
<article class="detail">
<div class="detail-main card">
<h1>{{ listing.title }}</h1>
<div class="detail-meta">
{% if listing.price_display %}<span class="price big">{{ listing.price_display }}</span>{% endif %}
<span class="badge cat">{{ listing.category.name }}</span>
{% if not listing.is_live %}<span class="badge warn">{{ listing.status.value }}</span>{% endif %}
</div>
<p class="muted small">
{% if listing.city %}{{ listing.city }}, {{ listing.state }} {{ listing.zip }}{% endif %}
· {{ _('%(n)s views', n=listing.view_count) }}
</p>
{% if listing.images %}
<div class="gallery">
{% for img in listing.images %}
<img src="{{ url_for('listings.media', rel=img.path) }}" alt="">
{% endfor %}
</div>
{% endif %}
<div class="body">{{ listing.body | replace('\n','<br>') | safe }}</div>
{% if listing.attributes %}
<table class="attrs">
{% for f in listing.category.fields() %}
{% if listing.attributes.get(f.name) is not none %}
<tr><th>{{ f.label }}</th><td>{{ listing.attributes.get(f.name) }}</td></tr>
{% endif %}
{% endfor %}
</table>
{% endif %}
</div>
<aside class="detail-side card">
<div class="seller">
<strong>{{ listing.user.display_name }}</strong>
{% if listing.user.verified %}<span class="badge ok">{{ _('Verified') }}</span>{% endif %}
</div>
{% if is_owner %}
<a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a>
{% if listing.status.value == 'active' %}
<form method="post" action="{{ url_for('listings.mark_sold', listing_id=listing.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn ghost" type="submit">{{ _('Mark sold') }}</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('listings.delete', listing_id=listing.id) }}"
onsubmit="return confirm('{{ _('Delete this listing?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn danger" type="submit">{{ _('Delete') }}</button>
</form>
{% else %}
<p class="muted small">{{ _('Messaging arrives in Phase 3.') }}</p>
{% endif %}
</aside>
</article>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Edit listing') if listing else _('New listing') }}{% endblock %}
{% block content %}
<div class="card form-wide">
<h2>{{ _('Edit listing') if listing else _('New listing') }}</h2>
<form method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{{ field(form.category_id) }}
{{ field(form.title) }}
{{ field(form.body) }}
<div class="row2">{{ field(form.lang) }}{{ field(form.price) }}</div>
{{ field(form.zip) }}
{# category-specific fields: one group per category, toggled by JS #}
<div id="attr-groups">
{% for c in categories %}
<div class="attr-group" data-cat="{{ c.id }}" style="display:none">
{% for f in c.fields() %}
<div class="field">
<label>{{ f.label }}{% if f.required %} *{% endif %}</label>
{% set val = (listing.attributes.get(f.name) if listing and listing.attributes else '') %}
{% if f.type == 'select' %}
<select class="input" name="attr_{{ f.name }}">
<option value=""></option>
{% for opt in f.options %}<option value="{{ opt }}" {{ 'selected' if val==opt }}>{{ opt }}</option>{% endfor %}
</select>
{% elif f.type == 'number' %}
<input class="input" type="number" name="attr_{{ f.name }}" value="{{ val }}">
{% elif f.type == 'bool' %}
<input type="checkbox" name="attr_{{ f.name }}" value="true" {{ 'checked' if val }}>
{% else %}
<input class="input" name="attr_{{ f.name }}" value="{{ val }}">
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{{ field(form.images) }}
{{ form.submit(class="btn") }}
</form>
{% if listing and listing.images %}
<div class="existing-images">
<h4>{{ _('Current photos') }}</h4>
<div class="thumbs">
{% for img in listing.images %}
<div class="thumb-wrap">
<img src="{{ url_for('listings.media', rel=img.thumb_path) }}" alt="">
<form method="post" action="{{ url_for('listings.delete_image', listing_id=listing.id, image_id=img.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">×</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<script>
(function(){
var sel = document.querySelector('[name=category_id]');
var groups = document.querySelectorAll('.attr-group');
function sync(){
groups.forEach(function(g){
g.style.display = (g.dataset.cat === sel.value) ? 'block' : 'none';
});
}
if (sel){ sel.addEventListener('change', sync); sync(); }
})();
</script>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ _('My listings') }}{% endblock %}
{% block content %}
<div class="card">
<div class="mine-head">
<h2>{{ _('My listings') }}</h2>
<span class="muted">{{ _('Active: %(a)s', a=active) }}{% if cap is not none %} / {{ cap }}{% else %} / ∞{% endif %}</span>
<a class="btn" href="{{ url_for('listings.create') }}">{{ _('Post new') }}</a>
</div>
{% if not items %}<p class="muted">{{ _('No listings yet.') }}</p>{% endif %}
<table class="list">
{% for l in items %}
<tr>
<td><a href="{{ url_for('listings.detail', listing_id=l.id) }}">{{ l.title }}</a></td>
<td>{{ l.category.name }}</td>
<td>{{ l.price_display or '—' }}</td>
<td><span class="badge {{ 'ok' if l.is_live else 'warn' }}">{{ l.status.value }}</span></td>
<td>{{ l.view_count }} {{ _('views') }}</td>
<td><a href="{{ url_for('listings.edit', listing_id=l.id) }}">{{ _('Edit') }}</a></td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
"""RBAC decorators. Never trust the client; gate on server side."""
from functools import wraps
from flask import abort
from flask_login import current_user
from app.models.enums import Role
def role_required(*roles):
"""Require the current user to hold one of the given roles."""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if current_user.role not in roles:
abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def admin_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if not current_user.is_admin:
abort(403)
return fn(*args, **kwargs)
return wrapper
def moderator_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if not current_user.is_moderator:
abort(403)
return fn(*args, **kwargs)
return wrapper
+42
View File
@@ -0,0 +1,42 @@
"""Security primitives: Argon2 password hashing + signed tokens for email/reset."""
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from flask import current_app
_ph = PasswordHasher()
def hash_password(raw: str) -> str:
return _ph.hash(raw)
def verify_password(stored_hash: str, raw: str) -> bool:
try:
return _ph.verify(stored_hash, raw)
except (VerifyMismatchError, InvalidHashError, Exception):
return False
def needs_rehash(stored_hash: str) -> bool:
try:
return _ph.check_needs_rehash(stored_hash)
except Exception:
return False
# --- Signed tokens (email verify, password reset) ---
def _serializer(salt: str) -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt=salt)
def generate_token(data, salt: str) -> str:
return _serializer(salt).dumps(data)
def read_token(token: str, salt: str, max_age: int):
"""Return payload or None if invalid/expired."""
try:
return _serializer(salt).loads(token, max_age=max_age)
except (BadSignature, SignatureExpired):
return None
+26
View File
@@ -0,0 +1,26 @@
"""Text normalization for accent-insensitive search.
Strips diacritics so "phở" -> "pho" and "ñandú" -> "nandu".
Used now for display/utility; drives `title_norm` shadow column in Phase 2 listings.
"""
import unicodedata
# Vietnamese đ/Đ do not decompose via NFKD, map explicitly.
_EXPLICIT = {
"đ": "d", "Đ": "d",
"ð": "d", "Ð": "d",
}
def normalize(text: str) -> str:
if not text:
return ""
out = []
for ch in text:
if ch in _EXPLICIT:
out.append(_EXPLICIT[ch])
continue
decomposed = unicodedata.normalize("NFKD", ch)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
out.append(stripped)
return "".join(out).lower().strip()
+2
View File
@@ -0,0 +1,2 @@
[python: app/**.py]
[jinja2: app/templates/**.html]
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Classifieds: expire past-due listings (oneshot)
After=mysql.service
[Service]
Type=oneshot
User=classifieds
Group=classifieds
WorkingDirectory=/opt/classifieds
Environment="PATH=/opt/classifieds/venv/bin"
EnvironmentFile=/opt/classifieds/.env
ExecStart=/opt/classifieds/venv/bin/flask expire-listings
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Run classifieds listing-expiry sweep hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=Classifieds Flask app (Gunicorn)
After=network.target mysql.service redis-server.service
Wants=redis-server.service
[Service]
Type=notify
User=classifieds
Group=classifieds
WorkingDirectory=/opt/classifieds
Environment="PATH=/opt/classifieds/venv/bin"
EnvironmentFile=/opt/classifieds/.env
RuntimeDirectory=classifieds
ExecStart=/opt/classifieds/venv/bin/gunicorn -c deploy/gunicorn.conf.py wsgi:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+12
View File
@@ -0,0 +1,12 @@
"""Gunicorn config. Unix socket behind Nginx."""
import multiprocessing
bind = "unix:/run/classifieds/classifieds.sock"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
timeout = 60
graceful_timeout = 30
keepalive = 5
accesslog = "-"
errorlog = "-"
loglevel = "info"
+36
View File
@@ -0,0 +1,36 @@
server {
listen 80;
server_name classifieds.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name classifieds.example.com;
# ssl_certificate /etc/letsencrypt/live/classifieds.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/classifieds.example.com/privkey.pem;
client_max_body_size 12m; # raise in Phase 2 for image uploads
location /static/ {
alias /opt/classifieds/app/static/;
expires 30d;
access_log off;
}
# Phase 2: user-uploaded media served direct by Nginx
location /media/ {
alias /opt/classifieds/instance/media/;
expires 7d;
access_log off;
}
location / {
proxy_pass http://unix:/run/classifieds/classifieds.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+18
View File
@@ -0,0 +1,18 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-Babel==4.0.0
Flask-Limiter==3.8.0
SQLAlchemy==2.0.34
alembic==1.13.2
argon2-cffi==23.1.0
email-validator==2.2.0
itsdangerous==2.2.0
python-dotenv==1.0.1
redis==5.0.8
requests==2.32.3
PyMySQL==1.1.1
gunicorn==23.0.0
Pillow==10.4.0
+180
View File
@@ -0,0 +1,180 @@
"""Seed reference data: the four plans, and an optional admin account.
Usage:
python seed.py # seed plans only
python seed.py --admin EMAIL PASS # also create/upgrade an admin user
"""
import sys
from app import create_app
from app.extensions import db
from app.models.plan import Plan
from app.models.user import User
from app.models.category import Category
from app.models.geo import ZipGeo
from app.models.enums import Role
PLANS = [
{
"slug": "free", "name": "Free", "price_monthly_cents": 0, "sort_order": 0,
"config": {
"active_listings": 3, "listing_life_days": 14, "images_per_listing": 3,
"featured_per_month": 0, "auto_bump": None, "analytics": None,
"storefront": False, "verified_badge": False, "ad_free": False,
"scheduled_posting": False, "bulk_csv": False, "priority_support": False,
},
},
{
"slug": "basic", "name": "Basic", "price_monthly_cents": 999, "sort_order": 1,
"config": {
"active_listings": 15, "listing_life_days": 30, "images_per_listing": 8,
"featured_per_month": 1, "auto_bump": None, "analytics": "basic",
"storefront": False, "verified_badge": False, "ad_free": True,
"scheduled_posting": False, "bulk_csv": False, "priority_support": False,
},
},
{
"slug": "pro", "name": "Pro", "price_monthly_cents": 2499, "sort_order": 2,
"config": {
"active_listings": 50, "listing_life_days": 60, "images_per_listing": 15,
"featured_per_month": 5, "auto_bump": "weekly", "analytics": "full",
"storefront": True, "verified_badge": True, "ad_free": True,
"scheduled_posting": True, "bulk_csv": False, "priority_support": False,
},
},
{
"slug": "business", "name": "Business", "price_monthly_cents": 5999, "sort_order": 3,
"config": {
"active_listings": None, "listing_life_days": 90, "images_per_listing": 25,
"featured_per_month": 20, "auto_bump": "daily", "analytics": "full_export",
"storefront": True, "verified_badge": True, "ad_free": True,
"scheduled_posting": True, "bulk_csv": True, "priority_support": True,
},
},
]
def seed_plans():
for p in PLANS:
existing = Plan.query.filter_by(slug=p["slug"]).first()
if existing:
existing.name = p["name"]
existing.price_monthly_cents = p["price_monthly_cents"]
existing.config = p["config"]
existing.sort_order = p["sort_order"]
else:
db.session.add(Plan(**p))
db.session.commit()
print(f"Seeded {len(PLANS)} plans.")
CATEGORIES = [
{"slug": "for-sale", "name": "For Sale", "icon": "tag", "sort_order": 0,
"field_schema": {"fields": [
{"name": "condition", "label": "Condition", "type": "select", "required": True,
"options": ["new", "like_new", "good", "fair"], "hot": True},
{"name": "brand", "label": "Brand", "type": "text", "required": False, "max": 80},
]},
"children": ["Furniture", "Electronics", "Cars & Trucks", "Appliances"]},
{"slug": "wanted", "name": "Wanted", "icon": "search", "sort_order": 1,
"field_schema": {"fields": [
{"name": "budget", "label": "Budget (USD)", "type": "number", "required": False, "min": 0},
]},
"children": ["Items Wanted", "Housing Wanted"]},
{"slug": "jobs", "name": "Jobs", "icon": "briefcase", "sort_order": 2,
"field_schema": {"fields": [
{"name": "job_type", "label": "Job type", "type": "select", "required": True,
"options": ["full_time", "part_time", "contract", "temp"], "hot": True},
{"name": "salary_min", "label": "Salary min (USD/yr)", "type": "number",
"required": False, "min": 0, "hot": True},
{"name": "salary_max", "label": "Salary max (USD/yr)", "type": "number",
"required": False, "min": 0, "hot": True},
{"name": "remote", "label": "Remote", "type": "bool", "required": False},
]},
"children": ["Restaurant", "Salon & Spa", "Construction", "Office"]},
{"slug": "services", "name": "Services", "icon": "tools", "sort_order": 3,
"field_schema": {"fields": [
{"name": "rate", "label": "Rate (USD/hr)", "type": "number", "required": False, "min": 0},
]},
"children": ["Home Services", "Beauty", "Tutoring", "Auto Repair"]},
{"slug": "supplies", "name": "Supplies", "icon": "box", "sort_order": 4,
"field_schema": {"fields": [
{"name": "min_order", "label": "Min order qty", "type": "number", "required": False, "min": 1},
{"name": "condition", "label": "Condition", "type": "select", "required": False,
"options": ["new", "used"], "hot": True},
]},
"children": ["Wholesale", "Restaurant Supply", "Salon Supply"]},
{"slug": "community", "name": "Community", "icon": "users", "sort_order": 5,
"field_schema": {"fields": [
{"name": "event_date", "label": "Event date", "type": "text", "required": False, "max": 40},
]},
"children": ["Events", "Announcements", "Free Stuff", "Classes"]},
]
# Sample ZIPs for Vietnamese/Hispanic hubs (replace with full SimpleMaps/Census dataset).
ZIP_SAMPLE = [
("92683", "Westminster", "CA", 33.7513, -117.9939, "Orange County"),
("92840", "Garden Grove", "CA", 33.7886, -117.9390, "Orange County"),
("95111", "San Jose", "CA", 37.2956, -121.8200, "San Jose"),
("95122", "San Jose", "CA", 37.3300, -121.8350, "San Jose"),
("77036", "Houston", "TX", 29.7045, -95.5380, "Houston"),
("77072", "Houston", "TX", 29.7060, -95.5930, "Houston"),
("78228", "San Antonio", "TX", 29.4630, -98.5790, "San Antonio"),
("33125", "Miami", "FL", 25.7840, -80.2370, "Miami"),
("90250", "Hawthorne", "CA", 33.9130, -118.3490, "Los Angeles"),
("22041", "Falls Church", "VA", 38.8480, -77.1370, "DC Metro"),
]
def seed_categories():
for c in CATEGORIES:
parent = Category.query.filter_by(slug=c["slug"]).first()
if parent is None:
parent = Category(slug=c["slug"], name=c["name"], icon=c.get("icon"),
sort_order=c["sort_order"], field_schema=c["field_schema"])
db.session.add(parent)
db.session.flush()
else:
parent.field_schema = c["field_schema"]
parent.sort_order = c["sort_order"]
for i, child_name in enumerate(c.get("children", [])):
cslug = f"{c['slug']}-{child_name.lower().replace(' & ', '-').replace(' ', '-')}"
if Category.query.filter_by(slug=cslug).first() is None:
db.session.add(Category(slug=cslug, name=child_name,
parent_id=parent.id, sort_order=i,
field_schema=c["field_schema"]))
db.session.commit()
print(f"Seeded {len(CATEGORIES)} top categories + subcategories.")
def seed_zip_sample():
for z, city, st, lat, lng, metro in ZIP_SAMPLE:
if ZipGeo.query.get(z) is None:
db.session.add(ZipGeo(zip=z, city=city, state=st, lat=lat, lng=lng, metro=metro))
db.session.commit()
print(f"Seeded {len(ZIP_SAMPLE)} sample ZIP rows.")
def make_admin(email, password):
user = User.query.filter_by(email=email.lower()).first()
if user is None:
user = User(email=email.lower(), display_name="Admin", role=Role.admin,
email_verified=True)
user.set_password(password)
db.session.add(user)
else:
user.role = Role.admin
user.email_verified = True
user.set_password(password)
db.session.commit()
print(f"Admin ready: {email}")
if __name__ == "__main__":
app = create_app()
with app.app_context():
seed_plans()
seed_categories()
seed_zip_sample()
if "--admin" in sys.argv:
i = sys.argv.index("--admin")
make_admin(sys.argv[i + 1], sys.argv[i + 2])
View File
+253
View File
@@ -0,0 +1,253 @@
"""Phase 1 smoke test. Runs against SQLite in-memory; no MySQL/Redis needed.
python -m tests.test_smoke (or) pytest -q
Production runs over HTTPS (PREFERRED_URL_SCHEME=https), so Flask-WTF enforces
the secure-referrer CSRF check. The client helper below sends an https base_url
and a matching Referer, exactly as a real browser would.
"""
import os
import re
import io
import logging
import tempfile
os.environ.setdefault("SECRET_KEY", "test-secret")
# File-based SQLite: in-memory sqlite:// gives each connection its own empty DB
# under Flask-SQLAlchemy, so use a temp file that all connections share.
_SMOKE_DB = os.path.join(tempfile.gettempdir(), "classifieds_smoke.db")
if os.path.exists(_SMOKE_DB):
os.remove(_SMOKE_DB)
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_SMOKE_DB}")
os.environ.setdefault("REDIS_URL", "memory://") # limiter in-memory
os.environ.setdefault("FLASK_CONFIG", "dev")
from app import create_app # noqa: E402
from app.extensions import db # noqa: E402
from app.models.plan import Plan # noqa: E402
from app.models.user import User # noqa: E402
from app.utils.text import normalize # noqa: E402
from seed import seed_plans, seed_categories, seed_zip_sample # noqa: E402
BASE = "https://localhost"
def _csrf(html):
return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1)
def run():
app = create_app()
with app.app_context():
db.create_all()
seed_plans()
seed_categories()
seed_zip_sample()
plans = [(p.slug, p.limit("active_listings"), p.limit("ad_free"))
for p in Plan.query.order_by(Plan.sort_order)]
assert plans == [("free", 3, False), ("basic", 15, True),
("pro", 50, True), ("business", None, True)], plans
print("plans seeded:", plans)
assert normalize("Phở Bò Đặc Biệt") == "pho bo dac biet"
assert normalize("Ñandú Jalapeño") == "nandu jalapeno"
print("accent normalizer ok")
c = app.test_client()
buf = io.StringIO()
h = logging.StreamHandler(buf); h.setLevel(logging.INFO)
app.logger.addHandler(h)
def post(path, data, ref=None):
return c.post(path, base_url=BASE, data=data, follow_redirects=True,
headers={"Referer": ref or f"{BASE}{path}"})
def get(path):
return c.get(path, base_url=BASE, follow_redirects=True)
# register
tok = _csrf(get("/auth/register").get_data(as_text=True))
r = post("/auth/register", {"csrf_token": tok, "display_name": "Test User",
"email": "t@example.com", "password": "StrongPass123",
"confirm": "StrongPass123"})
assert r.status_code == 200 and "verify" in r.get_data(as_text=True).lower()
print("register + verify email: ok")
# verify email link
link = re.search(r"/auth/verify/(\S+)", buf.getvalue()).group(1)
r = get("/auth/verify/" + link)
assert "verified" in r.get_data(as_text=True).lower()
with app.app_context():
u = User.query.filter_by(email="t@example.com").first()
assert u.email_verified and u.trust_score == 5 and u.tier.slug == "free"
print(f"verify: ok (trust={u.trust_score}, tier={u.tier.slug})")
# login
tok = _csrf(get("/auth/login").get_data(as_text=True))
r = post("/auth/login", {"csrf_token": tok, "email": "t@example.com",
"password": "StrongPass123"})
assert "Test User" in r.get_data(as_text=True)
print("login: ok")
# language switch
assert get("/lang/vi").status_code in (302, 200)
print("lang switch: ok")
# bad password rejected
get("/auth/logout")
tok = _csrf(get("/auth/login").get_data(as_text=True))
r = post("/auth/login", {"csrf_token": tok, "email": "t@example.com",
"password": "wrong"})
assert "invalid" in r.get_data(as_text=True).lower()
print("bad login rejected: ok")
# duplicate email rejected
tok = _csrf(get("/auth/register").get_data(as_text=True))
r = post("/auth/register", {"csrf_token": tok, "display_name": "Dupe",
"email": "t@example.com", "password": "StrongPass123",
"confirm": "StrongPass123"})
assert "already exists" in r.get_data(as_text=True).lower()
print("duplicate email rejected: ok")
# password reset round-trip
tok = _csrf(get("/auth/reset").get_data(as_text=True))
buf2 = io.StringIO(); h2 = logging.StreamHandler(buf2); h2.setLevel(logging.INFO)
app.logger.addHandler(h2)
post("/auth/reset", {"csrf_token": tok, "email": "t@example.com"})
rlink = re.search(r"/auth/reset/(\S+)", buf2.getvalue()).group(1)
tok = _csrf(get("/auth/reset/" + rlink).get_data(as_text=True))
r = post("/auth/reset/" + rlink, {"csrf_token": tok,
"password": "NewPass456", "confirm": "NewPass456"})
assert "updated" in r.get_data(as_text=True).lower()
tok = _csrf(get("/auth/login").get_data(as_text=True))
r = post("/auth/login", {"csrf_token": tok, "email": "t@example.com",
"password": "NewPass456"})
assert "Test User" in r.get_data(as_text=True)
print("password reset round-trip: ok")
# misc
assert get("/").status_code == 200
assert get("/healthz").get_json() == {"status": "ok"}
assert get("/nope").status_code == 404
print("home / healthz / 404: ok")
_phase2(app)
print("\nALL SMOKE CHECKS PASSED")
def _phase2(app):
"""Phase 2: listings core — geocode, tier limits, accent search, radius,
image pipeline, expiry sweep."""
import io
from datetime import datetime, timedelta
from werkzeug.datastructures import FileStorage
from PIL import Image
from app.models.user import User
from app.models.category import Category
from app.models.listing import Listing
from app.models.enums import ListingStatus
from app.services import listings as lsvc
from app.services.geo import geocode_zip, haversine_mi
from app.services.images import process_upload
with app.app_context():
user = User.query.filter_by(email="t@example.com").first()
for_sale = Category.query.filter_by(slug="for-sale").first()
assert for_sale and for_sale.hot_fields(), "category schema missing"
# geocode sanity
g = geocode_zip("92683")
assert g and g[2] == "Westminster" and g[3] == "CA"
print(f"geocode 92683: ok ({g[2]}, {g[3]})")
# create three (free cap = 3)
l1 = lsvc.create_listing(user, for_sale, title="Phở Bò Đặc Biệt",
body="Authentic beef noodle soup gear for sale.",
lang="vi", price_cents=2500, zip_code="92683",
raw_attributes={"condition": "good", "brand": "Acme"})
l2 = lsvc.create_listing(user, for_sale, title="Used Sofa",
body="Comfortable three-seat sofa, gently used.",
lang="en", price_cents=15000, zip_code="77036",
raw_attributes={"condition": "good"})
l3 = lsvc.create_listing(user, for_sale, title="iPhone 14",
body="Unlocked phone in great shape, no scratches.",
lang="en", price_cents=40000, zip_code="92840",
raw_attributes={"condition": "like_new"})
assert l1.lat and l1.attr_condition == "good" and l1.title_norm == "pho bo dac biet"
print("create + geocode + hot-column + title_norm: ok")
# tier limit: 4th create blocked
try:
lsvc.create_listing(user, for_sale, title="Fourth", body="blocked one two",
lang="en", price_cents=100, zip_code="92683",
raw_attributes={"condition": "fair"})
assert False, "expected tier limit error"
except lsvc.ListingError as e:
assert "limit" in str(e).lower()
print("tier active-listing limit enforced: ok")
# attribute validation (unit-level, independent of tier cap)
from app.services.field_schema import validate_attributes
_cleaned, _errs = validate_attributes(for_sale, {"condition": "banana"})
assert _errs.get("condition") == "invalid option"
_cleaned, _errs = validate_attributes(for_sale, {"brand": "OK"})
assert _errs.get("condition") == "required" # required select missing
_cleaned, _errs = validate_attributes(for_sale, {"condition": "new"})
assert not _errs and _cleaned["condition"] == "new"
print("attribute schema validation: ok")
# accent-insensitive search: 'pho' finds the diacritic title
found = lsvc.browse_query(q="pho").all()
assert l1 in found and l2 not in found
print("accent-insensitive search: ok")
# price filter
cheap = lsvc.browse_query(max_price=5000).all()
assert l1 in cheap and l3 not in cheap
print("price filter: ok")
# radius: near 92683 within 30 mi -> Westminster + Garden Grove, not Houston
base = lsvc.browse_query()
lat, lng = geocode_zip("92683")[0], geocode_zip("92683")[1]
near = lsvc.search_with_radius(base, lat, lng, 30)
ids = {l.id for l, d in near}
assert l1.id in ids and l3.id in ids and l2.id not in ids
d_gg = dict((l.id, d) for l, d in near)[l3.id]
assert d_gg < 15, d_gg
print(f"radius filter: ok (Garden Grove {d_gg} mi from Westminster)")
# image pipeline: synthesize a PNG, process it
buf = io.BytesIO()
Image.new("RGB", (1200, 900), (80, 120, 200)).save(buf, "PNG")
buf.seek(0)
fs = FileStorage(stream=buf, filename="x.png", content_type="image/png")
img = process_upload(fs, l1.id, sort_order=0)
db.session.add(img); db.session.commit()
assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600
import os
media = os.path.join(app.instance_path, "media", str(l1.id))
assert os.path.isdir(media) and len(os.listdir(media)) == 2
print("image pipeline (re-encode + thumbnail + EXIF strip): ok")
# expiry sweep: backdate l2, sweep, confirm status transition
l2.expires_at = datetime.utcnow() - timedelta(hours=1)
db.session.commit()
active_status_before = Listing.query.filter_by(
user_id=user.id, status=ListingStatus.active).count()
n = lsvc.expire_due_listings()
active_status_after = Listing.query.filter_by(
user_id=user.id, status=ListingStatus.active).count()
assert n == 1, n
assert active_status_after == active_status_before - 1
assert Listing.query.get(l2.id).status == ListingStatus.expired
print(f"expiry sweep: ok (status active {active_status_before}->{active_status_after})")
def test_smoke(): # pytest entry
run()
if __name__ == "__main__":
run()
+9
View File
@@ -0,0 +1,9 @@
"""WSGI entry point. Used by Gunicorn and `flask` CLI.
export FLASK_APP=wsgi:app
flask db init / migrate / upgrade
gunicorn -c deploy/gunicorn.conf.py wsgi:app
"""
from app import create_app
app = create_app()