#
#   Copyright (c) 2014-2021, 2024-2025 eGauge Systems LLC
# 	4805 Sterling Dr, Suite 1
# 	Boulder, CO 80301
# 	voice: 720-545-9767
# 	email: davidm@egauge.net
#
#   All rights reserved.
#
#   This code is the property of eGauge Systems LLC and may not be
#   copied, modified, or disclosed without any prior and written
#   permission from eGauge Systems LLC.
#
# pylint: disable=too-many-lines, too-many-ancestors, too-few-public-methods
import hashlib
import json
import math
import operator
import os
import re
from collections import OrderedDict
from datetime import date, datetime, timedelta
from datetime import timezone as tz
from decimal import Decimal

import crispy_forms.helper
from dal import autocomplete
from django import forms
from django.conf import settings as cfg
from django.contrib.auth.decorators import permission_required
from django.core.exceptions import (
    ObjectDoesNotExist,
    PermissionDenied,
    ValidationError,
)
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import transaction
from django.forms import ModelForm
from django.forms.forms import NON_FIELD_ERRORS
from django.forms.formsets import DELETION_FIELD_NAME
from django.forms.models import BaseInlineFormSet, inlineformset_factory
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template import defaultfilters
from django.urls import reverse
from django.utils import html, timezone
from django.views import generic

import epic.imports
from epic import perms
from epic.apps import EPIC_App_Config
from epic.base import Enum
from epic.footprints import Footprints
from epic.lib import (
    breadcrumb,
    get_assemblies_using_part,
    get_assembly_name,
    get_initial_from_post,
    get_inventory_summary,
    get_model_fields,
    get_open_order_line_items,
    get_open_order_summary,
    get_part_history,
    get_stock,
    get_stock_summary,
    html_list_pager,
    html_page_nav,
    part_history_summary,
)
from epic.models import (
    Assembly_Item,
    Datasheet,
    Delta,
    Inventory,
    Line_Item,
    Order,
    Part,
    Shipment,
    Transaction,
    Vendor,
    Vendor_Part,
    Warehouse,
    format_part_number,
    html_list_or_none,
    html_part_link,
    part_cost,
)
from epic.widgets import DateTimePicker

# pylint: disable=arguments-differ

KiB = 1024
MiB = KiB * KiB

NUM_EXTRA_ROWS = 10


def crispy_form_helper():
    helper = crispy_forms.helper.FormHelper()
    helper.form_class = "form-horizontal"
    helper.label_class = "col-md-3"
    helper.field_class = "col-md-9"
    helper.form_tag = False
    helper.include_media = False  # why does this default to True???
    return helper


def crispy_inline_form_helper():
    helper = crispy_forms.helper.FormHelper()
    # See https://github.com/maraujop/django-crispy-forms/issues/376
    # as to why we can't used bootstrap3/table_inline_formset.html
    # directly.  It results in a mangled 'Delete' checkbox column.
    helper.template = "epic/inline_formset.html"
    helper.form_tag = False
    return helper


def assert_permission(request, perm):
    if not request.user.has_perm(perm):
        raise PermissionDenied


class Part_Autocomplete(autocomplete.Select2QuerySetView):
    def __init__(self, *args, **kwargs):
        # really defined by ViewMixin but pylint complains without this:
        self.q = None
        super().__init__(*args, **kwargs)

    def get_queryset(self):
        assert_permission(self.request, perms.VIEW)
        if self.q:
            m = re.match(r"(EP)?(\d+)$", self.q, re.I)
            if m:
                # if search is for a number, return that part alone:
                qs = Part.objects.filter(id=m.group(2))
                if len(qs) == 1:
                    return qs
            qs = Part.objects.filter(mfg__icontains=self.q)
            qs |= Part.objects.filter(mfg_pn__icontains=self.q)
        else:
            qs = Part.objects.all()
        self.q = None
        return qs

    def get_result_label(self, result):
        return result.choice_label()

    @staticmethod
    def widget(**kwargs):
        return autocomplete.ModelSelect2(
            url="epic:part-dal",
            attrs={"data-placeholder": "Part #?"},
            **kwargs,
        )

    @staticmethod
    def widget_multiple():
        return autocomplete.ModelSelect2Multiple(
            url="epic:part-dal", attrs={"data-placeholder": "Part #?"}
        )


class Assembly_Autocomplete(autocomplete.Select2QuerySetView):
    def __init__(self, *args, **kwargs):
        # really defined by ViewMixin but pylint complains without this:
        self.q = None
        super().__init__(*args, **kwargs)

    def get_queryset(self):
        assert_permission(self.request, perms.VIEW)
        assy_ids = (
            Assembly_Item.objects.order_by("assy_id")
            .values_list("assy_id", flat=True)
            .distinct()
        )
        assy_qs = Part.objects.filter(id__in=assy_ids)
        if self.q:
            if re.match(r"\d+$", self.q, re.I):
                # if search is for a number, return that part alone:
                qs = assy_qs.filter(id=self.q)
                if len(qs) == 1:
                    return qs
            qs = assy_qs.filter(mfg__icontains=self.q)
            qs |= assy_qs.filter(mfg_pn__icontains=self.q)
        else:
            qs = assy_qs
        self.q = None
        return qs

    def get_result_label(self, result):
        return result.choice_label()

    @staticmethod
    def widget(**kwargs):
        return autocomplete.ModelSelect2(
            url="epic:assembly-dal",
            attrs={"data-placeholder": "Part #?"},
            **kwargs,
        )

    @staticmethod
    def widget_multiple():
        return autocomplete.ModelSelect2Multiple(
            url="epic:assembly-dal", attrs={"data-placeholder": "Part #?"}
        )


class Order_Autocomplete(autocomplete.Select2QuerySetView):
    def __init__(self, *args, **kwargs):
        # really defined by ViewMixin but pylint complains without this:
        self.q = None
        super().__init__(*args, **kwargs)

    def get_queryset(self):
        assert_permission(self.request, perms.VIEW)
        # It would be nice to limit the choices to just open orders, but
        # that doesn't work when we open an existing shipment which is
        # associated with a closed order.  In that case, we lose the order
        # because that closed order won't be a valid choice...
        # qs = qs.filter (status=Enum.TXTN_STATUS_OPEN)
        if self.q:
            qs = Order.objects.none()
            if re.match(r"\d+$", self.q):
                # q is numeric: search open orders for transaction
                # numbers containing that numeric string:
                for c in Order.objects.all():
                    if re.match(r"\d*%s\d*" % self.q, "%d" % c.id):
                        qs |= Order.objects.filter(id=c.id)
                if qs.exists():
                    return qs.order_by("-id")
            qs = Order.objects.filter(vendor__name__contains=self.q)
            qs |= Order.objects.filter(warehouse__name__contains=self.q)
        else:
            qs = Order.objects.all()
        self.q = None
        return qs.order_by("-id")

    def get_result_label(self, result):
        return result.choice_label()

    @staticmethod
    def widget():
        return autocomplete.ModelSelect2(
            url="epic:order-dal", attrs={"data-placeholder": "Order #?"}
        )


def get_mfg_list():
    return (
        Part.objects.order_by("mfg").values_list("mfg", flat=True).distinct()
    )


class Mfg_Autocomplete(autocomplete.Select2ListView):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.choice_list = get_mfg_list()

    def create(self, text):
        return text

    def get_list(self):
        assert_permission(self.request, perms.VIEW)
        return self.choice_list

    @staticmethod
    def widget():
        return autocomplete.ListSelect2(
            url="epic:mfg-dal", attrs={"data-placeholder": "Manufacturer?"}
        )


class Footprint_Autocomplete(autocomplete.Select2ListView):
    def get_list(self):
        assert_permission(self.request, perms.VIEW)
        return sorted(Footprints.get())

    @staticmethod
    def widget():
        return autocomplete.Select2(
            url="epic:footprint-dal", attrs={"data-placeholder": "Footprint?"}
        )


@permission_required(perms.VIEW)
def part_info(request):
    """This lets Javascript query for part info.  The query-format is:

    /epic/part/info/?pid=PART_ID&vid=VENDOR_ID

    where PID is the part id and VID is the id of the vendor.  If only
    PID is specified, the part-id (pid), manufacturer (mfg),
    manufacturer's part-number (mfg_pn), and the target-price (price)
    are returned.  The values are returned as a dictionary, indexed by
    the part-number.

    If PID and VID are specified and the part is available from the
    specified vendor, additionally the vendor's part-number (vendor_pn)
    is returned.  Also, instead of the part's target-price, the
    vendor's price is returned (price).

    PID may also be a comma-separated list of part-ids, in which case
    info for each of those parts is returned.

    If only VID is specified, all that vendor's parts are returned.

    """
    pid = request.GET.get("pid", "")
    if pid:
        try:
            pid_list = [int(pid_str) for pid_str in pid.split(",")]
        except ValueError:
            pid = None
    vid = request.GET.get("vid", None)
    qs = Part.objects.none()
    if vid:
        try:
            qs = Vendor_Part.objects.filter(vendor_id=int(vid))
            if pid:
                qs = qs.filter(part_id__in=pid_list)
                if not qs:
                    qs = Part.objects.filter(id__in=pid_list)
        except ValueError:
            vid = None
    elif pid:
        qs = Part.objects.filter(id__in=pid_list)

    info = {}
    for part in qs:
        i = {}
        if isinstance(part, Vendor_Part):
            vp = part
            part = vp.part
            i["vendor_pn"] = vp.vendor_pn
            i["price"] = "%s" % vp.price
        else:
            i["price"] = "%s" % part.target_price
        i["mfg"] = part.mfg
        i["mfg_pn"] = part.mfg_pn
        info[part.id] = i
    json_info = json.dumps(info)
    return HttpResponse(json_info, content_type="application/json")


class PartMultipleChoiceField(forms.ModelMultipleChoiceField):
    def label_from_instance(self, obj):
        return obj.choice_label()


