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