Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .celery import app as celery_app


__all__ = ("celery_app",)
10 changes: 10 additions & 0 deletions backend/config/celery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import os

from celery import Celery


os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")

app = Celery("penn_marketplace")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
32 changes: 32 additions & 0 deletions backend/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,35 @@
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
TWILIO_PHONE_NUMBER = os.environ.get("TWILIO_PHONE_NUMBER", "")
PHONE_VERIFICATION_CODE_EXPIRY_MINUTES = 10

OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")

# Celery
CELERY_BROKER_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL", "redis://redis:6379/0")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True

# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "[{asctime}] {levelname} {name}: {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.0.2 on 2026-03-29 18:52

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("market", "0005_sublet_true_latitude_sublet_true_longitude"),
]

operations = [
migrations.AddField(
model_name="listing",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
],
db_index=True,
default="PENDING",
max_length=10,
),
),
migrations.AddIndex(
model_name="listing",
index=models.Index(fields=["status"], name="market_list_status_0933cf_idx"),
),
]
19 changes: 19 additions & 0 deletions backend/market/migrations/0007_set_existing_listings_approved.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2026-03-29 18:57

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("market", "0006_listing_status_listing_market_list_status_0933cf_idx"),
]

operations = [
migrations.RunPython(
lambda apps, schema_editor: apps.get_model("market", "Listing")
.objects.all()
.update(status="APPROVED"),
migrations.RunPython.noop,
),
]
13 changes: 12 additions & 1 deletion backend/market/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class Meta:
def __str__(self):
return f"Offer for {self.listing} made by {self.user}"


class Category(models.Model):
name = models.CharField(max_length=100, unique=True)

Expand All @@ -72,8 +73,14 @@ class Meta:
models.Index(fields=["created_at"]),
models.Index(fields=["expires_at"]),
models.Index(fields=["negotiable"]),
models.Index(fields=["status"]),
]

class Status(models.TextChoices):
PENDING = "PENDING", "Pending"
APPROVED = "APPROVED", "Approved"
REJECTED = "REJECTED", "Rejected"

seller = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="listings_created"
)
Expand All @@ -94,6 +101,9 @@ class Meta:
negotiable = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
status = models.CharField(
max_length=10, choices=Status.choices, default=Status.PENDING, db_index=True
)

def __str__(self):
return f"{self.title} by {self.seller}"
Expand Down Expand Up @@ -170,7 +180,8 @@ def _calculate_approximate_location(self, latitude, longitude):
def approximate_location(self):
if self.latitude is not None and self.longitude is not None:
approximate_location = self._calculate_approximate_location(
self.latitude, self.longitude)
self.latitude, self.longitude
)
return approximate_location
return None, None

Expand Down
32 changes: 29 additions & 3 deletions backend/market/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as ModelValidationError
from profanity_check import predict
Expand Down Expand Up @@ -126,6 +125,7 @@ def get_longitude(self, obj):
return float(approx_lon)
return None


# Unified serializer for all listing types (Items and Sublets); used for CRUD operations
class ListingSerializer(ListingTypeMixin, ModelSerializer):
LISTING_TYPE_CONFIG = {
Expand Down Expand Up @@ -176,6 +176,7 @@ class Meta:
"negotiable",
"created_at",
"expires_at",
"status",
"images",
"listing_type",
"additional_data",
Expand All @@ -188,6 +189,7 @@ class Meta:
"buyers",
"images",
"favorites",
"status",
]

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

try:
return create_method(validated_data, additional_data)
instance = create_method(validated_data, additional_data)
except ModelValidationError as e:
raise ValidationError(
e.message_dict if hasattr(e, "message_dict") else e.messages
) from e

from django.db import transaction

from market.tasks import moderate_listing_task

transaction.on_commit(lambda: moderate_listing_task.delay(instance.id))
return instance

def _create_item(self, validated_data, additional_data):
category_name = additional_data.get("category")
category = Category.objects.filter(name=category_name).first()
Expand Down Expand Up @@ -291,7 +300,6 @@ def _create_sublet(self, validated_data, additional_data):
latitude = additional_data.get("latitude")
longitude = additional_data.get("longitude")


if latitude is not None:
latitude = float(latitude)
if longitude is not None:
Expand All @@ -314,6 +322,8 @@ def _create_sublet(self, validated_data, additional_data):
return sublet

def update(self, instance, validated_data):
old_title = instance.title
old_description = instance.description
listing_type = self.initial_data.get("listing_type")
additional_data = self.initial_data.get("additional_data", {})

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

instance.save()

