Source code for plans.views

from decimal import Decimal

from django.core.urlresolvers import reverse
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import get_object_or_404
from django.views.generic import TemplateView, RedirectView, CreateView, UpdateView, View
from django.db.models import Q
from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden
from django.conf import settings
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _
from django.views.generic.detail import DetailView, SingleObjectMixin
from django.views.generic.edit import DeleteView, ModelFormMixin, FormView
from django.views.generic.list import ListView

from itertools import chain
from plans.importer import import_name
from plans.mixins import LoginRequired
from plans.models import UserPlan, PlanPricing, Plan, Order, BillingInfo
from plans.forms import CreateOrderForm, BillingInfoForm, FakePaymentsForm
from plans.models import Quota, Invoice
from plans.signals import order_started
from plans.validators import plan_validation


class AccountActivationView(LoginRequired, TemplateView):
    template_name = 'plans/account_activation.html'

    def get_context_data(self, **kwargs):
        if self.request.user.userplan.active == True or self.request.user.userplan.is_expired():
            raise Http404()

        context = super(AccountActivationView, self).get_context_data(**kwargs)
        errors = self.request.user.userplan.clean_activation()

        if errors['required_to_activate']:
            context['SUCCESSFUL'] = False
        else:
            context['SUCCESSFUL'] = True
            messages.success(self.request, _("Your account is now active"))

        for error in errors['required_to_activate']:
            messages.error(self.request, error)
        for error in errors['other']:
            messages.warning(self.request, error)

        return context


class PlanTableMixin(object):
    def get_plan_table(self, plan_list):
        """
        This method return a list in following order:
        [
            ( Quota1, [ Plan1Quota1, Plan2Quota1, ... , PlanNQuota1] ),
            ( Quota2, [ Plan1Quota2, Plan2Quota2, ... , PlanNQuota2] ),
            ...
            ( QuotaM, [ Plan1QuotaM, Plan2QuotaM, ... , PlanNQuotaM] ),
        ]

        This can be very easily printed as an HTML table element with quotas by row.

        Quotas are calculated based on ``plan_list``. These are all available quotas that are
        used by given plans. If any ``Plan`` does not have any of ``PlanQuota`` then value ``None``
        will be propagated to the data structure.

        """

        # Retrieve all quotas that are used by any ``Plan`` in ``plan_list``
        quota_list = Quota.objects.all().filter(planquota__plan__in=plan_list).distinct()

        # Create random access dict that for every ``Plan`` map ``Quota`` -> ``PlanQuota``
        plan_quotas_dic = {}
        for plan in plan_list:
            plan_quotas_dic[plan] = {}
            for plan_quota in plan.planquota_set.all():
                plan_quotas_dic[plan][plan_quota.quota] = plan_quota

        # Generate data structure described in method docstring, propagate ``None`` whenever
        # ``PlanQuota`` is not available for given ``Plan`` and ``Quota``
        return map(lambda quota: (quota,
                                  map(lambda plan: plan_quotas_dic[plan].get(quota, None), plan_list)

        ), quota_list)


class PlanTableViewBase(PlanTableMixin, ListView):
    model = Plan
    context_object_name = "plan_list"

    def get_queryset(self):
        queryset = super(PlanTableViewBase, self).get_queryset().prefetch_related('planpricing_set__pricing',
                                                                                  'planquota_set__quota')
        if self.request.user.is_authenticated():
            queryset = queryset.filter(
                Q(available=True, visible=True) & (
                    Q(customized=self.request.user) | Q(customized__isnull=True)
                )
            )
        else:
            queryset = queryset.filter(Q(available=True, visible=True) & Q(customized__isnull=True))
        return queryset

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

        if self.request.user.is_authenticated():
            try:
                self.userplan = UserPlan.objects.select_related('plan').get(user=self.request.user)
            except UserPlan.DoesNotExist:
                self.userplan = None

            context['userplan'] = self.userplan

            try:
                context['current_userplan_index'] = list(self.object_list).index(self.userplan.plan)
            except (ValueError, AttributeError):
                pass

        context['plan_table'] = self.get_plan_table(self.object_list)
        context['CURRENCY'] = settings.PLANS_CURRENCY

        return context


