06/15 Phase 3 codes
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user