diff --git a/app/__init__.py b/app/__init__.py index 5dd93ad..e66f078 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/blueprints/admin/routes.py b/app/blueprints/admin/routes.py index 3192734..7d1ce9a 100644 --- a/app/blueprints/admin/routes.py +++ b/app/blueprints/admin/routes.py @@ -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( diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py index a1608fa..600b126 100644 --- a/app/blueprints/auth/routes.py +++ b/app/blueprints/auth/routes.py @@ -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("/"): diff --git a/app/blueprints/main/routes.py b/app/blueprints/main/routes.py index 3d54866..6f65ec6 100644 --- a/app/blueprints/main/routes.py +++ b/app/blueprints/main/routes.py @@ -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) diff --git a/app/models/ads.py b/app/models/ads.py index 93ddee8..f2cbad7 100644 --- a/app/models/ads.py +++ b/app/models/ads.py @@ -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"" diff --git a/app/models/audit.py b/app/models/audit.py index 47cf477..608c11f 100644 --- a/app/models/audit.py +++ b/app/models/audit.py @@ -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")) diff --git a/app/models/category.py b/app/models/category.py index 48cf49f..6798006 100644 --- a/app/models/category.py +++ b/app/models/category.py @@ -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") diff --git a/app/models/favorite.py b/app/models/favorite.py index e8d7f0c..aab5d2f 100644 --- a/app/models/favorite.py +++ b/app/models/favorite.py @@ -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")) diff --git a/app/models/listing.py b/app/models/listing.py index 45e655d..fadc427 100644 --- a/app/models/listing.py +++ b/app/models/listing.py @@ -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") diff --git a/app/models/messaging.py b/app/models/messaging.py index 7ede49c..4fc32ca 100644 --- a/app/models/messaging.py +++ b/app/models/messaging.py @@ -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"" diff --git a/app/models/payments.py b/app/models/payments.py index 388ed2b..3d2d1f5 100644 --- a/app/models/payments.py +++ b/app/models/payments.py @@ -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"" diff --git a/app/models/plan.py b/app/models/plan.py index b2096bd..7a9c797 100644 --- a/app/models/plan.py +++ b/app/models/plan.py @@ -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") diff --git a/app/models/report.py b/app/models/report.py index 798902d..0636677 100644 --- a/app/models/report.py +++ b/app/models/report.py @@ -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")) diff --git a/app/models/review.py b/app/models/review.py index 83ade03..62b318e 100644 --- a/app/models/review.py +++ b/app/models/review.py @@ -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) # 1–5 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")) diff --git a/app/models/setting.py b/app/models/setting.py index 985c5fa..1972480 100644 --- a/app/models/setting.py +++ b/app/models/setting.py @@ -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"" diff --git a/app/models/trust.py b/app/models/trust.py index 69e7953..aef7ff0 100644 --- a/app/models/trust.py +++ b/app/models/trust.py @@ -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") diff --git a/app/models/user.py b/app/models/user.py index e89cfde..c92df97 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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") diff --git a/app/services/admin_dashboard.py b/app/services/admin_dashboard.py index 85d62c0..33879f7 100644 --- a/app/services/admin_dashboard.py +++ b/app/services/admin_dashboard.py @@ -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, diff --git a/app/services/ads.py b/app/services/ads.py index 3c2120c..cfeb4b3 100644 --- a/app/services/ads.py +++ b/app/services/ads.py @@ -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) diff --git a/app/services/billing.py b/app/services/billing.py index c551dbb..bdfe309 100644 --- a/app/services/billing.py +++ b/app/services/billing.py @@ -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: diff --git a/app/services/contact.py b/app/services/contact.py index dde07c3..76d2bba 100644 --- a/app/services/contact.py +++ b/app/services/contact.py @@ -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"(? 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 diff --git a/app/services/expiry_notifications.py b/app/services/expiry_notifications.py index 43f869e..4e02028 100644 --- a/app/services/expiry_notifications.py +++ b/app/services/expiry_notifications.py @@ -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, diff --git a/app/services/geo.py b/app/services/geo.py index 138fe03..2603c34 100644 --- a/app/services/geo.py +++ b/app/services/geo.py @@ -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 diff --git a/app/services/listings.py b/app/services/listings.py index 0a338e0..a4ce968 100644 --- a/app/services/listings.py +++ b/app/services/listings.py @@ -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, diff --git a/app/services/messaging.py b/app/services/messaging.py index 7c6992f..8ff19a9 100644 --- a/app/services/messaging.py +++ b/app/services/messaging.py @@ -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: diff --git a/app/static/style.v2.css b/app/static/style.v2.css deleted file mode 100644 index e755b46..0000000 --- a/app/static/style.v2.css +++ /dev/null @@ -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} diff --git a/app/utils/time.py b/app/utils/time.py new file mode 100644 index 0000000..e88c78c --- /dev/null +++ b/app/utils/time.py @@ -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)