class Part_Edit_Form(ModelForm):
    substitutes = PartMultipleChoiceField(
        required=False,
        queryset=Part.objects.all(),
        widget=Part_Autocomplete.widget_multiple(),
    )
    mfg = autocomplete.Select2ListCreateChoiceField(
        choice_list=get_mfg_list, widget=Mfg_Autocomplete.widget()
    )
    footprint = autocomplete.Select2ListChoiceField(
        required=False,
        choice_list=Footprints.get,
        widget=autocomplete.ListSelect2(
            url="epic:footprint-dal", attrs={"data-placeholder": "Footprint?"}
        ),
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if kwargs["instance"]:
            substitutes_initial = list(self.instance.equivalent_parts())
            substitutes_initial.remove(self.instance)
            self.fields["substitutes"].initial = substitutes_initial

        self.helper = crispy_form_helper()

    def clean_footprint(self):
        return self.cleaned_data.get("footprint").strip()

    def save(self, commit=True):
        instance = super().save(commit)

        old_set = set(instance.equivalent_parts())
        new_set = set([instance])
        changed = set()

        for sub in self.cleaned_data["substitutes"]:
            new_set.add(sub)

            # if this part's status is PREFERRED, downgrade all other
            # substitutes from PREFERRED to active:
            if (
                instance.status == Enum.STATUS_PREFERRED
                and sub.status == Enum.STATUS_PREFERRED
            ):
                sub.status = Enum.STATUS_ACTIVE
                changed.add(sub)

        added = new_set - old_set
        deleted = old_set - new_set
        current = old_set.copy()

        for d in deleted:
            current.remove(d)
            for prev in current:
                if prev.substitute == d:
                    prev.substitute = d.substitute
                    changed.add(prev)
            if d.substitute is not None:
                d.substitute = None
                changed.add(d)

        for a in added:
            for prev in current:
                if prev.substitute is None:
                    prev.substitute = a
                    changed.add(prev)
            current.add(a)

        for c in changed:
            c.save()

        return instance

    def clean_substitutes(self):
        instance_equivalent = (
            None if self.instance is None else self.instance.equivalent_parts()
        )
        for part in self.cleaned_data["substitutes"]:
            params = {"part": format_part_number(int(part.id))}
            if part == self.instance:
                raise ValidationError(
                    "Part %(part)s cannot be its own substitute.",
                    params=params,
                )
            if (
                part not in instance_equivalent
                and len(part.equivalent_parts()) != 1
                and len(instance_equivalent) != 1
            ):
                raise ValidationError(
                    "Part %(part)s is already in a different "
                    "substitute set.  Remove it from that set "
                    "before adding it to this set.",
                    params=params,
                )

        return self.cleaned_data["substitutes"]

    class Meta:
        model = Part
        fields = [
            "mfg",
            "mfg_pn",
            "descr",
            "val",
            "footprint",
            "mounting",
            "target_price",
            "overage",
            "spq",
            "lead_time",
            "status",
            "datasheet",
            "notes",
        ]


class Vendor_Part_Edit_Formset(BaseInlineFormSet):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_inline_form_helper()

    def add_fields(self, form, index):
        super().add_fields(form, index)
        form.fields["status"].help_text = ""

    def clean(self):
        super().clean()

        for form in self.forms:
            vendor = form.cleaned_data.get("vendor")
            if vendor and form.cleaned_data.get(DELETION_FIELD_NAME):
                qs = Order.objects.filter(vendor_id=vendor.id).filter(
                    line_item__part_id=self.instance.id
                )
                if qs.exists():
                    form.add_error(
                        "vendor_pn",
                        "Vendor Part # cannot be deleted as it is "
                        "referenced by one or more orders.",
                    )
                    raise ValidationError("Vendor Part # in use")


class Vendor_Part_Edit_Form(ModelForm):
    class Meta:
        model = Vendor_Part
        fields = ["vendor", "vendor_pn", "price", "status"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["vendor_pn"].widget.attrs["placeholder"] = (
            "Vendor's Part #?"
        )
        self.fields["price"].widget.attrs["placeholder"] = "Price?"


@permission_required(perms.VIEW)
def part_list(request):
    return Part_List_View.as_view()(request)


@permission_required(perms.VIEW)
def part_detail(request, pk):
    return Part_Detail_View.as_view()(request, pk=pk)


@permission_required(perms.VIEW)
def part_bom_detail(request, pk):
    # Part detail already includes BOM:
    return Part_Detail_View.as_view()(request, pk=pk)


class Assembly_Item_Edit_Formset(BaseInlineFormSet):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_inline_form_helper()

    def add_fields(self, form, index):
        super().add_fields(form, index)
        ndict = OrderedDict()
        # first, copy all hidden fields:
        for f in form.fields:
            if form.fields[f].widget.is_hidden:
                ndict[f] = form.fields[f]
        # second, rearrange the order of the visible fields and add
        # virtual (calculated) fields:
        if "qty" in form.fields:
            ndict["qty"] = form.fields["qty"]
            ndict["qty"].widget.attrs["size"] = 6
            ndict["qty"].widget.attrs["placeholder"] = "Quantity?"
            ndict["qty"].help_text = ""
        ndict["comp"] = PartChoiceField(
            queryset=Part.objects.all(), widget=Part_Autocomplete.widget()
        )
        ndict["refdes"] = form.fields["refdes"]
        ndict["refdes"].widget.attrs["rows"] = 2
        ndict["refdes"].help_text = ""
        ndict[DELETION_FIELD_NAME] = form.fields[DELETION_FIELD_NAME]

        form.fields = ndict

    def initialize_virtual_fields(self):
        # setup initial data for calculated (virtual) fields:
        for form in self:
            init = form.initial
            if "refdes" in init:
                refdes_list = re.findall(r"([^, ]+)", init["refdes"])
                init["refdes"] = " ".join(refdes_list)

    def clean(self):
        super().clean()

        for form in self.forms:
            if "comp" in form.cleaned_data:
                comp = form.cleaned_data["comp"]
                if comp.id == self.instance.id:
                    form.add_error(
                        "comp",
                        "BOM may not contain the assembly-part itself.",
                    )


@transaction.atomic
@permission_required(perms.EDIT)
def part_bom_edit(request, pk):
    part = get_object_or_404(Part, pk=pk)
    my_url = reverse("epic:part_bom_edit", kwargs={"pk": pk})
    prev_url = reverse("epic:part_detail", kwargs={"pk": pk})

    BOM_Formset = inlineformset_factory(
        Part,
        Assembly_Item,
        formset=Assembly_Item_Edit_Formset,
        fk_name="assy",
        fields=["comp", "qty", "refdes"],
        extra=NUM_EXTRA_ROWS,
    )
    if request.method == "POST":
        bom_form = BOM_Formset(request.POST, instance=part)
        if bom_form.is_valid():
            part.last_bom_mod_type = Enum.LAST_MOD_TYPE_USER
            part.last_bom_mod_name = request.user.get_username()
            part.save()

            bom_form.save()
            if "save-and-done" in request.POST:
                return HttpResponseRedirect(prev_url)
            return HttpResponseRedirect(my_url)
    else:
        bom_form = BOM_Formset(instance=part)

    bom_form.initialize_virtual_fields()
    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/bom_edit.html",
        {
            "pk": pk,
            "bom_form": bom_form,
            "breadcrumb": crumb,
            "cancel_url": prev_url,
        },
    )


class PartChoiceField(forms.ModelChoiceField):
    def label_from_instance(self, obj):
        return obj.choice_label()


class Part_Selection_Form(forms.Form):
    other_part = PartChoiceField(
        queryset=Part.objects.all(), widget=Assembly_Autocomplete.widget()
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_form_helper()


@permission_required(perms.VIEW)
def part_bom_compare(request, pk):
    part = get_object_or_404(Part, pk=pk)
    if request.method == "POST":
        form = Part_Selection_Form(request.POST)
        if form.is_valid():
            other_part = form.cleaned_data["other_part"]
            if other_part == part:
                form.add_error("other_part", "Please select a distinct part.")
            else:
                qs = Assembly_Item.objects.filter(
                    assy_id=other_part.id
                ).distinct()
                if not qs:
                    form.add_error("other_part", "Part is not an assembly.")
                else:
                    if "cmp-qty" in request.POST:
                        return redirect(
                            "epic:part_bom_compare_qty",
                            pk=pk,
                            other_pk=other_part.id,
                        )
                    return redirect(
                        "epic:part_bom_compare_refdes",
                        pk=pk,
                        other_pk=other_part.id,
                    )
    else:
        form = Part_Selection_Form()

    my_url = reverse("epic:part_bom_compare", kwargs={"pk": pk})
    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/bom_compare.html",
        {"form": form, "part": part, "breadcrumb": crumb},
    )


def read_bom(assembly):
    qty = {}
    refdes = {}
    for item in assembly.assembly_items():
        part = item.comp.best_part()
        qty[part] = item.qty
        refdes[part] = set(item.refdes.split(","))
    return qty, refdes


def format_set(the_set, css_class):
    """Sort the members in the set in ascending order and apply CSS class
    CSS_CLASS to each of them.  Returns a list of HTML strings, one
    per member.

    """
    the_list = sorted(the_set)
    return [
        '<span class="%s">%s</span>' % (css_class, html.escape(member))
        for member in the_list
    ]


@permission_required(perms.VIEW)
def part_bom_compare_qty(request, pk, other_pk):
    bom_part = get_object_or_404(Part, pk=pk)
    other_bom_part = get_object_or_404(Part, pk=other_pk)
    my_url = reverse(
        "epic:part_bom_compare_qty", kwargs={"pk": pk, "other_pk": other_pk}
    )

    bom_qty, bom_refdes = read_bom(bom_part)
    other_bom_qty, other_bom_refdes = read_bom(other_bom_part)

    added = []
    removed = []
    changed_qty = []
    for part, qty in bom_qty.items():
        if part not in other_bom_qty:
            refdes_add = ", ".join(
                format_set(bom_refdes[part], "text-success")
            )
            added.append([part, 0, qty, qty, refdes_add])
        elif qty != other_bom_qty[part]:
            refdes_del = format_set(
                other_bom_refdes[part] - bom_refdes[part], "text-danger"
            )
            refdes_add = format_set(
                bom_refdes[part] - other_bom_refdes[part], "text-success"
            )
            refdes_chg = ", ".join(refdes_del + refdes_add)
            changed_qty.append(
                [
                    part,
                    other_bom_qty[part],
                    qty,
                    qty - other_bom_qty[part],
                    refdes_chg,
                ]
            )
    for part, qty in other_bom_qty.items():
        if part not in bom_qty:
            refdes_del = ", ".join(
                format_set(other_bom_refdes[part], "text-danger")
            )
            removed.append([part, qty, 0, -qty, refdes_del])

    num_changes = len(added) + len(removed) + len(changed_qty)

    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/bom_compare_qty.html",
        {
            "pk": pk,
            "bom_part": bom_part,
            "other_bom_part": other_bom_part,
            "num_changes": num_changes,
            "added": added,
            "removed": removed,
            "changed_qty": changed_qty,
            "breadcrumb": crumb,
        },
    )


def read_refdes_map(assembly):
    refdes_map = {}
    for item in assembly.assembly_items():
        part = item.comp.best_part()
        for refdes in item.refdes.split(","):
            refdes_map[refdes] = part
    return refdes_map


@permission_required(perms.VIEW)
def part_bom_compare_refdes(request, pk, other_pk):
    bom_part = get_object_or_404(Part, pk=pk)
    other_bom_part = get_object_or_404(Part, pk=other_pk)
    my_url = reverse(
        "epic:part_bom_compare_refdes", kwargs={"pk": pk, "other_pk": other_pk}
    )

    newer = read_refdes_map(bom_part)
    older = read_refdes_map(other_bom_part)

    added = []
    removed = []
    changed = []
    for refdes in newer:
        if refdes not in older:
            added.append([refdes, None, newer[refdes]])
        elif newer[refdes] != older[refdes]:
            changed.append([refdes, older[refdes], newer[refdes]])
    for refdes in older:
        if refdes not in newer:
            removed.append([refdes, older[refdes], None])

    num_changes = len(added) + len(removed) + len(changed)

    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/bom_compare_refdes.html",
        {
            "pk": pk,
            "bom_part": bom_part,
            "other_bom_part": other_bom_part,
            "num_changes": num_changes,
            "added": added,
            "removed": removed,
            "changed": changed,
            "breadcrumb": crumb,
        },
    )


@transaction.atomic
@permission_required(perms.EDIT)
def part_edit(request, pk):
    def prev_url(pk):
        if pk is None:
            return reverse("epic:part_list")
        return reverse("epic:part_detail", kwargs={"pk": pk})

    if pk is None:
        part = None
        my_url = reverse("epic:part_add")
    else:
        part = get_object_or_404(Part, pk=pk)
        my_url = reverse("epic:part_edit", kwargs={"pk": pk})

    Vendor_Part_Formset = inlineformset_factory(
        Part,
        Vendor_Part,
        form=Vendor_Part_Edit_Form,
        formset=Vendor_Part_Edit_Formset,
        extra=NUM_EXTRA_ROWS,
    )

    if request.method == "POST":
        part_form = Part_Edit_Form(request.POST, instance=part)
        vendor_form = Vendor_Part_Formset(request.POST, instance=part)
        if part_form.is_valid() and vendor_form.is_valid():
            new_part = part_form.save()
            pk = new_part.pk

            new_part = get_object_or_404(Part, pk=pk)
            new_vendor_form = Vendor_Part_Formset(
                request.POST, instance=new_part
            )
            new_vendor_form.is_valid()  # re-create cleaned data for .save()
            new_vendor_form.save()
            return HttpResponseRedirect(prev_url(pk))
    else:
        part_form = Part_Edit_Form(instance=part)
        vendor_form = Vendor_Part_Formset(instance=part)

    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/part_edit.html",
        {
            "pk": pk,
            "part_form": part_form,
            "breadcrumb": crumb,
            "vendor_form": vendor_form,
            "cancel_url": prev_url(pk),
        },
    )


