diff --git a/backend/config/__init__.py b/backend/config/__init__.py index e69de29..8a0ea45 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -0,0 +1,4 @@ +from .celery import app as celery_app + + +__all__ = ("celery_app",) diff --git a/backend/config/celery.py b/backend/config/celery.py new file mode 100644 index 0000000..b8d7892 --- /dev/null +++ b/backend/config/celery.py @@ -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() diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 1fdca99..3bc44b3 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -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", + }, +} diff --git a/backend/market/migrations/0006_listing_status_listing_market_list_status_0933cf_idx.py b/backend/market/migrations/0006_listing_status_listing_market_list_status_0933cf_idx.py new file mode 100644 index 0000000..0cfcc73 --- /dev/null +++ b/backend/market/migrations/0006_listing_status_listing_market_list_status_0933cf_idx.py @@ -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"), + ), + ] diff --git a/backend/market/migrations/0007_set_existing_listings_approved.py b/backend/market/migrations/0007_set_existing_listings_approved.py new file mode 100644 index 0000000..8f3e542 --- /dev/null +++ b/backend/market/migrations/0007_set_existing_listings_approved.py @@ -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, + ), + ] diff --git a/backend/market/models.py b/backend/market/models.py index 7812865..69a0018 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -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) @@ -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" ) @@ -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}" @@ -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 diff --git a/backend/market/serializers.py b/backend/market/serializers.py index c129131..fa5bbb5 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -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 @@ -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 = { @@ -176,6 +176,7 @@ class Meta: "negotiable", "created_at", "expires_at", + "status", "images", "listing_type", "additional_data", @@ -188,6 +189,7 @@ class Meta: "buyers", "images", "favorites", + "status", ] def validate(self, attrs): @@ -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() @@ -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: @@ -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", {}) @@ -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: @@ -395,6 +419,7 @@ class Meta: "price", "negotiable", "expires_at", + "status", "images", "favorite_count", "listing_type", @@ -434,6 +459,7 @@ class Meta: "title", "price", "expires_at", + "status", "images", "favorite_count", "listing_type", diff --git a/backend/market/tasks.py b/backend/market/tasks.py new file mode 100644 index 0000000..dde5875 --- /dev/null +++ b/backend/market/tasks.py @@ -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"]) diff --git a/backend/market/views.py b/backend/market/views.py index d61d749..c30b616 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -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) @@ -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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 87336d2..d69c9ec 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "inflection", "firebase-admin", "twilio", + "requests", ] [dependency-groups] diff --git a/backend/utils/moderation.py b/backend/utils/moderation.py new file mode 100644 index 0000000..0abca71 --- /dev/null +++ b/backend/utils/moderation.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock index 5b281ea..4fb2a08 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -34,6 +34,7 @@ dependencies = [ { name = "python-dateutil" }, { name = "pyyaml" }, { name = "redis" }, + { name = "requests" }, { name = "selenium" }, { name = "sentry-sdk" }, { name = "twilio" }, @@ -87,6 +88,7 @@ requires-dist = [ { name = "python-dateutil" }, { name = "pyyaml" }, { name = "redis" }, + { name = "requests" }, { name = "selenium" }, { name = "sentry-sdk" }, { name = "twilio" }, diff --git a/docker-compose.yml b/docker-compose.yml index 8b98363..3419c44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,29 @@ services: - REDIS_URL=redis://redis:6379/0 command: sh -c "uv run python manage.py migrate && uv run python manage.py runserver 0.0.0.0:8000" + celery: + platform: linux/amd64 + build: + context: ./backend + dockerfile: Dockerfile + target: dev + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./backend:/app + - penn-marketplace-backend-venv:/app/.venv + env_file: + - ./backend/.env + environment: + - PYTHONUNBUFFERED=1 + - DJANGO_SETTINGS_MODULE=config.settings.development + - DATABASE_URL=postgres://postgres:postgres@db:5432/penn_marketplace + - REDIS_URL=redis://redis:6379/0 + command: uv run celery -A config worker --loglevel=info + frontend: build: context: ./frontend diff --git a/frontend/components/listings/ListingsCard.tsx b/frontend/components/listings/ListingsCard.tsx index 6092ca3..8e0bcf1 100644 --- a/frontend/components/listings/ListingsCard.tsx +++ b/frontend/components/listings/ListingsCard.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; import { Calendar } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { formatPrice, formatCondition, formatDate } from "@/lib/utils"; +import { formatPrice, formatCondition, formatDate, cn } from "@/lib/utils"; import { Item, Sublet } from "@/lib/types"; const DEFAULT_IMAGE = "/images/default-image.jpg"; @@ -58,7 +58,12 @@ export const ListingsCard = ({ listing, previewImageUrl, href, isMyListing = fal return ( {/* image container */}
@@ -77,6 +82,20 @@ export const ListingsCard = ({ listing, previewImageUrl, href, isMyListing = fal
)} + {isMyListing && listing.status === "PENDING" && ( +
+ + Under Review + +
+ )} + {isMyListing && listing.status === "REJECTED" && ( +
+ + Rejected + +
+ )}
{getBadgeContent(listing)} diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index 358023e..ac18a06 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -81,6 +81,16 @@ export const ListingDetail = ({ listingId }: Props) => {
+ {listing.status === "PENDING" && ( +
+ This listing is under review and is not yet visible to others. +
+ )} + {listing.status === "REJECTED" && ( +
+ This listing was rejected for violating our content policy and is not visible to others. +
+ )}
diff --git a/frontend/components/listings/form/ItemForm.tsx b/frontend/components/listings/form/ItemForm.tsx index bd814f2..d29b2af 100644 --- a/frontend/components/listings/form/ItemForm.tsx +++ b/frontend/components/listings/form/ItemForm.tsx @@ -50,7 +50,7 @@ export function ItemForm() { const { mutate, isPending } = useMutation({ mutationFn: createListing, onSuccess: (data) => { - toast.success(`${DISPLAY_LABEL} created successfully!`); + toast.success(`${DISPLAY_LABEL} submitted and is under review.`); queryClient.invalidateQueries({ queryKey: ["items"] }); reset(); imageUpload.clearImages(); diff --git a/frontend/components/listings/form/SubletForm.tsx b/frontend/components/listings/form/SubletForm.tsx index 34f9412..e8ea3ba 100644 --- a/frontend/components/listings/form/SubletForm.tsx +++ b/frontend/components/listings/form/SubletForm.tsx @@ -60,7 +60,7 @@ export function SubletForm() { const { mutate, isPending } = useMutation({ mutationFn: createListing, onSuccess: (data) => { - toast.success(`${DISPLAY_LABEL} created successfully!`); + toast.success(`${DISPLAY_LABEL} submitted and is under review.`); queryClient.invalidateQueries({ queryKey: ["sublets"] }); reset(); imageUpload.clearImages(); diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index c21cf62..5079cce 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -109,6 +109,11 @@ export type ValidatedAddress = { placeId: number; }; +// ------------------------------------------------------------ +// listing status (content moderation) +// ------------------------------------------------------------ +export type ListingStatus = "PENDING" | "APPROVED" | "REJECTED"; + // ------------------------------------------------------------ // base listing fields (shared by all listings) // ------------------------------------------------------------ @@ -126,6 +131,7 @@ type BaseListing = { favorite_count: number; is_favorited?: boolean; seller: User; + status: ListingStatus; }; // ------------------------------------------------------------