示例#1
0
class ClusterExpiration(models.Model):
    """Handles the info about cluster expiration."""
    cluster = models.OneToOneField(Cluster,
                                   verbose_name=_("Cluster"),
                                   related_name='_expiration')
    expiration_date = models.DateField(verbose_name=_("Expiration date"))

    user_permissions = JSONField(default={})
    """Copy of user permissions on expiration date."""
    def expire(self):
        """Collect, then disable existing users permissions."""
        success = True
        user_permissions = {}
        for v in self.cluster.volumes:  # v is a volume object
            perms = {'grant-read': [], 'grant-write': [], 'grant-manager': []}
            user_permissions[v.full_name] = perms
            for u in v.acl:  # u is a dictionary of one user's permissions
                if not u['is_reserved']:
                    for perm in 'read', 'write', 'manager':
                        if u[perm]:
                            perms['grant-' + perm].append(u['email'])
            try:
                sx.updateVolumeACL(
                    v.full_name, {
                        key.replace('grant', 'revoke'): value
                        for key, value in perms.iteritems()
                    })
            except SXClientException as e:
                success = False
                msg = '\n'.join([
                    "Failed to revoke permissions on an expired cluster.",
                    "\tCluster:\t{}".format(self.cluster.name),
                    "\tVolume:\t{}".format(v.full_name),
                    "\tException:\t{}".format(e),
                ])
                logger.error(msg)
        if success:
            self.user_permissions = user_permissions
            self.save()

    def restore(self, delete=True):
        """Restore saved permissions."""
        success = True
        for volume, perms in self.user_permissions.iteritems():
            try:
                sx.updateVolumeACL(volume, perms)
            except SXClientException as e:
                success = False
                msg = '\n'.join([
                    "Failed to restore some permisisons.",
                    "\tCluster:\t{}".format(self.cluster.name),
                    "\tVolume:\t{}".format(volume.full_name),
                    "\tPermissions:\n{}".format(pprint.pformat(perms)),
                    "\tException:\t{}".format(e),
                ])
                logger.error(msg)
        if success:
            if delete:
                self.expiration_date = None
                self.delete()
            else:
                self.user_permissions = {}
                self.save()
