Django styleguide used in HackSoft projects
Go to file
2018-11-18 12:11:36 +02:00
.gitignore Use markdow-toc to generate a table of contents 2018-07-31 14:43:38 +03:00
package-lock.json Add prettier to package.json 2018-11-18 12:11:19 +02:00
package.json Add prettier to package.json 2018-11-18 12:11:19 +02:00
README.md Prettify README.md 2018-11-18 12:11:36 +02:00
utils.py Add utils.py with inline_serializer implementation 2018-07-26 12:40:05 +03:00

Django Styleguide

Django styleguide used in HackSoft projects.

Expect often updates as we discuss & decide upon different things.

Table of contents:

Examples

Most of the examples are taken from HackSoft's Learning Management System - Odin - https://github.com/HackSoftware/Odin

Overview

In Django, business logic should live in:

  • Model properties (with some exceptions).
  • Model clean method for additional validations (with some exceptions).
  • Services - functions, that take care of code written to the database.
  • Selectors - functions, that take care of code taken from the database.

In Django, business logic should not live in:

  • APIs and Views.
  • Serializers and Forms.
  • Form tags.
  • Model save method.

Model properties vs selectors:

  • If the model property spans multiple relations, it should better be a selector.
  • If a model property, added to some list API, will cause N + 1 problem that cannot be easily solved with select_related, it should better be a selector.

We recommend starting every new project with cookiecutter-django

Once this is done, depending on the context, remove everything that's not needed.

The usual list is:

  • allauth
  • templates
  • Settings for things that are not yet required (always add settings when necessary)

Models

Lets take a look at an example model:

class Course(models.Model):
    name = models.CharField(unique=True, max_length=255)

    start_date = models.DateField()
    end_date = models.DateField()

    attendable = models.BooleanField(default=True)

    students = models.ManyToManyField(
        Student,
        through='CourseAssignment',
        through_fields=('course', 'student')
    )

    teachers = models.ManyToManyField(
        Teacher,
        through='CourseAssignment',
        through_fields=('course', 'teacher')
    )

    slug_url = models.SlugField(unique=True)

    repository = models.URLField(blank=True)
    video_channel = models.URLField(blank=True, null=True)
    facebook_group = models.URLField(blank=True, null=True)

    logo = models.ImageField(blank=True, null=True)

    public = models.BooleanField(default=True)

    generate_certificates_delta = models.DurationField(default=timedelta(days=15))

    objects = CourseManager()

    def clean(self):
        if self.start_date > self.end_date:
            raise ValidationError("End date cannot be before start date!")

    def save(self, *args, **kwargs):
        self.full_clean()
        return super().save(*args, **kwargs)

    @property
    def visible_teachers(self):
        return self.teachers.filter(course_assignments__hidden=False).select_related('profile')

    @property
    def duration_in_weeks(self):
        weeks = rrule.rrule(
            rrule.WEEKLY,
            dtstart=self.start_date,
            until=self.end_date
        )
        return weeks.count()

    @property
    def has_started(self):
        now = get_now()

        return self.start_date <= now.date()

    @property
    def has_finished(self):
        now = get_now()

        return self.end_date <= now.date()

    @property
    def can_generate_certificates(self):
        now = get_now()

        return now.date() <= self.end_date + self.generate_certificates_delta

    def __str__(self) -> str:
        return self.name

Few things to spot here.

Custom validation:

  • There's a custom model validation, defined in clean. This validation uses only model fields and no relations.
  • This requires someone to call full_clean() on the model instance. The best place to do that is in the save() method of the model. Otherwise people can forget to call full_clean() in the respective service.

Properties:

  • All properties, expect visible_teachers work directly on model fields.
  • visible_teachers is a great candidate for a selector.

We have few general rules for custom validations & model properties / methods:

Custom validation

  • If the custom validation depends only on the non-relational model fields, define it in clean and call full_clean in save.
  • If the custom validation is more complex & spans relationships, do it in the service that creates the model.
  • It's OK to combine both clean and additional validation in the service.

Properties

  • If your model properties use only non-relational model fields, they are OK to stay as properties.
  • If a property, such as visible_teachers starts spanning relationships, it's better to define a selector for that.

Methods

  • If you need a method that updates several fields at once (for example - created_at and created_by when something happens), you can create a model method that does the job.
  • Every model method should be wrapped in a service. There should be no model method calling outside a service.

Testing

Models need to be tested only if there's something additional to them - like custom validation or properties.

If we are strict & don't do custom validation / properties, then we can test the models without actually writing anything to the database => we are going to get quicker tests.

For example, if we want to test the custom validation, here's how a test could look like:

from datetime import timedelta

from django.test import TestCase
from django.core.exceptions import ValidationError

from odin.common.utils import get_now

from odin.education.factories import CourseFactory
from odin.education.models import Course


