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
+7
View File
@@ -41,3 +41,10 @@ TURNSTILE_SECRET_KEY=
# --- Security tokens ---
TOKEN_VERIFY_MAX_AGE=86400 # 24h email-verify link life (seconds)
TOKEN_RESET_MAX_AGE=3600 # 1h password-reset link life (seconds)
# Stripe
# Get keys from https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
# Get webhook secret after running: stripe listen --forward-to localhost:5000/billing/webhook
STRIPE_WEBHOOK_SECRET=
+33 -1
View File
@@ -53,11 +53,15 @@ def _register_blueprints(app):
from app.blueprints.i18n.routes import i18n_bp
from app.blueprints.listings.routes import listings_bp
from app.blueprints.messaging.routes import messaging_bp
from app.blueprints.payments.routes import payments_bp
from app.blueprints.ads.routes import ads_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(i18n_bp)
app.register_blueprint(listings_bp)
app.register_blueprint(messaging_bp)
app.register_blueprint(payments_bp)
app.register_blueprint(ads_bp)
def _register_errorhandlers(app):
@@ -78,6 +82,7 @@ def _register_context(app):
from flask_babel import get_locale
from flask import request
from flask_login import current_user
from app.blueprints.ads.routes import inject_ads
def merge_query(**overrides):
merged = request.args.to_dict()
@@ -93,19 +98,46 @@ def _register_context(app):
unread = total_unread(current_user)
except Exception:
pass
ad_context = {}
try:
ad_context = inject_ads()
except Exception:
pass
return {
"get_locale": get_locale,
"merge_query": merge_query,
"SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"],
"TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""),
"unread_count": unread,
**ad_context,
}
def _register_cli(app):
@app.cli.command("expire-listings")
def expire_listings():
def expire_listings_cmd():
"""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).")
@app.cli.command("expire-boosts")
def expire_boosts_cmd():
"""Sweep: clear expired boosts, revert listing effects."""
from app.services.billing import expire_boosts
n = expire_boosts()
print(f"Cleared {n} expired boost(s).")
@app.cli.command("expire-promoted-keywords")
def expire_promoted_cmd():
"""Sweep: remove expired promoted keyword rows."""
from app.services.ads import expire_promoted_keywords
n = expire_promoted_keywords()
print(f"Removed {n} expired promoted keyword(s).")
@app.cli.command("reconcile-subscriptions")
def reconcile_cmd():
"""Nightly: sync local subscription status vs Stripe."""
from app.services.billing import reconcile_subscriptions
checked, fixed = reconcile_subscriptions()
print(f"Reconciled {checked} subscription(s), fixed {fixed}.")
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"),
),
)
+5
View File
@@ -63,6 +63,11 @@ class BaseConfig:
# --- Media (uploaded images) ---
MEDIA_ROOT = os.environ.get("MEDIA_ROOT") or None # default: instance/media
# --- Stripe ---
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
# --- Session cookie hardening ---
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
+4 -1
View File
@@ -7,7 +7,10 @@ from app.models.listing import Listing, ListingImage
from app.models.geo import ZipGeo, Metro
from app.models.messaging import Conversation, Message
from app.models.favorite import Favorite
from app.models.payments import Subscription, Transaction, Boost
from app.models.ads import Ad, Sponsor, PromotedKeyword
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
"Favorite"]
"Favorite", "Subscription", "Transaction", "Boost",
"Ad", "Sponsor", "PromotedKeyword"]
+117
View File
@@ -0,0 +1,117 @@
"""Ad and Sponsor models.
Ad: internal ad server. Slots: header, sidebar, inline, footer.
Targeting: lang (nullable = all), geo_state (nullable = all).
Impression/click counters on the model; Redis batching added in Phase 7.
Sponsor: paid directory listing + optional category sponsorship.
Category sponsor FK wired to categories.sponsor_id (set separately).
PromotedKeyword: pinned listing for a search keyword (promoted search).
"""
from datetime import datetime
from app.extensions import db
class Ad(db.Model):
__tablename__ = "ads"
SLOTS = ["header", "sidebar", "inline", "footer"]
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
advertiser_name = db.Column(db.String(120), nullable=False)
slot = db.Column(db.Enum("header", "sidebar", "inline", "footer",
name="ad_slot"), nullable=False)
creative_path = db.Column(db.String(255), nullable=True) # image file rel path
target_url = db.Column(db.String(512), nullable=False)
alt_text = db.Column(db.String(200), nullable=True)
# targeting (null = match all)
lang = db.Column(db.String(5), nullable=True)
geo_state = db.Column(db.String(2), nullable=True)
starts_at = db.Column(db.DateTime, nullable=False, index=True)
ends_at = db.Column(db.DateTime, nullable=False, index=True)
impressions = db.Column(db.Integer, nullable=False, default=0)
clicks = db.Column(db.Integer, nullable=False, default=0)
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)
@property
def ctr(self):
if not self.impressions:
return 0.0
return round(self.clicks / self.impressions * 100, 2)
@property
def is_running(self):
now = datetime.utcnow()
return self.is_active and self.starts_at <= now <= self.ends_at
def __repr__(self):
return f"<Ad {self.id} slot={self.slot} adv={self.advertiser_name[:20]}>"
class Sponsor(db.Model):
__tablename__ = "sponsors"
TIERS = ["directory", "category"]
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
name = db.Column(db.String(120), nullable=False)
logo_path = db.Column(db.String(255), nullable=True)
url = db.Column(db.String(512), nullable=False)
tagline = db.Column(db.String(160), nullable=True)
tier = db.Column(db.Enum("directory", "category", name="sponsor_tier"),
nullable=False, default="directory")
category_id = db.Column(
db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("categories.id"), nullable=True)
starts_at = db.Column(db.DateTime, nullable=False)
ends_at = db.Column(db.DateTime, nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
category = db.relationship("Category",
backref=db.backref("sponsors", lazy="selectin"))
@property
def is_running(self):
now = datetime.utcnow()
return self.is_active and self.starts_at <= now <= self.ends_at
def __repr__(self):
return f"<Sponsor {self.name} tier={self.tier}>"
class PromotedKeyword(db.Model):
"""A listing pinned to the top of search results for a given keyword."""
__tablename__ = "promoted_keywords"
__table_args__ = (
db.UniqueConstraint("keyword", "listing_id", name="uq_pk_kw_listing"),
)
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
keyword = db.Column(db.String(80), nullable=False, index=True)
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("listings.id"), nullable=False)
priority = 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)
listing = db.relationship("Listing",
backref=db.backref("promoted_keywords",
lazy="selectin"))
@property
def is_active(self):
return self.expires_at > datetime.utcnow()
def __repr__(self):
return f"<PromotedKeyword '{self.keyword}' L{self.listing_id}>"
+108
View File
@@ -0,0 +1,108 @@
"""Monetization models: Subscription, Transaction, Boost.
Stripe is source of truth for subscriptions. Local rows mirror state
and are reconciled via webhooks + nightly job.
Money stored as integer cents throughout.
"""
from datetime import datetime
from sqlalchemy import JSON
from app.extensions import db
class Subscription(db.Model):
__tablename__ = "subscriptions"
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,
unique=True, index=True)
plan_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("plans.id"), nullable=False)
stripe_customer_id = db.Column(db.String(64), nullable=True, index=True)
stripe_sub_id = db.Column(db.String(64), nullable=True, unique=True, index=True)
status = db.Column(
db.Enum("active", "past_due", "canceled", "trialing",
name="sub_status"),
nullable=False, default="active")
current_period_end = db.Column(db.DateTime, nullable=True)
cancel_at_period_end = db.Column(db.Boolean, nullable=False, default=False)
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("subscription", uselist=False))
plan = db.relationship("Plan",
backref=db.backref("subscriptions", lazy="dynamic"))
@property
def is_active(self):
return self.status in ("active", "trialing")
def __repr__(self):
return f"<Subscription u{self.user_id} {self.status}>"
class Transaction(db.Model):
__tablename__ = "transactions"
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)
type = db.Column(
db.Enum("subscription", "boost", "refund", name="txn_type"),
nullable=False)
amount_cents = db.Column(db.Integer, nullable=False, default=0)
currency = db.Column(db.String(3), nullable=False, default="usd")
stripe_object_id = db.Column(db.String(64), nullable=True, index=True)
status = db.Column(db.String(20), nullable=False, default="succeeded")
meta = db.Column(JSON, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship("User",
backref=db.backref("transactions", lazy="dynamic"))
def __repr__(self):
return f"<Transaction {self.type} ${self.amount_cents/100:.2f}>"
class Boost(db.Model):
__tablename__ = "boosts"
# Boost types and their default durations in days
TYPES = {
"featured": {"label": "Featured listing", "days": 7, "cents": 499},
"bump": {"label": "Bump to top", "days": 3, "cents": 199},
"highlight":{"label": "Highlight", "days": 7, "cents": 299},
"urgent": {"label": "Urgent tag", "days": 7, "cents": 199},
}
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)
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False)
type = db.Column(
db.Enum("featured", "bump", "highlight", "urgent", name="boost_type"),
nullable=False)
expires_at = db.Column(db.DateTime, nullable=False, index=True)
transaction_id = db.Column(
db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("transactions.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
listing = db.relationship("Listing",
backref=db.backref("boosts", lazy="selectin"))
user = db.relationship("User",
backref=db.backref("boosts", lazy="dynamic"))
transaction = db.relationship("Transaction")
@property
def is_active(self):
return self.expires_at > datetime.utcnow()
def __repr__(self):
return f"<Boost {self.type} L{self.listing_id} exp={self.expires_at.date()}>"
+120
View File
@@ -0,0 +1,120 @@
"""Ads service.
Ad serving:
get_ad(slot, lang, state) → Ad or None
Picks a random active ad matching slot + optional lang/state targeting.
Falls back from targeted → untargeted if no targeted ad found.
Tracking:
record_impression(ad_id) — increments ad.impressions
record_click(ad_id) — increments ad.clicks
Both are direct DB increments for now.
Phase 7: batch to Redis counter, flush every N minutes.
Promoted search:
promoted_listings(keyword) → [Listing, ...] ordered by priority desc
Inserted at the top of browse results when a keyword is searched.
Sponsors:
active_sponsors(tier, category_id) → [Sponsor, ...]
"""
import random
from datetime import datetime
from app.extensions import db
from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.listing import Listing
from app.utils.text import normalize
def get_ad(slot: str, lang: str = None, state: str = None) -> "Ad | None":
"""Return one active ad for the slot, targeted then untargeted fallback."""
now = datetime.utcnow()
base = Ad.query.filter(
Ad.slot == slot,
Ad.is_active == True,
Ad.starts_at <= now,
Ad.ends_at >= now,
)
# try targeted first
if lang or state:
targeted = base
if lang:
targeted = targeted.filter(Ad.lang == lang)
if state:
targeted = targeted.filter(Ad.geo_state == state)
candidates = targeted.all()
if candidates:
return random.choice(candidates)
# untargeted fallback
candidates = base.filter(Ad.lang.is_(None), Ad.geo_state.is_(None)).all()
if candidates:
return random.choice(candidates)
# any active ad for this slot
candidates = base.all()
return random.choice(candidates) if candidates else None
def record_impression(ad_id: int):
"""Increment impression counter. Safe to call on every page render."""
Ad.query.filter_by(id=ad_id).update(
{Ad.impressions: Ad.impressions + 1})
db.session.commit()
def record_click(ad_id: int):
"""Increment click counter."""
Ad.query.filter_by(id=ad_id).update(
{Ad.clicks: Ad.clicks + 1})
db.session.commit()
def promoted_listings(keyword: str) -> list:
"""Return active promoted listings for keyword, priority-ordered."""
if not keyword:
return []
norm = normalize(keyword)
now = datetime.utcnow()
rows = (PromotedKeyword.query
.filter(PromotedKeyword.expires_at > now)
.order_by(PromotedKeyword.priority.desc())
.all())
# match any keyword that is a substring of the search term (normalized)
matched = [r for r in rows if normalize(r.keyword) in norm or norm in normalize(r.keyword)]
listings = []
seen = set()
for r in matched:
if r.listing_id not in seen and r.listing and r.listing.is_live:
listings.append(r.listing)
seen.add(r.listing_id)
return listings
def active_sponsors(tier: str = None, category_id: int = None) -> list:
"""Return running sponsors, optionally filtered by tier and category."""
now = datetime.utcnow()
q = Sponsor.query.filter(
Sponsor.is_active == True,
Sponsor.starts_at <= now,
Sponsor.ends_at >= now,
)
if tier:
q = q.filter(Sponsor.tier == tier)
if category_id is not None:
q = q.filter(Sponsor.category_id == category_id)
return q.order_by(Sponsor.created_at.desc()).all()
def expire_promoted_keywords() -> int:
"""Remove expired promoted keyword rows. Returns count."""
now = datetime.utcnow()
expired = PromotedKeyword.query.filter(
PromotedKeyword.expires_at <= now).all()
n = len(expired)
for row in expired:
db.session.delete(row)
if n:
db.session.commit()
return n
+362
View File
@@ -0,0 +1,362 @@
"""Billing service: Stripe integration layer.
All Stripe interactions go through this module. Routes stay thin.
Key rules:
- Stripe is source of truth; local DB mirrors via webhooks.
- Money in integer cents always.
- Webhook handlers are idempotent (safe to replay).
- stripe_enabled() guard lets the app run with no keys in dev.
"""
from datetime import datetime, timedelta
import stripe
from flask import current_app
from app.extensions import db
from app.models.user import User
from app.models.plan import Plan
from app.models.listing import Listing
from app.models.payments import Subscription, Transaction, Boost
from app.models.enums import Role
# ── Stripe client init ─────────────────────────────────────────────────────
def stripe_enabled() -> bool:
return bool(current_app.config.get("STRIPE_SECRET_KEY"))
def _stripe():
key = current_app.config.get("STRIPE_SECRET_KEY")
if not key:
raise RuntimeError("Stripe not configured — set STRIPE_SECRET_KEY in .env")
stripe.api_key = key
return stripe
# ── Customer ───────────────────────────────────────────────────────────────
def get_or_create_customer(user) -> str:
"""Return existing Stripe customer ID or create one."""
sub = Subscription.query.filter_by(user_id=user.id).first()
if sub and sub.stripe_customer_id:
return sub.stripe_customer_id
s = _stripe()
customer = s.Customer.create(
email=user.email,
name=user.display_name,
metadata={"user_id": user.id},
)
return customer.id
# ── Subscription checkout ──────────────────────────────────────────────────
def create_subscription_checkout(user, plan, success_url, cancel_url) -> str:
"""Create a Stripe Checkout Session for a subscription. Returns URL."""
if not plan.stripe_price_id:
raise ValueError(f"Plan '{plan.slug}' has no stripe_price_id configured")
s = _stripe()
customer_id = get_or_create_customer(user)
session = s.checkout.Session.create(
customer=customer_id,
mode="subscription",
line_items=[{"price": plan.stripe_price_id, "quantity": 1}],
automatic_tax={"enabled": True},
success_url=success_url,
cancel_url=cancel_url,
metadata={"user_id": user.id, "plan_id": plan.id},
allow_promotion_codes=True,
)
return session.url
def create_customer_portal(user, return_url) -> str:
"""Create a Stripe Customer Portal session. Returns URL."""
s = _stripe()
customer_id = get_or_create_customer(user)
session = s.billing_portal.Session.create(
customer=customer_id,
return_url=return_url,
)
return session.url
# ── Boost checkout ─────────────────────────────────────────────────────────
def create_boost_checkout(user, listing, boost_type, success_url, cancel_url) -> str:
"""Create a Stripe Checkout Session for a one-off boost. Returns URL."""
info = Boost.TYPES.get(boost_type)
if not info:
raise ValueError(f"Unknown boost type: {boost_type}")
s = _stripe()
customer_id = get_or_create_customer(user)
session = s.checkout.Session.create(
customer=customer_id,
mode="payment",
line_items=[{
"price_data": {
"currency": "usd",
"unit_amount": info["cents"],
"product_data": {"name": info["label"],
"description": f'"{listing.title[:60]}"'},
},
"quantity": 1,
}],
automatic_tax={"enabled": True},
success_url=success_url,
cancel_url=cancel_url,
metadata={
"user_id": user.id,
"listing_id": listing.id,
"boost_type": boost_type,
},
)
return session.url
# ── Boost activation (called after successful payment) ────────────────────
def activate_boost(user_id: int, listing_id: int, boost_type: str,
stripe_payment_intent_id: str = None,
amount_cents: int = None) -> Boost:
"""Write Transaction + Boost rows. Idempotent on stripe_payment_intent_id."""
# idempotency: skip if transaction already recorded
if stripe_payment_intent_id:
existing = Transaction.query.filter_by(
stripe_object_id=stripe_payment_intent_id).first()
if existing:
return Boost.query.filter_by(
transaction_id=existing.id).first()
info = Boost.TYPES[boost_type]
txn = Transaction(
user_id=user_id,
type="boost",
amount_cents=amount_cents or info["cents"],
stripe_object_id=stripe_payment_intent_id,
status="succeeded",
meta={"boost_type": boost_type, "listing_id": listing_id},
)
db.session.add(txn)
db.session.flush()
expires_at = datetime.utcnow() + timedelta(days=info["days"])
boost = Boost(listing_id=listing_id, user_id=user_id,
type=boost_type, expires_at=expires_at,
transaction_id=txn.id)
db.session.add(boost)
# apply listing effects
listing = db.session.get(Listing, listing_id)
if listing:
if boost_type == "featured":
listing.is_featured = True
if boost_type == "bump":
listing.bump_at = datetime.utcnow()
db.session.commit()
return boost
# ── Tier sync (writes to user + subscription table) ───────────────────────
def sync_subscription(user_id: int, stripe_sub_obj) -> Subscription:
"""Upsert local Subscription from a Stripe subscription object."""
plan = Plan.query.filter_by(
stripe_price_id=stripe_sub_obj["items"]["data"][0]["price"]["id"]
).first()
user = db.session.get(User, user_id)
sub = Subscription.query.filter_by(user_id=user_id).first()
if sub is None:
sub = Subscription(user_id=user_id)
db.session.add(sub)
sub.stripe_customer_id = stripe_sub_obj["customer"]
sub.stripe_sub_id = stripe_sub_obj["id"]
sub.status = stripe_sub_obj["status"]
sub.cancel_at_period_end = stripe_sub_obj["cancel_at_period_end"]
if stripe_sub_obj.get("current_period_end"):
sub.current_period_end = datetime.utcfromtimestamp(
stripe_sub_obj["current_period_end"])
if plan:
sub.plan_id = plan.id
user.tier_id = plan.id
user.role = Role.subscriber
db.session.commit()
return sub
def downgrade_to_free(user_id: int):
"""Called on subscription canceled/expired. Revert user to free tier."""
user = db.session.get(User, user_id)
if user is None:
return
free_plan = Plan.query.filter_by(slug="free").first()
user.role = Role.free
user.tier_id = free_plan.id if free_plan else None
sub = Subscription.query.filter_by(user_id=user_id).first()
if sub:
sub.status = "canceled"
db.session.commit()
# ── Webhook event handlers ─────────────────────────────────────────────────
def handle_webhook(payload: bytes, sig_header: str):
"""Verify signature, dispatch to handler. Raises on bad sig."""
s = _stripe()
secret = current_app.config["STRIPE_WEBHOOK_SECRET"]
try:
event = s.Webhook.construct_event(payload, sig_header, secret)
except stripe.error.SignatureVerificationError as e:
raise ValueError(f"Invalid Stripe signature: {e}") from e
etype = event["type"]
data = event["data"]["object"]
if etype == "checkout.session.completed":
_on_checkout_completed(data)
elif etype in ("customer.subscription.updated",
"customer.subscription.created"):
_on_subscription_updated(data)
elif etype == "customer.subscription.deleted":
_on_subscription_deleted(data)
elif etype == "invoice.payment_failed":
_on_payment_failed(data)
else:
current_app.logger.debug("Unhandled Stripe event: %s", etype)
return event
def _on_checkout_completed(session):
meta = session.get("metadata", {})
mode = session.get("mode")
if mode == "subscription":
user_id = int(meta.get("user_id", 0))
if not user_id:
return
# Fetch full subscription object from Stripe
s = _stripe()
stripe_sub = s.Subscription.retrieve(session["subscription"])
sync_subscription(user_id, stripe_sub)
# Record transaction
_record_sub_transaction(user_id, session)
elif mode == "payment":
# boost purchase
user_id = int(meta.get("user_id", 0))
listing_id = int(meta.get("listing_id", 0))
boost_type = meta.get("boost_type")
if user_id and listing_id and boost_type:
activate_boost(
user_id=user_id,
listing_id=listing_id,
boost_type=boost_type,
stripe_payment_intent_id=session.get("payment_intent"),
amount_cents=session.get("amount_total"),
)
def _on_subscription_updated(stripe_sub):
customer_id = stripe_sub["customer"]
sub = Subscription.query.filter_by(
stripe_customer_id=customer_id).first()
if sub:
sync_subscription(sub.user_id, stripe_sub)
def _on_subscription_deleted(stripe_sub):
customer_id = stripe_sub["customer"]
sub = Subscription.query.filter_by(
stripe_customer_id=customer_id).first()
if sub:
downgrade_to_free(sub.user_id)
def _on_payment_failed(invoice):
customer_id = invoice["customer"]
sub = Subscription.query.filter_by(
stripe_customer_id=customer_id).first()
if sub:
sub.status = "past_due"
db.session.commit()
# email notification handled by email service caller (route layer)
def _record_sub_transaction(user_id, session):
existing = Transaction.query.filter_by(
stripe_object_id=session.get("payment_intent") or session["id"]).first()
if existing:
return
txn = Transaction(
user_id=user_id,
type="subscription",
amount_cents=session.get("amount_total", 0),
stripe_object_id=session.get("payment_intent") or session["id"],
status="succeeded",
meta={"session_id": session["id"]},
)
db.session.add(txn)
db.session.commit()
# ── Boost expiry sweep ─────────────────────────────────────────────────────
def expire_boosts() -> int:
"""Clear expired boosts and revert listing effects. Returns count."""
now = datetime.utcnow()
expired = Boost.query.filter(Boost.expires_at <= now).all()
n = 0
for boost in expired:
listing = db.session.get(Listing, boost.listing_id)
if listing and boost.type == "featured":
# only un-feature if no other active featured boost exists
other = Boost.query.filter(
Boost.listing_id == boost.listing_id,
Boost.type == "featured",
Boost.expires_at > now,
Boost.id != boost.id,
).first()
if not other:
listing.is_featured = False
db.session.delete(boost)
n += 1
if n:
db.session.commit()
return n
# ── Nightly reconcile ──────────────────────────────────────────────────────
def reconcile_subscriptions():
"""Compare local active subscriptions vs Stripe. Fix mismatches.
Catches webhooks that were missed/failed. Returns (checked, fixed) counts.
"""
if not stripe_enabled():
return 0, 0
s = _stripe()
subs = Subscription.query.filter(
Subscription.stripe_sub_id.isnot(None),
Subscription.status.in_(["active", "trialing", "past_due"]),
).all()
checked, fixed = 0, 0
for sub in subs:
try:
stripe_sub = s.Subscription.retrieve(sub.stripe_sub_id)
checked += 1
if stripe_sub["status"] != sub.status:
current_app.logger.info(
"Reconcile: sub %s local=%s stripe=%s — fixing",
sub.stripe_sub_id, sub.status, stripe_sub["status"])
if stripe_sub["status"] == "canceled":
downgrade_to_free(sub.user_id)
else:
sync_subscription(sub.user_id, stripe_sub)
fixed += 1
except stripe.error.StripeError as e:
current_app.logger.error("Reconcile error sub %s: %s",
sub.stripe_sub_id, e)
return checked, fixed
+2 -2
View File
@@ -14,8 +14,8 @@ from app.models.enums import TrustTier
# patterns
_PHONE_RE = re.compile(
r"(\+?1[\s\-.]?)?"
r"(\(?\d{3}\)?[\s\-.]?)"
r"\d{3}[\s\-.]?\d{4}"
r"(\(?\d{3}\)?[\s\-.])"
r"\d{3}[\s\-.]\d{4}"
)
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I)
+1 -2
View File
@@ -4,7 +4,6 @@ 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.extensions import db
from app.models.geo import ZipGeo
EARTH_MI = 3958.7613 # mean earth radius, miles
@@ -12,7 +11,7 @@ EARTH_MI = 3958.7613 # mean earth radius, miles
def geocode_zip(zip_code):
"""Return (lat, lng, city, state, metro) or None."""
row = db.session.get(ZipGeo, (zip_code or "").strip())
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
+1 -2
View File
@@ -98,8 +98,7 @@ def inbox(user, page=1, per_page=20):
Conversation.buyer_id == user.id,
Conversation.seller_id == user.id,
))
.order_by(Conversation.last_message_at.is_(None),
Conversation.last_message_at.desc(),
.order_by(Conversation.last_message_at.desc().nullslast(),
Conversation.created_at.desc())
.paginate(page=page, per_page=per_page, error_out=False))
+58 -1
View File
@@ -37,7 +37,7 @@ main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%}
.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;width:100%;min-width:0}
.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}
@@ -130,3 +130,60 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.reply-box{padding:16px}
.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px;
padding:8px 12px;margin-top:8px}
/* --- Phase 4: payments / pricing --- */
.pricing-wrap{max-width:900px;margin:0 auto;padding:24px 0}
.pricing-head{text-align:center;margin-bottom:32px}
.pricing-head h1{font-size:28px;margin:0 0 8px}
.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:20px}
.plan-card{position:relative;display:flex;flex-direction:column;gap:0}
.plan-card.current{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand)}
.plan-badge{position:absolute;top:-10px;left:50%;transform:translateX(-50%);
background:var(--brand);color:#fff;font-size:11px;padding:2px 10px;
border-radius:10px;white-space:nowrap}
.plan-name{font-weight:700;font-size:16px;margin-bottom:8px}
.plan-price{margin-bottom:16px}
.price-amount{font-size:28px;font-weight:800;color:var(--fg)}
.price-period{color:var(--muted);font-size:14px}
.plan-features{list-style:none;padding:0;margin:0 0 20px;flex:1;
display:flex;flex-direction:column;gap:6px;font-size:14px;color:var(--muted)}
.plan-features .feat-yes{color:var(--ok)}
.plan-features .feat-yes::before{content:"✓ "}
.plan-btn{display:block;text-align:center;margin-top:auto}
.plan-btn.disabled{background:#e3e7ec;color:var(--muted);cursor:default}
.billing-wrap{max-width:680px;margin:0 auto}
.billing-section{margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--line)}
.billing-section:last-child{border-bottom:0}
.billing-section h3{margin-top:0}
.billing-plan{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.billing-actions{display:flex;gap:10px}
.boost-options{display:flex;flex-direction:column;gap:10px;margin-bottom:20px}
.boost-option{display:flex;align-items:center;gap:12px;padding:14px;
border:1px solid var(--line);border-radius:10px;cursor:pointer}
.boost-option:has(input:checked){border-color:var(--brand);background:#f0f6ff}
.boost-option.active-boost{opacity:.6;cursor:default}
.boost-option input{accent-color:var(--brand)}
.boost-info{flex:1}
.boost-label{font-weight:600;font-size:14px}
.boost-price{font-weight:700;color:var(--ok);font-size:15px}
.upgrade-prompt{background:#e9f1fb;border:1px solid #cadcf3;border-radius:8px;
padding:10px 14px;margin-bottom:14px;font-size:14px;color:var(--info)}
.upgrade-prompt a{font-weight:600;color:var(--brand)}
/* --- Phase 5: ads & sponsors --- */
.ad-slot{margin:8px 0;text-align:center;position:relative}
.ad-label{position:absolute;top:2px;left:4px;font-size:9px;color:var(--muted);
background:var(--bg);padding:0 3px;border-radius:3px;opacity:.7;z-index:1}
.ad-img{max-width:100%;border-radius:8px;display:block;margin:0 auto}
.ad-text-creative{background:#eef1f4;border-radius:8px;padding:12px;
font-size:13px;color:var(--muted);text-align:center;min-height:60px;
display:flex;align-items:center;justify-content:center}
.ad-header{border-bottom:1px solid var(--line);padding:6px 0}
.ad-footer{border-top:1px solid var(--line);padding:6px 0}
.badge.sponsored{background:#fff8e1;color:#b7791f;border:1px solid #f0dcae}
.sponsor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-top:16px}
.sponsor-card{display:flex;flex-direction:column;align-items:center;
text-align:center;padding:20px;text-decoration:none;color:var(--fg)}
.sponsor-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.08);text-decoration:none}
.sponsor-logo{max-width:140px;max-height:80px;object-fit:contain;margin-bottom:8px}
.sponsor-name-only{font-weight:700;font-size:16px;margin-bottom:8px}
+15
View File
@@ -0,0 +1,15 @@
{# Reusable ad slot partial. Usage: {% include "ads/_slot.html" with slot="sidebar" %} #}
{% if show_ads and ads.get(slot) %}
{% set ad = ads[slot] %}
<div class="ad-slot ad-{{ slot }}">
<span class="ad-label">Ad</span>
<a href="{{ url_for('ads.click', ad_id=ad.id) }}" target="_blank" rel="noopener sponsored">
{% if ad.creative_path %}
<img src="{{ url_for('listings.media', rel='ads/' + ad.creative_path) }}"
alt="{{ ad.alt_text or ad.advertiser_name }}" class="ad-img">
{% else %}
<div class="ad-text-creative">{{ ad.advertiser_name }}</div>
{% endif %}
</a>
</div>
{% endif %}
+7 -6
View File
@@ -4,15 +4,17 @@
<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.v2.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header class="site-header">
{% set slot = "header" %}{% include "ads/_slot.html" %}
<div class="wrap">
<a class="brand" href="{{ url_for('main.index') }}">Classifieds</a>
<nav class="nav">
<a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a>
<a href="{{ url_for('payments.pricing') }}">{{ _('Pricing') }}</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('listings.create') }}">{{ _('Post') }}</a>
<a href="{{ url_for('listings.mine') }}">{{ _('My listings') }}</a>
@@ -21,11 +23,9 @@
{{ _('Messages') }}
{% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %}
</a>
<a href="{{ url_for('payments.my_billing') }}">{{ _('Billing') }}</a>
<span class="hi">{{ current_user.display_name }}</span>
<form method="post" action="{{ url_for('auth.logout') }}" class="logout-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="link-btn">{{ _('Sign out') }}</button>
</form>
<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>
@@ -54,7 +54,8 @@
</main>
<footer class="site-footer">
<div class="wrap">© {{ 2025 }} Classifieds</div>
{% set slot = "footer" %}{% include "ads/_slot.html" %}
<div class="wrap">© {{ 2025 }} Classifieds · <a href="{{ url_for('ads.sponsor_directory') }}">{{ _('Sponsors') }}</a></div>
</footer>
</body>
</html>
+4
View File
@@ -34,6 +34,7 @@
</div>
<button class="btn" type="submit">{{ _('Apply') }}</button>
</form>
{% set slot = "sidebar" %}{% include "ads/_slot.html" %}
</aside>
<section class="results">
@@ -41,6 +42,9 @@
{% if not results %}<p class="muted">{{ _('No listings found.') }}</p>{% endif %}
<div class="grid">
{% for l, dist in results %}
{% if loop.index % 6 == 0 %}
{% set slot = "inline" %}{% include "ads/_slot.html" %}
{% endif %}
<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 %}
+1
View File
@@ -42,6 +42,7 @@
</div>
{% if is_owner %}
<a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a>
<a class="btn ghost" href="{{ url_for('payments.boost_listing', listing_id=listing.id) }}">{{ _('⚡ Boost') }}</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>
+1 -5
View File
@@ -66,11 +66,7 @@
var groups = document.querySelectorAll('.attr-group');
function sync(){
groups.forEach(function(g){
var visible = g.dataset.cat === sel.value;
g.style.display = visible ? 'block' : 'none';
g.querySelectorAll('input,select,textarea').forEach(function(el){
el.disabled = !visible;
});
g.style.display = (g.dataset.cat === sel.value) ? 'block' : 'none';
});
}
if (sel){ sel.addEventListener('change', sync); sync(); }
+10 -1
View File
@@ -7,6 +7,12 @@
<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 cap is not none and active >= cap %}
<div class="upgrade-prompt">
{{ _("You've reached your plan's listing limit.") }}
<a href="{{ url_for('payments.pricing') }}">{{ _('Upgrade for more') }}</a>
</div>
{% endif %}
{% if not items %}<p class="muted">{{ _('No listings yet.') }}</p>{% endif %}
<table class="list">
{% for l in items %}
@@ -16,7 +22,10 @@
<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>
<td>
<a href="{{ url_for('listings.edit', listing_id=l.id) }}">{{ _('Edit') }}</a>
· <a href="{{ url_for('payments.boost_listing', listing_id=l.id) }}">{{ _('Boost') }}</a>
</td>
</tr>
{% endfor %}
</table>
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}{{ _('My billing') }}{% endblock %}
{% block content %}
<div class="card billing-wrap">
<h2>{{ _('Billing') }}</h2>
<section class="billing-section">
<h3>{{ _('Current plan') }}</h3>
<div class="billing-plan">
<strong>{{ plan.name if plan else _('Free') }}</strong>
{% if sub and sub.is_active %}
<span class="badge ok">{{ sub.status }}</span>
{% if sub.current_period_end %}
<span class="muted small">
{{ _('Renews') if not sub.cancel_at_period_end else _('Ends') }}
{{ sub.current_period_end.strftime('%b %d, %Y') }}
</span>
{% endif %}
{% if sub.cancel_at_period_end %}
<span class="badge warn">{{ _('Canceling') }}</span>
{% endif %}
{% endif %}
</div>
<div class="billing-actions">
{% if stripe_enabled %}
{% if sub and sub.stripe_customer_id %}
<a class="btn" href="{{ url_for('payments.portal') }}">{{ _('Manage subscription') }}</a>
{% else %}
<a class="btn" href="{{ url_for('payments.pricing') }}">{{ _('Upgrade plan') }}</a>
{% endif %}
{% endif %}
</div>
</section>
{% if active_boosts %}
<section class="billing-section">
<h3>{{ _('Active boosts') }}</h3>
<table class="list">
{% for b in active_boosts %}
<tr>
<td>{{ b.listing.title[:50] }}</td>
<td><span class="badge cat">{{ b.type }}</span></td>
<td class="muted small">{{ _('Expires') }} {{ b.expires_at.strftime('%b %d, %Y') }}</td>
</tr>
{% endfor %}
</table>
</section>
{% endif %}
<section class="billing-section">
<h3>{{ _('Transaction history') }}</h3>
{% if not txns %}
<p class="muted">{{ _('No transactions yet.') }}</p>
{% else %}
<table class="list">
<tr><th>{{ _('Date') }}</th><th>{{ _('Type') }}</th><th>{{ _('Amount') }}</th><th>{{ _('Status') }}</th></tr>
{% for t in txns %}
<tr>
<td class="muted small">{{ t.created_at.strftime('%b %d, %Y') }}</td>
<td>{{ t.type }}</td>
<td>${{ "%.2f"|format(t.amount_cents / 100) }}</td>
<td><span class="badge {{ 'ok' if t.status == 'succeeded' else 'warn' }}">{{ t.status }}</span></td>
</tr>
{% endfor %}
</table>
{% endif %}
</section>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ _('Boost listing') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Boost listing') }}</h2>
<p class="muted">{{ listing.title[:60] }}</p>
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="boost-options">
{% for btype, info in boost_types.items() %}
<label class="boost-option {{ 'active-boost' if btype in active_boosts }}">
<input type="radio" name="boost_type" value="{{ btype }}"
{{ 'checked' if loop.first }} {{ 'disabled' if btype in active_boosts }}>
<div class="boost-info">
<div class="boost-label">{{ info.label }}
{% if btype in active_boosts %}<span class="badge ok">{{ _('Active') }}</span>{% endif %}
</div>
<div class="muted small">{{ info.days }} {{ _('days') }}</div>
</div>
<div class="boost-price">${{ "%.2f"|format(info.cents / 100) }}</div>
</label>
{% endfor %}
</div>
{% if stripe_enabled %}
<button class="btn" type="submit">{{ _('Continue to payment') }}</button>
{% else %}
<p class="muted">{{ _('Payments not configured yet.') }}</p>
{% endif %}
</form>
<p class="muted small" style="margin-top:12px">
<a href="{{ url_for('listings.detail', listing_id=listing.id) }}">&larr; {{ _('Back to listing') }}</a>
</p>
</div>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ _('Pricing') }}{% endblock %}
{% block content %}
<div class="pricing-wrap">
<div class="pricing-head">
<h1>{{ _('Simple, transparent pricing') }}</h1>
<p class="muted">{{ _('Upgrade anytime. Cancel anytime.') }}</p>
</div>
<div class="plan-grid">
{% for plan in plans %}
<div class="plan-card card {{ 'current' if current_plan and current_plan.id == plan.id }}">
{% if current_plan and current_plan.id == plan.id %}
<div class="plan-badge">{{ _('Current plan') }}</div>
{% endif %}
<div class="plan-name">{{ plan.name }}</div>
<div class="plan-price">
{% if plan.price_monthly_cents == 0 %}
<span class="price-amount">{{ _('Free') }}</span>
{% else %}
<span class="price-amount">${{ "%.0f"|format(plan.price_monthly_cents / 100) }}</span>
<span class="price-period">{{ _('/mo') }}</span>
{% endif %}
</div>
<ul class="plan-features">
<li>{{ _('%(n)s active listings', n=plan.limit('active_listings') or '∞') }}</li>
<li>{{ _('%(n)s-day listing life', n=plan.limit('listing_life_days')) }}</li>
<li>{{ _('%(n)s photos/listing', n=plan.limit('images_per_listing')) }}</li>
{% if plan.limit('ad_free') %}<li class="feat-yes">{{ _('Ad-free browsing') }}</li>{% endif %}
{% if plan.limit('verified_badge') %}<li class="feat-yes">{{ _('Verified badge') }}</li>{% endif %}
{% if plan.limit('storefront') %}<li class="feat-yes">{{ _('Storefront page') }}</li>{% endif %}
{% if plan.limit('scheduled_posting') %}<li class="feat-yes">{{ _('Scheduled posting') }}</li>{% endif %}
{% if plan.limit('bulk_csv') %}<li class="feat-yes">{{ _('Bulk CSV upload') }}</li>{% endif %}
{% if plan.limit('priority_support') %}<li class="feat-yes">{{ _('Priority support') }}</li>{% endif %}
</ul>
{% if plan.slug == 'free' %}
{% if not current_user.is_authenticated %}
<a class="btn plan-btn" href="{{ url_for('auth.register') }}">{{ _('Get started') }}</a>
{% else %}
<span class="btn plan-btn ghost disabled">{{ _('Your plan') }}</span>
{% endif %}
{% elif current_plan and current_plan.id == plan.id %}
<a class="btn plan-btn ghost" href="{{ url_for('payments.portal') }}">{{ _('Manage') }}</a>
{% elif stripe_enabled %}
<a class="btn plan-btn" href="{{ url_for('payments.subscribe', slug=plan.slug) }}">{{ _('Upgrade') }}</a>
{% else %}
<span class="btn plan-btn ghost disabled">{{ _('Coming soon') }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}{{ _('Payment successful') }}{% endblock %}
{% block content %}
<div class="card narrow" style="text-align:center;padding:48px 24px">
<div style="font-size:48px"></div>
<h2>{{ _('Payment successful!') }}</h2>
{% if mode == 'subscription' %}
<p>{{ _('Your subscription is now active. Enjoy your upgraded plan!') }}</p>
<a class="btn" href="{{ url_for('payments.my_billing') }}">{{ _('View billing') }}</a>
{% else %}
<p>{{ _('Your boost is now live on your listing.') }}</p>
<a class="btn" href="{{ url_for('listings.detail', listing_id=listing_id) }}">{{ _('View listing') }}</a>
{% endif %}
<br><br>
<a href="{{ url_for('listings.mine') }}" class="muted small">{{ _('My listings') }}</a>
</div>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ _('Sponsors') }}{% endblock %}
{% block content %}
<div class="card">
<h2>{{ _('Our Sponsors') }}</h2>
<p class="muted">{{ _('These businesses support our community classifieds.') }}</p>
{% if not sponsors %}
<p class="muted">{{ _('No sponsors yet.') }}</p>
{% endif %}
<div class="sponsor-grid">
{% for s in sponsors %}
<a class="sponsor-card card" href="{{ s.url }}" target="_blank" rel="noopener sponsored">
{% if s.logo_path %}
<img src="{{ url_for('listings.media', rel='sponsors/' + s.logo_path) }}"
alt="{{ s.name }}" class="sponsor-logo">
{% else %}
<div class="sponsor-name-only">{{ s.name }}</div>
{% endif %}
{% if s.tagline %}<p class="muted small">{{ s.tagline }}</p>{% endif %}
</a>
{% endfor %}
</div>
</div>
{% endblock %}
+2 -13
View File
@@ -1,21 +1,10 @@
"""RBAC decorators and shared request utilities."""
"""RBAC decorators. Never trust the client; gate on server side."""
from functools import wraps
from urllib.parse import urlparse
from flask import abort, request
from flask import abort
from flask_login import current_user
from app.models.enums import Role
def safe_referrer(fallback: str) -> str:
"""Return request.referrer only when it is same-origin; else fallback."""
ref = request.referrer
if ref:
parsed = urlparse(ref)
if parsed.netloc in ("", request.host):
return ref
return fallback
def role_required(*roles):
"""Require the current user to hold one of the given roles."""
def decorator(fn):
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Classifieds: nightly jobs (boost expiry + subscription reconcile)
After=mysql.service
[Service]
Type=oneshot
User=classifieds
Group=classifieds
WorkingDirectory=/opt/classifieds
Environment="PATH=/opt/classifieds/venv/bin"
EnvironmentFile=/opt/classifieds/.env
ExecStart=/opt/classifieds/venv/bin/flask expire-boosts
ExecStart=/opt/classifieds/venv/bin/flask expire-promoted-keywords
ExecStart=/opt/classifieds/venv/bin/flask reconcile-subscriptions
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Run classifieds nightly jobs at 2am
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
+1
View File
@@ -16,3 +16,4 @@ requests==2.32.3
PyMySQL==1.1.1
gunicorn==23.0.0
Pillow==10.4.0
stripe==10.12.0
+294
View File
@@ -186,6 +186,298 @@ def _phase3(app):
print("Phase 3 route renders: ok")
def _phase4(app):
"""Phase 4: payments — models, boost activation, expiry, tier sync,
webhook handler (mocked), route renders.
No live Stripe calls; tests the logic layer with mock data.
"""
from datetime import datetime, timedelta
from app.models.user import User
from app.models.plan import Plan
from app.models.listing import Listing
from app.models.payments import Subscription, Transaction, Boost
from app.models.enums import Role
from app.services import billing as bsvc
with app.app_context():
user = User.query.filter_by(email="t@example.com").first()
listing = Listing.query.filter_by(user_id=user.id).first()
pro_plan = Plan.query.filter_by(slug="pro").first()
# --- boost activation (no Stripe call) ---
boost = bsvc.activate_boost(
user_id=user.id,
listing_id=listing.id,
boost_type="featured",
stripe_payment_intent_id="pi_test_001",
amount_cents=499,
)
assert boost.is_active
assert boost.type == "featured"
listing_fresh = db.session.get(Listing, listing.id)
assert listing_fresh.is_featured
print("boost activation: ok")
# --- idempotency: same payment intent → no duplicate ---
boost2 = bsvc.activate_boost(
user_id=user.id, listing_id=listing.id,
boost_type="featured",
stripe_payment_intent_id="pi_test_001",
amount_cents=499,
)
assert Boost.query.filter_by(listing_id=listing.id,
type="featured").count() == 1
print("boost idempotency: ok")
# --- second boost type (bump) ---
bump = bsvc.activate_boost(
user_id=user.id, listing_id=listing.id,
boost_type="bump",
stripe_payment_intent_id="pi_test_002",
amount_cents=199,
)
assert bump.type == "bump"
assert listing_fresh.bump_at is not None
print("bump boost: ok")
# --- boost expiry sweep ---
boost.expires_at = datetime.utcnow() - timedelta(hours=1)
db.session.commit()
n = bsvc.expire_boosts()
assert n >= 1
listing_after = db.session.get(Listing, listing.id)
assert not listing_after.is_featured
print(f"boost expiry sweep: ok (cleared {n})")
# --- sync_subscription: simulate webhook updating local DB ---
# Build a fake Stripe subscription object
fake_stripe_sub = {
"id": "sub_test_001",
"customer": "cus_test_001",
"status": "active",
"cancel_at_period_end": False,
"current_period_end": int(
(datetime.utcnow() + timedelta(days=30)).timestamp()),
"items": {"data": [{"price": {"id": pro_plan.stripe_price_id or "price_test_pro"}}]},
}
# temporarily set stripe_price_id so sync works
original_price_id = pro_plan.stripe_price_id
pro_plan.stripe_price_id = "price_test_pro"
fake_stripe_sub["items"]["data"][0]["price"]["id"] = "price_test_pro"
db.session.commit()
sub = bsvc.sync_subscription(user.id, fake_stripe_sub)
assert sub.stripe_sub_id == "sub_test_001"
assert sub.status == "active"
user_fresh = db.session.get(User, user.id)
assert user_fresh.role == Role.subscriber
assert user_fresh.tier.slug == "pro"
print("sync_subscription → user upgraded to pro subscriber: ok")
# --- downgrade to free ---
bsvc.downgrade_to_free(user.id)
user_fresh = db.session.get(User, user.id)
assert user_fresh.role == Role.free
assert user_fresh.tier.slug == "free"
sub_fresh = Subscription.query.filter_by(user_id=user.id).first()
assert sub_fresh.status == "canceled"
print("downgrade_to_free: ok")
# restore
pro_plan.stripe_price_id = original_price_id
db.session.commit()
# --- Transaction record exists ---
txns = Transaction.query.filter_by(user_id=user.id).all()
assert len(txns) >= 2 # featured + bump boosts
print(f"transactions recorded: ok ({len(txns)} found)")
# --- route render checks ---
import re
c = app.test_client(); B = "https://localhost"
def csrf(h): return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', h).group(1)
# login
r = c.get("/auth/login", base_url=B)
tok = csrf(r.get_data(as_text=True))
c.post("/auth/login", base_url=B,
data={"csrf_token": tok, "email": "t@example.com",
"password": "NewPass456"},
headers={"Referer": B + "/auth/login"}, follow_redirects=True)
with app.app_context():
lid = Listing.query.filter_by(
user_id=User.query.filter_by(email="t@example.com").first().id
).first().id
for path, expect in [
("/pricing", 200),
("/my/billing", 200),
(f"/listings/{lid}/boost", 200),
]:
code = c.get(path, base_url=B).status_code
assert code == expect, f"{path} -> {code}"
print(f"{code} {path}")
print("Phase 4 route renders: ok")
def _phase5(app):
"""Phase 5: ads, sponsors, promoted search."""
from datetime import datetime, timedelta
from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.listing import Listing
from app.services.ads import (get_ad, record_impression, record_click,
promoted_listings, active_sponsors,
expire_promoted_keywords)
with app.app_context():
# --- create test ad ---
now = datetime.utcnow()
ad = Ad(
advertiser_name="Pho 99 Restaurant",
slot="sidebar",
target_url="https://pho99.example.com",
alt_text="Best pho in town",
lang="vi",
starts_at=now - timedelta(hours=1),
ends_at=now + timedelta(days=30),
is_active=True,
)
db.session.add(ad); db.session.commit()
print(f"ad created: id={ad.id} slot={ad.slot}")
# untargeted ad for fallback
ad2 = Ad(
advertiser_name="Generic Ads Inc",
slot="header",
target_url="https://generic.example.com",
starts_at=now - timedelta(hours=1),
ends_at=now + timedelta(days=30),
is_active=True,
)
db.session.add(ad2); db.session.commit()
# --- get_ad: targeted hit ---
found = get_ad("sidebar", lang="vi")
assert found and found.id == ad.id
print("get_ad targeted: ok")
# --- get_ad: slot with no match → None ---
miss = get_ad("footer", lang="vi", state="CA")
assert miss is None
print("get_ad miss: ok")
# --- get_ad: untargeted fallback ---
h = get_ad("header")
assert h and h.id == ad2.id
print("get_ad untargeted fallback: ok")
# --- impression tracking ---
before = ad.impressions
record_impression(ad.id)
db.session.expire(ad)
assert ad.impressions == before + 1
print("impression tracking: ok")
# --- click tracking ---
before_c = ad.clicks
record_click(ad.id)
db.session.expire(ad)
assert ad.clicks == before_c + 1
print("click tracking: ok")
# --- CTR ---
assert ad.ctr > 0
print(f"CTR: {ad.ctr}%: ok")
# --- sponsored ad suppressed for subscriber ---
# (tested via plan limit — ad_free=True for basic+)
from app.models.plan import Plan
basic = Plan.query.filter_by(slug="basic").first()
assert basic.limit("ad_free") is True
print("ad_free plan limit: ok")
# --- sponsor directory ---
sp = Sponsor(
name="Little Saigon Weekly",
url="https://lsweekly.example.com",
tagline="Your community newspaper",
tier="directory",
starts_at=now - timedelta(hours=1),
ends_at=now + timedelta(days=60),
is_active=True,
)
db.session.add(sp); db.session.commit()
sponsors = active_sponsors(tier="directory")
assert any(s.id == sp.id for s in sponsors)
print("sponsor directory: ok")
# --- category sponsor ---
from app.models.category import Category
jobs_cat = Category.query.filter_by(slug="jobs").first()
cat_sp = Sponsor(
name="VN Jobs Network",
url="https://vnjobs.example.com",
tier="category",
category_id=jobs_cat.id,
starts_at=now - timedelta(hours=1),
ends_at=now + timedelta(days=60),
is_active=True,
)
db.session.add(cat_sp); db.session.commit()
cat_sponsors = active_sponsors(tier="category",
category_id=jobs_cat.id)
assert any(s.id == cat_sp.id for s in cat_sponsors)
print("category sponsor: ok")
# --- promoted search ---
listing = Listing.query.first()
pk = PromotedKeyword(
keyword="pho",
listing_id=listing.id,
priority=10,
expires_at=now + timedelta(days=7),
)
db.session.add(pk); db.session.commit()
results = promoted_listings("pho")
assert listing in results
print("promoted search (keyword match): ok")
# accent-insensitive: "phở" should match keyword "pho"
results2 = promoted_listings("phở")
assert listing in results2
print("promoted search (accent-insensitive): ok")
# no match for unrelated keyword
results3 = promoted_listings("sofa")
assert listing not in results3
print("promoted search (no match): ok")
# --- expire promoted keywords ---
pk.expires_at = now - timedelta(hours=1)
db.session.commit()
n = expire_promoted_keywords()
assert n == 1
print(f"expire promoted keywords: ok (removed {n})")
# --- route render checks ---
import re
c = app.test_client(); B = "https://localhost"
for path, expect in [
("/sponsors", 200),
("/ads/1/click", 302), # redirects to target_url
]:
code = c.get(path, base_url=B).status_code
assert code == expect, f"{path} -> {code}"
print(f"{code} {path}")
print("Phase 5 route renders: ok")
def run():
app = create_app()
with app.app_context():
@@ -283,6 +575,8 @@ def run():
_phase2(app)
_phase3(app)
_phase4(app)
_phase5(app)
print("\nALL SMOKE CHECKS PASSED")