06/15 Phase 3 codes
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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))
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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}>"
|
||||||
@@ -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}>"
|
||||||
@@ -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)))
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') }}">← {{ _('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 %}
|
||||||
@@ -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) }}">← {{ _('Prev') }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a href="{{ url_for('messaging.my_favorites', page=pagination.next_num) }}">{{ _('Next') }} →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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) }}">← {{ _('Prev') }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<a href="{{ url_for('messaging.inbox', page=pagination.next_num) }}">{{ _('Next') }} →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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
@@ -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})")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user