示例#2
0
class BillingDocumentBase(models.Model):
    objects = BillingDocumentManager.from_queryset(BillingDocumentQuerySet)()

    class STATES(object):
        DRAFT = 'draft'
        ISSUED = 'issued'
        PAID = 'paid'
        CANCELED = 'canceled'

    STATE_CHOICES = Choices(
        (STATES.DRAFT, _('Draft')),
        (STATES.ISSUED, _('Issued')),
        (STATES.PAID, _('Paid')),
        (STATES.CANCELED, _('Canceled'))
    )

    kind = models.CharField(get_billing_documents_kinds, max_length=8, db_index=True)
    related_document = models.ForeignKey('self', blank=True, null=True, on_delete=models.SET_NULL,
                                         related_name='reverse_related_document')

    series = models.CharField(max_length=20, blank=True, null=True,
                              db_index=True)
    number = models.IntegerField(blank=True, null=True, db_index=True)
    customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
    provider = models.ForeignKey('Provider', on_delete=models.CASCADE)
    archived_customer = JSONField(default=dict, null=True, blank=True)
    archived_provider = JSONField(default=dict, null=True, blank=True)
    due_date = models.DateField(null=True, blank=True)
    issue_date = models.DateField(null=True, blank=True, db_index=True)
    paid_date = models.DateField(null=True, blank=True)
    cancel_date = models.DateField(null=True, blank=True)
    sales_tax_percent = models.DecimalField(max_digits=4, decimal_places=2,
                                            validators=[MinValueValidator(0.0)],
                                            null=True, blank=True)
    sales_tax_name = models.CharField(max_length=64, blank=True, null=True)
    currency = models.CharField(
        choices=currencies, max_length=4, default='USD',
        help_text='The currency used for billing.'
    )
    transaction_currency = models.CharField(
        choices=currencies, max_length=4,
        help_text='The currency used when making a transaction.'
    )
    transaction_xe_rate = models.DecimalField(
        max_digits=16, decimal_places=4, null=True, blank=True,
        help_text='Currency exchange rate from document currency to '
                  'transaction_currency.'
    )
    transaction_xe_date = models.DateField(
        null=True, blank=True,
        help_text='Date of the transaction exchange rate.'
    )

    pdf = ForeignKey(PDF, null=True, blank=True, on_delete=models.SET_NULL)
    state = FSMField(choices=STATE_CHOICES, max_length=10, default=STATES.DRAFT,
                     verbose_name="State",
                     help_text='The state the invoice is in.')

    _total = models.DecimalField(max_digits=19, decimal_places=2,
                                 null=True, blank=True)
    _total_in_transaction_currency = models.DecimalField(max_digits=19,
                                                         decimal_places=2,
                                                         null=True, blank=True)

    is_storno = models.BooleanField(default=False)

    _last_state = None
    _document_entries = None

    class Meta:
        unique_together = ('kind', 'provider', 'series', 'number')
        ordering = ('-issue_date', 'series', '-number')

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

        if not self.kind:
            self.kind = self.__class__.__name__.lower()
        else:
            for subclass in BillingDocumentBase.__subclasses__():
                if subclass.__name__.lower() == self.kind:
                    self.__class__ = subclass

        self._last_state = self.state

    def _get_entries(self):
        if not self._document_entries:
            self._document_entries = getattr(self, self.kind + '_entries').all()

        return self._document_entries

    def compute_total_in_transaction_currency(self):
        return sum([Decimal(entry.total_in_transaction_currency)
                    for entry in self._get_entries()])

    def compute_total(self):
        return sum([Decimal(entry.total)
                    for entry in self._get_entries()])

    def mark_for_generation(self):
        self.pdf.mark_as_dirty()

    def _issue(self, issue_date=None, due_date=None):
        if issue_date:
            self.issue_date = datetime.strptime(issue_date, '%Y-%m-%d').date()
        elif not self.issue_date and not issue_date:
            self.issue_date = timezone.now().date()

        if not self.transaction_xe_rate:
            if not self.transaction_xe_date:
                self.transaction_xe_date = self.issue_date

            try:
                xe_rate = CurrencyConverter.convert(1, self.currency,
                                                    self.transaction_currency,
                                                    self.transaction_xe_date)
            except RateNotFound:
                raise TransitionNotAllowed('Couldn\'t automatically obtain an '
                                           'exchange rate.')

            self.transaction_xe_rate = xe_rate

        if not self.is_storno:
            if due_date:
                self.due_date = datetime.strptime(due_date, '%Y-%m-%d').date()
            elif not self.due_date and not due_date:
                delta = timedelta(days=PAYMENT_DUE_DAYS)
                self.due_date = timezone.now().date() + delta

        if not self.sales_tax_name:
            self.sales_tax_name = self.customer.sales_tax_name
        if not self.sales_tax_percent:
            self.sales_tax_percent = self.customer.sales_tax_percent

        if not self.number:
            self.number = self._generate_number()

        self.archived_customer = self.customer.get_archivable_field_values()
        self._total = self.compute_total()
        self._total_in_transaction_currency = self.compute_total_in_transaction_currency()

    @transition(field=state, source=STATES.DRAFT, target=STATES.ISSUED)
    def issue(self, issue_date=None, due_date=None):
        self._issue(issue_date=issue_date, due_date=due_date)

    def _pay(self, paid_date=None):
        if paid_date:
            self.paid_date = datetime.strptime(paid_date, '%Y-%m-%d').date()
        if not self.paid_date and not paid_date:
            self.paid_date = timezone.now().date()

    @transition(field=state, source=STATES.ISSUED, target=STATES.PAID)
    def pay(self, paid_date=None):
        self._pay(paid_date=paid_date)

    def _cancel(self, cancel_date=None):
        if cancel_date:
            self.cancel_date = datetime.strptime(cancel_date, '%Y-%m-%d').date()
        if not self.cancel_date and not cancel_date:
            self.cancel_date = timezone.now().date()

    @transition(field=state, source=STATES.ISSUED, target=STATES.CANCELED)
    def cancel(self, cancel_date=None):
        self._cancel(cancel_date=cancel_date)

    def sync_related_document_state(self):
        if self.is_storno:
            return

        if self.related_document and self.state != self.related_document.state:
            state_transition_map = {
                BillingDocumentBase.STATES.ISSUED: 'issue',
                BillingDocumentBase.STATES.CANCELED: 'cancel',
                BillingDocumentBase.STATES.PAID: 'pay'
            }
            transition_name = state_transition_map[self.state]

            bound_transition_method = getattr(self.related_document, transition_name)
            bound_transition_method()

    def clone_into_draft(self):
        copied_fields = {
            'customer': self.customer,
            'provider': self.provider,
            'currency': self.currency,
            'sales_tax_percent': self.sales_tax_percent,
            'sales_tax_name': self.sales_tax_name
        }

        clone = self.__class__._default_manager.create(**copied_fields)
        clone.state = self.STATES.DRAFT

        # clone entries too
        for entry in self._entries:
            entry_clone = entry.clone()
            document_type_name = self.__class__.__name__.lower()
            setattr(entry_clone, document_type_name, clone)
            entry_clone.save()

        clone.save()

        return clone

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

        # The only change that is allowed if the document is in issued state
        # is the state chage from issued to paid
        # !! TODO: If _last_state == 'issued' and self.state == 'paid' || 'canceled'
        # it should also be checked that the other fields are the same bc.
        # right now a document can be in issued state and someone could
        # send a request which contains the state = 'paid' and also send
        # other changed fields and the request would be accepted bc. only
        # the state is verified.
        if self._last_state == self.STATES.ISSUED and\
           self.state not in [self.STATES.PAID, self.STATES.CANCELED]:
            msg = 'You cannot edit the document once it is in issued state.'
            raise ValidationError({NON_FIELD_ERRORS: msg})

        if self._last_state == self.STATES.CANCELED:
            msg = 'You cannot edit the document once it is in canceled state.'
            raise ValidationError({NON_FIELD_ERRORS: msg})

        # If it's in paid state => don't allow any changes
        if self._last_state == self.STATES.PAID:
            msg = 'You cannot edit the document once it is in paid state.'
            raise ValidationError(msg)

        if self.transactions.exclude(currency=self.transaction_currency).exists():
            message = 'There are unfinished transactions of this document that use a ' \
                      'different currency.'
            raise ValidationError({'transaction_currency': message})

    def clean_defaults(self):
        if not self.transaction_currency:
            self.transaction_currency = self.customer.currency or self.currency

        if not self.series:
            self.series = self.default_series

        # Generate the number
        if not self.number and self.state != BillingDocumentBase.STATES.DRAFT:
            self.number = self._generate_number()

        # Add tax info
        if not self.sales_tax_name:
            self.sales_tax_name = self.customer.sales_tax_name
        if not self.sales_tax_percent:
            self.sales_tax_percent = self.customer.sales_tax_percent

    def save(self, *args, **kwargs):
        # ToDo: Use AutoCleanModelMixin for this class and move clean_defaults() call
        # to full_clean
        self.clean_defaults()

        self._last_state = self.state

        with db_transaction.atomic():
            # Create pdf object
            if not self.pdf and self.state != self.STATES.DRAFT:
                self.pdf = PDF.objects.create(upload_path=self.get_pdf_upload_path(), dirty=1)

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

    def _generate_number(self, default_starting_number=1):
        """Generates the number for a proforma/invoice."""
        default_starting_number = max(default_starting_number, 1)

        documents = self.__class__._default_manager.filter(
            provider=self.provider, series=self.series
        )
        if not documents.exists():
            # An invoice/proforma with this provider and series does not exist
            if self.series == self.default_series:
                return self._starting_number
            else:
                return default_starting_number
        else:
            # An invoice with this provider and series already exists
            max_existing_number = documents.aggregate(
                Max('number')
            )['number__max']
            if max_existing_number:
                if self._starting_number and self.series == self.default_series:
                    return max(max_existing_number + 1, self._starting_number)
                else:
                    return max_existing_number + 1
            else:
                return default_starting_number

    def series_number(self):
        if self.series:
            if self.number:
                return "%s-%d" % (self.series, self.number)
            else:
                return "%s-draft-id:%d" % (self.series, self.pk)

        else:
            return "draft-id:%d" % self.pk

    series_number.short_description = 'Number'
    series_number = property(series_number)

    def __str__(self):
        return u'%s %s => %s [%.2f %s]' % (self.series_number,
                                           self.provider.billing_name,
                                           self.customer.billing_name,
                                           self.total, self.currency)

    @property
    def updateable_fields(self):
        return ['customer', 'provider', 'due_date', 'issue_date', 'paid_date',
                'cancel_date', 'sales_tax_percent', 'sales_tax_name',
                'currency']

    @property
    def admin_change_url(self):
        url_base = 'admin:{app_label}_{klass}_change'.format(
            app_label=self._meta.app_label,
            klass=self.__class__.__name__.lower())
        url = reverse(url_base, args=(self.pk,))
        return '<a href="{url}">{display_series}</a>'.format(
            url=url, display_series=self.series_number)

    @property
    def _entries(self):
        # entries iterator which replaces the invoice/proforma from the DB with
        # self. We need this in generate_pdf so that the data in PDF has the
        # lastest state for the document. Without this we get in template:
        #
        # invoice.issue_date != entry.invoice.issue_date
        #
        # which is obviously false.
        document_type_name = self.__class__.__name__  # Invoice or Proforma
        kwargs = {document_type_name.lower(): self}
        entries = DocumentEntry.objects.filter(**kwargs)
        for entry in entries:
            if document_type_name.lower() == 'invoice':
                entry.invoice = self
            if document_type_name.lower() == 'proforma':
                entry.proforma = self
            yield(entry)

    def get_template_context(self, state=None):
        customer = Customer(**self.archived_customer)
        provider = Provider(**self.archived_provider)
        if state is None:
            state = self.state

        return {
            'document': self,
            'provider': provider,
            'customer': customer,
            'entries': self._entries,
            'state': state
        }

    def get_template(self, state=None):
        provider_state_template = '{provider}/{kind}_{state}_pdf.html'.format(
            kind=self.kind, provider=self.provider.slug, state=state).lower()
        provider_template = '{provider}/{kind}_pdf.html'.format(
            kind=self.kind, provider=self.provider.slug).lower()
        generic_state_template = '{kind}_{state}_pdf.html'.format(
            kind=self.kind, state=state).lower()
        generic_template = '{kind}_pdf.html'.format(
            kind=self.kind).lower()
        _templates = [provider_state_template, provider_template,
                      generic_state_template, generic_template]

        templates = []
        for t in _templates:
            templates.append('billing_documents/' + t)

        return select_template(templates)

    def get_pdf_filename(self):
        return '{doc_type}_{series}-{number}.pdf'.format(
            doc_type=self.__class__.__name__,
            series=self.series,
            number=self.number
        )

    def get_pdf_upload_path(self):
        path_template = getattr(
            settings, 'SILVER_DOCUMENT_UPLOAD_PATH',
            'documents/{provider}/{doc.kind}/{issue_date}/{filename}'
        )

        context = {
            'doc': self,
            'filename': self.get_pdf_filename(),
            'provider': self.provider.slug,
            'customer': self.customer.slug,
            'issue_date': self.issue_date.strftime('%Y/%m/%d')
        }

        return path_template.format(**context)

    def generate_pdf(self, state=None, upload=True):
        # !!! ensure this is not called concurrently for the same document

        context = self.get_template_context(state)
        context['filename'] = self.get_pdf_filename()

        pdf_file_object = self.pdf.generate(template=self.get_template(state),
                                            context=context,
                                            upload=upload)

        return pdf_file_object

    def generate_html(self, state=None, request=None):
        context = self.get_template_context(state)
        template = self.get_template(state=context['state'])

        return template.render(context, request)

    def serialize_hook(self, hook):
        """
        Used to generate a skinny payload.
        """

        return {
            'hook': hook.dict(),
            'data': {
                'id': self.id
            }
        }

    @property
    def entries(self):
        raise NotImplementedError

    @property
    def total(self):
        if self._total is not None:
            return self._total

        return sum([entry.total for entry in self.entries])

    @property
    def total_before_tax(self):
        return sum([entry.total_before_tax for entry in self.entries])

    @property
    def tax_value(self):
        return sum([entry.tax_value for entry in self.entries])

    @property
    @require_transaction_currency_and_xe_rate
    def total_in_transaction_currency(self):
        if self._total_in_transaction_currency is not None:
            return self._total_in_transaction_currency

        return sum([entry.total_in_transaction_currency
                    for entry in self.entries])

    @property
    @require_transaction_currency_and_xe_rate
    def total_before_tax_in_transaction_currency(self):
        return sum([entry.total_before_tax_in_transaction_currency
                    for entry in self.entries])

    @property
    @require_transaction_currency_and_xe_rate
    def tax_value_in_transaction_currency(self):
        return sum([entry.tax_value_in_transaction_currency
                    for entry in self.entries])

    @property
    @require_transaction_currency_and_xe_rate
    def amount_paid_in_transaction_currency(self):
        Transaction = apps.get_model('silver.Transaction')

        return sum([transaction.amount
                    for transaction in self.transactions.filter(state=Transaction.States.Settled)])

    @property
    @require_transaction_currency_and_xe_rate
    def amount_pending_in_transaction_currency(self):
        Transaction = apps.get_model('silver.Transaction')

        return sum([transaction.amount
                    for transaction in self.transactions.filter(state=Transaction.States.Pending)])

    @property
    @require_transaction_currency_and_xe_rate
    def amount_to_be_charged_in_transaction_currency(self):
        Transaction = apps.get_model('silver.Transaction')

        return self.total_in_transaction_currency - sum([
            transaction.amount
            for transaction in self.transactions.filter(state__in=[
                Transaction.States.Initial,
                Transaction.States.Pending,
                Transaction.States.Settled
            ])
        ])
