from django.db.models import Case
from django.db.models import F, Q
from django.db.models import Sum
from django.db.models import Value
from django.db.models import When
from django.utils import timezone

from registrasion.models import commerce
from registrasion.models import conditions

_BIG_QUANTITY = 99999999  # A big quantity

class ConditionController(object):
    ''' Base class for testing conditions that activate Flag
    or Discount objects. '''

    def __init__(self, condition):
        self.condition = condition

    @staticmethod
    def _controllers():
        return {
            conditions.CategoryFlag: CategoryConditionController,
            conditions.GroupMemberDiscount: GroupMemberConditionController,
            conditions.GroupMemberFlag: GroupMemberConditionController,
            conditions.IncludedProductDiscount: ProductConditionController,
            conditions.ProductFlag: ProductConditionController,
            conditions.SpeakerFlag: SpeakerConditionController,
            conditions.SpeakerDiscount: SpeakerConditionController,
            conditions.TimeOrStockLimitDiscount:
                TimeOrStockLimitDiscountController,
            conditions.TimeOrStockLimitFlag:
                TimeOrStockLimitFlagController,
            conditions.VoucherDiscount: VoucherConditionController,
            conditions.VoucherFlag: VoucherConditionController,
        }

    @staticmethod
    def for_type(cls):
        return ConditionController._controllers()[cls]

    @staticmethod
    def for_condition(condition):
        try:
            return ConditionController.for_type(type(condition))(condition)
        except KeyError:
            return ConditionController()

    @classmethod
    def pre_filter(cls, queryset, user):
        ''' Returns only the flag conditions that might be available for this
        user. It should hopefully reduce the number of queries that need to be
        executed to determine if a flag is met.

        If this filtration implements the same query as is_met, then you should
        be able to implement ``is_met()`` in terms of this.

        Arguments:

            queryset (Queryset[c]): The canditate conditions.

            user (User): The user for whom we're testing these conditions.

        Returns:
            Queryset[c]: A subset of the conditions that pass the pre-filter
                test for this user.

        '''

        # Default implementation does NOTHING.
        return queryset

    def passes_filter(self, user):
        ''' Returns true if the condition passes the filter '''

        cls = type(self.condition)
        qs = cls.objects.filter(pk=self.condition.id)
        return self.condition in self.pre_filter(qs, user)

    def user_quantity_remaining(self, user, filtered=False):
        ''' Returns the number of items covered by this flag condition the
        user can add to the current cart. This default implementation returns
        a big number if is_met() is true, otherwise 0.

        Either this method, or is_met() must be overridden in subclasses.
        '''

        return _BIG_QUANTITY if self.is_met(user, filtered) else 0

    def is_met(self, user, filtered=False):
        ''' Returns True if this flag condition is met, otherwise returns
        False.

        Either this method, or user_quantity_remaining() must be overridden
        in subclasses.

        Arguments:

            user (User): The user for whom this test must be met.

            filter (bool): If true, this condition was part of a queryset
                returned by pre_filter() for this user.

        '''
        return self.user_quantity_remaining(user, filtered) > 0

class IsMetByFilter(object):

    def is_met(self, user, filtered=False):
        ''' Returns True if this flag condition is met, otherwise returns
        False. It determines if the condition is met by calling pre_filter
        with a queryset containing only self.condition. '''

        if filtered:
            return True  # Why query again?

        return self.passes_filter(user)

class RemainderSetByFilter(object):

    def user_quantity_remaining(self, user, filtered=True):
        ''' returns 0 if the date range is violated, otherwise, it will return
        the quantity remaining under the stock limit.

        The filter for this condition must add an annotation called "remainder"
        in order for this to work.
        '''

        if filtered:
            if hasattr(self.condition, "remainder"):
                return self.condition.remainder

        # Mark self.condition with a remainder
        qs = type(self.condition).objects.filter(pk=self.condition.id)
        qs = self.pre_filter(qs, user)

        if len(qs) > 0:
            return qs[0].remainder
        else:
            return 0

class CategoryConditionController(IsMetByFilter, ConditionController):

    @classmethod
    def pre_filter(self, queryset, user):
        ''' Returns all of the items from queryset where the user has a
        product from a category invoking that item's condition in one of their
        carts. '''

        in_user_carts = Q(
            enabling_category__product__productitem__cart__user=user
        )
        released = commerce.Cart.STATUS_RELEASED
        in_released_carts = Q(
            enabling_category__product__productitem__cart__status=released
        )
        queryset = queryset.filter(in_user_carts)
        queryset = queryset.exclude(in_released_carts)

        return queryset

class ProductConditionController(IsMetByFilter, ConditionController):
    ''' Condition tests for ProductFlag and
    IncludedProductDiscount. '''

    @classmethod
    def pre_filter(self, queryset, user):
        ''' Returns all of the items from queryset where the user has a
        product invoking that item's condition in one of their carts. '''

        in_user_carts = Q(enabling_products__productitem__cart__user=user)
        released = commerce.Cart.STATUS_RELEASED
        paid = commerce.Cart.STATUS_PAID
        active = commerce.Cart.STATUS_ACTIVE
        in_released_carts = Q(
            enabling_products__productitem__cart__status=released
        )
        not_in_paid_or_active_carts = ~(
            Q(enabling_products__productitem__cart__status=paid) |
            Q(enabling_products__productitem__cart__status=active)
        )

        queryset = queryset.filter(in_user_carts)
        queryset = queryset.exclude(
            in_released_carts & not_in_paid_or_active_carts
        )

        return queryset

class TimeOrStockLimitConditionController(
            RemainderSetByFilter,
            ConditionController,
        ):
    ''' Common condition tests for TimeOrStockLimit Flag and
    Discount.'''

    @classmethod
    def pre_filter(self, queryset, user):
        ''' Returns all of the items from queryset where the date falls into
        any specified range, but not yet where the stock limit is not yet
        reached.'''

        now = timezone.now()

        # Keep items with no start time, or start time not yet met.
        queryset = queryset.filter(Q(start_time=None) | Q(start_time__lte=now))
        queryset = queryset.filter(Q(end_time=None) | Q(end_time__gte=now))

        # Filter out items that have been reserved beyond the limits
        quantity_or_zero = self._calculate_quantities(user)

        remainder = Case(
            When(limit=None, then=Value(_BIG_QUANTITY)),
            default=F("limit") - Sum(quantity_or_zero),
        )

        queryset = queryset.annotate(remainder=remainder)
        queryset = queryset.filter(remainder__gt=0)

        return queryset

    @classmethod
    def _relevant_carts(cls, user):
        reserved_carts = commerce.Cart.reserved_carts()
        reserved_carts = reserved_carts.exclude(
            user=user,
            status=commerce.Cart.STATUS_ACTIVE,
        )
        return reserved_carts

class TimeOrStockLimitFlagController(
        TimeOrStockLimitConditionController):

    @classmethod
    def _calculate_quantities(cls, user):
        reserved_carts = cls._relevant_carts(user)

        # Calculate category lines
        item_cats = F('categories__product__productitem__product__category')
        reserved_category_products = (
            Q(categories=item_cats) &
            Q(categories__product__productitem__cart__in=reserved_carts)
        )

        # Calculate product lines
        reserved_products = (
            Q(products=F('products__productitem__product')) &
            Q(products__productitem__cart__in=reserved_carts)
        )

        category_quantity_in_reserved_carts = When(
            reserved_category_products,
            then="categories__product__productitem__quantity",
        )

        product_quantity_in_reserved_carts = When(
            reserved_products,
            then="products__productitem__quantity",
        )

        quantity_or_zero = Case(
            category_quantity_in_reserved_carts,
            product_quantity_in_reserved_carts,
            default=Value(0),
        )

        return quantity_or_zero

