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()
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 ]) ])
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)
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)
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)
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)
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='')
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)})