示例#3
0
class Subscription(models.Model):
    class STATES(object):
        ACTIVE = 'active'
        INACTIVE = 'inactive'
        CANCELED = 'canceled'
        ENDED = 'ended'

    STATE_CHOICES = Choices(
        (STATES.ACTIVE, _('Active')), (STATES.INACTIVE, _('Inactive')),
        (STATES.CANCELED, _('Canceled')), (STATES.ENDED, _('Ended')))

    class CANCEL_OPTIONS(object):
        NOW = 'now'
        END_OF_BILLING_CYCLE = 'end_of_billing_cycle'

    _INTERVALS_CODES = {
        'year': rrule.YEARLY,
        'month': rrule.MONTHLY,
        'week': rrule.WEEKLY,
        'day': rrule.DAILY
    }

    plan = models.ForeignKey(
        'Plan',
        help_text='The plan the customer is subscribed to.',
        on_delete=models.CASCADE)
    description = models.CharField(max_length=1024, blank=True, null=True)
    customer = models.ForeignKey(
        'Customer',
        related_name='subscriptions',
        help_text='The customer who is subscribed to the plan.',
        on_delete=models.CASCADE)
    trial_end = models.DateField(
        blank=True,
        null=True,
        help_text='The date at which the trial ends. '
        'If set, overrides the computed trial end date from the plan.')
    start_date = models.DateField(
        blank=True,
        null=True,
        help_text='The starting date for the subscription.')
    cancel_date = models.DateField(
        blank=True,
        null=True,
        help_text='The date when the subscription was canceled.')
    ended_at = models.DateField(
        blank=True,
        null=True,
        help_text='The date when the subscription ended.')
    reference = models.CharField(
        max_length=128,
        blank=True,
        null=True,
        validators=[validate_reference],
        help_text="The subscription's reference in an external system.")
    state = FSMField(choices=STATE_CHOICES,
                     max_length=12,
                     default=STATES.INACTIVE,
                     protected=True,
                     help_text='The state the subscription is in.')
    meta = JSONField(blank=True, null=True, default={})

    def clean(self):
        errors = dict()
        if self.start_date and self.trial_end:
            if self.trial_end < self.start_date:
                errors.update({
                    'trial_end':
                    "The trial end date cannot be older than "
                    "the subscription's start date."
                })
        if self.ended_at:
            if self.state not in [self.STATES.CANCELED, self.STATES.ENDED]:
                errors.update({
                    'ended_at':
                    'The ended at date cannot be set if the '
                    'subscription is not canceled or ended.'
                })
            elif self.ended_at < self.start_date:
                errors.update({
                    'ended_at':
                    "The ended at date cannot be older than the"
                    "subscription's start date."
                })

        if errors:
            raise ValidationError(errors)

    @property
    def provider(self):
        return self.plan.provider

    def _get_aligned_start_date_after_date(self,
                                           reference_date,
                                           interval_type,
                                           bymonth=None,
                                           byweekday=None,
                                           bymonthday=None):
        return list(
            rrule.rrule(
                interval_type,
                count=
                1,  # align the cycle to the given rules as quickly as possible
                bymonth=bymonth,
                bymonthday=bymonthday,
                byweekday=byweekday,
                dtstart=reference_date))[-1].date()

    def _get_last_start_date_within_range(self,
                                          range_start,
                                          range_end,
                                          interval_type,
                                          interval_count,
                                          bymonth=None,
                                          byweekday=None,
                                          bymonthday=None):
        # we try to obtain a start date aligned to the given rules
        aligned_start_date = self._get_aligned_start_date_after_date(
            reference_date=range_start,
            interval_type=interval_type,
            bymonth=bymonth,
            bymonthday=bymonthday,
            byweekday=byweekday,
        )

        relative_start_date = range_start if aligned_start_date > range_end else aligned_start_date

        dates = list(
            rrule.rrule(interval_type,
                        dtstart=relative_start_date,
                        interval=interval_count,
                        until=range_end))

        return aligned_start_date if not dates else dates[-1].date()

    def _cycle_start_date(self,
                          reference_date=None,
                          ignore_trial=None,
                          granulate=None):
        ignore_trial_default = False
        granulate_default = False

        ignore_trial = ignore_trial_default or ignore_trial
        granulate = granulate_default or granulate

        if reference_date is None:
            reference_date = timezone.now().date()

        if not self.start_date or reference_date < self.start_date:
            return None

        rules = {
            'interval_type': self._INTERVALS_CODES[self.plan.interval],
            'interval_count': 1 if granulate else self.plan.interval_count,
        }
        if self.plan.interval == self.plan.INTERVALS.MONTH:
            rules['bymonthday'] = 1  # first day of the month
        elif self.plan.interval == self.plan.INTERVALS.WEEK:
            rules['byweekday'] = 0  # first day of the week (Monday)
        elif self.plan.interval == self.plan.INTERVALS.YEAR:
            # first day of the first month (1 Jan)
            rules['bymonth'] = 1
            rules['bymonthday'] = 1

        start_date_ignoring_trial = self._get_last_start_date_within_range(
            range_start=self.start_date, range_end=reference_date, **rules)

        if ignore_trial or not self.trial_end:
            return start_date_ignoring_trial
        else:  # Trial period is considered
            if self.trial_end < reference_date:  # Trial period ended
                # The day after the trial ended can be a start date (once, right after trial ended)
                date_after_trial_end = self.trial_end + ONE_DAY

                return max(date_after_trial_end, start_date_ignoring_trial)
            else:  # Trial is still ongoing
                if granulate or self.separate_cycles_during_trial:
                    # The trial period is split into cycles according to the rules defined above
                    return start_date_ignoring_trial
                else:
                    # Otherwise, the start date of the trial period is the subscription start date
                    return self.start_date

    def _cycle_end_date(self,
                        reference_date=None,
                        ignore_trial=None,
                        granulate=None):
        ignore_trial_default = False
        granulate_default = False

        ignore_trial = ignore_trial or ignore_trial_default
        granulate = granulate or granulate_default

        if reference_date is None:
            reference_date = timezone.now().date()

        real_cycle_start_date = self._cycle_start_date(reference_date,
                                                       ignore_trial, granulate)

        # we need a current start date in order to compute a current end date
        if not real_cycle_start_date:
            return None

        # during trial and trial cycle is not separated into intervals
        if self.on_trial(reference_date) and not (
                self.separate_cycles_during_trial or granulate):
            return min(self.trial_end, (self.ended_at or datetime.max.date()))

        if self.plan.interval == self.plan.INTERVALS.YEAR:
            relative_delta = {'years': self.plan.interval_count}
        elif self.plan.interval == self.plan.INTERVALS.MONTH:
            relative_delta = {'months': self.plan.interval_count}
        elif self.plan.interval == self.plan.INTERVALS.WEEK:
            relative_delta = {'weeks': self.plan.interval_count}
        else:  # plan.INTERVALS.DAY
            relative_delta = {'days': self.plan.interval_count}

        maximum_cycle_end_date = real_cycle_start_date + relativedelta(
            **relative_delta) - ONE_DAY

        # We know that the cycle end_date is the day before the next cycle start_date,
        # therefore we check if the cycle start_date for our maximum cycle end_date is the same
        # as the initial cycle start_date.
        while True:
            reference_cycle_start_date = self._cycle_start_date(
                maximum_cycle_end_date, ignore_trial, granulate)
            # it means the cycle end_date we got is the right one
            if reference_cycle_start_date == real_cycle_start_date:
                return min(maximum_cycle_end_date,
                           (self.ended_at or datetime.max.date()))
            elif reference_cycle_start_date < real_cycle_start_date:
                # This should never happen in normal conditions, but it may stop infinite looping
                return None

            maximum_cycle_end_date = reference_cycle_start_date - ONE_DAY

    @property
    def prebill_plan(self):
        if self.plan.prebill_plan is not None:
            return self.plan.prebill_plan

        return self.provider.prebill_plan

    @property
    def cycle_billing_duration(self):
        if self.plan.cycle_billing_duration is not None:
            return self.plan.cycle_billing_duration

        return self.provider.cycle_billing_duration

    @property
    def separate_cycles_during_trial(self):
        if self.plan.separate_cycles_during_trial is not None:
            return self.plan.separate_cycles_during_trial

        return self.provider.separate_cycles_during_trial

    @property
    def generate_documents_on_trial_end(self):
        if self.plan.generate_documents_on_trial_end is not None:
            return self.plan.generate_documents_on_trial_end

        return self.provider.generate_documents_on_trial_end

    @property
    def _ignore_trial_end(self):
        return not self.generate_documents_on_trial_end

    def cycle_start_date(self, reference_date=None):
        return self._cycle_start_date(ignore_trial=self._ignore_trial_end,
                                      granulate=False,
                                      reference_date=reference_date)

    def cycle_end_date(self, reference_date=None):
        return self._cycle_end_date(ignore_trial=self._ignore_trial_end,
                                    granulate=False,
                                    reference_date=reference_date)

    def bucket_start_date(self, reference_date=None):
        return self._cycle_start_date(reference_date=reference_date,
                                      ignore_trial=False,
                                      granulate=True)

    def bucket_end_date(self, reference_date=None):
        return self._cycle_end_date(reference_date=reference_date,
                                    ignore_trial=False,
                                    granulate=True)

    def updateable_buckets(self):
        buckets = []

        if self.state in ['ended', 'inactive']:
            return buckets

        start_date = self.bucket_start_date()
        end_date = self.bucket_end_date()

        if start_date is None or end_date is None:
            return buckets

        if self.state == self.STATES.CANCELED:
            if self.cancel_date < start_date:
                return buckets

        buckets.append({'start_date': start_date, 'end_date': end_date})

        generate_after = timedelta(seconds=self.plan.generate_after)
        while (timezone.now() - generate_after < datetime.combine(
                start_date, datetime.min.time()).replace(
                    tzinfo=timezone.get_current_timezone())):
            end_date = start_date - ONE_DAY
            start_date = self.bucket_start_date(end_date)
            if start_date is None:
                return buckets
            buckets.append({'start_date': start_date, 'end_date': end_date})

        return buckets

    @property
    def is_on_trial(self):
        """
        Tells if the subscription is currently on trial.

        :rtype: bool
        """

        if self.state == self.STATES.ACTIVE and self.trial_end:
            return timezone.now().date() <= self.trial_end
        return False

    def on_trial(self, date):
        """
        Tells if the subscription was on trial at the date passed as argument.

        :param date: the date for which the check is made.
        :type date: datetime.date
        :rtype: bool
        """

        if self.trial_end:
            return date <= self.trial_end
        return False

    def _log_should_be_billed_result(self, billing_date, interval_end):
        logger.debug(
            'should_be_billed result: %s', {
                'subscription': self.id,
                'billing_date': billing_date.strftime('%Y-%m-%d'),
                'interval_end': interval_end.strftime('%Y-%m-%d')
            })

    @property
    def billed_up_to_dates(self):
        last_billing_log = self.last_billing_log

        return {
            'metered_features_billed_up_to':
            last_billing_log.metered_features_billed_up_to,
            'plan_billed_up_to': last_billing_log.plan_billed_up_to
        } if last_billing_log else {
            'metered_features_billed_up_to': self.start_date - ONE_DAY,
            'plan_billed_up_to': self.start_date - ONE_DAY
        }

    def should_be_billed(self, billing_date, generate_documents_datetime=None):
        if self.state not in [self.STATES.ACTIVE, self.STATES.CANCELED]:
            return False

        if not generate_documents_datetime:
            generate_documents_datetime = timezone.now()

        if self.cycle_billing_duration:
            if self.start_date > first_day_of_month(
                    billing_date) + self.cycle_billing_duration:
                # There was nothing to bill on the last day of the first cycle billing duration
                return False

            # We need the full cycle here (ignoring trial ends)
            cycle_start_datetime_ignoring_trial = self._cycle_start_date(
                billing_date, ignore_trial=False)
            latest_possible_billing_datetime = (
                cycle_start_datetime_ignoring_trial +
                self.cycle_billing_duration)

            billing_date = min(billing_date, latest_possible_billing_datetime)

        if billing_date > generate_documents_datetime.date():
            return False

        cycle_start_date = self.cycle_start_date(billing_date)

        if not cycle_start_date:
            return False

        if self.state == self.STATES.CANCELED:
            if billing_date <= self.cancel_date:
                return False

            cycle_start_date = self.cancel_date + ONE_DAY

        cycle_start_datetime = datetime.combine(
            cycle_start_date, datetime.min.time()).replace(tzinfo=utc)

        generate_after = timedelta(seconds=self.plan.generate_after)

        if generate_documents_datetime < cycle_start_datetime + generate_after:
            return False

        plan_billed_up_to = self.billed_up_to_dates['plan_billed_up_to']

        # We want to bill the subscription if the plan hasn't been billed for this cycle or
        # if the subscription has been canceled and the plan won't be billed for this cycle.
        if self.prebill_plan or self.state == self.STATES.CANCELED:
            return plan_billed_up_to < cycle_start_date

        # wait until the cycle that is going to be billed ends:
        billed_cycle_end_date = self.cycle_end_date(plan_billed_up_to +
                                                    ONE_DAY)
        return billed_cycle_end_date < cycle_start_date

    @property
    def _has_existing_customer_with_consolidated_billing(self):
        # TODO: move to Customer
        return (self.customer.consolidated_billing
                and self.customer.subscriptions.filter(
                    state=self.STATES.ACTIVE).count() > 1)

    @property
    def is_billed_first_time(self):
        return self.billing_logs.all().count() == 0

    @property
    def last_billing_log(self):
        return self.billing_logs.order_by('billing_date').last()

    @property
    def last_billing_date(self):
        # ToDo: Improve this when dropping Django 1.8 support
        try:
            return self.billing_logs.all()[0].billing_date
        except (BillingLog.DoesNotExist, IndexError):
            # It should never get here.
            return None

    def _should_activate_with_free_trial(self):
        return Subscription.objects.filter(plan__provider=self.plan.provider,
                                           customer=self.customer,
                                           state__in=[
                                               Subscription.STATES.ACTIVE,
                                               Subscription.STATES.CANCELED,
                                               Subscription.STATES.ENDED
                                           ]).count() == 0

    ##########################################################################
    # STATE MACHINE TRANSITIONS
    ##########################################################################
    @transition(field=state,
                source=[STATES.INACTIVE, STATES.CANCELED],
                target=STATES.ACTIVE)
    def activate(self, start_date=None, trial_end_date=None):
        if start_date:
            self.start_date = min(timezone.now().date(), start_date)
        else:
            if self.start_date:
                self.start_date = min(timezone.now().date(), self.start_date)
            else:
                self.start_date = timezone.now().date()

        if self._should_activate_with_free_trial():
            if trial_end_date:
                self.trial_end = max(self.start_date, trial_end_date)
            else:
                if self.trial_end:
                    if self.trial_end < self.start_date:
                        self.trial_end = None
                elif self.plan.trial_period_days > 0:
                    self.trial_end = self.start_date + timedelta(
                        days=self.plan.trial_period_days - 1)

    @transition(field=state, source=STATES.ACTIVE, target=STATES.CANCELED)
    def cancel(self, when):
        now = timezone.now().date()
        bsd = self.bucket_start_date()
        bed = self.bucket_end_date()

        if when == self.CANCEL_OPTIONS.END_OF_BILLING_CYCLE:
            if self.is_on_trial:
                self.cancel_date = self.bucket_end_date(
                    reference_date=self.trial_end)
            else:
                self.cancel_date = self.cycle_end_date()
        elif when == self.CANCEL_OPTIONS.NOW:
            for metered_feature in self.plan.metered_features.all():
                log = MeteredFeatureUnitsLog.objects.filter(
                    start_date=bsd,
                    end_date=bed,
                    metered_feature=metered_feature.pk,
                    subscription=self.pk).first()
                if log:
                    log.end_date = now
                    log.save()
            if self.on_trial(now):
                self.trial_end = now
            self.cancel_date = now

        self.save()

    @transition(field=state, source=STATES.CANCELED, target=STATES.ENDED)
    def end(self):
        self.ended_at = timezone.now().date()

    ##########################################################################

    def _cancel_now(self):
        self.cancel(when=self.CANCEL_OPTIONS.NOW)

    def _cancel_at_end_of_billing_cycle(self):
        self.cancel(when=self.CANCEL_OPTIONS.END_OF_BILLING_CYCLE)

    def _add_trial_value(self,
                         start_date,
                         end_date,
                         invoice=None,
                         proforma=None):
        self._add_plan_trial(start_date=start_date,
                             end_date=end_date,
                             invoice=invoice,
                             proforma=proforma)
        self._add_mfs_for_trial(start_date=start_date,
                                end_date=end_date,
                                invoice=invoice,
                                proforma=proforma)

    def _get_interval_end_date(self, date=None):
        """
        :returns: the end date of the interval that should be billed. The
            returned value is a function f(subscription_state, date)
        :rtype: datetime.date
        """

        if self.state == self.STATES.ACTIVE:
            end_date = self.bucket_end_date(reference_date=date)
        elif self.state == self.STATES.CANCELED:
            if self.trial_end and date <= self.trial_end:
                if self.trial_end <= self.cancel_date:
                    end_date = self.trial_end
                else:
                    end_date = self.cancel_date
            else:
                end_date = self.cancel_date
        return end_date

    def _log_value_state(self, value_state):
        logger.debug('Adding value: %s', {
            'subscription': self.id,
            'value_state': value_state
        })

    def _add_plan_trial(self,
                        start_date,
                        end_date,
                        invoice=None,
                        proforma=None):
        """
        Adds the plan trial to the document, by adding an entry with positive
        prorated value and one with prorated, negative value which represents
        the discount for the trial period.
        """

        prorated, percent = self._get_proration_status_and_percent(
            start_date, end_date)
        plan_price = self.plan.amount * percent

        context = self._build_entry_context({
            'name': self.plan.name,
            'unit': self.plan.interval,
            'product_code': self.plan.product_code,
            'start_date': start_date,
            'end_date': end_date,
            'prorated': prorated,
            'proration_percentage': percent,
            'context': 'plan-trial'
        })

        unit = self._entry_unit(context)

        description = self._entry_description(context)

        # Add plan with positive value
        DocumentEntry.objects.create(invoice=invoice,
                                     proforma=proforma,
                                     description=description,
                                     unit=unit,
                                     unit_price=plan_price,
                                     quantity=Decimal('1.00'),
                                     product_code=self.plan.product_code,
                                     prorated=prorated,
                                     start_date=start_date,
                                     end_date=end_date)

        context.update({'context': 'plan-trial-discount'})

        description = self._entry_description(context)

        # Add plan with negative value
        DocumentEntry.objects.create(invoice=invoice,
                                     proforma=proforma,
                                     description=description,
                                     unit=unit,
                                     unit_price=-plan_price,
                                     quantity=Decimal('1.00'),
                                     product_code=self.plan.product_code,
                                     prorated=prorated,
                                     start_date=start_date,
                                     end_date=end_date)

        return Decimal("0.00")

    def _get_consumed_units_from_total_included_in_trial(
            self, metered_feature, consumed_units):
        """
        :returns: (consumed_units, free_units)
        """

        if metered_feature.included_units_during_trial:
            included_units_during_trial = metered_feature.included_units_during_trial
            if consumed_units > included_units_during_trial:
                extra_consumed = consumed_units - included_units_during_trial
                return extra_consumed, included_units_during_trial
            else:
                return 0, consumed_units
        elif metered_feature.included_units_during_trial == Decimal('0.0000'):
            return consumed_units, 0
        elif metered_feature.included_units_during_trial is None:
            return 0, consumed_units

    def _get_extra_consumed_units_during_trial(self, metered_feature,
                                               consumed_units):
        """
        :returns: (extra_consumed, free_units)
            extra_consumed - units consumed extra during trial that will be
                billed
            free_units - the units included in trial
        """

        if self.is_billed_first_time:
            # It's on trial and is billed first time
            return self._get_consumed_units_from_total_included_in_trial(
                metered_feature, consumed_units)
        else:
            # It's still on trial but has been billed before
            # The following part tries so handle the case when the trial
            # spans over 2 months and the subscription has been already billed
            # once => this month it is still on trial but it only
            # has remaining = consumed_last_cycle - included_during_trial
            last_log_entry = self.billing_logs.all()[0]
            if last_log_entry.proforma:
                qs = last_log_entry.proforma.proforma_entries.filter(
                    product_code=metered_feature.product_code)
            else:
                qs = last_log_entry.invoice.invoice_entries.filter(
                    product_code=metered_feature.product_code)

            if not qs.exists():
                return self._get_consumed_units_from_total_included_in_trial(
                    metered_feature, consumed_units)

            consumed = [
                qs_item.quantity for qs_item in qs if qs_item.unit_price >= 0
            ]
            consumed_in_last_billing_cycle = sum(consumed)

            if metered_feature.included_units_during_trial:
                included_during_trial = metered_feature.included_units_during_trial
                if consumed_in_last_billing_cycle > included_during_trial:
                    return consumed_units, 0
                else:
                    remaining = included_during_trial - consumed_in_last_billing_cycle
                    if consumed_units > remaining:
                        return consumed_units - remaining, remaining
                    elif consumed_units <= remaining:
                        return 0, consumed_units
            return 0, consumed_units

    def _add_mfs_for_trial(self,
                           start_date,
                           end_date,
                           invoice=None,
                           proforma=None):
        prorated, percent = self._get_proration_status_and_percent(
            start_date, end_date)
        context = self._build_entry_context({
            'product_code': self.plan.product_code,
            'start_date': start_date,
            'end_date': end_date,
            'prorated': prorated,
            'proration_percentage': percent,
            'context': 'metered-feature-trial'
        })

        total = Decimal("0.00")

        # Add all the metered features consumed during the trial period
        for metered_feature in self.plan.metered_features.all():
            context.update({
                'metered_feature': metered_feature,
                'unit': metered_feature.unit,
                'name': metered_feature.name,
                'product_code': metered_feature.product_code
            })

            unit = self._entry_unit(context)

            qs = self.mf_log_entries.filter(metered_feature=metered_feature,
                                            start_date__gte=start_date,
                                            end_date__lte=end_date)
            log = [qs_item.consumed_units for qs_item in qs]
            total_consumed_units = sum(log)

            extra_consumed, free = self._get_extra_consumed_units_during_trial(
                metered_feature, total_consumed_units)

            if extra_consumed > 0:
                charged_units = extra_consumed
                free_units = free
            else:
                free_units = total_consumed_units
                charged_units = 0

            if free_units > 0:
                description = self._entry_description(context)

                # Positive value for the consumed items.
                DocumentEntry.objects.create(
                    invoice=invoice,
                    proforma=proforma,
                    description=description,
                    unit=unit,
                    quantity=free_units,
                    unit_price=metered_feature.price_per_unit,
                    product_code=metered_feature.product_code,
                    start_date=start_date,
                    end_date=end_date,
                    prorated=prorated)

                context.update({'context': 'metered-feature-trial-discount'})

                description = self._entry_description(context)

                # Negative value for the consumed items.
                DocumentEntry.objects.create(
                    invoice=invoice,
                    proforma=proforma,
                    description=description,
                    unit=unit,
                    quantity=free_units,
                    unit_price=-metered_feature.price_per_unit,
                    product_code=metered_feature.product_code,
                    start_date=start_date,
                    end_date=end_date,
                    prorated=prorated)

            # Extra items consumed items that are not included
            if charged_units > 0:
                context.update(
                    {'context': 'metered-feature-trial-not-discounted'})

                description_template_path = field_template_path(
                    field='entry_description',
                    provider=self.plan.provider.slug)
                description = render_to_string(description_template_path,
                                               context)

                total += DocumentEntry.objects.create(
                    invoice=invoice,
                    proforma=proforma,
                    description=description,
                    unit=unit,
                    quantity=charged_units,
                    prorated=prorated,
                    unit_price=metered_feature.price_per_unit,
                    product_code=metered_feature.product_code,
                    start_date=start_date,
                    end_date=end_date).total

        return total

    def _add_plan_value(self,
                        start_date,
                        end_date,
                        invoice=None,
                        proforma=None):
        """
        Adds to the document the value of the plan.
        """

        prorated, percent = self._get_proration_status_and_percent(
            start_date, end_date)

        context = self._build_entry_context({
            'name': self.plan.name,
            'unit': self.plan.interval,
            'product_code': self.plan.product_code,
            'start_date': start_date,
            'end_date': end_date,
            'prorated': prorated,
            'proration_percentage': percent,
            'context': 'plan'
        })
        description = self._entry_description(context)

        # Get the plan's prorated value
        plan_price = self.plan.amount * percent

        unit = self._entry_unit(context)

        return DocumentEntry.objects.create(
            invoice=invoice,
            proforma=proforma,
            description=description,
            unit=unit,
            unit_price=plan_price,
            quantity=Decimal('1.00'),
            product_code=self.plan.product_code,
            prorated=prorated,
            start_date=start_date,
            end_date=end_date).total

    def _get_consumed_units(self, metered_feature, proration_percent,
                            start_date, end_date):
        included_units = (proration_percent * metered_feature.included_units)

        qs = self.mf_log_entries.filter(metered_feature=metered_feature,
                                        start_date__gte=start_date,
                                        end_date__lte=end_date)
        log = [qs_item.consumed_units for qs_item in qs]
        total_consumed_units = reduce(lambda x, y: x + y, log, 0)

        if total_consumed_units > included_units:
            return total_consumed_units - included_units
        return 0

    def _add_mfs(self, start_date, end_date, invoice=None, proforma=None):
        prorated, percent = self._get_proration_status_and_percent(
            start_date, end_date)

        context = self._build_entry_context({
            'name': self.plan.name,
            'unit': self.plan.interval,
            'product_code': self.plan.product_code,
            'start_date': start_date,
            'end_date': end_date,
            'prorated': prorated,
            'proration_percentage': percent,
            'context': 'metered-feature'
        })

        mfs_total = Decimal('0.00')
        for metered_feature in self.plan.metered_features.all():
            consumed_units = self._get_consumed_units(metered_feature, percent,
                                                      start_date, end_date)

            context.update({
                'metered_feature': metered_feature,
                'unit': metered_feature.unit,
                'name': metered_feature.name,
                'product_code': metered_feature.product_code
            })

            description = self._entry_description(context)
            unit = self._entry_unit(context)

            mf = DocumentEntry.objects.create(
                invoice=invoice,
                proforma=proforma,
                description=description,
                unit=unit,
                quantity=consumed_units,
                prorated=prorated,
                unit_price=metered_feature.price_per_unit,
                product_code=metered_feature.product_code,
                start_date=start_date,
                end_date=end_date)

            mfs_total += mf.total

        return mfs_total

    def _get_proration_status_and_percent(self, start_date, end_date):
        """
        Returns the proration percent (how much of the interval will be billed)
        and the status (if the subscription is prorated or not).

        :returns: a tuple containing (Decimal(percent), status) where status
            can be one of [True, False]. The decimal value will from the
            interval [0.00; 1.00].
        :rtype: tuple
        """

        first_day_of_month = date(start_date.year, start_date.month, 1)
        last_day_index = calendar.monthrange(start_date.year,
                                             start_date.month)[1]
        last_day_of_month = date(start_date.year, start_date.month,
                                 last_day_index)

        if start_date == first_day_of_month and end_date == last_day_of_month:
            return False, Decimal('1.0000')
        else:
            days_in_full_interval = (last_day_of_month -
                                     first_day_of_month).days + 1
            days_in_interval = (end_date - start_date).days + 1
            percent = 1.0 * days_in_interval / days_in_full_interval
            percent = Decimal(percent).quantize(Decimal('0.0000'))

            return True, percent

    def _entry_unit(self, context):
        unit_template_path = field_template_path(
            field='entry_unit', provider=self.plan.provider.slug)
        return render_to_string(unit_template_path, context)

    def _entry_description(self, context):
        description_template_path = field_template_path(
            field='entry_description', provider=self.plan.provider.slug)
        return render_to_string(description_template_path, context)

    @property
    def _base_entry_context(self):
        return {
            'name': None,
            'unit': 1,
            'subscription': self,
            'plan': self.plan,
            'provider': self.plan.provider,
            'customer': self.customer,
            'product_code': None,
            'start_date': None,
            'end_date': None,
            'prorated': None,
            'proration_percentage': None,
            'metered_feature': None,
            'context': None
        }

    def _build_entry_context(self, context):
        base_context = self._base_entry_context
        base_context.update(context)
        return base_context

    def __unicode__(self):
        return u'%s (%s)' % (self.customer, self.plan)