class TimeOrStockLimitDiscountController(TimeOrStockLimitConditionController):

    @classmethod
    def _calculate_quantities(cls, user):
        reserved_carts = cls._relevant_carts(user)

        quantity_in_reserved_carts = When(
            discountitem__cart__in=reserved_carts,
            then="discountitem__quantity"
        )

        quantity_or_zero = Case(
            quantity_in_reserved_carts,
            default=Value(0)
        )

        return quantity_or_zero

class VoucherConditionController(IsMetByFilter, ConditionController):
    ''' Condition test for VoucherFlag and VoucherDiscount.'''

    @classmethod
    def pre_filter(self, queryset, user):
        ''' Returns all of the items from queryset where the user has entered
        a voucher that invokes that item's condition in one of their carts. '''

        return queryset.filter(voucher__cart__user=user)

class SpeakerConditionController(IsMetByFilter, ConditionController):

    @classmethod
    def pre_filter(self, queryset, user):
        ''' Returns all of the items from queryset which are enabled by a user
        being a presenter or copresenter of a non-cancelled proposal. '''

        # Filter out cancelled proposals
        queryset = queryset.filter(
            proposal_kind__proposalbase__presentation__cancelled=False
        )

        u = user
        # User is a presenter
        user_is_presenter = Q(
            is_presenter=True,
            proposal_kind__proposalbase__presentation__speaker__user=u,
        )
        # User is a copresenter
        user_is_copresenter = Q(
            is_copresenter=True,
            proposal_kind__proposalbase__presentation__additional_speakers__user=u,  # NOQA
        )

        return queryset.filter(user_is_presenter | user_is_copresenter)

class GroupMemberConditionController(IsMetByFilter, ConditionController):

    @classmethod
    def pre_filter(self, conditions, user):
        ''' Returns all of the items from conditions which are enabled by a
        user being member of a Django Auth Group. '''

        return conditions.filter(group__in=user.groups.all())


from .batch import BatchController
from .category import CategoryController
from .discount import DiscountController
from .flag import FlagController
from .product import ProductController

import collections
import datetime
import functools
import itertools

from django.core.exceptions import ObjectDoesNotExist
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Max
from django.db.models import Q
from django.utils import timezone

from registrasion.exceptions import CartValidationError
from registrasion.models import commerce
from registrasion.models import conditions
from registrasion.models import inventory

def _modifies_cart(func):
    ''' Decorator that makes the wrapped function raise ValidationError
    if we're doing something that could modify the cart.

    It also wraps the execution of this function in a database transaction,
    and marks the boundaries of a cart operations batch.
    '''

    @functools.wraps(func)
    def inner(self, *a, **k):
        self._fail_if_cart_is_not_active()
        with transaction.atomic():
            with BatchController.batch(self.cart.user):
                # Mark the version of self in the batch cache as modified
                memoised = self.for_user(self.cart.user)
                memoised._modified_by_batch = True
                return func(self, *a, **k)
    return inner