class CurrentPlanView(LoginRequired, PlanTableViewBase):
    template_name = "plans/current.html"

    def get_queryset(self):
        return Plan.objects.filter(userplan__user=self.request.user).prefetch_related('planpricing_set__pricing',
                                                                                      'planquota_set__quota')


class UpgradePlanView(LoginRequired, PlanTableViewBase):
    template_name = "plans/upgrade.html"


class PricingView(PlanTableViewBase):
    template_name = "plans/pricing.html"


class ChangePlanView(LoginRequired, View):
    """
    A view for instant changing user plan when it does not require additional payment.
    Plan can be changed without payment when:
    * user can enable this plan (it is available & visible and if it is customized it is for him,
    * plan is different from the current one that user have,
    * within current change plan policy this does not require any additional payment (None)

    It always redirects to ``upgrade_plan`` url as this is a potential only one place from
    where change plan could be invoked.
    """

    def get(self, request, *args, **kwargs):
        return HttpResponseRedirect(reverse('upgrade_plan'))

    def post(self, request, *args, **kwargs):
        plan = get_object_or_404(Plan, Q(pk=kwargs['pk']) & Q(available=True, visible=True) & (
            Q(customized=request.user) | Q(customized__isnull=True)))
        if request.user.userplan.plan != plan:
            policy = import_name(
                getattr(settings, 'PLANS_CHANGE_POLICY', 'plans.plan_change.StandardPlanChangePolicy'))()

            period = request.user.userplan.days_left()
            price = policy.get_change_price(request.user.userplan.plan, plan, period)

            if price is None:
                request.user.userplan.extend_account(plan, None)
                messages.success(request, _("Your plan has been successfully changed"))
            else:
                return HttpResponseForbidden()
        return HttpResponseRedirect(reverse('upgrade_plan'))


