Sometimes we might want to specify a week in a certain month, such that when we filter with a specific date, we look if that date is in the same week as the one stored in the model.
We can solve this for example with an
IntegerField
and then determine the number
of weeks since a specific date, for example January
1st, 19901.
The problem with such an IntegerField
is
that it takes a way a lot of convenience to determine
the week number. For example when filtering for a
specific week it requires that the programmer will need
to convert the datetime object to a week number. It will
require a lot of logic for example to join two items, or
compare one week with a date
object.
What problems are solved with this?
In this pattern we will discuss an approach to define
extra fields like a WeekField
and a
MonthField
. These fields will aim to
implement querying in a transparent manner. The
model fields will truncate the
date
(time
) object to the start
of the week or the month respectively, and also
truncates the operands in case filtering is done with
these fields.
What does this pattern look like?
Django's DateField
is designed to store
a given date. When creating a new field, the two methods
that one often has to implement are to_python(…)
[Django-doc]
and get_db_prep_value(…)
[Django-doc].
The first one is used to transform data from the
database to its Python counter part2 whereas the latter
is used to convert items to their database counterpart.
The .get_db_prep_value(…)
method will thus,
for a DateField
and
DateTimeField
, convert the
date
and datetime
objects to a
certain format the specific database backend
understands. For example 2021-09-04
.
What is interesting is that the
.get_db_prep_value(…)
method will call the
.get_prep_value(…)
function that will, on
its turn call the .to_python(…)
method.
This thus means that both when serializing and
serializing the data, the data is passed through the
.to_python(…)
method. Indeed, we see this
if we inspect
the source code [GitHub]:
class DateField(DateTimeCheckMixin, Field): # … def get_prep_value(self, value): value = super().get_prep_value(value) return self.to_python(value) def get_db_prep_value(self, value, connection, prepared=False): # Casts dates into the format expected by the backend if not prepared: value = self.get_prep_value(value) return connection.ops.adapt_datefield_value(value)
We can make use of this by overriding the
.to_python(…)
method, and truncate the
date
/datetime
to the
corresponding week, month, etc. We thus can work with a
DateTruncMixin
that is implemented as:
class DateTruncMixin:
def truncate_date(self, dt):
return dt
def to_python(self, value):
value = super().to_python(value)
if value is not None:
return self.truncate_date(value)
return value
we here thus make a mixin that will, in case the
datetime is not None
call the
truncate_date
which by default does not
truncate.
With this mixin, we can implement fields that
truncate like a WeekField
and a
MonthField
. In this example a week starts
on Monday:
from datetime import timedelta
from django.db.models import DateField
class WeekField(DateTruncMixin, DateField):
def truncate_date(self, dt):
return dt - timedelta(days=dt.weekday())
class MonthField(DateTruncMixin, DateField):
def truncate_date(self, dt):
return dt - timedelta(days=dt.day-1)
The same logic can be applied for
DateTimeField
s:
from django.db.models import DateTimeField
class MinuteField(DateTruncMixin, DateTimeField):
def truncate_date(self, dt):
return dt.replace(second=0, microsecond=0)
class HourField(DateTruncMixin, DateTimeField):
def truncate_date(self, dt):
return dt.replace(minute=0, second=0, microsecond=0)
Now we can thus implement variants for a
WeekField
, MonthField
,
QuarterField
, 'SeasonField', etc. The idea
is that we can use this for querying, creating, and
updating values in a model that uses such field.
We can for example make a simple Week
model with a WeekField
:
from django.db import models
class Week(models.Model):
week = WeekField(unique=True)
Now we can start creating Week
objects.
If we create the same Week
object twice
with .get_or_create(…)
[Django-doc],
then the second time it will use the old
Week
object, even if we query with another
date
of the same week, it will retrieve the
Week
object created by the first creation
call. We can for example use two dates of the
35th week of 2021:
>>>> Week.objects.get_or_create(week='2021-09-04')
(<Week: Week object (1)>, True)
>>> Week.objects.get_or_create(week='2021-08-31')
(<Week: Week object (1)>, False)
In the database the date is stored as
2021-08-30
:
> SELECT * FROM week;
mysql+----+------------+
id | week |
| +----+------------+
1 | 2021-08-30 |
| +----+------------+
1 row in set (0.00 sec)
We thus can also compare two WeekField
s
effectively to check if they point to the same week. If
for example our Week
model would have a
second field week2
, then we can filter with
Week.objects.filter(week=F('week2'))
to
check if the two fields are equivalent.
We can also retrieve the Week
object we
created with the start of the week as value for the
week
attribute:
>>> week = Week.objects.get(pk=1)
>>> week.week
datetime.date(2021, 8, 30)
If we change the week
to another
date
object, and save the object again, it
is updated to the start of the week of that date object.
If we query for the old week, then we do not get any
object:
>>> from datetime import date
>>> week.week = date(2021, 9, 8)
>>> week.save()
>>> Week.objects.get(week='2021-9-6')
<Week: Week object (1)>
>>> Week.objects.filter(week='2021-09-04')
<QuerySet []>
There are still some issues when comparing the value
for a WeekField
with the value of a
DateField
for example, since the
WeekField
is, behind the curtains, just a
DateField
that is set to the beginning of
the week. We however think that the fields defined above
will result in more programmer convenience.