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