在日期范围内按日期计算对象数

Count number of objects by date in daterange

在 Django 项目中,我定义了这些简化模型:

class People(models.Model):
    name = models.CharField(max_length=96)

class Event(models.Model):

    name = models.CharField(verbose_name='Nom', max_length=96)

    date_start = models.DateField()
    date_end = models.DateField()

    participants = models.ManyToManyField(to='People', through='Participation')

class Participation(models.Model):
    """Represent the participation of 1 people to 1 event, with information about arrival date and departure date"""

    people = models.ForeignKey(to=People, on_delete=models.CASCADE)
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE)

    arrival_d = models.DateField(blank=True, null=True)
    departure_d = models.DateField(blank=True, null=True)

现在,我需要生成一个参与图:对于每个活动日,我需要相应的参与总数。 目前,我使用这个糟糕的代码:

def daterange(start, end, include_last_day=False):
    """Return a generator for each date between start and end"""
    days = int((end - start).days)
    if include_last_day:
        days += 1
    for n in range(days):
        yield start + timedelta(n)

class ParticipationGraph(DetailView):

    template_name = 'events/participation_graph.html'
    model = Event

    def get_context_data(self, **kwargs):

        labels = []
        data = []

        for d in daterange(self.object.date_start, self.object.date_end):
            labels.append(formats.date_format(d, 'd/m/Y'))
            total_participation = self.object.participation_set
                .filter(arrival_d__lte=d, departure_d__gte=d).count()
            data.append(total_participation)

        kwargs.update({
            'labels': labels,
            'data': data,
        })
        return super(ParticipationGraph, self).get_context_data(**kwargs)

显然,我 运行 在 Event.date_startEvent.date_end 之间的每一天都有一个新的 SQL 查询。 有没有办法通过减少 SQL 查询(理想情况下,只有一个)的数量来获得相同的结果?

我尝试了 Django orm 中的许多聚合工具(values()、distinct() 等),但我总是遇到同样的问题:我没有具有简单日期值的字段,我只有开始和结束日期(在事件中)以及出发和到达日期(在参与中),所以我找不到按日期对结果进行分组的方法。

我同意当前的方法很昂贵,因为对于每一天,您都在重新查询数据库以查找您之前已经检索到的参与者。相反,我会通过对数据库进行一次性查询来获取参与者,然后使用该数据来填充结果数据结构来解决这个问题。

我要对您的解决方案进行的一个结构更改是,与其跟踪两个列表(其中每个索引对应于一天和参与),不如将数据聚合到字典中,将日期映射到参与者的数量。如果我们以这种方式聚合结果,如果需要的话,我们总是可以在最后将其转换为两个列表。

这是我的一般(伪代码)方法:

def formatDate(d):
    return formats.date_format(d, 'd/m/Y')

def get_context_data(self, **kwargs):

    # initialize the results with dates in question
    result = {}
    for d in daterange(self.object.date_start, self.object.date_end):
        result[formatDate(d)] = 0

    # for each participant, add 1 to each date that they are there
    for participant in self.object.participation_set:
        for d in daterange(participant.arrival_d, participant.departure_d):
            result[formatDate(d)] += 1

    # if needed, convert result to appropriate two-list format here

    kwargs.update({
        'participation_amounts': result
    })
    return super(ParticipationGraph, self).get_context_data(**kwargs)

就性能而言,两种方法执行相同数量的操作。在您的方法中,对于每一天 d,您过滤每个参与者 p。因此,操作次数为 O(dp)。在我的方法中,对于每个参与者,我都会经历他们参加的每一天(每天更糟,d)。因此,它也是 O(dp)。

喜欢我的方法的原因是你指出的。它只访问数据库一次以检索参与者列表。因此,它较少依赖于网络延迟。它确实牺牲了您从 SQL 对 python 代码的查询中获得的一些性能优势。但是,python 代码并不太复杂,即使有数十万人的事件也应该相当容易处理。

几天前看到这个问题,给了个赞,因为写得真好,问题也很有趣。最后我找到了一些时间来解决它。

Django 是模型-视图-控制器的变体,称为模型-模板-视图。因此,我的方法将遵循范例 "fat model and thin controllers"(或翻译成符合 Django "fat model and thin views")。

以下是我将如何重写模型:

import pandas

from django.db import models
from django.utils.functional import cached_property


class Person(models.Model):
    name = models.CharField(max_length=96)


