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