| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556 |
- from django.shortcuts import render, redirect, get_object_or_404
- from django.contrib.admin.views.decorators import staff_member_required
- from django.views.decorators.http import require_POST
- from django.contrib import messages
- from django.urls import reverse
- from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
- import django_filters as filters
- from admin_frontend.templatetags.public_urls import public_route
- from django import forms
- from admin_frontend.nav import _nav_items
- from cms.views import breadcrumbs
- from orgs.decorators import permissions_required
- from orgs.models import Organization, UserProfile
- from .models import (
- MaterialCategory,
- Material,
- MaterialImage,
- ProvidedService,
- Customer,
- CustomerSite,
- PickupItem,
- PriceList,
- PriceListItem,
- PickupOrder,
- WeighTicket,
- WeighLine,
- ScrapListing,
- ScrapBid,
- Document,
- AuditLog,
- CarbonEvent,
- CarbonBalance,
- MaterialEmissionFactor,
- )
- from .forms import (
- MaterialForm,
- MaterialCategoryForm,
- CustomerForm,
- PickupAssignForm,
- PickupStatusForm,
- DocumentForm,
- ProvidedServiceForm,
- WeighTicketForm,
- WeighLineForm,
- WeighTicketWeightsForm,
- MaterialEmissionFactorForm,
- PickupItemForm,
- )
- from .services.billing import generate_invoice_for_pickup, regenerate_invoice_for_pickup
- from billing.models import Invoice
- from .services import carbon as carbon_svc
- from django.contrib.contenttypes.models import ContentType
- from django.contrib.auth import get_user_model
- from django.http import HttpResponse, JsonResponse
- from django.db import transaction
- import json
- import csv
- from django.db.models import Sum, F, ExpressionWrapper, DecimalField, Value
- from django.db.models.functions import Coalesce
- from decimal import Decimal
- from functools import wraps
- def owner_required(view_func):
- @wraps(view_func)
- def _wrapped(request, *args, **kwargs):
- if not request.user.is_authenticated:
- from django.contrib.auth.views import redirect_to_login
- return redirect_to_login(request.get_full_path(), login_url=reverse("admin_frontend:login"))
- profile = getattr(request.user, "recycle_profile", None)
- if not profile or profile.role != UserProfile.ROLE_OWNER:
- messages.error(request, "Only organization owners can manage users.")
- return redirect("admin_frontend:dashboard")
- if getattr(request, "org", None) is not None and profile.organization_id != request.org.id:
- messages.error(request, "You are not permitted for this organization.")
- return redirect("admin_frontend:dashboard")
- return view_func(request, *args, **kwargs)
- return _wrapped
- @staff_member_required
- @public_route(label="Materials", order=10, icon="beaker")
- @breadcrumbs(label="Materials", name="re_materials")
- def materials_list(request):
- # Create forms
- mat_form = MaterialForm(request.POST or None, request.FILES or None)
- cat_form = MaterialCategoryForm(request.POST or None)
- # Restrict organization choices in forms to current org
- if getattr(request, "org", None) is not None:
- try:
- mat_form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
- cat_form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
- except Exception:
- pass
- if request.method == "POST":
- # Distinguish which form posted via a hidden field or field presence
- if "default_unit" in request.POST:
- if mat_form.is_valid():
- obj = mat_form.save(commit=False)
- if getattr(request, "org", None) is not None:
- obj.organization = request.org
- obj.save()
- # Save any uploaded images deferred by the form
- try:
- mat_form.save_images(instance=obj)
- except Exception:
- pass
- messages.success(request, "Material created.")
- return redirect("recycle_core:materials_list")
- else:
- messages.error(request, "Please correct the errors in material form.")
- else:
- if cat_form.is_valid():
- obj = cat_form.save(commit=False)
- if getattr(request, "org", None) is not None:
- obj.organization = request.org
- obj.save()
- messages.success(request, "Category created.")
- return redirect("recycle_core:materials_list")
- else:
- messages.error(request, "Please correct the errors in category form.")
- # Filters via django-filter to match list pattern
- class MaterialFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- category = filters.ChoiceFilter(choices=Material.CATEGORY_CHOICES)
- name = filters.CharFilter(field_name="name", lookup_expr="icontains")
- class Meta:
- model = Material
- fields = ["organization", "category", "name"]
- base_mats = Material.objects.select_related("organization").order_by("organization_id", "name")
- mat_filter = MaterialFilter(request.GET, queryset=base_mats)
- mats = mat_filter.qs
- # Scope to current organization if present
- org = getattr(request, "org", None)
- if org is not None:
- mats = mats.filter(organization=org)
- cats = MaterialCategory.objects.select_related("organization").order_by("organization_id", "name")
- try:
- sel_org = mat_filter.form.cleaned_data.get("organization")
- if sel_org:
- cats = cats.filter(organization=sel_org)
- except Exception:
- pass
- if org is not None:
- cats = cats.filter(organization=org)
- context = {
- "nav": _nav_items(),
- "materials": mats,
- "categories": cats,
- "mat_form": mat_form,
- "cat_form": cat_form,
- "filter": mat_filter,
- }
- return render(request, "recycle_core/materials_list.html", context)
- # Organization Users (owner-only)
- from .forms import UserCreateForm, UserEditForm, UserProfilePhotoForm
- @owner_required
- @public_route(label="Users", order=5, icon="user-group")
- @breadcrumbs(label="Users", name="re_users")
- def org_users_list(request):
- User = get_user_model()
- org = getattr(request, "org", None)
- qs = User.objects.order_by("id")
- if org is not None:
- qs = qs.filter(recycle_profile__organization=org)
- else:
- qs = qs.none()
- paginator = Paginator(qs, 15)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {"nav": _nav_items(), "users": page_obj.object_list, "page_obj": page_obj}
- return render(request, "recycle_core/users_list.html", context)
- @owner_required
- @breadcrumbs(label="Create User", parent="re_users")
- def org_user_create(request):
- form = UserCreateForm(request.POST or None)
- if request.method == "POST":
- if form.is_valid():
- User = get_user_model()
- user = User.objects.create(
- username=form.cleaned_data["username"],
- email=form.cleaned_data.get("email") or "",
- first_name=form.cleaned_data.get("first_name") or "",
- last_name=form.cleaned_data.get("last_name") or "",
- )
- user.set_password(form.cleaned_data["password1"])
- user.save()
- org = getattr(request, "org", None)
- UserProfile.objects.create(user=user, organization=org, role=form.cleaned_data["role"]) # type: ignore[arg-type]
- messages.success(request, f"User '{user.username}' created.")
- next_url = request.POST.get("next") or request.GET.get("next")
- if next_url:
- return redirect(next_url)
- return redirect("recycle_core:org_users_list")
- messages.error(request, "Please correct the errors below.")
- return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "creating": True, "cancel_url": reverse("recycle_core:org_users_list")})
- @owner_required
- @breadcrumbs(label="Edit User", parent="re_users")
- def org_user_edit(request, pk: int):
- User = get_user_model()
- org = getattr(request, "org", None)
- base_qs = User.objects.all()
- if org is not None:
- base_qs = base_qs.filter(recycle_profile__organization=org)
- user = get_object_or_404(base_qs, pk=pk)
- profile = getattr(user, "recycle_profile", None)
- form = UserEditForm(request.POST or None, initial={
- "email": user.email,
- "first_name": user.first_name,
- "last_name": user.last_name,
- "role": getattr(profile, "role", UserProfile.ROLE_MANAGER),
- "is_active": user.is_active,
- })
- form_photo = UserProfilePhotoForm(request.POST or None, request.FILES or None, instance=profile) if profile else None
- # Extra contact/address fields on the profile
- from django.forms import modelform_factory
- ExtraForm = modelform_factory(UserProfile, fields=(
- "phone",
- "job_title",
- "department",
- "preferred_language",
- "address_line1",
- "address_line2",
- "city",
- "state",
- "postal_code",
- "country",
- ))
- form_extras = ExtraForm(request.POST or None, instance=profile) if profile else None
- if request.method == "POST":
- ok_main = form.is_valid()
- ok_photo = True if form_photo is None else form_photo.is_valid()
- ok_extras = True if form_extras is None else form_extras.is_valid()
- if ok_main and ok_photo and ok_extras:
- user.email = form.cleaned_data.get("email") or ""
- user.first_name = form.cleaned_data.get("first_name") or ""
- user.last_name = form.cleaned_data.get("last_name") or ""
- desired_active = bool(form.cleaned_data.get("is_active"))
- if user.id == request.user.id and not desired_active:
- messages.error(request, "You cannot deactivate your own account.")
- else:
- user.is_active = desired_active
- p1 = form.cleaned_data.get("password1")
- if p1:
- user.set_password(p1)
- user.save()
- if profile is None:
- profile = UserProfile(user=user, organization=org) # type: ignore[arg-type]
- profile.role = form.cleaned_data["role"]
- if org is not None:
- profile.organization = org
- profile.save()
- if form_photo is not None:
- form_photo.instance = profile
- form_photo.save()
- if form_extras is not None:
- form_extras.instance = profile
- form_extras.save()
- messages.success(request, f"User '{user.username}' updated.")
- next_url = request.POST.get("next") or request.GET.get("next")
- if next_url:
- return redirect(next_url)
- return redirect("recycle_core:org_users_list")
- messages.error(request, "Please correct the errors below.")
- 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")})
- @owner_required
- @require_POST
- def org_user_delete(request, pk: int):
- User = get_user_model()
- org = getattr(request, "org", None)
- base_qs = User.objects.all()
- if org is not None:
- base_qs = base_qs.filter(recycle_profile__organization=org)
- user = get_object_or_404(base_qs, pk=pk)
- if user.id == request.user.id:
- messages.error(request, "You cannot delete your own account.")
- return redirect("recycle_core:org_users_list")
- user.delete()
- messages.success(request, "User deleted.")
- return redirect("recycle_core:org_users_list")
- @staff_member_required
- @breadcrumbs(label="Edit Material", parent="re_materials")
- def material_edit(request, pk: int):
- item = get_object_or_404(Material, pk=pk)
- if request.method == "POST":
- form = MaterialForm(request.POST, request.FILES, instance=item)
- if form.is_valid():
- form.save()
- messages.success(request, "Material updated.")
- return redirect("recycle_core:material_edit", pk=item.pk)
- else:
- form = MaterialForm(instance=item)
- return render(
- request,
- "recycle_core/material_form.html",
- {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:materials_list")},
- )
- @staff_member_required
- @require_POST
- def material_delete(request, pk: int):
- item = get_object_or_404(Material, pk=pk)
- item.delete()
- messages.success(request, "Material deleted.")
- return redirect("recycle_core:materials_list")
- @staff_member_required
- @require_POST
- def material_images_reorder(request, pk: int):
- """Reorder MaterialImage.display_order for a material.
- Expects JSON {"ids": [image_id, ...]} in the new order.
- """
- material = get_object_or_404(Material, pk=pk)
- try:
- payload = json.loads(request.body.decode("utf-8"))
- ids = payload.get("ids", [])
- if not isinstance(ids, list):
- return JsonResponse({"ok": False, "error": "Invalid payload"}, status=400)
- except Exception:
- return JsonResponse({"ok": False, "error": "Malformed JSON"}, status=400)
- imgs = MaterialImage.objects.filter(material=material, id__in=ids)
- img_map = {im.id: im for im in imgs}
- with transaction.atomic():
- for idx, iid in enumerate(ids):
- im = img_map.get(iid)
- if not im:
- continue
- if im.display_order != idx:
- im.display_order = idx
- im.save(update_fields=["display_order"])
- return JsonResponse({"ok": True})
- @staff_member_required
- @public_route(label="Customers", order=20, icon="building-storefront")
- @breadcrumbs(label="Customers", name="re_customers")
- def customers_list(request):
- form = CustomerForm(request.POST or None)
- # Restrict organization field to current org if present
- if getattr(request, "org", None) is not None:
- try:
- form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
- except Exception:
- pass
- if request.method == "POST":
- if form.is_valid():
- obj = form.save(commit=False)
- if getattr(request, "org", None) is not None:
- obj.organization = request.org
- obj.save()
- messages.success(request, "Customer created.")
- return redirect("recycle_core:customers_list")
- messages.error(request, "Please correct the errors below.")
- # FilterSet similar to opportunities list
- class CustomerFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- name = filters.CharFilter(field_name="name", lookup_expr="icontains")
- email = filters.CharFilter(field_name="email", lookup_expr="icontains")
- phone = filters.CharFilter(field_name="phone", lookup_expr="icontains")
- class Meta:
- model = Customer
- fields = ["organization", "name", "email", "phone"]
- base_qs = Customer.objects.select_related("organization", "price_list").order_by("organization_id", "name")
- cust_filter = CustomerFilter(request.GET, queryset=base_qs)
- qs = cust_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "customers": page_obj.object_list,
- "page_obj": page_obj,
- "form": form,
- "filter": cust_filter,
- }
- return render(request, "recycle_core/customers_list.html", context)
- @staff_member_required
- @breadcrumbs(label="Edit Customer", parent="re_customers")
- def customer_edit(request, pk: int):
- item = get_object_or_404(Customer, pk=pk)
- if request.method == "POST":
- form = CustomerForm(request.POST, instance=item)
- if form.is_valid():
- form.save()
- messages.success(request, "Customer updated.")
- return redirect("recycle_core:customer_edit", pk=item.pk)
- else:
- form = CustomerForm(instance=item)
- return render(
- request,
- "recycle_core/customer_form.html",
- {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:customers_list")},
- )
- @staff_member_required
- @require_POST
- def customer_delete(request, pk: int):
- item = get_object_or_404(Customer, pk=pk)
- item.delete()
- messages.success(request, "Customer deleted.")
- return redirect("recycle_core:customers_list")
- # Pickups UI -----------------------------------------------------------------
- @staff_member_required
- @public_route(label="Pickups", order=30, icon="truck")
- @breadcrumbs(label="Pickups", name="re_pickups")
- def pickups_list(request):
- # FilterSet for pickups
- class PickupFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
- site = filters.ModelChoiceFilter(queryset=CustomerSite.objects.all())
- assigned_driver = filters.CharFilter(field_name="assigned_driver__username", lookup_expr="icontains", label="Driver")
- status = filters.ChoiceFilter(field_name="status", choices=PickupOrder.STATUS_CHOICES)
- scheduled_at = filters.DateTimeFromToRangeFilter(
- field_name="scheduled_at",
- label="Scheduled between",
- widget=filters.widgets.RangeWidget(
- attrs={
- "type": "datetime-local",
- "class": "border border-gray-300 rounded px-2 py-1"
- }
- ),
- )
- class Meta:
- model = PickupOrder
- fields = ["organization", "customer", "site", "assigned_driver", "status", "scheduled_at"]
- base_qs = (
- PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver")
- .order_by("-created_at")
- )
- p_filter = PickupFilter(request.GET, queryset=base_qs)
- qs = p_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- # empty forms used in row actions
- assign_form = PickupAssignForm()
- # Limit driver choices to users with driver role, scoped to org if present
- drivers_qs = get_user_model().objects.filter(recycle_profile__role="driver")
- if org is not None:
- drivers_qs = drivers_qs.filter(recycle_profile__organization=org)
- assign_form.fields["driver"].queryset = drivers_qs.order_by("username")
- status_form = PickupStatusForm()
- context = {
- "nav": _nav_items(),
- "pickups": page_obj.object_list,
- "page_obj": page_obj,
- "filter": p_filter,
- "assign_form": assign_form,
- "status_form": status_form,
- }
- return render(request, "recycle_core/pickups_list.html", context)
- @staff_member_required
- @permissions_required('recycle_core.assign_driver', message="You are not allowed to assign drivers for this pickup.")
- @require_POST
- def pickup_assign(request, pk: int):
- pickup = get_object_or_404(PickupOrder, pk=pk)
- form = PickupAssignForm(request.POST)
- # Enforce driver role (and org, if present) on POST validation
- org = getattr(request, "org", None)
- drivers_qs = get_user_model().objects.filter(recycle_profile__role="driver")
- if org is not None:
- drivers_qs = drivers_qs.filter(recycle_profile__organization=org)
- form.fields["driver"].queryset = drivers_qs
- if form.is_valid():
- pickup.assigned_driver = form.cleaned_data["driver"]
- pickup.status = PickupOrder.STATUS_SCHEDULED
- pickup.save(update_fields=["assigned_driver", "status"])
- messages.success(request, f"Assigned driver to pickup #{pickup.id}.")
- else:
- messages.error(request, "Invalid driver selection.")
- return redirect("recycle_core:pickups_list")
- @staff_member_required
- @permissions_required('recycle_core.set_pickup_status', message="You are not allowed to change status for this pickup.")
- @require_POST
- def pickup_set_status(request, pk: int):
- pickup = get_object_or_404(PickupOrder, pk=pk)
- form = PickupStatusForm(request.POST)
- if form.is_valid():
- pickup.status = form.cleaned_data["status"]
- pickup.save(update_fields=["status"])
- messages.success(request, f"Updated status for pickup #{pickup.id}.")
- else:
- messages.error(request, "Invalid status selection.")
- return redirect("recycle_core:pickups_list")
- @staff_member_required
- @breadcrumbs(label="Pickup Detail", parent="re_pickups")
- def pickup_detail(request, pk: int):
- pickup = get_object_or_404(
- PickupOrder.objects.select_related("organization", "customer", "site", "assigned_driver"), pk=pk
- )
- items = pickup.items.select_related("material").all()
- add_item_form = None
- if request.method == "POST":
- action = request.POST.get("action")
- if action == "add_item":
- add_item_form = PickupItemForm(request.POST, organization=pickup.organization)
- if add_item_form.is_valid():
- PickupItem.objects.create(
- pickup=pickup,
- material=add_item_form.cleaned_data["material"],
- estimated_qty=add_item_form.cleaned_data.get("estimated_qty"),
- unit=add_item_form.cleaned_data["unit"],
- )
- messages.success(request, "Pickup item added.")
- return redirect("recycle_core:pickup_detail", pk=pickup.id)
- else:
- messages.error(request, "Please correct the item form errors.")
- if add_item_form is None:
- add_item_form = PickupItemForm(organization=pickup.organization)
- # Invoices associated with this pickup (with payment totals/balance)
- inv_qs = (
- Invoice.objects.select_related("customer")
- .filter(pickup=pickup)
- .annotate(
- payments_total=Coalesce(
- Sum("payments__amount"),
- Value(Decimal("0.00")),
- output_field=DecimalField(max_digits=14, decimal_places=2),
- )
- )
- .annotate(
- balance=ExpressionWrapper(
- F("total_amount") - F("payments_total"),
- output_field=DecimalField(max_digits=14, decimal_places=2),
- )
- )
- .order_by("-issued_at", "-id")
- )
- # Compute per-ticket pricing summary (unit price avg and total)
- # Attach pricing summaries on each ticket
- price_list = pickup.customer.price_list or PriceList.objects.filter(organization=pickup.organization).order_by("id").first()
- currency_code = getattr(price_list, "currency_code", None) or pickup.organization.currency_code
- for t in pickup.weigh_tickets.all():
- total = Decimal("0.00")
- for ln in t.lines.all():
- pli = (
- PriceListItem.objects.filter(
- price_list=price_list, material_id=ln.material_id, unit=ln.unit, direction=PriceListItem.DIRECTION_SELL
- ).first()
- if price_list
- else None
- )
- if pli and pli.unit_price and Decimal(pli.unit_price) > 0:
- total += (Decimal(ln.quantity) * Decimal(pli.unit_price)).quantize(Decimal("0.01"))
- avg_unit = None
- try:
- if total > 0 and t.net_weight:
- avg_unit = (total / Decimal(t.net_weight)).quantize(Decimal("0.01"))
- except Exception:
- avg_unit = None
- t.total_value = total
- t.avg_unit = avg_unit
- context = {
- "nav": _nav_items(),
- "pickup": pickup,
- "items": items,
- "add_item_form": add_item_form,
- "invoices": list(inv_qs),
- "ticket_currency": currency_code,
- }
- return render(request, "recycle_core/pickup_detail.html", context)
- @staff_member_required
- @permissions_required('recycle_core.create_weigh_ticket', message="You are not allowed to create weigh tickets.")
- def pickup_create_weigh_ticket(request, pk: int):
- pickup = get_object_or_404(PickupOrder.objects.select_related("customer", "organization"), pk=pk)
- if request.method == "POST":
- form = WeighTicketForm(request.POST)
- if form.is_valid():
- wt = WeighTicket.objects.create(
- pickup=pickup,
- ticket_number=form.cleaned_data.get("ticket_number") or "",
- gross_weight=form.cleaned_data["gross_weight"],
- tare_weight=form.cleaned_data["tare_weight"],
- net_weight=form.cleaned_data["net_weight"],
- unit=form.cleaned_data["unit"],
- recorded_by=request.user,
- )
- pickup.status = PickupOrder.STATUS_WEIGHED
- pickup.save(update_fields=["status"])
- messages.success(request, f"Weigh ticket {wt.ticket_number or wt.id} created.")
- return redirect("recycle_core:weigh_ticket_detail", pk=wt.id)
- messages.error(request, "Please correct the form errors.")
- else:
- form = WeighTicketForm()
- context = {
- "nav": _nav_items(),
- "pickup": pickup,
- "form": form,
- }
- return render(request, "recycle_core/weigh_ticket_form.html", context)
- @staff_member_required
- @permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
- @require_POST
- def pickup_generate_invoice(request, pk: int):
- pickup = get_object_or_404(PickupOrder, pk=pk)
- try:
- if not pickup.weigh_tickets.exists():
- messages.error(request, "Pickup has no weigh tickets.")
- return redirect("recycle_core:pickup_detail", pk=pickup.id)
- invoice = generate_invoice_for_pickup(pickup)
- messages.success(request, f"Invoice #{invoice.id} generated.")
- except Exception as e:
- messages.error(request, f"Failed to generate invoice: {e}")
- return redirect("recycle_core:pickup_detail", pk=pickup.id)
- @staff_member_required
- @permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
- @require_POST
- def pickup_regenerate_invoice(request, pk: int):
- pickup = get_object_or_404(PickupOrder, pk=pk)
- try:
- inv = regenerate_invoice_for_pickup(pickup)
- messages.success(request, f"Invoice #{inv.id} regenerated from current tickets.")
- except Exception as e:
- messages.error(request, f"Failed to regenerate invoice: {e}")
- return redirect("recycle_core:pickup_detail", pk=pickup.id)
- # Scrap Listings UI ----------------------------------------------------------
- @staff_member_required
- @public_route(label="Scrap Listings", order=40, icon="banknotes")
- @breadcrumbs(label="Scrap Listings", name="re_listings")
- def scrap_listings_list(request):
- class ListingFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- customer = filters.ModelChoiceFilter(queryset=Customer.objects.all())
- status = filters.ChoiceFilter(field_name="status", choices=ScrapListing.STATUS_CHOICES)
- is_public = filters.BooleanFilter(field_name="is_public")
- starts_at = filters.DateFromToRangeFilter(field_name="starts_at", label="Starts between")
- ends_at = filters.DateFromToRangeFilter(field_name="ends_at", label="Ends between")
- class Meta:
- model = ScrapListing
- fields = ["organization", "customer", "status", "is_public", "starts_at", "ends_at"]
- base_qs = (
- ScrapListing.objects.select_related("organization", "customer", "site", "created_by")
- .order_by("-created_at")
- )
- l_filter = ListingFilter(request.GET, queryset=base_qs)
- qs = l_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "listings": page_obj.object_list,
- "page_obj": page_obj,
- "filter": l_filter,
- }
- return render(request, "recycle_core/scrap_listings_list.html", context)
- @staff_member_required
- @require_POST
- def scrap_listing_open(request, pk: int):
- listing = get_object_or_404(ScrapListing, pk=pk)
- listing.status = ScrapListing.STATUS_OPEN
- if not listing.starts_at:
- from django.utils import timezone
- listing.starts_at = timezone.now()
- listing.save(update_fields=["status", "starts_at"])
- messages.success(request, f"Listing #{listing.id} opened.")
- return redirect("recycle_core:scrap_listings_list")
- @staff_member_required
- @require_POST
- def scrap_listing_close(request, pk: int):
- listing = get_object_or_404(ScrapListing, pk=pk)
- listing.status = ScrapListing.STATUS_CLOSED
- if not listing.ends_at:
- from django.utils import timezone
- listing.ends_at = timezone.now()
- listing.save(update_fields=["status", "ends_at"])
- messages.success(request, f"Listing #{listing.id} closed.")
- return redirect("recycle_core:scrap_listings_list")
- @staff_member_required
- @require_POST
- def scrap_listing_award(request, pk: int):
- listing = get_object_or_404(ScrapListing, pk=pk)
- top_bid = (
- ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE)
- .order_by("-price_total")
- .first()
- )
- if not top_bid:
- messages.error(request, "No bids to award.")
- return redirect("recycle_core:scrap_listings_list")
- if listing.reserve_price and top_bid.price_total < listing.reserve_price:
- messages.error(request, "Top bid below reserve price.")
- return redirect("recycle_core:scrap_listings_list")
- # Update bid statuses and listing
- ScrapBid.objects.filter(listing=listing, status=ScrapBid.STATUS_ACTIVE).exclude(id=top_bid.id).update(
- status=ScrapBid.STATUS_REJECTED
- )
- top_bid.status = ScrapBid.STATUS_ACCEPTED
- top_bid.save(update_fields=["status"])
- listing.status = ScrapListing.STATUS_AWARDED
- listing.save(update_fields=["status"])
- messages.success(request, f"Listing #{listing.id} awarded to bid #{top_bid.id}.")
- return redirect("recycle_core:scrap_listings_list")
- # Weigh Tickets UI -----------------------------------------------------------
- @staff_member_required
- @public_route(label="Weigh Tickets", order=45, icon="scale")
- @breadcrumbs(label="Weigh Tickets", name="re_tickets")
- def weigh_tickets_list(request):
- class TicketFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all(), field_name="pickup__organization")
- customer = filters.ModelChoiceFilter(queryset=Customer.objects.all(), field_name="pickup__customer")
- recorded_at = filters.DateFromToRangeFilter(field_name="recorded_at", label="Recorded between")
- ticket_number = filters.CharFilter(field_name="ticket_number", lookup_expr="icontains")
- class Meta:
- model = WeighTicket
- fields = ["organization", "customer", "recorded_at", "ticket_number"]
- base_qs = WeighTicket.objects.select_related("pickup", "pickup__customer", "pickup__organization").order_by("-recorded_at")
- t_filter = TicketFilter(request.GET, queryset=base_qs)
- qs = t_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(pickup__organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "tickets": page_obj.object_list,
- "page_obj": page_obj,
- "filter": t_filter,
- }
- return render(request, "recycle_core/weigh_tickets_list.html", context)
- @staff_member_required
- @breadcrumbs(label="Ticket Detail", parent="re_tickets")
- def weigh_ticket_detail(request, pk: int):
- ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__customer", "pickup__organization"), pk=pk)
- lines = WeighLine.objects.select_related("material").filter(ticket=ticket)
- # Add WeighLine inline form
- add_line_form = None
- weights_form = None
- if request.method == "POST":
- action = request.POST.get("action", "add")
- org = ticket.pickup.organization
- if action == "add":
- add_line_form = WeighLineForm(request.POST, organization=org)
- if add_line_form.is_valid():
- WeighLine.objects.create(
- ticket=ticket,
- material=add_line_form.cleaned_data["material"],
- quantity=add_line_form.cleaned_data["quantity"],
- unit=add_line_form.cleaned_data["unit"],
- )
- messages.success(request, "Weigh line added.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- else:
- messages.error(request, "Please correct the line form errors.")
- elif action == "edit":
- line_id = request.POST.get("line_id")
- line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
- form = WeighLineForm(request.POST, organization=org)
- if form.is_valid():
- line.material = form.cleaned_data["material"]
- line.quantity = form.cleaned_data["quantity"]
- line.unit = form.cleaned_data["unit"]
- line.save()
- messages.success(request, f"Line #{line.id} updated.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- else:
- messages.error(request, "Please correct the line form errors.")
- elif action == "delete":
- line_id = request.POST.get("line_id")
- line = get_object_or_404(WeighLine, pk=line_id, ticket=ticket)
- try:
- # Also remove any linked carbon event(s)
- CarbonEvent.objects.filter(weigh_line=line).delete()
- except Exception:
- pass
- line.delete()
- messages.success(request, "Weigh line deleted.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- elif action == "adjust_net_to_lines":
- if not org.allow_adjust_net_to_lines:
- messages.error(request, "Policy does not allow adjusting net to lines.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- # Recalculate sum in ticket unit (computed below again for display)
- LB_TO_KG = Decimal("0.45359237")
- def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
- if from_unit == to_unit:
- return qty
- if from_unit == 'lb' and to_unit == 'kg':
- return (qty * LB_TO_KG).quantize(Decimal('0.000'))
- if from_unit == 'kg' and to_unit == 'lb':
- return (qty / LB_TO_KG).quantize(Decimal('0.000'))
- return Decimal('0')
- total_lines = Decimal('0.000')
- for ln in lines:
- if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
- total_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
- ticket.net_weight = total_lines
- ticket.save(update_fields=["net_weight", "updated_at"])
- messages.success(request, "Net weight adjusted to sum of lines.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- elif action == "add_residual_line":
- if not org.allow_add_residual_line:
- messages.error(request, "Policy does not allow adding residual line.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- # Compute residual quantity = net - sum(lines in ticket unit)
- LB_TO_KG = Decimal("0.45359237")
- def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
- if from_unit == to_unit:
- return qty
- if from_unit == 'lb' and to_unit == 'kg':
- return (qty * LB_TO_KG).quantize(Decimal('0.000'))
- if from_unit == 'kg' and to_unit == 'lb':
- return (qty / LB_TO_KG).quantize(Decimal('0.000'))
- return Decimal('0')
- sum_lines = Decimal('0.000')
- for ln in lines:
- if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
- sum_lines += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
- resid = (Decimal(ticket.net_weight) - sum_lines).quantize(Decimal('0.000'))
- if resid == 0:
- messages.info(request, "No residual difference to add.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- # Ensure residual material exists for the org
- residual_name = "Residual"
- residual = Material.objects.filter(organization=org, name=residual_name).first()
- if not residual:
- residual = Material.objects.create(
- organization=org,
- category="Mixed",
- name=residual_name,
- default_unit=ticket.unit,
- )
- WeighLine.objects.create(ticket=ticket, material=residual, quantity=resid, unit=ticket.unit)
- messages.success(request, f"Residual line of {resid} {ticket.unit} added.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- elif action == "finalize_ticket":
- # Validate tolerance and finalize
- LB_TO_KG = Decimal("0.45359237")
- def _to_kg(qty: Decimal, unit: str) -> Decimal:
- if unit == 'kg':
- return qty
- if unit == 'lb':
- return (qty * LB_TO_KG).quantize(Decimal('0.000'))
- return Decimal('0')
- # compute delta in kg
- sum_lines_ticket = Decimal('0.000')
- for ln in lines:
- if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
- # convert line to ticket unit
- if ln.unit == ticket.unit:
- sum_lines_ticket += Decimal(ln.quantity)
- else:
- 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'))
- delta_ticket = (Decimal(ticket.net_weight) - sum_lines_ticket)
- delta_kg = _to_kg(delta_ticket, ticket.unit)
- net_kg = _to_kg(Decimal(ticket.net_weight), ticket.unit)
- abs_tol_kg = org.ticket_tolerance_abs_kg
- pct_tol_kg = (net_kg * (org.ticket_tolerance_pct / Decimal('100'))).quantize(Decimal('0.000')) if net_kg else Decimal('0.000')
- tol_kg = abs_tol_kg if abs_tol_kg >= pct_tol_kg else pct_tol_kg
- if delta_kg.copy_abs() > tol_kg:
- messages.error(request, f"Cannot finalize: difference {delta_kg} kg exceeds tolerance {tol_kg} kg.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- ticket.is_finalized = True
- ticket.finalized_by = request.user
- from django.utils import timezone as djtz
- ticket.finalized_at = djtz.now()
- ticket.save(update_fields=["is_finalized", "finalized_by", "finalized_at", "updated_at"])
- messages.success(request, "Ticket finalized.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- elif action == "edit_weights":
- # Permission and state checks
- if not request.user.has_perm('recycle_core.edit_weigh_ticket'):
- messages.error(request, "You are not allowed to edit weights.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- if ticket.is_finalized:
- messages.error(request, "Ticket is finalized and cannot be edited.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- wform = WeighTicketWeightsForm(request.POST)
- if wform.is_valid():
- gross = wform.cleaned_data['gross_weight']
- tare = wform.cleaned_data['tare_weight']
- unit = wform.cleaned_data['unit']
- # Disallow unit change if lines exist
- if unit != ticket.unit and lines.exists():
- messages.error(request, "Cannot change unit when lines exist. Remove lines first or keep unit.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- old = {
- 'gross': str(ticket.gross_weight),
- 'tare': str(ticket.tare_weight),
- 'net': str(ticket.net_weight),
- 'unit': ticket.unit,
- }
- ticket.gross_weight = gross
- ticket.tare_weight = tare
- ticket.net_weight = (Decimal(gross) - Decimal(tare)).quantize(Decimal('0.000'))
- ticket.unit = unit
- ticket.save(update_fields=["gross_weight", "tare_weight", "net_weight", "unit", "updated_at"])
- try:
- from .services.audit import log_action
- log_action(
- organization=org,
- user=request.user,
- action="weigh_ticket.update_weights",
- obj=ticket,
- metadata={"before": old, "after": {"gross": str(gross), "tare": str(tare), "net": str(ticket.net_weight), "unit": unit}},
- )
- except Exception:
- pass
- messages.success(request, "Weights updated.")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- else:
- messages.error(request, "Please correct the weights form errors.")
- weights_form = wform # keep field errors for rendering
- else:
- add_line_form = WeighLineForm(organization=ticket.pickup.organization, initial={"unit": ticket.unit})
- ct = ContentType.objects.get_for_model(WeighTicket)
- docs = Document.objects.filter(content_type=ct, object_id=ticket.id)
- # Forms for editing existing lines (attach to each line)
- for ln in lines:
- ln.form = WeighLineForm(
- organization=ticket.pickup.organization,
- initial={"material": ln.material_id, "quantity": ln.quantity, "unit": ln.unit},
- )
- # Compute lines total converted to ticket unit and delta to net weight
- LB_TO_KG = Decimal("0.45359237")
- def _to_ticket_unit(qty: Decimal, from_unit: str, to_unit: str) -> Decimal:
- if from_unit == to_unit:
- return qty
- if from_unit == 'lb' and to_unit == 'kg':
- return (qty * LB_TO_KG).quantize(Decimal('0.000'))
- if from_unit == 'kg' and to_unit == 'lb':
- return (qty / LB_TO_KG).quantize(Decimal('0.000'))
- # pcs or unknown: cannot convert; return 0 so we don't skew sums
- return Decimal('0')
- sum_in_ticket_unit = Decimal('0.000')
- has_unconvertible = False
- for ln in lines:
- if ln.unit in ('kg', 'lb') and ticket.unit in ('kg', 'lb'):
- sum_in_ticket_unit += _to_ticket_unit(Decimal(ln.quantity), ln.unit, ticket.unit)
- else:
- # pcs or mixed with pcs — flag as unconvertible
- if ln.unit != ticket.unit:
- has_unconvertible = True
- else:
- # same unit (e.g., pcs) — include as-is
- sum_in_ticket_unit += Decimal(ln.quantity)
- try:
- net = Decimal(ticket.net_weight)
- except Exception:
- net = Decimal('0.000')
- delta = (net - sum_in_ticket_unit).quantize(Decimal('0.000'))
- # Prepare weights edit form (pre-filled) when not coming from a failed POST
- if weights_form is None:
- weights_form = WeighTicketWeightsForm(initial={
- 'gross_weight': ticket.gross_weight,
- 'tare_weight': ticket.tare_weight,
- 'unit': ticket.unit,
- })
- context = {
- "nav": _nav_items(),
- "ticket": ticket,
- "lines": lines,
- "generate_url": reverse("recycle_core:weigh_ticket_generate_invoice", args=[ticket.id]),
- "add_line_form": add_line_form,
- "weights_form": weights_form,
- "documents": docs,
- "ct_id": ct.id,
- "lines_sum": sum_in_ticket_unit,
- "lines_delta": delta,
- "has_unconvertible": has_unconvertible,
- "allow_adjust_net": ticket.pickup.organization.allow_adjust_net_to_lines,
- "allow_residual": ticket.pickup.organization.allow_add_residual_line,
- "is_finalized": ticket.is_finalized,
- }
- return render(request, "recycle_core/weigh_ticket_detail.html", context)
- @staff_member_required
- @permissions_required('recycle_core.generate_invoice', message="You are not allowed to generate invoices.")
- @require_POST
- def weigh_ticket_generate_invoice(request, pk: int):
- ticket = get_object_or_404(WeighTicket, pk=pk)
- try:
- invoice = generate_invoice_for_pickup(ticket.pickup)
- messages.success(request, f"Invoice #{invoice.id} generated from ticket {ticket.ticket_number or ticket.id}.")
- except Exception as e:
- messages.error(request, f"Failed to generate invoice: {e}")
- return redirect("recycle_core:pickup_detail", pk=ticket.pickup_id)
- @staff_member_required
- @require_POST
- def weigh_ticket_recalc_carbon(request, pk: int):
- ticket = get_object_or_404(WeighTicket.objects.select_related("pickup", "pickup__organization"), pk=pk)
- try:
- events = carbon_svc.calculate_events_for_ticket(ticket)
- messages.success(request, f"Recalculated carbon events: {len(events)} event(s).")
- except Exception as e:
- messages.error(request, f"Failed to recalculate carbon events: {e}")
- return redirect("recycle_core:weigh_ticket_detail", pk=ticket.id)
- # Billing-related views moved to billing/views.py
- # Documents UI --------------------------------------------------------------
- @staff_member_required
- @public_route(label="Documents", order=55, icon="paper-clip")
- @breadcrumbs(label="Documents", name="re_documents")
- def documents_list(request):
- class DocumentFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- kind = filters.CharFilter(field_name="kind", lookup_expr="icontains")
- content_type = filters.ModelChoiceFilter(queryset=ContentType.objects.all())
- object_id = filters.NumberFilter(field_name="object_id")
- uploaded_by = filters.ModelChoiceFilter(queryset=get_user_model().objects.all())
- created_at = filters.DateFromToRangeFilter(field_name="created_at", label="Uploaded between")
- class Meta:
- model = Document
- fields = ["organization", "kind", "content_type", "object_id", "uploaded_by", "created_at"]
- form = DocumentForm(request.POST or None, request.FILES or None)
- if getattr(request, "org", None) is not None:
- try:
- form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
- except Exception:
- pass
- if request.method == "POST":
- if form.is_valid():
- doc = Document(
- organization=(getattr(request, "org", None) or form.cleaned_data["organization"]),
- file=form.cleaned_data["file"],
- kind=form.cleaned_data.get("kind") or "",
- content_type=form.cleaned_data["content_type"],
- object_id=form.cleaned_data["object_id"],
- uploaded_by=request.user,
- )
- doc.save()
- messages.success(request, "Document uploaded.")
- return redirect("recycle_core:documents_list")
- else:
- messages.error(request, "Please correct the document form errors.")
- base_qs = Document.objects.select_related("organization", "uploaded_by", "content_type").order_by("-created_at")
- d_filter = DocumentFilter(request.GET, queryset=base_qs)
- qs = d_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "documents": page_obj.object_list,
- "page_obj": page_obj,
- "filter": d_filter,
- "form": form,
- }
- return render(request, "recycle_core/documents_list.html", context)
- # Audit Logs UI -------------------------------------------------------------
- @staff_member_required
- @public_route(label="Audit Logs", order=60, icon="clock")
- @breadcrumbs(label="Audit Logs", name="re_audit_logs")
- def audit_logs_list(request):
- class AuditFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- user = filters.ModelChoiceFilter(queryset=get_user_model().objects.all())
- action = filters.CharFilter(field_name="action", lookup_expr="icontains")
- content_type = filters.ModelChoiceFilter(queryset=ContentType.objects.all())
- object_id = filters.NumberFilter(field_name="object_id")
- created_at = filters.DateFromToRangeFilter(field_name="created_at", label="Between")
- class Meta:
- model = AuditLog
- fields = ["organization", "user", "action", "content_type", "object_id", "created_at"]
- base_qs = AuditLog.objects.select_related("organization", "user", "content_type").order_by("-created_at")
- a_filter = AuditFilter(request.GET, queryset=base_qs)
- qs = a_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "logs": page_obj.object_list,
- "page_obj": page_obj,
- "filter": a_filter,
- }
- return render(request, "recycle_core/audit_logs_list.html", context)
- @staff_member_required
- def audit_logs_export_csv(request):
- qs = AuditLog.objects.select_related("organization", "user", "content_type").all()
- org_obj = getattr(request, "org", None)
- if org_obj is not None:
- qs = qs.filter(organization=org_obj)
- org = request.GET.get("organization")
- user = request.GET.get("user")
- action = request.GET.get("action")
- ctype = request.GET.get("content_type")
- obj_id = request.GET.get("object_id")
- start = request.GET.get("created_at_after") or request.GET.get("created_at_min")
- end = request.GET.get("created_at_before") or request.GET.get("created_at_max")
- from django.utils.dateparse import parse_datetime
- if org:
- qs = qs.filter(organization_id=org)
- if user:
- qs = qs.filter(user_id=user)
- if action:
- qs = qs.filter(action__icontains=action)
- if ctype:
- qs = qs.filter(content_type_id=ctype)
- if obj_id:
- qs = qs.filter(object_id=obj_id)
- if start:
- dt = parse_datetime(start)
- if dt:
- qs = qs.filter(created_at__gte=dt)
- if end:
- dt = parse_datetime(end)
- if dt:
- qs = qs.filter(created_at__lte=dt)
- response = HttpResponse(content_type="text/csv")
- response["Content-Disposition"] = "attachment; filename=audit_logs.csv"
- writer = csv.writer(response)
- writer.writerow(["created_at", "organization", "user", "action", "content_type", "object_id", "metadata"])
- for log in qs.iterator():
- writer.writerow([
- log.created_at.isoformat(),
- log.organization.code,
- getattr(log.user, "username", ""),
- log.action,
- log.content_type.model,
- log.object_id,
- log.metadata,
- ])
- return response
- # Carbon approvals queue -----------------------------------------------------
- @staff_member_required
- @public_route(label="Carbon Approvals", order=65, icon="check-circle")
- @breadcrumbs(label="Carbon Events", name="re_carbon_events")
- def carbon_events_list(request):
- class CarbonFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- status = filters.ChoiceFilter(field_name="status", choices=CarbonEvent.STATUS_CHOICES)
- event_date = filters.DateFromToRangeFilter(field_name="event_date", label="Event between")
- class Meta:
- model = CarbonEvent
- fields = ["organization", "status", "event_date"]
- base_qs = CarbonEvent.objects.select_related("organization", "weigh_ticket", "material").order_by("-event_date")
- c_filter = CarbonFilter(request.GET, queryset=base_qs)
- qs = c_filter.qs
- org = getattr(request, "org", None)
- if org is not None:
- qs = qs.filter(organization=org)
- if request.method == "POST":
- action = request.POST.get("action")
- ids = request.POST.getlist("ids")
- sel = list(qs.filter(id__in=ids))
- if action == "approve":
- n_total = 0
- for ev in sel:
- n_total += carbon_svc.approve_events(ev.organization, [ev], approver=request.user)
- messages.success(request, f"Approved {n_total} event(s).")
- return redirect("recycle_core:carbon_events_list")
- elif action == "reject":
- from django.utils import timezone
- now = timezone.now()
- for ev in sel:
- ev.status = CarbonEvent.STATUS_REJECTED
- ev.approved_by = request.user
- ev.approved_at = now
- ev.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
- messages.success(request, f"Rejected {len(sel)} event(s).")
- return redirect("recycle_core:carbon_events_list")
- else:
- messages.error(request, "Invalid action.")
- paginator = Paginator(qs, 10)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "events": page_obj.object_list,
- "page_obj": page_obj,
- "filter": c_filter,
- }
- return render(request, "recycle_core/carbon_events_list.html", context)
- # Emission Factors -----------------------------------------------------------
- @staff_member_required
- @public_route(label="Emission Factors", order=62, icon="bolt")
- @breadcrumbs(label="Emission Factors", name="re_factors")
- def emission_factors_list(request):
- class FactorFilter(filters.FilterSet):
- organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
- material = filters.ModelChoiceFilter(queryset=Material.objects.all())
- unit = filters.ChoiceFilter(choices=Material.UNIT_CHOICES)
- effective_from = filters.DateFromToRangeFilter(field_name="effective_from", label="Effective from")
- class Meta:
- model = MaterialEmissionFactor
- fields = ["organization", "material", "unit", "effective_from"]
- form = MaterialEmissionFactorForm(request.POST or None)
- # Scope org choices
- if getattr(request, "org", None) is not None:
- try:
- form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
- form.fields["material"].queryset = Material.objects.filter(organization=request.org)
- except Exception:
- pass
- if request.method == "POST":
- if form.is_valid():
- obj = form.save(commit=False)
- if getattr(request, "org", None) is not None:
- obj.organization = request.org
- obj.save()
- messages.success(request, "Emission factor created.")
- return redirect("recycle_core:emission_factors_list")
- else:
- messages.error(request, "Please correct the errors below.")
- base_qs = MaterialEmissionFactor.objects.select_related("organization", "material").order_by("organization_id", "material_id", "-effective_from", "-id")
- f_filter = FactorFilter(request.GET, queryset=base_qs)
- qs = f_filter.qs
- if getattr(request, "org", None) is not None:
- qs = qs.filter(organization=request.org)
- paginator = Paginator(qs, 15)
- page = request.GET.get("page")
- try:
- page_obj = paginator.page(page)
- except PageNotAnInteger:
- page_obj = paginator.page(1)
- except EmptyPage:
- page_obj = paginator.page(paginator.num_pages)
- context = {
- "nav": _nav_items(),
- "factors": page_obj.object_list,
- "page_obj": page_obj,
- "filter": f_filter,
- "form": form,
- }
- return render(request, "recycle_core/emission_factors_list.html", context)
- @staff_member_required
- @breadcrumbs(label="Edit Factor", parent="re_factors")
- def emission_factor_edit(request, pk: int):
- item = get_object_or_404(MaterialEmissionFactor.objects.select_related("organization", "material"), pk=pk)
- if request.method == "POST":
- form = MaterialEmissionFactorForm(request.POST, instance=item)
- # Scope choices
- if getattr(request, "org", None) is not None:
- try:
- form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
- form.fields["material"].queryset = Material.objects.filter(organization=request.org)
- except Exception:
- pass
- if form.is_valid():
- inst = form.save(commit=False)
- if getattr(request, "org", None) is not None:
- inst.organization = request.org
- inst.save()
- messages.success(request, "Emission factor updated.")
- return redirect("recycle_core:emission_factors_list")
- else:
- messages.error(request, "Please correct the errors below.")
- else:
- form = MaterialEmissionFactorForm(instance=item)
- if getattr(request, "org", None) is not None:
- try:
- form.fields["organization"].queryset = Organization.objects.filter(pk=request.org.pk)
- form.fields["material"].queryset = Material.objects.filter(organization=request.org)
- except Exception:
- pass
- return render(request, "recycle_core/emission_factor_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:emission_factors_list")})
- @staff_member_required
- @require_POST
- def emission_factor_delete(request, pk: int):
- item = get_object_or_404(MaterialEmissionFactor, pk=pk)
- item.delete()
- messages.success(request, "Emission factor deleted.")
- return redirect("recycle_core:emission_factors_list")
- @staff_member_required
- @public_route(label="Provided Services", order=15, icon="sparkles")
- @breadcrumbs(label="Provided Services", name="rc_services")
- def services_list(request):
- if not request.user.is_authenticated or not getattr(request.user, "is_staff", False):
- from django.contrib.auth.views import redirect_to_login
- return redirect_to_login(request.get_full_path(), login_url=reverse("admin_frontend:login"))
- org = getattr(request, "org", None)
- services = ProvidedService.objects.filter(organization=org).order_by("display_order", "id")
- return render(request, "recycle_core/services_list.html", {"nav": _nav_items(), "services": services})
- @breadcrumbs(label="New Service", parent="rc_services")
- @owner_required
- def service_create(request):
- if request.method == "POST":
- form = ProvidedServiceForm(request.POST)
- if form.is_valid():
- obj = form.save(commit=False)
- obj.organization = getattr(request, "org", None)
- obj.save()
- messages.success(request, "Service created.")
- return redirect("recycle_core:services_list")
- messages.error(request, "Please correct the errors below.")
- else:
- form = ProvidedServiceForm()
- return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "form": form, "cancel_url": reverse("recycle_core:services_list")})
- @breadcrumbs(label="Edit Service", parent="rc_services")
- @owner_required
- def service_edit(request, pk: int):
- org = getattr(request, "org", None)
- item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
- if not item:
- messages.error(request, "Service not found.")
- return redirect("recycle_core:services_list")
- if request.method == "POST":
- form = ProvidedServiceForm(request.POST, instance=item)
- if form.is_valid():
- obj = form.save(commit=False)
- obj.organization = org
- obj.save()
- messages.success(request, "Service updated.")
- return redirect("recycle_core:service_edit", pk=item.pk)
- messages.error(request, "Please correct the errors below.")
- else:
- form = ProvidedServiceForm(instance=item)
- return render(request, "recycle_core/service_form.html", {"nav": _nav_items(), "item": item, "form": form, "cancel_url": reverse("recycle_core:services_list")})
- @require_POST
- @owner_required
- def service_delete(request, pk: int):
- org = getattr(request, "org", None)
- item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
- if not item:
- messages.error(request, "Service not found.")
- else:
- item.delete()
- messages.success(request, "Service deleted.")
- return redirect("recycle_core:services_list")
- @require_POST
- @owner_required
- def service_toggle_enabled(request, pk: int):
- org = getattr(request, "org", None)
- item = ProvidedService.objects.filter(organization=org).filter(pk=pk).first()
- if not item:
- messages.error(request, "Service not found.")
- else:
- item.is_enabled = not item.is_enabled
- item.save(update_fields=["is_enabled"])
- messages.success(request, f"Service '{item.title}' {'enabled' if item.is_enabled else 'disabled'}.")
- return redirect("recycle_core:services_list")
- @require_POST
- @owner_required
- def services_reorder(request):
- """Reorder ProvidedService.display_order for the current organization.
- Expects JSON body with {"ids": [<service_id>, ...]} in the new order (top→bottom).
- """
- try:
- data = json.loads(request.body.decode("utf-8"))
- ids = data.get("ids", [])
- if not isinstance(ids, list):
- return JsonResponse({"ok": False, "error": "Invalid payload."}, status=400)
- except Exception:
- return JsonResponse({"ok": False, "error": "Malformed JSON."}, status=400)
- org = getattr(request, "org", None)
- # Fetch only services belonging to this org and requested ids
- qs = ProvidedService.objects.filter(organization=org, id__in=ids)
- existing = {obj.id: obj for obj in qs}
- # Enforce order based on the incoming list; skip unknown ids
- with transaction.atomic():
- for idx, sid in enumerate(ids):
- obj = existing.get(sid)
- if not obj:
- continue
- if obj.display_order != idx:
- obj.display_order = idx
- obj.save(update_fields=["display_order"])
- return JsonResponse({"ok": True})
|