4
\$\begingroup\$

How can I Pythonize/Djangoize/DRY my Django code?

views.py

class AccountDetail(DetailView):
    model = BankAccount
    template_name = 'bank/account_detail.html'

    def get(self, *args, **kwargs):
        self.object = self.get_object()

        if self.object.is_owner(self.request.user.citizen):
            return render(self.request, self.template_name, self.get_context_data())
        raise Http404


class SendTransfer(SingleObjectMixin, FormView):
    model = BankAccount
    form_class = SendTransferForm
    template_name = 'bank/send_transfer.html'

    def dispatch(self, request, *args, **kwargs):
        self.object = self.get_object()
        return super(SendTransfer, self).dispatch(request, *args, **kwargs)

    def get_object(self, queryset=None):
        obj = super(SendTransfer, self).get_object(queryset)

        if not obj.is_owner(self.request.user.citizen):
            raise Http404

        return obj

    def form_valid(self, form):
        data = form.cleaned_data
        MoneyTransfer.objects.create(sender=self.object,
                                     receiver=data['receiver'],  # ModelChoiceField in the form
                                     total=data['total'],  # FloatField in the form, etc.
                                     when=timezone.localtime(timezone.now()),
                                     comment=data['comment'])
        return redirect('AccountDetail', self.object.pk)

    def form_invalid(self, form):
        return render(self.request, self.template_name, self.get_context_data())

    def get_form_kwargs(self):
        kwargs = super(SendTransfer, self).get_form_kwargs()
        kwargs['sender'] = BankAccount.objects.get(id=self.kwargs['pk'])
        kwargs['user'] = self.request.user
        return kwargs


class OutcomeTransfers(DetailView):
    model = BankAccount
    template_name = 'bank/report.html'

    def get_object(self, queryset=None):
        obj = super(OutcomeTransfers, self).get_object(queryset)

        if not obj.is_owner(self.request.user.citizen):
            raise Http404

        return obj

    def _get_queryset(self):
        return self.model.objects.get(id=self.kwargs['pk']).outcome_transfers.all()

    def get_context_data(self, **kwargs):
        data = super(OutcomeTransfers, self).get_context_data()
        queryset = self._get_queryset()
        if 'sort' in self.request.GET:
            queryset = queryset.order_by(self.request.GET['sort'])
        data['table'] = MoneyTransferTable(queryset)
        return data


class IncomeTransfers(DetailView):
    model = BankAccount
    template_name = 'bank/report.html'

    def get_object(self, queryset=None):
        obj = super(IncomeTransfers, self).get_object(queryset)

        if not obj.is_owner(self.request.user.citizen):
            raise Http404

        return obj

    def _get_queryset(self):
        return self.model.objects.get(id=self.kwargs['pk']).income_transfers.all()

    def get_context_data(self, **kwargs):
        data = super(IncomeTransfers, self).get_context_data()
        queryset = self._get_queryset()
        if 'sort' in self.request.GET:
            queryset = queryset.order_by(self.request.GET['sort'])
        data['table'] = MoneyTransferTable(queryset)
        return data

models.py

class RuleQuerySet(models.QuerySet):

    def on_date(self, _date):
        return self.get(start_date__lte=_date, end_date__gte=_date)


class TransferQuerySet(models.QuerySet):

    def on_date(self, _date):
        return self.filter(when__lt=_date + timedelta(days=1), when__gte=_date)


class Rule(models.Model):
    start_date = models.DateField()
    end_date = models.DateField()

    deposit_percent = models.DecimalField(max_digits=10, decimal_places=3)
    credit_percent = models.DecimalField(max_digits=10, decimal_places=3)

    objects = RuleQuerySet.as_manager()


