Source code for plans.models

from __future__ import unicode_literals

from decimal import Decimal
import re
from datetime import date, timedelta, datetime
import logging

from django.contrib.sites.models import Site
from django.db.models import Max
from django.utils import translation
from django.utils.encoding import python_2_unicode_compatible
from django.utils.timezone import now
from django_countries.fields import CountryField
from django.core.urlresolvers import reverse
from django.template.base import Template
from django.utils.translation import ugettext_lazy as _, pgettext_lazy
from django.db import models
from ordered_model.models import OrderedModel
import vatnumber
from django.template import Context
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, ValidationError

from plans.contrib import send_template_email, get_user_language
from plans.enum import Enumeration
from plans.signals import order_completed, account_activated, account_expired, account_change_plan, account_deactivated
from .validators import plan_validation
from plans.taxation.eu import EUTaxationPolicy


accounts_logger = logging.getLogger('accounts')

# Create your models here.

@python_2_unicode_compatible
class Plan(OrderedModel):
    """
    Single plan defined in the system. A plan can customized (referred to user) which means
    that only this user can purchase this plan and have it selected.

    Plan also can be visible and available. Plan is displayed on the list of currently available plans
    for user if it is visible. User cannot change plan to a plan that is not visible. Available means
    that user can buy a plan. If plan is not visible but still available it means that user which
    is using this plan already will be able to extend this plan again. If plan is not visible and not
    available, he will be forced then to change plan next time he extends an account.
    """
    name = models.CharField(_('name'), max_length=100)
    description = models.TextField(_('description'), blank=True)
    default = models.BooleanField(default=False, db_index=True)
    available = models.BooleanField(_('available'), default=False, db_index=True,
                                    help_text=_('Is still available for purchase'))
    visible = models.BooleanField(_('visible'), default=True, db_index=True, help_text=_('Is visible in current offer'))
    created = models.DateTimeField(_('created'), db_index=True)
    customized = models.ForeignKey('auth.User', null=True, blank=True, verbose_name=_('customized'))
    quotas = models.ManyToManyField('Quota', through='PlanQuota', verbose_name=_('quotas'))
    url = models.CharField(max_length=200, blank=True, help_text=_(
        'Optional link to page with more information (for clickable pricing table headers)'))

    class Meta:
        ordering = ('order',)
        verbose_name = _("Plan")
        verbose_name_plural = _("Plans")

    def save(self, *args, **kwargs):
        if not self.created:
            self.created = now()

        super(Plan, self).save(*args, **kwargs)

    @classmethod
    def get_default_plan(cls):
        try:
            return cls.objects.filter(default=True)[0]
        except IndexError:
            return None

    def __str__(self):
        return self.name

    def get_quota_dict(self):
        quota_dic = {}
        for plan_quota in PlanQuota.objects.filter(plan=self).select_related('quota'):
            quota_dic[plan_quota.quota.codename] = plan_quota.value
        return quota_dic


