06/15 Phase 1 + 2 codes
This commit is contained in:
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
dev.db
|
||||
*.mo
|
||||
instance/
|
||||
.DS_Store
|
||||
@@ -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
@@ -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).")
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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}>"
|
||||
@@ -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"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Email service. Sends via SMTP relay (Brevo); falls back to console log in dev."""
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def send_email(to_addr: str, subject: str, body: str) -> bool:
|
||||
cfg = current_app.config
|
||||
server = cfg.get("MAIL_SERVER")
|
||||
|
||||
# Dev fallback: no SMTP configured -> log to console.
|
||||
if not server:
|
||||
current_app.logger.info(
|
||||
"[DEV EMAIL] to=%s subject=%s\n%s", to_addr, subject, body
|
||||
)
|
||||
return True
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["From"] = f"{cfg.get('MAIL_FROM_NAME')} <{cfg.get('MAIL_FROM')}>"
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(server, cfg.get("MAIL_PORT", 587), timeout=15) as s:
|
||||
if cfg.get("MAIL_USE_TLS"):
|
||||
s.starttls()
|
||||
if cfg.get("MAIL_USERNAME"):
|
||||
s.login(cfg.get("MAIL_USERNAME"), cfg.get("MAIL_PASSWORD"))
|
||||
s.send_message(msg)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
current_app.logger.error("Email send failed: %s", exc)
|
||||
return False
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Validate listing `attributes` against a category's `field_schema`.
|
||||
|
||||
Returns (cleaned_attributes, errors). Caller rejects on any errors.
|
||||
Supported field types: text, number, select, bool.
|
||||
"""
|
||||
|
||||
_HOT_COLUMNS = {"condition", "job_type", "salary_min", "salary_max"}
|
||||
|
||||
|
||||
class SchemaError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_attributes(category, raw):
|
||||
raw = raw or {}
|
||||
cleaned, errors = {}, {}
|
||||
|
||||
for field in category.fields():
|
||||
name = field["name"]
|
||||
ftype = field.get("type", "text")
|
||||
required = field.get("required", False)
|
||||
val = raw.get(name)
|
||||
|
||||
if val in (None, "", []):
|
||||
if required:
|
||||
errors[name] = "required"
|
||||
continue
|
||||
|
||||
try:
|
||||
cleaned[name] = _coerce(field, ftype, val)
|
||||
except SchemaError as e:
|
||||
errors[name] = str(e)
|
||||
|
||||
return cleaned, errors
|
||||
|
||||
|
||||
def _coerce(field, ftype, val):
|
||||
if ftype == "text":
|
||||
s = str(val).strip()
|
||||
mx = field.get("max", 255)
|
||||
if len(s) > mx:
|
||||
raise SchemaError(f"max length {mx}")
|
||||
return s
|
||||
|
||||
if ftype == "number":
|
||||
try:
|
||||
n = int(val) if str(val).lstrip("-").isdigit() else float(val)
|
||||
except (TypeError, ValueError):
|
||||
raise SchemaError("must be a number")
|
||||
if "min" in field and n < field["min"]:
|
||||
raise SchemaError(f"min {field['min']}")
|
||||
if "max" in field and n > field["max"]:
|
||||
raise SchemaError(f"max {field['max']}")
|
||||
return n
|
||||
|
||||
if ftype == "select":
|
||||
opts = field.get("options", [])
|
||||
if val not in opts:
|
||||
raise SchemaError("invalid option")
|
||||
return val
|
||||
|
||||
if ftype == "bool":
|
||||
return bool(val) if isinstance(val, bool) else str(val).lower() in ("1", "true", "on", "yes")
|
||||
|
||||
raise SchemaError("unknown field type")
|
||||
|
||||
|
||||
def hot_values(cleaned):
|
||||
"""Map cleaned attributes -> denormalized Listing hot columns."""
|
||||
return {
|
||||
"attr_condition": cleaned.get("condition"),
|
||||
"attr_job_type": cleaned.get("job_type"),
|
||||
"attr_salary_min": cleaned.get("salary_min"),
|
||||
"attr_salary_max": cleaned.get("salary_max"),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Geo helpers: ZIP geocoding, haversine distance, bounding-box radius filter.
|
||||
|
||||
Portable across MySQL and SQLite (no spatial extension needed). For large-scale
|
||||
deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade.
|
||||
"""
|
||||
import math
|
||||
from app.models.geo import ZipGeo
|
||||
|
||||
EARTH_MI = 3958.7613 # mean earth radius, miles
|
||||
|
||||
|
||||
def geocode_zip(zip_code):
|
||||
"""Return (lat, lng, city, state, metro) or None."""
|
||||
row = ZipGeo.query.get((zip_code or "").strip())
|
||||
if row is None:
|
||||
return None
|
||||
return row.lat, row.lng, row.city, row.state, row.metro
|
||||
|
||||
|
||||
def haversine_mi(lat1, lng1, lat2, lng2):
|
||||
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlng = math.radians(lng2 - lng1)
|
||||
a = (math.sin(dlat / 2) ** 2
|
||||
+ math.cos(rlat1) * math.cos(rlat2) * math.sin(dlng / 2) ** 2)
|
||||
return EARTH_MI * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def bounding_box(lat, lng, radius_mi):
|
||||
"""Lat/lng min/max box enclosing the radius. Cheap indexed prefilter."""
|
||||
lat_delta = radius_mi / 69.0
|
||||
# guard against poles; cos(lat) shrinks lng degrees-per-mile
|
||||
cos_lat = max(math.cos(math.radians(lat)), 0.01)
|
||||
lng_delta = radius_mi / (69.172 * cos_lat)
|
||||
return (lat - lat_delta, lat + lat_delta, lng - lng_delta, lng + lng_delta)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Image pipeline. Validates, re-encodes (drops EXIF), thumbnails, saves to media.
|
||||
|
||||
Re-encoding through Pillow strips metadata and neutralizes polyglot/malicious
|
||||
payloads. Filenames are randomized. Returns a ListingImage (uncommitted).
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import secrets
|
||||
from PIL import Image
|
||||
from flask import current_app
|
||||
from app.models.listing import ListingImage
|
||||
|
||||
MAX_BYTES = 8 * 1024 * 1024 # 8 MB per upload
|
||||
MAX_DIM = 1600 # longest edge for the full image
|
||||
THUMB_DIM = 400
|
||||
ALLOWED = {"JPEG", "PNG", "WEBP"}
|
||||
|
||||
|
||||
class ImageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _media_root():
|
||||
root = current_app.config.get("MEDIA_ROOT") or os.path.join(
|
||||
current_app.instance_path, "media")
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def process_upload(file_storage, listing_id, sort_order=0):
|
||||
data = file_storage.read()
|
||||
if not data:
|
||||
raise ImageError("empty file")
|
||||
if len(data) > MAX_BYTES:
|
||||
raise ImageError("file too large")
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.verify() # detect truncated/corrupt
|
||||
img = Image.open(io.BytesIO(data)) # re-open after verify
|
||||
except Exception:
|
||||
raise ImageError("not a valid image")
|
||||
|
||||
if img.format not in ALLOWED:
|
||||
raise ImageError("unsupported format")
|
||||
|
||||
img = img.convert("RGB") # normalize, drop alpha/EXIF
|
||||
img.thumbnail((MAX_DIM, MAX_DIM))
|
||||
|
||||
root = _media_root()
|
||||
sub = os.path.join(root, str(listing_id))
|
||||
os.makedirs(sub, exist_ok=True)
|
||||
name = secrets.token_hex(16)
|
||||
|
||||
full_rel = os.path.join(str(listing_id), f"{name}.jpg")
|
||||
thumb_rel = os.path.join(str(listing_id), f"{name}_t.jpg")
|
||||
img.save(os.path.join(root, full_rel), "JPEG", quality=85, optimize=True)
|
||||
|
||||
thumb = img.copy()
|
||||
thumb.thumbnail((THUMB_DIM, THUMB_DIM))
|
||||
thumb.save(os.path.join(root, thumb_rel), "JPEG", quality=80, optimize=True)
|
||||
|
||||
return ListingImage(
|
||||
listing_id=listing_id, path=full_rel, thumb_path=thumb_rel,
|
||||
sort_order=sort_order, width=img.width, height=img.height,
|
||||
)
|
||||
|
||||
|
||||
def delete_image_files(image):
|
||||
root = _media_root()
|
||||
for rel in (image.path, image.thumb_path):
|
||||
if rel:
|
||||
try:
|
||||
os.remove(os.path.join(root, rel))
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Listing business logic: creation/edit with tier enforcement, the
|
||||
browse/search/radius query builder, and the expiry sweep.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from app.extensions import db
|
||||
from app.models.listing import Listing
|
||||
from app.models.enums import ListingStatus, Lang
|
||||
from app.utils.text import normalize
|
||||
from app.services.geo import geocode_zip, bounding_box, haversine_mi
|
||||
from app.services.field_schema import validate_attributes, hot_values
|
||||
|
||||
|
||||
class ListingError(ValueError):
|
||||
def __init__(self, message, field_errors=None):
|
||||
super().__init__(message)
|
||||
self.field_errors = field_errors or {}
|
||||
|
||||
|
||||
# --- tier limits ---
|
||||
def _limit(user, key, default):
|
||||
plan = user.tier
|
||||
if plan is None:
|
||||
return default
|
||||
val = plan.limit(key, default)
|
||||
return val
|
||||
|
||||
|
||||
def active_count(user):
|
||||
return Listing.query.filter(
|
||||
Listing.user_id == user.id,
|
||||
Listing.status == ListingStatus.active,
|
||||
Listing.expires_at > datetime.utcnow(),
|
||||
).count()
|
||||
|
||||
|
||||
def can_create(user):
|
||||
cap = _limit(user, "active_listings", 3)
|
||||
if cap is None: # unlimited (business)
|
||||
return True
|
||||
return active_count(user) < cap
|
||||
|
||||
|
||||
def image_cap(user):
|
||||
return _limit(user, "images_per_listing", 3)
|
||||
|
||||
|
||||
def _life_days(user):
|
||||
return _limit(user, "listing_life_days", 14)
|
||||
|
||||
|
||||
# --- create / update ---
|
||||
def create_listing(user, category, *, title, body, lang, price_cents,
|
||||
zip_code, raw_attributes):
|
||||
if not can_create(user):
|
||||
raise ListingError("active listing limit reached for your plan")
|
||||
|
||||
cleaned, errors = validate_attributes(category, raw_attributes)
|
||||
if errors:
|
||||
raise ListingError("attribute validation failed", errors)
|
||||
|
||||
listing = Listing(
|
||||
user_id=user.id,
|
||||
category_id=category.id,
|
||||
title=title.strip(),
|
||||
title_norm=normalize(title),
|
||||
body=body.strip(),
|
||||
lang=Lang(lang) if lang in Lang._value2member_map_ else Lang.en,
|
||||
price_cents=price_cents,
|
||||
attributes=cleaned,
|
||||
status=ListingStatus.active,
|
||||
expires_at=datetime.utcnow() + timedelta(days=_life_days(user)),
|
||||
**hot_values(cleaned),
|
||||
)
|
||||
_apply_location(listing, zip_code)
|
||||
db.session.add(listing)
|
||||
db.session.commit()
|
||||
return listing
|
||||
|
||||
|
||||
def update_listing(listing, category, *, title, body, lang, price_cents,
|
||||
zip_code, raw_attributes):
|
||||
cleaned, errors = validate_attributes(category, raw_attributes)
|
||||
if errors:
|
||||
raise ListingError("attribute validation failed", errors)
|
||||
|
||||
listing.title = title.strip()
|
||||
listing.title_norm = normalize(title)
|
||||
listing.body = body.strip()
|
||||
listing.lang = Lang(lang) if lang in Lang._value2member_map_ else listing.lang
|
||||
listing.price_cents = price_cents
|
||||
listing.attributes = cleaned
|
||||
for col, val in hot_values(cleaned).items():
|
||||
setattr(listing, col, val)
|
||||
_apply_location(listing, zip_code)
|
||||
db.session.commit()
|
||||
return listing
|
||||
|
||||
|
||||
def _apply_location(listing, zip_code):
|
||||
listing.zip = (zip_code or "").strip() or None
|
||||
geo = geocode_zip(zip_code) if zip_code else None
|
||||
if geo:
|
||||
listing.lat, listing.lng, listing.city, listing.state, _metro = geo
|
||||
else:
|
||||
listing.lat = listing.lng = listing.city = listing.state = None
|
||||
|
||||
|
||||
# --- browse / search / radius ---
|
||||
def browse_query(*, category_id=None, q=None, state=None, min_price=None,
|
||||
max_price=None, condition=None, job_type=None):
|
||||
"""Base query of live listings with optional filters (no radius)."""
|
||||
query = Listing.query.filter(
|
||||
Listing.status == ListingStatus.active,
|
||||
Listing.expires_at > datetime.utcnow(),
|
||||
)
|
||||
if category_id:
|
||||
query = query.filter(Listing.category_id == category_id)
|
||||
if state:
|
||||
query = query.filter(Listing.state == state.upper())
|
||||
if min_price is not None:
|
||||
query = query.filter(Listing.price_cents >= min_price)
|
||||
if max_price is not None:
|
||||
query = query.filter(Listing.price_cents <= max_price)
|
||||
if condition:
|
||||
query = query.filter(Listing.attr_condition == condition)
|
||||
if job_type:
|
||||
query = query.filter(Listing.attr_job_type == job_type)
|
||||
if q:
|
||||
term = f"%{normalize(q)}%"
|
||||
# accent-insensitive on title_norm; body LIKE as a fallback.
|
||||
query = query.filter(db.or_(
|
||||
Listing.title_norm.like(term),
|
||||
Listing.body.like(f"%{q.strip()}%"),
|
||||
))
|
||||
return query
|
||||
|
||||
|
||||
def order_default(query):
|
||||
"""Featured first, then most recently bumped/created."""
|
||||
return query.order_by(
|
||||
Listing.is_featured.desc(),
|
||||
db.func.coalesce(Listing.bump_at, Listing.created_at).desc(),
|
||||
)
|
||||
|
||||
|
||||
def radius_filter(listings, lat, lng, radius_mi):
|
||||
"""Refine an iterable of listings to those within radius (exact haversine).
|
||||
|
||||
Pair with a bounding-box DB prefilter for efficiency (see search_with_radius).
|
||||
"""
|
||||
out = []
|
||||
for l in listings:
|
||||
if l.lat is None or l.lng is None:
|
||||
continue
|
||||
d = haversine_mi(lat, lng, l.lat, l.lng)
|
||||
if d <= radius_mi:
|
||||
out.append((l, round(d, 1)))
|
||||
out.sort(key=lambda t: t[1])
|
||||
return out
|
||||
|
||||
|
||||
def search_with_radius(base_query, lat, lng, radius_mi):
|
||||
"""Apply a bounding-box prefilter in SQL, then exact-distance refine."""
|
||||
min_lat, max_lat, min_lng, max_lng = bounding_box(lat, lng, radius_mi)
|
||||
prefiltered = base_query.filter(
|
||||
Listing.lat.between(min_lat, max_lat),
|
||||
Listing.lng.between(min_lng, max_lng),
|
||||
).all()
|
||||
return radius_filter(prefiltered, lat, lng, radius_mi)
|
||||
|
||||
|
||||
# --- expiry sweep (scheduler / CLI) ---
|
||||
def expire_due_listings():
|
||||
"""Flip active listings past expires_at to expired. Returns count."""
|
||||
now = datetime.utcnow()
|
||||
due = Listing.query.filter(
|
||||
Listing.status == ListingStatus.active,
|
||||
Listing.expires_at <= now,
|
||||
)
|
||||
n = 0
|
||||
for listing in due:
|
||||
listing.status = ListingStatus.expired
|
||||
n += 1
|
||||
if n:
|
||||
db.session.commit()
|
||||
return n
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Trust scoring. Append events, recompute score + tier.
|
||||
|
||||
Phase 1 wiring: email verification grants trust. Listing-survival and flag
|
||||
penalties hook in during Phase 2/3. Thresholds are intentionally simple here.
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.models.trust import TrustEvent
|
||||
from app.models.enums import TrustEventType, TrustTier
|
||||
|
||||
# tier thresholds by cumulative score
|
||||
_TIER_THRESHOLDS = [
|
||||
(50, TrustTier.verified),
|
||||
(20, TrustTier.trusted),
|
||||
(5, TrustTier.basic),
|
||||
(0, TrustTier.new),
|
||||
]
|
||||
|
||||
|
||||
def record_event(user, event_type: TrustEventType, delta: int):
|
||||
"""Append a trust event, bump score, recompute tier. Caller commits."""
|
||||
db.session.add(TrustEvent(user_id=user.id, type=event_type, delta=delta))
|
||||
user.trust_score = max(0, (user.trust_score or 0) + delta)
|
||||
user.trust_tier = _tier_for(user.trust_score)
|
||||
return user
|
||||
|
||||
|
||||
def _tier_for(score: int) -> TrustTier:
|
||||
for threshold, tier in _TIER_THRESHOLDS:
|
||||
if score >= threshold:
|
||||
return tier
|
||||
return TrustTier.new
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Cloudflare Turnstile verification. Bypasses in dev when no secret is set."""
|
||||
import requests
|
||||
from flask import current_app, request
|
||||
|
||||
_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
|
||||
|
||||
def turnstile_enabled() -> bool:
|
||||
return bool(current_app.config.get("TURNSTILE_SECRET_KEY"))
|
||||
|
||||
|
||||
def verify_turnstile() -> bool:
|
||||
"""Validate the cf-turnstile-response token from the current request form."""
|
||||
secret = current_app.config.get("TURNSTILE_SECRET_KEY")
|
||||
if not secret:
|
||||
return True # dev bypass
|
||||
|
||||
token = request.form.get("cf-turnstile-response", "")
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
resp = requests.post(
|
||||
_VERIFY_URL,
|
||||
data={
|
||||
"secret": secret,
|
||||
"response": token,
|
||||
"remoteip": request.remote_addr,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
return bool(resp.json().get("success"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
current_app.logger.error("Turnstile verify failed: %s", exc)
|
||||
return False
|
||||
@@ -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}}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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)) }}">← {{ _('Prev') }}</a>{% endif %}
|
||||
{% if has_next %}<a href="{{ url_for('listings.browse', **merge_query(page=page+1)) }}">{{ _('Next') }} →</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Run classifieds listing-expiry sweep hourly
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user