class Event(models.Model):
    name = models.CharField(verbose_name='Nom', max_length=96)
    date_start = models.DateField()
    date_end = models.DateField()
    participants = models.ManyToManyField(to='Person', through='Participation')

    @cached_property
    def days(self):
        days = pandas.date_range(self.date_start, self.date_end).tolist()
        return [day.date() for day in days]

    @cached_property
    def number_of_participants_per_day(self):
        number_of_participants = []
        participations = self.participation_set.all()
        for day in self.days:
            count = len([par for par in participations if day in par.days])
            number_of_participants.append((day, count))
        return number_of_participants


class Participation(models.Model):
    people = models.ForeignKey(to=Person, on_delete=models.CASCADE)
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE)
    arrival_d = models.DateField(blank=True, null=True)
    departure_d = models.DateField(blank=True, null=True)

    @cached_property
    def days(self):
        days = pandas.date_range(self.arrival_d, self.departure_d).tolist()
        return [day.date() for day in days]

所有计算都放在模型中。依赖于存储在数据库中的数据的信息可用 cached_property.

让我们看一个例子 Event:

djangocon = Event.objects.create(
    name='DjangoCon Europe 2018',
    date_start=date(2018,5,23),
    date_end=date(2018,5,28)
)
djangocon.days
>>> [datetime.date(2018, 5, 23),
     datetime.date(2018, 5, 24),
     datetime.date(2018, 5, 25),
     datetime.date(2018, 5, 26),
     datetime.date(2018, 5, 27),
     datetime.date(2018, 5, 28)]

我使用 pandas 来生成日期范围,这对您的应用程序来说可能有点矫枉过正,但它的语法很好,非常适合演示目的。您可以按照自己的方式生成日期范围。
要得到这个结果只有一个查询。 days 与任何其他字段一样可用。
我在 Participation 中做的同样的事情,这里有一些例子:

antwane = Person.objects.create(name='Antwane')
rohan = Person.objects.create(name='Rohan Varma')
cezar = Person.objects.create(name='cezar')

他们都想参加 2018 年的 DjangoCon Europe,但并非所有人都整天都参加:

p1 = Participation.objects.create(
    people=antwane,
    event=djangocon,
    arrival_d=date(2018,5,23),
    departure_d=date(2018,5,28)
)
p2 = Participation.objects.create(
    people=rohan,
    event=djangocon,
    arrival_d=date(2018,5,23),
    departure_d=date(2018,5,26)
)
p3 = Participation.objects.create(
    people=cezar,
    event=djangocon,
    arrival_d=date(2018,5,25),
    departure_d=date(2018,5,28)
)

现在我们想看看活动进行的每一天有多少参与者。我们也跟踪 SQL 查询的数量。

from django.db import connection
djangocon = Event.objects.get(pk=1)
djangocon.number_of_participants_per_day
>>> [(datetime.date(2018, 5, 23), 2),
     (datetime.date(2018, 5, 24), 2),
     (datetime.date(2018, 5, 25), 3),
     (datetime.date(2018, 5, 26), 3),
     (datetime.date(2018, 5, 27), 2),
     (datetime.date(2018, 5, 28), 2)]

connection.queries
>>>[{'time': '0.000', 'sql': 'SELECT "participants_event"."id", "participants_event"."name", "participants_event"."date_start", "participants_event"."date_end" FROM "participants_event" WHERE "participants_event"."id" = 1'},
    {'time': '0.000', 'sql': 'SELECT "participants_participation"."id", "participants_participation"."people_id", "participants_participation"."event_id", "participants_participation"."arrival_d", "participants_participation"."departure_d" FROM "participants_participation" WHERE "participants_participation"."event_id" = 1'}]

有两个查询。第一个获取对象 Event,第二个获取活动每天的参与者人数。

现在您可以随意在视图中使用它了。由于缓存的属性,您无需重复数据库查询即可获得结果。

您可以遵循相同的原则,也可以添加 属性 以列出活动每一天的所有参与者。它可能看起来像:

class Event(models.Model):
    # ... snip ...
    @cached_property
    def participants_per_day(self):
        participants  = []
        participations = self.participation_set.all().select_related('people')
        for day in self.days:
            people = [par.people for par in participations if day in par.days]
            participants.append((day, people))
        return participants

    # refactor the number of participants per day
    @cached_property
    def number_of_participants_per_day(self):
        return [(day, len(people)) for day, people in self.participants_per_day]

希望您喜欢这个解决方案。