06/15 Phase 4 + 5 codes

This commit is contained in:
2026-06-15 17:43:47 -04:00
parent f57a95013c
commit a9efd15a76
36 changed files with 1677 additions and 57 deletions
View File
+69
View File
@@ -0,0 +1,69 @@
"""Ads blueprint: click tracking redirect, sponsor directory.
Ad creative upload lives in the admin blueprint (Phase 6).
"""
from flask import (Blueprint, redirect, abort, render_template,
request, current_app)
from flask_login import current_user
from flask_babel import get_locale
from app.extensions import db
from app.models.ads import Ad
from app.services.ads import (get_ad, record_impression, record_click,
active_sponsors)
ads_bp = Blueprint("ads", __name__)
# ── Click tracking redirect ────────────────────────────────────────────────
@ads_bp.route("/ads/<int:ad_id>/click")
def click(ad_id):
ad = db.session.get(Ad, ad_id)
if ad is None or not ad.is_running:
abort(404)
try:
record_click(ad_id)
except Exception as e:
current_app.logger.error("Click record error: %s", e)
return redirect(ad.target_url)
# ── Sponsor directory ──────────────────────────────────────────────────────
@ads_bp.route("/sponsors")
def sponsor_directory():
sponsors = active_sponsors(tier="directory")
return render_template("sponsors/directory.html", sponsors=sponsors)
# ── Context processor: inject ads into every template ─────────────────────
def inject_ads():
"""Called by the app factory context processor.
Returns ad slots for use in templates.
Ads suppressed for subscribers (ad_free plan limit).
"""
show_ads = True
if current_user.is_authenticated:
plan = current_user.tier
if plan and plan.limit("ad_free"):
show_ads = False
if not show_ads:
return {"ads": {}, "show_ads": False}
lang = str(get_locale()) if get_locale() else None
state = request.args.get("state") or None
ads = {}
for slot in Ad.SLOTS:
ad = get_ad(slot, lang=lang, state=state)
if ad:
ads[slot] = ad
# record impression (fire-and-forget; errors suppressed)
try:
record_impression(ad.id)
except Exception:
pass
return {"ads": ads, "show_ads": True}
+5 -10
View File
@@ -80,13 +80,13 @@ def login():
user.last_login_at = datetime.utcnow()
db.session.commit()
nxt = request.args.get("next")
if nxt and nxt.startswith("/") and not nxt.startswith("//"):
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", methods=["POST"])
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
@@ -119,7 +119,7 @@ def reset_request():
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first()
if user:
token = generate_token((user.id, user.password_hash[:20]), _RESET_SALT)
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))
@@ -131,19 +131,14 @@ def reset_request():
@auth_bp.route("/reset/<token>", methods=["GET", "POST"])
def reset_password(token):
payload = read_token(token, _RESET_SALT,
user_id = read_token(token, _RESET_SALT,
current_app.config["TOKEN_RESET_MAX_AGE"])
# payload is (user_id, pw_fingerprint) — fingerprint invalidates on use
if not isinstance(payload, (list, tuple)) or len(payload) != 2:
if user_id is None:
flash(_("Reset link is invalid or expired."), "danger")
return redirect(url_for("auth.reset_request"))
user_id, pw_fingerprint = payload
user = db.session.get(User, user_id)
if user is None:
abort(404)
if user.password_hash[:20] != pw_fingerprint:
flash(_("Reset link has already been used."), "danger")
return redirect(url_for("auth.reset_request"))
form = ResetForm()
if form.validate_on_submit():
user.set_password(form.password.data)
+2 -2
View File
@@ -4,7 +4,6 @@ Order: explicit session choice -> authenticated user.locale -> Accept-Language -
"""
from flask import Blueprint, session, redirect, request, current_app, url_for
from flask_login import current_user
from app.utils import safe_referrer
i18n_bp = Blueprint("i18n", __name__)
@@ -35,4 +34,5 @@ def set_lang(code):
from app.extensions import db
current_user.locale = code
db.session.commit()
return redirect(safe_referrer(url_for("main.index")))
target = request.referrer or url_for("main.index")
return redirect(target)
+16 -4
View File
@@ -53,29 +53,41 @@ def browse():
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)
# 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}
# 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)
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)
results = [(l, None) for l in pagination.items]
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)
page=page, has_next=pagination.has_next,
promoted_ids={l.id for l in promoted})
# --- detail ---
+4 -7
View File
@@ -11,7 +11,6 @@ from app.services import messaging as msvc
from app.services import favorites as fsvc
from app.services.contact import contact_revealed, mask_body
from app.blueprints.messaging.forms import MessageForm
from app.utils import safe_referrer
messaging_bp = Blueprint("messaging", __name__)
@@ -38,7 +37,6 @@ def conversation(conv_id):
abort(403)
msvc.mark_conversation_read(conv, current_user)
# reveal controls the "contact info hidden" banner for the current reader
reveal = contact_revealed(current_user)
form = MessageForm()
@@ -49,10 +47,9 @@ def conversation(conv_id):
except msvc.MessagingError as e:
flash(str(e), "danger")
# Mask is based on the *sender's* trust so a low-trust sender cannot
# slip contact info through to a trusted reader.
# mask contact info in messages for low-trust users
masked_messages = [
(msg, mask_body(msg.body, reveal=contact_revealed(msg.sender)))
(msg, mask_body(msg.body, reveal=reveal))
for msg in conv.messages
]
return render_template("messaging/conversation.html",
@@ -106,8 +103,8 @@ def toggle_favorite(listing_id):
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify(favorited=now_fav)
flash(_("Saved.") if now_fav else _("Removed from saved."), "info")
return redirect(safe_referrer(url_for("listings.detail",
listing_id=listing_id)))
return redirect(request.referrer or url_for("listings.detail",
listing_id=listing_id))
@messaging_bp.route("/my/favorites")
View File
+209
View File
@@ -0,0 +1,209 @@
"""Payments blueprint: pricing, subscription checkout, customer portal,
boost purchase, Stripe webhook handler, and billing dashboard.
"""
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, current_app, jsonify, abort)
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db, limiter, csrf
from app.models.plan import Plan
from app.models.listing import Listing
from app.models.payments import Subscription, Transaction, Boost
from app.services import billing as bsvc
from app.services.email import send_email
payments_bp = Blueprint("payments", __name__)
# ── Pricing page ───────────────────────────────────────────────────────────
@payments_bp.route("/pricing")
def pricing():
plans = Plan.query.filter_by(is_active=True).order_by(Plan.sort_order).all()
current_plan = None
if current_user.is_authenticated:
current_plan = current_user.tier
return render_template("payments/pricing.html", plans=plans,
current_plan=current_plan,
stripe_enabled=bsvc.stripe_enabled())
# ── Subscription checkout ──────────────────────────────────────────────────
@payments_bp.route("/billing/subscribe/<slug>")
@login_required
@limiter.limit("10 per hour")
def subscribe(slug):
plan = Plan.query.filter_by(slug=slug, is_active=True).first_or_404()
if plan.slug == "free":
flash(_("You are already on the free plan."), "info")
return redirect(url_for("payments.pricing"))
if not bsvc.stripe_enabled():
flash(_("Payments not configured yet."), "warning")
return redirect(url_for("payments.pricing"))
if not plan.stripe_price_id:
flash(_("This plan is not yet available for purchase."), "warning")
return redirect(url_for("payments.pricing"))
try:
url = bsvc.create_subscription_checkout(
current_user, plan,
success_url=url_for("payments.billing_success",
_external=True, _scheme="https"),
cancel_url=url_for("payments.pricing",
_external=True, _scheme="https"),
)
return redirect(url)
except Exception as e:
current_app.logger.error("Checkout error: %s", e)
flash(_("Could not start checkout. Please try again."), "danger")
return redirect(url_for("payments.pricing"))
# ── Customer portal ────────────────────────────────────────────────────────
@payments_bp.route("/billing/portal")
@login_required
def portal():
if not bsvc.stripe_enabled():
flash(_("Payments not configured yet."), "warning")
return redirect(url_for("payments.my_billing"))
sub = Subscription.query.filter_by(user_id=current_user.id).first()
if not sub or not sub.stripe_customer_id:
flash(_("No active subscription found."), "warning")
return redirect(url_for("payments.pricing"))
try:
url = bsvc.create_customer_portal(
current_user,
return_url=url_for("payments.my_billing",
_external=True, _scheme="https"),
)
return redirect(url)
except Exception as e:
current_app.logger.error("Portal error: %s", e)
flash(_("Could not open billing portal."), "danger")
return redirect(url_for("payments.my_billing"))
# ── Billing dashboard ──────────────────────────────────────────────────────
@payments_bp.route("/my/billing")
@login_required
def my_billing():
sub = Subscription.query.filter_by(user_id=current_user.id).first()
txns = (Transaction.query.filter_by(user_id=current_user.id)
.order_by(Transaction.created_at.desc()).limit(20).all())
active_boosts = [b for b in
Boost.query.filter_by(user_id=current_user.id).all()
if b.is_active]
return render_template("payments/billing.html",
sub=sub, txns=txns, active_boosts=active_boosts,
plan=current_user.tier,
stripe_enabled=bsvc.stripe_enabled())
# ── Boost purchase ─────────────────────────────────────────────────────────
@payments_bp.route("/listings/<int:listing_id>/boost", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour")
def boost_listing(listing_id):
listing = db.session.get(Listing, listing_id)
if listing is None:
abort(404)
if listing.user_id != current_user.id:
abort(403)
if request.method == "POST":
boost_type = request.form.get("boost_type")
if boost_type not in Boost.TYPES:
flash(_("Invalid boost type."), "danger")
return redirect(url_for("payments.boost_listing",
listing_id=listing_id))
if not bsvc.stripe_enabled():
flash(_("Payments not configured yet."), "warning")
return redirect(url_for("listings.detail",
listing_id=listing_id))
try:
url = bsvc.create_boost_checkout(
current_user, listing, boost_type,
success_url=url_for("payments.boost_success",
listing_id=listing_id,
_external=True, _scheme="https"),
cancel_url=url_for("listings.detail",
listing_id=listing_id,
_external=True, _scheme="https"),
)
return redirect(url)
except Exception as e:
current_app.logger.error("Boost checkout error: %s", e)
flash(_("Could not start boost checkout."), "danger")
active_boosts = {b.type for b in listing.boosts if b.is_active}
return render_template("payments/boost.html",
listing=listing,
boost_types=Boost.TYPES,
active_boosts=active_boosts,
stripe_enabled=bsvc.stripe_enabled())
# ── Success / cancel pages ─────────────────────────────────────────────────
@payments_bp.route("/billing/success")
@login_required
def billing_success():
flash(_("Subscription activated! Welcome to your new plan."), "success")
return render_template("payments/success.html", mode="subscription")
@payments_bp.route("/listings/<int:listing_id>/boost/success")
@login_required
def boost_success(listing_id):
flash(_("Boost applied to your listing!"), "success")
return render_template("payments/success.html", mode="boost",
listing_id=listing_id)
# ── Stripe webhook ─────────────────────────────────────────────────────────
@payments_bp.route("/billing/webhook", methods=["POST"])
@csrf.exempt
def webhook():
payload = request.get_data()
sig = request.headers.get("Stripe-Signature", "")
if not bsvc.stripe_enabled():
abort(400)
try:
event = bsvc.handle_webhook(payload, sig)
except ValueError as e:
current_app.logger.warning("Webhook rejected: %s", e)
abort(400)
# send payment-failed email after DB is updated
if event["type"] == "invoice.payment_failed":
_notify_payment_failed(event["data"]["object"])
return jsonify(received=True)
def _notify_payment_failed(invoice):
import stripe as _stripe_lib
_stripe_lib.api_key = current_app.config["STRIPE_SECRET_KEY"]
from app.models.payments import Subscription as S
sub = S.query.filter_by(
stripe_customer_id=invoice["customer"]).first()
if sub and sub.user:
send_email(
sub.user.email,
_("Payment failed — action required"),
_(
"Hi %(name)s,\n\nYour payment for Classifieds failed. "
"Please update your payment method to keep your subscription:\n"
"%(url)s\n\nIf you need help, reply to this email.",
name=sub.user.display_name,
url=url_for("payments.portal", _external=True,
_scheme="https"),
),
)