class BankAccount(models.Model):

    organization = models.OneToOneField(Organization, related_name='bank_account', blank=True, null=True)
    citizen = models.OneToOneField(Citizen, related_name='bank_account', blank=True, null=True)

    DEBET = 1
    CREDIT = 2

    method = models.PositiveSmallIntegerField(choices=(
        (DEBET, 'Дебет'),
        (CREDIT, 'Кредит'),
    ))

    when_opened = models.DateField()

    @property
    def owner(self):
        if self.is_legal():
            return self.organization
        return self.citizen

    def __unicode__(self):
        prefix = "L" if self.is_legal() else "N"
        prefix += "D" if self.method == self.DEBET else "C"
        public_id = str(self.id).zfill(4)
        return "%s (#%s)" % (self.owner, prefix + public_id)

    def is_legal(self):
        if self.organization and self.citizen:
            raise ValueError("Account is legal and natural at the same time. Make it ")
        return self.organization is not None

    def is_owner(self, citizen):
        return (self.citizen and self.citizen.id == citizen.id) or \
               (self.organization and self.organization.owners.all().filter(id=citizen.id).count())

    @property
    def balance(self):
        today = timezone.localtime(timezone.now()).date()
        if self.method == self.CREDIT:
            percent = Rule.objects.on_date(self.when_opened).credit_percent * decimal.Decimal(str(0.01))
        else:
            percent = Rule.objects.on_date(self.when_opened).deposit_percent * decimal.Decimal(str(0.01))
        result = 0
        current_date = self.when_opened
        while current_date <= today:
            dd = self.sum_total_transfers_on_date(current_date)
            result += dd
            if current_date < today:
                result *= 1 + percent
            current_date += timedelta(days=1)
        return result

    def sum_total_transfers_on_date(self, _date):
        result = 0
        result -= sum(x.total for x in self.cashing_set.on_date(_date))
        result -= sum(x.total for x in self.outcome_transfers.on_date(_date))
        result += sum(x.total for x in self.deposit_set.all().on_date(_date))
        result += sum(x.total for x in self.income_transfers.on_date(_date))
        return result


class MoneyTransfer(models.Model):
    sender = models.ForeignKey(BankAccount, related_name='outcome_transfers', verbose_name="Отправитель")
    receiver = models.ForeignKey(BankAccount, related_name='income_transfers', verbose_name="Получатель")
    when = models.DateTimeField(verbose_name="Время")
    total = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Сумма")
    comment = models.CharField(max_length=255, verbose_name="Комментарий")

    objects = TransferQuerySet.as_manager()


class Deposit(models.Model):
    account = models.ForeignKey(BankAccount)
    total = models.DecimalField(max_digits=10, decimal_places=3)
    banker = models.ForeignKey(Citizen)
    when = models.DateTimeField()

    objects = TransferQuerySet.as_manager()


class Cashing(models.Model):
    account = models.ForeignKey(BankAccount)
    total = models.DecimalField(max_digits=10, decimal_places=3)
    banker = models.ForeignKey(Citizen)
    when = models.DateTimeField()

    objects = TransferQuerySet.as_manager()
\$\endgroup\$
  • \$\begingroup\$ Can you explain what self.object.is_owner(self.request.user.citizen) means? \$\endgroup\$ – 200_success Jan 13 '16 at 15:04
  • \$\begingroup\$ Objects type is bank account. Account have two foreign keys to organization an to citizen. Organization have many to many relation to citizen. Is_owner checks is citizen is owner of bank account or one of owners of organization that owns bank account \$\endgroup\$ – Vassily Vorobyov Jan 13 '16 at 15:08
