06/15 Phase 4 + 5 codes
This commit is contained in:
@@ -7,7 +7,10 @@ from app.models.listing import Listing, ListingImage
|
||||
from app.models.geo import ZipGeo, Metro
|
||||
from app.models.messaging import Conversation, Message
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.payments import Subscription, Transaction, Boost
|
||||
from app.models.ads import Ad, Sponsor, PromotedKeyword
|
||||
|
||||
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
|
||||
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
|
||||
"Favorite"]
|
||||
"Favorite", "Subscription", "Transaction", "Boost",
|
||||
"Ad", "Sponsor", "PromotedKeyword"]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Ad and Sponsor models.
|
||||
|
||||
Ad: internal ad server. Slots: header, sidebar, inline, footer.
|
||||
Targeting: lang (nullable = all), geo_state (nullable = all).
|
||||
Impression/click counters on the model; Redis batching added in Phase 7.
|
||||
|
||||
Sponsor: paid directory listing + optional category sponsorship.
|
||||
Category sponsor FK wired to categories.sponsor_id (set separately).
|
||||
|
||||
PromotedKeyword: pinned listing for a search keyword (promoted search).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Ad(db.Model):
|
||||
__tablename__ = "ads"
|
||||
|
||||
SLOTS = ["header", "sidebar", "inline", "footer"]
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
advertiser_name = db.Column(db.String(120), nullable=False)
|
||||
slot = db.Column(db.Enum("header", "sidebar", "inline", "footer",
|
||||
name="ad_slot"), nullable=False)
|
||||
creative_path = db.Column(db.String(255), nullable=True) # image file rel path
|
||||
target_url = db.Column(db.String(512), nullable=False)
|
||||
alt_text = db.Column(db.String(200), nullable=True)
|
||||
|
||||
# targeting (null = match all)
|
||||
lang = db.Column(db.String(5), nullable=True)
|
||||
geo_state = db.Column(db.String(2), nullable=True)
|
||||
|
||||
starts_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
ends_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
impressions = db.Column(db.Integer, nullable=False, default=0)
|
||||
clicks = db.Column(db.Integer, nullable=False, default=0)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
@property
|
||||
def ctr(self):
|
||||
if not self.impressions:
|
||||
return 0.0
|
||||
return round(self.clicks / self.impressions * 100, 2)
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
now = datetime.utcnow()
|
||||
return self.is_active and self.starts_at <= now <= self.ends_at
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Ad {self.id} slot={self.slot} adv={self.advertiser_name[:20]}>"
|
||||
|
||||
|
||||
class Sponsor(db.Model):
|
||||
__tablename__ = "sponsors"
|
||||
|
||||
TIERS = ["directory", "category"]
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
logo_path = db.Column(db.String(255), nullable=True)
|
||||
url = db.Column(db.String(512), nullable=False)
|
||||
tagline = db.Column(db.String(160), nullable=True)
|
||||
tier = db.Column(db.Enum("directory", "category", name="sponsor_tier"),
|
||||
nullable=False, default="directory")
|
||||
category_id = db.Column(
|
||||
db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("categories.id"), nullable=True)
|
||||
starts_at = db.Column(db.DateTime, nullable=False)
|
||||
ends_at = db.Column(db.DateTime, nullable=False)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
category = db.relationship("Category",
|
||||
backref=db.backref("sponsors", lazy="selectin"))
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
now = datetime.utcnow()
|
||||
return self.is_active and self.starts_at <= now <= self.ends_at
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Sponsor {self.name} tier={self.tier}>"
|
||||
|
||||
|
||||
class PromotedKeyword(db.Model):
|
||||
"""A listing pinned to the top of search results for a given keyword."""
|
||||
__tablename__ = "promoted_keywords"
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("keyword", "listing_id", name="uq_pk_kw_listing"),
|
||||
)
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
keyword = db.Column(db.String(80), nullable=False, index=True)
|
||||
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("listings.id"), nullable=False)
|
||||
priority = db.Column(db.Integer, nullable=False, default=0)
|
||||
expires_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("promoted_keywords",
|
||||
lazy="selectin"))
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.expires_at > datetime.utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PromotedKeyword '{self.keyword}' L{self.listing_id}>"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Monetization models: Subscription, Transaction, Boost.
|
||||
|
||||
Stripe is source of truth for subscriptions. Local rows mirror state
|
||||
and are reconciled via webhooks + nightly job.
|
||||
Money stored as integer cents throughout.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Subscription(db.Model):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False,
|
||||
unique=True, index=True)
|
||||
plan_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("plans.id"), nullable=False)
|
||||
stripe_customer_id = db.Column(db.String(64), nullable=True, index=True)
|
||||
stripe_sub_id = db.Column(db.String(64), nullable=True, unique=True, index=True)
|
||||
status = db.Column(
|
||||
db.Enum("active", "past_due", "canceled", "trialing",
|
||||
name="sub_status"),
|
||||
nullable=False, default="active")
|
||||
current_period_end = db.Column(db.DateTime, nullable=True)
|
||||
cancel_at_period_end = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("subscription", uselist=False))
|
||||
plan = db.relationship("Plan",
|
||||
backref=db.backref("subscriptions", lazy="dynamic"))
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.status in ("active", "trialing")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription u{self.user_id} {self.status}>"
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
type = db.Column(
|
||||
db.Enum("subscription", "boost", "refund", name="txn_type"),
|
||||
nullable=False)
|
||||
amount_cents = db.Column(db.Integer, nullable=False, default=0)
|
||||
currency = db.Column(db.String(3), nullable=False, default="usd")
|
||||
stripe_object_id = db.Column(db.String(64), nullable=True, index=True)
|
||||
status = db.Column(db.String(20), nullable=False, default="succeeded")
|
||||
meta = db.Column(JSON, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("transactions", lazy="dynamic"))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Transaction {self.type} ${self.amount_cents/100:.2f}>"
|
||||
|
||||
|
||||
class Boost(db.Model):
|
||||
__tablename__ = "boosts"
|
||||
|
||||
# Boost types and their default durations in days
|
||||
TYPES = {
|
||||
"featured": {"label": "Featured listing", "days": 7, "cents": 499},
|
||||
"bump": {"label": "Bump to top", "days": 3, "cents": 199},
|
||||
"highlight":{"label": "Highlight", "days": 7, "cents": 299},
|
||||
"urgent": {"label": "Urgent tag", "days": 7, "cents": 199},
|
||||
}
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("listings.id"), nullable=False, index=True)
|
||||
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("users.id"), nullable=False)
|
||||
type = db.Column(
|
||||
db.Enum("featured", "bump", "highlight", "urgent", name="boost_type"),
|
||||
nullable=False)
|
||||
expires_at = db.Column(db.DateTime, nullable=False, index=True)
|
||||
transaction_id = db.Column(
|
||||
db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("transactions.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
listing = db.relationship("Listing",
|
||||
backref=db.backref("boosts", lazy="selectin"))
|
||||
user = db.relationship("User",
|
||||
backref=db.backref("boosts", lazy="dynamic"))
|
||||
transaction = db.relationship("Transaction")
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.expires_at > datetime.utcnow()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Boost {self.type} L{self.listing_id} exp={self.expires_at.date()}>"
|
||||
Reference in New Issue
Block a user