class Calibration(TimeStampedModel): id = SmallUUIDField(default=uuid_default(), primary_key=True, db_index=True, editable=False, verbose_name='ID') calibrator = models.ForeignKey('calibrations.Calibrator', related_name='calibrations', on_delete=models.CASCADE) start_date = models.DateTimeField() end_date = models.DateTimeField(db_index=True) r2 = models.FloatField() formula = models.CharField(max_length=255, blank=True, default='', validators=[validate_formula]) def __str__(self): return self.formula @property def days(self): return (self.end_date - self.start_date).days
class Calibration(TimeStampedModel): COUNTIES = Choices(*County.names) MONITOR_TYPES = lazy(lambda: Choices(*Monitor.subclasses()), list)() id = SmallUUIDField( default=uuid_default(), primary_key=True, db_index=True, editable=False, verbose_name='ID' ) monitor_type = models.CharField(max_length=20, choices=MONITOR_TYPES) county = models.CharField(max_length=20, choices=COUNTIES) pm25_formula = models.CharField(max_length=255, blank=True, default='', validators=[validate_formula]) class Meta: indexes = [ models.Index(fields=['monitor_type', 'county']) ] unique_together = [ ('monitor_type', 'county') ] def __str__(self): return f'{self.monitor_type} – {self.county}'
class Subscription(TimeStampedModel): LEVELS = LEVELS id = SmallUUIDField(default=uuid_default(), primary_key=True, db_index=True, editable=False, verbose_name='ID') user = models.ForeignKey( 'accounts.User', related_name='subscriptions', on_delete=models.CASCADE, ) monitor = models.ForeignKey( 'monitors.Monitor', related_name='subscriptions', on_delete=models.CASCADE, ) level = models.CharField(max_length=25, choices=LEVELS) class Meta: constraints = [ models.UniqueConstraint(fields=['user', 'monitor'], name='user_subscriptions') ] ordering = ['monitor__name'] def __str__(self): return f'{self.user_id} : {self.monitor_id} @ {self.level}'
class StatementImport(models.Model): """ Records an import of a bank statement Attributes: uuid (SmallUUID): UUID for statement import. Use to prevent leaking of IDs (if desired). timestamp (datetime): The datetime when the object was created. bank_account (Account): The account the import is for (should normally point to an asset account which represents your bank account) """ uuid = SmallUUIDField(default=uuid_default(), editable=False) timestamp = models.DateTimeField(default=timezone.now) # TODO: Add constraint to ensure destination account expects statements (copy 0007) bank_account = models.ForeignKey(Account, related_name="imports", on_delete=models.CASCADE) source = models.CharField( max_length=20, help_text="A value uniquely identifying where this data came from. " 'Examples: "csv", "teller.io".', ) extra = JSONField( default=json_default, help_text="Any extra data relating to the import, probably specific " "to the data source.", ) objects = StatementImportManager() def natural_key(self): return (self.uuid, )
class User(AbstractEmailUser): uuid = SmallUUIDField(editable=False, default=uuid_default()) first_name = models.CharField(max_length=31, null=True, blank=True) last_name = models.CharField(max_length=31, null=True, blank=True) objects = CaseInsensitiveEmailUserManager() current_campaign = models.ForeignKey( 'accounts.Campaign', on_delete=models.SET_NULL, null=True, blank=True, related_name='current_users', ) campaigns = models.ManyToManyField('accounts.Campaign', blank=True, through='accounts.CampaignRole') class Meta: ordering = ['last_name', 'first_name'] def __str__(self): return f'{self.name} <{self.email}>' @property def name(self): return f'{self.first_name} {self.last_name}'
class UUIDModel(models.Model): uuid = SmallUUIDField(default=uuid_default(), editable=False, primary_key=True) class Meta(object): abstract = True
class User(AbstractUser, TimeStampedModel): CITY_CHOICES = ( (1, 'بندرعباس'), (2, 'کرمان'), (3, 'یزد'), ) city = models.PositiveIntegerField(null=True, blank=True, default=1, verbose_name='شهر', choices=CITY_CHOICES) mobile = models.CharField(unique=True, max_length=14, null=True, blank=True, verbose_name='موبایل') mobile_verified = models.BooleanField(default=False, verbose_name='موبایل تأیید شده') email_verified = models.BooleanField( default=False, verbose_name='پست الکترونیکی تأیید شده') invitations_count = models.PositiveSmallIntegerField( null=True, blank=True, verbose_name='تعداد دعوتها', default=0) used_invite_code = models.BooleanField( default=False, verbose_name='از کد دعوت کاربر دیگر استفاده کردهاست') invite_id = SmallUUIDField(default=uuid_default(), null=True, blank=True, verbose_name='شناسه یکتا') MOBILE_FIELD = 'mobile' description = models.TextField(null=True, blank=True, verbose_name='توضیحات')
class Option(TimeStampedModel): uuid = SmallUUIDField(default=uuid_default()) name = models.CharField(max_length=200) class Meta: ordering = ['name'] def get_fitness_for_user(self, user): return self.get_fitness([user]) def get_fitness(self, users=None): if users is None: users = get_user_model().objects.all() fitnesses = [c.get_fitness(self, users) for c in Criterion.objects.all()] # Some criterion may not have been scored, in which case ignore them fitnesses = list(filter(lambda f: f is not None, fitnesses)) if fitnesses: return sum(fitnesses) else: return None def get_completed_users(self): total_criteria = Criterion.objects.count() res = Score.objects.filter(option=self).\ values('user').\ annotate(total_scores=Count('user')).\ order_by('total_scores') user_ids = [r['user'] for r in res if r['total_scores'] == total_criteria] return get_user_model().objects.filter(pk__in=user_ids)
class StorageItem(PartnerModel, UUIDModel, TimestampModel): token = SmallUUIDField(default=uuid_default()) app = EnumField(enums.FileType) file = models.FileField(storage=HighValueDownloadStorage()) email = models.EmailField(blank=True, null=True) expires = models.DateTimeField(default=storage_expire_date_time) first_download = models.DateTimeField(blank=True, null=True) class Meta: ordering = ["-created_at"] def get_absolute_url(self): return self.file.url def refresh_token(self): self.token = smalluuid.SmallUUID() self.expires = storage_expire_date_time() self.save(update_fields=["token", "expires"]) return self.token def validate_token(self, token): return token == str(self.token) @property def download_url(self): path = reverse("storage:download", kwargs={"pk": self.pk}) return f"{settings.PRIMARY_ORIGIN}{path}?token={self.token}" @property def reset_url(self): return settings.FILE_TOKEN_RESET_URL.format(item_id=self.pk)
class Alert(TimeStampedModel): LEVELS = LEVELS id = SmallUUIDField(default=uuid_default(), primary_key=True, db_index=True, editable=False, verbose_name='ID') start_time = models.DateTimeField() end_time = models.DateTimeField(blank=True, null=True) monitor = models.ForeignKey('monitors.Monitor', related_name='alerts', on_delete=models.CASCADE) pm25_average = models.DecimalField(max_digits=8, decimal_places=2, null=True) level = models.CharField(max_length=25, choices=LEVELS) class Meta: ordering = ['-start_time'] def __str__(self): return f'{self.monitor_id} : {self.get_level_display()}' def get_average(self, hours=1): end_time = timezone.now() start_time = end_time - timedelta(hours=hours) return (self.monitor.entries.filter( timestamp__range=(start_time, end_time)).aggregate( average=Avg('pm25'))['average']) def send_notifications(self): # If SMS alerts are disbaled, do nothing. if not settings.SEND_SMS_ALERTS: return values = [level[0] for level in LEVELS] notification_levels = values[:values.index(self.level) + 1] message = '\n'.join([ f'Air Quality Alert in {self.monitor.county} County for {self.monitor.name} ({self.monitor.device})', '', f'{self.get_level_display()}: {GUIDANCE[self.level]}', '', f'https://sjvair.com{self.monitor.get_absolute_url()}', ]) queryset = (Subscription.objects.filter( monitor_id=self.monitor.pk).select_related('user')) # hazardous means everyone gets it. Evey other # level gets filtered. if self.level != LEVELS.hazardous: queryset = queryset.filter(level=self.level) for subscription in queryset: subscription.user.send_sms(message)
class Transaction(models.Model): """ Represents a transaction A transaction is a movement of funds between two accounts. Each transaction will have two or more legs, each leg specifies an account and an amount. .. note: When working with Hordak Transaction objects you will typically need to do so within a database transaction. This is because the database has integrity checks in place to ensure the validity of the transaction (i.e. money in = money out). See Also: :meth:`Account.transfer_to()` is a useful shortcut to avoid having to create transactions manually. Examples: You can manually create a transaction as follows:: from django.db import transaction as db_transaction from hordak.models import Transaction, Leg with db_transaction.atomic(): transaction = Transaction.objects.create() Leg.objects.create(transaction=transaction, account=my_account1, amount=Money(100, 'EUR')) Leg.objects.create(transaction=transaction, account=my_account2, amount=Money(-100, 'EUR')) Attributes: uuid (SmallUUID): UUID for transaction. Use to prevent leaking of IDs (if desired). timestamp (datetime): The datetime when the object was created. date (date): The date when the transaction actually occurred, as this may be different to :attr:`timestamp`. description (str): Optional user-provided description """ uuid = SmallUUIDField(default=uuid_default(), editable=False) timestamp = models.DateTimeField( default=timezone.now, help_text="The creation date of this transaction object") date = models.DateField( default=timezone.now, help_text="The date on which this transaction occurred") description = models.TextField(default="", blank=True) objects = TransactionManager() class Meta: get_latest_by = "date" def balance(self): return self.legs.sum_to_balance() def natural_key(self): return (self.uuid, )
class UUIDModel(models.Model): """Abstract model that adds a UUID Field""" uuid = SmallUUIDField(default=uuid_default(), editable=False, db_index=True) class Meta(object): """Meta info for TimeStamp abstract model""" abstract = True
class Category(TimeStampedModel): uuid = SmallUUIDField(default=uuid_default()) name = models.CharField(max_length=200) order_num = models.IntegerField() class Meta: verbose_name_plural = 'categories' ordering = ['order_num'] def __str__(self): return self.name or 'Unnamed Category' def save(self, **kwargs): if self.order_num is None: self.order_num = Category.objects.count() super(Category, self).save(**kwargs) def get_average_weight(self, *a, **kw): res = Weight.objects.filter(criterion__category=self, **kw).aggregate(Avg('value')) return res['value__avg'] def get_total_weight(self, *a, **kw): res = Weight.objects.filter(criterion__category=self, **kw).aggregate(Sum('value')) return res['value__sum'] def get_average_score(self, option, *a, **kw): res = Score.objects.filter(criterion__category=self, option=option, **kw).aggregate(Avg('value')) return res['value__avg'] def get_total_fitness(self, option): total = None for criterion in self.criteria.all(): fitness = criterion.get_fitness(option) if fitness is not None: if total is None: total = 0 total += fitness return total def get_total_fitness_for_user(self, option, user): total = None for criterion in self.criteria.all(): fitness = criterion.get_fitness_for_user(option, user) if fitness is not None: if total is None: total = 0 total += fitness return total def get_normalised_fitness(self, option, *a, **kw): total_fitness = self.get_total_fitness(option, *a, **kw) total_weight = self.get_total_weight(*a, **kw) if total_fitness is None or total_weight is None: return None else: return total_fitness / total_weight
class UserOwnedModel(models.Model): class Meta: abstract = True user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s_owned_set", editable=False) uuid = SmallUUIDField(editable=False, default=uuid_default()) objects = UserModelManager()
class Weight(TimeStampedModel): uuid = SmallUUIDField(default=uuid_default()) user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='weights') criterion = models.ForeignKey('criterion', related_name='weights') value = models.IntegerField() class Meta: unique_together = ( ('user', 'criterion'), )
class CampaignOwnedModel(models.Model): class Meta: abstract = True campaign = models.ForeignKey( 'accounts.Campaign', on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s_owned_set", editable=False) uuid = SmallUUIDField(editable=False, default=uuid_default()) objects = CampaignModelManager()
class Housemate(models.Model): uuid = SmallUUIDField(default=uuid_default(), editable=False) account = models.OneToOneField(Account, related_name='housemate', unique=True) user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name='housemate', blank=True, null=True, unique=True)
class RecurringCostSplit(models.Model): """Represents how a recurring cost should be split between accounts (i.e. housemates)""" uuid = SmallUUIDField(default=uuid_default(), editable=False) recurring_cost = models.ForeignKey(RecurringCost, related_name='splits') from_account = models.ForeignKey('hordak.Account') portion = models.DecimalField(max_digits=13, decimal_places=2, default=1) objects = models.Manager.from_queryset(RecurringCostSplitQuerySet)() class Meta: base_manager_name = 'objects' unique_together = (('recurring_cost', 'from_account'), )
class Campaign(models.Model): uuid = SmallUUIDField(editable=False, default=uuid_default()) name = models.CharField(max_length=63) creation_date = models.DateField(auto_now_add=True) active_encounter = models.OneToOneField('plot.Encounter', on_delete=models.SET_NULL, null=True, blank=True, related_name='campaign_active_in') def __str__(self): return self.name
class User(AbstractBaseUser, PermissionsMixin, models.Model): id = SmallUUIDField(default=uuid_default(), primary_key=True, db_index=True, editable=False, verbose_name='ID') full_name = models.CharField(max_length=100) email = models.EmailField(unique=True, db_index=True) # Normally provided by auth.AbstractUser, but we're not using that here. date_joined = models.DateTimeField(_('date joined'), default=timezone.now, editable=False) is_active = models.BooleanField( _('active'), default=True, help_text=_('Designates whether this user should be treated as ' 'active. Unselect this instead of deleting accounts.')) is_staff = models.BooleanField( _('staff status'), default=False, help_text=_( 'Designates whether the user can log into this admin site.') ) # Required for Django Admin, for tenant staff/admin see role EMAIL_FIELD = 'email' USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['full_name'] objects = managers.UserManager() class Meta: ordering = ('-date_joined', ) def get_name(self): name = HumanName(self.full_name) name.capitalize() return name def set_name(self, value): self.full_name = value del self.name name = cached_property(get_name) name.setter = set_name def get_short_name(self): return self.name.first def get_full_name(self): return self.name
class Invite(UUIDModel, TimestampModel): email = models.EmailField() token = SmallUUIDField(default=uuid_default(), editable=False) user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL, editable=False) expires = models.DateTimeField(default=expire_date_time) consumed_at = models.DateTimeField(null=True, editable=False) clients = models.ManyToManyField("multi_tenant.Client", through="multi_tenant.InviteAssociation") primary_client = models.ForeignKey( "multi_tenant.Client", related_name="invited_client", on_delete=models.PROTECT, ) objects = models.Manager() actives = ActiveInvitesManager() class Meta(object): verbose_name = _("Invite") verbose_name_plural = _("Invites") ordering = ["created_at"] def __str__(self): return f"{self.email} {self.token}" @property def expired(self): return self.expires <= now() @property def full_url(self): path = reverse("accounts:consume_invite", kwargs={"slug": self.token}) return f"{settings.PRIMARY_ORIGIN}{path}" def consume_invite(self, user): new_associations = [] new_associations.append( Association(client=self.primary_client, user=user)) for client in self.clients.exclude(pk=self.primary_client.pk): new_associations.append(Association(client=client, user=user)) Association.objects.bulk_create(new_associations) Invite.objects.filter(email__iexact=user.email).update( user=user, consumed_at=now()) def get_absolute_url(self): return reverse("accounts:consume_invite", kwargs={"slug": self.token})
class Post(models.Model): title = models.CharField(max_length=200, blank=False) text = models.TextField() uuid = SmallUUIDField(default=uuid_default()) pub_date = models.DateTimeField('date published', default=timezone.now) user = models.ForeignKey(User, on_delete=models.CASCADE, blank=True, null=True) @classmethod def create_post(self, title, text): posti = self(title=title, text=text) return posti
class StatementImport(models.Model): """ Records an import of a bank statement Attributes: uuid (SmallUUID): UUID for statement import. Use to prevent leaking of IDs (if desired). timestamp (datetime): The datetime when the object was created. bank_account (Account): The account the import is for (should normally point to an asset account which represents your bank account) """ uuid = SmallUUIDField(default=uuid_default(), editable=False) timestamp = models.DateTimeField(default=timezone.now) # TODO: Add constraint to ensure destination account expects statements (copy 0007) bank_account = models.ForeignKey(Account, related_name='imports') objects = StatementImportManager() def natural_key(self): return (self.uuid, )
class Account(MPTTModel): """ Represents an account An account may have a parent, and may have zero or more children. Only root accounts can have a type, all child accounts are assumed to have the same type as their parent. An account's balance is calculated as the sum of all of the transaction Leg's referencing the account. Attributes: uuid (SmallUUID): UUID for account. Use to prevent leaking of IDs (if desired). name (str): Name of the account. Required. parent (Account|None): Parent account, nonen if root account code (str): Account code. Must combine with account codes of parent accounts to get fully qualified account code. type (str): Type of account as defined by :attr:`Account.TYPES`. Can only be set on root accounts. Child accounts are assumed to have the same time as their parent. TYPES (Choices): Available account types. Uses ``Choices`` from ``django-model-utils``. Types can be accessed in the form ``Account.TYPES.asset``, ``Account.TYPES.expense``, etc. is_bank_account (bool): Is this a bank account. This implies we can import bank statements into it and that it only supports a single currency. """ TYPES = Choices( ("AS", "asset", "Asset"), # Eg. Cash in bank ("LI", "liability", "Liability"), # Eg. Loans, bills paid after the fact (in arrears) ("IN", "income", "Income"), # Eg. Sales, housemate contributions ("EX", "expense", "Expense"), # Eg. Office supplies, paying bills ("EQ", "equity", "Equity"), # Eg. Money from shares ("TR", "trading", "Currency Trading"), # Used to represent currency conversions ) uuid = SmallUUIDField(default=uuid_default(), editable=False) name = models.CharField(max_length=50) parent = TreeForeignKey( "self", null=True, blank=True, related_name="children", db_index=True, on_delete=models.CASCADE, ) code = models.CharField(max_length=3, null=True, blank=True) full_code = models.CharField(max_length=100, db_index=True, unique=True, null=True, blank=True) # TODO: Implement this child_code_width field, as it is probably a good idea # child_code_width = models.PositiveSmallIntegerField(default=1) type = models.CharField(max_length=2, choices=TYPES, blank=True) is_bank_account = models.BooleanField( default=False, blank=True, help_text="Is this a bank account. This implies we can import bank " "statements into it and that it only supports a single currency", ) currencies = ArrayField(models.CharField(max_length=3), db_index=True) objects = AccountManager.from_queryset(AccountQuerySet)() class MPTTMeta: order_insertion_by = ["code"] class Meta: unique_together = (("parent", "code"), ) def __init__(self, *args, **kwargs): super(Account, self).__init__(*args, **kwargs) self._initial_code = self.code def save(self, *args, **kwargs): is_creating = not bool(self.pk) if is_creating: update_fields = None else: # See issues #19 & #31. It seems that on Django 1.2, django-mptt's left/right # tree fields get overwritten on save. The solution here is to exclude them from # being modified upon saving by using the save methods' update_fields argument. update_fields = [ "uuid", "name", "parent", "code", "type", "is_bank_account", "currencies", ] super(Account, self).save(*args, update_fields=update_fields, **kwargs) do_refresh = False # If we've just created a non-root node then we're going to need to load # the type back from the DB (as it is set by trigger) if is_creating and not self.is_root_node(): do_refresh = True # If we've just create this account or if the code has changed then we're # going to need to reload from the DB (full_code is set by trigger) if is_creating or self._initial_code != self.code: do_refresh = True if do_refresh: self.refresh_from_db() @classmethod def validate_accounting_equation(cls): """Check that all accounts sum to 0""" balances = [ account.balance(raw=True) for account in Account.objects.root_nodes() ] if sum(balances, Balance()) != 0: raise exceptions.AccountingEquationViolationError( "Account balances do not sum to zero. They sum to {}".format( sum(balances))) def __str__(self): name = self.name or "Unnamed Account" if self.is_leaf_node(): try: balance = self.balance() except ValueError: if self.full_code: return "{} {}".format(self.full_code, name) else: return name else: if self.full_code: return "{} {} [{}]".format(self.full_code, name, balance) else: return "{} [{}]".format(name, balance) else: return name def natural_key(self): return (self.uuid, ) @property def sign(self): """ Returns 1 if a credit should increase the value of the account, or -1 if a credit should decrease the value of the account. This is based on the account type as is standard accounting practice. The signs can be derrived from the following expanded form of the accounting equation: Assets = Liabilities + Equity + (Income - Expenses) Which can be rearranged as: 0 = Liabilities + Equity + Income - Expenses - Assets Further details here: https://en.wikipedia.org/wiki/Debits_and_credits """ return -1 if self.type in (Account.TYPES.asset, Account.TYPES.expense) else 1 def balance(self, as_of=None, raw=False, leg_query=None, **kwargs): """Get the balance for this account, including child accounts Args: as_of (Date): Only include transactions on or before this date raw (bool): If true the returned balance should not have its sign adjusted for display purposes. kwargs (dict): Will be used to filter the transaction legs Returns: Balance See Also: :meth:`simple_balance()` """ balances = [ account.simple_balance(as_of=as_of, raw=raw, leg_query=leg_query, **kwargs) for account in self.get_descendants(include_self=True) ] return sum(balances, Balance()) def simple_balance(self, as_of=None, raw=False, leg_query=None, **kwargs): """Get the balance for this account, ignoring all child accounts Args: as_of (Date): Only include transactions on or before this date raw (bool): If true the returned balance should not have its sign adjusted for display purposes. leg_query (models.Q): Django Q-expression, will be used to filter the transaction legs. allows for more complex filtering than that provided by **kwargs. kwargs (dict): Will be used to filter the transaction legs Returns: Balance """ legs = self.legs if as_of: legs = legs.filter(transaction__date__lte=as_of) if leg_query or kwargs: leg_query = leg_query or models.Q() legs = legs.filter(leg_query, **kwargs) return legs.sum_to_balance() * (1 if raw else self.sign) + self._zero_balance() def _zero_balance(self): """Get a balance for this account with all currencies set to zero""" return Balance([Money("0", currency) for currency in self.currencies]) @db_transaction.atomic() def transfer_to(self, to_account, amount, **transaction_kwargs): """Create a transaction which transfers amount to to_account This is a shortcut utility method which simplifies the process of transferring between accounts. This method attempts to perform the transaction in an intuitive manner. For example: * Transferring income -> income will result in the former decreasing and the latter increasing * Transferring asset (i.e. bank) -> income will result in the balance of both increasing * Transferring asset -> asset will result in the former decreasing and the latter increasing .. note:: Transfers in any direction between ``{asset | expense} <-> {income | liability | equity}`` will always result in both balances increasing. This may change in future if it is found to be unhelpful. Transfers to trading accounts will always behave as normal. Args: to_account (Account): The destination account. amount (Money): The amount to be transferred. transaction_kwargs: Passed through to transaction creation. Useful for setting the transaction `description` field. """ if not isinstance(amount, Money): raise TypeError("amount must be of type Money") if to_account.sign == 1 and to_account.type != self.TYPES.trading: # Transferring from two positive-signed accounts implies that # the caller wants to reduce the first account and increase the second # (which is opposite to the implicit behaviour) direction = -1 elif self.type == self.TYPES.liability and to_account.type == self.TYPES.expense: # Transfers from liability -> asset accounts should reduce both. # For example, moving money from Rent Payable (liability) to your Rent (expense) account # should use the funds you've built up in the liability account to pay off the expense account. direction = -1 else: direction = 1 transaction = Transaction.objects.create(**transaction_kwargs) Leg.objects.create(transaction=transaction, account=self, amount=+amount * direction) Leg.objects.create(transaction=transaction, account=to_account, amount=-amount * direction) return transaction
class StatementLine(models.Model): """ Records an single imported bank statement line A StatementLine is purely a utility to aid in the creation of transactions (in the process known as reconciliation). StatementLines have no impact on account balances. However, the :meth:`StatementLine.create_transaction()` method can be used to create a transaction based on the information in the StatementLine. Attributes: uuid (SmallUUID): UUID for statement line. Use to prevent leaking of IDs (if desired). timestamp (datetime): The datetime when the object was created. date (date): The date given by the statement line statement_import (StatementImport): The import to which the line belongs amount (Decimal): The amount for the statement line, positive or nagative. description (str): Any description/memo information provided transaction (Transaction): Optionally, the transaction created for this statement line. This normally occurs during reconciliation. See also :meth:`StatementLine.create_transaction()`. """ uuid = SmallUUIDField(default=uuid_default(), editable=False) timestamp = models.DateTimeField(default=timezone.now) date = models.DateField() statement_import = models.ForeignKey(StatementImport, related_name="lines", on_delete=models.CASCADE) amount = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES) description = models.TextField(default="", blank=True) type = models.CharField(max_length=50, default="") # TODO: Add constraint to ensure transaction amount = statement line amount # TODO: Add constraint to ensure one statement line per transaction transaction = models.ForeignKey( Transaction, default=None, blank=True, null=True, help_text="Reconcile this statement line to this transaction", on_delete=models.SET_NULL, ) source_data = JSONField( default=json_default, help_text="Original data received from the data source.") objects = StatementLineManager() def natural_key(self): return (self.uuid, ) @property def is_reconciled(self): """Has this statement line been reconciled? Determined as ``True`` if :attr:`transaction` has been set. Returns: bool: ``True`` if reconciled, ``False`` if not. """ return bool(self.transaction) @db_transaction.atomic() def create_transaction(self, to_account): """Create a transaction for this statement amount and account, into to_account This will also set this StatementLine's ``transaction`` attribute to the newly created transaction. Args: to_account (Account): The account the transaction is into / out of. Returns: Transaction: The newly created (and committed) transaction. """ from_account = self.statement_import.bank_account transaction = Transaction.objects.create() Leg.objects.create(transaction=transaction, account=from_account, amount=+(self.amount * -1)) Leg.objects.create(transaction=transaction, account=to_account, amount=-(self.amount * -1)) transaction.date = self.date transaction.save() self.transaction = transaction self.save() return transaction
class Leg(models.Model): """ The leg of a transaction Represents a single amount either into or out of a transaction. All legs for a transaction must sum to zero, all legs must be of the same currency. Attributes: uuid (SmallUUID): UUID for transaction leg. Use to prevent leaking of IDs (if desired). transaction (Transaction): Transaction to which the Leg belongs. account (Account): Account the leg is transferring to/from. amount (Money): The amount being transferred description (str): Optional user-provided description type (str): :attr:`hordak.models.DEBIT` or :attr:`hordak.models.CREDIT`. """ uuid = SmallUUIDField(default=uuid_default(), editable=False) transaction = models.ForeignKey(Transaction, related_name="legs", on_delete=models.CASCADE) account = models.ForeignKey(Account, related_name="legs", on_delete=models.CASCADE) amount = MoneyField( max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, help_text="Record debits as positive, credits as negative", default_currency=defaults.INTERNAL_CURRENCY, ) description = models.TextField(default="", blank=True) objects = LegManager.from_queryset(LegQuerySet)() def save(self, *args, **kwargs): if self.amount.amount == 0: raise exceptions.ZeroAmountError() return super(Leg, self).save(*args, **kwargs) def natural_key(self): return (self.uuid, ) @property def type(self): if self.amount.amount < 0: return DEBIT elif self.amount.amount > 0: return CREDIT else: # This should have been caught earlier by the database integrity check. # If you are seeing this then something is wrong with your DB checks. raise exceptions.ZeroAmountError() def is_debit(self): return self.type == DEBIT def is_credit(self): return self.type == CREDIT def account_balance_after(self): """Get the balance of the account associated with this leg following the transaction""" # TODO: Consider moving to annotation, particularly once we can count on Django 1.11's subquery support transaction_date = self.transaction.date return self.account.balance(leg_query=( models.Q(transaction__date__lt=transaction_date) | (models.Q(transaction__date=transaction_date) & models.Q(transaction_id__lte=self.transaction_id)))) def account_balance_before(self): """Get the balance of the account associated with this leg before the transaction""" # TODO: Consider moving to annotation, particularly once we can count on Django 1.11's subquery support transaction_date = self.transaction.date return self.account.balance( leg_query=(models.Q(transaction__date__lt=transaction_date) | (models.Q(transaction__date=transaction_date) & models.Q(transaction_id__lt=self.transaction_id))))
class TransactionCsvImport(models.Model): STATES = Choices( ('pending', 'Pending'), ('uploaded', 'Uploaded, ready to import'), ('done', 'Import complete'), ) uuid = SmallUUIDField(default=uuid_default(), editable=False) timestamp = models.DateTimeField(default=timezone.now, editable=False) has_headings = models.BooleanField( default=True, verbose_name='First line of file contains headings') file = models.FileField(upload_to='transaction_imports', verbose_name='CSV file to import') state = models.CharField(max_length=20, choices=STATES, default='pending') date_format = models.CharField(choices=DATE_FORMATS, max_length=50, default='%d-%m-%Y', null=False) hordak_import = models.ForeignKey('hordak.StatementImport', on_delete=models.CASCADE) def _get_csv_reader(self): # TODO: Refactor to support multiple readers (xls, quickbooks, etc) csv_buffer = StringIO(self.file.read().decode()) return csv.reader(csv_buffer) def create_columns(self): """For each column in file create a TransactionCsvImportColumn""" reader = self._get_csv_reader() headings = six.next(reader) try: examples = six.next(reader) except StopIteration: examples = [] found_fields = set() for i, value in enumerate(headings): if i >= 20: break infer_field = self.has_headings and value not in found_fields to_field = { 'date': 'date', 'amount': 'amount', 'description': 'description', 'memo': 'description', 'notes': 'description', }.get(value.lower(), '') if infer_field else '' if to_field: found_fields.add(to_field) TransactionCsvImportColumn.objects.update_or_create( transaction_import=self, column_number=i + 1, column_heading=value if self.has_headings else '', to_field=to_field, example=examples[i].strip() if examples else '', ) def get_dataset(self): reader = self._get_csv_reader() if self.has_headings: six.next(reader) data = list(reader) headers = [ column.to_field or 'col_%s' % column.column_number for column in self.columns.all() ] return Dataset(*data, headers=headers)
class User(TimestampModel, OrganizationMixin, auth_models.AbstractBaseUser, auth_models.PermissionsMixin): """Kenedy application user""" username = SmallUUIDField('Username', max_length=150, unique=True, default=uuid_default(), editable=False) is_staff = models.BooleanField(_('Staff Status'), default=False) is_active = models.BooleanField(_('Active'), default=True, db_index=True) email = models.EmailField(_('Email Address'), editable=False) first_name = models.CharField(_('First Name'), max_length=100, blank=True) last_name = models.CharField(_('Last Name'), max_length=200, blank=True) locations = models.ManyToManyField('location.Location', through='location.UserLocation', related_name='userlocations_set') location = models.ForeignKey('location.Location') unsubscribed = models.BooleanField(default=False) # The actual username field will be email, but we can't add a unique # constraint on email, while we can on username USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email', 'organization'] objects = UserManager() class Meta(object): """Meta options for the model""" unique_together = ('email', 'organization') indexes = [ models.Index(fields=['organization', 'is_active', 'unsubscribed'], name="org_active_unsub_idx"), models.Index(fields=['organization', 'email'], name="org_email_idx") ] ordering = ['pk'] def __unicode__(self): """Unicode representation of the user""" if self.first_name and self.last_name: return self.first_name + u' ' + self.last_name if self.first_name: return self.first_name return self.email def get_short_name(self): """Short name representation of user""" return self.first_name @property def to_address(self): """Address to be used in the 'To' field in emails""" return u'"{0}" <{1}>'.format( unicode(Header(unicode(self), 'utf-8')).replace(u'"', u"'"), self.email) @cached_property def state(self): """State the user is in""" return self.location.state def manage_url(self, email): """The URL a user can visit to manage their profile and unsubscribe""" return 'https://{domain}{path}?email={email_uuid}'.format( domain=self.organization.primary_domain.hostname, path=reverse('accounts:self_update_user', kwargs={'slug': self.username}), email_uuid=email.uuid) def unsubscribe_url(self, email): """URL the user can visit to unsubscribe""" # pylint: disable=line-too-long return 'https://{domain}{path}?email={email_uuid}&user={user_uuid}'.format( domain=self.organization.primary_domain.hostname, path=reverse('unsubscribe:unsubscribe'), email_uuid=email.uuid, user_uuid=self.username)
class Invitation(models.Model): """Stores information during the process of both parties approving a link between a User and an Account.""" EXPIRE_TIMEDELTA = datetime.timedelta(days=7) uuid = SmallUUIDField(editable=False, default=uuid_default()) creation_date = models.DateField(auto_now_add=True) completion_date = models.DateField(editable=False, null=True, blank=True) campaign = models.ForeignKey('accounts.Campaign', on_delete=models.CASCADE) joiner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) approver = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name='invites_approved', ) joiner_external_identifier = models.TextField(null=True, blank=True) def __str__(self): return f"{self.joiner_external_identifier} -> {self.campaign!s}" class Expired(Exception): pass class ApproverNotAuthorized(Exception): pass def _validate(self, approving_user, accepting_user): assert approving_user, accepting_user if not approving_user.campaigns.filter( campaignrole__is_gm=True).filter(id=self.campaign_id): raise self.ApproverNotAuthorized if datetime.date.today() > self.creation_date + self.EXPIRE_TIMEDELTA: raise self.Expired def accept(self, accepting_user=None): self._validate(self.approver, accepting_user or self.joiner) self._complete(self.approver, accepting_user or self.joiner) def reject(self): # reject intentionally gives no information to the inviter that the invitee # has seen the message; it will just hang out in inviter's queue until it expires self.joiner = None self.save() def approve(self, approving_user): self._validate(approving_user, self.joiner) self._complete(approving_user, self.joiner) def _complete(self, approving_user, accepting_user): CampaignRole.objects.create(user=accepting_user, campaign=self.campaign) self.approver = approving_user self.joiner = accepting_user self.completion_date = datetime.date.today() self.save() @classmethod def get_current_invites(cls): earliest_allowed_creation_date = datetime.date.today( ) - datetime.timedelta(days=7) return cls.objects.filter( creation_date__gt=earliest_allowed_creation_date).filter( completion_date__isnull=True)
class Calibrator(TimeStampedModel): id = SmallUUIDField(default=uuid_default(), primary_key=True, db_index=True, editable=False, verbose_name='ID') reference = models.ForeignKey('monitors.Monitor', related_name='reference_calibrator', on_delete=models.CASCADE) # TODO: What if this has gone inactive? colocated = models.ForeignKey('monitors.Monitor', related_name='colocated_calibrator', on_delete=models.CASCADE) is_active = models.BooleanField(default=False) calibration = models.OneToOneField('calibrations.Calibration', blank=True, null=True, related_name='calibrator_current', on_delete=models.SET_NULL) def get_distance(self): return geopy_distance( (self.reference.position.y, self.reference.position.x), (self.colocated.position.y, self.colocated.position.x), ) def calibrate(self, end_date=None): # assert self.reference.is_active # assert self.colocated.is_active if end_date is None: end_date = timezone.now().date() # Set of coefficients to test and the # formulas for their calibrations formulas = [ (['particles_03-10', 'particles_10-25', 'humidity'], lambda reg: ' + '.join([ f"((particles_03um - particles_10um) * {reg.coef_[0]})", f"((particles_10um - particles_25um) * {reg.coef_[1]})", f"(humidity * {reg.coef_[2]})" f"{reg.intercept_}", ])), (['particles_10-25', 'particles_25-05', 'humidity'], lambda reg: ' + '.join([ f"((particles_10um - particles_25um) * {reg.coef_[0]})", f"((particles_25um - particles_05um) * {reg.coef_[1]})", f"(humidity * {reg.coef_[2]})", f"{reg.intercept_}", ])) ] results = list( filter(bool, [ self.generate_calibration( coefs=coefs, formula=formula, end_date=end_date, days=days, ) for (coefs, formula ), days in itertools.product(formulas, [7, 14, 21, 28]) ])) print( f'{self.reference.name} / {self.colocated.name} ({self.get_distance().meters} m)' ) pprint(results) print('\n') if results: # Sort by R2 (highest last)... results.sort(key=lambda i: i['r2']) # ...and save the calibration self.calibrations.create(**results[-1]) self.calibration = self.calibrations.order_by('end_date').first() def generate_calibration(self, coefs, formula, end_date, days): start_date = end_date - timedelta(days=days) # Load the reference entries into a DataFrame, sampled hourly. ref_qs = (self.reference.entries.filter( timestamp__date__range=(start_date, end_date)).annotate( ref_pm25=F('pm25')).values('timestamp', 'ref_pm25')) if not ref_qs.exists(): print( f'Calibrator {self.pk}: no ref_qs ({start_date} - {end_date})') return ref_df = pd.DataFrame(ref_qs).set_index('timestamp') ref_df = pd.to_numeric(ref_df.ref_pm25) # ref_df = ref_df.ref_pm25.astype('float') ref_df = ref_df.resample('H').mean() # Load the colocated entries into a DataFrame col_qs = (self.colocated.entries.filter( timestamp__date__range=(start_date, end_date), sensor=self.colocated.default_sensor, ).annotate(col_pm25=F('pm25')).values('timestamp', 'col_pm25', 'humidity', 'particles_03um', 'particles_05um', 'particles_10um', 'particles_25um')) if not col_qs.exists(): print( f'Calibrator {self.pk}: no col_qs ({start_date} - {end_date})') return col_df = pd.DataFrame(col_qs).set_index('timestamp') # Convert columns to floats # cols = df.columns[df.dtypes.eq('object')] col_df[col_df.columns] = col_df[col_df.columns].apply(pd.to_numeric, errors='coerce') # particle count calculations and hourly sample col_df['particles_03-10'] = col_df['particles_03um'] - col_df[ 'particles_10um'] col_df['particles_10-25'] = col_df['particles_10um'] - col_df[ 'particles_25um'] col_df['particles_25-05'] = col_df['particles_25um'] - col_df[ 'particles_05um'] col_df = col_df.resample('H').mean() # Merge the dataframes merged = pd.concat([ref_df, col_df], axis=1, join="inner") merged = merged.dropna() if not len(merged): print( f'Calibrator {self.pk}: no merged ({start_date} - {end_date})') return # Linear Regression time! endog = merged['ref_pm25'] exog = merged[coefs] try: reg = LinearRegression() reg.fit(exog, endog) except ValueError as err: import code code.interact(local=locals()) return { 'start_date': start_date, 'end_date': end_date, 'r2': reg.score(exog, endog), 'formula': formula(reg), }