暫無描述

views_api.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. from __future__ import annotations
  2. from rest_framework import viewsets, status
  3. from rest_framework.exceptions import ValidationError
  4. from rest_framework.permissions import IsAuthenticated
  5. from rest_framework.decorators import action
  6. from rest_framework.response import Response
  7. from django_filters.rest_framework import DjangoFilterBackend
  8. from django.utils import timezone
  9. from django.contrib.auth import get_user_model
  10. from .models import (
  11. Organization,
  12. MaterialCategory,
  13. Material,
  14. PriceList,
  15. PriceListItem,
  16. Customer,
  17. CustomerSite,
  18. ServiceAgreement,
  19. PickupOrder,
  20. PickupItem,
  21. WeighTicket,
  22. WeighLine,
  23. ScrapListing,
  24. ScrapListingItem,
  25. ScrapBid,
  26. ScrapAward,
  27. ScrapListingInvite,
  28. Document,
  29. AuditLog,
  30. )
  31. from billing.models import Invoice, InvoiceLine, Payment, Payout
  32. from .serializers import (
  33. OrganizationSerializer,
  34. MaterialCategorySerializer,
  35. MaterialSerializer,
  36. PriceListSerializer,
  37. PriceListItemSerializer,
  38. CustomerSerializer,
  39. CustomerSiteSerializer,
  40. ServiceAgreementSerializer,
  41. PickupOrderSerializer,
  42. PickupItemSerializer,
  43. WeighTicketSerializer,
  44. WeighLineSerializer,
  45. InvoiceSerializer,
  46. InvoiceLineSerializer,
  47. PaymentSerializer,
  48. PayoutSerializer,
  49. ScrapListingSerializer,
  50. ScrapListingItemSerializer,
  51. ScrapBidSerializer,
  52. ScrapAwardSerializer,
  53. ScrapListingInviteSerializer,
  54. DocumentSerializer,
  55. AuditLogSerializer,
  56. )
  57. from .services.billing import generate_invoice_for_pickup
  58. from .services.audit import log_action
  59. User = get_user_model()
  60. class BaseViewSet(viewsets.ModelViewSet):
  61. permission_classes = [IsAuthenticated]
  62. filter_backends = [DjangoFilterBackend]
  63. class OrganizationViewSet(BaseViewSet):
  64. queryset = Organization.objects.all()
  65. serializer_class = OrganizationSerializer
  66. filterset_fields = {"name": ["icontains", "exact"], "code": ["exact"]}
  67. class MaterialCategoryViewSet(BaseViewSet):
  68. queryset = MaterialCategory.objects.all()
  69. serializer_class = MaterialCategorySerializer
  70. filterset_fields = {"organization": ["exact"], "name": ["icontains", "exact"]}
  71. class MaterialViewSet(BaseViewSet):
  72. queryset = Material.objects.all()
  73. serializer_class = MaterialSerializer
  74. filterset_fields = {"organization": ["exact"], "category": ["exact"], "name": ["icontains", "exact"]}
  75. class PriceListViewSet(BaseViewSet):
  76. queryset = PriceList.objects.all()
  77. serializer_class = PriceListSerializer
  78. filterset_fields = {"organization": ["exact"], "name": ["icontains", "exact"], "currency_code": ["exact"]}
  79. class PriceListItemViewSet(BaseViewSet):
  80. queryset = PriceListItem.objects.all()
  81. serializer_class = PriceListItemSerializer
  82. filterset_fields = {"price_list": ["exact"], "material": ["exact"], "direction": ["exact"]}
  83. class CustomerViewSet(BaseViewSet):
  84. queryset = Customer.objects.all()
  85. serializer_class = CustomerSerializer
  86. filterset_fields = {"organization": ["exact"], "name": ["icontains", "exact"], "email": ["icontains", "exact"]}
  87. class CustomerSiteViewSet(BaseViewSet):
  88. queryset = CustomerSite.objects.all()
  89. serializer_class = CustomerSiteSerializer
  90. filterset_fields = {"customer": ["exact"], "name": ["icontains", "exact"]}
  91. class ServiceAgreementViewSet(BaseViewSet):
  92. queryset = ServiceAgreement.objects.all()
  93. serializer_class = ServiceAgreementSerializer
  94. filterset_fields = {"customer": ["exact"], "site": ["exact"], "price_list": ["exact"]}
  95. class PickupOrderViewSet(BaseViewSet):
  96. queryset = PickupOrder.objects.select_related("customer", "site", "assigned_driver")
  97. serializer_class = PickupOrderSerializer
  98. filterset_fields = {
  99. "organization": ["exact"],
  100. "customer": ["exact"],
  101. "site": ["exact"],
  102. "status": ["exact"],
  103. "scheduled_at": ["gte", "lte"],
  104. "assigned_driver": ["exact"],
  105. }
  106. @action(detail=True, methods=["post"])
  107. def assign(self, request, pk=None):
  108. pickup = self.get_object()
  109. driver_id = request.data.get("driver_id")
  110. if not driver_id:
  111. return Response({"detail": "driver_id required"}, status=status.HTTP_400_BAD_REQUEST)
  112. try:
  113. driver = User.objects.get(id=driver_id)
  114. except User.DoesNotExist:
  115. return Response({"detail": "Driver not found"}, status=status.HTTP_404_NOT_FOUND)
  116. pickup.assigned_driver = driver
  117. pickup.status = PickupOrder.STATUS_SCHEDULED
  118. pickup.save()
  119. try:
  120. log_action(
  121. organization=pickup.organization,
  122. user=request.user,
  123. action="pickup.assign",
  124. obj=pickup,
  125. metadata={"driver_id": driver.id},
  126. )
  127. except Exception:
  128. pass
  129. return Response(self.get_serializer(pickup).data)
  130. @action(detail=True, methods=["post"])
  131. def set_status(self, request, pk=None):
  132. pickup = self.get_object()
  133. status_val = request.data.get("status")
  134. valid = dict(PickupOrder.STATUS_CHOICES)
  135. if status_val not in valid:
  136. return Response({"detail": "invalid status"}, status=status.HTTP_400_BAD_REQUEST)
  137. pickup.status = status_val
  138. if status_val == PickupOrder.STATUS_COMPLETED:
  139. pickup.completed_at = timezone.now()
  140. pickup.save()
  141. try:
  142. log_action(
  143. organization=pickup.organization,
  144. user=request.user,
  145. action="pickup.set_status",
  146. obj=pickup,
  147. metadata={"status": status_val},
  148. )
  149. except Exception:
  150. pass
  151. return Response(self.get_serializer(pickup).data)
  152. class PickupItemViewSet(BaseViewSet):
  153. queryset = PickupItem.objects.all()
  154. serializer_class = PickupItemSerializer
  155. filterset_fields = {"pickup": ["exact"], "material": ["exact"]}
  156. class WeighTicketViewSet(BaseViewSet):
  157. queryset = WeighTicket.objects.select_related("pickup")
  158. serializer_class = WeighTicketSerializer
  159. filterset_fields = {"pickup": ["exact"], "recorded_at": ["gte", "lte"]}
  160. @action(detail=True, methods=["post"])
  161. def generate_invoice(self, request, pk=None):
  162. ticket = self.get_object()
  163. try:
  164. invoice = generate_invoice_for_pickup(ticket.pickup)
  165. except ValueError as e:
  166. return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
  167. try:
  168. log_action(
  169. organization=ticket.pickup.organization,
  170. user=request.user,
  171. action="invoice.generated",
  172. obj=invoice,
  173. metadata={"pickup_id": ticket.pickup_id},
  174. )
  175. except Exception:
  176. pass
  177. return Response(InvoiceSerializer(invoice).data)
  178. class WeighLineViewSet(BaseViewSet):
  179. queryset = WeighLine.objects.select_related("ticket", "material")
  180. serializer_class = WeighLineSerializer
  181. filterset_fields = {"ticket": ["exact"], "material": ["exact"]}
  182. class InvoiceViewSet(BaseViewSet):
  183. queryset = Invoice.objects.select_related("customer", "pickup")
  184. serializer_class = InvoiceSerializer
  185. filterset_fields = {"organization": ["exact"], "customer": ["exact"], "status": ["exact"], "currency_code": ["exact"]}
  186. class InvoiceLineViewSet(BaseViewSet):
  187. queryset = InvoiceLine.objects.select_related("invoice")
  188. serializer_class = InvoiceLineSerializer
  189. filterset_fields = {"invoice": ["exact"], "material": ["exact"]}
  190. class PaymentViewSet(BaseViewSet):
  191. queryset = Payment.objects.select_related("invoice")
  192. serializer_class = PaymentSerializer
  193. filterset_fields = {"invoice": ["exact"], "received_at": ["gte", "lte"]}
  194. def perform_create(self, serializer):
  195. payment = serializer.save()
  196. try:
  197. org = payment.invoice.organization if payment.invoice_id else None
  198. if org:
  199. log_action(
  200. organization=org,
  201. user=self.request.user,
  202. action="payment.create",
  203. obj=payment,
  204. metadata={"invoice_id": payment.invoice_id, "amount": str(payment.amount)},
  205. )
  206. except Exception:
  207. pass
  208. class PayoutViewSet(BaseViewSet):
  209. queryset = Payout.objects.select_related("customer", "pickup")
  210. serializer_class = PayoutSerializer
  211. filterset_fields = {"organization": ["exact"], "customer": ["exact"], "paid_at": ["gte", "lte"]}
  212. def perform_create(self, serializer):
  213. payout = serializer.save()
  214. try:
  215. log_action(
  216. organization=payout.organization,
  217. user=self.request.user,
  218. action="payout.create",
  219. obj=payout,
  220. metadata={"amount": str(payout.amount), "customer_id": payout.customer_id},
  221. )
  222. except Exception:
  223. pass
  224. class ScrapListingViewSet(BaseViewSet):
  225. queryset = ScrapListing.objects.select_related("customer", "site", "organization", "created_by")
  226. serializer_class = ScrapListingSerializer
  227. filterset_fields = {
  228. "organization": ["exact"],
  229. "customer": ["exact"],
  230. "status": ["exact"],
  231. "starts_at": ["gte", "lte"],
  232. "ends_at": ["gte", "lte"],
  233. }
  234. @action(detail=True, methods=["post"])
  235. def open(self, request, pk=None):
  236. listing = self.get_object()
  237. # Only creator's org managers/owners/customers can open
  238. self._require_listing_org_manager(request.user, listing)
  239. listing.status = ScrapListing.STATUS_OPEN
  240. listing.starts_at = listing.starts_at or timezone.now()
  241. listing.save(update_fields=["status", "starts_at"])
  242. try:
  243. log_action(
  244. organization=listing.organization,
  245. user=request.user,
  246. action="listing.open",
  247. obj=listing,
  248. )
  249. except Exception:
  250. pass
  251. return Response(self.get_serializer(listing).data)
  252. @action(detail=True, methods=["post"])
  253. def close(self, request, pk=None):
  254. listing = self.get_object()
  255. self._require_listing_org_manager(request.user, listing)
  256. listing.status = ScrapListing.STATUS_CLOSED
  257. listing.ends_at = listing.ends_at or timezone.now()
  258. listing.save(update_fields=["status", "ends_at"])
  259. try:
  260. log_action(
  261. organization=listing.organization,
  262. user=request.user,
  263. action="listing.close",
  264. obj=listing,
  265. )
  266. except Exception:
  267. pass
  268. return Response(self.get_serializer(listing).data)
  269. def _require_listing_org_manager(self, user, listing: ScrapListing):
  270. from django.core.exceptions import PermissionDenied
  271. profile = getattr(user, "recycle_profile", None)
  272. if not profile or profile.organization_id != listing.organization_id or profile.role not in ("owner", "manager", "customer"):
  273. raise PermissionDenied("Not allowed for this listing")
  274. def perform_create(self, serializer):
  275. profile = getattr(self.request.user, "recycle_profile", None)
  276. if not profile or profile.role not in ("owner", "manager", "customer"):
  277. raise ValidationError({"detail": "Only organization owner/manager/customer can create listings"})
  278. serializer.save(created_by=self.request.user)
  279. class ScrapListingItemViewSet(BaseViewSet):
  280. queryset = ScrapListingItem.objects.select_related("listing", "material")
  281. serializer_class = ScrapListingItemSerializer
  282. filterset_fields = {"listing": ["exact"], "material": ["exact"]}
  283. class ScrapBidViewSet(BaseViewSet):
  284. queryset = ScrapBid.objects.select_related("listing", "bidder_org", "bidder_user")
  285. serializer_class = ScrapBidSerializer
  286. filterset_fields = {"listing": ["exact"], "bidder_org": ["exact"], "status": ["exact"]}
  287. def perform_create(self, serializer):
  288. listing = serializer.validated_data.get("listing")
  289. if listing.status != ScrapListing.STATUS_OPEN:
  290. raise ValidationError({"detail": "Listing is not open for bids"})
  291. if listing.ends_at and timezone.now() > listing.ends_at:
  292. raise ValidationError({"detail": "Listing has ended"})
  293. # Only invited orgs if not public
  294. bidder_org = serializer.validated_data.get("bidder_org")
  295. if not listing.is_public:
  296. invited = ScrapListingInvite.objects.filter(listing=listing, invited_org=bidder_org).exists()
  297. if not invited:
  298. raise ValidationError({"detail": "Your organization is not invited to bid on this listing"})
  299. # minimal min_increment logic
  300. if listing.min_increment:
  301. top = (
  302. ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE)
  303. .order_by("-price_total")
  304. .values_list("price_total", flat=True)
  305. .first()
  306. )
  307. if top is not None and serializer.validated_data.get("price_total") < top + listing.min_increment:
  308. raise ValidationError({"price_total": "Must exceed current top by min_increment"})
  309. # Ensure bidder user belongs to bidder org and has role
  310. profile = getattr(self.request.user, "recycle_profile", None)
  311. if not profile or profile.organization_id != bidder_org.id or profile.role not in ("owner", "manager"):
  312. raise ValidationError({"detail": "You must be an owner/manager of the bidder organization"})
  313. serializer.save(bidder_user=self.request.user)
  314. @action(detail=True, methods=["post"])
  315. def retract(self, request, pk=None):
  316. bid = self.get_object()
  317. if bid.status != ScrapBid.STATUS_ACTIVE:
  318. return Response({"detail": "Only active bids can be retracted"}, status=status.HTTP_400_BAD_REQUEST)
  319. bid.status = ScrapBid.STATUS_RETRACTED
  320. bid.save(update_fields=["status"])
  321. return Response(self.get_serializer(bid).data)
  322. class ScrapAwardViewSet(BaseViewSet):
  323. queryset = ScrapAward.objects.select_related("listing", "winning_bid", "pickup")
  324. serializer_class = ScrapAwardSerializer
  325. filterset_fields = {"listing": ["exact"]}
  326. @action(detail=False, methods=["post"], url_path="award-listing")
  327. def award_listing(self, request):
  328. listing_id = request.data.get("listing_id")
  329. if not listing_id:
  330. return Response({"detail": "listing_id required"}, status=status.HTTP_400_BAD_REQUEST)
  331. try:
  332. listing = ScrapListing.objects.get(id=listing_id)
  333. except ScrapListing.DoesNotExist:
  334. return Response({"detail": "Listing not found"}, status=status.HTTP_404_NOT_FOUND)
  335. # Only listing org owner/manager can award
  336. profile = getattr(request.user, "recycle_profile", None)
  337. if not profile or profile.organization_id != listing.organization_id or profile.role not in ("owner", "manager"):
  338. return Response({"detail": "Not allowed"}, status=status.HTTP_403_FORBIDDEN)
  339. if listing.status not in (ScrapListing.STATUS_OPEN, ScrapListing.STATUS_CLOSED):
  340. return Response({"detail": "Listing must be open or closed to award"}, status=status.HTTP_400_BAD_REQUEST)
  341. # Pick highest active bid meeting reserve
  342. top_bid = (
  343. ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE)
  344. .order_by("-price_total")
  345. .first()
  346. )
  347. if not top_bid:
  348. return Response({"detail": "No bids to award"}, status=status.HTTP_400_BAD_REQUEST)
  349. if listing.reserve_price and top_bid.price_total < listing.reserve_price:
  350. return Response({"detail": "Top bid below reserve price"}, status=status.HTTP_400_BAD_REQUEST)
  351. # Mark win/lose
  352. ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE).exclude(id=top_bid.id).update(status=ScrapBid.STATUS_REJECTED)
  353. top_bid.status = ScrapBid.STATUS_ACCEPTED
  354. top_bid.save(update_fields=["status"])
  355. listing.status = ScrapListing.STATUS_AWARDED
  356. listing.save(update_fields=["status"])
  357. # Optionally create a pickup order for the seller
  358. pickup = PickupOrder.objects.create(
  359. organization=listing.organization,
  360. customer=listing.customer,
  361. site=listing.site,
  362. status=PickupOrder.STATUS_SCHEDULED,
  363. notes=f"Awarded to org {top_bid.bidder_org_id} for listing {listing.id}",
  364. )
  365. award = ScrapAward.objects.create(listing=listing, winning_bid=top_bid, pickup=pickup)
  366. try:
  367. log_action(
  368. organization=listing.organization,
  369. user=request.user,
  370. action="listing.award",
  371. obj=award,
  372. metadata={"winning_bid_id": top_bid.id, "pickup_id": pickup.id},
  373. )
  374. except Exception:
  375. pass
  376. return Response(self.get_serializer(award).data, status=status.HTTP_201_CREATED)
  377. class ScrapListingInviteViewSet(BaseViewSet):
  378. queryset = ScrapListingInvite.objects.select_related("listing", "invited_org", "invited_user")
  379. serializer_class = ScrapListingInviteSerializer
  380. filterset_fields = {"listing": ["exact"], "invited_org": ["exact"]}
  381. class DocumentViewSet(BaseViewSet):
  382. queryset = Document.objects.select_related("organization")
  383. serializer_class = DocumentSerializer
  384. filterset_fields = {
  385. "organization": ["exact"],
  386. "kind": ["exact", "icontains"],
  387. "content_type": ["exact"],
  388. "object_id": ["exact"],
  389. "uploaded_by": ["exact"],
  390. }
  391. def perform_create(self, serializer):
  392. doc = serializer.save(uploaded_by=self.request.user)
  393. try:
  394. log_action(
  395. organization=doc.organization,
  396. user=self.request.user,
  397. action="document.upload",
  398. obj=doc,
  399. metadata={"kind": doc.kind},
  400. )
  401. except Exception:
  402. pass
  403. class AuditLogViewSet(BaseViewSet):
  404. queryset = AuditLog.objects.select_related("organization", "user")
  405. serializer_class = AuditLogSerializer
  406. filterset_fields = {
  407. "organization": ["exact"],
  408. "user": ["exact"],
  409. "action": ["exact", "icontains"],
  410. "content_type": ["exact"],
  411. "object_id": ["exact"],
  412. "created_at": ["gte", "lte"],
  413. }