Skip to content

Commit 89508f3

Browse files
committed
add backend support for listing moderation
1 parent 3a77b8d commit 89508f3

13 files changed

Lines changed: 266 additions & 6 deletions

backend/config/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .celery import app as celery_app
2+
3+
4+
__all__ = ("celery_app",)

backend/config/celery.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import os
2+
3+
from celery import Celery
4+
5+
6+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
7+
8+
app = Celery("penn_marketplace")
9+
app.config_from_object("django.conf:settings", namespace="CELERY")
10+
app.autodiscover_tasks()

backend/config/settings/base.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,35 @@
124124
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
125125
TWILIO_PHONE_NUMBER = os.environ.get("TWILIO_PHONE_NUMBER", "")
126126
PHONE_VERIFICATION_CODE_EXPIRY_MINUTES = 10
127+
128+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
129+
130+
# Celery
131+
CELERY_BROKER_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
132+
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL", "redis://redis:6379/0")
133+
CELERY_ACCEPT_CONTENT = ["json"]
134+
CELERY_TASK_SERIALIZER = "json"
135+
CELERY_RESULT_SERIALIZER = "json"
136+
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
137+
138+
# Logging
139+
LOGGING = {
140+
"version": 1,
141+
"disable_existing_loggers": False,
142+
"formatters": {
143+
"verbose": {
144+
"format": "[{asctime}] {levelname} {name}: {message}",
145+
"style": "{",
146+
},
147+
},
148+
"handlers": {
149+
"console": {
150+
"class": "logging.StreamHandler",
151+
"formatter": "verbose",
152+
},
153+
},
154+
"root": {
155+
"handlers": ["console"],
156+
"level": "INFO",
157+
},
158+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 5.0.2 on 2026-03-29 18:52
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("market", "0005_sublet_true_latitude_sublet_true_longitude"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="listing",
15+
name="status",
16+
field=models.CharField(
17+
choices=[
18+
("PENDING", "Pending"),
19+
("APPROVED", "Approved"),
20+
("REJECTED", "Rejected"),
21+
],
22+
db_index=True,
23+
default="PENDING",
24+
max_length=10,
25+
),
26+
),
27+
migrations.AddIndex(
28+
model_name="listing",
29+
index=models.Index(fields=["status"], name="market_list_status_0933cf_idx"),
30+
),
31+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 5.0.2 on 2026-03-29 18:57
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("market", "0006_listing_status_listing_market_list_status_0933cf_idx"),
10+
]
11+
12+
operations = [
13+
migrations.RunPython(
14+
lambda apps, schema_editor: apps.get_model("market", "Listing")
15+
.objects.all()
16+
.update(status="APPROVED"),
17+
migrations.RunPython.noop,
18+
),
19+
]

backend/market/models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class Meta:
4747
def __str__(self):
4848
return f"Offer for {self.listing} made by {self.user}"
4949

50+
5051
class Category(models.Model):
5152
name = models.CharField(max_length=100, unique=True)
5253

@@ -72,8 +73,14 @@ class Meta:
7273
models.Index(fields=["created_at"]),
7374
models.Index(fields=["expires_at"]),
7475
models.Index(fields=["negotiable"]),
76+
models.Index(fields=["status"]),
7577
]
7678

79+
class Status(models.TextChoices):
80+
PENDING = "PENDING", "Pending"
81+
APPROVED = "APPROVED", "Approved"
82+
REJECTED = "REJECTED", "Rejected"
83+
7784
seller = models.ForeignKey(
7885
User, on_delete=models.CASCADE, related_name="listings_created"
7986
)
@@ -94,6 +101,9 @@ class Meta:
94101
negotiable = models.BooleanField(default=True)
95102
created_at = models.DateTimeField(auto_now_add=True)
96103
expires_at = models.DateTimeField(null=True, blank=True)
104+
status = models.CharField(
105+
max_length=10, choices=Status.choices, default=Status.PENDING, db_index=True
106+
)
97107

98108
def __str__(self):
99109
return f"{self.title} by {self.seller}"
@@ -170,7 +180,8 @@ def _calculate_approximate_location(self, latitude, longitude):
170180
def approximate_location(self):
171181
if self.latitude is not None and self.longitude is not None:
172182
approximate_location = self._calculate_approximate_location(
173-
self.latitude, self.longitude)
183+
self.latitude, self.longitude
184+
)
174185
return approximate_location
175186
return None, None
176187

backend/market/serializers.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
from django.contrib.auth import get_user_model
32
from django.core.exceptions import ValidationError as ModelValidationError
43
from profanity_check import predict
@@ -126,6 +125,7 @@ def get_longitude(self, obj):
126125
return float(approx_lon)
127126
return None
128127

128+
129129
# Unified serializer for all listing types (Items and Sublets); used for CRUD operations
130130
class ListingSerializer(ListingTypeMixin, ModelSerializer):
131131
LISTING_TYPE_CONFIG = {
@@ -176,6 +176,7 @@ class Meta:
176176
"negotiable",
177177
"created_at",
178178
"expires_at",
179+
"status",
179180
"images",
180181
"listing_type",
181182
"additional_data",
@@ -188,6 +189,7 @@ class Meta:
188189
"buyers",
189190
"images",
190191
"favorites",
192+
"status",
191193
]
192194