@permission_required(perms.EDIT)
def part_add(request):
    return part_edit(request, None)


def here_are_all_or_some(
    item_name_prefix, suffix_singular, suffix_plural, items, max_items=5
):
    msg = "Here "
    msg += "is" if len(items) == 1 else "are"
    msg += "" if len(items) <= max_items else " some of"
    msg += (
        " the "
        + item_name_prefix
        + (suffix_singular if len(items) == 1 else suffix_plural)
        + ": "
    )
    msg += ", ".join(items[:max_items])
    if len(items) > max_items:
        msg += ", &hellip;"
    else:
        msg += "."
    return msg


def get_parent_url(url):
    m = re.match(r"(.*/)[^/]+/?$", url)
    if not m:
        raise Http404
    return m.group(1)


@permission_required(perms.EDIT)
def part_delete(request, pk):
    part = get_object_or_404(Part, pk=pk)
    my_url = reverse("epic:part_delete", kwargs={"pk": pk})
    parent_url = get_parent_url(my_url)

    # Check if the part is being referenced:
    # 	1) In any Delta
    # 	2) As a component in an assembly
    # 	3) In any Line_Item
    # If so, refuse to delete, as that could throw everything off balance.
    # Note that it is OK if the Part is an Assembly_Item itself
    # or if a Vendor_Part refers to it (as long as the above conditions are
    # satisfied).  Those will be deleted automatically and there are no
    # negative effects in doing so.
    txtns = Transaction.objects.filter(delta__part_id=part.id)
    txtns |= Transaction.objects.filter(line_item__part_id=part.id)
    txtns = txtns.distinct().order_by("-id")
    assys = Part.objects.filter(assembly_item_part__comp_id=part.id).distinct()
    subs = set(part.equivalent_parts())
    subs.remove(part)
    if txtns.exists() or assys.exists() or subs:
        messages = [
            "Sorry, part %s cannot be deleted because " % part.html_link()
        ]
        sep = ""
        if txtns.exists():
            messages[0] += "%d transaction%s refer%s to it" % (
                len(txtns),
                "" if len(txtns) == 1 else "s",
                "s" if len(txtns) == 1 else "",
            )
            messages.append(
                here_are_all_or_some(
                    "transaction",
                    "",
                    "s",
                    [txtn.html_link() for txtn in txtns],
                )
            )
            sep = " and "
        if assys.exists():
            messages[0] += "%s%d assembl%s use%s it" % (
                sep,
                len(assys),
                "y" if len(assys) == 1 else "ies",
                "s" if len(assys) == 1 else "",
            )
            messages.append(
                here_are_all_or_some(
                    "assembl", "y", "ies", [assy.html_link() for assy in assys]
                )
            )
            sep = " and "
        if subs:
            messages[0] += "%s%d substitute%s exist" % (
                sep,
                len(subs),
                "" if len(subs) == 1 else "s",
            )
            messages.append(
                here_are_all_or_some(
                    "substitute", "", "s", [sub.html_link() for sub in subs]
                )
            )
        messages[0] += "."

        return render(
            request,
            "epic/delete_error.html",
            {
                "breadcrumb": breadcrumb(my_url),
                "parent_url": parent_url,
                "messages": messages,
            },
        )
    part.delete()
    return HttpResponseRedirect(get_parent_url(parent_url))


class Part_List_View(generic.ListView):
    model = Part
    paginate_by = 100  # this makes the the paging match the 3rd digit of PN

    def get_queryset(self):
        return super().get_queryset().order_by("id")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        context["breadcrumb"] = breadcrumb(reverse("epic:part_list"))
        context["list_pager"] = html_list_pager(
            context["page_obj"], page_name=lambda pg: pg - 1
        )
        return context


class Part_Detail_View(generic.DetailView):
    model = Part
    fields = [
        "mfg",
        "mfg_pn",
        "val",
        "descr",
        "datasheet",
        "footprint",
        "mounting",
        "target_price",
        "overage",
        "spq",
        "lead_time",
        "status",
        "notes",
    ]

    def get_history_filter(self):
        if "ht" in self.request.GET:
            return self.request.GET["ht"]
        return "truncated"

    def get_context_data(self, **kwargs):
        def map_fields(name, value, verbose_name):
            if name != "mfg_pn":
                return verbose_name, value
            args = {"mfg": self.object.mfg, "mfg_pn": self.object.mfg_pn}
            ds_link = ""
            if self.object.datasheet:
                ds_link = (
                    '&ensp;<a href="%s" target="datasheet">'
                    '<i class="glyphicon glyphicon-book"></i></a>'
                    % self.object.datasheet.ds_file.url
                )
            else:
                ds_link = (
                    '&ensp;<a href="%s" target="datasheet_add">'
                    '<i class="glyphicon glyphicon-plus"></i></a>'
                    % reverse(
                        "epic:datasheet_add_part",
                        kwargs={"pk": self.object.id},
                    )
                )

            url = "https://octopart.com/search?q={mfg}%20{mfg_pn}".format(
                **args
            )
            return verbose_name, (
                '%s%s&ensp;<a href="%s" target="part_lookup">'
                '<img src="%s" alt='
                "></a>"
                % (
                    value,
                    ds_link,
                    url,
                    os.path.join(cfg.STATIC_URL, "epic/img/octopart.ico"),
                )
            )

        assemblies = get_assemblies_using_part(self.object)
        part_users = []
        obsolete_part_users = []
        for assy in assemblies:
            name = get_assembly_name(assy)
            url = reverse("epic:part_detail", kwargs={"pk": assy.id})
            html = '<a href="%s">%s</a>' % (url, name)
            if assy.status == Enum.STATUS_OBSOLETE:
                obsolete_part_users.append(html)
            else:
                part_users.append(html)

        fields = get_model_fields(self.object, self.fields, map_fields)

        subs = set(self.object.equivalent_parts())
        subs.remove(self.object)
        subs = sorted(subs, key=lambda x: x.id)
        subs = html_list_or_none(html_part_link(s) for s in subs)

        fields.append(["Substitutes", subs])

        vendor_parts = Vendor_Part.objects.filter(part_id=self.object.id)

        bom = []
        total_target_cost = 0
        for item in self.object.assembly_items():
            best_part = item.comp.best_part()
            refdes_list = sorted(item.refdes.split(","))
            total_target_cost += item.qty * best_part.target_price
            avg_cost = best_part.avg_cost()
            bom.append(
                {
                    "best_part": best_part,
                    "assy_item": item,
                    "refdes_list": refdes_list,
                    "avg_cost": avg_cost,
                    "amount": item.qty * avg_cost,
                    "cumulative_cost": 0,
                }
            )
        bom.sort(key=lambda x: x["amount"], reverse=True)
        cumulative_cost = 0
        for row in bom:
            cumulative_cost += row["amount"]
            row["cumulative_cost"] = cumulative_cost
        cost_summary = {"part": {}, "cmp": {}, "tot": {"tgt": 0, "avg": 0}}
        cost_summary["part"]["tgt"] = self.object.target_price
        cost_summary["part"]["avg"] = self.object.avg_cost()
        cost_summary["cmp"]["avg"] = cumulative_cost
        cost_summary["cmp"]["tgt"] = total_target_cost
        for kind in ["tgt", "avg"]:
            cost_summary["tot"][kind] += (
                cost_summary["part"][kind] + cost_summary["cmp"][kind]
            )

        history = get_part_history(self.object)

        part_list = [p.id for p in self.object.equivalent_parts()]
        open_order_items = get_open_order_line_items().filter(
            part_id__in=part_list
        )

        pk = self.object.id
        ht = self.get_history_filter()

        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = breadcrumb(
            reverse("epic:part_detail", kwargs={"pk": pk})
        )
        context["fields"] = fields
        context["vendor_parts"] = vendor_parts
        context["part_users"] = html_list_or_none(part_users)
        context["obsolete_part_users"] = html_list_or_none(obsolete_part_users)
        context["bom"] = bom
        context["cost_summary"] = cost_summary
        context["page_nav"] = html_page_nav("epic:part_detail", Part, pk)
        context["history"] = part_history_summary(
            self.object, history, ht == "full"
        )
        context["ht"] = ht
        context["open_order_items"] = open_order_items
        return context