class CourseTests(TestCase):
    def test_course_end_date_cannot_be_before_start_date(self):
        start_date = get_now()
        end_date = get_now() - timedelta(days=1)

        course_data = CourseFactory.build()
        course_data['start_date'] = start_date
        course_data['end_date'] = end_date

        course = Course(**course_data)

        with self.assertRaises(ValidationError):
            course.full_clean()

There's a lot going on in this test:

  • get_now() returns a timezone aware datetime.
  • CourseFactory.build() will return a dictionary with all required fields for a course to exist.
  • We replace the values for start_date and end_date.
  • We assert that a validation error is going to be raised if we call full_clean.
  • We are not hitting the database at all, since there's no need for that.

Here's how CourseFactory looks like:

class CourseFactory(factory.DjangoModelFactory):
    name = factory.Sequence(lambda n: f'{n}{faker.word()}')
    start_date = factory.LazyAttribute(
        lambda _: get_now()
    )
    end_date = factory.LazyAttribute(
        lambda _: get_now() + timedelta(days=30)
    )

    slug_url = factory.Sequence(lambda n: f'{n}{faker.slug()}')

    repository = factory.LazyAttribute(lambda _: faker.url())
    video_channel = factory.LazyAttribute(lambda _: faker.url())
    facebook_group = factory.LazyAttribute(lambda _: faker.url())

    class Meta:
        model = Course

    @classmethod
    def _build(cls, model_class, *args, **kwargs):
        return kwargs

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        return create_course(**kwargs)

Services

A service is a simple function that:

  • Lives in your_app/services.py module
  • Takes keyword-only arguments
  • Is type-annotated (even if you are not using mypy at the moment)
  • Works mostly with models & other services and selectors
  • Does business logic - from simple model creation to complex cross-cutting concerns, to calling external services & tasks.

An example service that creates an user:

def create_user(
    *,
    email: str,
    name: str
) -> User:
    user = User(email=email)
    user.full_clean()
    user.save()

    create_profile(user=user, name=name)
    send_confirmation_email(user=user)

    return user

As you can see, this service calls 2 other services - create_profile and send_confirmation_email

Selectors

A selector is a simple function that:

  • Lives in your_app/selectors.py module
  • Takes keyword-only arguments
  • Is type-annotated (even if you are not using mypy at the moment)
  • Works mostly with models & other services and selectors
  • Does business logic around fetching data from your database

An example selector that list users from the database:

def get_users(*, fetched_by: User) -> Iterable[User]:
    user_ids = get_visible_users_for(user=fetched_by)

    query = Q(id__in=user_ids)

    return User.objects.filter(query)

As you can see, get_visible_users_for is another selector.

APIs & Serializers

When using services & selectors, all of your APIs should look simple & the same.

General rules for an API is:

  • Do 1 API per operation. For CRUD on a model, this means 4 APIs.
  • Use the most simple APIView or GenericAPIView
  • Use services / selectors & don't do business logic in your API.
  • Use serializers for fetching objects from params - passed either via GET or POST
  • Serializer should be nested in the API and be named either InputSerializer or OutputSerializer
    • OutputSerializer can subclass ModelSerializer, if needed.
    • InputSerializer should always be a plain Serializer
    • Reuse serializers as little as possible
    • If you need a nested serializer, use the inline_serializer util

An example list API

class CourseListApi(SomeAuthenticationMixin, APIView):
    class OutputSerializer(serializers.ModelSerializer):
        class Meta:
            model = Course
            fields = ('id', 'name', 'start_date', 'end_date')

    def get(self, request):
        courses = get_courses()

        data = self.OutputSerializer(courses, many=True)

        return Response(data)

An example detail API

class CourseDetailApi(SomeAuthenticationMixin, APIView):
    class OutputSerializer(serializers.ModelSerializer):
        class Meta:
            model = Course
            fields = ('id', 'name', 'start_date', 'end_date')

    def get(self, request, course_id):
        course = get_course(id=course_id)

        data = self.OutputSerializer(course)

        return Response(data)

An example create API

class CourseCreateApi(SomeAuthenticationMixin, APIView):
    class InputSerializer(serializers.Serializer):
        name = serializers.CharField()
        start_date = serializers.DateField()
        end_date = serializers.DateField()

    def post(self, request):
        serializer = self.InputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        create_course(**serializer.validated_data)

        return Response(status=status.HTTP_201_CREATED)

An example update API

class CourseUpdateApi(SomeAuthenticationMixin, APIView):
    class InputSerializer(serializers.Serializer):
        name = serializers.CharField(required=False)
        start_date = serializers.DateField(required=False)
        end_date = serializers.DateField(required=False)

    def post(self, request, course_id):
        serializer = self.InputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        update_course(course_id=course_id, **serializer.validated_data)

        return Response(status=status.HTTP_200_OK)

Nested serializers

In case you need to use a nested serializer, you can do the following thing:

class Serializer(serializers.Serializer):
    weeks = inline_serializer(many=True, fields={
        'id': serializers.IntegerField(),
        'number': serializers.IntegerField(),
    })

The implementation of inline_serializer can be found in utils.py in this repo.

Inspiration

The way we do Django is inspired by the following things: