Jul 13 - Optimize code
This commit is contained in:
+21
-1
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("/"):
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user