4
\$\begingroup\$
  1. Use exists instead of count when trying to find if there are objects that match that filter.

    self.organization.owners.filter(id=citizen.id).exists()
    
  2. Use generic foreign keys instead of multiple one-to-one relationships. Change organization and citizen to one field owner that is generic. There are lots of great examples on these but here is just one.

  3. Why do you have Cashing, Deposit and MoneyTransfer as seperate models instead of one model Transaction with a type choice field? Then you can just do your math based on the type

    class Transaction(models.Model):
        DEPOSIT = 1
        CASHING = 2
        TRANSFER = 3
    
        CHOICES = (
            (DEPOSIT, 'Deposit'),
            (CASHING, 'Cashing'),
            (TRANSFER, 'Transfer')
        )
    
        account = models.ForeignKey(BankAccount)
        total = models.DecimalField(max_digits=10, decimal_places=3)
        banker = models.ForeignKey(Citizen)
        when = models.DateTimeField()
        type = models.IntegerField(choices=CHOICES)
    
        objects = TransferQuerySet.as_manager()
    
  4. Implement a IsOwnerMixin that has a get_queryset method that does a filter on the objects where owner is equal to the users owner.

    def get_queryset(self):
         default_qs = super(IsOwnerMixin, self).get_queryset()
         qs = default_qs.filter(owner=self.request.user.citizen)
         return qs
    

    This of course is an overly simplified example. You will want to add logic to figure out if a user is apart of an organization and if so filter based on the organization id instead of the citizen id. Here is an example taken straight out of our code base that only returns objects that belong to a users `company'. This isn't all of the code but it is enough to get you started.

    class CompanyRelationMixin(object):
        company_path = "company"
        force_company_only = False
    
        def is_customer(self):
            return not self.request.user.is_superuser and self.request.user.user_profile.company
    
        def company_fk_allows_null(self, qs):
            ## Figures out which field on model relates to `company`, these details aren't important for your case
    
        def get_queryset(self):
            default_qs = super(CompanyRelationMixin, self).get_queryset()
            request = self.request
            options = ''
    
            if not self.force_company_only:
                allows_none = self.company_fk_allows_null(default_qs)
                if allows_none:
                    options = Q(**{self.company_path: None})
    
            if self.is_customer():
                company = request.user.user_profile.company.pk
                if options:
                     options |= Q(**{self.company_path: company})
                else:
                     options = Q(**{self.company_path: company})
    
            elif request.user.is_superuser:
                 try:
                     request.QUERY_PARAMS['company']
                 except KeyError:
                    return default_qs
                 options = Q(**{self.company_path: request.QUERY_PARAMS['company']})
                 options |= Q(**{self.company_path: None})
            if options:
                default_qs = default_qs.filter(options)
            return default_qs
    

    These are extremely useful as the amount of views you have start to grow. This remove a LOT of redundancy in your views.

I will add more later if I can think of anything else that will help DRY this up.

\$\endgroup\$
2
\$\begingroup\$

Your models

We've already covered most of these in your other question, but there are some changes that you've made that I might as well review.

Create a filter for getting bank accounts owned by a citizen

You already have querysets for filtering the Rule and Transfer objects, but there is logic in your BankAccount that can be grouped. Specifically, the logic for determining if someone is the owner of a bank account can be done in a query.

class BankQuerySet(models.QuerySet):

    def owned_by(self, citizen):
        from django.db.models import Q

        organizations_owned_by_citizen = Organization.objects.filter(
            owners__in=[citizen]
        ).distinct()

        return self.filter(
            (Q(owner_id=citizen.id) & Q(owner_ct=Citizen)) |
            (Q(owner_id__in=organizations_owned_by_citizen) & Q(owner_ct=Organization))
        )

Use a generic foreign key instead of two nullable foreign keys

Right now it's possible to create a BankAccount without a citizen or an organization. With a generic foreign key, the BankAccount will point to the owner (the organization or citizen) using one common set of fields, and this removes the need for your owner property. You will also be able to add other models as owners of the bank account in the future without making large changes to your code. This also reduces the logic needed in is_legal, which sounds like it might be better named is_owned_by_organization, but I may be missing something.

owner = GenericForeignKey(
    'object_id',
    'object_ct',
)
owner_id = models.PositiveIntegerField()
owner_ct = models.ForeignKey("contenttypes.ContentType")

def is_legal(self):
    return self.owner_ct is Organization

def is_owner(self, citizen):
    if self.owner is Citizen:
        return self.owner_id == citizen.id

    return self.owner.owners.filter(id=citizen.id).exists()

Use "0.01" instead of str(0.01) in your filters

This is a small one, but the point is that you shouldn't be computing values that you already know ahead of time. str(0.01) should be the same as 0.01, and it avoids the risk of your Decimal object not actually having the intended value of 0.01.

if self.method == self.CREDIT:
    percent = Rule.objects.on_date(self.when_opened).credit_percent * decimal.Decimal("0.01")
else:
    percent = Rule.objects.on_date(self.when_opened).deposit_percent * decimal.Decimal("0.01")

Use QuerySet.count() when you want the count and QuerySet.exists() when you want a boolean

Your logic for determining if someone is an owner of an organization returns the .count() of a queryset, even though the method is supposed to be returning a boolean. The .exists() method will make a lighter request to the database, which will only check to see if a single row exists instead of getting a count of all rows returned by the queryset.

Your views

These are the main point of your question, and there are also a few things that can be improved by them.

Limit your queryset instead of overriding get_object

Right now all of your views share the same get_object method which calls get_queryset and checks if the BankAccount returned is valid. You can remove the need for this by just not returning invalid objects from get_queryset, which will raise the 404 error that you are currently manually raising.

def get_queryset(self):
    return BankAccount.objects.owned_by(self.request.user.citizen)

Use a mixin for get_queryset/get_object

You are currently repeating your get_object method across all of your views, even though the logic is the same. Python supports mixins which allow you to group common code and use it across multiple classes, such as your get_object/get_queryset method. This way, if you ever need to make a change in the future you will only need to make it in a single place.

class BankObjectMixin(object):

    def get_queryset(self):
        return BankAccount.objects.owned_by(self.request.user.citizen)


class AccountDetail(BankObjectMixin, DetailView):

class SendTransfer(BankObjectMixin, SingleObjectMixin, FormView):

class OutcomeTransfers(BankObjectMixin, DetailView):

class IncomeTransfers(BankObjectMixin, DetailView):
    # ...

Avoid overriding dispatch unless you absolutely need to

Usually this is a sign that you are doing something wrong, in this case setting self.object for all methods. The change suggested for get_queryset will get around the issue you were likely facing, and you can instead just keep calling self.get_object() whenever you need the object that was requested.

BankAccount.objects.get(id=self.kwargs['pk'])

would become

self.get_object()
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.