class CartController(object):

    def __init__(self, cart):
        self.cart = cart

    @classmethod
    @BatchController.memoise
    def for_user(cls, user):
        ''' Returns the user's current cart, or creates a new cart
        if there isn't one ready yet. '''

        try:
            existing = commerce.Cart.objects.get(
                user=user,
                status=commerce.Cart.STATUS_ACTIVE,
            )
        except ObjectDoesNotExist:
            existing = commerce.Cart.objects.create(
                user=user,
                time_last_updated=timezone.now(),
                reservation_duration=datetime.timedelta(),
            )
        return cls(existing)

    def _fail_if_cart_is_not_active(self):
        self.cart.refresh_from_db()
        if self.cart.status != commerce.Cart.STATUS_ACTIVE:
            raise ValidationError("You can only amend active carts.")

    def _autoextend_reservation(self):
        ''' Updates the cart's time last updated value, which is used to
        determine whether the cart has reserved the items and discounts it
        holds. '''

        time = timezone.now()

        # Calculate the residual of the _old_ reservation duration
        # if it's greater than what's in the cart now, keep it.
        time_elapsed_since_updated = (time - self.cart.time_last_updated)
        residual = self.cart.reservation_duration - time_elapsed_since_updated

        reservations = [datetime.timedelta(0), residual]

        # If we have vouchers, we're entitled to an hour at minimum.
        if len(self.cart.vouchers.all()) >= 1:
            reservations.append(inventory.Voucher.RESERVATION_DURATION)

        # Else, it's the maximum of the included products
        items = commerce.ProductItem.objects.filter(cart=self.cart)
        agg = items.aggregate(Max("product__reservation_duration"))
        product_max = agg["product__reservation_duration__max"]

        if product_max is not None:
            reservations.append(product_max)

        self.cart.time_last_updated = time
        self.cart.reservation_duration = max(reservations)

    def end_batch(self):
        ''' Calls ``_end_batch`` if a modification has been performed in the
        previous batch. '''
        if hasattr(self, '_modified_by_batch'):
            self._end_batch()

    def _end_batch(self):
        ''' Performs operations that occur occur at the end of a batch of
        product changes/voucher applications etc.

        You need to call this after you've finished modifying the user's cart.
        This is normally done by wrapping a block of code using
        ``operations_batch``.

        '''

        self.cart.refresh_from_db()

        self._recalculate_discounts()

        self._autoextend_reservation()
        self.cart.revision += 1
        self.cart.save()

    def extend_reservation(self, timedelta):
        ''' Extends the reservation on this cart by the given timedelta.
        This can only be done if the current state of the cart is valid (i.e
        all items and discounts in the cart are still available.)

        Arguments:
            timedelta (timedelta): The amount of time to extend the cart by.
                The resulting reservation_duration will be now() + timedelta,
                unless the requested extension is *LESS* than the current
                reservation deadline.

        '''

        self.validate_cart()
        cart = self.cart
        cart.refresh_from_db()

        elapsed = (timezone.now() - cart.time_last_updated)

        if cart.reservation_duration - elapsed > timedelta:
            return

        cart.time_last_updated = timezone.now()
        cart.reservation_duration = timedelta
        cart.save()

    @_modifies_cart
    def set_quantities(self, product_quantities):
        ''' Sets the quantities on each of the products on each of the
        products specified. Raises an exception (ValidationError) if a limit
        is violated. `product_quantities` is an iterable of (product, quantity)
        pairs. '''

        items_in_cart = commerce.ProductItem.objects.filter(cart=self.cart)
        items_in_cart = items_in_cart.select_related(
            "product",
            "product__category",
        )

        product_quantities = list(product_quantities)

        # n.b need to add have the existing items first so that the new
        # items override the old ones.
        all_product_quantities = dict(itertools.chain(
            ((i.product, i.quantity) for i in items_in_cart.all()),
            product_quantities,
        )).items()

        # Validate that the limits we're adding are OK
        products = set(product for product, q in product_quantities)
        try:
            self._test_limits(all_product_quantities)
        except CartValidationError as ve:
            # Only raise errors for products that we're explicitly
            # Manipulating here.
            for ve_field in ve.error_list:
                product, message = ve_field.message
                if product in products:
                    raise ve

        new_items = []
        products = []
        for product, quantity in product_quantities:
            products.append(product)

            if quantity == 0:
                continue

            item = commerce.ProductItem(
                cart=self.cart,
                product=product,
                quantity=quantity,
            )
            new_items.append(item)

        to_delete = (
            Q(quantity=0) |
            Q(product__in=products)
        )

        items_in_cart.filter(to_delete).delete()
        commerce.ProductItem.objects.bulk_create(new_items)

    def _test_limits(self, product_quantities):
        ''' Tests that the quantity changes we intend to make do not violate
        the limits and flag conditions imposed on the products. '''

        errors = []

        # Pre-annotate products
        remainders = ProductController.user_remainders(self.cart.user)

        # Test each product limit here
        for product, quantity in product_quantities:
            if quantity < 0:
                errors.append((product, "Value must be zero or greater."))

            limit = remainders[product.id]

            if quantity > limit:
                errors.append((
                    product,
                    "You may only have %d of product: %s" % (
                        limit, product,
                    )
                ))

        # Collect by category
        by_cat = collections.defaultdict(list)
        for product, quantity in product_quantities:
            by_cat[product.category].append((product, quantity))

        # Pre-annotate categories
        remainders = CategoryController.user_remainders(self.cart.user)

        # Test each category limit here
        for category in by_cat:
            limit = remainders[category.id]

            # Get the amount so far in the cart
            to_add = sum(i[1] for i in by_cat[category])

            if to_add > limit:
                message_base = "You may only add %d items from category: %s"
                message = message_base % (
                    limit, category.name,
                )
                for product, quantity in by_cat[category]:
                    errors.append((product, message))

        # Test the flag conditions
        errs = FlagController.test_flags(
            self.cart.user,
            product_quantities=product_quantities,
        )

        if errs:
            for error in errs:
                errors.append(error)

        if errors:
            raise CartValidationError(errors)

    @_modifies_cart
    def apply_voucher(self, voucher_code):
        ''' Applies the voucher with the given code to this cart. '''

        # Try and find the voucher
        voucher = inventory.Voucher.objects.get(code=voucher_code.upper())

        # Re-applying vouchers should be idempotent
        if voucher in self.cart.vouchers.all():
            return

        self._test_voucher(voucher)

        # If successful...
        self.cart.vouchers.add(voucher)

    def _test_voucher(self, voucher):
        ''' Tests whether this voucher is allowed to be applied to this cart.
        Raises ValidationError if not. '''

        # Is voucher exhausted?
        active_carts = commerce.Cart.reserved_carts()

        # It's invalid for a user to enter a voucher that's exhausted
        carts_with_voucher = active_carts.filter(vouchers=voucher)
        carts_with_voucher = carts_with_voucher.exclude(pk=self.cart.id)
        if carts_with_voucher.count() >= voucher.limit:
            raise ValidationError(
                "Voucher %s is no longer available" % voucher.code)

        # It's not valid for users to re-enter a voucher they already have
        user_carts_with_voucher = carts_with_voucher.filter(
            user=self.cart.user,
        )

        if user_carts_with_voucher.count() > 0:
            raise ValidationError("You have already entered this voucher.")

    def _test_vouchers(self, vouchers):
        ''' Tests each of the vouchers against self._test_voucher() and raises
        the collective ValidationError.
        Future work will refactor _test_voucher in terms of this, and save some
        queries. '''
        errors = []
        for voucher in vouchers:
            try:
                self._test_voucher(voucher)
            except ValidationError as ve:
                errors.append(ve)

        if errors:
            raise(ValidationError(errors))

    def _test_required_categories(self):
        ''' Makes sure that the owner of this cart has satisfied all of the
        required category constraints in the inventory (be it in this cart
        or others). '''

        required = set(inventory.Category.objects.filter(required=True))

        items = commerce.ProductItem.objects.filter(
            product__category__required=True,
            cart__user=self.cart.user,
        ).exclude(
            cart__status=commerce.Cart.STATUS_RELEASED,
        )

        for item in items:
            required.remove(item.product.category)

        errors = []
        for category in required:
            msg = "You must have at least one item from: %s" % category
            errors.append((None, msg))

        if errors:
            raise ValidationError(errors)

    def _append_errors(self, errors, ve):
        for error in ve.error_list:
            errors.append(error.message[1])

    def validate_cart(self):
        ''' Determines whether the status of the current cart is valid;
        this is normally called before generating or paying an invoice '''

        cart = self.cart
        user = self.cart.user
        errors = []

        try:
            self._test_vouchers(self.cart.vouchers.all())
        except ValidationError as ve:
            errors.append(ve)

        items = commerce.ProductItem.objects.filter(cart=cart)
        items = items.select_related("product", "product__category")

        product_quantities = list((i.product, i.quantity) for i in items)
        try:
            self._test_limits(product_quantities)
        except ValidationError as ve:
            self._append_errors(errors, ve)

        try:
            self._test_required_categories()
        except ValidationError as ve:
            self._append_errors(errors, ve)

        # Validate the discounts
        # TODO: refactor in terms of available_discounts
        # why aren't we doing that here?!

        #     def available_discounts(cls, user, categories, products):

        products = [i.product for i in items]
        discounts_with_quantity = DiscountController.available_discounts(
            user,
            [],
            products,
        )
        discounts = set(i.discount.id for i in discounts_with_quantity)

        discount_items = commerce.DiscountItem.objects.filter(cart=cart)
        for discount_item in discount_items:
            discount = discount_item.discount

            if discount.id not in discounts:
                errors.append(
                    ValidationError("Discounts are no longer available")
                )

        if errors:
            raise ValidationError(errors)

    @_modifies_cart
    def fix_simple_errors(self):
        ''' This attempts to fix the easy errors raised by ValidationError.
        This includes removing items from the cart that are no longer
        available, recalculating all of the discounts, and removing voucher
        codes that are no longer available. '''

        # Fix vouchers first (this affects available discounts)
        to_remove = []
        for voucher in self.cart.vouchers.all():
            try:
                self._test_voucher(voucher)
            except ValidationError:
                to_remove.append(voucher)

        for voucher in to_remove:
            self.cart.vouchers.remove(voucher)

        # Fix products and discounts
        items = commerce.ProductItem.objects.filter(cart=self.cart)
        items = items.select_related("product")
        products = set(i.product for i in items)
        available = set(ProductController.available_products(
            self.cart.user,
            products=products,
        ))

        not_available = products - available
        zeros = [(product, 0) for product in not_available]

        self.set_quantities(zeros)

    @transaction.atomic
    def _recalculate_discounts(self):
        ''' Calculates all of the discounts available for this product.'''

        # Delete the existing entries.
        commerce.DiscountItem.objects.filter(cart=self.cart).delete()

        # Order the products such that the most expensive ones are
        # processed first.
        product_items = self.cart.productitem_set.all().select_related(
            "product", "product__category"
        ).order_by("-product__price")

        products = [i.product for i in product_items]
        discounts = DiscountController.available_discounts(
            self.cart.user,
            [],
            products,
        )

        # The highest-value discounts will apply to the highest-value
        # products first, because of the order_by clause
        for item in product_items:
            self._add_discount(item.product, item.quantity, discounts)

    def _add_discount(self, product, quantity, discounts):
        ''' Applies the best discounts on the given product, from the given
        discounts.'''

        def matches(discount):
            ''' Returns True if and only if the given discount apples to
            our product. '''
            if isinstance(discount.clause, conditions.DiscountForCategory):
                return discount.clause.category == product.category
            else:
                return discount.clause.product == product

        def value(discount):
            ''' Returns the value of this discount clause
            as applied to this product '''
            if discount.clause.percentage is not None:
                return discount.clause.percentage * product.price
            else:
                return discount.clause.price

        discounts = [i for i in discounts if matches(i)]
        discounts.sort(key=value)

        for candidate in reversed(discounts):
            if quantity == 0:
                break
            elif candidate.quantity == 0:
                # This discount clause has been exhausted by this cart
                continue

            # Get a provisional instance for this DiscountItem
            # with the quantity set to as much as we have in the cart
            discount_item = commerce.DiscountItem.objects.create(
                product=product,
                cart=self.cart,
                discount=candidate.discount,
                quantity=quantity,
            )

            # Truncate the quantity for this DiscountItem if we exceed quantity
            ours = discount_item.quantity
            allowed = candidate.quantity
            if ours > allowed:
                discount_item.quantity = allowed
                discount_item.save()
                # Update the remaining quantity.
                quantity = ours - allowed
            else:
                quantity = 0

            candidate.quantity -= discount_item.quantity


import csv

from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import render
from django.core.urlresolvers import reverse
from django.http import HttpResponse
from functools import wraps

from registrasion import views

''' A list of report views objects that can be used to load a list of
reports. '''
_all_report_views = []

class Report(object):

    def __init__(self):
        pass

    def title():
        raise NotImplementedError

    def headings():
        ''' Returns the headings for the report. '''
        raise NotImplementedError

    def rows(content_type):
        '''

        Arguments:
            content_type (str): The content-type for the output format of this
            report.

        Returns:
            An iterator, which yields each row of the data. Each row should
            be an iterable containing the cells, rendered appropriately for
            content_type.
        '''
        raise NotImplementedError

    def _linked_text(self, content_type, address, text):
        '''

        Returns:
            an HTML linked version of text, if the content_type for this report
            is HTMLish, otherwise, the text.
        '''

        if content_type == "text/html":
            return Report._html_link(address, text)
        else:
            return text

    @staticmethod
    def _html_link(address, text):
        return '%s' % (address, text)

class _ReportTemplateWrapper(object):
    ''' Used internally to pass `Report` objects to templates. They effectively
    are used to specify the content_type for a report. '''

    def __init__(self, content_type, report):
        self.content_type = content_type
        self.report = report

    def title(self):
        return self.report.title()

    def headings(self):
        return self.report.headings()

    def rows(self):
        return self.report.rows(self.content_type)

class BasicReport(Report):

    def __init__(self, title, headings, link_view=None):
        super(BasicReport, self).__init__()
        self._title = title
        self._headings = headings
        self._link_view = link_view

    def title(self):
        ''' Returns the title for this report. '''
        return self._title

    def headings(self):
        ''' Returns the headings for the table. '''
        return self._headings

    def cell_text(self, content_type, index, text):
        if index > 0 or not self._link_view:
            return text
        else:
            address = self.get_link(text)
            return self._linked_text(content_type, address, text)

    def get_link(self, argument):
        return reverse(self._link_view, args=[argument])

class ListReport(BasicReport):

    def __init__(self, title, headings, data, link_view=None):
        super(ListReport, self).__init__(title, headings, link_view=link_view)
        self._data = data

    def rows(self, content_type):
        ''' Returns the data rows for the table. '''

        for row in self._data:
            yield [
                self.cell_text(content_type, i, cell)
                for i, cell in enumerate(row)
            ]

class QuerysetReport(BasicReport):

    def __init__(self, title, attributes, queryset, headings=None,
                 link_view=None):
        super(QuerysetReport, self).__init__(
            title, headings, link_view=link_view
        )
        self._attributes = attributes
        self._queryset = queryset

    def headings(self):
        if self._headings is not None:
            return self._headings

        return [
            " ".join(i.split("_")).capitalize() for i in self._attributes
        ]

    def rows(self, content_type):

        def rgetattr(item, attr):
            for i in attr.split("__"):
                item = getattr(item, i)

            if callable(item):
                try:
                    return item()
                except TypeError:
                    pass

            return item

        for row in self._queryset:
            yield [
                self.cell_text(content_type, i, rgetattr(row, attribute))
                for i, attribute in enumerate(self._attributes)
            ]

class Links(Report):

    def __init__(self, title, links):
        '''
        Arguments:
            links ([tuple, ...]): a list of 2-tuples:
                (url, link_text)

        '''
        self._title = title
        self._links = links

    def title(self):
        return self._title

    def headings(self):
        return []

    def rows(self, content_type):
        for url, link_text in self._links:
            yield [
                self._linked_text(content_type, url, link_text)
            ]

def report_view(title, form_type=None):
    ''' Decorator that converts a report view function into something that
    displays a Report.

    Arguments:
        title (str):
            The title of the report.
        form_type (Optional[forms.Form]):
            A form class that can make this report display things. If not
            supplied, no form will be displayed.

    '''

    # Create & return view
    def _report(view):
        report_view = ReportView(view, title, form_type)
        report_view = user_passes_test(views._staff_only)(report_view)
        report_view = wraps(view)(report_view)

        # Add this report to the list of reports.
        _all_report_views.append(report_view)

        return report_view

    return _report

class ReportView(object):
    ''' View objects that can render report data into HTML or CSV. '''

    def __init__(self, inner_view, title, form_type):
        '''

        Arguments:
            inner_view: Callable that returns either a Report or a sequence of
                Report objects.

            title: The title that appears at the top of all of the reports.

            form_type: A Form class that can be used to query the report.

        '''

        # Consolidate form_type so it has content type and section
        self.inner_view = inner_view
        self.title = title
        self.form_type = form_type

    def __call__(self, request, *a, **k):
        data = ReportViewRequestData(self, request, *a, **k)
        return self.render(data)

    def get_form(self, request):

        ''' Creates an instance of self.form_type using request.GET '''

        # Create a form instance
        if self.form_type is not None:
            form = self.form_type(request.GET)

            # Pre-validate it
            form.is_valid()
        else:
            form = None

        return form

    @classmethod
    def wrap_reports(cls, reports, content_type):
        ''' Wraps the reports in a _ReportTemplateWrapper for the given
        content_type -- this allows data to be returned as HTML links, for
        instance. '''

        reports = [
            _ReportTemplateWrapper(content_type, report)
            for report in reports
        ]

        return reports

    def render(self, data):
        ''' Renders the reports based on data.content_type's value.

        Arguments:
            data (ReportViewRequestData): The report data. data.content_type
                is used to determine how the reports are rendered.

        Returns:
            HTTPResponse: The rendered version of the report.

        '''
        renderers = {
            "text/csv": self._render_as_csv,
            "text/html": self._render_as_html,
            None: self._render_as_html,
        }
        render = renderers[data.content_type]
        return render(data)

    def _render_as_html(self, data):
        ctx = {
            "title": self.title,
            "form": data.form,
            "reports": data.reports,
        }

        return render(data.request, "registrasion/report.html", ctx)

    def _render_as_csv(self, data):
        report = data.reports[data.section]

        # Create the HttpResponse object with the appropriate CSV header.
        response = HttpResponse(content_type='text/csv')

        writer = csv.writer(response)
        encode = lambda i: i.encode("utf8") if isinstance(i, unicode) else i  # NOQA
        writer.writerow(list(encode(i) for i in report.headings()))
        for row in report.rows():
            writer.writerow(list(encode(i) for i in row))

        return response

class ReportViewRequestData(object):
    '''

    Attributes:
        form (Form): form based on request
        reports ([Report, ...]): The reports rendered from the request

    Arguments:
        report_view (ReportView): The ReportView to call back to.
        request (HTTPRequest): A django HTTP request

    '''

    def __init__(self, report_view, request, *a, **k):

        self.report_view = report_view
        self.request = request

        # Calculate other data
        self.form = report_view.get_form(request)

        # Content type and section come from request.GET
        self.content_type = request.GET.get("content_type")
        self.section = request.GET.get("section")
        self.section = int(self.section) if self.section else None

        if self.content_type is None:
            self.content_type = "text/html"

        # Reports come from calling the inner view
        reports = report_view.inner_view(request, self.form, *a, **k)

        # Normalise to a list
        if isinstance(reports, Report):
            reports = [reports]

        # Wrap them in appropriate format
        reports = ReportView.wrap_reports(reports, self.content_type)

        self.reports = reports

def get_all_reports():
    ''' Returns all the views that have been registered with @report '''

    return list(_all_report_views)


from . import forms

import collections
import datetime
import itertools

from django.conf import settings
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.db import models
from django.db.models import F, Q
from django.db.models import Count, Max, Sum
from django.db.models import Case, When, Value
from django.db.models.fields.related import RelatedField
from django.shortcuts import render

from registrasion.controllers.cart import CartController
from registrasion.controllers.item import ItemController
from registrasion.models import commerce
from registrasion.models import people
from registrasion import util
from registrasion import views

from symposion.schedule import models as schedule_models

from .reports import get_all_reports
from .reports import Links
from .reports import ListReport
from .reports import QuerysetReport
from .reports import report_view

def CURRENCY():
    return models.DecimalField(decimal_places=2)

AttendeeProfile = util.get_object_from_name(settings.ATTENDEE_PROFILE_MODEL)

@user_passes_test(views._staff_only)
def reports_list(request):
    ''' Lists all of the reports currently available. '''

    reports = []

    for report in get_all_reports():
        reports.append({
            "name": report.__name__,
            "url": reverse(report),
            "description": report.__doc__,
        })

    reports.sort(key=lambda report: report["name"])

    ctx = {
        "reports": reports,
    }

    return render(request, "registrasion/reports_list.html", ctx)

# Report functions

@report_view("Reconcilitation")
def reconciliation(request, form):
    ''' Shows the summary of sales, and the full history of payments and
    refunds into the system. '''

    return [
        sales_payment_summary(),
        items_sold(),
        payments(),
        credit_note_refunds(),
    ]

def items_sold():
    ''' Summarises the items sold and discounts granted for a given set of
    products, or products from categories. '''

    data = None
    headings = None

    line_items = commerce.LineItem.objects.filter(
        invoice__status=commerce.Invoice.STATUS_PAID,
    ).select_related("invoice")

    line_items = line_items.order_by(
        # sqlite requires an order_by for .values() to work
        "-price", "description",
    ).values(
        "price", "description",
    ).annotate(
        total_quantity=Sum("quantity"),
    )

    headings = ["Description", "Quantity", "Price", "Total"]

    data = []
    total_income = 0
    for line in line_items:
        cost = line["total_quantity"] * line["price"]
        data.append([
            line["description"], line["total_quantity"],
            line["price"], cost,
        ])
        total_income += cost

    data.append([
        "(TOTAL)", "--", "--", total_income,
    ])

    return ListReport("Items sold", headings, data)

def sales_payment_summary():
    ''' Summarises paid items and payments. '''

    def value_or_zero(aggregate, key):
        return aggregate[key] or 0

    def sum_amount(payment_set):
        a = payment_set.values("amount").aggregate(total=Sum("amount"))
        return value_or_zero(a, "total")

    headings = ["Category", "Total"]
    data = []

    # Summarise all sales made (= income.)
    sales = commerce.LineItem.objects.filter(
        invoice__status=commerce.Invoice.STATUS_PAID,
    ).values(
        "price", "quantity"
    ).aggregate(
        total=Sum(F("price") * F("quantity"), output_field=CURRENCY()),
    )
    sales = value_or_zero(sales, "total")

    all_payments = sum_amount(commerce.PaymentBase.objects.all())

    # Manual payments
    # Credit notes generated (total)
    # Payments made by credit note
    # Claimed credit notes

    all_credit_notes = 0 - sum_amount(commerce.CreditNote.objects.all())
    unclaimed_credit_notes = 0 - sum_amount(commerce.CreditNote.unclaimed())
    claimed_credit_notes = sum_amount(
        commerce.CreditNoteApplication.objects.all()
    )
    refunded_credit_notes = 0 - sum_amount(commerce.CreditNote.refunded())

    data.append(["Items on paid invoices", sales])
    data.append(["All payments", all_payments])
    data.append(["Sales - Payments ", sales - all_payments])
    data.append(["All credit notes", all_credit_notes])
    data.append(["Credit notes paid on invoices", claimed_credit_notes])
    data.append(["Credit notes refunded", refunded_credit_notes])
    data.append(["Unclaimed credit notes", unclaimed_credit_notes])
    data.append([
        "Credit notes - (claimed credit notes + unclaimed credit notes)",
        all_credit_notes - claimed_credit_notes -
        refunded_credit_notes - unclaimed_credit_notes
        ])

    return ListReport("Sales and Payments Summary", headings, data)

def payments():
    ''' Shows the history of payments into the system '''

    payments = commerce.PaymentBase.objects.all()
    return QuerysetReport(
        "Payments",
        ["invoice__id", "id", "reference", "amount"],
        payments,
        link_view=views.invoice,
    )

def credit_note_refunds():
    ''' Shows all of the credit notes that have been generated. '''
    notes_refunded = commerce.CreditNote.refunded()
    return QuerysetReport(
        "Credit note refunds",
        ["id", "creditnoterefund__reference", "amount"],
        notes_refunded,
        link_view=views.credit_note,
    )

def group_by_cart_status(queryset, order, values):
    queryset = queryset.annotate(
        is_reserved=Case(
            When(cart__in=commerce.Cart.reserved_carts(), then=Value(True)),
            default=Value(False),
            output_field=models.BooleanField(),
        ),
    )

    values = queryset.order_by(*order).values(*values)
    values = values.annotate(
        total_paid=Sum(Case(
            When(
                cart__status=commerce.Cart.STATUS_PAID,
                then=F("quantity"),
            ),
            default=Value(0),
        )),
        total_refunded=Sum(Case(
            When(
                cart__status=commerce.Cart.STATUS_RELEASED,
                then=F("quantity"),
            ),
            default=Value(0),
        )),
        total_unreserved=Sum(Case(
            When(
                (
                    Q(cart__status=commerce.Cart.STATUS_ACTIVE) &
                    Q(is_reserved=False)
                ),
                then=F("quantity"),
            ),
            default=Value(0),
        )),
        total_reserved=Sum(Case(
            When(
                (
                    Q(cart__status=commerce.Cart.STATUS_ACTIVE) &
                    Q(is_reserved=True)
                ),
                then=F("quantity"),
            ),
            default=Value(0),
        )),
    )

    return values

@report_view("Product status", form_type=forms.ProductAndCategoryForm)
def product_status(request, form):
    ''' Summarises the inventory status of the given items, grouping by
    invoice status. '''

    products = form.cleaned_data["product"]
    categories = form.cleaned_data["category"]

    items = commerce.ProductItem.objects.filter(
        Q(product__in=products) | Q(product__category__in=categories),
    ).select_related("cart", "product")

    items = group_by_cart_status(
        items,
        ["product__category__order", "product__order"],
        ["product", "product__category__name", "product__name"],
    )

    headings = [
        "Product", "Paid", "Reserved", "Unreserved", "Refunded",
    ]
    data = []

    for item in items:
        data.append([
            "%s - %s" % (
                item["product__category__name"], item["product__name"]
            ),
            item["total_paid"],
            item["total_reserved"],
            item["total_unreserved"],
            item["total_refunded"],
        ])

    return ListReport("Inventory", headings, data)

@report_view("Product status", form_type=forms.DiscountForm)
def discount_status(request, form):
    ''' Summarises the usage of a given discount. '''

    discounts = form.cleaned_data["discount"]

    items = commerce.DiscountItem.objects.filter(
        Q(discount__in=discounts),
    ).select_related("cart", "product", "product__category")

    items = group_by_cart_status(
        items,
        ["discount"],
        ["discount", "discount__description"],
    )

    headings = [
        "Discount", "Paid", "Reserved", "Unreserved", "Refunded",
    ]
    data = []

    for item in items:
        data.append([
            item["discount__description"],
            item["total_paid"],
            item["total_reserved"],
            item["total_unreserved"],
            item["total_refunded"],
        ])

    return ListReport("Usage by item", headings, data)

@report_view("Product Line Items By Date & Customer", form_type=forms.ProductAndCategoryForm)
def product_line_items(request, form):
    ''' Shows each product line item from invoices, including their date and
    purchashing customer. '''

    products = form.cleaned_data["product"]
    categories = form.cleaned_data["category"]

    invoices = commerce.Invoice.objects.filter(
        (
            Q(lineitem__product__in=products) |
            Q(lineitem__product__category__in=categories)
        ),
        status=commerce.Invoice.STATUS_PAID,
    ).select_related(
        "cart",
        "user",
        "user__attendee",
        "user__attendee__attendeeprofilebase"
    ).order_by("issue_time")

    headings = [
        'Invoice', 'Invoice Date', 'Attendee', 'Qty', 'Product', 'Status'
    ]

    data = []
    for invoice in invoices:
        for item in invoice.cart.productitem_set.all():
            if item.product in products or item.product.category in categories:
                output = []
                output.append(invoice.id)
                output.append(invoice.issue_time.strftime('%Y-%m-%d %H:%M:%S'))
                output.append(
                    invoice.user.attendee.attendeeprofilebase.attendee_name()
                )
                output.append(item.quantity)
                output.append(item.product)
                cart = invoice.cart
                if cart.status == commerce.Cart.STATUS_PAID:
                    output.append('PAID')
                elif cart.status == commerce.Cart.STATUS_ACTIVE:
                    output.append('UNPAID')
                elif cart.status == commerce.Cart.STATUS_RELEASED:
                    output.append('REFUNDED')
                data.append(output)

    return ListReport("Line Items", headings, data)

@report_view("Paid invoices by date", form_type=forms.ProductAndCategoryForm)
def paid_invoices_by_date(request, form):
    ''' Shows the number of paid invoices containing given products or
    categories per day. '''

    products = form.cleaned_data["product"]
    categories = form.cleaned_data["category"]

    invoices = commerce.Invoice.objects.filter(
        (
            Q(lineitem__product__in=products) |
            Q(lineitem__product__category__in=categories)
        ),
        status=commerce.Invoice.STATUS_PAID,
    )

    # Invoices with payments will be paid at the time of their latest payment
    payments = commerce.PaymentBase.objects.all()
    payments = payments.filter(
        invoice__in=invoices,
    )
    payments = payments.order_by("invoice")
    invoice_max_time = payments.values("invoice").annotate(
        max_time=Max("time")
    )

    # Zero-value invoices will have no payments, so they're paid at issue time
    zero_value_invoices = invoices.filter(value=0)

    times = itertools.chain(
        (line["max_time"] for line in invoice_max_time),
        (invoice.issue_time for invoice in zero_value_invoices),
    )

    by_date = collections.defaultdict(int)
    for time in times:
        date = datetime.datetime(
            year=time.year, month=time.month, day=time.day
        )
        by_date[date] += 1

    data = [(date_, count) for date_, count in sorted(by_date.items())]
    data = [(date_.strftime("%Y-%m-%d"), count) for date_, count in data]

    return ListReport(
        "Paid Invoices By Date",
        ["date", "count"],
        data,
    )

@report_view("Credit notes")
def credit_notes(request, form):
    ''' Shows all of the credit notes in the system. '''

    notes = commerce.CreditNote.objects.all().select_related(
        "creditnoterefund",
        "creditnoteapplication",
        "invoice",
        "invoice__user__attendee__attendeeprofilebase",
    )

    return QuerysetReport(
        "Credit Notes",
        ["id",
         "invoice__user__attendee__attendeeprofilebase__invoice_recipient",
         "status", "value"],
        notes,
        headings=["id", "Owner", "Status", "Value"],
        link_view=views.credit_note,
    )

@report_view("Invoices")
def invoices(request, form):
    ''' Shows all of the invoices in the system. '''

    invoices = commerce.Invoice.objects.all().order_by("status", "id")

    return QuerysetReport(
        "Invoices",
        ["id", "recipient", "value", "get_status_display"],
        invoices,
        headings=["id", "Recipient", "Value", "Status"],
        link_view=views.invoice,
    )

class AttendeeListReport(ListReport):

    def get_link(self, argument):
        return reverse(self._link_view) + "?user=%d" % int(argument)

@report_view("Attendee", form_type=forms.UserIdForm)
def attendee(request, form, user_id=None):
    ''' Returns a list of all manifested attendees if no attendee is specified,
    else displays the attendee manifest. '''

    if user_id is None and form.cleaned_data["user"] is not None:
        user_id = form.cleaned_data["user"]

    if user_id is None:
        return attendee_list(request)

    attendee = people.Attendee.objects.get(user__id=user_id)
    name = attendee.attendeeprofilebase.attendee_name()

    reports = []

    profile_data = []
    try:
        profile = people.AttendeeProfileBase.objects.get_subclass(
            attendee=attendee
        )
        fields = profile._meta.get_fields()
    except people.AttendeeProfileBase.DoesNotExist:
        fields = []

    exclude = set(["attendeeprofilebase_ptr", "id"])
    for field in fields:
        if field.name in exclude:
            # Not actually important
            continue
        if not hasattr(field, "verbose_name"):
            continue  # Not a publicly visible field
        value = getattr(profile, field.name)

        if isinstance(field, models.ManyToManyField):
            value = ", ".join(str(i) for i in value.all())

        profile_data.append((field.verbose_name, value))

    cart = CartController.for_user(attendee.user)
    reservation = cart.cart.reservation_duration + cart.cart.time_last_updated
    profile_data.append(("Current cart reserved until", reservation))

    reports.append(ListReport("Profile", ["", ""], profile_data))

    links = []
    links.append((
        reverse(views.badge, args=[user_id]),
        "View badge",
    ))
    links.append((
        reverse(views.amend_registration, args=[user_id]),
        "Amend current cart",
    ))
    links.append((
        reverse(views.extend_reservation, args=[user_id]),
        "Extend reservation",
    ))

    reports.append(Links("Actions for " + name, links))

    # Paid and pending  products
    ic = ItemController(attendee.user)
    reports.append(ListReport(
        "Paid Products",
        ["Product", "Quantity"],
        [(pq.product, pq.quantity) for pq in ic.items_purchased()],
    ))
    reports.append(ListReport(
        "Unpaid Products",
        ["Product", "Quantity"],
        [(pq.product, pq.quantity) for pq in ic.items_pending()],
    ))

    # Invoices
    invoices = commerce.Invoice.objects.filter(
        user=attendee.user,
    )
    reports.append(QuerysetReport(
        "Invoices",
        ["id", "get_status_display", "value"],
        invoices,
        headings=["Invoice ID", "Status", "Value"],
        link_view=views.invoice,
    ))

    # Credit Notes
    credit_notes = commerce.CreditNote.objects.filter(
        invoice__user=attendee.user,
    ).select_related("invoice", "creditnoteapplication", "creditnoterefund")

    reports.append(QuerysetReport(
        "Credit Notes",
        ["id", "status", "value"],
        credit_notes,
        link_view=views.credit_note,
    ))

    # All payments
    payments = commerce.PaymentBase.objects.filter(
        invoice__user=attendee.user,
    ).select_related("invoice")

    reports.append(QuerysetReport(
        "Payments",
        ["invoice__id", "id", "reference", "amount"],
        payments,
        link_view=views.invoice,
    ))

    return reports

def attendee_list(request):
    ''' Returns a list of all attendees. '''

    attendees = people.Attendee.objects.select_related(
        "attendeeprofilebase",
        "user",
    )

    profiles = AttendeeProfile.objects.filter(
        attendee__in=attendees
    ).select_related(
        "attendee", "attendee__user",
    )
    profiles_by_attendee = dict((i.attendee, i) for i in profiles)

    attendees = attendees.annotate(
        has_registered=Count(
            Q(user__invoice__status=commerce.Invoice.STATUS_PAID)
        ),
    )

    headings = [
        "User ID", "Name", "Email", "Has registered",
    ]

    data = []

    for a in attendees:
        data.append([
            a.user.id,
            (profiles_by_attendee[a].attendee_name()
                if a in profiles_by_attendee else ""),
            a.user.email,
            a.has_registered > 0,
        ])

    # Sort by whether they've registered, then ID.
    data.sort(key=lambda a: (-a[3], a[0]))

    return AttendeeListReport("Attendees", headings, data, link_view=attendee)

ProfileForm = forms.model_fields_form_factory(AttendeeProfile)

@report_view(
    "Attendees By Product/Category",
    form_type=forms.mix_form(
        forms.ProductAndCategoryForm, ProfileForm, forms.GroupByForm
    ),
)
def attendee_data(request, form, user_id=None):
    ''' Lists attendees for a given product/category selection along with
    profile data.'''

    status_display = {
        commerce.Cart.STATUS_ACTIVE: "Unpaid",
        commerce.Cart.STATUS_PAID: "Paid",
        commerce.Cart.STATUS_RELEASED: "Refunded",
    }

    output = []

    by_category = (
        form.cleaned_data["group_by"] == forms.GroupByForm.GROUP_BY_CATEGORY)

    products = form.cleaned_data["product"]
    categories = form.cleaned_data["category"]
    fields = form.cleaned_data["fields"]
    name_field = AttendeeProfile.name_field()

    items = commerce.ProductItem.objects.filter(
        Q(product__in=products) | Q(product__category__in=categories),
    ).exclude(
        cart__status=commerce.Cart.STATUS_RELEASED
    ).select_related(
        "cart", "cart__user", "product", "product__category",
    ).order_by("cart__status")

    # Add invoice nag link
    links = []
    invoice_mailout = reverse(views.invoice_mailout, args=[])
    invoice_mailout += "?" + request.META["QUERY_STRING"]
    links += [
        (invoice_mailout + "&status=1", "Send invoice reminders",),
        (invoice_mailout + "&status=2", "Send mail for paid invoices",),
    ]

    if items.count() > 0:
        output.append(Links("Actions", links))

    # Make sure we select all of the related fields
    related_fields = set(
        field for field in fields
        if isinstance(AttendeeProfile._meta.get_field(field), RelatedField)
    )

    # Get all of the relevant attendee profiles in one hit.
    profiles = AttendeeProfile.objects.filter(
        attendee__user__cart__productitem__in=items
    ).select_related("attendee__user").prefetch_related(*related_fields)
    by_user = {}
    for profile in profiles:
        by_user[profile.attendee.user] = profile

    cart = "attendee__user__cart"
    cart_status = cart + "__status"  # noqa
    product = cart + "__productitem__product"
    product_name = product + "__name"
    category = product + "__category"
    category_name = category + "__name"

    if by_category:
        grouping_fields = (category, category_name)
        order_by = (category, )
        first_column = "Category"
        group_name = lambda i: "%s" % (i[category_name], )  # noqa
    else:
        grouping_fields = (product, product_name, category_name)
        order_by = (category, )
        first_column = "Product"
        group_name = lambda i: "%s - %s" % (i[category_name], i[product_name])  # noqa

    # Group the responses per-field.
    for field in fields:
        concrete_field = AttendeeProfile._meta.get_field(field)
        field_verbose = concrete_field.verbose_name

        # Render the correct values for related fields
        if field in related_fields:
            # Get all of the IDs that will appear
            all_ids = profiles.order_by(field).values(field)
            all_ids = [i[field] for i in all_ids if i[field] is not None]
            # Get all of the concrete objects for those IDs
            model = concrete_field.related_model
            all_objects = model.objects.filter(id__in=all_ids)
            all_objects_by_id = dict((i.id, i) for i in all_objects)

            # Define a function to render those IDs.
            def display_field(value):
                if value in all_objects_by_id:
                    return all_objects_by_id[value]
                else:
                    return None
        else:
            def display_field(value):
                return value

        status_count = lambda status: Case(When(  # noqa
                attendee__user__cart__status=status,
                then=Value(1),
            ),
            default=Value(0),
            output_field=models.fields.IntegerField(),
        )
        paid_count = status_count(commerce.Cart.STATUS_PAID)
        unpaid_count = status_count(commerce.Cart.STATUS_ACTIVE)

        groups = profiles.order_by(
            *(order_by + (field, ))
        ).values(
            *(grouping_fields + (field, ))
        ).annotate(
            paid_count=Sum(paid_count),
            unpaid_count=Sum(unpaid_count),
        )
        output.append(ListReport(
            "Grouped by %s" % field_verbose,
            [first_column, field_verbose, "paid", "unpaid"],
            [
                (
                    group_name(group),
                    display_field(group[field]),
                    group["paid_count"] or 0,
                    group["unpaid_count"] or 0,
                )
                for group in groups
            ],
        ))

    # DO the report for individual attendees

    field_names = [
        AttendeeProfile._meta.get_field(field).verbose_name for field in fields
    ]

    def display_field(profile, field):
        field_type = AttendeeProfile._meta.get_field(field)
        attr = getattr(profile, field)

        if isinstance(field_type, models.ManyToManyField):
            return [str(i) for i in attr.all()] or ""
        else:
            return attr

    headings = ["User ID", "Name", "Email", "Product", "Item Status"]
    headings.extend(field_names)
    data = []
    for item in items:
        profile = by_user[item.cart.user]
        line = [
            item.cart.user.id,
            getattr(profile, name_field),
            profile.attendee.user.email,
            item.product,
            status_display[item.cart.status],
        ] + [
            display_field(profile, field) for field in fields
        ]
        data.append(line)

    output.append(AttendeeListReport(
        "Attendees by item with profile data", headings, data,
        link_view=attendee
    ))
    return output

@report_view(
    "Speaker Registration Status",
    form_type=forms.ProposalKindForm,
)
def speaker_registrations(request, form):
    ''' Shows registration status for speakers with a given proposal kind. '''

    kinds = form.cleaned_data["kind"]

    presentations = schedule_models.Presentation.objects.filter(
        proposal_base__kind__in=kinds,
    ).exclude(
        cancelled=True,
    )

    users = User.objects.filter(
        Q(speaker_profile__presentations__in=presentations) |
        Q(speaker_profile__copresentations__in=presentations)
    )

    paid_carts = commerce.Cart.objects.filter(status=commerce.Cart.STATUS_PAID)

    paid_carts = Case(
        When(cart__in=paid_carts, then=Value(1)),
        default=Value(0),
        output_field=models.IntegerField(),
    )
    users = users.annotate(paid_carts=Sum(paid_carts))
    users = users.order_by("paid_carts")

    return QuerysetReport(
        "Speaker Registration Status",
        ["id", "speaker_profile__name", "email", "paid_carts"],
        users,
        link_view=attendee,
    )

    return []

@report_view(
    "Manifest",
    forms.ProductAndCategoryForm,
)
def manifest(request, form):
    '''
    Produces the registration manifest for people with the given product
    type.
    '''

    products = form.cleaned_data["product"]
    categories = form.cleaned_data["category"]

    line_items = (
        Q(lineitem__product__in=products) |
        Q(lineitem__product__category__in=categories)
    )

    invoices = commerce.Invoice.objects.filter(
        line_items,
        status=commerce.Invoice.STATUS_PAID,
    ).select_related(
        "cart",
        "user",
        "user__attendee",
        "user__attendee__attendeeprofilebase"
    )

    users = set(i.user for i in invoices)

    carts = commerce.Cart.objects.filter(
        user__in=users
    )

    items = commerce.ProductItem.objects.filter(
        cart__in=carts
    ).select_related(
        "product",
        "product__category",
        "cart",
        "cart__user",
        "cart__user__attendee",
        "cart__user__attendee__attendeeprofilebase"
    ).order_by("product__category__order", "product__order")

    users = {}

    for item in items:
        cart = item.cart
        if cart.user not in users:
            users[cart.user] = {"unpaid": [], "paid": [], "refunded": []}
        items = users[cart.user]
        if cart.status == commerce.Cart.STATUS_ACTIVE:
            items["unpaid"].append(item)
        elif cart.status == commerce.Cart.STATUS_PAID:
            items["paid"].append(item)
        elif cart.status == commerce.Cart.STATUS_RELEASED:
            items["refunded"].append(item)

    users_by_name = list(users.keys())
    users_by_name.sort(key=(
        lambda i: i.attendee.attendeeprofilebase.attendee_name().lower()
    ))

    headings = ["User ID", "Name", "Paid", "Unpaid", "Refunded"]

    def format_items(item_list):
        strings = [
            '%d x %s' % (item.quantity, str(item.product))
            for item in item_list
        ]
        return ", \n".join(strings)

    output = []
    for user in users_by_name:
        items = users[user]
        output.append([
            user.id,
            user.attendee.attendeeprofilebase.attendee_name(),
            format_items(items["paid"]),
            format_items(items["unpaid"]),
            format_items(items["refunded"]),
        ])

    return ListReport("Manifest", headings, output)

    # attendeeprofilebase.attendee_name()


from registrasion.models import commerce
from registrasion.controllers.category import CategoryController
from registrasion.controllers.item import ItemController

from django import template
from django.conf import settings
from django.db.models import Sum
from urllib import urlencode  # TODO: s/urllib/six.moves.urllib/

register = template.Library()

def user_for_context(context):
    ''' Returns either context.user or context.request.user if the former is
    not defined. '''
    try:
        return context["user"]
    except KeyError:
        return context.request.user

@register.assignment_tag(takes_context=True)
def available_categories(context):
    ''' Gets all of the currently available products.

    Returns:
        [models.inventory.Category, ...]: A list of all of the categories that
            have Products that the current user can reserve.

    '''
    return CategoryController.available_categories(user_for_context(context))

@register.assignment_tag(takes_context=True)
def missing_categories(context):
    ''' Adds the categories that the user does not currently have. '''
    user = user_for_context(context)
    categories_available = set(CategoryController.available_categories(user))
    items = ItemController(user).items_pending_or_purchased()

    categories_held = set()

    for product, quantity in items:
        categories_held.add(product.category)

    return categories_available - categories_held

@register.assignment_tag(takes_context=True)
def available_credit(context):
    ''' Calculates the sum of unclaimed credit from this user's credit notes.

    Returns:
        Decimal: the sum of the values of unclaimed credit notes for the
            current user.

    '''

    notes = commerce.CreditNote.unclaimed().filter(
        invoice__user=user_for_context(context),
    )
    ret = notes.values("amount").aggregate(Sum("amount"))["amount__sum"] or 0
    return 0 - ret

@register.assignment_tag(takes_context=True)
def invoices(context):
    '''

    Returns:
        [models.commerce.Invoice, ...]: All of the current user's invoices. '''
    return commerce.Invoice.objects.filter(user=user_for_context(context))

@register.assignment_tag(takes_context=True)
def items_pending(context):
    ''' Gets all of the items that the user from this context has reserved.

    The user will be either `context.user`, and `context.request.user` if
    the former is not defined.
    '''

    return ItemController(user_for_context(context)).items_pending()

@register.assignment_tag(takes_context=True)
def items_purchased(context, category=None):
    ''' Returns the items purchased for this user.

    The user will be either `context.user`, and `context.request.user` if
    the former is not defined.
    '''

    return ItemController(user_for_context(context)).items_purchased(
        category=category
    )

@register.assignment_tag(takes_context=True)
def total_items_purchased(context, category=None):
    ''' Returns the number of items purchased for this user (sum of quantities).

    The user will be either `context.user`, and `context.request.user` if
    the former is not defined.
    '''

    return sum(i.quantity for i in items_purchased(context, category))

@register.assignment_tag(takes_context=True)
def report_as_csv(context, section):

    old_query = context.request.META["QUERY_STRING"]
    query = dict([("section", section), ("content_type", "text/csv")])
    querystring = urlencode(query)

    if old_query:
        querystring = old_query + "&" + querystring

    return context.request.path + "?" + querystring

@register.assignment_tag(takes_context=True)
def sold_out_and_unregistered(context):
    ''' If the current user is unregistered, returns True if there are no
    products in the TICKET_PRODUCT_CATEGORY that are available to that user.

    If there *are* products available, the return False.

    If the current user *is* registered, then return None (it's not a
    pertinent question for people who already have a ticket).

    '''

    user = user_for_context(context)
    if hasattr(user, "attendee") and user.attendee.completed_registration:
        # This user has completed registration, and so we don't need to answer
        # whether they have sold out yet.

        # TODO: what if a user has got to the review phase?
        # currently that user will hit the review page, click "Check out and
        # pay", and that will fail. Probably good enough for now.

        return None

    ticket_category = settings.TICKET_PRODUCT_CATEGORY
    categories = available_categories(context)

    return ticket_category not in [cat.id for cat in categories]

class IncludeNode(template.Node):
    ''' https://djangosnippets.org/snippets/2058/ '''

    def __init__(self, template_name):
        # template_name as passed in includes quotmarks?
        # strip them from the start and end
        self.template_name = template_name[1:-1]

    def render(self, context):
        try:
            # Loading the template and rendering it
            return template.loader.render_to_string(
                self.template_name, context=context,
            )
        except template.TemplateDoesNotExist:
            return ""

@register.simple_tag
def template_exists(template_name):
    try:
        template.loader.get_template(template_name)
        return True
    except template.TemplateDoesNotExist:
        return False

@register.tag
def include_if_exists(parser, token):
    """Usage: {% include_if_exists "head.html" %}

    This will fail silently if the template doesn't exist. If it does, it will
    be rendered with the current context.

    From: https://djangosnippets.org/snippets/2058/
    """
    try:
        tag_name, template_name = token.split_contents()
    except ValueError:
        raise template.TemplateSyntaxError, \
            "%r tag requires a single argument" % token.contents.split()[0]

    return IncludeNode(template_name)

@register.filter
def addstr(arg1, arg2):
    """concatenate arg1 & arg2"""
    return str(arg1) + str(arg2)