303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""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, ReportReason
|
|
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.services import reports as rsvc
|
|
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)
|
|
|
|
# promoted listings pinned to top when keyword searched
|
|
from app.services.ads import promoted_listings as get_promoted
|
|
promoted = get_promoted(q) if q else []
|
|
|
|
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)
|
|
total = len(results)
|
|
start = (page - 1) * PER_PAGE
|
|
items = results[start:start + PER_PAGE]
|
|
near = {"zip": zip_code, "radius": radius, "total": total}
|
|
# prepend promoted (no distance)
|
|
promoted_pairs = [(l, None) for l in promoted]
|
|
promoted_ids = {l.id for l in promoted}
|
|
items = promoted_pairs + [(l, d) for l, d in items
|
|
if l.id not in promoted_ids]
|
|
return render_template("listings/browse.html",
|
|
results=items, near=near,
|
|
categories=_category_choices(),
|
|
filters=request.args, page=page,
|
|
has_next=start + PER_PAGE < total,
|
|
promoted_ids={l.id for l in promoted})
|
|
flash(_("ZIP not found; showing all results."), "warning")
|
|
|
|
pagination = base.paginate(page=page, per_page=PER_PAGE, error_out=False)
|
|
promoted_ids = {l.id for l in promoted}
|
|
organic = [(l, None) for l in pagination.items if l.id not in promoted_ids]
|
|
results = [(l, None) for l in promoted] + organic
|
|
return render_template("listings/browse.html", results=results, near=None,
|
|
categories=_category_choices(), filters=request.args,
|
|
page=page, has_next=pagination.has_next,
|
|
promoted_ids={l.id for l in promoted})
|
|
|
|
|
|
# --- 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 = db.session.get(Category, 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 = db.session.get(Category, 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))
|
|
|
|
|
|
# --- report ---
|
|
@listings_bp.route("/listings/<int:listing_id>/report", methods=["POST"])
|
|
@login_required
|
|
@limiter.limit("20 per hour", methods=["POST"])
|
|
def report(listing_id):
|
|
listing = Listing.query.get_or_404(listing_id)
|
|
reason_raw = request.form.get("reason", type=str)
|
|
note = request.form.get("note", type=str)
|
|
try:
|
|
reason = ReportReason(reason_raw)
|
|
except ValueError:
|
|
flash(_("Invalid report reason."), "danger")
|
|
return redirect(url_for("listings.detail", listing_id=listing.id))
|
|
try:
|
|
rsvc.create_report(listing, current_user, reason, note=note)
|
|
except rsvc.ReportError as e:
|
|
flash(str(e), "warning")
|
|
else:
|
|
flash(_("Thanks — this listing has been reported for review."), "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()
|