class Warehouse_Edit_Form(ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_form_helper()

    class Meta:
        model = Warehouse
        fields = ["name", "address"]


class Warehouse_List_View(generic.ListView):
    model = Warehouse
    paginate_by = 50

    def get_queryset(self):
        return super().get_queryset().order_by("name")

    def get_context_data(self, **kwargs):
        crumb = breadcrumb(reverse("epic:warehouse_list"))
        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = crumb
        context["list_pager"] = html_list_pager(context["page_obj"])
        return context


class Warehouse_Detail_View(generic.DetailView):
    model = Warehouse
    fields = ["name", "address"]

    def get_context_data(self, **kwargs):
        pk = self.object.id

        inventories = self.object.inventories().order_by("-ts")
        paginator = Paginator(inventories, 50)
        page = self.request.GET.get("ipg")
        try:
            inventories = paginator.page(page)
        except PageNotAnInteger:
            inventories = paginator.page(1)
        except EmptyPage:
            inventories = paginator.page(paginator.num_pages)

        txtns = Transaction.objects.filter(warehouse_id=pk)
        txtns |= Transaction.objects.filter(shipment__from_warehouse_id=pk)
        txtns = txtns.distinct().order_by("-ts")
        paginator = Paginator(txtns, 50)
        page = self.request.GET.get("tpg")
        try:
            txtns = paginator.page(page)
        except PageNotAnInteger:
            txtns = paginator.page(1)
        except EmptyPage:
            txtns = paginator.page(paginator.num_pages)

        context = super().get_context_data(**kwargs)
        # we don't want to depend on having
        # django.core.context_processors.request enabled:
        context["request"] = self.request
        context["fields"] = get_model_fields(self.object, self.fields)
        context["breadcrumb"] = breadcrumb(
            reverse("epic:warehouse_detail", kwargs={"pk": pk})
        )
        context["inventories"] = inventories
        context["inventories_pager"] = html_list_pager(inventories, key="ipg")
        context["txtns"] = txtns
        context["txtns_pager"] = html_list_pager(txtns, key="tpg")
        return context


class Vendor_List_View(generic.ListView):
    model = Vendor
    paginate_by = 50

    def get_queryset(self):
        return super().get_queryset().order_by("name")

    def get_context_data(self, **kwargs):
        crumb = breadcrumb(reverse("epic:vendor_list"))
        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = crumb
        context["list_pager"] = html_list_pager(context["page_obj"])
        return context


class Vendor_Detail_View(generic.DetailView):
    model = Vendor
    fields = ["name", "search_url"]

    def get_context_data(self, **kwargs):
        pk = self.object.id

        vps = Vendor_Part.objects.filter(vendor_id=pk).order_by("id")
        paginator = Paginator(vps, 50)
        page = self.request.GET.get("vpg")
        try:
            vps = paginator.page(page)
        except PageNotAnInteger:
            vps = paginator.page(1)
        except EmptyPage:
            vps = paginator.page(paginator.num_pages)

        txtns = Transaction.objects.filter(order__vendor__id=pk)
        txtns |= Transaction.objects.filter(shipment__ordr__vendor_id=pk)
        txtns = txtns.distinct().order_by("-ts")
        paginator = Paginator(txtns, 50)
        page = self.request.GET.get("tpg")
        try:
            txtns = paginator.page(page)
        except PageNotAnInteger:
            txtns = paginator.page(1)
        except EmptyPage:
            txtns = paginator.page(paginator.num_pages)

        context = super().get_context_data(**kwargs)
        # we don't want to depend on having
        # django.core.context_processors.request enabled:
        context["request"] = self.request
        context["vps"] = vps
        context["vps_pager"] = html_list_pager(vps, key="vpg")
        context["txtns"] = txtns
        context["txtns_pager"] = html_list_pager(txtns, key="tpg")
        context["breadcrumb"] = breadcrumb(
            reverse("epic:vendor_detail", kwargs={"pk": pk})
        )
        context["fields"] = get_model_fields(self.object, self.fields)
        return context


class Vendor_Edit_Form(ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_form_helper()

    class Meta:
        model = Vendor
        fields = ["name", "search_url"]


@permission_required(perms.VIEW)
def vendor_list(request):
    return Vendor_List_View.as_view()(request)


@permission_required(perms.VIEW)
def vendor_detail(request, pk):
    return Vendor_Detail_View.as_view()(request, pk=pk)


@transaction.atomic
@permission_required(perms.EDIT)
def vendor_edit(request, pk):
    def prev_url(pk):
        if pk is None:
            return reverse("epic:vendor_list")
        return reverse("epic:vendor_detail", kwargs={"pk": pk})

    if pk is None:
        vendor = None
        my_url = reverse("epic:vendor_add")
    else:
        vendor = get_object_or_404(Vendor, pk=pk)
        my_url = reverse("epic:vendor_edit", kwargs={"pk": pk})

    if request.method == "POST":
        form = Vendor_Edit_Form(request.POST, instance=vendor)
        if form.is_valid():
            new_form = form.save()
            return HttpResponseRedirect(prev_url(new_form.pk))
    else:
        form = Vendor_Edit_Form(instance=vendor)

    data_lists = ""
    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/vendor_edit.html",
        {
            "form": form,
            "pk": pk,
            "breadcrumb": crumb,
            "data_lists": data_lists,
            "cancel_url": prev_url(pk),
        },
    )


@permission_required(perms.EDIT)
def vendor_add(request):
    return vendor_edit(request, None)


@permission_required(perms.EDIT)
def vendor_delete(request, pk):
    vendor = get_object_or_404(Vendor, pk=pk)
    my_url = reverse("epic:vendor_delete", kwargs={"pk": pk})
    parent_url = get_parent_url(my_url)

    # Check if the vendor is being referenced:
    # 	1) In any Order
    # 	2) In any Vendor_Part
    # If so, refuse to delete, as that could throw everything off balance.
    orders = Order.objects.filter(vendor_id=vendor.id).order_by("-id")
    vps = Vendor_Part.objects.filter(vendor_id=vendor.id).order_by("id")
    if orders.exists() or vps.exists():
        messages = [
            "Sorry, vendor %s cannot be deleted because " % vendor.html_link()
        ]
        sep = ""
        if orders.exists():
            messages[0] += "%d order%s refer%s to it" % (
                len(orders),
                "" if len(orders) == 1 else "s",
                "s" if len(orders) == 1 else "",
            )
            messages.append(
                here_are_all_or_some(
                    "order", "", "s", [order.html_link() for order in orders]
                )
            )
            sep = " and "
        if vps.exists():
            messages[0] += "%s%d vendor part%s refer%s to it" % (
                sep,
                len(vps),
                "" if len(vps) == 1 else "s",
                "s" if len(vps) == 1 else "",
            )
            messages.append(
                here_are_all_or_some(
                    "part", "", "s", [vp.part.html_link() for vp in vps]
                )
            )
        messages[0] += "."
        return render(
            request,
            "epic/delete_error.html",
            {
                "breadcrumb": breadcrumb(my_url),
                "parent_url": parent_url,
                "messages": messages,
            },
        )
    vendor.delete()
    return HttpResponseRedirect(get_parent_url(parent_url))


@permission_required(perms.VIEW)
def warehouse_stock(request, pk=None):
    context = {}

    title = "Current Stock "
    if pk is None:
        warehouse = None
        title += "for all warehouses together"
        my_url = reverse("epic:warehouse_stock_all")
    else:
        warehouse = get_object_or_404(Warehouse, pk=pk)
        title += "at warehouse %s" % warehouse.name
        my_url = reverse("epic:warehouse_stock", kwargs={"pk": pk})

    if request.method == "POST":
        return HttpResponseRedirect(
            my_url + "?as_of_date=%s" % request.POST.get("as_of_date")
        )
    if "as_of_date" in request.GET:
        as_of_date = datetime.strptime(
            request.GET["as_of_date"], "%Y-%m-%d"
        ).date()
    else:
        as_of_date = date.today()

    title += " as of %s" % defaultfilters.date(as_of_date, "Y-m-d")

    stock, stock_total = get_stock_summary(warehouse, as_of_date=as_of_date)

    context["pk"] = pk
    context["breadcrumb"] = breadcrumb(my_url)
    context["title"] = title
    context["stock"] = stock
    context["stock_total"] = stock_total
    context["as_of_date"] = as_of_date
    return render(request, "epic/warehouse_stock.html", context)


@permission_required(perms.VIEW)
def warehouse_stock_all(
    request,
):
    return warehouse_stock(request, pk=None)


@permission_required(perms.VIEW)
def warehouse_list(request):
    return Warehouse_List_View.as_view()(request)


@permission_required(perms.VIEW)
def warehouse_detail(request, pk):
    return Warehouse_Detail_View.as_view()(request, pk=pk)


@permission_required(perms.VIEW)
def warehouse_inventory(request, pk):
    # dummy to make breadcrumbs work...
    return warehouse_detail(request, pk)


@transaction.atomic
@permission_required(perms.EDIT)
def warehouse_edit(request, pk):
    def prev_url(pk):
        if pk is None:
            return reverse("epic:warehouse_list")
        return reverse("epic:warehouse_detail", kwargs={"pk": pk})

    if pk is None:
        warehouse = None
        my_url = reverse("epic:warehouse_add")
    else:
        warehouse = get_object_or_404(Warehouse, pk=pk)
        my_url = reverse("epic:warehouse_edit", kwargs={"pk": pk})

    if request.method == "POST":
        form = Warehouse_Edit_Form(request.POST, instance=warehouse)
        if form.is_valid():
            new_form = form.save()
            return HttpResponseRedirect(prev_url(new_form.pk))
    else:
        form = Warehouse_Edit_Form(instance=warehouse)

    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/warehouse_edit.html",
        {
            "form": form,
            "pk": pk,
            "breadcrumb": crumb,
            "cancel_url": prev_url(pk),
        },
    )


@permission_required(perms.EDIT)
def warehouse_add(request):
    return warehouse_edit(request, None)


@permission_required(perms.EDIT)
def warehouse_delete(request, pk):
    warehouse = get_object_or_404(Warehouse, pk=pk)
    my_url = reverse("epic:warehouse_delete", kwargs={"pk": pk})
    parent_url = get_parent_url(my_url)

    # Check if the warehouse is being referenced:
    # 	1) In any Delta
    # 	2) In any Transaction
    # If so, refuse to delete, as that could throw everything off balance.
    # Note: we don't check explicitly if there are any shipments where
    # Shipment.from_warehouse is set: if the shipment has no deltas, it's
    # ok to delete it (which will happen automatically) and if it does,
    # then it'd show up as a delta and line-item.
    txtns = Transaction.objects.filter(delta__warehouse_id=warehouse.id)
    txtns |= Transaction.objects.filter(warehouse_id=warehouse.id)
    txtns = txtns.distinct().order_by("-id")
    if txtns.exists():
        messages = [
            "Sorry, warehouse %s cannot be deleted because "
            % warehouse.html_link()
        ]
        if txtns.exists():
            messages[0] += "%d transaction%s refer%s to it" % (
                len(txtns),
                "" if len(txtns) == 1 else "s",
                "s" if len(txtns) == 1 else "",
            )
            messages.append(
                here_are_all_or_some(
                    "transaction",
                    "",
                    "s",
                    [txtn.html_link() for txtn in txtns],
                )
            )
        messages[0] += "."
        return render(
            request,
            "epic/delete_error.html",
            {
                "breadcrumb": breadcrumb(my_url),
                "parent_url": parent_url,
                "messages": messages,
            },
        )
    warehouse.delete()
    return HttpResponseRedirect(get_parent_url(parent_url))


@permission_required(perms.EDIT)
def warehouse_add_shipment(request, pk):
    return ship_edit(request, None, from_warehouse_id=pk)


@permission_required(perms.EDIT)
def warehouse_add_inventory(request, pk):
    return inventory_edit(request, None, warehouse=pk)


class Warehouse_Inv_Detail_View(generic.DetailView):
    model = Inventory
    template_name = "epic/inventory_detail.html"

    def get_context_data(self, **kwargs):
        pk = self.object.id
        warehouse_id = int(self.kwargs["warehouse"])

        if warehouse_id != self.object.warehouse_id:
            raise Http404

        (
            inventory_items,
            total_value_change,
            has_relative_deltas,
        ) = get_inventory_summary(warehouse_id, self.object)

        context = super().get_context_data(**kwargs)
        context["pk"] = pk
        context["breadcrumb"] = breadcrumb(
            reverse(
                "epic:warehouse_inventory_detail",
                kwargs={"warehouse": warehouse_id, "pk": pk},
            )
        )
        context["warehouse"] = warehouse_id
        context["inventory_items"] = inventory_items
        context["total_value_change"] = total_value_change
        context["has_relative_deltas"] = has_relative_deltas
        return context


@permission_required(perms.VIEW)
def warehouse_inv_detail(request, warehouse, pk):
    return Warehouse_Inv_Detail_View.as_view()(
        request, warehouse=warehouse, pk=pk
    )


class Inventory_Edit_Form(ModelForm):
    spreadsheet = forms.FileField(required=False)

    def __init__(self, *args, **kwargs):
        self.request = kwargs.pop("request", None)
        super().__init__(*args, **kwargs)
        self.helper = crispy_form_helper()
        self.has_assemblies = False
        self.fields["ts"].label = "Inventory Date"
        self.fields["ts"].input_formats = ["%Y-%m-%d %I:%M %p"]
        self.fields["spreadsheet"].label = "Upload Spreadsheet"

    def clean_spreadsheet(self):
        if "spreadsheet" not in self.request.FILES:
            return None
        cleaned = epic.imports.clean_spreadsheet(
            self.request.FILES["spreadsheet"],
            epic.imports.DELTA_COLUMNS,
            lambda e: self.add_error("spreadsheet", e),
        )
        return cleaned

    class Meta:
        model = Inventory
        fields = ["ts", "notes"]
        widgets = {
            "ts": DateTimePicker(options={"format": "YYYY-MM-DD hh:mm a"}),
            "part": Part_Autocomplete.widget(),
        }


def save_form_with_transaction(
    request, formset_create, txtn_form, warehouse_id=None
):
    """Handle the common steps of saving the line-items and its
    transaction.  txtn_form must have been validated before calling
    this function.

    """
    txtn = txtn_form.save(commit=False)
    if txtn.ts is None:
        txtn.ts = timezone.now()
    if txtn.warehouse_id is None:
        txtn.warehouse_id = warehouse_id
    txtn.save()

    new_item_form = formset_create(request.POST, instance=txtn)
    new_item_form.is_valid()  # re-create cleaned data for .save()

    new_line_items = new_item_form.save(commit=False)

    for item in new_item_form.deleted_objects:
        # With Django 1.7, formset.save (..., commit=False) does
        # not delete automatically.  We need to delete before saving
        # because otherwise we might end up with two entries of the
        # same part...
        item.delete()

    next_index = txtn.first_available_index()
    for item in new_line_items:
        if hasattr(item, "index"):
            if item.index is None:
                item.index = next_index
                next_index += 1
        if hasattr(item, "warehouse_id"):
            # initialize Delta fields that are not editable:
            item.warehouse_id = warehouse_id
            item.is_absolute = True
        item.save()
    txtn.finalize()
    return txtn