# TODO: needs to also validate images when implemented
content_changed = (
instance.title != old_title or instance.description != old_description
)
if content_changed:
from django.db import transaction

from market.tasks import moderate_listing_task

instance.status = Listing.Status.PENDING
instance.save(update_fields=["status"])
transaction.on_commit(lambda: moderate_listing_task.delay(instance.id))

return instance

except ModelValidationError as e:
Expand Down Expand Up @@ -395,6 +419,7 @@ class Meta:
"price",
"negotiable",
"expires_at",
"status",
"images",
"favorite_count",
"listing_type",
Expand Down Expand Up @@ -434,6 +459,7 @@ class Meta:
"title",
"price",
"expires_at",
"status",
"images",
"favorite_count",
"listing_type",
Expand Down
53 changes: 53 additions & 0 deletions backend/market/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import logging

from celery import shared_task


logger = logging.getLogger(__name__)


@shared_task(
bind=True,
max_retries=3,
default_retry_delay=10,
autoretry_for=(Exception,),
retry_backoff=True,
)
def moderate_listing_task(self, listing_id):
from market.models import Listing
from utils.moderation import moderate_content

try:
listing = Listing.objects.get(id=listing_id)
except Listing.DoesNotExist:
logger.warning("Listing %s not found for moderation, skipping.", listing_id)
return

if listing.status != Listing.Status.PENDING:
logger.info(
"Listing %s status is %s (not PENDING), skipping.",
listing_id,
listing.status,
)
return

text = f"{listing.title} {listing.description or ''}"

image_urls = []
for img in listing.images.all():
if img.image and img.image.url.startswith("http"):
image_urls.append(img.image.url)

logger.info(
"Moderating listing %s: text=%r, image_count=%d",
listing_id,
text[:100],
len(image_urls),
)

is_safe = moderate_content(text, image_urls)

status = Listing.Status.APPROVED if is_safe else Listing.Status.REJECTED
logger.info("Listing %s moderation result: %s", listing_id, status)
listing.status = status
listing.save(update_fields=["status"])
7 changes: 5 additions & 2 deletions backend/market/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,11 @@ def list(self, request, *args, **kwargs):
if request.query_params.get("seller", "false").lower() == "true":
queryset = queryset.filter(seller=request.user)
else:
# Show listings that are not expired, or have no expiration
# Show only approved listings that are not expired
now = timezone.now()
queryset = queryset.filter(
Q(expires_at__gte=now) | Q(expires_at__isnull=True)
Q(expires_at__gte=now) | Q(expires_at__isnull=True),
status=Listing.Status.APPROVED,
)

page = self.paginate_queryset(queryset)
Expand All @@ -188,6 +189,8 @@ def retrieve(self, request, *args, **kwargs):
if instance.seller == request.user:
serializer_class = ListingSerializer
else:
if instance.status != Listing.Status.APPROVED:
raise exceptions.NotFound()
serializer_class = ListingSerializerPublic
serializer = serializer_class(instance, context={"request": request})
return Response(serializer.data)
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies = [
"inflection",
"firebase-admin",
"twilio",
"requests",
]

[dependency-groups]
Expand Down
45 changes: 45 additions & 0 deletions backend/utils/moderation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import logging

import requests
from django.conf import settings


logger = logging.getLogger(__name__)

OPENAI_MODERATION_API_URL = "https://api.openai.com/v1/moderations"


def moderate_content(text, image_urls=None):
"""
Call OpenAI's Moderation API with text and optional image URLs.
Returns True if ALL content is safe, False if any is flagged.
"""
api_key = settings.OPENAI_API_KEY
if not api_key:
logger.warning("OPENAI_API_KEY not set, skipping moderation")
return True

inputs = [{"type": "text", "text": text}]
for url in image_urls or []:
inputs.append({"type": "image_url", "image_url": {"url": url}})

try:
response = requests.post(
OPENAI_MODERATION_API_URL,
headers={"Authorization": f"Bearer {api_key}"},
json={"model": "omni-moderation-latest", "input": inputs},
timeout=30,
)
response.raise_for_status()
result = response.json()
flagged = any(r["flagged"] for r in result["results"])
logger.info("Moderation API response: flagged=%s", flagged)
for r in result["results"]:
scores = r.get("category_scores", {})
high_scores = {k: v for k, v in scores.items() if v > 0.01}
if high_scores:
logger.info(" high scores: %s", high_scores)
return not flagged
except Exception:
logger.exception("Moderation API call failed, defaulting to approved")
return True
2 changes: 2 additions & 0 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading