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": [, ...]} 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})