示例#4
0
class PaymentMethod(models.Model):
    class PaymentProcessors:
        @classmethod
        def as_choices(cls):
            for name in settings.PAYMENT_PROCESSORS.keys():
                yield (name, name)

        @classmethod
        def as_list(cls):
            return [name for name in settings.PAYMENT_PROCESSORS.keys()]

    payment_processor = models.CharField(choices=PaymentProcessors.as_choices(),
                                         blank=False, null=False, max_length=256)
    customer = models.ForeignKey(Customer, on_delete=models.PROTECT)
    added_at = models.DateTimeField(default=timezone.now)
    data = JSONField(blank=True, null=True, default={})

    verified = models.BooleanField(default=False)
    canceled = models.BooleanField(default=False)

    valid_until = models.DateTimeField(null=True, blank=True)
    display_info = models.CharField(max_length=256, null=True, blank=True)

    objects = InheritanceManager()

    class Meta:
        ordering = ['-id']

    @property
    def final_fields(self):
        return ['payment_processor', 'customer', 'added_at']

    @property
    def irreversible_fields(self):
        return ['verified', 'canceled']

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

        if self.id:
            try:
                payment_method_class = self.get_payment_processor().payment_method_class

                if payment_method_class:
                    self.__class__ = payment_method_class
            except AttributeError:
                pass

    @property
    def transactions(self):
        return self.transaction_set.all()

    def get_payment_processor(self):
        return payment_processors.get_instance(self.payment_processor)

    def delete(self, using=None):
        if not self.state == self.States.Uninitialized:
            self.remove()

        super(PaymentMethod, self).delete(using=using)

    def encrypt_data(self, data):
        key = settings.PAYMENT_METHOD_SECRET
        return Fernet(key).encrypt(bytes(data))

    def decrypt_data(self, crypted_data):
        key = settings.PAYMENT_METHOD_SECRET

        try:
            return str(Fernet(key).decrypt(bytes(crypted_data)))
        except InvalidToken:
            return None

    def cancel(self):
        if self.canceled:
            raise ValidationError("You can't cancel a canceled payment method.")

        cancelable_states = [Transaction.States.Initial,
                             Transaction.States.Pending]

        transactions = self.transactions.filter(state__in=cancelable_states)

        errors = []
        for transaction in transactions:
            if transaction.state == Transaction.States.Initial:
                try:
                    transaction.cancel()
                except TransitionNotAllowed:
                    errors.append("Transaction {} couldn't be canceled".format(transaction.uuid))

            if transaction.state == Transaction.States.Pending:
                payment_processor = self.get_payment_processor()
                if (hasattr(payment_processor, 'void_transaction') and
                        not payment_processor.void_transaction(transaction)):
                    errors.append("Transaction {} couldn't be voided".format(transaction.uuid))

            transaction.save()

        if errors:
            return errors

        self.canceled = True
        self.save()

        return None

    def clean_with_previous_instance(self, previous_instance):
        if not previous_instance:
            return

        for field in self.final_fields:
            old_value = getattr(previous_instance, field, None)
            current_value = getattr(self, field, None)

            if old_value != current_value:
                raise ValidationError(
                    "Field '%s' may not be changed." % field
                )

        for field in self.irreversible_fields:
            old_value = getattr(previous_instance, field, None)
            current_value = getattr(self, field, None)

            if old_value and old_value != current_value:
                raise ValidationError(
                    "Field '%s' may not be changed anymore." % field
                )

    def full_clean(self, *args, **kwargs):
        previous_instance = kwargs.pop('previous_instance', None)

        super(PaymentMethod, self).full_clean(*args, **kwargs)
        self.clean_with_previous_instance(previous_instance)

        # this assumes that nobody calls clean and then modifies this object
        # without calling clean again
        setattr(self, '.cleaned', True)

    @property
    def allowed_currencies(self):
        return self.get_payment_processor().allowed_currencies

    @property
    def public_data(self):
        return {}

    def __str__(self):
        return '{} - {} - {}'.format(self.customer,
                                     self.get_payment_processor_display(),
                                     self.pk)