@transaction.atomic
@permission_required(perms.EDIT)
def inventory_edit(request, pk, warehouse):
    def prev_url(pk):
        if pk is None:
            return reverse("epic:warehouse_list")
        return reverse(
            "epic:warehouse_inventory_detail",
            kwargs={"warehouse": warehouse, "pk": pk},
        )

    warehouse_id = int(warehouse)
    if pk is None:
        inventory = None
        my_url = reverse(
            "epic:warehouse_add_inventory", kwargs={"pk": warehouse}
        )
    else:
        inventory = get_object_or_404(Inventory, pk=pk)
        if warehouse_id != inventory.warehouse_id:
            raise Http404
        for d in Delta.objects.filter(txtn_id=pk):
            if not d.is_absolute:
                raise Http404
        my_url = reverse(
            "epic:warehouse_inventory_edit",
            kwargs={"warehouse": warehouse, "pk": pk},
        )

    Delta_Formset = inlineformset_factory(
        Transaction,
        Delta,
        form=Line_Item_Edit_Form,
        formset=Line_Item_Edit_Formset,
        fields=["part", "adj"],
        extra=NUM_EXTRA_ROWS,
    )
    if request.method == "POST":
        inv_form = Inventory_Edit_Form(
            request.POST, instance=inventory, request=request
        )
        item_form = Delta_Formset(request.POST, instance=inventory)

        if inv_form.is_valid():
            spreadsheet = inv_form.cleaned_data["spreadsheet"]
            if spreadsheet:
                txtn = save_delta_spreadsheet(
                    inv_form, spreadsheet, warehouse_id
                )
            elif item_form.is_valid():
                txtn = save_form_with_transaction(
                    request, Delta_Formset, inv_form, warehouse_id
                )
            else:
                txtn = None

            if txtn is not None:
                pk = txtn.pk

                if "save-and-zero" in request.POST:
                    qs = Delta.objects.filter(txtn_id=pk)
                    stock = get_stock(
                        warehouses=[warehouse_id], as_of_ts=txtn.ts
                    )
                    for p in stock.warehouse[warehouse_id]:
                        if stock.warehouse[warehouse_id][p].qty == 0:
                            continue
                        if qs.filter(part_id=p).exists():
                            continue  # already in inventory
                        d = Delta(
                            part_id=p,
                            is_absolute=True,
                            adj=0,
                            txtn=txtn,
                            warehouse_id=warehouse_id,
                        )
                        d.save()

                if "save-and-done" in request.POST:
                    return HttpResponseRedirect(prev_url(pk))
                return HttpResponseRedirect(
                    reverse(
                        "epic:warehouse_inventory_edit",
                        kwargs={"warehouse": warehouse, "pk": pk},
                    )
                )
    else:
        inv_form = Inventory_Edit_Form(instance=inventory)
        if inventory is None:
            inv_form.fields["ts"].initial = timezone.now()
        item_form = Delta_Formset(instance=inventory)

    item_form.initialize_virtual_fields(None)

    return render(
        request,
        "epic/inventory_edit.html",
        {
            "pk": pk,
            "breadcrumb": breadcrumb(my_url),
            "warehouse": warehouse,
            "inv_form": inv_form,
            "item_form": item_form,
            "cancel_url": prev_url(pk),
        },
    )


@permission_required(perms.EDIT)
def inventory_delete(request, pk, warehouse):
    # pylint: disable=unused-argument
    inv = get_object_or_404(Inventory, pk=pk)
    if inv.warehouse_id != int(warehouse):
        raise Http404
    my_url = reverse(
        "epic:warehouse_inventory_delete",
        kwargs={"warehouse": warehouse, "pk": pk},
    )
    parent_url = get_parent_url(my_url)

    # Inventories are easy: nothing else refers to them so we can always delete
    # them.
    inv.delete()
    return HttpResponseRedirect(get_parent_url(get_parent_url(parent_url)))


class Order_List_View(generic.ListView):
    model = Order

    def get_status_filter(self):
        if self.request.method == "GET" and ("st" in self.request.GET):
            return self.request.GET["st"]
        return "open"

    def get_queryset(self):
        qs = Order.objects.order_by("-expected_arrival_date")
        st = self.get_status_filter()
        if st == "open":
            qs = qs.filter(status=Enum.TXTN_STATUS_OPEN)
        elif st == "closed":
            qs = qs.filter(status=Enum.TXTN_STATUS_CLOSED)
        return qs

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = breadcrumb(reverse("epic:order_list"))
        context["st"] = self.get_status_filter()
        return context


class Order_Detail_View(generic.DetailView):
    model = Order
    fields = ["status", "ts", "expected_arrival_date"]

    def get_context_data(self, **kwargs):
        def map_fields(name, value, verbose_name):
            if name == "ts":
                ts = datetime.strptime(value, "%Y-%m-%d %H:%M:%S.%f+00:00")
                return "Order Date", defaultfilters.date(
                    ts.replace(tzinfo=tz.utc), "Y-m-d"
                )
            return verbose_name, value

        order = self.object

        shipments = []
        for s in Shipment.objects.filter(ordr_id=order.id):
            shipments.append(s.html_link())

        order_items = Line_Item.objects.filter(txtn_id=order.id)

        pk = order.id

        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = breadcrumb(
            reverse("epic:order_detail", kwargs={"pk": pk})
        )
        context["fields"] = get_model_fields(order, self.fields, map_fields)
        context["page_nav"] = html_page_nav("epic:order_detail", Order, pk)
        context["order"] = order
        context["order_items"] = order_items
        context["shipments"] = html_list_or_none(shipments)
        return context


class Transaction_Edit_Form(ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_form_helper()

    class Meta:
        model = Transaction
        fields = ["notes"]


class Order_Edit_Form(ModelForm):
    spreadsheet = forms.FileField(required=False)

    def __init__(self, *args, **kwargs):
        self.request = kwargs.pop("request", None)
        super().__init__(*args, **kwargs)
        self.helper = crispy_form_helper()
        self.has_assemblies = False
        self.fields["ts"].label = "Order Date"
        self.fields["warehouse"].label = "Ship-To Warehouse"
        self.fields["spreadsheet"].label = "Upload Spreadsheet"

    def clean_spreadsheet(self):
        if "spreadsheet" not in self.request.FILES:
            return None
        cleaned = epic.imports.clean_spreadsheet(
            self.request.FILES["spreadsheet"],
            epic.imports.LINE_ITEM_COLUMNS,
            lambda e: self.add_error("spreadsheet", e),
        )
        return cleaned

    def clean(self):
        cleaned_data = super().clean()
        if self.has_assemblies:
            vendor = cleaned_data["vendor"]
            try:
                Warehouse.by_name(vendor.name)
            except ObjectDoesNotExist:
                raise forms.ValidationError(
                    {
                        "vendor": [
                            "Order contains assemblies but this "
                            "vendor has no warehouse of the same name.  "
                            "Please change vendor or create a warehouse "
                            "for this vendor."
                        ]
                    }
                )

    class Meta:
        model = Order
        fields = [
            "ts",
            "vendor",
            "warehouse",
            "expected_arrival_date",
            "notes",
        ]
        widgets = {
            "ts": DateTimePicker(options={"format": "YYYY-MM-DD"}),
            "expected_arrival_date": DateTimePicker(
                options={"format": "YYYY-MM-DD"}
            ),
        }


class Line_Item_Edit_Form(ModelForm):
    class Meta:
        model = Line_Item
        fields = "__all__"

    part = PartChoiceField(
        queryset=Part.objects.all(), widget=Part_Autocomplete.widget()
    )


class Line_Item_Edit_Formset(BaseInlineFormSet):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_inline_form_helper()

    def add_fields(self, form, index):
        super().add_fields(form, index)
        ndict = OrderedDict()
        # first, copy all hidden fields:
        for f in form.fields:
            if form.fields[f].widget.is_hidden:
                ndict[f] = form.fields[f]
        # second, rearrange the order of the visible fields and add
        # virtual (calculated) fields:
        if "qty" in form.fields:
            ndict["qty"] = form.fields["qty"]
            ndict["qty"].widget.attrs["size"] = 6
            ndict["qty"].widget.attrs["placeholder"] = "Quantity?"
            ndict["qty"].help_text = ""
        elif "adj" in form.fields:
            ndict["adj"] = form.fields["adj"]
            ndict["adj"].widget.attrs["size"] = 6
            ndict["adj"].widget.attrs["placeholder"] = "Quantity?"
            ndict["adj"].label = "Qty"
            ndict["adj"].help_text = ""
        ndict["part"] = form.fields["part"]
        ndict["part"].widget.attrs["size"] = 9
        ndict["part"].help_text = ""
        if "qty" in form.fields:
            ndict["vendor_pn"] = forms.CharField(label="Vendor's Part #")
            ndict["vendor_pn"].widget.attrs["readonly"] = "True"
            ndict["vendor_pn"].widget.attrs["placeholder"] = (
                "Vendors's Part #?"
            )
            ndict["part_cost"] = forms.CharField(label="Price")
            ndict["part_cost"].widget.attrs["size"] = 8
            ndict["part_cost"].widget.attrs["placeholder"] = "Price?"
        if "line_cost" in form.fields:
            ndict["line_cost"] = form.fields["line_cost"]
            ndict["line_cost"].widget.attrs["size"] = 8
            ndict["line_cost"].widget.attrs["placeholder"] = "Line-item cost?"
            ndict["line_cost"].help_text = ""
        ndict[DELETION_FIELD_NAME] = form.fields[DELETION_FIELD_NAME]

        form.fields = ndict

    def clean(self):
        super().clean()

        parts_listed = {}
        line = 0
        for form in self.forms:
            line += 1
            if (
                "part" not in form.cleaned_data
                or "part_cost" not in form.cleaned_data
                or "vendor_pn" not in form.cleaned_data
            ):
                continue
            part = form.cleaned_data["part"]
            if part is None:
                continue
            if part in parts_listed:
                # same part is listed multiple times
                form.add_error(
                    "part",
                    "Line %d already lists this part." % parts_listed[part],
                )
                continue
            parts_listed[part] = line
            if form.instance.pk is None:
                # When adding a new line-item, make sure its part # doesn't
                # conflict with one that's already existing.  If it does,
                # add error messages that tell the user how to fix the problem.
                # Since this runs
                # after django.db.models._perform_unique_checks,
                # we can't fix the problem automatically.
                qs = form.instance.__class__.objects.filter(
                    txtn_id=self.instance.id
                ).filter(part_id=part.id)
                if qs.exists():
                    existing_pk = qs[0].pk
                    for other_form in self.forms:
                        if other_form == form:
                            continue
                        if other_form.instance.pk == existing_pk:
                            other_form.instance.pk = None
                            other_form.add_error(
                                "part",
                                "Please use this for part %s." % part,
                            )
                            form.add_error(
                                "part",
                                "Please use this for "
                                "part %s." % other_form.instance.part,
                            )
                            break
            part_cost = Decimal(form.cleaned_data["part_cost"])
            vendor_pn = form.cleaned_data["vendor_pn"]
            delete = form.cleaned_data[DELETION_FIELD_NAME]
            txtn = self.instance
            if (
                not delete
                and hasattr(txtn, "order")
                and txtn.order.vendor_id is not None
            ):
                # Update Vendor_Part number if necessary:
                vp = Vendor_Part.get(
                    part_id=part.id, vendor_id=txtn.order.vendor_id
                )
                if vp is None:
                    nvp = Vendor_Part(
                        part_id=part.id,
                        vendor_id=txtn.order.vendor_id,
                        vendor_pn=vendor_pn,
                        price=part_cost,
                    )
                    try:
                        nvp.full_clean()
                        nvp.save()
                    except ValidationError as e:
                        for msg in e.message_dict[NON_FIELD_ERRORS]:
                            form.add_error("vendor_pn", msg)
                elif part_cost != vp.price:
                    vp.price = part_cost
                    vp.save()

                # Update target_price of part if it's higher than part_cost:
                if part_cost < part.target_price:
                    part.target_price = part_cost
                    part.save()

    def initialize_virtual_fields(self, vendor_id):
        # setup initial data for calculated (virtual) fields:
        for form in self:
            init = form.initial
            if "part" in init:
                if vendor_id is not None:
                    vp = Vendor_Part.get(
                        part_id=init["part"], vendor_id=vendor_id
                    )
                    if vp is not None:
                        init["vendor_pn"] = vp.vendor_pn
            if "line_cost" in init and "qty" in init:
                init["part_cost"] = part_cost(init["line_cost"], init["qty"])


def save_delta_spreadsheet(txtn_form, spreadsheet, warehouse_id=None):
    txtn = txtn_form.save(commit=False)
    if txtn.ts is None:
        txtn.ts = timezone.now()
    if txtn.warehouse_id is None:
        txtn.warehouse_id = warehouse_id
    txtn.save()

    # delete old deltas for this transaction (if any):
    Delta.objects.filter(txtn_id=txtn.id).delete()

    for row in spreadsheet:
        qty = row["qty"]
        part = row["part"]
        d = Delta(
            part=part,
            is_absolute=True,
            adj=qty,
            txtn=txtn,
            warehouse_id=warehouse_id,
        )
        d.save()

    txtn.finalize()
    return txtn


def save_line_item_spreadsheet(txtn_form, spreadsheet):
    txtn = txtn_form.save(commit=False)
    if txtn.ts is None:
        txtn.ts = timezone.now()
    txtn.save()

    # delete old line-items for this transaction (if any):
    Line_Item.objects.filter(txtn_id=txtn.id).delete()

    index = 1
    for row in spreadsheet:
        qty = row["qty"]
        part = row["part"]
        amount = row["amount"]
        l = Line_Item(
            part=part, qty=qty, line_cost=amount, index=index, txtn=txtn
        )
        l.save()
        index += 1

    txtn.finalize()
    return txtn


@permission_required(perms.VIEW)
def order_list(request):
    return Order_List_View.as_view()(request)


@permission_required(perms.VIEW)
def order_detail(request, pk):
    return Order_Detail_View.as_view()(request, pk=pk)


@transaction.atomic
@permission_required(perms.EDIT)
def order_edit(request, pk, order_initial=None, items_initial=None):
    def prev_url(pk):
        if pk is None:
            return reverse("epic:order_list")
        return reverse("epic:order_detail", kwargs={"pk": pk})

    if pk is None:
        order = None
        my_url = reverse("epic:order_add")
    else:
        order = get_object_or_404(Order, pk=pk)
        my_url = reverse("epic:order_edit", kwargs={"pk": pk})

    # See http://bit.ly/1wfFNKa:
    if order_initial is None:
        order_initial = {}
    if items_initial is None:
        items_initial = []

    num_extra = len(items_initial) + NUM_EXTRA_ROWS
    Line_Item_Formset = inlineformset_factory(
        Order,
        Line_Item,
        form=Line_Item_Edit_Form,
        formset=Line_Item_Edit_Formset,
        fields=["part", "qty", "line_cost"],
        extra=num_extra,
    )

    if request.method == "POST" and "preset" not in request.POST:
        order_form = Order_Edit_Form(
            request.POST, instance=order, request=request
        )
        item_form = Line_Item_Formset(request.POST, instance=order)

        #
        # We first, do a partial validation of the order items so we
        # can check if any assemblies are being ordered.
        #
        if order_form.is_valid():
            spreadsheet = order_form.cleaned_data["spreadsheet"]
            if spreadsheet:
                for row in spreadsheet:
                    if row["part"].assembly_items().exists():
                        order_form.has_assemblies = True
                        break
                txtn = save_line_item_spreadsheet(order_form, spreadsheet)
            elif item_form.is_valid():
                for form in item_form.forms:
                    if "part" not in form.cleaned_data:
                        continue
                    if form.cleaned_data["part"].assembly_items().exists():
                        order_form.has_assemblies = True
                        break
                txtn = save_form_with_transaction(
                    request, Line_Item_Formset, order_form
                )
            else:
                txtn = None
            if txtn is not None:
                pk = txtn.id
                if "save-and-done" in request.POST:
                    return HttpResponseRedirect(prev_url(pk))
                return HttpResponseRedirect(
                    reverse("epic:order_edit", kwargs={"pk": pk})
                )
    else:
        if order is None and not order_initial:
            order_initial["ts"] = datetime.today()
        order_form = Order_Edit_Form(
            instance=order, initial=order_initial, request=request
        )
        item_form = Line_Item_Formset(instance=order, initial=items_initial)

    item_form.initialize_virtual_fields(order_form.initial.get("vendor"))

    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/order_edit.html",
        {
            "order_form": order_form,
            "item_form": item_form,
            "pk": pk,
            "breadcrumb": crumb,
            "cancel_url": prev_url(pk),
        },
    )


