06/15 Phase 1 + 2 codes
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""Model exports. Import order matters for relationship resolution."""
|
||||
from app.models.plan import Plan
|
||||
from app.models.user import User
|
||||
from app.models.trust import TrustEvent
|
||||
from app.models.category import Category
|
||||
from app.models.listing import Listing, ListingImage
|
||||
from app.models.geo import ZipGeo, Metro
|
||||
|
||||
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
|
||||
"ListingImage", "ZipGeo", "Metro"]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Category model. Self-referential (parent_id) for subcategories.
|
||||
|
||||
`field_schema` is admin-editable JSON describing category-specific listing
|
||||
fields. Shape:
|
||||
{
|
||||
"fields": [
|
||||
{"name":"condition","label":"Condition","type":"select",
|
||||
"required":true,"options":["new","like_new","good","fair"],"hot":true},
|
||||
{"name":"brand","label":"Brand","type":"text","required":false,"max":80},
|
||||
{"name":"salary_min","label":"Min salary","type":"number","required":false,"hot":true}
|
||||
]
|
||||
}
|
||||
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 sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
slug = db.Column(db.String(60), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(80), nullable=False)
|
||||
parent_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("categories.id"), nullable=True, index=True)
|
||||
field_schema = db.Column(JSON, nullable=False, default=dict)
|
||||
icon = db.Column(db.String(40), nullable=True)
|
||||
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||
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)
|
||||
|
||||
children = db.relationship("Category", backref=db.backref("parent",
|
||||
remote_side=[id]), lazy="selectin")
|
||||
listings = db.relationship("Listing", back_populates="category", lazy="dynamic")
|
||||
|
||||
@property
|
||||
def is_subcategory(self):
|
||||
return self.parent_id is not None
|
||||
|
||||
def fields(self):
|
||||
return (self.field_schema or {}).get("fields", [])
|
||||
|
||||
def hot_fields(self):
|
||||
return [f for f in self.fields() if f.get("hot")]
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Category {self.slug}>"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared enumerations used across models."""
|
||||
import enum
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
free = "free"
|
||||
subscriber = "subscriber"
|
||||
moderator = "moderator"
|
||||
admin = "admin"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
suspended = "suspended"
|
||||
banned = "banned"
|
||||
|
||||
|
||||
class TrustTier(str, enum.Enum):
|
||||
new = "new"
|
||||
basic = "basic"
|
||||
trusted = "trusted"
|
||||
verified = "verified"
|
||||
|
||||
|
||||
class TrustEventType(str, enum.Enum):
|
||||
account_age = "account_age"
|
||||
listing_survived = "listing_survived"
|
||||
flag_received = "flag_received"
|
||||
verified_email = "verified_email"
|
||||
payment = "payment"
|
||||
|
||||
|
||||
class ListingStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
flagged = "flagged"
|
||||
sold = "sold"
|
||||
expired = "expired"
|
||||
removed = "removed"
|
||||
|
||||
|
||||
class Lang(str, enum.Enum):
|
||||
en = "en"
|
||||
vi = "vi"
|
||||
es = "es"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Geocoding reference tables.
|
||||
|
||||
ZipGeo: offline US ZIP -> lat/lng/city/state/metro (source the full ~42k-row
|
||||
dataset from SimpleMaps US ZIP or Census ZCTA; a small sample is seeded for tests).
|
||||
Metro: SEO landing-page anchor (pages built in Phase 7; column wired now).
|
||||
"""
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class ZipGeo(db.Model):
|
||||
__tablename__ = "zip_geo"
|
||||
|
||||
zip = db.Column(db.String(12), primary_key=True)
|
||||
city = db.Column(db.String(80), nullable=False)
|
||||
state = db.Column(db.String(2), nullable=False, index=True)
|
||||
lat = db.Column(db.Float, nullable=False)
|
||||
lng = db.Column(db.Float, nullable=False)
|
||||
metro = db.Column(db.String(80), nullable=True, index=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ZipGeo {self.zip} {self.city},{self.state}>"
|
||||
|
||||
|
||||
class Metro(db.Model):
|
||||
__tablename__ = "metros"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
primary_key=True, autoincrement=True)
|
||||
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
state = db.Column(db.String(2), nullable=True)
|
||||
center_lat = db.Column(db.Float, nullable=True)
|
||||
center_lng = db.Column(db.Float, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Metro {self.slug}>"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Listing model.
|
||||
|
||||
Portability notes (see README "MySQL upgrades"):
|
||||
- Location stored as lat/lng floats; radius search uses a bounding-box prefilter
|
||||
+ haversine refine. On MySQL you may add a generated POINT + SPATIAL INDEX.
|
||||
- Hot filter fields (condition/job_type/salary) are denormalized onto indexed
|
||||
columns, populated in the service layer from `attributes`. On MySQL these can
|
||||
instead be GENERATED ALWAYS columns over the JSON.
|
||||
- Free-text search uses the accent-insensitive `title_norm` + body LIKE. On
|
||||
MySQL add a FULLTEXT(title, body) index and switch to MATCH ... AGAINST.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import JSON, Index
|
||||
from app.extensions import db
|
||||
from app.models.enums import ListingStatus, Lang
|
||||
|
||||
|
||||
class Listing(db.Model):
|
||||
__tablename__ = "listings"
|
||||
|
||||
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)
|
||||
category_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
|
||||
db.ForeignKey("categories.id"), nullable=False, index=True)
|
||||
|
||||
title = db.Column(db.String(140), nullable=False)
|
||||
title_norm = db.Column(db.String(140), nullable=False, index=True) # accent-stripped
|
||||
body = db.Column(db.Text, nullable=False)
|
||||
lang = db.Column(db.Enum(Lang), nullable=False, default=Lang.en)
|
||||
|
||||
price_cents = db.Column(db.Integer, nullable=True, index=True)
|
||||
|
||||
# location
|
||||
zip = db.Column(db.String(12), nullable=True, index=True)
|
||||
city = db.Column(db.String(80), nullable=True)
|
||||
state = db.Column(db.String(2), nullable=True, index=True)
|
||||
lat = db.Column(db.Float, nullable=True)
|
||||
lng = db.Column(db.Float, nullable=True)
|
||||
|
||||
# category-specific values
|
||||
attributes = db.Column(JSON, nullable=False, default=dict)
|
||||
# denormalized hot filter columns (populated from attributes on save)
|
||||
attr_condition = db.Column(db.String(40), nullable=True, index=True)
|
||||
attr_job_type = db.Column(db.String(40), nullable=True, index=True)
|
||||
attr_salary_min = db.Column(db.Integer, nullable=True, index=True)
|
||||
attr_salary_max = db.Column(db.Integer, nullable=True, index=True)
|
||||
|
||||
status = db.Column(db.Enum(ListingStatus), nullable=False,
|
||||
default=ListingStatus.active, index=True)
|
||||
is_featured = db.Column(db.Boolean, nullable=False, default=False)
|
||||
bump_at = db.Column(db.DateTime, nullable=True)
|
||||
flag_count = db.Column(db.Integer, nullable=False, default=0)
|
||||
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)
|
||||
|
||||
user = db.relationship("User", backref=db.backref("listings", lazy="dynamic"))
|
||||
category = db.relationship("Category", back_populates="listings")
|
||||
images = db.relationship("ListingImage", back_populates="listing",
|
||||
order_by="ListingImage.sort_order",
|
||||
cascade="all, delete-orphan", lazy="selectin")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_listing_browse", "status", "expires_at"),
|
||||
Index("ix_listing_sort", "is_featured", "bump_at"),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_live(self):
|
||||
return (self.status == ListingStatus.active
|
||||
and self.expires_at > datetime.utcnow())
|
||||
|
||||
@property
|
||||
def price_display(self):
|
||||
if self.price_cents is None:
|
||||
return None
|
||||
return f"${self.price_cents / 100:,.2f}"
|
||||
|
||||
@property
|
||||
def cover(self):
|
||||
return self.images[0] if self.images else None
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Listing {self.id} {self.title[:24]!r}>"
|
||||
|
||||
|
||||
class ListingImage(db.Model):
|
||||
__tablename__ = "listing_images"
|
||||
|
||||
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)
|
||||
path = db.Column(db.String(255), nullable=False)
|
||||
thumb_path = db.Column(db.String(255), nullable=True)
|
||||
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)
|
||||
|
||||
listing = db.relationship("Listing", back_populates="images")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ListingImage {self.id} of L{self.listing_id}>"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Plan model. Tier limits live in `config` JSON, editable in admin without redeploy."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import JSON
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Plan(db.Model):
|
||||
__tablename__ = "plans"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
slug = db.Column(db.String(40), unique=True, nullable=False) # free|basic|pro|business
|
||||
name = db.Column(db.String(80), nullable=False)
|
||||
price_monthly_cents = db.Column(db.Integer, nullable=False, default=0)
|
||||
stripe_price_id = db.Column(db.String(80), nullable=True)
|
||||
config = db.Column(JSON, nullable=False, default=dict) # limits (Section 6)
|
||||
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)
|
||||
|
||||
users = db.relationship("User", back_populates="tier", lazy="dynamic")
|
||||
|
||||
def limit(self, key, default=None):
|
||||
"""Read a single limit from config JSON."""
|
||||
return (self.config or {}).get(key, default)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Plan {self.slug}>"
|
||||
@@ -0,0 +1,19 @@
|
||||
"""TrustEvent model. Append-only events that adjust a user's trust score."""
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
from app.models.enums import TrustEventType
|
||||
|
||||
|
||||
class TrustEvent(db.Model):
|
||||
__tablename__ = "trust_events"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
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)
|
||||
|
||||
user = db.relationship("User", back_populates="trust_events")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TrustEvent u{self.user_id} {self.type.value} {self.delta:+d}>"
|
||||
@@ -0,0 +1,67 @@
|
||||
"""User model. Argon2 password hashing, role/trust fields, Flask-Login mixin."""
|
||||
from datetime import datetime
|
||||
from flask_login import UserMixin
|
||||
from app.extensions import db
|
||||
from app.models.enums import Role, UserStatus, TrustTier
|
||||
from app.utils.security import hash_password, verify_password
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
display_name = db.Column(db.String(80), nullable=False)
|
||||
|
||||
role = db.Column(db.Enum(Role), nullable=False, default=Role.free)
|
||||
status = db.Column(db.Enum(UserStatus), nullable=False, default=UserStatus.active)
|
||||
locale = db.Column(db.String(5), nullable=False, default="en")
|
||||
|
||||
tier_id = db.Column(db.BigInteger, db.ForeignKey("plans.id"), nullable=True)
|
||||
tier = db.relationship("Plan", back_populates="users")
|
||||
|
||||
trust_score = db.Column(db.Integer, nullable=False, default=0)
|
||||
trust_tier = db.Column(db.Enum(TrustTier), nullable=False, default=TrustTier.new)
|
||||
|
||||
verified = db.Column(db.Boolean, nullable=False, default=False) # identity/badge
|
||||
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)
|
||||
|
||||
trust_events = db.relationship("TrustEvent", back_populates="user",
|
||||
lazy="dynamic", cascade="all, delete-orphan")
|
||||
|
||||
# --- password ---
|
||||
def set_password(self, raw):
|
||||
self.password_hash = hash_password(raw)
|
||||
|
||||
def check_password(self, raw):
|
||||
return verify_password(self.password_hash, raw)
|
||||
|
||||
# --- RBAC helpers ---
|
||||
@property
|
||||
def is_admin(self):
|
||||
return self.role == Role.admin
|
||||
|
||||
@property
|
||||
def is_moderator(self):
|
||||
return self.role in (Role.moderator, Role.admin)
|
||||
|
||||
@property
|
||||
def is_subscriber(self):
|
||||
return self.role == Role.subscriber
|
||||
|
||||
def has_role(self, *roles):
|
||||
return self.role in roles
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
# Flask-Login uses this to permit login.
|
||||
return self.status == UserStatus.active
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.email}>"
|
||||
Reference in New Issue
Block a user