mirror of
https://github.com/HackSoftware/Django-Styleguide.git
synced 2025-02-16 19:41:00 +03:00
414 lines
12 KiB
Markdown
414 lines
12 KiB
Markdown
# Django Styleguide
|
|
|
|
Django styleguide used in [HackSoft](https://hacksoft.io) projects.
|
|
|
|
Expect often updates as we discuss & decide upon different things.
|
|
|
|
**Table of contents:**
|
|
|
|
<!-- toc -->
|
|
|
|
- [Examples](#examples)
|
|
- [Overview](#overview)
|
|
- [Cookie Cutter](#cookie-cutter)
|
|
- [Models](#models)
|
|
- [Custom validation](#custom-validation)
|
|
- [Properties](#properties)
|
|
- [Methods](#methods)
|
|
- [Testing](#testing)
|
|
- [Services](#services)
|
|
- [Selectors](#selectors)
|
|
- [APIs & Serializers](#apis--serializers)
|
|
- [An example list API](#an-example-list-api)
|
|
- [An example detail API](#an-example-detail-api)
|
|
- [An example create API](#an-example-create-api)
|
|
- [An example update API](#an-example-update-api)
|
|
- [Nested serializers](#nested-serializers)
|
|
- [Inspiration](#inspiration)
|
|
|
|
<!-- tocstop -->
|
|
|
|
## 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.
|
|
|
|
## Cookie Cutter
|
|
|
|
We recommend starting every new project with [`cookiecutter-django`](https://github.com/pydanny/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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
- The general idea for **separation of concerns**
|
|
- [Boundaries by Gary Bernhardt](https://www.youtube.com/watch?v=yTkzNHF6rMs)
|
|
- Rails service objects
|