@permission_required(perms.EDIT)
def order_add(request):
    order_initial = None
    items_initial = None
    if request.method == "POST" and "preset" in request.POST:
        order_initial, items_initial = get_initial_from_post(
            request, "item_set", Order, Line_Item
        )
    return order_edit(request, None, order_initial, items_initial)


@permission_required(perms.EDIT)
def order_delete(request, pk):
    order = get_object_or_404(Order, pk=pk)
    my_url = reverse("epic:order_delete", kwargs={"pk": pk})
    parent_url = get_parent_url(my_url)

    # Check if the order is being referenced:
    # 	1) In any Shipment
    # If so, refuse to delete, as that could throw everything off balance.
    ships = Shipment.objects.filter(ordr_id=order.id).order_by("-id")
    if ships.exists():
        messages = [
            "Sorry, order %s cannot be deleted because "
            "%d shipment%s refer%s to it."
            % (
                order.html_link(),
                len(ships),
                "" if len(ships) == 1 else "s",
                "s" if len(ships) == 1 else "",
            )
        ]
        messages.append(
            here_are_all_or_some(
                "shipment",
                "",
                "s",
                [ship.transaction_ptr.html_link() for ship in ships],
            )
        )
        return render(
            request,
            "epic/delete_error.html",
            {
                "breadcrumb": breadcrumb(my_url),
                "parent_url": parent_url,
                "messages": messages,
            },
        )
    order.delete()
    return HttpResponseRedirect(get_parent_url(parent_url))


class Makeup_Order_Item:
    def __init__(self, src, part, qty):
        self.part = part
        self.amount = None
        if isinstance(src, Vendor):
            self.vendor_part = Vendor_Part.get(part.id, src.id)
        else:
            self.vendor_part = None
        self.set_qty(qty)

    def set_qty(self, qty):
        self.qty = qty
        if self.vendor_part:
            self.amount = self.qty * self.vendor_part.price

    def __repr__(self):
        rest = ""
        if self.vendor_part:
            rest = ",vendor_part=%s,amount=%s" % (
                repr(self.vendor_part),
                self.amount,
            )
        return "Makeup_Order_Item(part=%s,qty=%s%s)" % (
            self.part,
            self.qty,
            rest,
        )


class Makeup_Order:
    def __init__(self, src, dst):
        self.src = src
        self.dst = dst
        self.items = []

    def add_part(self, part, qty):
        self.items.append(Makeup_Order_Item(self.src, part, qty))

    def finalize(self):
        for item in self.items:
            if isinstance(self.src, Vendor):
                spq = item.part.spq
                if spq > 1:
                    item.set_qty(int(math.ceil(float(item.qty) / spq)) * spq)
        self.items.sort(key=operator.attrgetter("part.id"))

    def __repr__(self):
        return "Makeup_Order(%s->%s): %s" % (
            self.src.name,
            self.dst.name,
            repr(self.items),
        )


def add_to_makeup_orders(orders, for_warehouse, part, qty_short, inv):
    # first, see if we can ship parts from another warehouse:
    for w in inv.warehouse:
        if w == for_warehouse.id:
            continue
        for p in part.equivalent_parts():
            have = inv.get_qty(w, p.id)
            # only arrange for inter-warehouse shipment for this part if the
            # other warehouse has enough parts to satisfy the shortage or
            # it has at least standard-package-quantity pieces:
            if have >= qty_short or have >= part.spq:
                if have >= qty_short:
                    take = qty_short
                else:
                    take = have
                warehouse = get_object_or_404(Warehouse, pk=w)
                if warehouse not in orders:
                    orders[warehouse] = Makeup_Order(warehouse, for_warehouse)
                orders[warehouse].add_part(p, qty=take)
                qty_short -= take
                inv.adj_qty(w, p.id, -take)

    # second, order the parts from a vendor
    if qty_short > 0:
        vendor = part.best_vendor()
        if not vendor:
            return
        if vendor not in orders:
            orders[vendor] = Makeup_Order(vendor, for_warehouse)
        orders[vendor].add_part(part, qty_short)


@permission_required(perms.VIEW)
def order_check_stock(request, pk):
    def shortage_key(x):
        qty = x["warehouses"][0]["qty_remaining"]
        if qty < 0:
            return qty
        return x["part"].id

    my_url = reverse("epic:order_check_stock", kwargs={"pk": pk})
    crumb = breadcrumb(my_url)
    order = get_object_or_404(Order, pk=pk)
    if (
        order.status != Enum.TXTN_STATUS_OPEN
        or not order.assembly_line_items().exists()
    ):
        raise Http404  # order not open or has no line-items for assemblies
    open_orders = get_open_order_summary(last_order=order)

    try:
        order_warehouse = Warehouse.by_name(order.vendor.name)
    except ObjectDoesNotExist:
        parent_url = get_parent_url(my_url)
        return render(
            request,
            "epic/error.html",
            {
                "breadcrumb": crumb,
                "parent_url": parent_url,
                "messages": [
                    'Vendor "%s" has no warehouse of the same name.'
                    'Please <a href="%s">create the warehouse</a> '
                    "to track stock."
                    % (order.vendor.name, reverse("epic:warehouse_add"))
                ],
            },
        )

    inv = get_stock()

    my_summary_items = []
    for summary in open_orders:
        if summary["order"].id == order.id:
            my_summary_items = summary["items"]
        for item in summary["items"]:
            inv.apply_order_line_item(item)

    comp_status = []
    max_lead_time = 0
    warehouses = [order_warehouse.id]
    max_assy = None
    for line_item in my_summary_items:
        assy_lead_time = line_item.part.lead_time
        if max_assy is None:
            max_assy = line_item.part
        for assy_item in line_item.part.assembly_items():
            if assy_lead_time + assy_item.comp.lead_time > max_lead_time:
                max_lead_time = assy_lead_time + assy_item.comp.lead_time
                max_assy = line_item.part
                max_comp = assy_item.comp
            part = assy_item.comp.best_part()
            if any(sts["part"] == part for sts in comp_status):
                continue  # we already checked this part
            comp_status.append({"part": part, "warehouses": []})
            for w in inv.warehouse:
                for p in part.equivalent_parts():
                    if inv.get_qty(w, p.id) != 0:
                        try:
                            warehouses.index(w)
                        except Exception:
                            warehouses.append(w)

    min_assy_arrival_date = date.today() + timedelta(days=7 * max_lead_time)

    needed_by = order.expected_arrival_date - timedelta(
        days=7 * max_assy.lead_time
    )
    tomorrow = date.today() + timedelta(days=1)
    if needed_by < tomorrow:
        needed_by = tomorrow
    available_time = order.expected_arrival_date - date.today()
    available_time = math.ceil(available_time.days / 7)

    shortages = {}
    makeup_orders = {}
    for sts in comp_status:
        part = sts["part"]
        for w in warehouses:
            total_qty = 0
            qty_breakdown = []
            for p in part.equivalent_parts():
                qty = inv.get_qty(w, p.id)
                total_qty += qty
                if qty != 0:
                    qty_breakdown.append({"part": p, "qty": qty})
            if total_qty < 0 and w == order_warehouse.id:
                shortages[part.id] = -total_qty
            warehouse_sts = {"qty_remaining": total_qty}
            if len(qty_breakdown) > 1:
                warehouse_sts["qty_breakdown"] = qty_breakdown

            sts["warehouses"].append(warehouse_sts)
        if part.id in shortages:
            add_to_makeup_orders(
                makeup_orders, order_warehouse, part, shortages[part.id], inv
            )
    makeup_order_list = []
    for makeup_order in makeup_orders.values():
        makeup_order.finalize()
        makeup_order_list.append(makeup_order)
    # list inter-warehouse shipments first:
    makeup_order_list.sort(key=lambda x: isinstance(x.src, Vendor))

    warehouse_names = []
    for w in warehouses:
        warehouse_names.append(get_object_or_404(Warehouse, pk=w).name)
    comp_status.sort(key=shortage_key)

    return render(
        request,
        "epic/order_check_stock.html",
        {
            "breadcrumb": crumb,
            "order": order,
            "shortage_count": len(shortages),
            "open_orders": open_orders,
            "comp_status": comp_status,
            "warehouse_names": warehouse_names,
            "makeup_order_list": makeup_order_list,
            "needed_by": needed_by,
            "max_lead_time": max_lead_time,
            "available_time": available_time,
            "min_assy_arrival_date": min_assy_arrival_date,
            "max_comp": max_comp,
            "max_assy": max_assy,
        },
    )


