Django-Styleguide/README.md

1593 lines
48 KiB
Markdown
Raw Normal View History

2020-03-15 20:14:23 +03:00
![Django Styleguide](logo.png)
---
2018-07-26 00:30:29 +03:00
2021-09-16 17:23:36 +03:00
Django styleguide that we use in [HackSoft](https://hacksoft.io).
2018-07-26 00:30:29 +03:00
2021-09-16 17:23:36 +03:00
1. We have a [`Styleguide-Example`](https://github.com/HackSoftware/Styleguide-Example) to show most of the styleguide in an actual project.
1. You can watch Radoslav Georgiev's [Django structure for scale and longevity](https://www.youtube.com/watch?v=yG3ZdxBb1oo) for the philosophy behind the styleguide.
2020-07-07 10:08:03 +03:00
**Table of contents:**
<!-- toc -->
- [Overview](#overview)
2018-08-19 15:27:18 +03:00
- [Cookie Cutter](#cookie-cutter)
2018-08-13 15:27:32 +03:00
- [Models](#models)
2021-09-16 17:10:28 +03:00
* [Base model](#base-model)
* [Validation - `clean` and `full_clean`](#validation---clean-and-full_clean)
* [Validation - constraints](#validation---constraints)
2018-08-13 15:27:32 +03:00
* [Properties](#properties)
* [Methods](#methods)
* [Testing](#testing)
- [Services](#services)
2019-12-02 12:49:58 +03:00
* [Naming convention](#naming-convention)
- [Selectors](#selectors)
2019-12-02 12:49:58 +03:00
* [Naming convention](#naming-convention-1)
- [APIs & Serializers](#apis--serializers)
2019-12-02 12:49:58 +03:00
* [Naming convention](#naming-convention-2)
* [An example list API](#an-example-list-api)
+ [Plain](#plain)
+ [Filters + Pagination](#filters--pagination)
* [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)
2019-10-27 17:57:34 +03:00
- [Urls](#urls)
2018-12-28 19:28:42 +03:00
- [Exception Handling](#exception-handling)
2019-01-20 18:07:53 +03:00
* [Raising Exceptions in Services / Selectors](#raising-exceptions-in-services--selectors)
2018-12-28 19:28:42 +03:00
* [Handle Exceptions in APIs](#handle-exceptions-in-apis)
* [Error formatting](#error-formatting)
2019-01-20 18:07:53 +03:00
- [Testing](#testing-1)
* [Naming conventions](#naming-conventions)
2019-01-20 18:19:24 +03:00
* [Example](#example)
+ [Example models](#example-models)
+ [Example selectors](#example-selectors)
+ [Example services](#example-services)
* [Testing services](#testing-services)
* [Testing selectors](#testing-selectors)
2019-11-03 13:00:25 +03:00
- [Celery](#celery)
* [Structure](#structure)
+ [Configuration](#configuration)
+ [Tasks](#tasks)
+ [Circular imports between tasks & services](#circular-imports-between-tasks--services)
2019-11-03 13:00:25 +03:00
* [Periodic Tasks](#periodic-tasks)
* [Configuration](#configuration-1)
- [Misc](#misc)
* [mypy / type annotations](#mypy--type-annotations)
2019-01-21 17:33:38 +03:00
- [Inspiration](#inspiration)
<!-- tocstop -->
2018-07-26 00:30:29 +03:00
## Overview
**In Django, business logic should live in:**
* Model properties (with some exceptions).
* Model `clean` method for additional validations (with some exceptions).
2021-09-16 15:36:31 +03:00
* Services - functions, that mostly take care of writing things to the database.
* Selectors - functions, that mostly take care of fetching things from the database.
2018-07-26 00:30:29 +03:00
**In Django, business logic should not live in:**
* APIs and Views.
* Serializers and Forms.
* Form tags.
* Model `save` method.
**Model properties vs selectors:**
2021-09-16 15:36:31 +03:00
* If the property spans multiple relations, it should better be a selector.
* If the property is non-trivial & can easily cause `N + 1` queries problem, when serialized, it should better be a selector.
2018-08-01 00:02:03 +03:00
## Cookie Cutter
2019-10-26 11:19:12 +03:00
We recommend starting every new project with some kind of cookiecutter. Having the proper structure from the start pays off.
2018-08-01 00:02:03 +03:00
2021-09-16 15:36:31 +03:00
Few examples:
* You can use the [`Styleguide-Example`](https://github.com/HackSoftware/Styleguide-Example) project as a starting point.
* You can also use [`cookiecutter-django`](https://github.com/pydanny/cookiecutter-django) since it has a ton of good stuff inside.
* Or you can create something that works for your case & turn it into a [cookiecutter](https://cookiecutter.readthedocs.io/en/latest/) project.
2018-08-01 00:02:03 +03:00
2018-08-13 15:27:32 +03:00
## Models
2021-09-16 17:10:28 +03:00
Models should take care of the data model and not much else.
### Base model
It's a good idea to define a `BaseModel`, that you can inherit.
Usually, fields like `created_at` and `updated_at` are perfect candidates to go into a `BaseModel`.
Defining a primary key can also go there. Potential candidate for that is the [`UUIDField`](https://docs.djangoproject.com/en/dev/ref/models/fields/#uuidf)
Here's an example `BaseModel`:
```python
from django.db import models
from django.utils import timezone
class BaseModel(models.Model):
created_at = models.DateTimeField(db_index=True, default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
```
Then, whenever you need a new model, just inherit `BaseModel`:
```python
class SomeModel(BaseModel):
pass
```
### Validation - `clean` and `full_clean`
2018-08-13 15:27:32 +03:00
Lets take a look at an example model:
```python
2021-09-16 17:10:28 +03:00
class Course(BaseModel):
2018-08-13 15:27:32 +03:00
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
2021-09-16 17:10:28 +03:00
def clean(self):
if self.start_date >= self.end_date:
raise ValidationError("End date cannot be before start date")
```
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
We are defining the model's `clean` method, because we want to make sure we get good data in our database.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
Now, in order for the `clean` method to be called, someone must call `full_clean` on an instance of our model, before saving.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
**Our recommendation is to do that in the service, right before calling clean:**
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
```python
def course_create(*, name: str, start_date: date, end_date: date) -> Course:
obj = Course(name=name, start_date=start_date, end_date=end_date)
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
obj.full_clean()
obj.save()
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
return obj
```
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
This also plays well with Django admin, because the forms used there will trigger `full_clean` on the instance.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
**We have few general rules of thumb for when to add validation in the model's `clean` method:**
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
1. If we are validating based on multiple, **non-relational fields**, of the model.
1. If the validation itself is simple enough.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
**Validation should be moved to the service layer if:**
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
1. The validation logic is more complex.
1. Spanning relations & fetching additional data is required.
> It's OK to have validation both in `clean` and in the service, but we tend to move things in the service, if that's the case.
### Validation - constraints
As proposed in [this issue](https://github.com/HackSoftware/Django-Styleguide/issues/22), if you can do validation using [Django's constraints](https://docs.djangoproject.com/en/dev/ref/models/constraints/), then you should aim for that.
Less code to write, less to code to maintain, the database will take care of the data even if it's being inserted from a different place.
**TODO:** Add above example with constraints.
### Properties
Model properties are great way to quickly access a derived value from a model's instance.
For example, lets look at the `has_started` and `has_finished` properties of our `Course` model:
```python
from django.utils import timezone
from django.core.exceptions import ValidationError
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
class Course(BaseModel):
name = models.CharField(unique=True, max_length=255)
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.start_date >= self.end_date:
raise ValidationError("End date cannot be before start date")
2018-08-13 15:27:32 +03:00
@property
2021-09-16 17:10:28 +03:00
def has_started(self) -> bool:
now = timezone.now()
2018-08-13 15:27:32 +03:00
return self.start_date <= now.date()
@property
2021-09-16 17:10:28 +03:00
def has_finished(self) -> bool:
now = timezone.now()
2018-08-13 15:27:32 +03:00
return self.end_date <= now.date()
2021-09-16 17:10:28 +03:00
```
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
Those properties are handy, because we can now refer to them in serializers or use them in templates.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
**We have few general rules of thumb, for when to add properties to the model:**
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
1. If we need a simple derived value, based on **non-relational model fields**, add a `@property` for that.
1. If the calculation of the derived value is simple enough.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
**Properties should be something else (service, selector, utility) in the following cases:**
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
1. If we need to span multiple relations or fetch additional data.
1. If the calculation is more complex.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
Keep in mind that those rules are vague, because context is quite often important. Use your best judgement!
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
### Methods
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
Model methods are also very powerful tool, that can build on top of properties.
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
Lets see an example with the `is_within(self, x)` method:
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
```python
from django.core.exceptions import ValidationError
from django.utils import timezone
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
class Course(BaseModel):
name = models.CharField(unique=True, max_length=255)
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
start_date = models.DateField()
end_date = models.DateField()
def clean(self):
if self.start_date >= self.end_date:
raise ValidationError("End date cannot be before start date")
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
@property
def has_started(self) -> bool:
now = timezone.now()
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
return self.start_date <= now.date()
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
@property
def has_finished(self) -> bool:
now = timezone.now()
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
return self.end_date <= now.date()
2018-08-13 15:27:32 +03:00
2021-09-16 17:10:28 +03:00
def is_within(self, x: date) -> bool:
return self.start_date <= x <= self.end_date
```
2021-09-16 17:10:28 +03:00
`is_within` cannot be a property, because it requires an argument. So it's a method instead.
2021-09-16 17:10:28 +03:00
Another great way for using methods in models is using them for **attribute setting**, when setting one attribute must always be followed by setting another attribute with a derived value.
2021-09-16 17:10:28 +03:00
An example:
```python
2021-09-16 17:10:28 +03:00
from django.utils.crypto import get_random_string
from django.conf import settings
from django.utils import timezone
2021-09-16 17:10:28 +03:00
class Token(BaseModel):
secret = models.CharField(max_length=255, unique=True)
expiry = models.DateTimeField(blank=True, null=True)
2021-09-16 17:10:28 +03:00
def set_new_secret(self):
now = timezone.now()
2021-09-16 17:10:28 +03:00
self.secret = get_random_string(255)
self.expiry = now + settings.TOKEN_EXPIRY_TIMEDELTA
2021-09-16 17:10:28 +03:00
return self
```
2021-09-16 17:10:28 +03:00
Now, we can safely call `set_new_secret`, that'll produce correct values for both `secret` and `expiry`.
2021-09-16 17:10:28 +03:00
**We have few general rules of thumb, for when to add methods to the model:**
2021-09-16 17:10:28 +03:00
1. If we need a simple derived value, that requires arguments, based on **non-relational model fields**, add a method for that.
1. If the calculation of the derived value is simple enough.
1. If setting one attribute always requires setting values to other attributes, use a method for that.
2021-09-16 17:10:28 +03:00
**Models should be something else (service, selector, utility) in the following cases:**
2021-09-16 17:10:28 +03:00
1. If we need to span multiple relations or fetch additional data.
1. If the calculation is more complex.
2021-09-16 17:10:28 +03:00
Keep in mind that those rules are vague, because context is quite often important. Use your best judgement!
### Testing
Models need to be tested only if there's something additional to them - like validation, properties or methods.
Here's an example:
2018-08-13 16:02:55 +03:00
```python
2021-09-16 17:10:28 +03:00
from datetime import timedelta
2018-08-13 16:02:55 +03:00
2021-09-16 17:10:28 +03:00
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.utils import timezone
2018-08-13 16:02:55 +03:00
2021-09-16 17:16:39 +03:00
from project.some_app.models import Course
2018-08-13 16:02:55 +03:00
2021-09-16 17:10:28 +03:00
class CourseTests(TestCase):
def test_course_end_date_cannot_be_before_start_date(self):
start_date = timezone.now()
end_date = timezone.now() - timedelta(days=1)
2018-08-13 16:02:55 +03:00
2021-09-16 17:10:28 +03:00
course = Course(start_date=start_date, end_date=end_date)
with self.assertRaises(ValidationError):
course.full_clean()
2018-08-13 16:02:55 +03:00
```
2021-09-16 17:10:28 +03:00
A few things to note here:
1. We assert that a validation error is going to be raised if we call `full_clean`.
1. **We are not hitting the database at all**, since there's no need for that. This can speed up certain tests.
## Services
A service is a simple function that:
2018-07-26 12:29:16 +03:00
* Lives in `your_app/services.py` module
* Takes keyword-only arguments
* Is type-annotated (even if you are not using [`mypy`](https://github.com/python/mypy) at the moment)
2018-07-26 12:29:16 +03:00
* 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.
2021-01-19 14:03:52 +03:00
An example service that creates a 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`
2018-07-26 12:29:16 +03:00
### Naming convention
2021-01-19 14:03:52 +03:00
Naming conventions depend on your taste. It pays off to have a consistent naming convention throughout a project.
If we take the example above, our service is named `create_user`. The pattern is - `<action>_<entity>`.
What we usually prefer in our projects, again, depending on taste, is `<entity>_<action>` or with the example above: `user_create`. This seems odd at first, but it has few nice features:
* Namespacing. It's easy to spot all services starting with `user_` and it's a good idea to put them in a `users.py` module.
* Greppability. Or in other words, if you want to see all actions for a specific entity, just grep for `user_`.
A full example would look like this:
```python
def user_create(
*,
email: str,
name: str
) -> User:
user = User(email=email)
user.full_clean()
user.save()
profile_create(user=user, name=name)
confirmation_email_send(user=user)
return user
```
2018-07-26 12:29:16 +03:00
## 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`](https://github.com/python/mypy) at the moment)
2018-07-26 12:29:16 +03:00
* Works mostly with models & other services and selectors
* Does business logic around fetching data from your database
An example selector that lists users from the database:
2018-07-26 12:29:16 +03:00
```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.
2018-07-26 12:39:31 +03:00
### Naming convention
2021-01-19 14:03:52 +03:00
Read the section in services. The same rules apply here.
2018-07-26 12:39:31 +03:00
## APIs & Serializers
2019-10-29 21:56:38 +03:00
When using services & selectors, all of your APIs should look simple & identical.
2018-07-26 12:39:31 +03:00
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
2019-11-30 13:44:37 +03:00
### Naming convention
For our APIs we use the following naming convention: `<Entity><Action>Api`.
Here are few examples: `UserCreateApi`, `UserSendResetPasswordApi`, `UserDeactivateApi`, etc.
2018-07-26 12:39:31 +03:00
### An example list API
#### Plain
A dead-simple list API would look like that:
2018-07-26 12:39:31 +03:00
```python
from rest_framework.views import APIView
from rest_framework import serializers
from rest_framework.response import Response
from styleguide_example.users.selectors import user_list
from styleguide_example.users.models import BaseUser
class UserListApi(APIView):
2018-07-26 12:39:31 +03:00
class OutputSerializer(serializers.ModelSerializer):
class Meta:
model = BaseUser
fields = (
'id',
'email'
)
2018-07-26 12:39:31 +03:00
def get(self, request):
users = user_list()
2018-07-26 12:39:31 +03:00
data = self.OutputSerializer(users, many=True).data
2018-07-26 12:39:31 +03:00
return Response(data)
2018-07-26 12:39:31 +03:00
```
Keep in mind this API is public by default. Authentication is up to you.
#### Filters + Pagination
2021-01-19 14:03:52 +03:00
At first glance, this is tricky, since our APIs are inheriting the plain `APIView` from DRF, while filtering and pagination are baked into the generic ones:
1. [DRF Filtering](https://www.django-rest-framework.org/api-guide/filtering/)
1. [DRF Pagination](https://www.django-rest-framework.org/api-guide/pagination/)
That's why, we take the following approach:
1. Selectors take care of the actual filtering.
1. APIs take care of filter parameter serialization.
1. APIs take care of pagination.
2021-01-19 14:03:52 +03:00
Let's look at the example:
```python
from rest_framework.views import APIView
from rest_framework import serializers
from styleguide_example.api.mixins import ApiErrorsMixin
from styleguide_example.api.pagination import get_paginated_response, LimitOffsetPagination
from styleguide_example.users.selectors import user_list
from styleguide_example.users.models import BaseUser
class UserListApi(ApiErrorsMixin, APIView):
class Pagination(LimitOffsetPagination):
default_limit = 1
class FilterSerializer(serializers.Serializer):
id = serializers.IntegerField(required=False)
# Important: If we use BooleanField, it will default to False
is_admin = serializers.NullBooleanField(required=False)
email = serializers.EmailField(required=False)
class OutputSerializer(serializers.ModelSerializer):
class Meta:
model = BaseUser
fields = (
'id',
'email',
'is_admin'
)
def get(self, request):
# Make sure the filters are valid, if passed
filters_serializer = self.FilterSerializer(data=request.query_params)
filters_serializer.is_valid(raise_exception=True)
users = user_list(filters=filters_serializer.validated_data)
return get_paginated_response(
pagination_class=self.Pagination,
serializer_class=self.OutputSerializer,
queryset=users,
request=request,
view=self
)
```
2021-01-19 14:03:52 +03:00
When we look at the API, we can identify few things:
1. There's a `FilterSerializer`, which will take care of the query parameters. If we don't do this here, we'll have to do it elsewhere & DRF serializers are great at this job.
1. We pass the filters to the `user_list` selector
1. We use the `get_paginated_response` utility, to return a .. paginated response.
2021-01-19 14:03:52 +03:00
Now, let's look at the selector:
```python
import django_filters
from styleguide_example.users.models import BaseUser
class BaseUserFilter(django_filters.FilterSet):
class Meta:
model = BaseUser
fields = ('id', 'email', 'is_admin')
def user_list(*, filters=None):
filters = filters or {}
qs = BaseUser.objects.all()
return BaseUserFilter(filters, qs).qs
```
As you can see, we are leveraging the powerful [`django-filter`](https://django-filter.readthedocs.io/en/stable/) library.
But you can do whatever suits you best here. We have projects, where we implemented our own filtering layer & used it here.
The key thing is - **selectors take care of filtering**.
2021-01-19 14:03:52 +03:00
Finally, let's look at `get_paginated_response`:
```python
from rest_framework.response import Response
def get_paginated_response(*, pagination_class, serializer_class, queryset, request, view):
paginator = pagination_class()
page = paginator.paginate_queryset(queryset, request, view=view)
if page is not None:
serializer = serializer_class(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = serializer_class(queryset, many=True)
return Response(data=serializer.data)
```
This is basically a code, extracted from within DRF.
Same goes for the `LimitOffsetPagination:
```python
from collections import OrderedDict
from rest_framework.pagination import LimitOffsetPagination as _LimitOffsetPagination
from rest_framework.response import Response
class LimitOffsetPagination(_LimitOffsetPagination):
default_limit = 10
max_limit = 50
def get_paginated_data(self, data):
return OrderedDict([
('limit', self.limit),
('offset', self.offset),
('count', self.count),
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data)
])
def get_paginated_response(self, data):
"""
We redefine this method in order to return `limit` and `offset`.
This is used by the frontend to construct the pagination itself.
"""
return Response(OrderedDict([
('limit', self.limit),
('offset', self.offset),
('count', self.count),
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data)
]))
```
What we basically did is reverse-engineered the generic APIs, since pagination should be able to live outside the layers of complexity there.
**A possible future implementation should be able to paginate without needing the request / response of the APIView.**
You can find the code for the example list API with filters & pagination in the [Styleguide Example](https://github.com/HackSoftware/Styleguide-Example#example-list-api) project.
2018-07-26 12:39:31 +03:00
### 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)
serializer = self.OutputSerializer(course)
2018-07-26 12:39:31 +03:00
return Response(serializer.data)
2018-07-26 12:39:31 +03:00
```
### 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 [here](https://github.com/HackSoftware/Styleguide-Example/blob/master/styleguide_example/common/utils.py#L34), in the [Styleguide-Example](https://github.com/HackSoftware/Styleguide-Example) repo.
2018-07-31 14:33:27 +03:00
2019-10-27 17:57:34 +03:00
## Urls
We usually organize our urls the same way we organize our APIs - 1 url per API, meaning 1 url per action.
A general rule of thumb is to split urls from different domains in their own `domain_patterns` list & include from `urlpatterns`.
Here's an example with the APIs from above:
```python
from django.urls import path, include
from project.education.apis import (
CourseCreateApi,
CourseUpdateApi,
CourseListApi,
CourseDetailApi,
CourseSpecificActionApi,
)
course_patterns = [
path('', CourseListApi.as_view(), name='list'),
path('<int:course_id>/', CourseDetailApi.as_view(), name='detail'),
path('create/', CourseCreateApi.as_view(), name='create'),
path('<int:course_id>/update/', CourseUpdateApi.as_view(), name='update'),
path(
'<int:course_id>/specific-action/',
CourseSpecificActionApi.as_view(),
name='specific-action'
),
]
urlpatterns = [
path('courses/', include((course_patterns, 'courses'))),
]
```
2018-07-31 14:33:27 +03:00
**Splitting urls like that can give you the extra flexibility to move separate domain patterns to separate modules**, especially for really big projects, where you'll often have merge conflicts in `urls.py`.
## Exception Handling
2018-12-28 20:28:10 +03:00
### Raising Exceptions in Services / Selectors
2018-11-18 12:47:03 +03:00
2021-01-19 14:03:52 +03:00
Now we have a separation between our HTTP interface & the core logic of our application.
2021-01-19 14:03:52 +03:00
To keep this separation of concerns, our services and selectors must not use the `rest_framework.exception` classes because they are bounded with HTTP status codes.
2018-12-28 20:28:10 +03:00
Our services and selectors must use one of:
2018-12-28 20:22:00 +03:00
* [Python built-in exceptions](https://docs.python.org/3/library/exceptions.html)
* Exceptions from `django.core.exceptions`
* Custom exceptions, inheriting from the ones above.
2019-10-29 21:56:09 +03:00
Here is a good example of service that performs some validation and raises `django.core.exceptions.ValidationError`:
2018-11-18 12:47:03 +03:00
```python
from django.core.exceptions import ValidationError
2019-10-27 17:02:55 +03:00
def create_topic(*, name: str, course: Course) -> Topic:
2018-11-18 12:47:03 +03:00
if course.end_date < timezone.now():
2018-12-28 20:22:00 +03:00
raise ValidationError('You can not create topics for course that has ended.')
topic = Topic.objects.create(name=name, course=course)
return topic
```
### Handle Exceptions in APIs
2018-11-18 12:47:03 +03:00
2021-01-19 14:03:52 +03:00
To transform the exceptions raised in the services or selectors, to a standard HTTP response, you need to catch the exception and raise something that the rest framework understands.
2019-10-29 21:56:38 +03:00
The best place to do this is in the `handle_exception` method of the `APIView`. There you can map your Python/Django exception to a DRF exception.
2018-12-28 20:22:00 +03:00
2021-01-19 14:03:52 +03:00
By default, the [`handle_exception` method implementation in DRF](https://www.django-rest-framework.org/api-guide/exceptions/#exception-handling-in-rest-framework-views) handles Django's built-in `Http404` and `PermissionDenied` exceptions, thus there is no need for you to handle it by hand.
Here is an example:
```python
2018-12-28 20:22:00 +03:00
from rest_framework import exceptions as rest_exceptions
from django.core.exceptions import ValidationError
class CourseCreateApi(SomeAuthenticationMixin, APIView):
expected_exceptions = {
2018-12-28 20:22:00 +03:00
ValidationError: rest_exceptions.ValidationError
}
class InputSerializer(serializers.Serializer):
...
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)
def handle_exception(self, exc):
if isinstance(exc, tuple(self.expected_exceptions.keys())):
drf_exception_class = self.expected_exceptions[exc.__class__]
2018-12-28 20:22:00 +03:00
drf_exception = drf_exception_class(get_error_message(exc))
return super().handle_exception(drf_exception)
return super().handle_exception(exc)
```
2018-12-28 20:22:00 +03:00
Here's the implementation of `get_error_message`:
```python
def get_first_matching_attr(obj, *attrs, default=None):
for attr in attrs:
if hasattr(obj, attr):
return getattr(obj, attr)
return default
def get_error_message(exc):
if hasattr(exc, 'message_dict'):
return exc.message_dict
error_msg = get_first_matching_attr(exc, 'message', 'messages')
if isinstance(error_msg, list):
error_msg = ', '.join(error_msg)
if error_msg is None:
error_msg = str(exc)
return error_msg
```
2019-10-27 11:29:49 +03:00
You can move this code to a mixin and use it in every API to prevent code duplication.
We call this `ApiErrorsMixin`. Here's a sample implementation from one of our projects:
2018-12-28 20:22:00 +03:00
```python
from rest_framework import exceptions as rest_exceptions
from django.core.exceptions import ValidationError
from project.common.utils import get_error_message
class ApiErrorsMixin:
2018-12-28 20:22:00 +03:00
"""
Mixin that transforms Django and Python exceptions into rest_framework ones.
2019-10-29 21:56:38 +03:00
Without the mixin, they return 500 status code which is not desired.
2018-12-28 20:22:00 +03:00
"""
expected_exceptions = {
ValueError: rest_exceptions.ValidationError,
ValidationError: rest_exceptions.ValidationError,
PermissionError: rest_exceptions.PermissionDenied
}
def handle_exception(self, exc):
if isinstance(exc, tuple(self.expected_exceptions.keys())):
drf_exception_class = self.expected_exceptions[exc.__class__]
drf_exception = drf_exception_class(get_error_message(exc))
return super().handle_exception(drf_exception)
return super().handle_exception(exc)
```
Having this mixin in mind, our API can be written like that:
```python
class CourseCreateApi(
2019-10-27 17:02:55 +03:00
SomeAuthenticationMixin,
ApiErrorsMixin,
2019-10-27 17:02:55 +03:00
APIView
2018-12-28 20:22:00 +03:00
):
class InputSerializer(serializers.Serializer):
...
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)
```
All of the code above can be found in [here](https://github.com/HackSoftware/Styleguide-Example/blob/master/styleguide_example/api/mixins.py#L70), in the [Styleguide-Example](https://github.com/HackSoftware/Styleguide-Example) repo.
2018-12-28 20:24:43 +03:00
### Error formatting
2021-01-19 14:03:52 +03:00
The next step is to generalize the format of the errors we get from our APIs. This will ease the process of displaying errors to the end-user, via JavaScript.
2020-01-19 16:31:43 +03:00
If we have a standard serializer and there is an error with one of the fields, the message we get by default looks like this:
```python
{
"url": [
"This field is required."
]
}
```
2020-01-19 16:31:43 +03:00
If we have a validation error with just a message - `raise ValidationError('Something is wrong.')` - it will look like this:
```python
[
"some error"
]
```
2020-01-19 16:31:43 +03:00
Another error format may look like this:
```python
{
"detail": "Method \"GET\" not allowed."
}
```
2020-01-19 16:31:43 +03:00
**Those are 3 different ways of formatting for our errors.** What we want to have is a single format, for all errors.
2020-01-19 16:31:43 +03:00
Luckily, DRF provides a way for us to give our own custom exception handler, where we can implement the desired formatting: <https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling>
In our projects, we format the errors like that:
```python
{
"errors": [
{
"message": "Error message",
"code": "Some code",
"field": "field_name"
},
{
"message": "Error message",
"code": "Some code",
"field": "nested.field_name"
},
]
}
```
2021-01-19 14:03:52 +03:00
If we raise a `ValidationError`, then the field is optional.
In order to achieve that, we implement a custom exception handler:
```python
from rest_framework.views import exception_handler
def exception_errors_format_handler(exc, context):
response = exception_handler(exc, context)
2021-01-19 14:03:52 +03:00
# If an unexpected error occurs (server error, etc.)
if response is None:
return response
formatter = ErrorsFormatter(exc)
response.data = formatter()
return response
```
2020-01-19 16:31:43 +03:00
which needs to be added to the `REST_FRAMEWORK` project settings:
```python
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'project.app.handlers.exception_errors_format_handler',
...
}
```
**The magic happens in the `ErrorsFormatter` class.**
The implementation of that class can be found [here](https://github.com/HackSoftware/Styleguide-Example/blob/master/styleguide_example/api/errors.py), in the [Styleguide-Example](https://github.com/HackSoftware/Styleguide-Example) repo.
Combining `ApiErrorsMixin`, the custom exception handler & the errors formatter class, we can have predictable behavior in our APIs, when it comes to errors.
**A note:**
> We've moved away from this particular way of formatting errors & we'll be updating the styleguide with a more generic approach.
## Testing
In our Django projects, we split our tests depending on the type of code they represent.
Meaning, we generally have tests for models, services, selectors & APIs / views.
The file structure usually looks like this:
```
project_name
├── app_name
│   ├── __init__.py
│   └── tests
│   ├── __init__.py
│   ├── models
│   │   └── __init__.py
│   │   └── test_some_model_name.py
│   ├── selectors
│   │   └── __init__.py
│   │   └── test_some_selector_name.py
│   └── services
│   ├── __init__.py
│   └── test_some_service_name.py
└── __init__.py
```
### Naming conventions
We follow 2 general naming conventions:
* The test file names should be `test_the_name_of_the_thing_that_is_tested.py`
2021-01-19 14:03:52 +03:00
* The test case should be `class TheNameOfTheThingThatIsTestedTests(TestCase):`
2021-01-19 14:03:52 +03:00
For example, if we have:
```python
def a_very_neat_service(*args, **kwargs):
pass
```
We are going to have the following for file name:
```
project_name/app_name/tests/services/test_a_very_neat_service.py
```
And the following for test case:
```python
class AVeryNeatServiceTests(TestCase):
pass
```
2021-01-19 14:03:52 +03:00
For tests of utility functions, we follow a similar pattern.
For example, if we have `project_name/common/utils.py`, then we are going to have `project_name/common/tests/test_utils.py` and place different test cases in that file.
If we are to split the `utils.py` module into submodules, the same will happen for the tests:
* `project_name/common/utils/files.py`
* `project_name/common/tests/utils/test_files.py`
2021-01-19 14:03:52 +03:00
We try to match the structure of our modules with the structure of their respective tests.
2019-01-20 18:18:08 +03:00
### Example
2019-01-04 18:16:01 +03:00
2019-01-20 18:18:08 +03:00
We have a demo `django_styleguide` project.
2019-01-04 18:16:01 +03:00
2019-01-20 18:18:08 +03:00
#### Example models
```python
import uuid
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from djmoney.models.fields import MoneyField
class Item(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
description = models.TextField()
price = MoneyField(
max_digits=14,
decimal_places=2,
default_currency='EUR'
)
def __str__(self):
return f'Item {self.id} / {self.name} / {self.price}'
2019-01-04 18:16:01 +03:00
class Payment(models.Model):
item = models.ForeignKey(
Item,
on_delete=models.CASCADE,
related_name='payments'
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='payments'
)
successful = models.BooleanField(default=False)
created_at = models.DateTimeField(default=timezone.now)
def __str__(self):
return f'Payment for {self.item} / {self.user}'
```
2019-01-20 18:18:08 +03:00
#### Example selectors
2019-01-04 18:16:01 +03:00
For implementation of `QuerySetType`, check `queryset_type.py`.
2019-01-04 18:16:01 +03:00
```python
from django.contrib.auth.models import User
from django_styleguide.common.types import QuerySetType
2019-01-21 16:53:44 +03:00
from django_styleguide.payments.models import Item
def get_items_for_user(
*,
user: User
) -> QuerySetType[Item]:
return Item.objects.filter(payments__user=user)
```
2019-01-20 18:18:08 +03:00
#### Example services
```python
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django_styleguide.payments.selectors import get_items_for_user
from django_styleguide.payments.models import Item, Payment
from django_styleguide.payments.tasks import charge_payment
def buy_item(
*,
item: Item,
user: User,
) -> Payment:
if item in get_items_for_user(user=user):
raise ValidationError(f'Item {item} already in {user} items.')
payment = Payment.objects.create(
item=item,
user=user,
successful=False
)
charge_payment.delay(payment_id=payment.id)
return payment
```
### Testing services
Service tests are the most important tests in the project. Usually, those are the heavier tests with most lines of code.
General rule of thumb for service tests:
* The tests should cover the business logic behind the services in an exhaustive manner.
* The tests should hit the database - creating & reading from it.
* The tests should mock async task calls & everything that goes outside the project.
When creating the required state for a given test, one can use a combination of:
* Fakes (We recommend using [`faker`](https://github.com/joke2k/faker))
2019-01-20 18:18:08 +03:00
* Other services, to create the required objects.
* Special test utility & helper methods.
* Factories (We recommend using [`factory_boy`](https://factoryboy.readthedocs.io/en/latest/orms.html))
* Plain `Model.objects.create()` calls, if factories are not yet introduced in the project.
2019-01-20 18:18:08 +03:00
2021-01-19 14:03:52 +03:00
**Let's take a look at our service from the example:**
```python
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django_styleguide.payments.selectors import get_items_for_user
from django_styleguide.payments.models import Item, Payment
from django_styleguide.payments.tasks import charge_payment
2019-01-04 18:16:01 +03:00
def buy_item(
*,
item: Item,
user: User,
) -> Payment:
if item in get_items_for_user(user=user):
raise ValidationError(f'Item {item} already in {user} items.')
payment = Payment.objects.create(
item=item,
user=user,
successful=False
)
charge_payment.delay(payment_id=payment.id)
return payment
2019-01-20 18:18:08 +03:00
2019-01-04 18:16:01 +03:00
```
The service:
* Calls a selector for validation
* Creates ORM object
2019-01-04 18:16:01 +03:00
* Calls a task
**Those are our tests:**
2019-01-04 18:16:01 +03:00
```python
from unittest.mock import patch
from django.test import TestCase
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django_styleguide.payments.services import buy_item
from django_styleguide.payments.models import Payment, Item
2019-01-04 18:16:01 +03:00
class BuyItemTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='Test User')
self.item = Item.objects.create(
name='Test Item',
description='Test Item description',
price=10.15
)
2019-01-04 18:16:01 +03:00
self.service = buy_item
@patch('django_styleguide.payments.services.get_items_for_user')
2019-01-04 18:16:01 +03:00
def test_buying_item_that_is_already_bought_fails(self, get_items_for_user_mock):
"""
Since we already have tests for `get_items_for_user`,
we can safely mock it here and give it a proper return value.
"""
get_items_for_user_mock.return_value = [self.item]
with self.assertRaises(ValidationError):
self.service(user=self.user, item=self.item)
@patch('django_styleguide.payments.services.charge_payment.delay')
2019-01-04 18:16:01 +03:00
def test_buying_item_creates_a_payment_and_calls_charge_task(
self,
charge_payment_mock
):
self.assertEqual(0, Payment.objects.count())
2019-01-04 18:16:01 +03:00
payment = self.service(user=self.user, item=self.item)
self.assertEqual(1, Payment.objects.count())
2019-01-04 18:16:01 +03:00
self.assertEqual(payment, Payment.objects.first())
self.assertFalse(payment.successful)
charge_payment_mock.assert_called()
2019-01-04 18:16:01 +03:00
```
2019-01-21 16:53:44 +03:00
### Testing selectors
Testing selectors is also an important part of every project.
Sometimes, the selectors can be really straightforward, and if we have to "cut corners", we can omit those tests. But it the end, it's important to cover our selectors too.
2021-01-19 14:03:52 +03:00
Let's take another look at our example selector:
2019-01-21 16:53:44 +03:00
```python
from django.contrib.auth.models import User
from django_styleguide.common.types import QuerySetType
from django_styleguide.payments.models import Item
def get_items_for_user(
*,
user: User
) -> QuerySetType[Item]:
return Item.objects.filter(payments__user=user)
```
2019-10-29 21:56:09 +03:00
As you can see, this is a very straightforward & simple selector. We can easily cover that with 2 to 3 tests.
2019-01-21 16:53:44 +03:00
**Here are the tests:**
```python
from django.test import TestCase
from django.contrib.auth.models import User
from django_styleguide.payments.selectors import get_items_for_user
from django_styleguide.payments.models import Item, Payment
class GetItemsForUserTests(TestCase):
def test_selector_returns_nothing_for_user_without_items(self):
"""
This is a "corner case" test.
We should get nothing if the user has no items.
"""
user = User.objects.create_user(username='Test User')
expected = []
result = list(get_items_for_user(user=user))
self.assertEqual(expected, result)
def test_selector_returns_item_for_user_with_that_item(self):
"""
This test will fail in case we change the model structure.
"""
user = User.objects.create_user(username='Test User')
item = Item.objects.create(
name='Test Item',
description='Test Item description',
price=10.15
)
Payment.objects.create(
item=item,
user=user
)
expected = [item]
result = list(get_items_for_user(user=user))
self.assertEqual(expected, result)
```
2019-11-03 12:26:13 +03:00
## Celery
2019-11-03 13:00:25 +03:00
We use [Celery](http://www.celeryproject.org/) for the following general cases:
2019-11-03 12:26:13 +03:00
* Communicating with 3rd party services (sending emails, notifications, etc.)
* Offloading heavier computational tasks outside the HTTP cycle.
* Periodic tasks (using Celery beat)
We try to treat Celery as if it's just another interface to our core logic - meaning - **don't put business logic there.**
An example task might look like this:
2019-11-03 12:26:13 +03:00
```python
from celery import shared_task
from project.app.services import some_service_name as service
2019-11-03 12:26:13 +03:00
@shared_task
def some_service_name(*args, **kwargs):
service(*args, **kwargs)
```
This is a task, having the same name as a service, which holds the actual business logic.
**Of course, we can have more complex situations**, like a chain or chord of tasks, each of them doing different domain related logic. In that case, it's hard to isolate everything in a service, because we now have dependencies between the tasks.
If that happens, we try to expose an interface to our domain & let the tasks work with that interface.
One can argue that having an ORM object is an interface by itself, and that's true. Sometimes, you can just update your object from a task & that's OK.
But there are times where you need to be strict and don't let tasks do database calls straight from the ORM, but rather, via an exposed interface for that.
**More complex scenarios depend on their context. Make sure you are aware of the architecture & the decisions you are making.**
2019-11-03 12:35:50 +03:00
### Structure
#### Configuration
We put Celery configuration in a Django app called `tasks`. The [Celery config](https://docs.celeryproject.org/en/latest/django/first-steps-with-django.html) itself is located in `apps.py`, in `TasksConfig.ready` method.
2019-11-03 12:35:50 +03:00
This Django app also holds any additional utilities, related to Celery.
Here's an example `project/tasks/apps.py` file:
```python
import os
2019-11-03 13:30:56 +03:00
2019-11-03 12:35:50 +03:00
from celery import Celery
2019-11-03 13:30:56 +03:00
2019-11-03 12:35:50 +03:00
from django.apps import apps, AppConfig
from django.conf import settings
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.local')
app = Celery('project')
class TasksConfig(AppConfig):
name = 'project.tasks'
verbose_name = 'Celery Config'
def ready(self):
app.config_from_object('django.conf:settings', namespace="CELERY")
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
from celery.utils.log import base_logger
base_logger = base_logger
base_logger.debug('debug message')
base_logger.info('info message')
base_logger.warning('warning message')
base_logger.error('error message')
base_logger.critical('critical message')
print('Request: {0!r}'.format(self.request))
return 42
```
#### Tasks
2021-01-19 14:03:52 +03:00
Tasks are located in `tasks.py` modules in different apps.
2019-11-03 12:35:50 +03:00
We follow the same rules as with everything else (APIs, services, selectors): **if the tasks for a given app grow too big, split them by domain.**
Meaning, you can end up with `tasks/domain_a.py` and `tasks/domain_b.py`. All you need to do is import them in `tasks/__init__.py` for Celery to autodiscover them.
2019-11-03 12:35:50 +03:00
The general rule of thumb is - split your tasks in a way that'll make sense to you.
#### Circular imports between tasks & services
2021-01-19 14:03:52 +03:00
In some cases, you need to invoke a task from a service or vice-versa:
```python
# project/app/services.py
from project.app.tasks import task_function_1
def service_function_1():
print('I delay a task!')
task_function_1.delay()
def service_function_2():
print('I do not delay a task!')
```
```python
# project/app/tasks.py
from celery import shared_task
from project.app.services import service_function_2
@shared_task
def task_function_1():
print('I do not call a service!')
@shared_task
def task_function_2():
print('I call a service!')
service_function_2()
```
Unfortunately, this will result in a circular import.
What we usually do is we import the service function **inside** the task function:
```python
# project/app/tasks.py
from celery import shared_task
@shared_task
def task_function_1():
print('I do not call a service!')
@shared_task
def task_function_2():
from project.app.services import service_function_2 # <--
print('I call a service!')
service_function_2()
```
* Note: Depending on the case, you may want to import the task function **inside** the service function. This is OK and will still prevent the circular import between service & task functions.
2019-11-03 12:58:07 +03:00
### Periodic Tasks
2021-01-19 14:03:52 +03:00
Managing periodic tasks is quite important, especially when you have tens or hundreds of them.
2019-11-03 12:58:07 +03:00
We use [Celery Beat](https://docs.celeryproject.org/en/latest/userguide/periodic-tasks.html) + `django_celery_beat.schedulers:DatabaseScheduler` + [`django-celery-beat`](https://github.com/celery/django-celery-beat) for our periodic tasks.
2019-11-03 12:58:07 +03:00
The extra thing that we do is to have a management command, called `setup_periodic_tasks`, which holds the definition of all periodic tasks within the system. This command is located in the `tasks` app, discussed above.
Here's how `project.tasks.management.commands.setup_periodic_tasks.py` looks like:
```python
from django.core.management.base import BaseCommand
from django.db import transaction
from django_celery_beat.models import IntervalSchedule, CrontabSchedule, PeriodicTask
from project.app.tasks import some_periodic_task
class Command(BaseCommand):
help = f"""
Setup celery beat periodic tasks.
Following tasks will be created:
- {some_periodic_task.name}
"""
@transaction.atomic
def handle(self, *args, **kwargs):
print('Deleting all periodic tasks and schedules...\n')
IntervalSchedule.objects.all().delete()
CrontabSchedule.objects.all().delete()
PeriodicTask.objects.all().delete()
periodic_tasks_data = [
{
'task': some_periodic_task
'name': 'Do some peridoic stuff',
# https://crontab.guru/#15_*_*_*_*
'cron': {
'minute': '15',
'hour': '*',
'day_of_week': '*',
'day_of_month': '*',
'month_of_year': '*',
},
'enabled': True
},
]
for periodic_task in periodic_tasks_data:
print(f'Setting up {periodic_task["task"].name}')
cron = CrontabSchedule.objects.create(
**periodic_task['cron']
)
PeriodicTask.objects.create(
name=periodic_task['name'],
task=periodic_task['task'].name,
crontab=cron,
enabled=periodic_task['enabled']
)
```
Few key things:
* We use this task as part of a deploy procedure.
2021-01-19 14:03:52 +03:00
* We always put a link to [`crontab.guru`](https://crontab.guru) to explain the cron. Otherwise it's unreadable.
2019-11-03 12:58:07 +03:00
* Everything is in one place.
* ⚠️ We use, almost exclusively, a cron schedule. **If you plan on using the other schedule objects, provided by Celery, please read thru their documentation** & the important notes - <https://django-celery-beat.readthedocs.io/en/latest/#example-creating-interval-based-periodic-task> - about pointing to the same schedule object. ⚠️
2019-11-03 12:58:07 +03:00
2019-11-03 13:00:25 +03:00
### Configuration
Celery is a complex topic, so it's a good idea to invest time reading the documentation & understanding the different configuration options.
We constantly do that & find new things or find better approaches to our problems.
## Misc
### mypy / type annotations
About type annotations & using `mypy`, [this tweet](https://twitter.com/queroumavodka/status/1294789817071542272) resonates a lot with our philosophy.
We have projects where we enforce `mypy` on CI and are very strict with types.
2021-01-19 14:03:52 +03:00
We have projects where types are looser.
Context is king here.
2018-07-31 14:33:27 +03:00
## 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