示例#5
0
class Transaction(models.Model):
    _provider = None

    amount = models.DecimalField(
        decimal_places=2,
        max_digits=12,
        validators=[MinValueValidator(Decimal('0.00'))])
    currency = models.CharField(choices=currencies,
                                max_length=4,
                                help_text='The currency used for billing.')

    class Meta:
        ordering = ['-id']

    class States:
        Initial = 'initial'
        Pending = 'pending'
        Settled = 'settled'
        Failed = 'failed'
        Canceled = 'canceled'
        Refunded = 'refunded'

        @classmethod
        def as_list(cls):
            return [
                getattr(cls, state) for state in vars(cls).keys()
                if state[0].isupper()
            ]

        @classmethod
        def as_choices(cls):
            return ((state, _(state.capitalize())) for state in cls.as_list())

    external_reference = models.CharField(max_length=256,
                                          null=True,
                                          blank=True)
    data = JSONField(default={}, null=True, blank=True)
    state = FSMField(max_length=8,
                     choices=States.as_choices(),
                     default=States.Initial)

    proforma = models.ForeignKey("BillingDocumentBase",
                                 null=True,
                                 blank=True,
                                 related_name='proforma_transactions')
    invoice = models.ForeignKey("BillingDocumentBase",
                                null=True,
                                blank=True,
                                related_name='invoice_transactions')

    payment_method = models.ForeignKey('PaymentMethod')
    uuid = models.UUIDField(default=uuid.uuid4)
    valid_until = models.DateTimeField(null=True, blank=True)
    last_access = models.DateTimeField(null=True, blank=True)

    created_at = models.DateTimeField(default=timezone.now)
    updated_at = AutoDateTimeField(default=timezone.now)

    fail_code = models.CharField(choices=[(code, code)
                                          for code in FAIL_CODES.keys()],
                                 max_length=32,
                                 null=True,
                                 blank=True)
    refund_code = models.CharField(choices=[(code, code)
                                            for code in REFUND_CODES.keys()],
                                   max_length=32,
                                   null=True,
                                   blank=True)
    cancel_code = models.CharField(choices=[(code, code)
                                            for code in CANCEL_CODES.keys()],
                                   max_length=32,
                                   null=True,
                                   blank=True)

    @property
    def final_fields(self):
        fields = [
            'proforma', 'invoice', 'uuid', 'payment_method', 'amount',
            'currency', 'created_at'
        ]

        return fields

    def __init__(self, *args, **kwargs):
        self.form_class = kwargs.pop('form_class', None)

        super(Transaction, self).__init__(*args, **kwargs)

    @transition(field=state, source=States.Initial, target=States.Pending)
    def process(self):
        pass

    @transition(field=state,
                source=[States.Initial, States.Pending],
                target=States.Settled)
    def settle(self):
        pass

    @transition(field=state,
                source=[States.Initial, States.Pending],
                target=States.Canceled)
    def cancel(self,
               cancel_code='default',
               cancel_reason='Unknown cancel reason'):
        self.cancel_code = cancel_code
        logger.error(str(cancel_reason))

    @transition(field=state,
                source=[States.Initial, States.Pending],
                target=States.Failed)
    def fail(self, fail_code='default', fail_reason='Unknown fail reason'):
        self.fail_code = fail_code
        logger.error(str(fail_reason))

    @transition(field=state, source=States.Settled, target=States.Refunded)
    def refund(self,
               refund_code='default',
               refund_reason='Unknown refund reason'):
        self.refund_code = refund_code
        logger.error(str(refund_reason))

    @transaction.atomic()
    def save(self, *args, **kwargs):
        previous_instance = get_object_or_None(Transaction,
                                               pk=self.pk) if self.pk else None
        setattr(self, 'previous_instance', previous_instance)

        if not previous_instance:
            # Creating a new Transaction so we lock the DB rows for related billing documents and
            # transactions
            if self.proforma:
                Proforma.objects.select_for_update().filter(
                    pk=self.proforma.pk)
            elif self.invoice:
                Invoice.objects.select_for_update().filter(pk=self.invoice.pk)

            Transaction.objects.select_for_update().filter(
                Q(proforma=self.proforma) | Q(invoice=self.invoice))

        if not getattr(self, '.cleaned', False):
            self.full_clean(previous_instance=previous_instance)

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

    def clean(self):
        # Validate documents
        document = self.document
        if not document:
            raise ValidationError(
                'The transaction must have at least one billing document '
                '(invoice or proforma).')

        if document.state == document.STATES.DRAFT:
            raise ValidationError(
                'The transaction must have a non-draft billing document '
                '(invoice or proforma).')

        if self.invoice and self.proforma:
            if self.invoice.related_document != self.proforma:
                raise ValidationError('Invoice and proforma are not related.')
        else:
            if self.invoice:
                self.proforma = self.invoice.related_document
            else:
                self.invoice = self.proforma.related_document

        if document.customer != self.customer:
            raise ValidationError(
                'Customer doesn\'t match with the one in documents.')

        # New transaction
        if not self.pk:
            if document.state != document.STATES.ISSUED:
                raise ValidationError(
                    'Transactions can only be created for issued documents.')

            if self.currency:
                if self.currency != self.document.transaction_currency:
                    message = "Transaction currency is different from it's document's "\
                              "transaction_currency."
                    raise ValidationError(message)
            else:
                self.currency = self.document.transaction_currency

            if (self.payment_method.allowed_currencies and self.currency
                    not in self.payment_method.allowed_currencies):
                message = 'Currency {} is not allowed by the payment method. Allowed currencies ' \
                          'are {}.'.format(
                              self.currency, self.payment_method.allowed_currencies
                          )
                raise ValidationError(message)
            if self.amount:
                if self.amount > self.document.amount_to_be_charged_in_transaction_currency:
                    message = "Amount is greater than the amount that should be charged in order " \
                              "to pay the billing document."
                    raise ValidationError(message)
            else:
                self.amount = self.document.amount_to_be_charged_in_transaction_currency

    def clean_with_previous_instance(self, previous_instance):
        if not previous_instance:
            return

        for field in self.final_fields:
            old_value = getattr(previous_instance, field, None)
            current_value = getattr(self, field, None)

            if old_value is not None and old_value != current_value:
                raise ValidationError("Field '%s' may not be changed." % field)

    def full_clean(self, *args, **kwargs):
        # 'amount' and 'currency' are handled in our clean method
        kwargs['exclude'] = kwargs.get('exclude', []) + ['currency', 'amount']

        previous_instance = kwargs.pop('previous_instance', None)

        super(Transaction, self).full_clean(*args, **kwargs)

        self.clean_with_previous_instance(previous_instance)

        # this assumes that nobody calls clean and then modifies this object
        # without calling clean again
        setattr(self, '.cleaned', True)

    @property
    def can_be_consumed(self):
        if self.valid_until and self.valid_until < timezone.now():
            return False

        if self.state != Transaction.States.Initial:
            return False

        return True

    @property
    def customer(self):
        return self.payment_method.customer

    @property
    def document(self):
        return self.invoice or self.proforma

    @document.setter
    def document(self, value):
        if isinstance(value, Invoice):
            self.invoice = value
        elif isinstance(value, Proforma):
            self.proforma = value
        else:
            raise ValueError(
                'The provided document is not an invoice or a proforma.')

    @property
    def provider(self):
        return self._provider or self.document.provider

    @provider.setter
    def provider(self, provider):
        self._provider = provider

    @property
    def payment_processor(self):
        return self.payment_method.payment_processor

    def update_document_state(self):
        if (self.state == Transaction.States.Settled and
                not self.document.amount_to_be_charged_in_transaction_currency
                and self.document.state != self.document.STATES.PAID):
            self.document.pay()

    def __str__(self):
        return force_text(self.uuid)
示例#6
0
class Game(models.Model):
    is_solo_game = models.BooleanField(default=False)
    # prevent backwards relation with related_name='+'
    current_player = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        related_name="+",
        blank=True,
        null=True,
    )
    # prevent backwards relation with related_name='+', as the Turn has a game already linked
    current_turn = models.ForeignKey("GameTurn",
                                     on_delete=models.SET_NULL,
                                     null=True,
                                     related_name="+",
                                     default=None)
    current_phase = models.PositiveSmallIntegerField(default=1)
    choice_dice_bank = JSONField(blank=True, null=True,
                                 default=[])  # list of die types available
    gather_dice_bank = JSONField(
        blank=True, null=True,
        default=[])  # list of already rolled dice available

    def setup_choice_dice_for_turn(self):
        """
        Based on the current turn, setup the choice dice bank, and put the given dice
        into each player's pool
        """
        turn = Turn(self.current_turn.turn_no)
        given_dice = turn.create_player_choice_dice_for_turn()

        # setup base choice die for all players with initial given set
        for playermat in self.playermat_set.all():
            playermat.choice_dice = given_dice
            playermat.save()

        self.dice_bank = turn.create_dice_bank_for_turn(
            self.playermat_set.count())
        self.save()

    def advance_turn(self):
        """
        Setup or reset the turn based values tracked in the Game.
        Also advance the turn number, and determine the new player order.
        """
        # TODO: custom logic to advance turn & reset base values
        # TODO: confirm barbarians phase completed

        # advance the turn, creating a new GameTurn
        if self.current_turn:
            self.current_turn = GameTurn.initialize_turn(
                self, self.current_turn.turn_no + 1)
        else:
            self.current_turn = GameTurn.initialize_turn(self, 1)

        # set player order on players
        self.determine_player_order()

        # get current player based on new player order
        self.current_player = self.playermat_set.get(player_order=1).player

        # clear dice banks
        self.choice_dice_bank = None
        self.gather_dice_bank = None

        # reset the phase
        self.current_phase = 1

        self.save()

    def advance_phase(self):
        """
        Update the current phase number to be the next phase.  Also advance the current player
        """
        # TODO: custom logic to check if all players have had their turn
        # or other conditions met (gathered all dice, etc)

        self.current_phase += 1
        # reset first player of the phase back to first player
        self.current_player = self.playermat_set.get(player_order=1).player
        self.save()

    def advance_current_player(self):
        """
        Based on game.current_player & each playermat.player_order,
        set game.current_player to the next player in order
        Used during a turn, after player_order has already been set.
        """
        # get the current player's order
        current_player_order = self.playermat_set.get(
            player=self.current_player).player_order

        # if the player_order is equal to the total number of players,
        # go back to the first player
        if current_player_order == self.playermat_set.count():
            self.current_player = self.playermat_set.get(player_order=1).player

        # else get the player with the next highest player_order
        else:
            self.current_player = self.playermat_set.get(
                player_order=current_player_order + 1).player

        self.save()

    def determine_player_order(self):
        playermats = self.playermat_set.all().order_by("id")
        playermats_list = list(playermats)
        max_horses = self.playermat_set.all().aggregate(
            models.Max("horses"))["horses__max"]
        max_horse_players = self.playermat_set.filter(horses=max_horses)

        # first turn, no player order set yet, no horses
        # this is ok for INITIAL drat but should be updated to dice rolling to
        # match game rules expectation
        if self.current_turn.turn_no == 1:
            if self.is_solo_game:
                ai_playermat = playermats.get(player=JoanAI.get_user_joan())
                ai_idx = playermats_list.index(ai_playermat)
                player_one_idx = int(not ai_idx)
            else:
                player_one_idx = playermats_list.index(
                    random.choice(playermats))

        # check for clear max_horses
        elif max_horse_players.count() == 1:
            player_one = max_horse_players[0]
            player_one_idx = playermats_list.index(player_one)

        else:
            # no max horses, simply increment the player_order
            player_one = playermats.get(player_order=2)
            player_one_idx = playermats_list.index(player_one)

        # reorder playermats so that player_one_idx is first, but still
        # 'incremental' ids
        reordered_mats = playermats[
            player_one_idx:] + playermats[:player_one_idx]

        initial_order = 1
        for playermat in reordered_mats:
            # initial turn, just go clockwise
            playermat.player_order = initial_order
            playermat.save()
            initial_order += 1
        return

    def get_current_player_playermat(self):
        return self.playermat_set.get(player=self.current_player)
