No Description

views.py 64KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556
  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 django import forms
  10. from admin_frontend.nav import _nav_items
  11. from cms.views import breadcrumbs
  12. from orgs.decorators import permissions_required
  13. from orgs.models import Organization, UserProfile
  14. from .models import (
  15. MaterialCategory,
  16. Material,
  17. MaterialImage,
  18. ProvidedService,
  19. Customer,
  20. CustomerSite,
  21. PickupItem,
  22. PriceList,
  23. PriceListItem,
  24. PickupOrder,
  25. WeighTicket,
  26. WeighLine,
  27. ScrapListing,
  28. ScrapBid,
  29. Document,
  30. AuditLog,
  31. CarbonEvent,
  32. CarbonBalance,
  33. MaterialEmissionFactor,
  34. )
  35. from .forms import (
  36. MaterialForm,
  37. MaterialCategoryForm,
  38. CustomerForm,
  39. PickupAssignForm,
  40. PickupStatusForm,
  41. DocumentForm,
  42. ProvidedServiceForm,
  43. WeighTicketForm,
  44. WeighLineForm,
  45. WeighTicketWeightsForm,
  46. MaterialEmissionFactorForm,
  47. PickupItemForm,
  48. )
  49. from .services.billing import generate_invoice_for_pickup, regenerate_invoice_for_pickup
  50. from billing.models import Invoice
  51. from .services import carbon as carbon_svc
  52. from django.contrib.contenttypes.models import ContentType
  53. from django.contrib.auth import get_user_model
  54. from django.http import HttpResponse, JsonResponse
  55. from django.db import transaction
  56. import json
  57. import csv
  58. from django.db.models import Sum, F, ExpressionWrapper, DecimalField, Value
  59. from django.db.models.functions import Coalesce
  60. from decimal import Decimal
  61. from functools import wraps
  62. def owner_required(view_func):
  63. @wraps(view_func)
  64. def _wrapped(request, *args, **kwargs):
  65. if not request.user.is_authenticated:
  66. from django.contrib.auth.views import redirect_to_login
  67. return redirect_to_login(request.get_full_path(), login_url=reverse("admin_frontend:login"))
  68. profile = getattr(request.user, "recycle_profile", None)
  69. if not profile or profile.role != UserProfile.ROLE_OWNER:
  70. messages.error(request, "Only organization owners can manage users.")
  71. return redirect("admin_frontend:dashboard")
  72. if getattr(request, "org", None) is not None and profile.organization_id != request.org.id:
  73. messages.error(request, "You are not permitted for this organization.")
  74. return redirect("admin_frontend:dashboard")
  75. return view_func(request, *args, **kwargs)
  76. return _wrapped
  77. @staff_member_required
  78. @public_route(label="Materials", order=10, icon="beaker")
  79. @breadcrumbs(label="Materials", name="re_materials")
  80. def materials_list(request):
  81. # Create forms
  82. mat_form = MaterialForm(request.POST or None, request.FILES or None)
  83. cat_form = MaterialCategoryForm(request.POST or None)
  84. # Restrict organization choices in forms to current org
  85. if getattr(request, "org", None) is not None:
  86. try:
  87. mat_form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  88. cat_form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  89. except Exception:
  90. pass
  91. if request.method == "POST":
  92. # Distinguish which form posted via a hidden field or field presence
  93. if "default_unit" in request.POST:
  94. if mat_form.is_valid():
  95. obj = mat_form.save(commit=False)
  96. if getattr(request, "org", None) is not None:
  97. obj.organization = request.org
  98. obj.save()
  99. # Save any uploaded images deferred by the form
  100. try:
  101. mat_form.save_images(instance=obj)
  102. except Exception:
  103. pass
  104. messages.success(request, "Material created.")
  105. return redirect("recycle_core:materials_list")
  106. else:
  107. messages.error(request, "Please correct the errors in material form.")
  108. else:
  109. if cat_form.is_valid():
  110. obj = cat_form.save(commit=False)
  111. if getattr(request, "org", None) is not None:
  112. obj.organization = request.org
  113. obj.save()
  114. messages.success(request, "Category created.")
  115. return redirect("recycle_core:materials_list")
  116. else:
  117. messages.error(request, "Please correct the errors in category form.")
  118. # Filters via django-filter to match list pattern
  119. class MaterialFilter(filters.FilterSet):
  120. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  121. category = filters.ChoiceFilter(choices=Material.CATEGORY_CHOICES)
  122. name = filters.CharFilter(field_name="name", lookup_expr="icontains")
  123. class Meta:
  124. model = Material
  125. fields = ["organization", "category", "name"]
  126. base_mats = Material.objects.select_related("organization").order_by("organization_id", "name")
  127. mat_filter = MaterialFilter(request.GET, queryset=base_mats)
  128. mats = mat_filter.qs
  129. # Scope to current organization if present
  130. org = getattr(request, "org", None)
  131. if org is not None:
  132. mats = mats.filter(organization=org)
  133. cats = MaterialCategory.objects.select_related("organization").order_by("organization_id", "name")
  134. try:
  135. sel_org = mat_filter.form.cleaned_data.get("organization")
  136. if sel_org:
  137. cats = cats.filter(organization=sel_org)
  138. except Exception:
  139. pass
  140. if org is not None:
  141. cats = cats.filter(organization=org)
  142. context = {
  143. "nav": _nav_items(),
  144. "materials": mats,
  145. "categories": cats,
  146. "mat_form": mat_form,
  147. "cat_form": cat_form,
  148. "filter": mat_filter,
  149. }
  150. return render(request, "recycle_core/materials_list.html", context)
  151. # Organization Users (owner-only)
  152. from .forms import UserCreateForm, UserEditForm, UserProfilePhotoForm
  153. @owner_required
  154. @public_route(label="Users", order=5, icon="user-group")
  155. @breadcrumbs(label="Users", name="re_users")
  156. def org_users_list(request):
  157. User = get_user_model()
  158. org = getattr(request, "org", None)
  159. qs = User.objects.order_by("id")
  160. if org is not None:
  161. qs = qs.filter(recycle_profile__organization=org)
  162. else:
  163. qs = qs.none()
  164. paginator = Paginator(qs, 15)
  165. page = request.GET.get("page")
  166. try:
  167. page_obj = paginator.page(page)
  168. except PageNotAnInteger:
  169. page_obj = paginator.page(1)
  170. except EmptyPage:
  171. page_obj = paginator.page(paginator.num_pages)
  172. context = {"nav": _nav_items(), "users": page_obj.object_list, "page_obj": page_obj}
  173. return render(request, "recycle_core/users_list.html", context)
  174. @owner_required
  175. @breadcrumbs(label="Create User", parent="re_users")
  176. def org_user_create(request):
  177. form = UserCreateForm(request.POST or None)
  178. if request.method == "POST":
  179. if form.is_valid():
  180. User = get_user_model()
  181. user = User.objects.create(
  182. username=form.cleaned_data["username"],
  183. email=form.cleaned_data.get("email") or "",
  184. first_name=form.cleaned_data.get("first_name") or "",
  185. last_name=form.cleaned_data.get("last_name") or "",
  186. )
  187. user.set_password(form.cleaned_data["password1"])
  188. user.save()
  189. org = getattr(request, "org", None)
  190. UserProfile.objects.create(user=user, organization=org, role=form.cleaned_data["role"]) # type: ignore[arg-type]
  191. messages.success(request, f"User '{user.username}' created.")
  192. next_url = request.POST.get("next") or request.GET.get("next")
  193. if next_url:
  194. return redirect(next_url)
  195. return redirect("recycle_core:org_users_list")
  196. messages.error(request, "Please correct the errors below.")
  197. return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "creating": True, "cancel_url": reverse("recycle_core:org_users_list")})
  198. @owner_required
  199. @breadcrumbs(label="Edit User", parent="re_users")
  200. def org_user_edit(request, pk: int):
  201. User = get_user_model()
  202. org = getattr(request, "org", None)
  203. base_qs = User.objects.all()
  204. if org is not None:
  205. base_qs = base_qs.filter(recycle_profile__organization=org)
  206. user = get_object_or_404(base_qs, pk=pk)
  207. profile = getattr(user, "recycle_profile", None)
  208. form = UserEditForm(request.POST or None, initial={
  209. "email": user.email,
  210. "first_name": user.first_name,
  211. "last_name": user.last_name,
  212. "role": getattr(profile, "role", UserProfile.ROLE_MANAGER),
  213. "is_active": user.is_active,
  214. })
  215. form_photo = UserProfilePhotoForm(request.POST or None, request.FILES or None, instance=profile) if profile else None
  216. # Extra contact/address fields on the profile
  217. from django.forms import modelform_factory
  218. ExtraForm = modelform_factory(UserProfile, fields=(
  219. "phone",
  220. "job_title",
  221. "department",
  222. "preferred_language",
  223. "address_line1",
  224. "address_line2",
  225. "city",
  226. "state",
  227. "postal_code",
  228. "country",
  229. ))
  230. form_extras = ExtraForm(request.POST or None, instance=profile) if profile else None
  231. if request.method == "POST":
  232. ok_main = form.is_valid()
  233. ok_photo = True if form_photo is None else form_photo.is_valid()
  234. ok_extras = True if form_extras is None else form_extras.is_valid()
  235. if ok_main and ok_photo and ok_extras:
  236. user.email = form.cleaned_data.get("email") or ""
  237. user.first_name = form.cleaned_data.get("first_name") or ""
  238. user.last_name = form.cleaned_data.get("last_name") or ""
  239. desired_active = bool(form.cleaned_data.get("is_active"))
  240. if user.id == request.user.id and not desired_active:
  241. messages.error(request, "You cannot deactivate your own account.")
  242. else:
  243. user.is_active = desired_active
  244. p1 = form.cleaned_data.get("password1")
  245. if p1:
  246. user.set_password(p1)
  247. user.save()
  248. if profile is None:
  249. profile = UserProfile(user=user, organization=org) # type: ignore[arg-type]
  250. profile.role = form.cleaned_data["role"]
  251. if org is not None:
  252. profile.organization = org
  253. profile.save()
  254. if form_photo is not None:
  255. form_photo.instance = profile
  256. form_photo.save()
  257. if form_extras is not None:
  258. form_extras.instance = profile
  259. form_extras.save()
  260. messages.success(request, f"User '{user.username}' updated.")
  261. next_url = request.POST.get("next") or request.GET.get("next")
  262. if next_url:
  263. return redirect(next_url)
  264. return redirect("recycle_core:org_users_list")
  265. messages.error(request, "Please correct the errors below.")
  266. return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "form_photo": form_photo, "form_extras": form_extras, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
  267. @owner_required
  268. @require_POST
  269. def org_user_delete(request, pk: int):
  270. User = get_user_model()
  271. org = getattr(request, "org", None)
  272. base_qs = User.objects.all()
  273. if org is not None:
  274. base_qs = base_qs.filter(recycle_profile__organization=org)
  275. user = get_object_or_404(base_qs, pk=pk)
  276. if user.id == request.user.id:
  277. messages.error(request, "You cannot delete your own account.")
  278. return redirect("recycle_core:org_users_list")
  279. user.delete()
  280. messages.success(request, "User deleted.")
  281. return redirect("recycle_core:org_users_list")
  282. @staff_member_required
  283. @breadcrumbs(label="Edit Material", parent="re_materials")
  284. def material_edit(request, pk: int):
  285. item = get_object_or_404(Material, pk=pk)
  286. if request.method == "POST":
  287. form = MaterialForm(request.POST, request.FILES, instance=item)
  288. if form.is_valid():
  289. form.save()
  290. messages.success(request, "Material updated.")
  291. return redirect("recycle_core:material_edit", pk=item.pk)
  292. else:
  293. form = MaterialForm(instance=item)
  294. return render(
  295. request,
  296. "recycle_core/material_form.html",
  297. {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:materials_list")},
  298. )
  299. @staff_member_required
  300. @require_POST
  301. def material_delete(request, pk: int):
  302. item = get_object_or_404(Material, pk=pk)
  303. item.delete()
  304. messages.success(request, "Material deleted.")
  305. return redirect("recycle_core:materials_list")
  306. @staff_member_required
  307. @require_POST
  308. def material_images_reorder(request, pk: int):
  309. """Reorder MaterialImage.display_order for a material.
  310. Expects JSON {"ids": [image_id, ...]} in the new order.
  311. """
  312. material = get_object_or_404(Material, pk=pk)
  313. try:
  314. payload = json.loads(request.body.decode("utf-8"))
  315. ids = payload.get("ids", [])
  316. if not isinstance(ids, list):
  317. return JsonResponse({"ok": False, "error": "Invalid payload"}, status=400)
  318. except Exception:
  319. return JsonResponse({"ok": False, "error": "Malformed JSON"}, status=400)
  320. imgs = MaterialImage.objects.filter(material=material, id__in=ids)
  321. img_map = {im.id: im for im in imgs}
  322. with transaction.atomic():
  323. for idx, iid in enumerate(ids):
  324. im = img_map.get(iid)
  325. if not im:
  326. continue
  327. if im.display_order != idx:
  328. im.display_order = idx
  329. im.save(update_fields=["display_order"])
  330. return JsonResponse({"ok": True})
  331. @staff_member_required
  332. @public_route(label="Customers", order=20, icon="building-storefront")
  333. @breadcrumbs(label="Customers", name="re_customers")
  334. def customers_list(request):
  335. form = CustomerForm(request.POST or None)
  336. # Restrict organization field to current org if present
  337. if getattr(request, "org", None) is not None:
  338. try:
  339. form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  340. except Exception:
  341. pass
  342. if request.method == "POST":
  343. if form.is_valid():
  344. obj = form.save(commit=False)
  345. if getattr(request, "org", None) is not None:
  346. obj.organization = request.org
  347. obj.save()
  348. messages.success(request, "Customer created.")
  349. return redirect("recycle_core:customers_list")
  350. messages.error(request, "Please correct the errors below.")
  351. # FilterSet similar to opportunities list
  352. class CustomerFilter(filters.FilterSet):
  353. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  354. name = filters.CharFilter(field_name="name", lookup_expr="icontains")
  355. email = filters.CharFilter(field_name="email", lookup_expr="icontains")
  356. phone = filters.CharFilter(field_name="phone", lookup_expr="icontains")
  357. class Meta:
  358. model = Customer
  359. fields = ["organization", "name", "email", "phone"]
  360. base_qs = Customer.objects.select_related("organization", "price_list").order_by("organization_id", "name")
  361. cust_filter = CustomerFilter(request.GET, queryset=base_qs)
  362. qs = cust_filter.qs
  363. org = getattr(request, "org", None)
  364. if org is not None:
  365. qs = qs.filter(organization=org)
  366. paginator = Paginator(qs, 10)
  367. page = request.GET.get("page")
  368. try:
  369. page_obj = paginator.page(page)
  370. except PageNotAnInteger:
  371. page_obj = paginator.page(1)
  372. except EmptyPage:
  373. page_obj = paginator.page(paginator.num_pages)
  374. context = {
  375. "nav": _nav_items(),
  376. "customers": page_obj.object_list,
  377. "page_obj": page_obj,
  378. "form": form,
  379. "filter": cust_filter,
  380. }
  381. return render(request, "recycle_core/customers_list.html", context)
  382. @staff_member_required
  383. @breadcrumbs(label="Edit Customer", parent="re_customers")
  384. def customer_edit(request, pk: int):
  385. item = get_object_or_404(Customer, pk=pk)
  386. if request.method == "POST":
  387. form = CustomerForm(request.POST, instance=item)
  388. if form.is_valid():
  389. form.save()
  390. messages.success(request, "Customer updated.")
  391. return redirect("recycle_core:customer_edit", pk=item.pk)
  392. else:
  393. form = CustomerForm(instance=item)
  394. return render(
  395. request,
  396. "recycle_core/customer_form.html",
  397. {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:customers_list")},
  398. )
  399. @staff_member_required
  400. @require_POST
  401. def customer_delete(request, pk: int):
  402. item = get_object_or_404(Customer, pk=pk)
  403. item.delete()
  404. messages.success(request, "Customer deleted.")
  405. return redirect("recycle_core:customers_list")
  406. # Pickups UI -----------------------------------------------------------------
  407. @staff_member_required
  408. @public_route(label="Pickups", order=30, icon="truck")
  409. @breadcrumbs(label="Pickups", name="re_pickups")
  410. def pickups_list(request):
  411. # FilterSet for pickups
  412. class PickupFilter(filters.FilterSet):
  413. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  414. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
  415. site = filters.ModelChoiceFilter(queryset=CustomerSite.objects.all())
  416. assigned_driver = filters.CharFilter(field_name="assigned_driver__username", lookup_expr="icontains", label="Driver")
  417. status = filters.ChoiceFilter(field_name="status", choices=PickupOrder.STATUS_CHOICES)
  418. scheduled_at = filters.DateTimeFromToRangeFilter(
  419. field_name="scheduled_at",
  420. label="Scheduled between",
  421. widget=filters.widgets.RangeWidget(
  422. attrs={
  423. "type": "datetime-local",
  424. "class": "border border-gray-300 rounded px-2 py-1"
  425. }
  426. ),
  427. )
  428. class Meta:
  429. model = PickupOrder
  430. fields = ["organization", "customer", "site", "assigned_driver", "status", "scheduled_at"]
  431. base_qs = (
  432. PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver")
  433. .order_by("-created_at")
  434. )
  435. p_filter = PickupFilter(request.GET, queryset=base_qs)
  436. qs = p_filter.qs
  437. org = getattr(request, "org", None)
  438. if org is not None:
  439. qs = qs.filter(organization=org)
  440. paginator = Paginator(qs, 10)
  441. page = request.GET.get("page")
  442. try:
  443. page_obj = paginator.page(page)
  444. except PageNotAnInteger:
  445. page_obj = paginator.page(1)
  446. except EmptyPage:
  447. page_obj = paginator.page(paginator.num_pages)
  448. # empty forms used in row actions
  449. assign_form = PickupAssignForm()
  450. # Limit driver choices to users with driver role, scoped to org if present
  451. drivers_qs = get_user_model().objects.filter(recycle_profile__role="driver")
  452. if org is not None:
  453. drivers_qs = drivers_qs.filter(recycle_profile__organization=org)
  454. assign_form.fields["driver"].queryset = drivers_qs.order_by("username")
  455. status_form = PickupStatusForm()
  456. context = {
  457. "nav": _nav_items(),
  458. "pickups": page_obj.object_list,
  459. "page_obj": page_obj,
  460. "filter": p_filter,
  461. "assign_form": assign_form,
  462. "status_form": status_form,
  463. }
  464. return render(request, "recycle_core/pickups_list.html", context)
  465. @staff_member_required
  466. @permissions_required('recycle_core.assign_driver', message="You are not allowed to assign drivers for this pickup.")
  467. @require_POST
  468. def pickup_assign(request, pk: int):
  469. pickup = get_object_or_404(PickupOrder, pk=pk)
  470. form = PickupAssignForm(request.POST)
  471. # Enforce driver role (and org, if present) on POST validation
  472. org = getattr(request, "org", None)
  473. drivers_qs = get_user_model().objects.filter(recycle_profile__role="driver")
  474. if org is not None:
  475. drivers_qs = drivers_qs.filter(recycle_profile__organization=org)
  476. form.fields["driver"].queryset = drivers_qs
  477. if form.is_valid():
  478. pickup.assigned_driver = form.cleaned_data["driver"]
  479. pickup.status = PickupOrder.STATUS_SCHEDULED
  480. pickup.save(update_fields=["assigned_driver", "status"])
  481. messages.success(request, f"Assigned driver to pickup #{pickup.id}.")
  482. else:
  483. messages.error(request, "Invalid driver selection.")
  484. return redirect("recycle_core:pickups_list")
  485. @staff_member_required
  486. @permissions_required('recycle_core.set_pickup_status', message="You are not allowed to change status for this pickup.")
  487. @require_POST
  488. def pickup_set_status(request, pk: int):
  489. pickup = get_object_or_404(PickupOrder, pk=pk)
  490. form = PickupStatusForm(request.POST)
  491. if form.is_valid():
  492. pickup.status = form.cleaned_data["status"]
  493. pickup.save(update_fields=["status"])
  494. messages.success(request, f"Updated status for pickup #{pickup.id}.")
  495. else:
  496. messages.error(request, "Invalid status selection.")
  497. return redirect("recycle_core:pickups_list")
  498. @staff_member_required
  499. @breadcrumbs(label="Pickup Detail", parent="re_pickups")
  500. def pickup_detail(request, pk: int):
  501. pickup = get_object_or_404(
  502. PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
  503. )
  504. items = pickup.items.select_related("material").all()
  505. add_item_form = None
  506. if request.method == "POST":
  507. action = request.POST.get("action")
  508. if action == "add_item":
  509. add_item_form = PickupItemForm(request.POST, organization=pickup.organization)
  510. if add_item_form.is_valid():
  511. PickupItem.objects.create(
  512. pickup=pickup,
  513. material=add_item_form.cleaned_data["material"],
  514. estimated_qty=add_item_form.cleaned_data.get("estimated_qty"),
  515. unit=add_item_form.cleaned_data["unit"],
  516. )
  517. messages.success(request, "Pickup item added.")
  518. return redirect("recycle_core:pickup_detail", pk=pickup.id)
  519. else:
  520. messages.error(request, "Please correct the item form errors.")
  521. if add_item_form is None:
  522. add_item_form = PickupItemForm(organization=pickup.organization)
  523. # Invoices associated with this pickup (with payment totals/balance)
  524. inv_qs = (
  525. Invoice.objects.select_related("customer")
  526. .filter(pickup=pickup)
  527. .annotate(
  528. payments_total=Coalesce(
  529. Sum("payments__amount"),
  530. Value(Decimal("0.00")),
  531. output_field=DecimalField(max_digits=14, decimal_places=2),
  532. )
  533. )
  534. .annotate(
  535. balance=ExpressionWrapper(
  536. F("total_amount") - F("payments_total"),
  537. output_field=DecimalField(max_digits=14, decimal_places=2),
  538. )
  539. )
  540. .order_by("-issued_at", "-id")
  541. )
  542. # Compute per-ticket pricing summary (unit price avg and total)
  543. # Attach pricing summaries on each ticket
  544. price_list = pickup.customer.price_list or PriceList.objects.filter(organization=pickup.organization).order_by("id").first()
  545. currency_code = getattr(price_list, "currency_code", None) or pickup.organization.currency_code
  546. for t in pickup.weigh_tickets.all():
  547. total = Decimal("0.00")
  548. for ln in t.lines.all():
  549. pli = (
  550. PriceListItem.objects.filter(
  551. price_list=price_list, material_id=ln.material_id, unit=ln.unit, direction=PriceListItem.DIRECTION_SELL
  552. ).first()
  553. if price_list
  554. else None
  555. )
  556. if pli and pli.unit_price and Decimal(pli.unit_price) > 0:
  557. total += (Decimal(ln.quantity) * Decimal(pli.unit_price)).quantize(Decimal("0.01"))
  558. avg_unit = None
  559. try:
  560. if total > 0 and t.net_weight:
  561. avg_unit = (total / Decimal(t.net_weight)).quantize(Decimal("0.01"))
  562. except Exception:
  563. avg_unit = None
  564. t.total_value = total
  565. t.avg_unit = avg_unit
  566. context = {
  567. "nav": _nav_items(),
  568. "pickup": pickup,
  569. "items": items,
  570. "add_item_form": add_item_form,
  571. "invoices": list(inv_qs),
  572. "ticket_currency": currency_code,
  573. }
  574. return render(request, "recycle_core/pickup_detail.html", context)
  575. @staff_member_required
  576. @permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
  577. def pickup_create_weigh_ticket(request, pk: int):
  578. pickup = get_object_or_404(PickupOrder.objects.select_related("customer", "organization"), pk=pk)
  579. if request.method == "POST":
  580. form = WeighTicketForm(request.POST)
  581. if form.is_valid():
  582. wt = WeighTicket.objects.create(
  583. pickup=pickup,
  584. ticket_number=form.cleaned_data.get("ticket_number") or "",
  585. gross_weight=form.cleaned_data["gross_weight"],
  586. tare_weight=form.cleaned_data["tare_weight"],
  587. net_weight=form.cleaned_data["net_weight"],
  588. unit=form.cleaned_data["unit"],
  589. recorded_by=request.user,
  590. )
  591. pickup.status = PickupOrder.STATUS_WEIGHED
  592. pickup.save(update_fields=["status"])
  593. messages.success(request, f"Weigh ticket {wt.ticket_number or wt.id} created.")
  594. return redirect("recycle_core:weigh_ticket_detail", pk=wt.id)
  595. messages.error(request, "Please correct the form errors.")
  596. else:
  597. form = WeighTicketForm()
  598. context = {
  599. "nav": _nav_items(),
  600. "pickup": pickup,
  601. "form": form,
  602. }
  603. return render(request, "recycle_core/weigh_ticket_form.html", context)
  604. @staff_member_required
  605. @permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
  606. @require_POST
  607. def pickup_generate_invoice(request, pk: int):
  608. pickup = get_object_or_404(PickupOrder, pk=pk)
  609. try:
  610. if not pickup.weigh_tickets.exists():
  611. messages.error(request, "Pickup has no weigh tickets.")
  612. return redirect("recycle_core:pickup_detail", pk=pickup.id)
  613. invoice = generate_invoice_for_pickup(pickup)
  614. messages.success(request, f"Invoice #{invoice.id} generated.")
  615. except Exception as e:
  616. messages.error(request, f"Failed to generate invoice: {e}")
  617. return redirect("recycle_core:pickup_detail", pk=pickup.id)
  618. @staff_member_required
  619. @permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
  620. @require_POST
  621. def pickup_regenerate_invoice(request, pk: int):
  622. pickup = get_object_or_404(PickupOrder, pk=pk)
  623. try:
  624. inv = regenerate_invoice_for_pickup(pickup)
  625. messages.success(request, f"Invoice #{inv.id} regenerated from current tickets.")
  626. except Exception as e:
  627. messages.error(request, f"Failed to regenerate invoice: {e}")
  628. return redirect("recycle_core:pickup_detail", pk=pickup.id)
  629. # Scrap Listings UI ----------------------------------------------------------
  630. @staff_member_required
  631. @public_route(label="Scrap Listings", order=40, icon="banknotes")
  632. @breadcrumbs(label="Scrap Listings", name="re_listings")
  633. def scrap_listings_list(request):
  634. class ListingFilter(filters.FilterSet):
  635. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  636. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
  637. status = filters.ChoiceFilter(field_name="status", choices=ScrapListing.STATUS_CHOICES)
  638. is_public = filters.BooleanFilter(field_name="is_public")
  639. starts_at = filters.DateFromToRangeFilter(field_name="starts_at", label="Starts between")
  640. ends_at = filters.DateFromToRangeFilter(field_name="ends_at", label="Ends between")
  641. class Meta:
  642. model = ScrapListing
  643. fields = ["organization", "customer", "status", "is_public", "starts_at", "ends_at"]
  644. base_qs = (
  645. ScrapListing.objects.select_related("organization", "customer", "site", "created_by")
  646. .order_by("-created_at")
  647. )
  648. l_filter = ListingFilter(request.GET, queryset=base_qs)
  649. qs = l_filter.qs
  650. org = getattr(request, "org", None)
  651. if org is not None:
  652. qs = qs.filter(organization=org)
  653. paginator = Paginator(qs, 10)
  654. page = request.GET.get("page")
  655. try:
  656. page_obj = paginator.page(page)
  657. except PageNotAnInteger:
  658. page_obj = paginator.page(1)
  659. except EmptyPage:
  660. page_obj = paginator.page(paginator.num_pages)
  661. context = {
  662. "nav": _nav_items(),
  663. "listings": page_obj.object_list,
  664. "page_obj": page_obj,
  665. "filter": l_filter,
  666. }
  667. return render(request, "recycle_core/scrap_listings_list.html", context)
  668. @staff_member_required
  669. @require_POST
  670. def scrap_listing_open(request, pk: int):
  671. listing = get_object_or_404(ScrapListing, pk=pk)
  672. listing.status = ScrapListing.STATUS_OPEN
  673. if not listing.starts_at:
  674. from django.utils import timezone
  675. listing.starts_at = timezone.now()
  676. listing.save(update_fields=["status", "starts_at"])
  677. messages.success(request, f"Listing #{listing.id} opened.")
  678. return redirect("recycle_core:scrap_listings_list")
  679. @staff_member_required
  680. @require_POST
  681. def scrap_listing_close(request, pk: int):
  682. listing = get_object_or_404(ScrapListing, pk=pk)
  683. listing.status = ScrapListing.STATUS_CLOSED
  684. if not listing.ends_at:
  685. from django.utils import timezone
  686. listing.ends_at = timezone.now()
  687. listing.save(update_fields=["status", "ends_at"])
  688. messages.success(request, f"Listing #{listing.id} closed.")
  689. return redirect("recycle_core:scrap_listings_list")
  690. @staff_member_required
  691. @require_POST
  692. def scrap_listing_award(request, pk: int):
  693. listing = get_object_or_404(ScrapListing, pk=pk)
  694. top_bid = (
  695. ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE)
  696. .order_by("-price_total")
  697. .first()
  698. )
  699. if not top_bid:
  700. messages.error(request, "No bids to award.")
  701. return redirect("recycle_core:scrap_listings_list")
  702. if listing.reserve_price and top_bid.price_total < listing.reserve_price:
  703. messages.error(request, "Top bid below reserve price.")
  704. return redirect("recycle_core:scrap_listings_list")
  705. # Update bid statuses and listing
  706. ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE).exclude(id=top_bid.id).update(
  707. status=ScrapBid.STATUS_REJECTED
  708. )
  709. top_bid.status = ScrapBid.STATUS_ACCEPTED
  710. top_bid.save(update_fields=["status"])
  711. listing.status = ScrapListing.STATUS_AWARDED
  712. listing.save(update_fields=["status"])
  713. messages.success(request, f"Listing #{listing.id} awarded to bid #{top_bid.id}.")
  714. return redirect("recycle_core:scrap_listings_list")
  715. # Weigh Tickets UI -----------------------------------------------------------
  716. @staff_member_required
  717. @public_route(label="Weigh Tickets", order=45, icon="scale")
  718. @breadcrumbs(label="Weigh Tickets", name="re_tickets")
  719. def weigh_tickets_list(request):
  720. class TicketFilter(filters.FilterSet):
  721. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all(), field_name="pickup__organization")
  722. customer = filters.ModelChoiceFilter(queryset=Customer.objects.all(), field_name="pickup__customer")
  723. recorded_at = filters.DateFromToRangeFilter(field_name="recorded_at", label="Recorded between")
  724. ticket_number = filters.CharFilter(field_name="ticket_number", lookup_expr="icontains")
  725. class Meta:
  726. model = WeighTicket
  727. fields = ["organization", "customer", "recorded_at", "ticket_number"]
  728. base_qs = WeighTicket.objects.select_related("pickup", "pickup__customer", "pickup__organization").order_by("-recorded_at")
  729. t_filter = TicketFilter(request.GET, queryset=base_qs)
  730. qs = t_filter.qs
  731. org = getattr(request, "org", None)
  732. if org is not None:
  733. qs = qs.filter(pickup__organization=org)
  734. paginator = Paginator(qs, 10)
  735. page = request.GET.get("page")
  736. try:
  737. page_obj = paginator.page(page)
  738. except PageNotAnInteger:
  739. page_obj = paginator.page(1)
  740. except EmptyPage:
  741. page_obj = paginator.page(paginator.num_pages)
  742. context = {
  743. "nav": _nav_items(),
  744. "tickets": page_obj.object_list,
  745. "page_obj": page_obj,
  746. "filter": t_filter,
  747. }
  748. return render(request, "recycle_core/weigh_tickets_list.html", context)
  749. @staff_member_required
  750. @breadcrumbs(label="Ticket Detail", parent="re_tickets")
  751. def weigh_ticket_detail(request, pk: int):
  752. ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__customer", "pickup__organization"), pk=pk)
  753. lines = WeighLine.objects.select_related("material").filter(ticket=ticket)
  754. # Add WeighLine inline form
  755. add_line_form = None
  756. weights_form = None
  757. if request.method == "POST":
  758. action = request.POST.get("action", "add")
  759. org = ticket.pickup.organization
  760. if action == "add":
  761. add_line_form = WeighLineForm(request.POST, organization=org)
  762. if add_line_form.is_valid():
  763. WeighLine.objects.create(
  764. ticket=ticket,
  765. material=add_line_form.cleaned_data["material"],
  766. quantity=add_line_form.cleaned_data["quantity"],
  767. unit=add_line_form.cleaned_data["unit"],
  768. )
  769. messages.success(request, "Weigh line added.")
  770. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  771. else:
  772. messages.error(request, "Please correct the line form errors.")
  773. elif action == "edit":
  774. line_id = request.POST.get("line_id")
  775. line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
  776. form = WeighLineForm(request.POST, organization=org)
  777. if form.is_valid():
  778. line.material = form.cleaned_data["material"]
  779. line.quantity = form.cleaned_data["quantity"]
  780. line.unit = form.cleaned_data["unit"]
  781. line.save()
  782. messages.success(request, f"Line #{line.id} updated.")
  783. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  784. else:
  785. messages.error(request, "Please correct the line form errors.")
  786. elif action == "delete":
  787. line_id = request.POST.get("line_id")
  788. line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
  789. try:
  790. # Also remove any linked carbon event(s)
  791. CarbonEvent.objects.filter(weigh_line=line).delete()
  792. except Exception:
  793. pass
  794. line.delete()
  795. messages.success(request, "Weigh line deleted.")
  796. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  797. elif action == "adjust_net_to_lines":
  798. if not org.allow_adjust_net_to_lines:
  799. messages.error(request, "Policy does not allow adjusting net to lines.")
  800. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  801. # Recalculate sum in ticket unit (computed below again for display)
  802. LB_TO_KG = Decimal("0.45359237")
  803. def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
  804. if from_unit == to_unit:
  805. return qty
  806. if from_unit == 'lb' and to_unit == 'kg':
  807. return (qty * LB_TO_KG).quantize(Decimal('0.000'))
  808. if from_unit == 'kg' and to_unit == 'lb':
  809. return (qty / LB_TO_KG).quantize(Decimal('0.000'))
  810. return Decimal('0')
  811. total_lines = Decimal('0.000')
  812. for ln in lines:
  813. if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
  814. total_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
  815. ticket.net_weight = total_lines
  816. ticket.save(update_fields=["net_weight", "updated_at"])
  817. messages.success(request, "Net weight adjusted to sum of lines.")
  818. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  819. elif action == "add_residual_line":
  820. if not org.allow_add_residual_line:
  821. messages.error(request, "Policy does not allow adding residual line.")
  822. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  823. # Compute residual quantity = net - sum(lines in ticket unit)
  824. LB_TO_KG = Decimal("0.45359237")
  825. def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
  826. if from_unit == to_unit:
  827. return qty
  828. if from_unit == 'lb' and to_unit == 'kg':
  829. return (qty * LB_TO_KG).quantize(Decimal('0.000'))
  830. if from_unit == 'kg' and to_unit == 'lb':
  831. return (qty / LB_TO_KG).quantize(Decimal('0.000'))
  832. return Decimal('0')
  833. sum_lines = Decimal('0.000')
  834. for ln in lines:
  835. if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
  836. sum_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
  837. resid = (Decimal(ticket.net_weight) - sum_lines).quantize(Decimal('0.000'))
  838. if resid == 0:
  839. messages.info(request, "No residual difference to add.")
  840. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  841. # Ensure residual material exists for the org
  842. residual_name = "Residual"
  843. residual = Material.objects.filter(organization=org, name=residual_name).first()
  844. if not residual:
  845. residual = Material.objects.create(
  846. organization=org,
  847. category="Mixed",
  848. name=residual_name,
  849. default_unit=ticket.unit,
  850. )
  851. WeighLine.objects.create(ticket=ticket, material=residual, quantity=resid, unit=ticket.unit)
  852. messages.success(request, f"Residual line of {resid} {ticket.unit} added.")
  853. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  854. elif action == "finalize_ticket":
  855. # Validate tolerance and finalize
  856. LB_TO_KG = Decimal("0.45359237")
  857. def _to_kg(qty: Decimal, unit: str) -> Decimal:
  858. if unit == 'kg':
  859. return qty
  860. if unit == 'lb':
  861. return (qty * LB_TO_KG).quantize(Decimal('0.000'))
  862. return Decimal('0')
  863. # compute delta in kg
  864. sum_lines_ticket = Decimal('0.000')
  865. for ln in lines:
  866. if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
  867. # convert line to ticket unit
  868. if ln.unit == ticket.unit:
  869. sum_lines_ticket += Decimal(ln.quantity)
  870. else:
  871. sum_lines_ticket += (Decimal(ln.quantity) * (LB_TO_KG if ln.unit=='lb' and ticket.unit=='kg' else Decimal('1')/LB_TO_KG)).quantize(Decimal('0.000'))
  872. delta_ticket = (Decimal(ticket.net_weight) - sum_lines_ticket)
  873. delta_kg = _to_kg(delta_ticket, ticket.unit)
  874. net_kg = _to_kg(Decimal(ticket.net_weight), ticket.unit)
  875. abs_tol_kg = org.ticket_tolerance_abs_kg
  876. pct_tol_kg = (net_kg * (org.ticket_tolerance_pct / Decimal('100'))).quantize(Decimal('0.000')) if net_kg else Decimal('0.000')
  877. tol_kg = abs_tol_kg if abs_tol_kg >= pct_tol_kg else pct_tol_kg
  878. if delta_kg.copy_abs() > tol_kg:
  879. messages.error(request, f"Cannot finalize: difference {delta_kg} kg exceeds tolerance {tol_kg} kg.")
  880. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  881. ticket.is_finalized = True
  882. ticket.finalized_by = request.user
  883. from django.utils import timezone as djtz
  884. ticket.finalized_at = djtz.now()
  885. ticket.save(update_fields=["is_finalized", "finalized_by", "finalized_at", "updated_at"])
  886. messages.success(request, "Ticket finalized.")
  887. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  888. elif action == "edit_weights":
  889. # Permission and state checks
  890. if not request.user.has_perm('recycle_core.edit_weigh_ticket'):
  891. messages.error(request, "You are not allowed to edit weights.")
  892. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  893. if ticket.is_finalized:
  894. messages.error(request, "Ticket is finalized and cannot be edited.")
  895. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  896. wform = WeighTicketWeightsForm(request.POST)
  897. if wform.is_valid():
  898. gross = wform.cleaned_data['gross_weight']
  899. tare = wform.cleaned_data['tare_weight']
  900. unit = wform.cleaned_data['unit']
  901. # Disallow unit change if lines exist
  902. if unit != ticket.unit and lines.exists():
  903. messages.error(request, "Cannot change unit when lines exist. Remove lines first or keep unit.")
  904. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  905. old = {
  906. 'gross': str(ticket.gross_weight),
  907. 'tare': str(ticket.tare_weight),
  908. 'net': str(ticket.net_weight),
  909. 'unit': ticket.unit,
  910. }
  911. ticket.gross_weight = gross
  912. ticket.tare_weight = tare
  913. ticket.net_weight = (Decimal(gross) - Decimal(tare)).quantize(Decimal('0.000'))
  914. ticket.unit = unit
  915. ticket.save(update_fields=["gross_weight", "tare_weight", "net_weight", "unit", "updated_at"])
  916. try:
  917. from .services.audit import log_action
  918. log_action(
  919. organization=org,
  920. user=request.user,
  921. action="weigh_ticket.update_weights",
  922. obj=ticket,
  923. metadata={"before": old, "after": {"gross": str(gross), "tare": str(tare), "net": str(ticket.net_weight), "unit": unit}},
  924. )
  925. except Exception:
  926. pass
  927. messages.success(request, "Weights updated.")
  928. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  929. else:
  930. messages.error(request, "Please correct the weights form errors.")
  931. weights_form = wform # keep field errors for rendering
  932. else:
  933. add_line_form = WeighLineForm(organization=ticket.pickup.organization, initial={"unit": ticket.unit})
  934. ct = ContentType.objects.get_for_model(WeighTicket)
  935. docs = Document.objects.filter(content_type=ct, object_id=ticket.id)
  936. # Forms for editing existing lines (attach to each line)
  937. for ln in lines:
  938. ln.form = WeighLineForm(
  939. organization=ticket.pickup.organization,
  940. initial={"material": ln.material_id, "quantity": ln.quantity, "unit": ln.unit},
  941. )
  942. # Compute lines total converted to ticket unit and delta to net weight
  943. LB_TO_KG = Decimal("0.45359237")
  944. def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
  945. if from_unit == to_unit:
  946. return qty
  947. if from_unit == 'lb' and to_unit == 'kg':
  948. return (qty * LB_TO_KG).quantize(Decimal('0.000'))
  949. if from_unit == 'kg' and to_unit == 'lb':
  950. return (qty / LB_TO_KG).quantize(Decimal('0.000'))
  951. # pcs or unknown: cannot convert; return 0 so we don't skew sums
  952. return Decimal('0')
  953. sum_in_ticket_unit = Decimal('0.000')
  954. has_unconvertible = False
  955. for ln in lines:
  956. if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
  957. sum_in_ticket_unit += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
  958. else:
  959. # pcs or mixed with pcs — flag as unconvertible
  960. if ln.unit != ticket.unit:
  961. has_unconvertible = True
  962. else:
  963. # same unit (e.g., pcs) — include as-is
  964. sum_in_ticket_unit += Decimal(ln.quantity)
  965. try:
  966. net = Decimal(ticket.net_weight)
  967. except Exception:
  968. net = Decimal('0.000')
  969. delta = (net - sum_in_ticket_unit).quantize(Decimal('0.000'))
  970. # Prepare weights edit form (pre-filled) when not coming from a failed POST
  971. if weights_form is None:
  972. weights_form = WeighTicketWeightsForm(initial={
  973. 'gross_weight': ticket.gross_weight,
  974. 'tare_weight': ticket.tare_weight,
  975. 'unit': ticket.unit,
  976. })
  977. context = {
  978. "nav": _nav_items(),
  979. "ticket": ticket,
  980. "lines": lines,
  981. "generate_url": reverse("recycle_core:weigh_ticket_generate_invoice", args=[ticket.id]),
  982. "add_line_form": add_line_form,
  983. "weights_form": weights_form,
  984. "documents": docs,
  985. "ct_id": ct.id,
  986. "lines_sum": sum_in_ticket_unit,
  987. "lines_delta": delta,
  988. "has_unconvertible": has_unconvertible,
  989. "allow_adjust_net": ticket.pickup.organization.allow_adjust_net_to_lines,
  990. "allow_residual": ticket.pickup.organization.allow_add_residual_line,
  991. "is_finalized": ticket.is_finalized,
  992. }
  993. return render(request, "recycle_core/weigh_ticket_detail.html", context)
  994. @staff_member_required
  995. @permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
  996. @require_POST
  997. def weigh_ticket_generate_invoice(request, pk: int):
  998. ticket = get_object_or_404(WeighTicket, pk=pk)
  999. try:
  1000. invoice = generate_invoice_for_pickup(ticket.pickup)
  1001. messages.success(request, f"Invoice #{invoice.id} generated from ticket {ticket.ticket_number or ticket.id}.")
  1002. except Exception as e:
  1003. messages.error(request, f"Failed to generate invoice: {e}")
  1004. return redirect("recycle_core:pickup_detail", pk=ticket.pickup_id)
  1005. @staff_member_required
  1006. @require_POST
  1007. def weigh_ticket_recalc_carbon(request, pk: int):
  1008. ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__organization"), pk=pk)
  1009. try:
  1010. events = carbon_svc.calculate_events_for_ticket(ticket)
  1011. messages.success(request, f"Recalculated carbon events: {len(events)} event(s).")
  1012. except Exception as e:
  1013. messages.error(request, f"Failed to recalculate carbon events: {e}")
  1014. return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
  1015. # Billing-related views moved to billing/views.py
  1016. # Documents UI --------------------------------------------------------------
  1017. @staff_member_required
  1018. @public_route(label="Documents", order=55, icon="paper-clip")
  1019. @breadcrumbs(label="Documents", name="re_documents")
  1020. def documents_list(request):
  1021. class DocumentFilter(filters.FilterSet):
  1022. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  1023. kind = filters.CharFilter(field_name="kind", lookup_expr="icontains")
  1024. content_type = filters.ModelChoiceFilter(queryset=ContentType.objects.all())
  1025. object_id = filters.NumberFilter(field_name="object_id")
  1026. uploaded_by = filters.ModelChoiceFilter(queryset=get_user_model().objects.all())
  1027. created_at = filters.DateFromToRangeFilter(field_name="created_at", label="Uploaded between")
  1028. class Meta:
  1029. model = Document
  1030. fields = ["organization", "kind", "content_type", "object_id", "uploaded_by", "created_at"]
  1031. form = DocumentForm(request.POST or None, request.FILES or None)
  1032. if getattr(request, "org", None) is not None:
  1033. try:
  1034. form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  1035. except Exception:
  1036. pass
  1037. if request.method == "POST":
  1038. if form.is_valid():
  1039. doc = Document(
  1040. organization=(getattr(request, "org", None) or form.cleaned_data["organization"]),
  1041. file=form.cleaned_data["file"],
  1042. kind=form.cleaned_data.get("kind") or "",
  1043. content_type=form.cleaned_data["content_type"],
  1044. object_id=form.cleaned_data["object_id"],
  1045. uploaded_by=request.user,
  1046. )
  1047. doc.save()
  1048. messages.success(request, "Document uploaded.")
  1049. return redirect("recycle_core:documents_list")
  1050. else:
  1051. messages.error(request, "Please correct the document form errors.")
  1052. base_qs = Document.objects.select_related("organization", "uploaded_by", "content_type").order_by("-created_at")
  1053. d_filter = DocumentFilter(request.GET, queryset=base_qs)
  1054. qs = d_filter.qs
  1055. org = getattr(request, "org", None)
  1056. if org is not None:
  1057. qs = qs.filter(organization=org)
  1058. paginator = Paginator(qs, 10)
  1059. page = request.GET.get("page")
  1060. try:
  1061. page_obj = paginator.page(page)
  1062. except PageNotAnInteger:
  1063. page_obj = paginator.page(1)
  1064. except EmptyPage:
  1065. page_obj = paginator.page(paginator.num_pages)
  1066. context = {
  1067. "nav": _nav_items(),
  1068. "documents": page_obj.object_list,
  1069. "page_obj": page_obj,
  1070. "filter": d_filter,
  1071. "form": form,
  1072. }
  1073. return render(request, "recycle_core/documents_list.html", context)
  1074. # Audit Logs UI -------------------------------------------------------------
  1075. @staff_member_required
  1076. @public_route(label="Audit Logs", order=60, icon="clock")
  1077. @breadcrumbs(label="Audit Logs", name="re_audit_logs")
  1078. def audit_logs_list(request):
  1079. class AuditFilter(filters.FilterSet):
  1080. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  1081. user = filters.ModelChoiceFilter(queryset=get_user_model().objects.all())
  1082. action = filters.CharFilter(field_name="action", lookup_expr="icontains")
  1083. content_type = filters.ModelChoiceFilter(queryset=ContentType.objects.all())
  1084. object_id = filters.NumberFilter(field_name="object_id")
  1085. created_at = filters.DateFromToRangeFilter(field_name="created_at", label="Between")
  1086. class Meta:
  1087. model = AuditLog
  1088. fields = ["organization", "user", "action", "content_type", "object_id", "created_at"]
  1089. base_qs = AuditLog.objects.select_related("organization", "user", "content_type").order_by("-created_at")
  1090. a_filter = AuditFilter(request.GET, queryset=base_qs)
  1091. qs = a_filter.qs
  1092. org = getattr(request, "org", None)
  1093. if org is not None:
  1094. qs = qs.filter(organization=org)
  1095. paginator = Paginator(qs, 10)
  1096. page = request.GET.get("page")
  1097. try:
  1098. page_obj = paginator.page(page)
  1099. except PageNotAnInteger:
  1100. page_obj = paginator.page(1)
  1101. except EmptyPage:
  1102. page_obj = paginator.page(paginator.num_pages)
  1103. context = {
  1104. "nav": _nav_items(),
  1105. "logs": page_obj.object_list,
  1106. "page_obj": page_obj,
  1107. "filter": a_filter,
  1108. }
  1109. return render(request, "recycle_core/audit_logs_list.html", context)
  1110. @staff_member_required
  1111. def audit_logs_export_csv(request):
  1112. qs = AuditLog.objects.select_related("organization", "user", "content_type").all()
  1113. org_obj = getattr(request, "org", None)
  1114. if org_obj is not None:
  1115. qs = qs.filter(organization=org_obj)
  1116. org = request.GET.get("organization")
  1117. user = request.GET.get("user")
  1118. action = request.GET.get("action")
  1119. ctype = request.GET.get("content_type")
  1120. obj_id = request.GET.get("object_id")
  1121. start = request.GET.get("created_at_after") or request.GET.get("created_at_min")
  1122. end = request.GET.get("created_at_before") or request.GET.get("created_at_max")
  1123. from django.utils.dateparse import parse_datetime
  1124. if org:
  1125. qs = qs.filter(organization_id=org)
  1126. if user:
  1127. qs = qs.filter(user_id=user)
  1128. if action:
  1129. qs = qs.filter(action__icontains=action)
  1130. if ctype:
  1131. qs = qs.filter(content_type_id=ctype)
  1132. if obj_id:
  1133. qs = qs.filter(object_id=obj_id)
  1134. if start:
  1135. dt = parse_datetime(start)
  1136. if dt:
  1137. qs = qs.filter(created_at__gte=dt)
  1138. if end:
  1139. dt = parse_datetime(end)
  1140. if dt:
  1141. qs = qs.filter(created_at__lte=dt)
  1142. response = HttpResponse(content_type="text/csv")
  1143. response["Content-Disposition"] = "attachment; filename=audit_logs.csv"
  1144. writer = csv.writer(response)
  1145. writer.writerow(["created_at", "organization", "user", "action", "content_type", "object_id", "metadata"])
  1146. for log in qs.iterator():
  1147. writer.writerow([
  1148. log.created_at.isoformat(),
  1149. log.organization.code,
  1150. getattr(log.user, "username", ""),
  1151. log.action,
  1152. log.content_type.model,
  1153. log.object_id,
  1154. log.metadata,
  1155. ])
  1156. return response
  1157. # Carbon approvals queue -----------------------------------------------------
  1158. @staff_member_required
  1159. @public_route(label="Carbon Approvals", order=65, icon="check-circle")
  1160. @breadcrumbs(label="Carbon Events", name="re_carbon_events")
  1161. def carbon_events_list(request):
  1162. class CarbonFilter(filters.FilterSet):
  1163. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  1164. status = filters.ChoiceFilter(field_name="status", choices=CarbonEvent.STATUS_CHOICES)
  1165. event_date = filters.DateFromToRangeFilter(field_name="event_date", label="Event between")
  1166. class Meta:
  1167. model = CarbonEvent
  1168. fields = ["organization", "status", "event_date"]
  1169. base_qs = CarbonEvent.objects.select_related("organization", "weigh_ticket", "material").order_by("-event_date")
  1170. c_filter = CarbonFilter(request.GET, queryset=base_qs)
  1171. qs = c_filter.qs
  1172. org = getattr(request, "org", None)
  1173. if org is not None:
  1174. qs = qs.filter(organization=org)
  1175. if request.method == "POST":
  1176. action = request.POST.get("action")
  1177. ids = request.POST.getlist("ids")
  1178. sel = list(qs.filter(id__in=ids))
  1179. if action == "approve":
  1180. n_total = 0
  1181. for ev in sel:
  1182. n_total += carbon_svc.approve_events(ev.organization, [ev], approver=request.user)
  1183. messages.success(request, f"Approved {n_total} event(s).")
  1184. return redirect("recycle_core:carbon_events_list")
  1185. elif action == "reject":
  1186. from django.utils import timezone
  1187. now = timezone.now()
  1188. for ev in sel:
  1189. ev.status = CarbonEvent.STATUS_REJECTED
  1190. ev.approved_by = request.user
  1191. ev.approved_at = now
  1192. ev.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
  1193. messages.success(request, f"Rejected {len(sel)} event(s).")
  1194. return redirect("recycle_core:carbon_events_list")
  1195. else:
  1196. messages.error(request, "Invalid action.")
  1197. paginator = Paginator(qs, 10)
  1198. page = request.GET.get("page")
  1199. try:
  1200. page_obj = paginator.page(page)
  1201. except PageNotAnInteger:
  1202. page_obj = paginator.page(1)
  1203. except EmptyPage:
  1204. page_obj = paginator.page(paginator.num_pages)
  1205. context = {
  1206. "nav": _nav_items(),
  1207. "events": page_obj.object_list,
  1208. "page_obj": page_obj,
  1209. "filter": c_filter,
  1210. }
  1211. return render(request, "recycle_core/carbon_events_list.html", context)
  1212. # Emission Factors -----------------------------------------------------------
  1213. @staff_member_required
  1214. @public_route(label="Emission Factors", order=62, icon="bolt")
  1215. @breadcrumbs(label="Emission Factors", name="re_factors")
  1216. def emission_factors_list(request):
  1217. class FactorFilter(filters.FilterSet):
  1218. organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
  1219. material = filters.ModelChoiceFilter(queryset=Material.objects.all())
  1220. unit = filters.ChoiceFilter(choices=Material.UNIT_CHOICES)
  1221. effective_from = filters.DateFromToRangeFilter(field_name="effective_from", label="Effective from")
  1222. class Meta:
  1223. model = MaterialEmissionFactor
  1224. fields = ["organization", "material", "unit", "effective_from"]
  1225. form = MaterialEmissionFactorForm(request.POST or None)
  1226. # Scope org choices
  1227. if getattr(request, "org", None) is not None:
  1228. try:
  1229. form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  1230. form.fields["material"].queryset = Material.objects.filter(organization=request.org)
  1231. except Exception:
  1232. pass
  1233. if request.method == "POST":
  1234. if form.is_valid():
  1235. obj = form.save(commit=False)
  1236. if getattr(request, "org", None) is not None:
  1237. obj.organization = request.org
  1238. obj.save()
  1239. messages.success(request, "Emission factor created.")
  1240. return redirect("recycle_core:emission_factors_list")
  1241. else:
  1242. messages.error(request, "Please correct the errors below.")
  1243. base_qs = MaterialEmissionFactor.objects.select_related("organization", "material").order_by("organization_id", "material_id", "-effective_from", "-id")
  1244. f_filter = FactorFilter(request.GET, queryset=base_qs)
  1245. qs = f_filter.qs
  1246. if getattr(request, "org", None) is not None:
  1247. qs = qs.filter(organization=request.org)
  1248. paginator = Paginator(qs, 15)
  1249. page = request.GET.get("page")
  1250. try:
  1251. page_obj = paginator.page(page)
  1252. except PageNotAnInteger:
  1253. page_obj = paginator.page(1)
  1254. except EmptyPage:
  1255. page_obj = paginator.page(paginator.num_pages)
  1256. context = {
  1257. "nav": _nav_items(),
  1258. "factors": page_obj.object_list,
  1259. "page_obj": page_obj,
  1260. "filter": f_filter,
  1261. "form": form,
  1262. }
  1263. return render(request, "recycle_core/emission_factors_list.html", context)
  1264. @staff_member_required
  1265. @breadcrumbs(label="Edit Factor", parent="re_factors")
  1266. def emission_factor_edit(request, pk: int):
  1267. item = get_object_or_404(MaterialEmissionFactor.objects.select_related("organization", "material"), pk=pk)
  1268. if request.method == "POST":
  1269. form = MaterialEmissionFactorForm(request.POST, instance=item)
  1270. # Scope choices
  1271. if getattr(request, "org", None) is not None:
  1272. try:
  1273. form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  1274. form.fields["material"].queryset = Material.objects.filter(organization=request.org)
  1275. except Exception:
  1276. pass
  1277. if form.is_valid():
  1278. inst = form.save(commit=False)
  1279. if getattr(request, "org", None) is not None:
  1280. inst.organization = request.org
  1281. inst.save()
  1282. messages.success(request, "Emission factor updated.")
  1283. return redirect("recycle_core:emission_factors_list")
  1284. else:
  1285. messages.error(request, "Please correct the errors below.")
  1286. else:
  1287. form = MaterialEmissionFactorForm(instance=item)
  1288. if getattr(request, "org", None) is not None:
  1289. try:
  1290. form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
  1291. form.fields["material"].queryset = Material.objects.filter(organization=request.org)
  1292. except Exception:
  1293. pass
  1294. return render(request, "recycle_core/emission_factor_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:emission_factors_list")})
  1295. @staff_member_required
  1296. @require_POST
  1297. def emission_factor_delete(request, pk: int):
  1298. item = get_object_or_404(MaterialEmissionFactor, pk=pk)
  1299. item.delete()
  1300. messages.success(request, "Emission factor deleted.")
  1301. return redirect("recycle_core:emission_factors_list")
  1302. @staff_member_required
  1303. @public_route(label="Provided Services", order=15, icon="sparkles")
  1304. @breadcrumbs(label="Provided Services", name="rc_services")
  1305. def services_list(request):
  1306. if not request.user.is_authenticated or not getattr(request.user, "is_staff", False):
  1307. from django.contrib.auth.views import redirect_to_login
  1308. return redirect_to_login(request.get_full_path(), login_url=reverse("admin_frontend:login"))
  1309. org = getattr(request, "org", None)
  1310. services = ProvidedService.objects.filter(organization=org).order_by("display_order", "id")
  1311. return render(request, "recycle_core/services_list.html", {"nav": _nav_items(), "services": services})
  1312. @breadcrumbs(label="New Service", parent="rc_services")
  1313. @owner_required
  1314. def service_create(request):
  1315. if request.method == "POST":
  1316. form = ProvidedServiceForm(request.POST)
  1317. if form.is_valid():
  1318. obj = form.save(commit=False)
  1319. obj.organization = getattr(request, "org", None)
  1320. obj.save()
  1321. messages.success(request, "Service created.")
  1322. return redirect("recycle_core:services_list")
  1323. messages.error(request, "Please correct the errors below.")
  1324. else:
  1325. form = ProvidedServiceForm()
  1326. return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "form": form, "cancel_url": reverse("recycle_core:services_list")})
  1327. @breadcrumbs(label="Edit Service", parent="rc_services")
  1328. @owner_required
  1329. def service_edit(request, pk: int):
  1330. org = getattr(request, "org", None)
  1331. item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
  1332. if not item:
  1333. messages.error(request, "Service not found.")
  1334. return redirect("recycle_core:services_list")
  1335. if request.method == "POST":
  1336. form = ProvidedServiceForm(request.POST, instance=item)
  1337. if form.is_valid():
  1338. obj = form.save(commit=False)
  1339. obj.organization = org
  1340. obj.save()
  1341. messages.success(request, "Service updated.")
  1342. return redirect("recycle_core:service_edit", pk=item.pk)
  1343. messages.error(request, "Please correct the errors below.")
  1344. else:
  1345. form = ProvidedServiceForm(instance=item)
  1346. return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:services_list")})
  1347. @require_POST
  1348. @owner_required
  1349. def service_delete(request, pk: int):
  1350. org = getattr(request, "org", None)
  1351. item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
  1352. if not item:
  1353. messages.error(request, "Service not found.")
  1354. else:
  1355. item.delete()
  1356. messages.success(request, "Service deleted.")
  1357. return redirect("recycle_core:services_list")
  1358. @require_POST
  1359. @owner_required
  1360. def service_toggle_enabled(request, pk: int):
  1361. org = getattr(request, "org", None)
  1362. item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
  1363. if not item:
  1364. messages.error(request, "Service not found.")
  1365. else:
  1366. item.is_enabled = not item.is_enabled
  1367. item.save(update_fields=["is_enabled"])
  1368. messages.success(request, f"Service '{item.title}' {'enabled' if item.is_enabled else 'disabled'}.")
  1369. return redirect("recycle_core:services_list")
  1370. @require_POST
  1371. @owner_required
  1372. def services_reorder(request):
  1373. """Reorder ProvidedService.display_order for the current organization.
  1374. Expects JSON body with {"ids": [<service_id>, ...]} in the new order (top→bottom).
  1375. """
  1376. try:
  1377. data = json.loads(request.body.decode("utf-8"))
  1378. ids = data.get("ids", [])
  1379. if not isinstance(ids, list):
  1380. return JsonResponse({"ok": False, "error": "Invalid payload."}, status=400)
  1381. except Exception:
  1382. return JsonResponse({"ok": False, "error": "Malformed JSON."}, status=400)
  1383. org = getattr(request, "org", None)
  1384. # Fetch only services belonging to this org and requested ids
  1385. qs = ProvidedService.objects.filter(organization=org, id__in=ids)
  1386. existing = {obj.id: obj for obj in qs}
  1387. # Enforce order based on the incoming list; skip unknown ids
  1388. with transaction.atomic():
  1389. for idx, sid in enumerate(ids):
  1390. obj = existing.get(sid)
  1391. if not obj:
  1392. continue
  1393. if obj.display_order != idx:
  1394. obj.display_order = idx
  1395. obj.save(update_fields=["display_order"])
  1396. return JsonResponse({"ok": True})