| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479 |
- 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"],
- }
|