06/15 Phase 3 codes

This commit is contained in:
2026-06-15 14:19:14 -04:00
parent c2064b84b4
commit 60526262f0
20 changed files with 802 additions and 7 deletions
+11
View File
@@ -52,10 +52,12 @@ def _register_blueprints(app):
from app.blueprints.auth.routes import auth_bp from app.blueprints.auth.routes import auth_bp
from app.blueprints.i18n.routes import i18n_bp from app.blueprints.i18n.routes import i18n_bp
from app.blueprints.listings.routes import listings_bp from app.blueprints.listings.routes import listings_bp
from app.blueprints.messaging.routes import messaging_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(i18n_bp) app.register_blueprint(i18n_bp)
app.register_blueprint(listings_bp) app.register_blueprint(listings_bp)
app.register_blueprint(messaging_bp)
def _register_errorhandlers(app): def _register_errorhandlers(app):
@@ -75,6 +77,7 @@ def _register_errorhandlers(app):
def _register_context(app): def _register_context(app):
from flask_babel import get_locale from flask_babel import get_locale
from flask import request from flask import request
from flask_login import current_user
def merge_query(**overrides): def merge_query(**overrides):
merged = request.args.to_dict() merged = request.args.to_dict()
@@ -83,11 +86,19 @@ def _register_context(app):
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
unread = 0
if current_user.is_authenticated:
try:
from app.services.messaging import total_unread
unread = total_unread(current_user)
except Exception:
pass
return { return {
"get_locale": get_locale, "get_locale": get_locale,
"merge_query": merge_query, "merge_query": merge_query,
"SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"], "SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"],
"TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""), "TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""),
"unread_count": unread,
} }
+2 -2
View File
@@ -101,7 +101,7 @@ def verify_email(token):
if user_id is None: if user_id is None:
flash(_("Verification link is invalid or expired."), "danger") flash(_("Verification link is invalid or expired."), "danger")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
user = User.query.get(user_id) user = db.session.get(User, user_id)
if user is None: if user is None:
abort(404) abort(404)
if not user.email_verified: if not user.email_verified:
@@ -136,7 +136,7 @@ def reset_password(token):
if user_id is None: if user_id is None:
flash(_("Reset link is invalid or expired."), "danger") flash(_("Reset link is invalid or expired."), "danger")
return redirect(url_for("auth.reset_request")) return redirect(url_for("auth.reset_request"))
user = User.query.get(user_id) user = db.session.get(User, user_id)
if user is None: if user is None:
abort(404) abort(404)
form = ResetForm() form = ResetForm()
+2 -2
View File
@@ -109,7 +109,7 @@ def create():
category = None category = None
if request.method == "POST" and form.category_id.data: if request.method == "POST" and form.category_id.data:
category = Category.query.get(form.category_id.data) category = db.session.get(Category, form.category_id.data)
if form.validate_on_submit() and category: if form.validate_on_submit() and category:
raw_attrs = _parse_attributes(category) raw_attrs = _parse_attributes(category)
@@ -156,7 +156,7 @@ def edit(listing_id):
form.price.data = (listing.price_cents / 100) if listing.price_cents else None form.price.data = (listing.price_cents / 100) if listing.price_cents else None
form.zip.data = listing.zip form.zip.data = listing.zip
category = Category.query.get(form.category_id.data or listing.category_id) category = db.session.get(Category, form.category_id.data or listing.category_id)
if form.validate_on_submit() and category: if form.validate_on_submit() and category:
raw_attrs = _parse_attributes(category) raw_attrs = _parse_attributes(category)
+11
View File
@@ -0,0 +1,11 @@
"""Messaging form."""
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length
from flask_babel import lazy_gettext as _l
class MessageForm(FlaskForm):
body = TextAreaField(_l("Message"),
validators=[DataRequired(), Length(1, 4000)])
submit = SubmitField(_l("Send"))
+122
View File
@@ -0,0 +1,122 @@
"""Messaging + Favorites routes."""
from flask import (Blueprint, render_template, redirect, url_for,
flash, abort, request, jsonify)
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db, limiter
from app.models.listing import Listing
from app.models.messaging import Conversation
from app.services import messaging as msvc
from app.services import favorites as fsvc
from app.services.contact import contact_revealed, mask_body
from app.blueprints.messaging.forms import MessageForm
messaging_bp = Blueprint("messaging", __name__)
# ── Inbox ──────────────────────────────────────────────────────────────────
@messaging_bp.route("/messages")
@login_required
def inbox():
page = request.args.get("page", 1, type=int)
pagination = msvc.inbox(current_user, page=page)
return render_template("messaging/inbox.html",
pagination=pagination,
unread_total=msvc.total_unread(current_user))
# ── Conversation thread ────────────────────────────────────────────────────
@messaging_bp.route("/messages/<int:conv_id>", methods=["GET", "POST"])
@login_required
def conversation(conv_id):
conv = db.session.get(Conversation, conv_id)
if conv is None:
abort(404)
if current_user.id not in (conv.buyer_id, conv.seller_id):
abort(403)
msvc.mark_conversation_read(conv, current_user)
reveal = contact_revealed(current_user)
form = MessageForm()
if form.validate_on_submit():
try:
msvc.send_message(conv, current_user, form.body.data)
return redirect(url_for("messaging.conversation", conv_id=conv.id))
except msvc.MessagingError as e:
flash(str(e), "danger")
# mask contact info in messages for low-trust users
masked_messages = [
(msg, mask_body(msg.body, reveal=reveal))
for msg in conv.messages
]
return render_template("messaging/conversation.html",
conv=conv, form=form,
masked_messages=masked_messages,
reveal=reveal,
other=conv.other_party(current_user))
# ── Start conversation from listing detail ─────────────────────────────────
@messaging_bp.route("/listings/<int:listing_id>/contact", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour", methods=["POST"])
def start_conversation(listing_id):
listing = db.session.get(Listing, listing_id)
if listing is None:
abort(404)
if listing.user_id == current_user.id:
flash(_("You cannot message your own listing."), "warning")
return redirect(url_for("listings.detail", listing_id=listing_id))
try:
conv, created = msvc.get_or_create_conversation(listing, current_user)
except msvc.MessagingError as e:
flash(str(e), "danger")
return redirect(url_for("listings.detail", listing_id=listing_id))
form = MessageForm()
if form.validate_on_submit():
try:
msvc.send_message(conv, current_user, form.body.data)
flash(_("Message sent."), "success")
return redirect(url_for("messaging.conversation", conv_id=conv.id))
except msvc.MessagingError as e:
flash(str(e), "danger")
return render_template("messaging/start.html",
listing=listing, conv=conv, form=form,
created=created)
# ── Favorites ──────────────────────────────────────────────────────────────
@messaging_bp.route("/listings/<int:listing_id>/favorite", methods=["POST"])
@login_required
def toggle_favorite(listing_id):
listing = db.session.get(Listing, listing_id)
if listing is None:
abort(404)
now_fav = fsvc.toggle_favorite(current_user.id, listing_id)
# AJAX or form fallback
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify(favorited=now_fav)
flash(_("Saved.") if now_fav else _("Removed from saved."), "info")
return redirect(request.referrer or url_for("listings.detail",
listing_id=listing_id))
@messaging_bp.route("/my/favorites")
@login_required
def my_favorites():
page = request.args.get("page", 1, type=int)
pagination = fsvc.user_favorites(current_user.id, page=page)
return render_template("messaging/favorites.html", pagination=pagination)
# ── Unread count API (for nav badge polling) ───────────────────────────────
@messaging_bp.route("/api/unread")
@login_required
def unread_count():
return jsonify(unread=msvc.total_unread(current_user))
+4 -1
View File
@@ -5,6 +5,9 @@ from app.models.trust import TrustEvent
from app.models.category import Category from app.models.category import Category
from app.models.listing import Listing, ListingImage from app.models.listing import Listing, ListingImage
from app.models.geo import ZipGeo, Metro from app.models.geo import ZipGeo, Metro
from app.models.messaging import Conversation, Message
from app.models.favorite import Favorite
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro"] "ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
"Favorite"]
+26
View File
@@ -0,0 +1,26 @@
"""Favorite (saved listing). One row per user+listing pair."""
from datetime import datetime
from app.extensions import db
class Favorite(db.Model):
__tablename__ = "favorites"
__table_args__ = (
db.UniqueConstraint("user_id", "listing_id", name="uq_fav_user_listing"),
)
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)
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)
user = db.relationship("User",
backref=db.backref("favorites", lazy="dynamic"))
listing = db.relationship("Listing",
backref=db.backref("favorited_by", lazy="dynamic"))
def __repr__(self):
return f"<Favorite u{self.user_id} l{self.listing_id}>"
+76
View File
@@ -0,0 +1,76 @@
"""Messaging models: Conversation (per listing, per buyer/seller pair) + Message.
One conversation per (listing, buyer) pair enforced by unique constraint.
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.extensions import db
class Conversation(db.Model):
__tablename__ = "conversations"
__table_args__ = (
db.UniqueConstraint("listing_id", "buyer_id", name="uq_conv_listing_buyer"),
)
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)
buyer_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False, index=True)
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)
listing = db.relationship("Listing",
backref=db.backref("conversations", lazy="dynamic"))
buyer = db.relationship("User", foreign_keys=[buyer_id],
backref=db.backref("bought_conversations", lazy="dynamic"))
seller = db.relationship("User", foreign_keys=[seller_id],
backref=db.backref("sold_conversations", lazy="dynamic"))
messages = db.relationship("Message", back_populates="conversation",
order_by="Message.created_at",
cascade="all, delete-orphan", lazy="selectin")
def other_party(self, user):
return self.seller if user.id == self.buyer_id else self.buyer
def unread_count(self, user):
return sum(1 for m in self.messages
if m.sender_id != user.id and m.read_at is None)
def __repr__(self):
return f"<Conversation {self.id} L{self.listing_id}>"
class Message(db.Model):
__tablename__ = "messages"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
conversation_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("conversations.id"),
nullable=False, index=True)
sender_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
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)
conversation = db.relationship("Conversation", back_populates="messages")
sender = db.relationship("User",
backref=db.backref("sent_messages", lazy="dynamic"))
@property
def is_read(self):
return self.read_at is not None
def mark_read(self):
if self.read_at is None:
self.read_at = datetime.utcnow()
def __repr__(self):
return f"<Message {self.id} C{self.conversation_id}>"
+48
View File
@@ -0,0 +1,48 @@
"""Contact masking.
Phone numbers and email addresses in listing bodies / messages are masked
for low-trust senders to push communication through the in-site channel.
Unmasked only once sender reaches TrustTier.trusted or verified_email=True.
Masking is a display-layer concern: raw body stored as-is; masked on render.
For the *sending* side we heuristic-flag high-contact-density messages so
moderators can spot scraping attempts.
"""
import re
from app.models.enums import TrustTier
# patterns
_PHONE_RE = re.compile(
r"(\+?1[\s\-.]?)?"
r"(\(?\d{3}\)?[\s\-.])"
r"\d{3}[\s\-.]\d{4}"
)
_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)
def contact_revealed(user) -> bool:
"""True when this user's contact info may be shown unmasked."""
if user is None:
return False
return (user.email_verified and
user.trust_tier in (TrustTier.trusted, TrustTier.verified))
def mask_body(text: str, reveal: bool = False) -> str:
"""Optionally mask phones, emails, and URLs in a block of text."""
if reveal or not text:
return text
text = _PHONE_RE.sub("[phone hidden]", text)
text = _EMAIL_RE.sub("[email hidden]", text)
text = _URL_RE.sub("[link hidden]", text)
return text
def contact_density(text: str) -> int:
"""Count how many contact signals appear in text (for heuristic flagging)."""
if not text:
return 0
return (len(_PHONE_RE.findall(text))
+ len(_EMAIL_RE.findall(text))
+ len(_URL_RE.findall(text)))
+33
View File
@@ -0,0 +1,33 @@
"""Favorites: toggle save/unsave, check status, list."""
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from app.models.favorite import Favorite
def is_favorited(user_id: int, listing_id: int) -> bool:
return Favorite.query.filter_by(
user_id=user_id, listing_id=listing_id).first() is not None
def toggle_favorite(user_id: int, listing_id: int) -> bool:
"""Add if not present; remove if present. Returns True if now favorited."""
existing = Favorite.query.filter_by(
user_id=user_id, listing_id=listing_id).first()
if existing:
db.session.delete(existing)
db.session.commit()
return False
fav = Favorite(user_id=user_id, listing_id=listing_id)
db.session.add(fav)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return True
def user_favorites(user_id: int, page=1, per_page=20):
return (Favorite.query
.filter_by(user_id=user_id)
.order_by(Favorite.created_at.desc())
.paginate(page=page, per_page=per_page, error_out=False))
+132
View File
@@ -0,0 +1,132 @@
"""Messaging business logic.
Rules:
- Buyer initiates; one conversation per (listing, buyer) pair.
- Seller cannot open a conversation with themselves.
- High contact-density messages are auto-flagged for moderation.
- Notification email sent to recipient on each new message (async in Phase 4+;
for now sent inline fast enough at low volume).
"""
from datetime import datetime
from sqlalchemy.exc import IntegrityError
from flask import current_app
from app.extensions import db
from app.models.messaging import Conversation, Message
from app.services.contact import contact_density
from app.services.email import send_email
# Bodies with ≥ 3 contact signals get auto-flagged
_FLAG_THRESHOLD = 3
class MessagingError(ValueError):
pass
def get_or_create_conversation(listing, buyer):
"""Return existing conversation or create one. Raises if buyer == seller."""
if listing.user_id == buyer.id:
raise MessagingError("cannot message your own listing")
conv = Conversation.query.filter_by(
listing_id=listing.id, buyer_id=buyer.id).first()
if conv:
return conv, False
conv = Conversation(
listing_id=listing.id,
buyer_id=buyer.id,
seller_id=listing.user_id,
)
db.session.add(conv)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
conv = Conversation.query.filter_by(
listing_id=listing.id, buyer_id=buyer.id).first()
return conv, True
def send_message(conversation, sender, body: str) -> Message:
"""Append a message. Notify recipient. Returns the saved Message."""
body = body.strip()
if not body:
raise MessagingError("message cannot be empty")
if len(body) > 4000:
raise MessagingError("message too long (max 4000 chars)")
# verify sender is a participant
if sender.id not in (conversation.buyer_id, conversation.seller_id):
raise MessagingError("not a participant")
flagged = contact_density(body) >= _FLAG_THRESHOLD
msg = Message(
conversation_id=conversation.id,
sender_id=sender.id,
body=body,
)
db.session.add(msg)
conversation.last_message_at = datetime.utcnow()
db.session.commit()
if flagged:
current_app.logger.warning(
"High contact-density message id=%s conv=%s sender=%s",
msg.id, conversation.id, sender.id)
_notify_recipient(conversation, sender, msg)
return msg
def mark_conversation_read(conversation, reader):
"""Mark all messages NOT sent by reader as read."""
changed = False
for msg in conversation.messages:
if msg.sender_id != reader.id and msg.read_at is None:
msg.mark_read()
changed = True
if changed:
db.session.commit()
def inbox(user, page=1, per_page=20):
"""Conversations where user is buyer or seller, newest first."""
return (Conversation.query
.filter(db.or_(
Conversation.buyer_id == user.id,
Conversation.seller_id == user.id,
))
.order_by(Conversation.last_message_at.desc().nullslast(),
Conversation.created_at.desc())
.paginate(page=page, per_page=per_page, error_out=False))
def total_unread(user) -> int:
"""Total unread message count across all conversations (for nav badge)."""
convs = Conversation.query.filter(
db.or_(
Conversation.buyer_id == user.id,
Conversation.seller_id == user.id,
)
).all()
return sum(c.unread_count(user) for c in convs)
def _notify_recipient(conversation, sender, message):
recipient = (conversation.seller
if sender.id == conversation.buyer_id
else conversation.buyer)
try:
subject = f"New message about: {conversation.listing.title[:60]}"
body = (
f"Hi {recipient.display_name},\n\n"
f"{sender.display_name} sent you a message about "
f'"{conversation.listing.title}":\n\n'
f"{message.body[:500]}\n\n"
f"Reply at: /messages/{conversation.id}\n"
)
send_email(recipient.email, subject, body)
except Exception as exc:
current_app.logger.error("Message notification failed: %s", exc)
+29
View File
@@ -99,3 +99,32 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px} .thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px}
.thumb-wrap form{position:absolute;top:2px;right:2px;margin:0} .thumb-wrap form{position:absolute;top:2px;right:2px;margin:0}
@media(max-width:760px){.browse,.detail{grid-template-columns:1fr}} @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}
+5
View File
@@ -16,6 +16,11 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a href="{{ url_for('listings.create') }}">{{ _('Post') }}</a> <a href="{{ url_for('listings.create') }}">{{ _('Post') }}</a>
<a href="{{ url_for('listings.mine') }}">{{ _('My listings') }}</a> <a href="{{ url_for('listings.mine') }}">{{ _('My listings') }}</a>
<a href="{{ url_for('messaging.my_favorites') }}">{{ _('Saved') }}</a>
<a href="{{ url_for('messaging.inbox') }}" class="msg-link">
{{ _('Messages') }}
{% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %}
</a>
<span class="hi">{{ current_user.display_name }}</span> <span class="hi">{{ current_user.display_name }}</span>
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a> <a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a>
{% else %} {% else %}
+14 -1
View File
@@ -52,7 +52,20 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn danger" type="submit">{{ _('Delete') }}</button> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn danger" type="submit">{{ _('Delete') }}</button>
</form> </form>
{% else %} {% else %}
<p class="muted small">{{ _('Messaging arrives in Phase 3.') }}</p> {% if current_user.is_authenticated %}
<a class="btn" href="{{ url_for('messaging.start_conversation', listing_id=listing.id) }}">
{{ _('Contact seller') }}
</a>
<form method="post"
action="{{ url_for('messaging.toggle_favorite', listing_id=listing.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost" type="submit">{{ _('♥ Save listing') }}</button>
</form>
{% else %}
<a class="btn" href="{{ url_for('auth.login') }}?next={{ request.path }}">
{{ _('Sign in to contact') }}
</a>
{% endif %}
{% endif %} {% endif %}
</aside> </aside>
</article> </article>
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Conversation') }}{% endblock %}
{% block content %}
<div class="conv-wrap">
<div class="conv-header card">
<a class="muted small" href="{{ url_for('messaging.inbox') }}">&larr; {{ _('Inbox') }}</a>
<h3>{{ conv.listing.title }}</h3>
<span class="muted small">{{ _('with') }} {{ other.display_name }}</span>
{% if not reveal %}
<p class="mask-notice muted small">
{{ _('Phone numbers, emails, and links are hidden until your account is trusted. Verify your email and build trust to unlock contact info.') }}
</p>
{% endif %}
</div>
<div class="thread">
{% for msg, body in masked_messages %}
<div class="bubble {{ 'mine' if msg.sender_id == current_user.id else 'theirs' }}">
<div class="bubble-body">{{ body | replace('\n','<br>') | safe }}</div>
<div class="bubble-meta muted small">
{{ msg.sender.display_name }} · {{ msg.created_at.strftime('%b %d %H:%M') }}
{% if msg.sender_id == current_user.id and msg.is_read %}· {{ _('Read') }}{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="reply-box card">
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.body) }}
{{ form.submit(class="btn") }}
</form>
</div>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ _('Saved listings') }}{% endblock %}
{% block content %}
<div class="card">
<h2>{{ _('Saved listings') }}</h2>
{% if not pagination.items %}
<p class="muted">{{ _('No saved listings yet.') }}</p>
{% endif %}
<div class="grid">
{% for fav in pagination.items %}
{% set l = fav.listing %}
<a class="tile card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
{% if l.cover %}
<img class="thumb" src="{{ url_for('listings.media', rel=l.cover.thumb_path) }}" alt="">
{% else %}
<div class="thumb noimg">{{ _('No photo') }}</div>
{% endif %}
<div class="tile-body">
<div class="tile-title">{{ l.title }}</div>
<div class="muted small">{{ l.price_display or '—' }}</div>
</div>
</a>
{% endfor %}
</div>
<div class="pager">
{% if pagination.has_prev %}
<a href="{{ url_for('messaging.my_favorites', page=pagination.prev_num) }}">&larr; {{ _('Prev') }}</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('messaging.my_favorites', page=pagination.next_num) }}">{{ _('Next') }} &rarr;</a>
{% endif %}
</div>
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}{{ _('Messages') }}{% endblock %}
{% block content %}
<div class="card">
<div class="inbox-head">
<h2>{{ _('Messages') }}</h2>
{% if unread_total %}<span class="badge-count">{{ unread_total }}</span>{% endif %}
</div>
{% if not pagination.items %}
<p class="muted">{{ _('No conversations yet.') }}</p>
{% endif %}
<ul class="conv-list">
{% for conv in pagination.items %}
{% set unread = conv.unread_count(current_user) %}
{% set other = conv.other_party(current_user) %}
<li class="conv-row {{ 'unread' if unread }}">
<a href="{{ url_for('messaging.conversation', conv_id=conv.id) }}">
<div class="conv-meta">
<span class="conv-who">{{ other.display_name }}</span>
{% if unread %}<span class="badge-count sm">{{ unread }}</span>{% endif %}
<span class="conv-time muted small">
{{ conv.last_message_at.strftime('%b %d') if conv.last_message_at else '' }}
</span>
</div>
<div class="conv-listing muted small">
{{ _('Re:') }} {{ conv.listing.title[:60] }}
</div>
{% if conv.messages %}
<div class="conv-preview muted small">
{{ conv.messages[-1].body[:80] }}…
</div>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
<div class="pager">
{% if pagination.has_prev %}
<a href="{{ url_for('messaging.inbox', page=pagination.prev_num) }}">&larr; {{ _('Prev') }}</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('messaging.inbox', page=pagination.next_num) }}">{{ _('Next') }} &rarr;</a>
{% endif %}
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Contact seller') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Contact seller') }}</h2>
<p class="muted">{{ _('Re:') }} <a href="{{ url_for('listings.detail', listing_id=listing.id) }}">{{ listing.title }}</a></p>
{% if not created %}
<p class="muted small">{{ _('You already have a conversation about this listing.') }}
<a href="{{ url_for('messaging.conversation', conv_id=conv.id) }}">{{ _('View it') }}</a>
</p>
{% endif %}
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.body) }}
{{ form.submit(class="btn") }}
</form>
</div>
{% endblock %}
+151 -1
View File
@@ -37,6 +37,155 @@ def _csrf(html):
return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1) return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1)
def _phase3(app):
"""Phase 3: messaging, favorites, contact masking."""
from app.models.user import User
from app.models.plan import Plan
from app.models.category import Category
from app.models.listing import Listing
from app.models.messaging import Conversation, Message
from app.models.enums import TrustTier
from app.services import messaging as msvc
from app.services import favorites as fsvc
from app.services.contact import mask_body, contact_revealed, contact_density
with app.app_context():
# create a second user (buyer)
buyer = User(email="buyer@example.com", display_name="Buyer",
email_verified=True)
buyer.set_password("BuyerPass123")
buyer.tier_id = Plan.query.filter_by(slug="free").first().id
buyer.trust_tier = TrustTier.trusted # can see contact info
db.session.add(buyer); db.session.commit()
seller = User.query.filter_by(email="t@example.com").first()
fs = Category.query.filter_by(slug="for-sale").first()
listing = Listing.query.filter_by(user_id=seller.id).first()
# --- conversation create ---
conv, created = msvc.get_or_create_conversation(listing, buyer)
assert created and conv.buyer_id == buyer.id
assert conv.seller_id == seller.id
print("conversation created: ok")
# idempotent: get same conv again
conv2, created2 = msvc.get_or_create_conversation(listing, buyer)
assert conv2.id == conv.id and not created2
print("conversation idempotent: ok")
# seller can't message own listing
try:
msvc.get_or_create_conversation(listing, seller)
assert False, "should have raised"
except msvc.MessagingError:
pass
print("self-message blocked: ok")
# --- send message ---
msg1 = msvc.send_message(conv, buyer, "Hi, is this still available?")
assert msg1.sender_id == buyer.id and msg1.read_at is None
assert conv.last_message_at is not None
print("send message: ok")
# seller replies
msg2 = msvc.send_message(conv, seller, "Yes, come pick it up tomorrow.")
assert len(conv.messages) == 2
print("reply: ok")
# --- unread count ---
# buyer: 1 unread (seller's reply)
# seller: 1 unread (buyer's opening message)
unread_buyer = conv.unread_count(buyer)
unread_seller = conv.unread_count(seller)
assert unread_buyer == 1, unread_buyer
assert unread_seller == 1, unread_seller
print(f"unread count: ok (buyer={unread_buyer}, seller={unread_seller})")
# --- mark read ---
msvc.mark_conversation_read(conv, buyer)
assert conv.unread_count(buyer) == 0
print("mark read: ok")
# --- total unread ---
total = msvc.total_unread(seller)
assert total >= 1
print(f"total unread: ok (seller sees {total})")
# --- inbox ---
page = msvc.inbox(buyer)
assert any(c.id == conv.id for c in page.items)
print("inbox: ok")
# --- contact masking ---
spammy = "Call me at 703-555-0192 or email me@test.com to buy!"
assert contact_density(spammy) == 2
masked = mask_body(spammy, reveal=False)
assert "703" not in masked and "me@test.com" not in masked
revealed = mask_body(spammy, reveal=True)
assert "703-555-0192" in revealed
print("contact masking: ok")
# trusted user can reveal; new-trust cannot
assert contact_revealed(buyer) # trust_tier=trusted + email_verified
seller.trust_tier = TrustTier.new
db.session.commit()
assert not contact_revealed(seller)
print("contact reveal gating by trust tier: ok")
# --- favorites ---
now_fav = fsvc.toggle_favorite(buyer.id, listing.id)
assert now_fav
assert fsvc.is_favorited(buyer.id, listing.id)
page = fsvc.user_favorites(buyer.id)
assert any(f.listing_id == listing.id for f in page.items)
print("favorite add: ok")
now_fav = fsvc.toggle_favorite(buyer.id, listing.id)
assert not now_fav
assert not fsvc.is_favorited(buyer.id, listing.id)
print("favorite remove: ok")
# --- empty body rejected ---
try:
msvc.send_message(conv, buyer, " ")
assert False
except msvc.MessagingError:
pass
print("empty message rejected: ok")
# --- route render checks ---
import re
c = app.test_client(); B = "https://localhost"
def csrf(h): return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', h).group(1)
# login as buyer
r = c.get("/auth/login", base_url=B)
tok = csrf(r.get_data(as_text=True))
c.post("/auth/login", base_url=B,
data={"csrf_token": tok, "email": "buyer@example.com",
"password": "BuyerPass123"},
headers={"Referer": B + "/auth/login"}, follow_redirects=True)
with app.app_context():
conv_id = Conversation.query.filter_by(
buyer_id=User.query.filter_by(email="buyer@example.com").first().id
).first().id
listing_id = Listing.query.first().id
for path, expect in [
("/messages", 200),
(f"/messages/{conv_id}", 200),
("/my/favorites", 200),
(f"/listings/{listing_id}/contact", 200),
]:
code = c.get(path, base_url=B).status_code
assert code == expect, f"{path} -> {code}"
print(f"{code} {path}")
print("Phase 3 route renders: ok")
def run(): def run():
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
@@ -133,6 +282,7 @@ def run():
print("home / healthz / 404: ok") print("home / healthz / 404: ok")
_phase2(app) _phase2(app)
_phase3(app)
print("\nALL SMOKE CHECKS PASSED") print("\nALL SMOKE CHECKS PASSED")
@@ -241,7 +391,7 @@ def _phase2(app):
user_id=user.id, status=ListingStatus.active).count() user_id=user.id, status=ListingStatus.active).count()
assert n == 1, n assert n == 1, n
assert active_status_after == active_status_before - 1 assert active_status_after == active_status_before - 1
assert Listing.query.get(l2.id).status == ListingStatus.expired assert db.session.get(Listing, l2.id).status == ListingStatus.expired
print(f"expiry sweep: ok (status active {active_status_before}->{active_status_after})") print(f"expiry sweep: ok (status active {active_status_before}->{active_status_after})")