193195
def validate(self, attrs):
@@ -254,12 +256,19 @@ def create(self, validated_data):
254256
raise ValidationError({"listing_type": f"Must be one of: {valid_types}"})
255257

256258
try:
257-
return create_method(validated_data, additional_data)
259+
instance = create_method(validated_data, additional_data)
258260
except ModelValidationError as e:
259261
raise ValidationError(
260262
e.message_dict if hasattr(e, "message_dict") else e.messages
261263
) from e
262264

265+
from django.db import transaction
266+
267+
from market.tasks import moderate_listing_task
268+
269+
transaction.on_commit(lambda: moderate_listing_task.delay(instance.id))
270+
return instance
271+
263272
def _create_item(self, validated_data, additional_data):
264273
category_name = additional_data.get("category")
265274
category = Category.objects.filter(name=category_name).first()
@@ -291,7 +300,6 @@ def _create_sublet(self, validated_data, additional_data):
291300
latitude = additional_data.get("latitude")
292301
longitude = additional_data.get("longitude")
293302

294-
295303
if latitude is not None:
296304
latitude = float(latitude)
297305
if longitude is not None:
@@ -314,6 +322,8 @@ def _create_sublet(self, validated_data, additional_data):
314322
return sublet
315323

316324
def update(self, instance, validated_data):
325+
old_title = instance.title
326+
old_description = instance.description
317327
listing_type = self.initial_data.get("listing_type")
318328
additional_data = self.initial_data.get("additional_data", {})
319329

@@ -336,6 +346,20 @@ def update(self, instance, validated_data):
336346
self._update_sublet(instance, additional_data)
337347

338348
instance.save()
349+
350+
# TODO: needs to also validate images when implemented
351+
content_changed = (
352+
instance.title != old_title or instance.description != old_description
353+
)
354+
if content_changed:
355+
from django.db import transaction
356+
357+
from market.tasks import moderate_listing_task
358+
359+
instance.status = Listing.Status.PENDING
360+
instance.save(update_fields=["status"])
361+
transaction.on_commit(lambda: moderate_listing_task.delay(instance.id))
362+
339363
return instance
340364

341365
except ModelValidationError as e:
@@ -395,6 +419,7 @@ class Meta:
395419
"price",
396420
"negotiable",
397421
"expires_at",
422+
"status",
398423
"images",
399424
"favorite_count",
400425
"listing_type",
@@ -434,6 +459,7 @@ class Meta:
434459
"title",
435460
"price",
436461
"expires_at",
462+
"status",
437463
"images",
438464
"favorite_count",
439465
"listing_type",

backend/market/tasks.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import logging
2+
3+
from celery import shared_task
4+
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
@shared_task(
10+
bind=True,
11+
max_retries=3,
12+
default_retry_delay=10,
13+
autoretry_for=(Exception,),
14+
retry_backoff=True,
15+
)
16+
def moderate_listing_task(self, listing_id):
17+
from market.models import Listing
18+
from utils.moderation import moderate_content
19+
20+
try:
21+
listing = Listing.objects.get(id=listing_id)
22+
except Listing.DoesNotExist:
23+
logger.warning("Listing %s not found for moderation, skipping.", listing_id)
24+
return
25+
26+
if listing.status != Listing.Status.PENDING:
27+
logger.info(
28+
"Listing %s status is %s (not PENDING), skipping.",
29+
listing_id,
30+
listing.status,
31+
)
32+
return
33+
34+
text = f"{listing.title} {listing.description or ''}"
35+
36+
image_urls = []
37+
for img in listing.images.all():
38+
if img.image and img.image.url.startswith("http"):
39+
image_urls.append(img.image.url)
40+
41+
logger.info(
42+
"Moderating listing %s: text=%r, image_count=%d",
43+
listing_id,
44+
text[:100],
45+
len(image_urls),
46+
)
47+
48+
is_safe = moderate_content(text, image_urls)
49+
50+
status = Listing.Status.APPROVED if is_safe else Listing.Status.REJECTED
51+
logger.info("Listing %s moderation result: %s", listing_id, status)
52+
listing.status = status
53+
listing.save(update_fields=["status"])

backend/market/views.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,11 @@ def list(self, request, *args, **kwargs):
169169
if request.query_params.get("seller", "false").lower() == "true":
170170
queryset = queryset.filter(seller=request.user)
171171
else:
172-
# Show listings that are not expired, or have no expiration
172+
# Show only approved listings that are not expired
173173
now = timezone.now()
174174
queryset = queryset.filter(
175-
Q(expires_at__gte=now) | Q(expires_at__isnull=True)
175+
Q(expires_at__gte=now) | Q(expires_at__isnull=True),
176+
status=Listing.Status.APPROVED,
176177
)
177178

178179
page = self.paginate_queryset(queryset)
@@ -188,6 +189,8 @@ def retrieve(self, request, *args, **kwargs):
188189
if instance.seller == request.user:
189190
serializer_class = ListingSerializer
190191
else:
192+
if instance.status != Listing.Status.APPROVED:
193+
raise exceptions.NotFound()
191194
serializer_class = ListingSerializerPublic
192195
serializer = serializer_class(instance, context={"request": request})
193196
return Response(serializer.data)

backend/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies = [
3636
"inflection",
3737
"firebase-admin",
3838
"twilio",
39+
"requests",
3940
]
4041

4142
[dependency-groups]

0 commit comments

Comments
 (0)