Nessuna descrizione

views.py 35KB

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