class CreateOrderView(LoginRequired, CreateView):
    template_name = "plans/create_order.html"
    form_class = CreateOrderForm

    def recalculate(self, amount, billing_info):
        """
        Calculates and return pre-filled Order
        """
        order = Order(pk=-1)
        order.amount = amount
        order.currency = self.get_currency()
        country = getattr(billing_info, 'country', None)
        if not country is None:
            country = country.code
        tax_number = getattr(billing_info, 'tax_number', None)

        # Calculating tax can be complex task (e.g. VIES webservice call)
        # To ensure that tax calculated on order preview will be the same on final order
        # tax rate is cached for a given billing data (as this value only depends on it)
        tax_session_key = "tax_%s_%s" % (tax_number, country)

        tax = self.request.session.get(tax_session_key)
        if tax is None:
            taxation_policy = getattr(settings, 'PLANS_TAXATION_POLICY', None)
            if not taxation_policy:
                raise ImproperlyConfigured('PLANS_TAXATION_POLICY is not set')
            taxation_policy = import_name(taxation_policy)
            tax = str(taxation_policy.get_tax_rate(tax_number, country))
            # Because taxation policy could return None which clutters with saving this value
            # into cache, we use str() representation of this value
            self.request.session[tax_session_key] = tax

        order.tax = Decimal(tax) if tax != 'None' else None

        return order

    def validate_plan(self, plan):
        validation_errors = plan_validation(self.request.user, plan)
        if validation_errors['required_to_activate'] or validation_errors['other']:
            messages.error(self.request, _(
                "The selected plan is insufficient for your account. "
                "Your account will not be activated or will not work fully after completing this order."
                "<br><br>Following limits will be exceeded: <ul><li>%(reasons)s</ul>") % {
                                             'reasons': '<li>'.join(chain(validation_errors['required_to_activate'],
                                                                          validation_errors['other'])),
                                         })


    def get_all_context(self):
        """
        Retrieves Plan and Pricing for current order creation
        """
        self.plan_pricing = get_object_or_404(PlanPricing.objects.all().select_related('plan', 'pricing'),
                                              Q(pk=self.kwargs['pk']) & Q(plan__available=True) & (
                                                  Q(plan__customized=self.request.user) | Q(
                                                      plan__customized__isnull=True)))


        # User is not allowed to create new order for Plan when he has different Plan
        # He should use Plan Change View for this kind of action
        if not self.request.user.userplan.is_expired() and self.request.user.userplan.plan != self.plan_pricing.plan:
            raise Http404

        self.plan = self.plan_pricing.plan
        self.pricing = self.plan_pricing.pricing


    def get_billing_info(self):
        try:
            return self.request.user.billinginfo
        except BillingInfo.DoesNotExist:
            return None

    def get_currency(self):
        CURRENCY = getattr(settings, 'PLANS_CURRENCY', '')
        if len(CURRENCY) != 3:
            raise ImproperlyConfigured('PLANS_CURRENCY should be configured as 3-letter currency code.')
        return CURRENCY

    def get_price(self):
        return self.plan_pricing.price

    def get_context_data(self, **kwargs):
        context = super(CreateOrderView, self).get_context_data(**kwargs)
        self.get_all_context()
        context['billing_info'] = self.get_billing_info()

        order = self.recalculate(self.plan_pricing.price, context['billing_info'])
        order.plan = self.plan_pricing.plan
        order.pricing = self.plan_pricing.pricing
        order.currency = self.get_currency()
        context['object'] = order

        self.validate_plan(order.plan)
        return context

    def form_valid(self, form):
        self.get_all_context()
        order = self.recalculate(self.get_price() or Decimal('0.0'), self.get_billing_info())

        self.object = form.save(commit=False)
        self.object.user = self.request.user
        self.object.plan = self.plan
        self.object.pricing = self.pricing
        self.object.amount = order.amount
        self.object.tax = order.tax
        self.object.currency = order.currency
        self.object.save()
        order_started.send(sender=self.object)
        return super(ModelFormMixin, self).form_valid(form)


class CreateOrderPlanChangeView(CreateOrderView):
    template_name = "plans/create_order.html"
    form_class = CreateOrderForm

    def get_all_context(self):
        self.plan = get_object_or_404(Plan, Q(pk=self.kwargs['pk']) & Q(available=True, visible=True) & (
            Q(customized=self.request.user) | Q(customized__isnull=True)))
        self.pricing = None

    def get_policy(self):
        policy_class = getattr(settings, 'PLANS_CHANGE_POLICY', 'plans.plan_change.StandardPlanChangePolicy')
        return import_name(policy_class)()

    def get_price(self):
        policy = self.get_policy()
        period = self.request.user.userplan.days_left()
        return policy.get_change_price(self.request.user.userplan.plan, self.plan, period)

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

        price = self.get_price()
        context['plan'] = self.plan
        context['billing_info'] = self.get_billing_info()
        if price is None:
            context['FREE_ORDER'] = True
            price = 0
        order = self.recalculate(price, context['billing_info'])
        order.pricing = None
        order.plan = self.plan
        context['billing_info'] = context['billing_info']
        context['object'] = order
        self.validate_plan(order.plan)
        return context


class OrderView(LoginRequired, DetailView):
    model = Order


    def get_queryset(self):
        return super(OrderView, self).get_queryset().filter(user=self.request.user).select_related('plan', 'pricing', )


class OrderListView(LoginRequired, ListView):
    model = Order
    paginate_by = 10

    def get_context_data(self, **kwargs):
        context = super(OrderListView, self).get_context_data(**kwargs)
        self.CURRENCY = getattr(settings, 'PLANS_CURRENCY', None)
        if len(self.CURRENCY) != 3:
            raise ImproperlyConfigured('PLANS_CURRENCY should be configured as 3-letter currency code.')
        context['CURRENCY'] = self.CURRENCY
        return context


    def get_queryset(self):
        return super(OrderListView, self).get_queryset().filter(user=self.request.user).select_related('plan',
                                                                                                       'pricing', )


