Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""
|
|
Domain Listing models for "Pounce For Sale" feature.
|
|
|
|
This implements the "Micro-Marktplatz" strategy from analysis_3.md:
|
|
- Users can create professional landing pages for domains they want to sell
|
|
- Buyers can contact sellers through Pounce
|
|
- DNS verification ensures only real owners can list domains
|
|
|
|
DATABASE TABLES TO CREATE:
|
|
1. domain_listings - Main listing table
|
|
2. listing_inquiries - Contact requests from potential buyers
|
|
3. listing_views - Track views for analytics
|
|
|
|
Run migrations: alembic upgrade head
|
|
"""
|
|
from datetime import datetime
|
|
from typing import Optional, List
|
|
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, Enum as SQLEnum
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
import enum
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class ListingStatus(str, enum.Enum):
|
|
"""Status of a domain listing."""
|
|
DRAFT = "draft" # Not yet published
|
|
PENDING_VERIFICATION = "pending_verification" # Awaiting DNS verification
|
|
ACTIVE = "active" # Live and visible
|
|
SOLD = "sold" # Marked as sold
|
|
EXPIRED = "expired" # Listing expired
|
|
SUSPENDED = "suspended" # Suspended by admin
|
|
|
|
|
|
class VerificationStatus(str, enum.Enum):
|
|
"""DNS verification status."""
|
|
NOT_STARTED = "not_started"
|
|
PENDING = "pending"
|
|
VERIFIED = "verified"
|
|
FAILED = "failed"
|
|
|
|
|
|
class DomainListing(Base):
|
|
"""
|
|
Domain listing for the Pounce marketplace.
|
|
|
|
Users can list their domains for sale with a professional landing page.
|
|
URL: pounce.ch/buy/{slug}
|
|
|
|
Features:
|
|
- DNS verification for ownership proof
|
|
- Professional landing page with valuation
|
|
- Contact form for buyers
|
|
- Analytics (views, inquiries)
|
|
|
|
From analysis_3.md:
|
|
"Ein User (Trader/Tycoon) kann für seine Domains mit einem Klick
|
|
eine schicke Verkaufsseite erstellen."
|
|
"""
|
|
|
|
__tablename__ = "domain_listings"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
|
|
|
# Domain info
|
|
domain: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
|
slug: Mapped[str] = mapped_column(String(300), unique=True, nullable=False, index=True)
|
|
|
|
# Listing details
|
|
title: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Custom headline
|
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
|
|
# Pricing
|
|
asking_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
min_offer: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
|
price_type: Mapped[str] = mapped_column(String(20), default="fixed") # fixed, negotiable, make_offer
|
|
|
|
# Pounce valuation (calculated)
|
|
pounce_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
|
|
estimated_value: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
|
|
# Verification (from analysis_3.md - Säule 2: Asset Verification)
|
|
verification_status: Mapped[str] = mapped_column(
|
|
String(20),
|
|
default=VerificationStatus.NOT_STARTED.value
|
|
)
|
|
verification_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
|
|
# Status
|
|
status: Mapped[str] = mapped_column(String(30), default=ListingStatus.DRAFT.value, index=True)
|
|
sold_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
sold_reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
|
sold_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
sold_currency: Mapped[Optional[str]] = mapped_column(String(3), nullable=True)
|
|
|
|
# Features
|
|
show_valuation: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
allow_offers: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
featured: Mapped[bool] = mapped_column(Boolean, default=False) # Premium placement
|
|
|
|
# Analytics
|
|
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
inquiry_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
# Expiry
|
|
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
|
|
# Relationships
|
|
user: Mapped["User"] = relationship("User", back_populates="listings")
|
|
inquiries: Mapped[List["ListingInquiry"]] = relationship(
|
|
"ListingInquiry", back_populates="listing", cascade="all, delete-orphan"
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<DomainListing {self.domain} ({self.status})>"
|
|
|
|
@property
|
|
def is_verified(self) -> bool:
|
|
return self.verification_status == VerificationStatus.VERIFIED.value
|
|
|
|
@property
|
|
def is_active(self) -> bool:
|
|
return self.status == ListingStatus.ACTIVE.value
|
|
|
|
@property
|
|
def public_url(self) -> str:
|
|
return f"/buy/{self.slug}"
|
|
|
|
|
|
class ListingInquiry(Base):
|
|
"""
|
|
Contact request from a potential buyer.
|
|
|
|
From analysis_3.md:
|
|
"Ein einfaches Kontaktformular, das die Anfrage direkt an den User leitet."
|
|
|
|
Security (from analysis_3.md - Säule 3):
|
|
- Keyword blocking for phishing prevention
|
|
- Rate limiting per IP/user
|
|
"""
|
|
|
|
__tablename__ = "listing_inquiries"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
|
buyer_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), index=True, nullable=True)
|
|
|
|
# Inquirer info
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
|
company: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
|
|
|
# Message
|
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
|
offer_amount: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
|
|
|
# Status
|
|
status: Mapped[str] = mapped_column(String(20), default="new") # new, read, replied, closed, spam
|
|
closed_reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
|
|
|
# Tracking
|
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
|
|
# Timestamps
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
|
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
replied_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
|
|
|
# Relationships
|
|
listing: Mapped["DomainListing"] = relationship("DomainListing", back_populates="inquiries")
|
|
messages: Mapped[List["ListingInquiryMessage"]] = relationship(
|
|
"ListingInquiryMessage", back_populates="inquiry", cascade="all, delete-orphan"
|
|
)
|
|
events: Mapped[List["ListingInquiryEvent"]] = relationship(
|
|
"ListingInquiryEvent", back_populates="inquiry", cascade="all, delete-orphan"
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<ListingInquiry from {self.email} for listing #{self.listing_id}>"
|
|
|
|
|
|
class ListingInquiryEvent(Base):
|
|
"""
|
|
Audit trail for inquiry status changes.
|
|
|
|
This is the minimal “deal system” log:
|
|
- who changed what status
|
|
- when it happened
|
|
- optional reason (close/spam)
|
|
"""
|
|
|
|
__tablename__ = "listing_inquiry_events"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
inquiry_id: Mapped[int] = mapped_column(ForeignKey("listing_inquiries.id"), index=True, nullable=False)
|
|
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
|
actor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
|
|
|
old_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
|
new_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
reason: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
|
|
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
inquiry: Mapped["ListingInquiry"] = relationship("ListingInquiry", back_populates="events")
|
|
|
|
|
|
class ListingInquiryMessage(Base):
|
|
"""
|
|
Thread messages for listing inquiries (in-product negotiation).
|
|
|
|
- Buyer sends messages from their account
|
|
- Seller replies from Terminal
|
|
"""
|
|
|
|
__tablename__ = "listing_inquiry_messages"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
inquiry_id: Mapped[int] = mapped_column(ForeignKey("listing_inquiries.id"), index=True, nullable=False)
|
|
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
|
|
|
sender_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
inquiry: Mapped["ListingInquiry"] = relationship("ListingInquiry", back_populates="messages")
|
|
|
|
|
|
class ListingView(Base):
|
|
"""
|
|
Track listing page views for analytics.
|
|
"""
|
|
|
|
__tablename__ = "listing_views"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
|
|
|
# Visitor info
|
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
referrer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
|
|
# User (if logged in)
|
|
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
|
|
|
|
# Timestamp
|
|
viewed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<ListingView #{self.listing_id} at {self.viewed_at}>"
|
|
|