Brak opisu

views.py 39KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  1. from django.shortcuts import render, redirect, get_object_or_404
  2. from django.contrib.admin.views.decorators import staff_member_required
  3. from django.views.decorators.http import require_POST
  4. from django.contrib import messages
  5. from django.urls import reverse
  6. from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
  7. import django_filters as filters
  8. from admin_frontend.templatetags.public_urls import public_route
  9. from admin_frontend.nav import _nav_items
  10. from cms.views import breadcrumbs
  11. from orgs.models import Organization, UserProfile
  12. from .models import (
  13. MaterialCategory,
  14. Material,
  15. ProvidedService,
  16. Customer,
  17. CustomerSite,
  18. PickupOrder,
  19. WeighTicket,
  20. WeighLine,
  21. ScrapListing,
  22. ScrapBid,
  23. Invoice,
  24. Payment,
  25. Document,
  26. AuditLog,
  27. Payout,
  28. )
  29. from .forms import (
  30. MaterialForm,
  31. MaterialCategoryForm,
  32. CustomerForm,
  33. PickupAssignForm,
  34. PickupStatusForm,
  35. PaymentForm,
  36. DocumentForm,
  37. ProvidedServiceForm,
  38. )
  39. from .services.billing import generate_invoice_for_pickup
  40. from django.contrib.contenttypes.models import ContentType
  41. from django.contrib.auth import get_user_model
  42. from django.http import HttpResponse
  43. import csv
  44. from django.db.models import Sum, F, ExpressionWrapper, DecimalField, Value
  45. from django.db.models.functions import Coalesce
  46. from decimal import Decimal
  47. from functools import wraps
  48. def owner_required(view_func):
  49. @wraps(view_func)
  50. def _wrapped(request, *args, **kwargs):
  51. if not request.user.is_authenticated:
  52. from django.contrib.auth.views import redirect_to_login
  53. return redirect_to_login(request.get_full_path(), login_url=reverse("admin_frontend:login"))
  54. profile = getattr(request.user, "recycle_profile", None)
  55. if not profile or profile.role != UserProfile.ROLE_OWNER:
  56. messages.error(request, "Only organization owners can manage users.")
  57. return redirect("admin_frontend:dashboard")
  58. if getattr(request, "org", None) is not None and profile.organization_id != request.org.id:
  59. messages.error(request, "You are not permitted for this organization.")
  60. return redirect("admin_frontend:dashboard")
  61. return view_func(request, *args, **kwargs)
  62. return _wrapped
  63. @staff_member_required
  64. @public_route(label="Materials", order=10, icon="beaker")
  65. @breadcrumbs(label="Materials", name="re_materials")
  66. def materials_list(request):
  67. # Create forms
  68. mat_form = MaterialForm(request.POST or None, request.FILES or None)
  69. cat_form = MaterialCategoryForm(request.POST or None)
  70. # Restrict organization choices in forms to current org
  71. if getattr(request, "org", None) is not None:
  72. try:
  73. mat_form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  74. cat_form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  75. except Exception:
  76. pass
  77. if request.method == "POST":
  78. # Distinguish which form posted via a hidden field or field presence
  79. if "default_unit" in request.POST:
  80. if mat_form.is_valid():
  81. obj = mat_form.save(commit=False)
  82. if getattr(request, "org", None) is not None:
  83. obj.organization = request.org
  84. obj.save()
  85. # Save any uploaded images deferred by the form
  86. try:
  87. mat_form.save_images(instance=obj)
  88. except Exception:
  89. pass
  90. messages.success(request, "Material created.")
  91. return redirect("recycle_core:materials_list")
  92. else:
  93. messages.error(request, "Please correct the errors in material form.")
  94. else:
  95. if cat_form.is_valid():
  96. obj = cat_form.save(commit=False)
  97. if getattr(request, "org", None) is not None:
  98. obj.organization = request.org
  99. obj.save()
  100. messages.success(request, "Category created.")
  101. return redirect("recycle_core:materials_list")
  102. else:
  103. messages.error(request, "Please correct the errors in category form.")
  104. # Filters via django-filter to match list pattern
  105. class MaterialFilter(filters.FilterSet):
  106. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  107. category = filters.ChoiceFilter(choices=Material.CATEGORY_CHOICES)
  108. name = filters.CharFilter(field_name="name", lookup_expr="icontains")
  109. class Meta:
  110. model = Material
  111. fields = ["organization", "category", "name"]
  112. base_mats = Material.objects.select_related("organization").order_by("organization_id", "name")
  113. mat_filter = MaterialFilter(request.GET, queryset=base_mats)
  114. mats = mat_filter.qs
  115. # Scope to current organization if present
  116. org = getattr(request, "org", None)
  117. if org is not None:
  118. mats = mats.filter(organization=org)
  119. cats = MaterialCategory.objects.select_related("organization").order_by("organization_id", "name")
  120. try:
  121. sel_org = mat_filter.form.cleaned_data.get("organization")
  122. if sel_org:
  123. cats = cats.filter(organization=sel_org)
  124. except Exception:
  125. pass
  126. if org is not None:
  127. cats = cats.filter(organization=org)
  128. context = {
  129. "nav": _nav_items(),
  130. "materials": mats,
  131. "categories": cats,
  132. "mat_form": mat_form,
  133. "cat_form": cat_form,
  134. "filter": mat_filter,
  135. }
  136. return render(request, "recycle_core/materials_list.html", context)
  137. # Organization Users (owner-only)
  138. from .forms import UserCreateForm, UserEditForm
  139. @owner_required
  140. @public_route(label="Users", order=5, icon="user-group")
  141. @breadcrumbs(label="Users", name="re_users")
  142. def org_users_list(request):
  143. User = get_user_model()
  144. org = getattr(request, "org", None)
  145. qs = User.objects.order_by("id")
  146. if org is not None:
  147. qs = qs.filter(recycle_profile__organization=org)
  148. else:
  149. qs = qs.none()
  150. paginator = Paginator(qs, 15)
  151. page = request.GET.get("page")
  152. try:
  153. page_obj = paginator.page(page)
  154. except PageNotAnInteger:
  155. page_obj = paginator.page(1)
  156. except EmptyPage:
  157. page_obj = paginator.page(paginator.num_pages)
  158. context = {"nav": _nav_items(), "users": page_obj.object_list, "page_obj": page_obj}
  159. return render(request, "recycle_core/users_list.html", context)
  160. @owner_required
  161. @breadcrumbs(label="Create User", parent="re_users")
  162. def org_user_create(request):
  163. form = UserCreateForm(request.POST or None)
  164. if request.method == "POST":
  165. if form.is_valid():
  166. User = get_user_model()
  167. user = User.objects.create(username=form.cleaned_data["username"], email=form.cleaned_data.get("email") or "")
  168. user.set_password(form.cleaned_data["password1"])
  169. user.save()
  170. org = getattr(request, "org", None)
  171. UserProfile.objects.create(user=user, organization=org, role=form.cleaned_data["role"]) # type: ignore[arg-type]
  172. messages.success(request, f"User '{user.username}' created.")
  173. return redirect("recycle_core:org_users_list")
  174. messages.error(request, "Please correct the errors below.")
  175. return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "creating": True, "cancel_url": reverse("recycle_core:org_users_list")})
  176. @owner_required
  177. @breadcrumbs(label="Edit User", parent="re_users")
  178. def org_user_edit(request, pk: int):
  179. User = get_user_model()
  180. org = getattr(request, "org", None)
  181. base_qs = User.objects.all()
  182. if org is not None:
  183. base_qs = base_qs.filter(recycle_profile__organization=org)
  184. user = get_object_or_404(base_qs, pk=pk)
  185. profile = getattr(user, "recycle_profile", None)
  186. form = UserEditForm(request.POST or None, initial={
  187. "email": user.email,
  188. "role": getattr(profile, "role", UserProfile.ROLE_MANAGER),
  189. })
  190. if request.method == "POST":
  191. if form.is_valid():
  192. user.email = form.cleaned_data.get("email") or ""
  193. p1 = form.cleaned_data.get("password1")
  194. if p1:
  195. user.set_password(p1)
  196. user.save()
  197. if profile is None:
  198. profile = UserProfile(user=user, organization=org) # type: ignore[arg-type]
  199. profile.role = form.cleaned_data["role"]
  200. if org is not None:
  201. profile.organization = org
  202. profile.save()
  203. messages.success(request, f"User '{user.username}' updated.")
  204. return redirect("recycle_core:org_users_list")
  205. messages.error(request, "Please correct the errors below.")
  206. return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
  207. @owner_required
  208. @require_POST
  209. def org_user_delete(request, pk: int):
  210. User = get_user_model()
  211. org = getattr(request, "org", None)
  212. base_qs = User.objects.all()
  213. if org is not None:
  214. base_qs = base_qs.filter(recycle_profile__organization=org)
  215. user = get_object_or_404(base_qs, pk=pk)
  216. if user.id == request.user.id:
  217. messages.error(request, "You cannot delete your own account.")
  218. return redirect("recycle_core:org_users_list")
  219. user.delete()
  220. messages.success(request, "User deleted.")
  221. return redirect("recycle_core:org_users_list")
  222. @staff_member_required
  223. @breadcrumbs(label="Edit Material", parent="re_materials")
  224. def material_edit(request, pk: int):
  225. item = get_object_or_404(Material, pk=pk)
  226. if request.method == "POST":
  227. form = MaterialForm(request.POST, request.FILES, instance=item)
  228. if form.is_valid():
  229. form.save()
  230. messages.success(request, "Material updated.")
  231. return redirect("recycle_core:material_edit", pk=item.pk)
  232. else:
  233. form = MaterialForm(instance=item)
  234. return render(
  235. request,
  236. "recycle_core/material_form.html",
  237. {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:materials_list")},
  238. )
  239. @staff_member_required
  240. @require_POST
  241. def material_delete(request, pk: int):
  242. item = get_object_or_404(Material, pk=pk)
  243. item.delete()
  244. messages.success(request, "Material deleted.")
  245. return redirect("recycle_core:materials_list")
  246. @staff_member_required
  247. @public_route(label="Customers", order=20, icon="building-storefront")
  248. @breadcrumbs(label="Customers", name="re_customers")
  249. def customers_list(request):
  250. form = CustomerForm(request.POST or None)
  251. # Restrict organization field to current org if present
  252. if getattr(request, "org", None) is not None:
  253. try:
  254. form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  255. except Exception:
  256. pass
  257. if request.method == "POST":
  258. if form.is_valid():
  259. obj = form.save(commit=False)
  260. if getattr(request, "org", None) is not None:
  261. obj.organization = request.org
  262. obj.save()
  263. messages.success(request, "Customer created.")
  264. return redirect("recycle_core:customers_list")
  265. messages.error(request, "Please correct the errors below.")
  266. # FilterSet similar to opportunities list
  267. class CustomerFilter(filters.FilterSet):
  268. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  269. name = filters.CharFilter(field_name="name", lookup_expr="icontains")
  270. email = filters.CharFilter(field_name="email", lookup_expr="icontains")
  271. phone = filters.CharFilter(field_name="phone", lookup_expr="icontains")
  272. class Meta:
  273. model = Customer
  274. fields = ["organization", "name", "email", "phone"]
  275. base_qs = Customer.objects.select_related("organization", "price_list").order_by("organization_id", "name")
  276. cust_filter = CustomerFilter(request.GET, queryset=base_qs)
  277. qs = cust_filter.qs
  278. org = getattr(request, "org", None)
  279. if org is not None:
  280. qs = qs.filter(organization=org)
  281. paginator = Paginator(qs, 10)
  282. page = request.GET.get("page")
  283. try:
  284. page_obj = paginator.page(page)
  285. except PageNotAnInteger:
  286. page_obj = paginator.page(1)
  287. except EmptyPage:
  288. page_obj = paginator.page(paginator.num_pages)
  289. context = {
  290. "nav": _nav_items(),
  291. "customers": page_obj.object_list,
  292. "page_obj": page_obj,
  293. "form": form,
  294. "filter": cust_filter,
  295. }
  296. return render(request, "recycle_core/customers_list.html", context)
  297. @staff_member_required
  298. @breadcrumbs(label="Edit Customer", parent="re_customers")
  299. def customer_edit(request, pk: int):
  300. item = get_object_or_404(Customer, pk=pk)
  301. if request.method == "POST":
  302. form = CustomerForm(request.POST, instance=item)
  303. if form.is_valid():
  304. form.save()
  305. messages.success(request, "Customer updated.")
  306. return redirect("recycle_core:customer_edit", pk=item.pk)
  307. else:
  308. form = CustomerForm(instance=item)
  309. return render(
  310. request,
  311. "recycle_core/customer_form.html",
  312. {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:customers_list")},
  313. )
  314. @staff_member_required
  315. @require_POST
  316. def customer_delete(request, pk: int):
  317. item = get_object_or_404(Customer, pk=pk)
  318. item.delete()
  319. messages.success(request, "Customer deleted.")
  320. return redirect("recycle_core:customers_list")
  321. # Pickups UI -----------------------------------------------------------------
  322. @staff_member_required
  323. @public_route(label="Pickups", order=30, icon="truck")
  324. @breadcrumbs(label="Pickups", name="re_pickups")
  325. def pickups_list(request):
  326. # FilterSet for pickups
  327. class PickupFilter(filters.FilterSet):
  328. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  329. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
  330. site = filters.ModelChoiceFilter(queryset=CustomerSite.objects.all())
  331. assigned_driver = filters.CharFilter(field_name="assigned_driver__username", lookup_expr="icontains", label="Driver")
  332. status = filters.ChoiceFilter(field_name="status", choices=PickupOrder.STATUS_CHOICES)
  333. scheduled_at = filters.DateFromToRangeFilter(field_name="scheduled_at", label="Scheduled between")
  334. class Meta:
  335. model = PickupOrder
  336. fields = ["organization", "customer", "site", "assigned_driver", "status", "scheduled_at"]
  337. base_qs = (
  338. PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver")
  339. .order_by("-created_at")
  340. )
  341. p_filter = PickupFilter(request.GET, queryset=base_qs)
  342. qs = p_filter.qs
  343. org = getattr(request, "org", None)
  344. if org is not None:
  345. qs = qs.filter(organization=org)
  346. paginator = Paginator(qs, 10)
  347. page = request.GET.get("page")
  348. try:
  349. page_obj = paginator.page(page)
  350. except PageNotAnInteger:
  351. page_obj = paginator.page(1)
  352. except EmptyPage:
  353. page_obj = paginator.page(paginator.num_pages)
  354. # empty forms used in row actions
  355. assign_form = PickupAssignForm()
  356. status_form = PickupStatusForm()
  357. context = {
  358. "nav": _nav_items(),
  359. "pickups": page_obj.object_list,
  360. "page_obj": page_obj,
  361. "filter": p_filter,
  362. "assign_form": assign_form,
  363. "status_form": status_form,
  364. }
  365. return render(request, "recycle_core/pickups_list.html", context)
  366. @staff_member_required
  367. @require_POST
  368. def pickup_assign(request, pk: int):
  369. pickup = get_object_or_404(PickupOrder, pk=pk)
  370. form = PickupAssignForm(request.POST)
  371. if form.is_valid():
  372. pickup.assigned_driver = form.cleaned_data["driver"]
  373. pickup.status = PickupOrder.STATUS_SCHEDULED
  374. pickup.save(update_fields=["assigned_driver", "status"])
  375. messages.success(request, f"Assigned driver to pickup #{pickup.id}.")
  376. else:
  377. messages.error(request, "Invalid driver selection.")
  378. return redirect("recycle_core:pickups_list")
  379. @staff_member_required
  380. @require_POST
  381. def pickup_set_status(request, pk: int):
  382. pickup = get_object_or_404(PickupOrder, pk=pk)
  383. form = PickupStatusForm(request.POST)
  384. if form.is_valid():
  385. pickup.status = form.cleaned_data["status"]
  386. pickup.save(update_fields=["status"])
  387. messages.success(request, f"Updated status for pickup #{pickup.id}.")
  388. else:
  389. messages.error(request, "Invalid status selection.")
  390. return redirect("recycle_core:pickups_list")
  391. @staff_member_required
  392. @require_POST
  393. def pickup_generate_invoice(request, pk: int):
  394. pickup = get_object_or_404(PickupOrder, pk=pk)
  395. try:
  396. if not hasattr(pickup, "weigh_ticket"):
  397. messages.error(request, "Pickup has no weigh ticket.")
  398. return redirect("recycle_core:pickups_list")
  399. invoice = generate_invoice_for_pickup(pickup)
  400. messages.success(request, f"Invoice #{invoice.id} generated.")
  401. except Exception as e:
  402. messages.error(request, f"Failed to generate invoice: {e}")
  403. return redirect("recycle_core:pickups_list")
  404. # Scrap Listings UI ----------------------------------------------------------
  405. @staff_member_required
  406. @public_route(label="Scrap Listings", order=40, icon="banknotes")
  407. @breadcrumbs(label="Scrap Listings", name="re_listings")
  408. def scrap_listings_list(request):
  409. class ListingFilter(filters.FilterSet):
  410. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  411. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
  412. status = filters.ChoiceFilter(field_name="status", choices=ScrapListing.STATUS_CHOICES)
  413. is_public = filters.BooleanFilter(field_name="is_public")
  414. starts_at = filters.DateFromToRangeFilter(field_name="starts_at", label="Starts between")
  415. ends_at = filters.DateFromToRangeFilter(field_name="ends_at", label="Ends between")
  416. class Meta:
  417. model = ScrapListing
  418. fields = ["organization", "customer", "status", "is_public", "starts_at", "ends_at"]
  419. base_qs = (
  420. ScrapListing.objects.select_related("organization", "customer", "site", "created_by")
  421. .order_by("-created_at")
  422. )
  423. l_filter = ListingFilter(request.GET, queryset=base_qs)
  424. qs = l_filter.qs
  425. org = getattr(request, "org", None)
  426. if org is not None:
  427. qs = qs.filter(organization=org)
  428. paginator = Paginator(qs, 10)
  429. page = request.GET.get("page")
  430. try:
  431. page_obj = paginator.page(page)
  432. except PageNotAnInteger:
  433. page_obj = paginator.page(1)
  434. except EmptyPage:
  435. page_obj = paginator.page(paginator.num_pages)
  436. context = {
  437. "nav": _nav_items(),
  438. "listings": page_obj.object_list,
  439. "page_obj": page_obj,
  440. "filter": l_filter,
  441. }
  442. return render(request, "recycle_core/scrap_listings_list.html", context)
  443. @staff_member_required
  444. @require_POST
  445. def scrap_listing_open(request, pk: int):
  446. listing = get_object_or_404(ScrapListing, pk=pk)
  447. listing.status = ScrapListing.STATUS_OPEN
  448. if not listing.starts_at:
  449. from django.utils import timezone
  450. listing.starts_at = timezone.now()
  451. listing.save(update_fields=["status", "starts_at"])
  452. messages.success(request, f"Listing #{listing.id} opened.")
  453. return redirect("recycle_core:scrap_listings_list")
  454. @staff_member_required
  455. @require_POST
  456. def scrap_listing_close(request, pk: int):
  457. listing = get_object_or_404(ScrapListing, pk=pk)
  458. listing.status = ScrapListing.STATUS_CLOSED
  459. if not listing.ends_at:
  460. from django.utils import timezone
  461. listing.ends_at = timezone.now()
  462. listing.save(update_fields=["status", "ends_at"])
  463. messages.success(request, f"Listing #{listing.id} closed.")
  464. return redirect("recycle_core:scrap_listings_list")
  465. @staff_member_required
  466. @require_POST
  467. def scrap_listing_award(request, pk: int):
  468. listing = get_object_or_404(ScrapListing, pk=pk)
  469. top_bid = (
  470. ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE)
  471. .order_by("-price_total")
  472. .first()
  473. )
  474. if not top_bid:
  475. messages.error(request, "No bids to award.")
  476. return redirect("recycle_core:scrap_listings_list")
  477. if listing.reserve_price and top_bid.price_total < listing.reserve_price:
  478. messages.error(request, "Top bid below reserve price.")
  479. return redirect("recycle_core:scrap_listings_list")
  480. # Update bid statuses and listing
  481. ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE).exclude(id=top_bid.id).update(
  482. status=ScrapBid.STATUS_REJECTED
  483. )
  484. top_bid.status = ScrapBid.STATUS_ACCEPTED
  485. top_bid.save(update_fields=["status"])
  486. listing.status = ScrapListing.STATUS_AWARDED
  487. listing.save(update_fields=["status"])
  488. messages.success(request, f"Listing #{listing.id} awarded to bid #{top_bid.id}.")
  489. return redirect("recycle_core:scrap_listings_list")
  490. # Weigh Tickets UI -----------------------------------------------------------
  491. @staff_member_required
  492. @public_route(label="Weigh Tickets", order=45, icon="scale")
  493. @breadcrumbs(label="Weigh Tickets", name="re_tickets")
  494. def weigh_tickets_list(request):
  495. class TicketFilter(filters.FilterSet):
  496. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all(), field_name="pickup__organization")
  497. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all(), field_name="pickup__customer")
  498. recorded_at = filters.DateFromToRangeFilter(field_name="recorded_at", label="Recorded between")
  499. ticket_number = filters.CharFilter(field_name="ticket_number", lookup_expr="icontains")
  500. class Meta:
  501. model = WeighTicket
  502. fields = ["organization", "customer", "recorded_at", "ticket_number"]
  503. base_qs = WeighTicket.objects.select_related("pickup", "pickup__customer", "pickup__organization").order_by("-recorded_at")
  504. t_filter = TicketFilter(request.GET, queryset=base_qs)
  505. qs = t_filter.qs
  506. org = getattr(request, "org", None)
  507. if org is not None:
  508. qs = qs.filter(pickup__organization=org)
  509. paginator = Paginator(qs, 10)
  510. page = request.GET.get("page")
  511. try:
  512. page_obj = paginator.page(page)
  513. except PageNotAnInteger:
  514. page_obj = paginator.page(1)
  515. except EmptyPage:
  516. page_obj = paginator.page(paginator.num_pages)
  517. context = {
  518. "nav": _nav_items(),
  519. "tickets": page_obj.object_list,
  520. "page_obj": page_obj,
  521. "filter": t_filter,
  522. }
  523. return render(request, "recycle_core/weigh_tickets_list.html", context)
  524. @staff_member_required
  525. @breadcrumbs(label="Ticket Detail", parent="re_tickets")
  526. def weigh_ticket_detail(request, pk: int):
  527. ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__customer"), pk=pk)
  528. lines = WeighLine.objects.select_related("material").filter(ticket=ticket)
  529. ct = ContentType.objects.get_for_model(WeighTicket)
  530. docs = Document.objects.filter(content_type=ct, object_id=ticket.id)
  531. context = {
  532. "nav": _nav_items(),
  533. "ticket": ticket,
  534. "lines": lines,
  535. "generate_url": reverse("recycle_core:weigh_ticket_generate_invoice", args=[ticket.id]),
  536. "documents": docs,
  537. "ct_id": ct.id,
  538. }
  539. return render(request, "recycle_core/weigh_ticket_detail.html", context)
  540. @staff_member_required
  541. @require_POST
  542. def weigh_ticket_generate_invoice(request, pk: int):
  543. ticket = get_object_or_404(WeighTicket, pk=pk)
  544. try:
  545. invoice = generate_invoice_for_pickup(ticket.pickup)
  546. messages.success(request, f"Invoice #{invoice.id} generated from ticket {ticket.ticket_number or ticket.id}.")
  547. except Exception as e:
  548. messages.error(request, f"Failed to generate invoice: {e}")
  549. return redirect("recycle_core:weigh_tickets_list")
  550. # Invoices UI ---------------------------------------------------------------
  551. @staff_member_required
  552. @public_route(label="Invoices", order=50, icon="receipt-percent")
  553. @breadcrumbs(label="Invoices", name="re_invoices")
  554. def invoices_list(request):
  555. class InvoiceFilter(filters.FilterSet):
  556. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  557. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
  558. status = filters.ChoiceFilter(field_name="status", choices=Invoice.STATUS_CHOICES)
  559. issued_at = filters.DateFromToRangeFilter(field_name="issued_at", label="Issued between")
  560. class Meta:
  561. model = Invoice
  562. fields = ["organization", "customer", "status", "issued_at"]
  563. base_qs = (
  564. Invoice.objects.select_related("organization", "customer")
  565. .annotate(
  566. payments_total=Coalesce(
  567. Sum("payments__amount"),
  568. Value(Decimal("0.00")),
  569. output_field=DecimalField(max_digits=14, decimal_places=2),
  570. ),
  571. )
  572. .annotate(
  573. balance=ExpressionWrapper(
  574. F("total_amount") - F("payments_total"),
  575. output_field=DecimalField(max_digits=14, decimal_places=2),
  576. )
  577. )
  578. .order_by("-issued_at", "-id")
  579. )
  580. i_filter = InvoiceFilter(request.GET, queryset=base_qs)
  581. qs = i_filter.qs
  582. org = getattr(request, "org", None)
  583. if org is not None:
  584. qs = qs.filter(organization=org)
  585. paginator = Paginator(qs, 10)
  586. page = request.GET.get("page")
  587. try:
  588. page_obj = paginator.page(page)
  589. except PageNotAnInteger:
  590. page_obj = paginator.page(1)
  591. except EmptyPage:
  592. page_obj = paginator.page(paginator.num_pages)
  593. pay_form = PaymentForm()
  594. context = {
  595. "nav": _nav_items(),
  596. "invoices": page_obj.object_list,
  597. "page_obj": page_obj,
  598. "filter": i_filter,
  599. "pay_form": pay_form,
  600. }
  601. return render(request, "recycle_core/invoices_list.html", context)
  602. @staff_member_required
  603. @require_POST
  604. def invoice_add_payment(request, pk: int):
  605. invoice = get_object_or_404(Invoice, pk=pk)
  606. form = PaymentForm(request.POST)
  607. if form.is_valid():
  608. Payment.objects.create(
  609. invoice=invoice,
  610. amount=form.cleaned_data["amount"],
  611. currency_code=invoice.currency_code,
  612. received_at=form.cleaned_data.get("received_at") or None,
  613. reference=form.cleaned_data.get("reference", ""),
  614. )
  615. messages.success(request, f"Payment recorded for invoice #{invoice.id}.")
  616. else:
  617. messages.error(request, "Invalid payment data.")
  618. return redirect("recycle_core:invoices_list")
  619. # Payouts UI + CSV ----------------------------------------------------------
  620. @staff_member_required
  621. @public_route(label="Payouts", order=52, icon="banknotes")
  622. @breadcrumbs(label="Payouts", name="re_payouts")
  623. def payouts_list(request):
  624. class PayoutFilter(filters.FilterSet):
  625. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  626. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
  627. paid_at = filters.DateFromToRangeFilter(field_name="paid_at", label="Paid between")
  628. class Meta:
  629. model = Payout
  630. fields = ["organization", "customer", "paid_at"]
  631. base_qs = Payout.objects.select_related("organization", "customer").order_by("-paid_at", "-id")
  632. p_filter = PayoutFilter(request.GET, queryset=base_qs)
  633. qs = p_filter.qs
  634. org = getattr(request, "org", None)
  635. if org is not None:
  636. qs = qs.filter(organization=org)
  637. paginator = Paginator(qs, 10)
  638. page = request.GET.get("page")
  639. try:
  640. page_obj = paginator.page(page)
  641. except PageNotAnInteger:
  642. page_obj = paginator.page(1)
  643. except EmptyPage:
  644. page_obj = paginator.page(paginator.num_pages)
  645. context = {
  646. "nav": _nav_items(),
  647. "payouts": page_obj.object_list,
  648. "page_obj": page_obj,
  649. "filter": p_filter,
  650. }
  651. return render(request, "recycle_core/payouts_list.html", context)
  652. @staff_member_required
  653. def payouts_export_csv(request):
  654. qs = Payout.objects.select_related("organization", "customer").all()
  655. org_obj = getattr(request, "org", None)
  656. if org_obj is not None:
  657. qs = qs.filter(organization=org_obj)
  658. org = request.GET.get("organization")
  659. cust = request.GET.get("customer")
  660. start = request.GET.get("paid_at_after") or request.GET.get("paid_at_min")
  661. end = request.GET.get("paid_at_before") or request.GET.get("paid_at_max")
  662. from django.utils.dateparse import parse_datetime
  663. if org:
  664. qs = qs.filter(organization_id=org)
  665. if cust:
  666. qs = qs.filter(customer_id=cust)
  667. if start:
  668. dt = parse_datetime(start)
  669. if dt:
  670. qs = qs.filter(paid_at__gte=dt)
  671. if end:
  672. dt = parse_datetime(end)
  673. if dt:
  674. qs = qs.filter(paid_at__lte=dt)
  675. response = HttpResponse(content_type="text/csv")
  676. response["Content-Disposition"] = "attachment; filename=payouts.csv"
  677. writer = csv.writer(response)
  678. writer.writerow(["organization", "customer", "amount", "currency", "paid_at", "reference", "pickup_id"])
  679. for p in qs.iterator():
  680. writer.writerow([
  681. p.organization.code,
  682. p.customer.name,
  683. p.amount,
  684. p.currency_code,
  685. p.paid_at.isoformat(),
  686. p.reference,
  687. p.pickup_id or "",
  688. ])
  689. return response
  690. # Documents UI --------------------------------------------------------------
  691. @staff_member_required
  692. @public_route(label="Documents", order=55, icon="paper-clip")
  693. @breadcrumbs(label="Documents", name="re_documents")
  694. def documents_list(request):
  695. class DocumentFilter(filters.FilterSet):
  696. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  697. kind = filters.CharFilter(field_name="kind", lookup_expr="icontains")
  698. content_type = filters.ModelChoiceFilter(queryset=ContentType.objects.all())
  699. object_id = filters.NumberFilter(field_name="object_id")
  700. uploaded_by = filters.ModelChoiceFilter(queryset=get_user_model().objects.all())
  701. created_at = filters.DateFromToRangeFilter(field_name="created_at", label="Uploaded between")
  702. class Meta:
  703. model = Document
  704. fields = ["organization", "kind", "content_type", "object_id", "uploaded_by", "created_at"]
  705. form = DocumentForm(request.POST or None, request.FILES or None)
  706. if getattr(request, "org", None) is not None:
  707. try:
  708. form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  709. except Exception:
  710. pass
  711. if request.method == "POST":
  712. if form.is_valid():
  713. doc = Document(
  714. organization=(getattr(request, "org", None) or form.cleaned_data["organization"]),
  715. file=form.cleaned_data["file"],
  716. kind=form.cleaned_data.get("kind") or "",
  717. content_type=form.cleaned_data["content_type"],
  718. object_id=form.cleaned_data["object_id"],
  719. uploaded_by=request.user,
  720. )
  721. doc.save()
  722. messages.success(request, "Document uploaded.")
  723. return redirect("recycle_core:documents_list")
  724. else:
  725. messages.error(request, "Please correct the document form errors.")
  726. base_qs = Document.objects.select_related("organization", "uploaded_by", "content_type").order_by("-created_at")
  727. d_filter = DocumentFilter(request.GET, queryset=base_qs)
  728. qs = d_filter.qs
  729. org = getattr(request, "org", None)
  730. if org is not None:
  731. qs = qs.filter(organization=org)
  732. paginator = Paginator(qs, 10)
  733. page = request.GET.get("page")
  734. try:
  735. page_obj = paginator.page(page)
  736. except PageNotAnInteger:
  737. page_obj = paginator.page(1)
  738. except EmptyPage:
  739. page_obj = paginator.page(paginator.num_pages)
  740. context = {
  741. "nav": _nav_items(),
  742. "documents": page_obj.object_list,
  743. "page_obj": page_obj,
  744. "filter": d_filter,
  745. "form": form,
  746. }
  747. return render(request, "recycle_core/documents_list.html", context)
  748. # Audit Logs UI -------------------------------------------------------------
  749. @staff_member_required
  750. @public_route(label="Audit Logs", order=60, icon="clock")
  751. @breadcrumbs(label="Audit Logs", name="re_audit_logs")
  752. def audit_logs_list(request):
  753. class AuditFilter(filters.FilterSet):
  754. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  755. user = filters.ModelChoiceFilter(queryset=get_user_model().objects.all())
  756. action = filters.CharFilter(field_name="action", lookup_expr="icontains")
  757. content_type = filters.ModelChoiceFilter(queryset=ContentType.objects.all())
  758. object_id = filters.NumberFilter(field_name="object_id")
  759. created_at = filters.DateFromToRangeFilter(field_name="created_at", label="Between")
  760. class Meta:
  761. model = AuditLog
  762. fields = ["organization", "user", "action", "content_type", "object_id", "created_at"]
  763. base_qs = AuditLog.objects.select_related("organization", "user", "content_type").order_by("-created_at")
  764. a_filter = AuditFilter(request.GET, queryset=base_qs)
  765. qs = a_filter.qs
  766. org = getattr(request, "org", None)
  767. if org is not None:
  768. qs = qs.filter(organization=org)
  769. paginator = Paginator(qs, 10)
  770. page = request.GET.get("page")
  771. try:
  772. page_obj = paginator.page(page)
  773. except PageNotAnInteger:
  774. page_obj = paginator.page(1)
  775. except EmptyPage:
  776. page_obj = paginator.page(paginator.num_pages)
  777. context = {
  778. "nav": _nav_items(),
  779. "logs": page_obj.object_list,
  780. "page_obj": page_obj,
  781. "filter": a_filter,
  782. }
  783. return render(request, "recycle_core/audit_logs_list.html", context)
  784. @staff_member_required
  785. def audit_logs_export_csv(request):
  786. qs = AuditLog.objects.select_related("organization", "user", "content_type").all()
  787. org_obj = getattr(request, "org", None)
  788. if org_obj is not None:
  789. qs = qs.filter(organization=org_obj)
  790. org = request.GET.get("organization")
  791. user = request.GET.get("user")
  792. action = request.GET.get("action")
  793. ctype = request.GET.get("content_type")
  794. obj_id = request.GET.get("object_id")
  795. start = request.GET.get("created_at_after") or request.GET.get("created_at_min")
  796. end = request.GET.get("created_at_before") or request.GET.get("created_at_max")
  797. from django.utils.dateparse import parse_datetime
  798. if org:
  799. qs = qs.filter(organization_id=org)
  800. if user:
  801. qs = qs.filter(user_id=user)
  802. if action:
  803. qs = qs.filter(action__icontains=action)
  804. if ctype:
  805. qs = qs.filter(content_type_id=ctype)
  806. if obj_id:
  807. qs = qs.filter(object_id=obj_id)
  808. if start:
  809. dt = parse_datetime(start)
  810. if dt:
  811. qs = qs.filter(created_at__gte=dt)
  812. if end:
  813. dt = parse_datetime(end)
  814. if dt:
  815. qs = qs.filter(created_at__lte=dt)
  816. response = HttpResponse(content_type="text/csv")
  817. response["Content-Disposition"] = "attachment; filename=audit_logs.csv"
  818. writer = csv.writer(response)
  819. writer.writerow(["created_at", "organization", "user", "action", "content_type", "object_id", "metadata"])
  820. for log in qs.iterator():
  821. writer.writerow([
  822. log.created_at.isoformat(),
  823. log.organization.code,
  824. getattr(log.user, "username", ""),
  825. log.action,
  826. log.content_type.model,
  827. log.object_id,
  828. log.metadata,
  829. ])
  830. return response
  831. @staff_member_required
  832. @public_route(label="Provided Services", order=15, icon="sparkles")
  833. @breadcrumbs(label="Provided Services", name="rc_services")
  834. def services_list(request):
  835. if not request.user.is_authenticated or not getattr(request.user, "is_staff", False):
  836. from django.contrib.auth.views import redirect_to_login
  837. return redirect_to_login(request.get_full_path(), login_url=reverse("admin_frontend:login"))
  838. org = getattr(request, "org", None)
  839. services = ProvidedService.objects.filter(organization=org).order_by("display_order", "id")
  840. return render(request, "recycle_core/services_list.html", {"nav": _nav_items(), "services": services})
  841. @breadcrumbs(label="New Service", parent="rc_services")
  842. @owner_required
  843. def service_create(request):
  844. if request.method == "POST":
  845. form = ProvidedServiceForm(request.POST)
  846. if form.is_valid():
  847. obj = form.save(commit=False)
  848. obj.organization = getattr(request, "org", None)
  849. obj.save()
  850. messages.success(request, "Service created.")
  851. return redirect("recycle_core:services_list")
  852. messages.error(request, "Please correct the errors below.")
  853. else:
  854. form = ProvidedServiceForm()
  855. return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "form": form, "cancel_url": reverse("recycle_core:services_list")})
  856. @breadcrumbs(label="Edit Service", parent="rc_services")
  857. @owner_required
  858. def service_edit(request, pk: int):
  859. org = getattr(request, "org", None)
  860. item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
  861. if not item:
  862. messages.error(request, "Service not found.")
  863. return redirect("recycle_core:services_list")
  864. if request.method == "POST":
  865. form = ProvidedServiceForm(request.POST, instance=item)
  866. if form.is_valid():
  867. obj = form.save(commit=False)
  868. obj.organization = org
  869. obj.save()
  870. messages.success(request, "Service updated.")
  871. return redirect("recycle_core:service_edit", pk=item.pk)
  872. messages.error(request, "Please correct the errors below.")
  873. else:
  874. form = ProvidedServiceForm(instance=item)
  875. return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:services_list")})
  876. @require_POST
  877. @owner_required
  878. def service_delete(request, pk: int):
  879. org = getattr(request, "org", None)
  880. item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
  881. if not item:
  882. messages.error(request, "Service not found.")
  883. else:
  884. item.delete()
  885. messages.success(request, "Service deleted.")
  886. return redirect("recycle_core:services_list")
  887. @require_POST
  888. @owner_required
  889. def service_toggle_enabled(request, pk: int):
  890. org = getattr(request, "org", None)
  891. item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
  892. if not item:
  893. messages.error(request, "Service not found.")
  894. else:
  895. item.is_enabled = not item.is_enabled
  896. item.save(update_fields=["is_enabled"])
  897. messages.success(request, f"Service '{item.title}' {'enabled' if item.is_enabled else 'disabled'}.")
  898. return redirect("recycle_core:services_list")