示例#7
0
class Property(models.Model):
    AVAILABLE = 1
    UNAVAILABLE = 2
    MAINTAINENCE = 3
    OCCUPIED = 4
    STATUS = (
        (AVAILABLE, 'available'),
        (UNAVAILABLE, 'unavailable'),
        (MAINTAINENCE, 'maintainence'),
        (OCCUPIED, 'occupied'),
    )

    HOUSE = 1
    BUNGALOW = 2
    FLAT = 3
    VILLA = 4
    ROOM = 5
    TYPES = (
        (HOUSE, 'house'),
        (BUNGALOW, 'bungalow'),
        (FLAT, 'flat'),
        (VILLA, 'villa'),
        (ROOM, 'room'),
    )

    HOUR = 1
    DAY = 2
    WEEK = 3
    FORTNIGHT = 4
    MONTH = 5
    CALENDER_MONTH = 6
    QUARTER = 7
    SIX_MONTH = 8
    ANNUAL = 9
    PAYMENT_INTERVALS = (
        (HOUR, 'hour'),
        (DAY, 'day'),
        (WEEK, 'week'),
        (FORTNIGHT, 'fortnight'),
        (MONTH, 'month'),
        (CALENDER_MONTH, 'calender month'),
        (QUARTER, 'quarter'),
        (SIX_MONTH, 'six months'),
        (ANNUAL, 'annual'),
    )

    FURNISHED = 1
    PART_FURNISHED = 2
    WHITE_GOODS = 3
    UNFURNISHED = 4
    FURNISHING = (
        (FURNISHED, 'furnished'),
        (PART_FURNISHED, 'part furnished'),
        (WHITE_GOODS, 'white goods'),
        (UNFURNISHED, 'unfurnished'),
    )

    image_gallery = models.ForeignKey(ImageGallery)
    company = models.ForeignKey(Company)
    occupant = models.ForeignKey(User, null=True, blank=True, default=None)

    calendar = AutoOneToOneField(Calendar)
    address = models.OneToOneField('Address')

    managers = models.ManyToManyField(User)

    bathrooms = models.PositiveSmallIntegerField(default=1)
    bedrooms = models.PositiveSmallIntegerField(default=1)
    epc_rating = models.PositiveSmallIntegerField(default=1)
    furnishing = models.PositiveSmallIntegerField(choices=FURNISHING,
                                                  default=UNFURNISHED)
    payment_interval = models.PositiveSmallIntegerField(
        choices=PAYMENT_INTERVALS, default=MONTH)
    status = models.PositiveSmallIntegerField(choices=STATUS,
                                              default=UNAVAILABLE)
    type = models.PositiveSmallIntegerField(choices=TYPES, default=HOUSE)

    price = models.DecimalField(max_digits=8, decimal_places=2)

    date_available = models.DateField()
    date_created = models.DateField()

    last_time_saved = models.DateTimeField(auto_now=True)
    last_time_json_updated = models.DateTimeField()

    disabled_access = models.BooleanField(default=False)
    pets_allowed = models.BooleanField(default=False)
    published = models.BooleanField(default=False)
    smoking = models.BooleanField(default=False)

    description = models.TextField(blank=True, default='')
    features = models.TextField(blank=True, default='')

    objects = PropertyManager()

    json = JSONField(blank=True, default='')
