説明なし

views_api.py 18KB

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