06/18 Phase 7
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""Listing expiry warning and expired notification emails.
|
||||
|
||||
Two sweeps (called from nightly systemd timer):
|
||||
- warn_expiring(days=3): send one warning email per listing expiring within N days
|
||||
that hasn't already received one.
|
||||
- notify_expired(): send "your listing expired" email for newly-expired listings
|
||||
that haven't been notified yet.
|
||||
|
||||
We track state via a JSON field on the listing. Rather than adding columns, we
|
||||
use the existing `attributes` JSON and a private `_notified` sub-key so no migration
|
||||
is required. The key is prefixed with `_` to avoid colliding with user attributes.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from flask import current_app
|
||||
from app.extensions import db
|
||||
from app.models.listing import Listing
|
||||
from app.models.enums import ListingStatus
|
||||
from app.services.email import send_email
|
||||
|
||||
|
||||
def _already_notified(listing, key: str) -> bool:
|
||||
attrs = listing.attributes or {}
|
||||
return bool(attrs.get(key))
|
||||
|
||||
|
||||
def _mark_notified(listing, key: str):
|
||||
attrs = dict(listing.attributes or {})
|
||||
attrs[key] = datetime.utcnow().isoformat()
|
||||
listing.attributes = attrs
|
||||
|
||||
|
||||
def warn_expiring(days: int = 3) -> int:
|
||||
"""Send warning emails for listings expiring within `days` days. Returns count."""
|
||||
now = datetime.utcnow()
|
||||
window_end = now + timedelta(days=days)
|
||||
soon = (Listing.query
|
||||
.filter(Listing.status == ListingStatus.active,
|
||||
Listing.expires_at > now,
|
||||
Listing.expires_at <= window_end)
|
||||
.all())
|
||||
sent = 0
|
||||
for listing in soon:
|
||||
if _already_notified(listing, "_warn_sent"):
|
||||
continue
|
||||
user = listing.user
|
||||
if not user or not user.email:
|
||||
continue
|
||||
days_left = (listing.expires_at - now).days
|
||||
subject = f"Your listing '{listing.title[:50]}' expires in {days_left} day(s)"
|
||||
body = (
|
||||
f"Hi {user.display_name},\n\n"
|
||||
f"Your listing \"{listing.title}\" will expire in {days_left} day(s) "
|
||||
f"({listing.expires_at.strftime('%Y-%m-%d')}).\n\n"
|
||||
f"To keep it active, edit and re-save it, or purchase a boost.\n\n"
|
||||
f"View your listing: {current_app.config.get('SERVER_NAME', '')}/listings/{listing.id}\n\n"
|
||||
f"— Classifieds"
|
||||
)
|
||||
if send_email(user.email, subject, body):
|
||||
_mark_notified(listing, "_warn_sent")
|
||||
db.session.commit()
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
|
||||
def notify_expired() -> int:
|
||||
"""Send 'your listing expired' emails for newly-expired listings. Returns count."""
|
||||
expired = (Listing.query
|
||||
.filter(Listing.status == ListingStatus.expired)
|
||||
.all())
|
||||
sent = 0
|
||||
for listing in expired:
|
||||
if _already_notified(listing, "_expired_sent"):
|
||||
continue
|
||||
user = listing.user
|
||||
if not user or not user.email:
|
||||
continue
|
||||
subject = f"Your listing '{listing.title[:50]}' has expired"
|
||||
body = (
|
||||
f"Hi {user.display_name},\n\n"
|
||||
f"Your listing \"{listing.title}\" expired on "
|
||||
f"{listing.expires_at.strftime('%Y-%m-%d')}.\n\n"
|
||||
f"To re-list it, create a new listing or upgrade your plan for longer listing life.\n\n"
|
||||
f"— Classifieds"
|
||||
)
|
||||
if send_email(user.email, subject, body):
|
||||
_mark_notified(listing, "_expired_sent")
|
||||
db.session.commit()
|
||||
sent += 1
|
||||
return sent
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Review business logic."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.extensions import db
|
||||
from app.models.review import Review
|
||||
|
||||
|
||||
class ReviewError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def create_review(listing, author, rating: int, body: str | None = None):
|
||||
"""Leave a review for a seller. Listing must be sold. Returns Review."""
|
||||
from app.models.enums import ListingStatus
|
||||
if listing.status != ListingStatus.sold:
|
||||
raise ReviewError("can only review sold listings")
|
||||
if listing.user_id == author.id:
|
||||
raise ReviewError("cannot review your own listing")
|
||||
if rating not in range(1, 6):
|
||||
raise ReviewError("rating must be 1–5")
|
||||
|
||||
review = Review(
|
||||
listing_id=listing.id,
|
||||
author_id=author.id,
|
||||
seller_id=listing.user_id,
|
||||
rating=rating,
|
||||
body=(body or "").strip() or None,
|
||||
)
|
||||
db.session.add(review)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
raise ReviewError("you have already reviewed this listing")
|
||||
return review
|
||||
|
||||
|
||||
def seller_rating(user_id) -> dict:
|
||||
"""Return avg rating and count for a seller."""
|
||||
from app.extensions import db
|
||||
result = (db.session.query(
|
||||
db.func.round(db.func.avg(Review.rating), 1).label("avg"),
|
||||
db.func.count(Review.id).label("count"))
|
||||
.filter(Review.seller_id == user_id)
|
||||
.first())
|
||||
return {
|
||||
"avg": float(result.avg) if result.avg else None,
|
||||
"count": result.count or 0,
|
||||
}
|
||||
Reference in New Issue
Block a user