@permission_required(perms.EDIT)
def order_add_shipment(request, pk):
    return ship_edit(request, None, order_id=pk)


class Ship_List_View(generic.ListView):
    model = Shipment

    def get_queryset(self):
        return super().get_queryset().order_by("-id")

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = breadcrumb(reverse("epic:ship_list"))
        return context


class Ship_Detail_View(generic.DetailView):
    model = Shipment

    def get_context_data(self, **kwargs):
        ship = self.object
        pk = ship.id
        line_items = Line_Item.objects.filter(txtn_id=pk)

        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = breadcrumb(
            reverse("epic:ship_detail", kwargs={"pk": pk})
        )
        context["page_nav"] = html_page_nav("epic:ship_detail", Shipment, pk)
        context["shipment"] = ship
        context["line_items"] = line_items
        return context


class OrderChoiceField(forms.ModelChoiceField):
    def label_from_instance(self, obj):
        return obj.choice_label()


class Ship_Edit_Form(ModelForm):
    ordr = OrderChoiceField(
        required=False,
        queryset=Order.objects.all(),
        widget=Order_Autocomplete.widget(),
    )
    spreadsheet = forms.FileField(required=False)

    def __init__(self, fixed, *args, **kwargs):
        self.request = kwargs.pop("request", None)
        super().__init__(*args, **kwargs)
        self.fixed = fixed
        self.helper = crispy_form_helper()
        self.has_assemblies = False
        self.fields["ts"].label = "Ship Date"
        self.fields["from_warehouse"].label = "Ship-From Warehouse"
        self.fields["warehouse"].label = "Ship-To Warehouse"
        self.fields["spreadsheet"].label = "Upload Spreadsheet"

        if self.fixed:
            if "order" in fixed:
                self.fields["ordr"].widget.attrs["readonly"] = "True"
            else:
                del self.fields["ordr"]
            if "from_warehouse" in fixed:
                self.fields["from_warehouse"].widget.attrs["readonly"] = "True"
            else:
                del self.fields["from_warehouse"]

    def clean_spreadsheet(self):
        if "spreadsheet" not in self.request.FILES:
            return None
        cleaned = epic.imports.clean_spreadsheet(
            self.request.FILES["spreadsheet"],
            epic.imports.LINE_ITEM_COLUMNS,
            lambda e: self.add_error("spreadsheet", e),
        )
        return cleaned

    def clean(self):
        cleaned_data = super().clean()

        if "order" in self.fixed:
            cleaned_data["ordr"] = get_object_or_404(
                Order, pk=self.fixed["order"]
            )
        if "from_warehouse" in self.fixed:
            cleaned_data["from_warehouse"] = get_object_or_404(
                Warehouse, pk=self.fixed["from_warehouse"]
            )

        dst_warehouse = cleaned_data.get("warehouse")

        order = cleaned_data.get("ordr")
        from_warehouse = cleaned_data.get("from_warehouse")

        if from_warehouse is not None:
            if order is not None:
                raise forms.ValidationError(
                    {
                        "ordr": [
                            "Order # and Ship-From Warehouse "
                            "cannot both be non-empty."
                        ]
                    }
                )
            if from_warehouse == dst_warehouse:
                raise forms.ValidationError(
                    {
                        "warehouse": [
                            "Must be different from Ship-From Warehouse."
                        ]
                    }
                )
        else:
            if order is None:
                raise forms.ValidationError(
                    {
                        "ordr": [
                            "One of Order # or Ship-From Warehouse "
                            "must be specified."
                        ]
                    }
                )

    class Meta:
        model = Shipment
        fields = [
            "ts",
            "ordr",
            "from_warehouse",
            "warehouse",
            "tracking",
            "cost_freight",
            "cost_other",
            "cost_discount",
            "notes",
        ]
        widgets = {"ts": DateTimePicker(options={"format": "YYYY-MM-DD"})}


@permission_required(perms.VIEW)
def ship_list(request):
    return Ship_List_View.as_view()(request)


@permission_required(perms.VIEW)
def ship_detail(request, pk):
    return Ship_Detail_View.as_view()(request, pk=pk)


@transaction.atomic
@permission_required(perms.EDIT)
def ship_edit(
    request,
    pk,
    order_id=None,
    from_warehouse_id=None,
    ship_initial=None,
    items_initial=None,
):
    def prev_url(pk):
        if pk is None:
            if "order" in fixed:
                return reverse(
                    "epic:order_detail", kwargs={"pk": fixed["order"]}
                )
            if "from_warehouse" in fixed:
                return reverse(
                    "epic:warehouse_detail",
                    kwargs={"pk": fixed["from_warehouse"]},
                )
            return reverse("epic:ship_list")
        return reverse("epic:ship_detail", kwargs={"pk": pk})

    # See http://bit.ly/1wfFNKa:
    if ship_initial is None:
        ship_initial = {}
    if items_initial is None:
        items_initial = []

    if order_id is not None:
        fixed = {"order": order_id}
    elif from_warehouse_id is not None:
        fixed = {"from_warehouse": from_warehouse_id}
    else:
        fixed = {}

    order = None
    if pk is None:
        ship = None
        warehouse = None
        if order_id is not None:
            my_url = reverse(
                "epic:order_add_shipment", kwargs={"pk": order_id}
            )
        elif from_warehouse_id is not None:
            my_url = reverse(
                "epic:warehouse_add_shipment", kwargs={"pk": from_warehouse_id}
            )
        else:
            my_url = reverse("epic:ship_add")

        if "order" in fixed:
            order = get_object_or_404(Order, pk=fixed["order"])
            if not items_initial:
                # pre-populated with order-items that remain to be shipped:
                for item in Line_Item.objects.filter(
                    txtn_id=fixed["order"]
                ).order_by("index"):
                    qty = item.qty_remaining_to_ship()
                    if qty > 0:
                        part_cost = item.line_cost / item.qty
                        lc = Decimal("%.2f" % (qty * part_cost))
                        items_initial.append(
                            {"qty": qty, "part": item.part.id, "line_cost": lc}
                        )
        elif "from_warehouse" in fixed:
            warehouse = int(fixed["from_warehouse"])
            if not items_initial:
                # pre-populate with in-stock items of the warehouse:
                inv = get_stock()
                for p in inv.warehouse.get(warehouse, []):
                    qty = inv.warehouse[warehouse][p].qty
                    if qty > 0:
                        items_initial.append({"qty": qty, "part": p})
    else:
        ship = get_object_or_404(Shipment, pk=pk)
        order = ship.ordr  # may be None
        warehouse = ship.from_warehouse  # may be None
        my_url = reverse("epic:ship_edit", kwargs={"pk": pk})

    num_extra = len(items_initial) + NUM_EXTRA_ROWS

    Line_Item_Formset = inlineformset_factory(
        Shipment,
        Line_Item,
        form=Line_Item_Edit_Form,
        formset=Line_Item_Edit_Formset,
        fields=["part", "qty", "line_cost"],
        extra=num_extra,
    )

    spreadsheet = None
    if request.method == "POST" and "preset" not in request.POST:
        ship_form = Ship_Edit_Form(
            fixed, request.POST, instance=ship, request=request
        )
        item_form = Line_Item_Formset(request.POST, instance=ship)

        if ship_form.is_valid():
            spreadsheet = ship_form.cleaned_data["spreadsheet"]
            if spreadsheet:
                txtn = save_line_item_spreadsheet(ship_form, spreadsheet)
            elif item_form.is_valid():
                txtn = save_form_with_transaction(
                    request, Line_Item_Formset, ship_form
                )
            else:
                txtn = None
            if txtn is not None:
                pk = txtn.id
                if "save-and-done" in request.POST:
                    return HttpResponseRedirect(prev_url(pk))
                return HttpResponseRedirect(
                    reverse("epic:ship_edit", kwargs={"pk": pk})
                )
    else:
        if ship is None and not ship_initial:
            ship_initial["ts"] = datetime.today()
            if order is not None:
                ship_initial["ordr"] = order.id
                ship_initial["warehouse"] = order.warehouse_id
            if warehouse is not None:
                ship_initial["from_warehouse"] = warehouse
        ship_form = Ship_Edit_Form(fixed, instance=ship, initial=ship_initial)
        item_form = Line_Item_Formset(instance=ship, initial=items_initial)

    vendor_id = None if order is None else order.vendor.id
    item_form.initialize_virtual_fields(vendor_id)

    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/shipment_edit.html",
        {
            "breadcrumb": crumb,
            "pk": pk,
            "ship_form": ship_form,
            "item_form": item_form,
            "cancel_url": prev_url(pk),
        },
    )


@permission_required(perms.VIEW)
def ship_add(request):
    from_warehouse_id = None
    ship_initial = {}
    items_initial = []
    if request.method == "POST" and "preset" in request.POST:
        ship_initial, items_initial = get_initial_from_post(
            request, "item_set", Shipment, Line_Item
        )
        from_warehouse_id = ship_initial.get("from_warehouse")
    return ship_edit(
        request,
        None,
        from_warehouse_id=from_warehouse_id,
        ship_initial=ship_initial,
        items_initial=items_initial,
    )


@permission_required(perms.EDIT)
def ship_delete(request, pk):
    # pylint: disable=unused-argument
    ship = get_object_or_404(Shipment, pk=pk)
    my_url = reverse("epic:ship_delete", kwargs={"pk": pk})
    parent_url = get_parent_url(my_url)
    # Shipments are easy: nothing else refers to them so we can always delete
    # them.
    ship.delete()
    return HttpResponseRedirect(get_parent_url(parent_url))