[docs]class BillingInfo(models.Model): """ Stores customer billing data needed to issue an invoice """ user = models.OneToOneField('auth.User', verbose_name=_('user')) tax_number = models.CharField(_('VAT ID'), max_length=200, blank=True, db_index=True) name = models.CharField(_('name'), max_length=200, db_index=True) street = models.CharField(_('street'), max_length=200) zipcode = models.CharField(_('zip code'), max_length=200) city = models.CharField(_('city'), max_length=200) country = CountryField(_("country")) shipping_name = models.CharField(_('name (shipping)'), max_length=200, blank=True, help_text=_('optional')) shipping_street = models.CharField(_('street (shipping)'), max_length=200, blank=True, help_text=_('optional')) shipping_zipcode = models.CharField(_('zip code (shipping)'), max_length=200, blank=True, help_text=_('optional')) shipping_city = models.CharField(_('city (shipping)'), max_length=200, blank=True, help_text=_('optional')) class Meta: verbose_name = _("Billing info") verbose_name_plural = _("Billing infos") @staticmethod def clean_tax_number(tax_number, country): tax_number = re.sub(r'[^A-Z0-9]', '', tax_number.upper()) if tax_number and country: if country in vatnumber.countries(): number = tax_number if tax_number.startswith(country): number = tax_number[len(country):] if not vatnumber.check_vat(country + number): # This is a proper solution to bind ValidationError to a Field but it is not # working due to django bug :( # errors = defaultdict(list) # errors['tax_number'].append(_('VAT ID is not correct')) # raise ValidationError(errors) raise ValidationError(_('VAT ID is not correct')) return tax_number else: return ''
# FIXME: How to make validation in Model clean and attach it to a field? Seems that it is not working right now # def clean(self): # super(BillingInfo, self).clean() # self.tax_number = BillingInfo.clean_tax_number(self.tax_number, self.country) @python_2_unicode_compatible class UserPlan(models.Model): """ Currently selected plan for user account. """ user = models.OneToOneField('auth.User', verbose_name=_('user')) plan = models.ForeignKey('Plan', verbose_name=_('plan')) expire = models.DateField(_('expire'), default=None, blank=True, null=True, db_index=True) active = models.BooleanField(_('active'), default=True, db_index=True) class Meta: verbose_name = _("User plan") verbose_name_plural = _("Users plans") def __str__(self): return "%s [%s]" % (self.user, self.plan) def is_active(self): return self.active def is_expired(self): if self.expire is None: return False else: return self.expire < date.today() def days_left(self): if self.expire is None: return None else: return (self.expire - date.today()).days def clean_activation(self): errors = plan_validation(self.user) if not errors['required_to_activate']: plan_validation(self.user, on_activation=True) self.activate() else: self.deactivate() return errors def activate(self): if not self.active: self.active = True self.save() account_activated.send(sender=self, user=self.user) def deactivate(self): if self.active: self.active = False self.save() account_deactivated.send(sender=self, user=self.user) def initialize(self): """ Set up user plan for first use """ if not self.is_active(): if self.expire is None: self.expire = now() + timedelta( days=getattr(settings, 'PLANS_DEFAULT_GRACE_PERIOD', 30)) self.activate() # this will call self.save() def extend_account(self, plan, pricing): """ Manages extending account after plan or pricing order :param plan: :param pricing: if pricing is None then account will be only upgraded :return: """ status = False # flag; if extending account was successful? if pricing is None: # Process a plan change request (downgrade or upgrade) # No account activation or extending at this point self.plan = plan self.save() account_change_plan.send(sender=self, user=self.user) mail_context = Context({'user': self.user, 'userplan': self, 'plan': plan}) send_template_email([self.user.email], 'mail/change_plan_title.txt', 'mail/change_plan_body.txt', mail_context, get_user_language(self.user)) accounts_logger.info( "Account '%s' [id=%d] plan changed to '%s' [id=%d]" % (self.user, self.user.pk, plan, plan.pk)) status = True else: # Processing standard account extending procedure if self.plan == plan: status = True if self.expire is None: pass elif self.expire > date.today(): self.expire += timedelta(days=pricing.period) else: self.expire = date.today() + timedelta(days=pricing.period) else: # This should not ever happen (as this case should be managed by plan change request) # but just in case we consider a case when user has a different plan if self.expire is None: status = True elif self.expire > date.today(): status = False accounts_logger.warning("Account '%s' [id=%d] plan NOT changed to '%s' [id=%d]" % ( self.user, self.user.pk, plan, plan.pk)) else: status = True account_change_plan.send(sender=self, user=self.user) self.plan = plan self.expire = date.today() + timedelta(days=pricing.period) if status: self.save() accounts_logger.info("Account '%s' [id=%d] has been extended by %d days using plan '%s' [id=%d]" % ( self.user, self.user.pk, pricing.period, plan, plan.pk)) mail_context = Context({'user': self.user, 'userplan': self, 'plan': plan, 'pricing': pricing}) send_template_email([self.user.email], 'mail/extend_account_title.txt', 'mail/extend_account_body.txt', mail_context, get_user_language(self.user)) if status: self.clean_activation() return status def expire_account(self): """manages account expiration""" self.deactivate() accounts_logger.info("Account '%s' [id=%d] has expired" % (self.user, self.user.pk)) mail_context = Context({'user': self.user, 'userplan': self}) send_template_email([self.user.email], 'mail/expired_account_title.txt', 'mail/expired_account_body.txt', mail_context, get_user_language(self.user)) account_expired.send(sender=self, user=self.user) def remind_expire_soon(self): """reminds about soon account expiration""" mail_context = Context({'user': self.user, 'userplan': self, 'days': self.days_left()}) send_template_email([self.user.email], 'mail/remind_expire_title.txt', 'mail/remind_expire_body.txt', mail_context, get_user_language(self.user)) @python_2_unicode_compatible class Pricing(models.Model): """ Type of plan period that could be purchased (e.g. 10 days, month, year, etc) """ name = models.CharField(_('name'), max_length=100) period = models.PositiveIntegerField(_('period'), default=30, null=True, blank=True, db_index=True) url = models.CharField(max_length=200, blank=True, help_text=_( 'Optional link to page with more information (for clickable pricing table headers)')) class Meta: ordering = ('period',) verbose_name = _("Pricing") verbose_name_plural = _("Pricings") def __str__(self): return "%s (%d " % (self.name, self.period) + "%s)" % _("days") @python_2_unicode_compatible class Quota(OrderedModel): """ Single countable or boolean property of system (limitation). """ codename = models.CharField(_('codename'), max_length=50, unique=True, db_index=True) name = models.CharField(_('name'), max_length=100) unit = models.CharField(_('unit'), max_length=100, blank=True) description = models.TextField(_('description'), blank=True) is_boolean = models.BooleanField(_('is boolean'), default=False) url = models.CharField(max_length=200, blank=True, help_text=_( 'Optional link to page with more information (for clickable pricing table headers)')) class Meta: ordering = ('order',) verbose_name = _("Quota") verbose_name_plural = _("Quotas") def __str__(self): return "%s" % (self.codename, ) class PlanPricingManager(models.Manager): def get_query_set(self): return super(PlanPricingManager, self).get_query_set().select_related('plan', 'pricing') @python_2_unicode_compatible class PlanPricing(models.Model): plan = models.ForeignKey('Plan') pricing = models.ForeignKey('Pricing') price = models.DecimalField(max_digits=7, decimal_places=2, db_index=True) objects = PlanPricingManager() class Meta: ordering = ('pricing__period', ) verbose_name = _("Plan pricing") verbose_name_plural = _("Plans pricings") def __str__(self): return "%s %s" % (self.plan.name, self.pricing) class PlanQuotaManager(models.Manager): def get_query_set(self): return super(PlanQuotaManager, self).get_query_set().select_related('plan', 'quota') class PlanQuota(models.Model): plan = models.ForeignKey('Plan') quota = models.ForeignKey('Quota') value = models.IntegerField(default=1, null=True, blank=True) objects = PlanQuotaManager() class Meta: verbose_name = _("Plan quota") verbose_name_plural = _("Plans quotas") @python_2_unicode_compatible class Order(models.Model): """ Order in this app supports only one item per order. This item is defined by plan and pricing attributes. If both are defined the order represents buying an account extension. If only plan is provided (with pricing set to None) this means that user purchased a plan upgrade. """ STATUS = Enumeration([ (1, 'NEW', pgettext_lazy('Order status', 'new')), (2, 'COMPLETED', pgettext_lazy('Order status', 'completed')), (3, 'NOT_VALID', pgettext_lazy('Order status', 'not valid')), (4, 'CANCELED', pgettext_lazy('Order status', 'canceled')), (5, 'RETURNED', pgettext_lazy('Order status', 'returned')), ]) user = models.ForeignKey('auth.User', verbose_name=_('user')) flat_name = models.CharField(max_length=200, blank=True, null=True) plan = models.ForeignKey('Plan', verbose_name=_('plan'), related_name="plan_order") pricing = models.ForeignKey('Pricing', blank=True, null=True, verbose_name=_( 'pricing')) # if pricing is None the order is upgrade plan, not buy new pricing created = models.DateTimeField(_('created'), db_index=True) completed = models.DateTimeField(_('completed'), null=True, blank=True, db_index=True) amount = models.DecimalField(_('amount'), max_digits=7, decimal_places=2, db_index=True) tax = models.DecimalField(_('tax'), max_digits=4, decimal_places=2, db_index=True, null=True, blank=True) # Tax=None is when tax is not applicable currency = models.CharField(_('currency'), max_length=3, default='EUR') status = models.IntegerField(_('status'), choices=STATUS, default=STATUS.NEW) def save(self, force_insert=False, force_update=False, using=None, update_fields=None): if self.created is None: self.created = now() return super(Order, self).save(force_insert, force_update, using) def __str__(self): return _("Order #%(id)d") % {'id': self.id} @property def name(self): """ Support for two kind of Order names: * (preferred) dynamically generated from Plan and Pricing (if flatname is not provided) (translatable) * (legacy) just return flat name, which is any text (not translatable) Flat names are only introduced for legacy system support, when you need to migrate old orders into django-plans and you cannot match Plan&Pricings convention. """ if self.flat_name: return self.flat_name else: return "%s %s %s " % ( _('Plan'), self.plan.name, "(upgrade)" if self.pricing is None else '- %s' % self.pricing) def is_ready_for_payment(self): return self.status == self.STATUS.NEW and (now() - self.created).days < getattr( settings, 'PLANS_ORDER_EXPIRATION', 14) def complete_order(self): if self.completed is None: status = self.user.userplan.extend_account(self.plan, self.pricing) self.completed = now() if status: self.status = Order.STATUS.COMPLETED else: self.status = Order.STATUS.NOT_VALID self.save() order_completed.send(self) return True else: return False def get_invoices_proforma(self): return Invoice.proforma.filter(order=self) def get_invoices(self): return Invoice.invoices.filter(order=self) def get_all_invoices(self): return self.invoice_set.order_by('issued', 'issued_duplicate', 'pk') def tax_total(self): if self.tax is None: return Decimal('0.00') else: return self.total() - self.amount def total(self): if self.tax is not None: return (self.amount * (self.tax + 100) / 100).quantize(Decimal('1.00')) else: return self.amount def get_absolute_url(self): return reverse('order', kwargs={'pk': self.pk}) class Meta: ordering = ('-created', ) verbose_name = _("Order") verbose_name_plural = _("Orders") class InvoiceManager(models.Manager): def get_query_set(self): return super(InvoiceManager, self).get_query_set().filter(type=Invoice.INVOICE_TYPES['INVOICE']) class InvoiceProformaManager(models.Manager): def get_query_set(self): return super(InvoiceProformaManager, self).get_query_set().filter(type=Invoice.INVOICE_TYPES['PROFORMA']) class InvoiceDuplicateManager(models.Manager): def get_query_set(self): return super(InvoiceDuplicateManager, self).get_query_set().filter(type=Invoice.INVOICE_TYPES['DUPLICATE']) @python_2_unicode_compatible
[docs]class Invoice(models.Model): """ Single invoice document. """ INVOICE_TYPES = Enumeration([ (1, 'INVOICE', _('Invoice')), (2, 'DUPLICATE', _('Invoice Duplicate')), (3, 'PROFORMA', pgettext_lazy('proforma', 'Order confirmation')), ]) objects = models.Manager() invoices = InvoiceManager() proforma = InvoiceProformaManager() duplicates = InvoiceDuplicateManager()
[docs] class NUMBERING: """Used as a choices for settings.PLANS_INVOICE_COUNTER_RESET""" DAILY = 1 MONTHLY = 2 ANNUALLY = 3
user = models.ForeignKey('auth.User') order = models.ForeignKey('Order') number = models.IntegerField(db_index=True) full_number = models.CharField(max_length=200) type = models.IntegerField(choices=INVOICE_TYPES, default=INVOICE_TYPES.INVOICE, db_index=True) issued = models.DateField(db_index=True) issued_duplicate = models.DateField(db_index=True, null=True, blank=True) selling_date = models.DateField(db_index=True, null=True, blank=True) payment_date = models.DateField(db_index=True) unit_price_net = models.DecimalField(max_digits=7, decimal_places=2) quantity = models.IntegerField(default=1) total_net = models.DecimalField(max_digits=7, decimal_places=2) total = models.DecimalField(max_digits=7, decimal_places=2) tax_total = models.DecimalField(max_digits=7, decimal_places=2) tax = models.DecimalField(max_digits=4, decimal_places=2, db_index=True, null=True, blank=True) # Tax=None is whet tax is not applicable rebate = models.DecimalField(max_digits=4, decimal_places=2, default=Decimal(0)) currency = models.CharField(max_length=3, default='EUR') item_description = models.CharField(max_length=200) buyer_name = models.CharField(max_length=200, verbose_name=_("Name")) buyer_street = models.CharField(max_length=200, verbose_name=_("Street")) buyer_zipcode = models.CharField(max_length=200, verbose_name=_("Zip code")) buyer_city = models.CharField(max_length=200, verbose_name=_("City")) buyer_country = CountryField(verbose_name=_("Country"), default='PL') buyer_tax_number = models.CharField(max_length=200, blank=True, verbose_name=_("TAX/VAT number")) shipping_name = models.CharField(max_length=200, verbose_name=_("Name")) shipping_street = models.CharField(max_length=200, verbose_name=_("Street")) shipping_zipcode = models.CharField(max_length=200, verbose_name=_("Zip code")) shipping_city = models.CharField(max_length=200, verbose_name=_("City")) shipping_country = CountryField(verbose_name=_("Country"), default='PL') require_shipment = models.BooleanField(default=False, db_index=True) issuer_name = models.CharField(max_length=200, verbose_name=_("Name")) issuer_street = models.CharField(max_length=200, verbose_name=_("Street")) issuer_zipcode = models.CharField(max_length=200, verbose_name=_("Zip code")) issuer_city = models.CharField(max_length=200, verbose_name=_("City")) issuer_country = CountryField(verbose_name=_("Country"), default='PL') issuer_tax_number = models.CharField(max_length=200, blank=True, verbose_name=_("TAX/VAT number")) class Meta: verbose_name = _("Invoice") verbose_name_plural = _("Invoices") def __str__(self): return self.full_number def get_absolute_url(self): return reverse('invoice_preview_html', kwargs={'pk': self.pk}) def clean(self): if self.number is None: invoice_counter_reset = getattr(settings, 'PLANS_INVOICE_COUNTER_RESET', Invoice.NUMBERING.MONTHLY) if invoice_counter_reset == Invoice.NUMBERING.DAILY: last_number = Invoice.objects.filter(issued=self.issued, type=self.type).aggregate(Max('number'))[ 'number__max'] or 0 elif invoice_counter_reset == Invoice.NUMBERING.MONTHLY: last_number = Invoice.objects.filter(issued__year=self.issued.year, issued__month=self.issued.month, type=self.type).aggregate(Max('number'))['number__max'] or 0 elif invoice_counter_reset == Invoice.NUMBERING.ANNUALLY: last_number = \ Invoice.objects.filter(issued__year=self.issued.year, type=self.type).aggregate(Max('number'))[ 'number__max'] or 0 else: raise ImproperlyConfigured( "PLANS_INVOICE_COUNTER_RESET can be set only to these values: daily, monthly, yearly.") self.number = last_number + 1 if self.full_number == "": self.full_number = self.get_full_number() super(Invoice, self).clean() # def validate_unique(self, exclude=None): # super(Invoice, self).validate_unique(exclude) # if self.type == Invoice.INVOICE_TYPES.INVOICE: # if Invoice.objects.filter(order=self.order).count(): # raise ValidationError("Duplicate invoice for order") # if self.type in (Invoice.INVOICE_TYPES.INVOICE, Invoice.INVOICE_TYPES.PROFORMA): # pass
[docs] def get_full_number(self): """ Generates on the fly invoice full number from template provided by ``settings.PLANS_INVOICE_NUMBER_FORMAT``. ``Invoice`` object is provided as ``invoice`` variable to the template, therefore all object fields can be used to generate full number format. .. warning:: This is only used to prepopulate ``full_number`` field on saving new invoice. To get invoice full number always use ``full_number`` field. :return: string (generated full number) """ format = getattr(settings, "PLANS_INVOICE_NUMBER_FORMAT", "{{ invoice.number }}/{% ifequal invoice.type invoice.INVOICE_TYPES.PROFORMA %}PF{% else %}FV{% endifequal %}/{{ invoice.issued|date:'m/Y' }}") return Template(format).render(Context({'invoice': self}))
[docs] def set_issuer_invoice_data(self): """ Fills models object with issuer data copied from ``settings.PLANS_INVOICE_ISSUER`` :raise: ImproperlyConfigured """ try: issuer = getattr(settings, 'PLANS_INVOICE_ISSUER') except: raise ImproperlyConfigured("Please set PLANS_INVOICE_ISSUER in order to make an invoice.") self.issuer_name = issuer['issuer_name'] self.issuer_street = issuer['issuer_street'] self.issuer_zipcode = issuer['issuer_zipcode'] self.issuer_city = issuer['issuer_city'] self.issuer_country = issuer['issuer_country'] self.issuer_tax_number = issuer['issuer_tax_number']
[docs] def set_buyer_invoice_data(self, billing_info): """ Fill buyer invoice billing and shipping data by copy them from provided user's ``BillingInfo`` object. :param billing_info: BillingInfo object :type billing_info: BillingInfo """ self.buyer_name = billing_info.name self.buyer_street = billing_info.street self.buyer_zipcode = billing_info.zipcode self.buyer_city = billing_info.city self.buyer_country = billing_info.country self.buyer_tax_number = billing_info.tax_number self.shipping_name = billing_info.shipping_name or billing_info.name self.shipping_street = billing_info.shipping_street or billing_info.street self.shipping_zipcode = billing_info.shipping_zipcode or billing_info.zipcode self.shipping_city = billing_info.shipping_city or billing_info.city #TODO: Should allow shipping to other country? Not think so self.shipping_country = billing_info.country
[docs] def copy_from_order(self, order): """ Filling orders details likes totals, taxes, etc and linking provided ``Order`` object with an invoice :param order: Order object :type order: Order """ self.order = order self.user = order.user self.unit_price_net = order.amount self.total_net = order.amount self.total = order.total() self.tax_total = order.total() - order.amount self.tax = order.tax self.currency = order.currency self.item_description = "%s - %s" % (Site.objects.get_current().name, order.name)
@classmethod def create(cls, order, invoice_type): language_code = get_user_language(order.user) if language_code is not None: translation.activate(language_code) try: billing_info = BillingInfo.objects.get(user=order.user) except BillingInfo.DoesNotExist: return day = date.today() pday = order.completed if invoice_type == Invoice.INVOICE_TYPES['PROFORMA']: pday = day + timedelta(days=14) invoice = cls(issued=day, selling_date=order.completed, payment_date=pday) # FIXME: 14 - this should set accordingly to ORDER_TIMEOUT in days invoice.type = invoice_type invoice.copy_from_order(order) invoice.set_issuer_invoice_data() invoice.set_buyer_invoice_data(billing_info) invoice.clean() invoice.save() if language_code is not None: translation.deactivate() def send_invoice_by_email(self): language_code = get_user_language(self.user) if language_code is not None: translation.activate(language_code) mail_context = Context({'user': self.user, 'invoice_type': self.get_type_display(), 'invoice_number': self.get_full_number(), 'order': self.order.id, 'url': self.get_absolute_url(), }) if language_code is not None: translation.deactivate() send_template_email([self.user.email], 'mail/invoice_created_title.txt', 'mail/invoice_created_body.txt', mail_context, language_code) def is_UE_customer(self): return EUTaxationPolicy.is_in_EU(self.buyer_country.code)
#noinspection PyUnresolvedReferences import plans.listeners