06/15 Phase 1 + 2 codes

This commit is contained in:
2026-06-15 11:23:05 -04:00
commit c2064b84b4
62 changed files with 2937 additions and 0 deletions
+10
View File
@@ -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"]
+55
View File
@@ -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}>"
+44
View File
@@ -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"
+36
View File
@@ -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}>"
+109
View File
@@ -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}>"
+30
View File
@@ -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}>"
+19
View File
@@ -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}>"
+67
View File
@@ -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}>"