@permission_required(perms.VIEW)
def search_results(request):
    def find_parts(q):
        # EP<number> or <number> results in direct part id lookup:
        m = re.match(r"(?:EP)?(\d+)$", q, re.I)
        if m:
            qs = Part.objects.filter(id=int(m.group(1)))
            if qs.exists():
                return qs
        qs = Part.objects.filter(mfg_pn__icontains=q)
        qs |= Part.objects.filter(mfg__icontains=q)
        qs |= Part.objects.filter(descr__icontains=q)
        qs |= Part.objects.filter(vendor_part__vendor__name__icontains=q)
        qs |= Part.objects.filter(vendor_part__vendor_pn__icontains=q)
        qs |= Part.objects.filter(val__icontains=q)
        return qs.distinct().order_by("status").order_by("val")

    def find_txtns(q):
        m = re.match(r"(\d+)$", q, re.I)
        qs = Transaction.objects.none()
        if m:
            qs = Transaction.objects.filter(pk=int(m.group(1)))
            if len(qs) == 1:
                return qs
            for txtn in Transaction.objects.all():
                if re.match(r"\d*%s\d*" % q, str(txtn.id)):
                    qs |= Transaction.objects.filter(pk=txtn.pk)
            if qs:
                return qs
        qs |= Transaction.objects.filter(warehouse__name__icontains=q)
        qs |= Transaction.objects.filter(notes__icontains=q)
        qs |= Transaction.objects.filter(order__vendor__name__icontains=q)
        qs |= Transaction.objects.filter(shipment__tracking__icontains=q)
        return qs.distinct().order_by("-ts")

    parts = Part.objects.none()
    txtns = Transaction.objects.none()
    queries = []
    if request.method == "GET" and ("q" in request.GET):
        q = request.GET["q"].strip()
        queries.append("q=" + q)
        if q != "":
            parts = find_parts(q)
            txtns = find_txtns(q)

    if len(parts) == 1 and not txtns:
        return HttpResponseRedirect(
            reverse("epic:part_detail", kwargs={"pk": parts[0].pk})
        )
    if len(txtns) == 1 and not parts:
        txtn = txtns[0]
        if hasattr(txtn, "order"):
            url = reverse("epic:order_detail", kwargs={"pk": txtn.pk})
        elif hasattr(txtn, "shipment"):
            url = reverse("epic:ship_detail", kwargs={"pk": txtn.pk})
        elif hasattr(txtn, "inventory"):
            url = reverse(
                "epic:warehouse_inventory_detail",
                kwargs={"warehouse": txtn.warehouse.id, "pk": txtn.pk},
            )
        return HttpResponseRedirect(url)

    paginator = Paginator(parts, 25)
    ppg = request.GET.get("ppg")
    try:
        parts = paginator.page(ppg)
    except PageNotAnInteger:
        parts = paginator.page(1)
    except EmptyPage:
        parts = paginator.page(paginator.num_pages)

    paginator = Paginator(txtns, 10)
    tpg = request.GET.get("tpg")
    try:
        txtns = paginator.page(tpg)
    except PageNotAnInteger:
        txtns = paginator.page(1)
    except EmptyPage:
        txtns = paginator.page(paginator.num_pages)

    my_url = reverse("epic:search_results")
    crumb = breadcrumb(my_url)
    return render(
        request,
        "epic/search_results.html",
        {
            "q": q,
            "breadcrumb": crumb,
            "parts": parts,
            "parts_pager": html_list_pager(parts, queries=queries, key="ppg"),
            "txtns": txtns,
            "txtns_pager": html_list_pager(txtns, queries=queries, key="tpg"),
        },
    )


@permission_required(perms.VIEW)
def epic_index(request):
    open_order_summaries = get_open_order_summary()
    inv = get_stock()
    orders_with_shortages = []
    for summary in open_order_summaries:
        # update current stock:
        for line_item in summary["items"]:
            inv.apply_order_line_item(line_item)

        try:
            warehouse = Warehouse.by_name(summary["order"].vendor)
        except Exception:
            continue

        w = warehouse.id

        has_shortages = 0
        for line_item in summary["items"]:
            for assy_item in line_item.part.assembly_items():
                part = assy_item.comp.best_part()
                qty = 0
                for p in part.equivalent_parts():
                    qty += inv.get_qty(w, p.id)
                if qty < 0:
                    orders_with_shortages.append(
                        summary["order"].transaction_ptr
                    )
                    has_shortages = True
                    break
            if has_shortages:
                break

    orders_with_shortages.sort(key=operator.attrgetter("ts"), reverse=True)

    now = timezone.now()
    ts_limit = now - timedelta(days=30)

    activity = []

    recent = Transaction.objects.filter(ts__gte=ts_limit).order_by("-ts")[:20]
    for txtn in recent:
        try:
            orders_with_shortages.index(txtn)
        except Exception:
            activity.append(txtn)

    past_due = (
        Order.objects.filter(status=Enum.TXTN_STATUS_OPEN)
        .filter(expected_arrival_date__lte=now)
        .order_by("-ts")
    )
    for txtn in past_due:
        try:
            activity.index(txtn)
        except Exception:
            activity.append(txtn)

    breadcrumb = '<ol class="breadcrumb">%s: %s</ol>' % (
        EPIC_App_Config.name.upper(),
        EPIC_App_Config.verbose_name,
    )
    return render(
        request,
        "epic/epic_index.html",
        {
            "breadcrumb": breadcrumb,
            "orders_with_shortages": orders_with_shortages,
            "activity": activity,
        },
    )


class Datasheet_List_View(generic.ListView):
    model = Datasheet
    paginate_by = 50

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        context["breadcrumb"] = breadcrumb(reverse("epic:datasheet_list"))
        context["list_pager"] = html_list_pager(context["page_obj"])
        return context


class Datasheet_Detail_View(generic.DetailView):
    model = Datasheet
    fields = ["name", "ds_file", "notes"]

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["breadcrumb"] = breadcrumb(
            reverse("epic:datasheet_detail", kwargs={"pk": self.object.pk})
        )
        context["page_nav"] = html_page_nav(
            "epic:datasheet_detail", Datasheet, self.object.pk
        )
        context["fields"] = get_model_fields(self.object, self.fields)
        context["parts"] = ", ".join(
            [p.html_link() for p in self.object.part_set.all()]
        )
        return context


@permission_required(perms.VIEW)
def datasheet_detail(request, pk):
    return Datasheet_Detail_View.as_view()(request, pk=pk)


class Datasheet_Edit_Form(ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_form_helper()

    def clean_ds_file(self):
        md5 = hashlib.md5()
        ds_file = self.cleaned_data.get("ds_file")
        try:
            # These exist only for freshly uploaded (not previously existing)
            # values of ds_file:
            content_type = ds_file.content_type
            size = ds_file.size
        except AttributeError:
            content_type = None
            size = 0
        if content_type is not None and content_type != "application/pdf":
            raise ValidationError(
                "File `%(name)s' is not a PDF file",
                params={"name": ds_file.name},
                code="invalid",
            )
        limit = getattr(cfg, "EPIC_DATASHEET_MAX_SIZE", 20 * 1024 * 1024)
        if size > limit:
            raise ValidationError(
                "File size of %(size).1f MiB exceeds limit of %(limit).1f MiB",
                params={"size": ds_file.size / MiB, "limit": limit / MiB},
            )
        for chunk in ds_file.chunks():
            md5.update(chunk)
        md5sum = md5.hexdigest()
        existing_ds = Datasheet.objects.filter(md5sum=md5sum)
        if existing_ds and existing_ds[0] != self.instance:
            raise ValidationError(
                "Datasheet `%(ds)s' already has the same "
                "contents as file `%(name)s'.",
                params={"name": ds_file.name, "ds": existing_ds[0].name},
                code="invalid",
            )
        return ds_file

    class Meta:
        model = Datasheet
        fields = ["name", "ds_file", "notes"]


@permission_required(perms.VIEW)
def datasheet_list(request):
    return Datasheet_List_View.as_view()(request)


class Part_Datasheet_Edit_Formset(BaseInlineFormSet):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = crispy_inline_form_helper()

    def add_fields(self, form, index):
        super().add_fields(form, index)

        ndict = OrderedDict()
        # first, copy all hidden fields:
        for f in form.fields:
            if form.fields[f].widget.is_hidden:
                ndict[f] = form.fields[f]
        # second, load our autocomplete field for Part selection
        ndict["id"] = PartChoiceField(
            queryset=Part.objects.all(),
            widget=Part_Autocomplete.widget(),
            label="Part #",
        )
        # render delete last
        if DELETION_FIELD_NAME in form.fields:
            ndict[DELETION_FIELD_NAME] = form.fields[DELETION_FIELD_NAME]

        form.fields = ndict

    def clean(self):
        super().clean()

        for form in self:
            if "id" in form.cleaned_data:
                part = form.cleaned_data["id"]
                if part and part.datasheet:
                    if part.datasheet != self.instance:
                        form.add_error(
                            "id",
                            "This part is already "
                            "associated with datasheet %s." % part.datasheet,
                        )

    def save(self, commit=False):
        """This formset is not for creating/deleting, only changing FKs."""
        for form in self:
            part = form.cleaned_data.get("id")
            if not part:
                continue
            if form.cleaned_data.get(DELETION_FIELD_NAME):
                part.datasheet = None
            else:
                part.datasheet = self.instance
            part.save(commit)


@permission_required(perms.EDIT)
def datasheet_edit(request, pk, parts_list=None):
    def prev_url(pk):
        if pk is None:
            return reverse("epic:datasheet_list")
        return reverse("epic:datasheet_detail", kwargs={"pk": pk})

    if pk is None:
        datasheet = None
        my_url = reverse("epic:datasheet_add")
        Part_Inline_Form_Set = inlineformset_factory(
            Datasheet,
            Part,
            fields=("id",),
            formset=Part_Datasheet_Edit_Formset,
            extra=NUM_EXTRA_ROWS,
            can_delete=False,
        )
    else:
        datasheet = get_object_or_404(Datasheet, pk=pk)
        my_url = reverse("epic:datasheet_edit", kwargs={"pk": pk})
        Part_Inline_Form_Set = inlineformset_factory(
            Datasheet,
            Part,
            fields=("id",),
            formset=Part_Datasheet_Edit_Formset,
            extra=NUM_EXTRA_ROWS,
            can_delete=True,
        )

    if request.method == "POST":
        datasheet_form = Datasheet_Edit_Form(
            request.POST, request.FILES, instance=datasheet
        )
        part_formset = Part_Inline_Form_Set(request.POST, instance=datasheet)
        if datasheet_form.is_valid() and part_formset.is_valid():
            datasheet = datasheet_form.save()
            # Datasheet may be new
            part_formset = Part_Inline_Form_Set(
                request.POST, instance=datasheet
            )
            part_formset.is_valid()
            part_formset.save()
            return HttpResponseRedirect(prev_url(datasheet.pk))
    else:
        datasheet_form = Datasheet_Edit_Form(instance=datasheet)
        initial = []
        if parts_list:
            for p in parts_list:
                initial.append({"id": p})
        part_formset = Part_Inline_Form_Set(
            instance=datasheet, initial=initial
        )

    return render(
        request,
        "epic/datasheet_edit.html",
        {
            "pk": pk,
            "datasheet_form": datasheet_form,
            "breadcrumb": breadcrumb(my_url),
            "part_formset": part_formset,
            "cancel_url": prev_url(pk),
        },
    )


@permission_required(perms.EDIT)
def datasheet_add(request):
    return datasheet_edit(request, None)


@permission_required(perms.EDIT)
def datasheet_add_part(request, pk):
    return datasheet_edit(request, None, parts_list=[pk])


@permission_required(perms.EDIT)
def datasheet_delete(request, pk):
    datasheet = get_object_or_404(Datasheet, pk=pk)
    my_url = reverse("epic:datasheet_delete", kwargs={"pk": pk})
    parent_url = get_parent_url(my_url)

    # Check if the datasheet is being referenced:
    #  1) By any Part
    # If so, refuse to delete, as that could throw everything off balance.
    parts = Part.objects.filter(datasheet_id=datasheet.id).order_by("-id")
    if parts.exists():
        messages = [
            "Sorry, datasheet %s cannot be deleted because "
            "%d part%s refer%s to it."
            % (
                datasheet.html_link(),
                len(parts),
                "" if len(parts) == 1 else "s",
                "s" if len(parts) == 1 else "",
            )
        ]
        messages.append(
            here_are_all_or_some(
                "part",
                "",
                "s",
                [p.html_link() for p in datasheet.part_set.all()],
            )
        )
        return render(
            request,
            "epic/delete_error.html",
            {
                "breadcrumb": breadcrumb(my_url),
                "parent_url": parent_url,
                "messages": messages,
            },
        )
    datasheet.delete()
    return HttpResponseRedirect(get_parent_url(parent_url))