示例#8
0
class Client(models.Model):

    # Characters are used to keep a backward-compatibility
    # with the previous system.
    PENDING = 'D'
    ACTIVE = 'A'
    PAUSED = 'S'
    STOPNOCONTACT = 'N'
    STOPCONTACT = 'C'
    DECEASED = 'I'

    CLIENT_STATUS = (
        (PENDING, _('Pending')),
        (ACTIVE, _('Active')),
        (PAUSED, _('Paused')),
        (STOPNOCONTACT, _('Stop: no contact')),
        (STOPCONTACT, _('Stop: contact')),
        (DECEASED, _('Deceased')),
    )

    LANGUAGES = (
        ('en', _('English')),
        ('fr', _('French')),
        ('al', _('Allophone')),
    )

    class Meta:
        verbose_name_plural = _('clients')
        ordering = ["-member__created_at"]

    billing_member = models.ForeignKey(
        'member.Member',
        related_name='+',
        verbose_name=_('billing member'),
        # A client must have a billing member
        on_delete=models.PROTECT)

    billing_payment_type = models.CharField(
        verbose_name=_('Payment Type'),
        max_length=10,
        null=True,
        blank=True,
        choices=PAYMENT_TYPE,
    )

    rate_type = models.CharField(verbose_name=_('rate type'),
                                 max_length=10,
                                 choices=RATE_TYPE,
                                 default='default')

    member = models.ForeignKey('member.Member',
                               verbose_name=_('member'),
                               on_delete=models.CASCADE)

    status = models.CharField(max_length=1,
                              choices=CLIENT_STATUS,
                              default=PENDING)

    language = models.CharField(max_length=2, choices=LANGUAGES, default='fr')

    alert = models.TextField(
        verbose_name=_('alert client'),
        blank=True,
        null=True,
    )

    delivery_type = models.CharField(max_length=1,
                                     choices=DELIVERY_TYPE,
                                     default='O')

    gender = models.CharField(
        max_length=1,
        default='U',
        blank=True,
        null="True",
        choices=GENDER_CHOICES,
    )

    birthdate = models.DateField(auto_now=False,
                                 auto_now_add=False,
                                 default=timezone.now,
                                 blank=True,
                                 null=True)

    route = models.ForeignKey('member.Route',
                              on_delete=models.SET_NULL,
                              verbose_name=_('route'),
                              blank=True,
                              null=True)

    meal_default_week = JSONField(blank=True, null=True)

    delivery_note = models.TextField(verbose_name=_('Delivery Note'),
                                     blank=True,
                                     null=True)

    ingredients_to_avoid = models.ManyToManyField(
        'meal.Ingredient', through='Client_avoid_ingredient')

    components_to_avoid = models.ManyToManyField(
        'meal.Component', through='Client_avoid_component')

    options = models.ManyToManyField('member.option', through='Client_option')

    restrictions = models.ManyToManyField('meal.Restricted_item',
                                          through='Restriction')

    def __str__(self):
        return "{} {}".format(self.member.firstname, self.member.lastname)

    objects = ClientManager()

    active = ActiveClientManager()
    pending = PendingClientManager()
    ongoing = OngoingClientManager()
    contact = ContactClientManager()

    @property
    def is_geolocalized(self):
        """
        Returns if the client's address is properly geolocalized.
        """
        if self.member.address.latitude is None or \
                self.member.address.longitude is None:
            return False
        return True

    @property
    def age(self):
        """
        Returns integer specifying person's age in years on the current date.

        To compare the Month and Day they need to be in the same year. Since
        either the birthday can be in a leap year or today can be Feb 29 of a
        leap year, we need to make sure to compare the days, in a leap year,
        therefore we are comparing in the year 2000, which was a leap year.

        >>> from datetime import date
        >>> p = Client(birthdate=date(1950, 4, 19)
        >>> p.age()
        66
        """
        today = datetime.date.today()
        if today < self.birthdate:
            age = 0
        elif datetime.date(2000, self.birthdate.month, self.birthdate.day) <= \
                datetime.date(2000, today.month, today.day):
            age = today.year - self.birthdate.year
        else:
            age = today.year - self.birthdate.year - 1
        return age

    @property
    def orders(self):
        """
        Returns orders associated to this client
        """
        return self.client_order.all()

    @property
    def food_preparation(self):
        """
        Returns specific food preparation associated to this client
        """
        return self.options.filter(option_group='preparation')

    @property
    def notes(self):
        """
        Returns notes associated to this client
        """
        return self.client_notes.all()

    @property
    def simple_meals_schedule(self):
        """
        Returns a list of days, corresponding to the client's delivery
        days.
        """
        for co in self.client_option_set.all():
            if co.option.name == 'meals_schedule':
                try:
                    return json.loads(co.value)
                except (ValueError, TypeError):  # JSON error
                    continue
        return None

    @property
    def meals_default(self):
        """
        Returns a list of tuple ((weekday, meal default), ...) that
        represents what the client wants on particular days.

        The "meal default" always contains all available components.
        If not set, it will be None.

        It is possible to have zero value, representing that the client
        has said no to a component on a particular day.
        """
        defaults = []
        for day, str in DAYS_OF_WEEK:
            current = {}
            numeric_fields = []
            for component, label in COMPONENT_GROUP_CHOICES:
                if component is COMPONENT_GROUP_CHOICES_SIDES:
                    continue  # skip "Sides"
                item = self.meal_default_week.get(component + '_' + day +
                                                  '_quantity')
                current[component] = item
                numeric_fields.append(item)

            size = self.meal_default_week.get('size_' + day)
            current['size'] = size

            defaults.append((day, current))

        return defaults

    @property
    def meals_schedule(self):
        """
        Filters `self.meals_default` based on `self.simple_meals_schedule`.
        Non-scheduled days are excluded from the result tuple.

        Intended to be called only for Ongoing clients. For episodic clients
        or if `simple_meals_schedule` is not set, it returns empty tuple.
        """
        defaults = self.meals_default
        prefs = []
        simple_meals_schedule = self.simple_meals_schedule

        if self.delivery_type == 'E' or simple_meals_schedule is None:
            return ()
        else:
            for day, meal_schedule in defaults:
                if day in simple_meals_schedule:
                    prefs.append((day, meal_schedule))
            return prefs

    def set_simple_meals_schedule(self, schedule):
        """
        Set the delivery days for the client.
        @param schedule
            A python list of days.
        """
        meal_schedule_option, _ = Option.objects.get_or_create(
            name='meals_schedule')
        client_option, _ = Client_option.objects.update_or_create(
            client=self,
            option=meal_schedule_option,
            defaults={'value': json.dumps(schedule)})