from __future__ import annotations from rest_framework import viewsets, status from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend from django.utils import timezone from django.contrib.auth import get_user_model from .models import ( Organization, MaterialCategory, Material, PriceList, PriceListItem, Customer, CustomerSite, ServiceAgreement, PickupOrder, PickupItem, WeighTicket, WeighLine, ScrapListing, ScrapListingItem, ScrapBid, ScrapAward, ScrapListingInvite, Document, AuditLog, ) from billing.models import Invoice, InvoiceLine, Payment, Payout from .serializers import ( OrganizationSerializer, MaterialCategorySerializer, MaterialSerializer, PriceListSerializer, PriceListItemSerializer, CustomerSerializer, CustomerSiteSerializer, ServiceAgreementSerializer, PickupOrderSerializer, PickupItemSerializer, WeighTicketSerializer, WeighLineSerializer, InvoiceSerializer, InvoiceLineSerializer, PaymentSerializer, PayoutSerializer, ScrapListingSerializer, ScrapListingItemSerializer, ScrapBidSerializer, ScrapAwardSerializer, ScrapListingInviteSerializer, DocumentSerializer, AuditLogSerializer, ) from .services.billing import generate_invoice_for_pickup from .services.audit import log_action User = get_user_model() class BaseViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] filter_backends = [DjangoFilterBackend] class OrganizationViewSet(BaseViewSet): queryset = Organization.objects.all() serializer_class = OrganizationSerializer filterset_fields = {"name": ["icontains", "exact"], "code": ["exact"]} class MaterialCategoryViewSet(BaseViewSet): queryset = MaterialCategory.objects.all() serializer_class = MaterialCategorySerializer filterset_fields = {"organization": ["exact"], "name": ["icontains", "exact"]} class MaterialViewSet(BaseViewSet): queryset = Material.objects.all() serializer_class = MaterialSerializer filterset_fields = {"organization": ["exact"], "category": ["exact"], "name": ["icontains", "exact"]} class PriceListViewSet(BaseViewSet): queryset = PriceList.objects.all() serializer_class = PriceListSerializer filterset_fields = {"organization": ["exact"], "name": ["icontains", "exact"], "currency_code": ["exact"]} class PriceListItemViewSet(BaseViewSet): queryset = PriceListItem.objects.all() serializer_class = PriceListItemSerializer filterset_fields = {"price_list": ["exact"], "material": ["exact"], "direction": ["exact"]} class CustomerViewSet(BaseViewSet): queryset = Customer.objects.all() serializer_class = CustomerSerializer filterset_fields = {"organization": ["exact"], "name": ["icontains", "exact"], "email": ["icontains", "exact"]} class CustomerSiteViewSet(BaseViewSet): queryset = CustomerSite.objects.all() serializer_class = CustomerSiteSerializer filterset_fields = {"customer": ["exact"], "name": ["icontains", "exact"]} class ServiceAgreementViewSet(BaseViewSet): queryset = ServiceAgreement.objects.all() serializer_class = ServiceAgreementSerializer filterset_fields = {"customer": ["exact"], "site": ["exact"], "price_list": ["exact"]} class PickupOrderViewSet(BaseViewSet): queryset = PickupOrder.objects.select_related("customer", "site", "assigned_driver") serializer_class = PickupOrderSerializer filterset_fields = { "organization": ["exact"], "customer": ["exact"], "site": ["exact"], "status": ["exact"], "scheduled_at": ["gte", "lte"], "assigned_driver": ["exact"], } @action(detail=True, methods=["post"]) def assign(self, request, pk=None): pickup = self.get_object() driver_id = request.data.get("driver_id") if not driver_id: return Response({"detail": "driver_id required"}, status=status.HTTP_400_BAD_REQUEST) try: driver = User.objects.get(id=driver_id) except User.DoesNotExist: return Response({"detail": "Driver not found"}, status=status.HTTP_404_NOT_FOUND) pickup.assigned_driver = driver pickup.status = PickupOrder.STATUS_SCHEDULED pickup.save() try: log_action( organization=pickup.organization, user=request.user, action="pickup.assign", obj=pickup, metadata={"driver_id": driver.id}, ) except Exception: pass return Response(self.get_serializer(pickup).data) @action(detail=True, methods=["post"]) def set_status(self, request, pk=None): pickup = self.get_object() status_val = request.data.get("status") valid = dict(PickupOrder.STATUS_CHOICES) if status_val not in valid: return Response({"detail": "invalid status"}, status=status.HTTP_400_BAD_REQUEST) pickup.status = status_val if status_val == PickupOrder.STATUS_COMPLETED: pickup.completed_at = timezone.now() pickup.save() try: log_action( organization=pickup.organization, user=request.user, action="pickup.set_status", obj=pickup, metadata={"status": status_val}, ) except Exception: pass return Response(self.get_serializer(pickup).data) class PickupItemViewSet(BaseViewSet): queryset = PickupItem.objects.all() serializer_class = PickupItemSerializer filterset_fields = {"pickup": ["exact"], "material": ["exact"]} class WeighTicketViewSet(BaseViewSet): queryset = WeighTicket.objects.select_related("pickup") serializer_class = WeighTicketSerializer filterset_fields = {"pickup": ["exact"], "recorded_at": ["gte", "lte"]} @action(detail=True, methods=["post"]) def generate_invoice(self, request, pk=None): ticket = self.get_object() try: invoice = generate_invoice_for_pickup(ticket.pickup) except ValueError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) try: log_action( organization=ticket.pickup.organization, user=request.user, action="invoice.generated", obj=invoice, metadata={"pickup_id": ticket.pickup_id}, ) except Exception: pass return Response(InvoiceSerializer(invoice).data) class WeighLineViewSet(BaseViewSet): queryset = WeighLine.objects.select_related("ticket", "material") serializer_class = WeighLineSerializer filterset_fields = {"ticket": ["exact"], "material": ["exact"]} class InvoiceViewSet(BaseViewSet): queryset = Invoice.objects.select_related("customer", "pickup") serializer_class = InvoiceSerializer filterset_fields = {"organization": ["exact"], "customer": ["exact"], "status": ["exact"], "currency_code": ["exact"]} class InvoiceLineViewSet(BaseViewSet): queryset = InvoiceLine.objects.select_related("invoice") serializer_class = InvoiceLineSerializer filterset_fields = {"invoice": ["exact"], "material": ["exact"]} class PaymentViewSet(BaseViewSet): queryset = Payment.objects.select_related("invoice") serializer_class = PaymentSerializer filterset_fields = {"invoice": ["exact"], "received_at": ["gte", "lte"]} def perform_create(self, serializer): payment = serializer.save() try: org = payment.invoice.organization if payment.invoice_id else None if org: log_action( organization=org, user=self.request.user, action="payment.create", obj=payment, metadata={"invoice_id": payment.invoice_id, "amount": str(payment.amount)}, ) except Exception: pass class PayoutViewSet(BaseViewSet): queryset = Payout.objects.select_related("customer", "pickup") serializer_class = PayoutSerializer filterset_fields = {"organization": ["exact"], "customer": ["exact"], "paid_at": ["gte", "lte"]} def perform_create(self, serializer): payout = serializer.save() try: log_action( organization=payout.organization, user=self.request.user, action="payout.create", obj=payout, metadata={"amount": str(payout.amount), "customer_id": payout.customer_id}, ) except Exception: pass class ScrapListingViewSet(BaseViewSet): queryset = ScrapListing.objects.select_related("customer", "site", "organization", "created_by") serializer_class = ScrapListingSerializer filterset_fields = { "organization": ["exact"], "customer": ["exact"], "status": ["exact"], "starts_at": ["gte", "lte"], "ends_at": ["gte", "lte"], } @action(detail=True, methods=["post"]) def open(self, request, pk=None): listing = self.get_object() # Only creator's org managers/owners/customers can open self._require_listing_org_manager(request.user, listing) listing.status = ScrapListing.STATUS_OPEN listing.starts_at = listing.starts_at or timezone.now() listing.save(update_fields=["status", "starts_at"]) try: log_action( organization=listing.organization, user=request.user, action="listing.open", obj=listing, ) except Exception: pass return Response(self.get_serializer(listing).data) @action(detail=True, methods=["post"]) def close(self, request, pk=None): listing = self.get_object() self._require_listing_org_manager(request.user, listing) listing.status = ScrapListing.STATUS_CLOSED listing.ends_at = listing.ends_at or timezone.now() listing.save(update_fields=["status", "ends_at"]) try: log_action( organization=listing.organization, user=request.user, action="listing.close", obj=listing, ) except Exception: pass return Response(self.get_serializer(listing).data) def _require_listing_org_manager(self, user, listing: ScrapListing): from django.core.exceptions import PermissionDenied profile = getattr(user, "recycle_profile", None) if not profile or profile.organization_id != listing.organization_id or profile.role not in ("owner", "manager", "customer"): raise PermissionDenied("Not allowed for this listing") def perform_create(self, serializer): profile = getattr(self.request.user, "recycle_profile", None) if not profile or profile.role not in ("owner", "manager", "customer"): raise ValidationError({"detail": "Only organization owner/manager/customer can create listings"}) serializer.save(created_by=self.request.user) class ScrapListingItemViewSet(BaseViewSet): queryset = ScrapListingItem.objects.select_related("listing", "material") serializer_class = ScrapListingItemSerializer filterset_fields = {"listing": ["exact"], "material": ["exact"]} class ScrapBidViewSet(BaseViewSet): queryset = ScrapBid.objects.select_related("listing", "bidder_org", "bidder_user") serializer_class = ScrapBidSerializer filterset_fields = {"listing": ["exact"], "bidder_org": ["exact"], "status": ["exact"]} def perform_create(self, serializer): listing = serializer.validated_data.get("listing") if listing.status != ScrapListing.STATUS_OPEN: raise ValidationError({"detail": "Listing is not open for bids"}) if listing.ends_at and timezone.now() > listing.ends_at: raise ValidationError({"detail": "Listing has ended"}) # Only invited orgs if not public bidder_org = serializer.validated_data.get("bidder_org") if not listing.is_public: invited = ScrapListingInvite.objects.filter(listing=listing, invited_org=bidder_org).exists() if not invited: raise ValidationError({"detail": "Your organization is not invited to bid on this listing"}) # minimal min_increment logic if listing.min_increment: top = ( ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE) .order_by("-price_total") .values_list("price_total", flat=True) .first() ) if top is not None and serializer.validated_data.get("price_total") < top + listing.min_increment: raise ValidationError({"price_total": "Must exceed current top by min_increment"}) # Ensure bidder user belongs to bidder org and has role profile = getattr(self.request.user, "recycle_profile", None) if not profile or profile.organization_id != bidder_org.id or profile.role not in ("owner", "manager"): raise ValidationError({"detail": "You must be an owner/manager of the bidder organization"}) serializer.save(bidder_user=self.request.user) @action(detail=True, methods=["post"]) def retract(self, request, pk=None): bid = self.get_object() if bid.status != ScrapBid.STATUS_ACTIVE: return Response({"detail": "Only active bids can be retracted"}, status=status.HTTP_400_BAD_REQUEST) bid.status = ScrapBid.STATUS_RETRACTED bid.save(update_fields=["status"]) return Response(self.get_serializer(bid).data) class ScrapAwardViewSet(BaseViewSet): queryset = ScrapAward.objects.select_related("listing", "winning_bid", "pickup") serializer_class = ScrapAwardSerializer filterset_fields = {"listing": ["exact"]} @action(detail=False, methods=["post"], url_path="award-listing") def award_listing(self, request): listing_id = request.data.get("listing_id") if not listing_id: return Response({"detail": "listing_id required"}, status=status.HTTP_400_BAD_REQUEST) try: listing = ScrapListing.objects.get(id=listing_id) except ScrapListing.DoesNotExist: return Response({"detail": "Listing not found"}, status=status.HTTP_404_NOT_FOUND) # Only listing org owner/manager can award profile = getattr(request.user, "recycle_profile", None) if not profile or profile.organization_id != listing.organization_id or profile.role not in ("owner", "manager"): return Response({"detail": "Not allowed"}, status=status.HTTP_403_FORBIDDEN) if listing.status not in (ScrapListing.STATUS_OPEN, ScrapListing.STATUS_CLOSED): return Response({"detail": "Listing must be open or closed to award"}, status=status.HTTP_400_BAD_REQUEST) # Pick highest active bid meeting reserve top_bid = ( ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE) .order_by("-price_total") .first() ) if not top_bid: return Response({"detail": "No bids to award"}, status=status.HTTP_400_BAD_REQUEST) if listing.reserve_price and top_bid.price_total < listing.reserve_price: return Response({"detail": "Top bid below reserve price"}, status=status.HTTP_400_BAD_REQUEST) # Mark win/lose ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE).exclude(id=top_bid.id).update(status=ScrapBid.STATUS_REJECTED) top_bid.status = ScrapBid.STATUS_ACCEPTED top_bid.save(update_fields=["status"]) listing.status = ScrapListing.STATUS_AWARDED listing.save(update_fields=["status"]) # Optionally create a pickup order for the seller pickup = PickupOrder.objects.create( organization=listing.organization, customer=listing.customer, site=listing.site, status=PickupOrder.STATUS_SCHEDULED, notes=f"Awarded to org {top_bid.bidder_org_id} for listing {listing.id}", ) award = ScrapAward.objects.create(listing=listing, winning_bid=top_bid, pickup=pickup) try: log_action( organization=listing.organization, user=request.user, action="listing.award", obj=award, metadata={"winning_bid_id": top_bid.id, "pickup_id": pickup.id}, ) except Exception: pass return Response(self.get_serializer(award).data, status=status.HTTP_201_CREATED) class ScrapListingInviteViewSet(BaseViewSet): queryset = ScrapListingInvite.objects.select_related("listing", "invited_org", "invited_user") serializer_class = ScrapListingInviteSerializer filterset_fields = {"listing": ["exact"], "invited_org": ["exact"]} class DocumentViewSet(BaseViewSet): queryset = Document.objects.select_related("organization") serializer_class = DocumentSerializer filterset_fields = { "organization": ["exact"], "kind": ["exact", "icontains"], "content_type": ["exact"], "object_id": ["exact"], "uploaded_by": ["exact"], } def perform_create(self, serializer): doc = serializer.save(uploaded_by=self.request.user) try: log_action( organization=doc.organization, user=self.request.user, action="document.upload", obj=doc, metadata={"kind": doc.kind}, ) except Exception: pass class AuditLogViewSet(BaseViewSet): queryset = AuditLog.objects.select_related("organization", "user") serializer_class = AuditLogSerializer filterset_fields = { "organization": ["exact"], "user": ["exact"], "action": ["exact", "icontains"], "content_type": ["exact"], "object_id": ["exact"], "created_at": ["gte", "lte"], }