Nessuna descrizione

views.py 40KB

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