Jul 13 - Optimize code

This commit is contained in:
2026-07-13 16:52:00 -04:00
parent 936d860342
commit 00a9ba7770
27 changed files with 141 additions and 208 deletions
+21 -1
View File
@@ -45,7 +45,7 @@ def _init_login(app):
@login_manager.user_loader
def load_user(user_id):
from app.models.user import User
return User.query.get(int(user_id))
return db.session.get(User, int(user_id))
def _init_babel(app):
@@ -122,6 +122,26 @@ def _register_context(app):
def _register_hooks(app):
@app.before_request
def enforce_active_account():
# A ban/suspension applied mid-session must take effect on the next
# request, not just at next login. Flask-Login only checks is_active
# when logging in, so we re-check the live status here.
if not current_user.is_authenticated:
return None
if getattr(current_user, "is_active", True):
return None
from flask import flash, redirect, url_for
from flask_babel import gettext as _
from flask_login import logout_user
ep = request.endpoint or ""
if ep in ("static", "auth.logout", "auth.login"):
return None
logout_user()
flash(_("Your account is no longer active. Please contact support."),
"warning")
return redirect(url_for("auth.login"))
@app.before_request
def check_maintenance():
from app.services.settings import get_setting
+7 -6
View File
@@ -1,6 +1,7 @@
"""Admin backend: dashboard KPIs + user management. Admin-only."""
import json
from datetime import datetime, timedelta
from app.utils.time import utcnow
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
from flask_login import current_user
from flask_babel import gettext as _
@@ -443,9 +444,9 @@ def admin_ad_new():
lang=request.form.get("lang", "").strip() or None,
geo_state=request.form.get("geo_state", "").strip().upper() or None,
starts_at=_parse_dt(request.form.get("starts_at"),
datetime.utcnow()),
utcnow()),
ends_at=_parse_dt(request.form.get("ends_at"),
datetime.utcnow() + timedelta(days=30)),
utcnow() + timedelta(days=30)),
is_active=request.form.get("is_active") == "on",
)
db.session.add(ad)
@@ -540,9 +541,9 @@ def admin_sponsor_new():
logo_path=request.form.get("logo_path", "").strip() or None,
tier=request.form.get("tier", "directory"),
category_id=cat_id,
starts_at=_parse_dt(request.form.get("starts_at"), datetime.utcnow()),
starts_at=_parse_dt(request.form.get("starts_at"), utcnow()),
ends_at=_parse_dt(request.form.get("ends_at"),
datetime.utcnow() + timedelta(days=30)),
utcnow() + timedelta(days=30)),
is_active=request.form.get("is_active") == "on",
)
db.session.add(sp)
@@ -620,7 +621,7 @@ def admin_promoted_keyword_new():
keyword = request.form.get("keyword", "").strip()
priority = request.form.get("priority", 0, type=int)
expires_at = _parse_dt(request.form.get("expires_at"),
datetime.utcnow() + timedelta(days=7))
utcnow() + timedelta(days=7))
listing = db.session.get(Listing, listing_id) if listing_id else None
if not listing:
error = "Listing not found."
@@ -662,7 +663,7 @@ def admin_promoted_keyword_delete(pk_id):
@admin_bp.route("/admin/analytics")
@admin_required
def analytics():
since_30 = datetime.utcnow() - timedelta(days=30)
since_30 = utcnow() - timedelta(days=30)
# signups per day (last 30 days)
signups_raw = (db.session.query(
+2 -1
View File
@@ -1,5 +1,6 @@
"""Auth flows: register, login, logout, email verification, password reset."""
from datetime import datetime
from app.utils.time import utcnow
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, current_app, abort)
from flask_login import login_user, logout_user, login_required, current_user
@@ -82,7 +83,7 @@ def login():
flash(_("This account is suspended."), "danger")
return render_template("auth/login.html", form=form)
login_user(user, remember=form.remember.data)
user.last_login_at = datetime.utcnow()
user.last_login_at = utcnow()
db.session.commit()
nxt = request.args.get("next")
if nxt and nxt.startswith("/"):
+4 -3
View File
@@ -1,5 +1,6 @@
"""Main blueprint: landing page, health check, SEO files."""
from datetime import datetime
from app.utils.time import utcnow
from flask import Blueprint, render_template, jsonify, Response, request
main_bp = Blueprint("main", __name__)
@@ -54,7 +55,7 @@ def sitemap():
# active listings (last 500 by bump/created for crawl budget)
listings = (Listing.query
.filter(Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow())
Listing.expires_at > utcnow())
.order_by(Listing.created_at.desc())
.limit(500).all())
for l in listings:
@@ -82,7 +83,7 @@ def category_landing(slug):
listings = (Listing.query
.filter(Listing.category_id == cat.id,
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow())
Listing.expires_at > utcnow())
.order_by(Listing.created_at.desc())
.limit(20).all())
return render_template("main/category_landing.html", cat=cat, listings=listings)
@@ -97,7 +98,7 @@ def state_landing(state):
listings = (Listing.query
.filter(Listing.state == state,
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow())
Listing.expires_at > utcnow())
.order_by(Listing.created_at.desc())
.limit(20).all())
return render_template("main/state_landing.html", state=state, listings=listings)
+9 -8
View File
@@ -10,6 +10,7 @@ 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.utils.time import utcnow
from app.extensions import db
@@ -37,9 +38,9 @@ class Ad(db.Model):
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=utcnow, nullable=False)
@property
def ctr(self):
@@ -49,7 +50,7 @@ class Ad(db.Model):
@property
def is_running(self):
now = datetime.utcnow()
now = utcnow()
return self.is_active and self.starts_at <= now <= self.ends_at
def __repr__(self):
@@ -75,14 +76,14 @@ class Sponsor(db.Model):
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
category = db.relationship("Category",
backref=db.backref("sponsors", lazy="selectin"))
@property
def is_running(self):
now = datetime.utcnow()
now = utcnow()
return self.is_active and self.starts_at <= now <= self.ends_at
def __repr__(self):
@@ -103,7 +104,7 @@ class PromotedKeyword(db.Model):
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing",
backref=db.backref("promoted_keywords",
@@ -111,7 +112,7 @@ class PromotedKeyword(db.Model):
@property
def is_active(self):
return self.expires_at > datetime.utcnow()
return self.expires_at > utcnow()
def __repr__(self):
return f"<PromotedKeyword '{self.keyword}' L{self.listing_id}>"
+2 -1
View File
@@ -1,5 +1,6 @@
"""Append-only audit trail for admin write actions."""
from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON
from app.extensions import db
@@ -16,7 +17,7 @@ class AuditLog(db.Model):
target_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
nullable=True, index=True)
meta = db.Column(JSON, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
actor = db.relationship("User", backref=db.backref("audit_logs", lazy="dynamic"))
+4 -3
View File
@@ -14,6 +14,7 @@ Supported types: text, number, select, bool. `hot:true` marks a field whose
value is denormalized onto an indexed Listing column for fast filtering.
"""
from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON
from app.extensions import db
@@ -33,9 +34,9 @@ class Category(db.Model):
sponsor_id = db.Column(db.BigInteger, nullable=True) # FK wired in Phase 5
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=utcnow, nullable=False)
children = db.relationship("Category", backref=db.backref("parent",
remote_side=[id]), lazy="selectin")
+2 -1
View File
@@ -1,5 +1,6 @@
"""Favorite (saved listing). One row per user+listing pair."""
from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db
@@ -15,7 +16,7 @@ class Favorite(db.Model):
db.ForeignKey("users.id"), nullable=False, index=True)
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("listings.id"), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User",
backref=db.backref("favorites", lazy="dynamic"))
+6 -5
View File
@@ -10,6 +10,7 @@ Portability notes (see README "MySQL upgrades"):
MySQL add a FULLTEXT(title, body) index and switch to MATCH ... AGAINST.
"""
from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON, Index
from app.extensions import db
from app.models.enums import ListingStatus, Lang
@@ -55,9 +56,9 @@ class Listing(db.Model):
view_count = 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)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=utcnow, nullable=False)
user = db.relationship("User", backref=db.backref("listings", lazy="dynamic"))
category = db.relationship("Category", back_populates="listings")
@@ -73,7 +74,7 @@ class Listing(db.Model):
@property
def is_live(self):
return (self.status == ListingStatus.active
and self.expires_at > datetime.utcnow())
and self.expires_at > utcnow())
@property
def price_display(self):
@@ -101,7 +102,7 @@ class ListingImage(db.Model):
sort_order = db.Column(db.Integer, nullable=False, default=0)
width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing", back_populates="images")
+4 -3
View File
@@ -5,6 +5,7 @@ The seller is always listing.user; buyer is any other authenticated user.
Messages are append-only; soft-delete not needed at Phase 3.
"""
from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db
@@ -23,7 +24,7 @@ class Conversation(db.Model):
seller_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False, index=True)
last_message_at = db.Column(db.DateTime, nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing",
backref=db.backref("conversations", lazy="dynamic"))
@@ -58,7 +59,7 @@ class Message(db.Model):
db.ForeignKey("users.id"), nullable=False)
body = db.Column(db.Text, nullable=False)
read_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
conversation = db.relationship("Conversation", back_populates="messages")
sender = db.relationship("User",
@@ -70,7 +71,7 @@ class Message(db.Model):
def mark_read(self):
if self.read_at is None:
self.read_at = datetime.utcnow()
self.read_at = utcnow()
def __repr__(self):
return f"<Message {self.id} C{self.conversation_id}>"
+7 -6
View File
@@ -5,6 +5,7 @@ and are reconciled via webhooks + nightly job.
Money stored as integer cents throughout.
"""
from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON
from app.extensions import db
@@ -27,9 +28,9 @@ class Subscription(db.Model):
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=utcnow, nullable=False)
user = db.relationship("User",
backref=db.backref("subscription", uselist=False))
@@ -59,7 +60,7 @@ class Transaction(db.Model):
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User",
backref=db.backref("transactions", lazy="dynamic"))
@@ -92,7 +93,7 @@ class Boost(db.Model):
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing",
backref=db.backref("boosts", lazy="selectin"))
@@ -102,7 +103,7 @@ class Boost(db.Model):
@property
def is_active(self):
return self.expires_at > datetime.utcnow()
return self.expires_at > utcnow()
def __repr__(self):
return f"<Boost {self.type} L{self.listing_id} exp={self.expires_at.date()}>"
+4 -3
View File
@@ -1,5 +1,6 @@
"""Plan model. Tier limits live in `config` JSON, editable in admin without redeploy."""
from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON
from app.extensions import db
@@ -16,9 +17,9 @@ class Plan(db.Model):
is_active = db.Column(db.Boolean, nullable=False, default=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=utcnow, nullable=False)
users = db.relationship("User", back_populates="tier", lazy="dynamic")
+2 -1
View File
@@ -1,5 +1,6 @@
"""User-submitted reports against listings (spam/abuse moderation)."""
from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db
from app.models.enums import ReportReason
@@ -15,7 +16,7 @@ class Report(db.Model):
db.ForeignKey("users.id"), nullable=False, index=True)
reason = db.Column(db.Enum(ReportReason), nullable=False)
note = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing", backref=db.backref("reports", lazy="dynamic"))
reporter = db.relationship("User", backref=db.backref("reports_filed", lazy="dynamic"))
+2 -1
View File
@@ -1,5 +1,6 @@
"""Seller reviews — left by buyers after a listing is marked sold."""
from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db
@@ -16,7 +17,7 @@ class Review(db.Model):
db.ForeignKey("users.id"), nullable=False, index=True)
rating = db.Column(db.SmallInteger, nullable=False) # 15
body = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing",
backref=db.backref("reviews", lazy="dynamic"))
+3 -2
View File
@@ -1,5 +1,6 @@
"""Admin-editable runtime settings (registration_open, flag_threshold, etc.)."""
from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON
from app.extensions import db
@@ -9,8 +10,8 @@ class Setting(db.Model):
key = db.Column(db.String(80), primary_key=True)
value = db.Column(JSON, nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=utcnow, nullable=False)
def __repr__(self):
return f"<Setting {self.key}>"
+2 -1
View File
@@ -1,5 +1,6 @@
"""TrustEvent model. Append-only events that adjust a user's trust score."""
from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db
from app.models.enums import TrustEventType
@@ -11,7 +12,7 @@ class TrustEvent(db.Model):
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False, index=True)
type = db.Column(db.Enum(TrustEventType), nullable=False)
delta = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User", back_populates="trust_events")
+4 -3
View File
@@ -1,5 +1,6 @@
"""User model. Argon2 password hashing, role/trust fields, Flask-Login mixin."""
from datetime import datetime
from app.utils.time import utcnow
from flask_login import UserMixin
from app.extensions import db
from app.models.enums import Role, UserStatus, TrustTier
@@ -28,9 +29,9 @@ class User(UserMixin, db.Model):
email_verified = db.Column(db.Boolean, nullable=False, default=False)
last_login_at = db.Column(db.DateTime, nullable=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)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=utcnow, nullable=False)
trust_events = db.relationship("TrustEvent", back_populates="user",
lazy="dynamic", cascade="all, delete-orphan")
+4 -3
View File
@@ -1,5 +1,6 @@
"""Admin dashboard KPI queries. Money returned as integer cents."""
from datetime import datetime, timedelta
from app.utils.time import utcnow
from app.extensions import db
from app.models.user import User
from app.models.listing import Listing
@@ -13,12 +14,12 @@ _ACTIVE_SUB_STATUSES = ("active", "trialing")
def active_listings_count():
return Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(),
Listing.expires_at > utcnow(),
).count()
def new_users_count(days):
since = datetime.utcnow() - timedelta(days=days)
since = utcnow() - timedelta(days=days)
return User.query.filter(User.created_at >= since).count()
@@ -32,7 +33,7 @@ def mrr_cents():
def revenue_30d_cents():
"""Subscription + boost transactions in the last 30 days."""
since = datetime.utcnow() - timedelta(days=30)
since = utcnow() - timedelta(days=30)
return (db.session.query(db.func.coalesce(db.func.sum(Transaction.amount_cents), 0))
.filter(Transaction.type.in_(("subscription", "boost")),
Transaction.created_at >= since,
+5 -4
View File
@@ -20,6 +20,7 @@ Sponsors:
"""
import random
from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db
from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.listing import Listing
@@ -28,7 +29,7 @@ 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()
now = utcnow()
base = Ad.query.filter(
Ad.slot == slot,
Ad.is_active == True,
@@ -76,7 +77,7 @@ def promoted_listings(keyword: str) -> list:
if not keyword:
return []
norm = normalize(keyword)
now = datetime.utcnow()
now = utcnow()
rows = (PromotedKeyword.query
.filter(PromotedKeyword.expires_at > now)
.order_by(PromotedKeyword.priority.desc())
@@ -94,7 +95,7 @@ def promoted_listings(keyword: str) -> list:
def active_sponsors(tier: str = None, category_id: int = None) -> list:
"""Return running sponsors, optionally filtered by tier and category."""
now = datetime.utcnow()
now = utcnow()
q = Sponsor.query.filter(
Sponsor.is_active == True,
Sponsor.starts_at <= now,
@@ -109,7 +110,7 @@ def active_sponsors(tier: str = None, category_id: int = None) -> list:
def expire_promoted_keywords() -> int:
"""Remove expired promoted keyword rows. Returns count."""
now = datetime.utcnow()
now = utcnow()
expired = PromotedKeyword.query.filter(
PromotedKeyword.expires_at <= now).all()
n = len(expired)
+4 -3
View File
@@ -9,6 +9,7 @@ Key rules:
- stripe_enabled() guard lets the app run with no keys in dev.
"""
from datetime import datetime, timedelta
from app.utils.time import utcnow
import stripe
from flask import current_app
from app.extensions import db
@@ -140,7 +141,7 @@ def activate_boost(user_id: int, listing_id: int, boost_type: str,
db.session.add(txn)
db.session.flush()
expires_at = datetime.utcnow() + timedelta(days=info["days"])
expires_at = 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)
@@ -152,7 +153,7 @@ def activate_boost(user_id: int, listing_id: int, boost_type: str,
if boost_type == "featured":
listing.is_featured = True
if boost_type == "bump":
listing.bump_at = datetime.utcnow()
listing.bump_at = utcnow()
db.session.commit()
return boost
@@ -307,7 +308,7 @@ def _record_sub_transaction(user_id, session):
def expire_boosts() -> int:
"""Clear expired boosts and revert listing effects. Returns count."""
now = datetime.utcnow()
now = utcnow()
expired = Boost.query.filter(Boost.expires_at <= now).all()
n = 0
for boost in expired:
+7 -4
View File
@@ -10,14 +10,17 @@ moderators can spot scraping attempts.
"""
import re
from datetime import datetime
from app.utils.time import utcnow
from app.models.enums import TrustTier
from app.services.settings import get_setting
# patterns
_PHONE_RE = re.compile(
r"(\+?1[\s\-.]?)?"
r"(\(?\d{3}\)?[\s\-.])"
r"\d{3}[\s\-.]\d{4}"
r"(?<!\d)" # not mid-way through a longer digit run
r"(?:\+?1[\s\-.]?)?" # optional country code
r"\(?\d{3}\)?[\s\-.]?" # area code — separators now optional
r"\d{3}[\s\-.]?\d{4}" # so 5551234567 is caught, not just 555-123-4567
r"(?!\d)"
)
_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)
@@ -32,7 +35,7 @@ def contact_revealed(user) -> bool:
return False
gate_days = get_setting("new_user_trust_gate_days", 0)
if gate_days and user.created_at:
if (datetime.utcnow() - user.created_at).days < gate_days:
if (utcnow() - user.created_at).days < gate_days:
return False
return True
+3 -2
View File
@@ -11,6 +11,7 @@ use the existing `attributes` JSON and a private `_notified` sub-key so no migra
is required. The key is prefixed with `_` to avoid colliding with user attributes.
"""
from datetime import datetime, timedelta
from app.utils.time import utcnow
from flask import current_app
from app.extensions import db
from app.models.listing import Listing
@@ -25,13 +26,13 @@ def _already_notified(listing, key: str) -> bool:
def _mark_notified(listing, key: str):
attrs = dict(listing.attributes or {})
attrs[key] = datetime.utcnow().isoformat()
attrs[key] = utcnow().isoformat()
listing.attributes = attrs
def warn_expiring(days: int = 3) -> int:
"""Send warning emails for listings expiring within `days` days. Returns count."""
now = datetime.utcnow()
now = utcnow()
window_end = now + timedelta(days=days)
soon = (Listing.query
.filter(Listing.status == ListingStatus.active,
+2 -1
View File
@@ -4,6 +4,7 @@ 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
@@ -11,7 +12,7 @@ EARTH_MI = 3958.7613 # mean earth radius, miles
def geocode_zip(zip_code):
"""Return (lat, lng, city, state, metro) or None."""
row = ZipGeo.query.get((zip_code or "").strip())
row = db.session.get(ZipGeo, (zip_code or "").strip())
if row is None:
return None
return row.lat, row.lng, row.city, row.state, row.metro
+15 -5
View File
@@ -1,7 +1,9 @@
"""Listing business logic: creation/edit with tier enforcement, the
browse/search/radius query builder, and the expiry sweep.
"""
import re
from datetime import datetime, timedelta
from app.utils.time import utcnow
from app.extensions import db
from app.models.listing import Listing
from app.models.enums import ListingStatus, Lang
@@ -30,7 +32,7 @@ def active_count(user):
return Listing.query.filter(
Listing.user_id == user.id,
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(),
Listing.expires_at > utcnow(),
).count()
@@ -55,7 +57,15 @@ def _blocklist_hit(title, body):
if not blocklist:
return False
hay = normalize(f"{title} {body}")
return any(normalize(term) in hay for term in blocklist if term)
for term in blocklist:
if not term:
continue
t = normalize(term).strip()
# word-boundary match so "ass" doesn't flag "class"; multi-word
# phrases still match as an internal-boundaried run.
if t and re.search(rf"\b{re.escape(t)}\b", hay):
return True
return False
# --- create / update ---
@@ -78,7 +88,7 @@ def create_listing(user, category, *, title, body, lang, price_cents,
price_cents=price_cents,
attributes=cleaned,
status=ListingStatus.active,
expires_at=datetime.utcnow() + timedelta(days=_life_days(user)),
expires_at=utcnow() + timedelta(days=_life_days(user)),
**hot_values(cleaned),
)
if _blocklist_hit(title, body):
@@ -125,7 +135,7 @@ def browse_query(*, category_id=None, q=None, state=None, min_price=None,
"""Base query of live listings with optional filters (no radius)."""
query = Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(),
Listing.expires_at > utcnow(),
)
if category_id:
query = query.filter(Listing.category_id == category_id)
@@ -186,7 +196,7 @@ def search_with_radius(base_query, lat, lng, radius_mi):
# --- expiry sweep (scheduler / CLI) ---
def expire_due_listings():
"""Flip active listings past expires_at to expired. Returns count."""
now = datetime.utcnow()
now = utcnow()
due = Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at <= now,
+2 -1
View File
@@ -8,6 +8,7 @@ Rules:
for now sent inline fast enough at low volume).
"""
from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy.exc import IntegrityError
from flask import current_app
from app.extensions import db
@@ -67,7 +68,7 @@ def send_message(conversation, sender, body: str) -> Message:
body=body,
)
db.session.add(msg)
conversation.last_message_at = datetime.utcnow()
conversation.last_message_at = utcnow()
db.session.commit()
if flagged:
-136
View File
@@ -1,136 +0,0 @@
:root{
--bg:#f6f7f9; --fg:#1c2430; --muted:#6b7785; --line:#e3e7ec;
--brand:#1f6feb; --brand-d:#1a5fd0; --ok:#1f9d55; --warn:#b7791f;
--danger:#d64545; --info:#2b6cb0; --card:#fff;
}
*{box-sizing:border-box}
body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
background:var(--bg);color:var(--fg)}
.wrap{max-width:960px;margin:0 auto;padding:0 16px}
a{color:var(--brand);text-decoration:none}
a:hover{text-decoration:underline}
.site-header{background:var(--card);border-bottom:1px solid var(--line)}
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;height:56px}
.brand{font-weight:700;font-size:18px;color:var(--fg)}
.nav{display:flex;align-items:center;gap:14px}
.nav .hi{color:var(--muted)}
.langs{display:flex;gap:6px;margin-left:8px}
.langs a{font-size:12px;color:var(--muted);border:1px solid var(--line);
padding:2px 6px;border-radius:4px}
.langs a.on{background:var(--brand);color:#fff;border-color:var(--brand)}
.btn{display:inline-block;background:var(--brand);color:#fff;border:0;
padding:9px 16px;border-radius:8px;cursor:pointer;font-size:15px}
.btn:hover{background:var(--brand-d);text-decoration:none}
.btn-lg{padding:12px 22px;font-size:17px}
main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%}
.hero{text-align:center;padding:48px 0}
.hero h1{font-size:32px;margin:0 0 8px}
.hero p{color:var(--muted);margin:0 0 24px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:24px}
.card.narrow{max-width:420px;margin:0 auto}
.card h2{margin-top:0}
.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}
.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}
.errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px}
.muted{color:var(--muted);font-size:14px;margin-top:14px}
.flashes{margin-bottom:18px;display:flex;flex-direction:column;gap:8px}
.flash{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}
.flash-success{background:#e8f6ee;border-color:#bfe3cd;color:var(--ok)}
.flash-danger{background:#fdeaea;border-color:#f4c4c4;color:var(--danger)}
.flash-warning{background:#fdf4e3;border-color:#f0dcae;color:var(--warn)}
.flash-info{background:#e9f1fb;border-color:#cadcf3;color:var(--info)}
.site-footer{border-top:1px solid var(--line);color:var(--muted);
font-size:13px;padding:18px 0}
.cf-turnstile{margin:0 0 14px}
/* --- Phase 2: listings --- */
.btn.ghost{background:#fff;color:var(--brand);border:1px solid var(--brand)}
.btn.danger{background:var(--danger)}
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-wide{max-width:640px;margin:0 auto}
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start;width:100%}
.browse .filters{min-width:0}
.browse .results{min-width:0;width:100%}
.filters h3{margin-top:0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,220px));gap:14px}
.tile{padding:0;overflow:hidden;display:block;color:var(--fg);width:100%;max-width:220px}
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)}
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4}
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
.tile-body{padding:10px}
.tile-title{font-weight:600;font-size:14px;line-height:1.3;margin-bottom:4px}
.tile-meta{display:flex;gap:8px;align-items:center}
.price{color:var(--ok);font-weight:700}
.price.big{font-size:24px}
.small{font-size:12px}
.badge{display:inline-block;font-size:11px;padding:2px 7px;border-radius:10px;background:#eef1f4;color:var(--muted)}
.badge.ok{background:#e8f6ee;color:var(--ok)}
.badge.warn{background:#fdf4e3;color:var(--warn)}
.badge.cat{background:#e9f1fb;color:var(--info)}
.pager{margin-top:18px;display:flex;gap:16px}
.detail{display:grid;grid-template-columns:1fr 280px;gap:20px;align-items:start}
.detail-meta{display:flex;gap:10px;align-items:center;margin:8px 0}
.gallery{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}
.gallery img{max-width:220px;border-radius:8px}
.body{margin:14px 0;line-height:1.6}
.attrs{border-collapse:collapse;width:100%}
.attrs th,.attrs td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)}
.attrs th{color:var(--muted);font-weight:600;width:40%}
.detail-side .btn{display:block;margin-bottom:8px;text-align:center}
.detail-side form{margin:0}
.seller{margin-bottom:14px}
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.mine-head .btn{margin-left:auto}
table.list{width:100%;border-collapse:collapse}
table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.thumbs{display:flex;gap:10px;flex-wrap:wrap}
.thumb-wrap{position:relative}
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px}
.thumb-wrap form{position:absolute;top:2px;right:2px;margin:0}
@media(max-width:760px){.browse,.detail{grid-template-columns:1fr}}
/* --- Phase 3: messaging + favorites --- */
.msg-link{position:relative}
.badge-count{display:inline-flex;align-items:center;justify-content:center;
background:var(--danger);color:#fff;border-radius:10px;font-size:11px;
min-width:18px;height:18px;padding:0 5px;font-weight:700;vertical-align:middle}
.badge-count.sm{min-width:16px;height:16px;font-size:10px}
.inbox-head{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.inbox-head h2{margin:0}
.conv-list{list-style:none;padding:0;margin:0}
.conv-row{border-bottom:1px solid var(--line);padding:12px 0}
.conv-row a{display:block;text-decoration:none;color:var(--fg)}
.conv-row a:hover{background:var(--bg);border-radius:8px;padding:4px;margin:-4px}
.conv-row.unread .conv-who{font-weight:700}
.conv-meta{display:flex;align-items:center;gap:8px;margin-bottom:2px}
.conv-who{flex:1}
.conv-time{margin-left:auto}
.conv-listing,.conv-preview{margin-top:2px}
.conv-wrap{display:flex;flex-direction:column;gap:16px;max-width:680px;margin:0 auto}
.conv-header h3{margin:8px 0 4px}
.thread{display:flex;flex-direction:column;gap:12px}
.bubble{max-width:80%;padding:12px 14px;border-radius:16px;line-height:1.5}
.bubble.mine{align-self:flex-end;background:var(--brand);color:#fff;border-bottom-right-radius:4px}
.bubble.theirs{align-self:flex-start;background:var(--card);border:1px solid var(--line);border-bottom-left-radius:4px}
.bubble-meta{margin-top:6px;font-size:11px;opacity:.7}
.bubble.mine .bubble-meta{text-align:right}
.reply-box{padding:16px}
.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px;
padding:8px 12px;margin-top:8px}
.link-btn{background:none;border:none;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:none}
.link-btn:hover{text-decoration:underline}
.logout-form{display:inline}
+14
View File
@@ -0,0 +1,14 @@
"""Time helpers.
`datetime.utcnow()` is deprecated on Python 3.12+. This helper preserves the
existing *naive UTC* semantics the codebase relies on (DB columns are naive
`DateTime`, and comparisons assume naive UTC) while avoiding the deprecation:
we build an aware UTC datetime and strip the tzinfo, yielding the exact same
value `datetime.utcnow()` used to return.
"""
from datetime import datetime, timezone
def utcnow() -> datetime:
"""Current UTC time as a naive datetime (tzinfo stripped)."""
return datetime.now(timezone.utc).replace(tzinfo=None)