class OrderPaymentReturnView(LoginRequired, DetailView):
    """
    This view is a fallback from any payments processor. It allows just to set additional message
    context and redirect to Order view itself.
    """
    model = Order
    status = None

    def render_to_response(self, context, **response_kwargs):
        if self.status == 'success':
            messages.success(self.request,
                             _('Thank you for placing a payment. It will be processed as soon as possible.'))
        elif self.status == 'failure':
            messages.error(self.request, _('Payment was not completed correctly. Please repeat payment process.'))

        return HttpResponseRedirect(self.object.get_absolute_url())


    def get_queryset(self):
        return super(OrderPaymentReturnView, self).get_queryset().filter(user=self.request.user)


[docs]class BillingInfoRedirectView(LoginRequired, RedirectView): """ Checks if billing data for user exists and redirects to create or update view. """ permanent = False def get_redirect_url(self, **kwargs): try: BillingInfo.objects.get(user=self.request.user) except BillingInfo.DoesNotExist: return reverse('billing_info_create') return reverse('billing_info_update')
[docs]class BillingInfoCreateView(LoginRequired, CreateView): """ Creates billing data for user """ form_class = BillingInfoForm template_name = 'plans/billing_info_create.html' def form_valid(self, form): self.object = form.save(commit=False) self.object.user = self.request.user self.object.save() return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): messages.success(self.request, _('Billing info has been updated successfuly.')) return reverse('billing_info_update')
[docs]class BillingInfoUpdateView(LoginRequired, UpdateView): """ Updates billing data for user """ model = BillingInfo form_class = BillingInfoForm template_name = 'plans/billing_info_update.html' def get_object(self): try: return self.request.user.billinginfo except BillingInfo.DoesNotExist: raise Http404 def get_success_url(self): messages.success(self.request, _('Billing info has been updated successfuly.')) return reverse('billing_info_update')
[docs]class BillingInfoDeleteView(LoginRequired, DeleteView): """ Deletes billing data for user """ template_name = 'plans/billing_info_delete.html' def get_object(self): try: return self.request.user.billinginfo except BillingInfo.DoesNotExist: raise Http404 def get_success_url(self): messages.success(self.request, _('Billing info has been deleted.')) return reverse('billing_info_create')
class InvoiceDetailView(LoginRequired, DetailView): model = Invoice def get_template_names(self): return getattr(settings, 'PLANS_INVOICE_TEMPLATE', 'plans/invoices/PL_EN.html') def get_context_data(self, **kwargs): context = super(InvoiceDetailView, self).get_context_data(**kwargs) context['logo_url'] = getattr(settings, 'PLANS_INVOICE_LOGO_URL', None) context['auto_print'] = True return context def get_queryset(self): if self.request.user.is_superuser: return super(InvoiceDetailView, self).get_queryset().select_related('order') else: return super(InvoiceDetailView, self).get_queryset().filter(user=self.request.user).select_related('order') class FakePaymentsView(LoginRequired, SingleObjectMixin, FormView): form_class = FakePaymentsForm model = Order template_name = 'plans/fake_payments.html' def get_success_url(self): return self.object.get_absolute_url() def get_queryset(self): return super(FakePaymentsView, self).get_queryset().filter(user=self.request.user) def dispatch(self, *args, **kwargs): if not getattr(settings, 'DEBUG', False): return HttpResponseForbidden('This view is accessible only in debug mode.') self.object = self.get_object() return super(FakePaymentsView, self).dispatch(*args, **kwargs) def form_valid(self, form): if int(form['status'].value()) == Order.STATUS.COMPLETED: self.object.complete_order() return HttpResponseRedirect(reverse('order_payment_success', kwargs={'pk': self.object.pk})) else: self.object.status = form['status'].value() self.object.save() return HttpResponseRedirect(reverse('order_payment_failure', kwargs={'pk': self.object.pk}))