2

So I want to display all available items for any given date, shouldn't be that hard but somehow I ran into a problem concerning related items.

Let's say we have the following models, a model to store all bookings and a model with the Item.

Then I would create ListView to retrieve all items available between any given dates. I override the queryset to retrieve the data filled in by the user.

This seems to be working but there's an issue, even though I check if the "form_start_date" or "form_end_data" are in conflict with existing bookings, when a single Item has multiple bookings it does not work.

example

Bookings [X] for item #01:
01-01-2019 to 01-03-2019
01-11-2019 to 01-18-2019

  Jan 2019   1   2   3   4   5   6   7   8   9   10   11   12   13   14   15   16   17   18  
 ---------- --- --- --- --- --- --- --- --- --- ---- ---- ---- ---- ---- ---- ---- ---- ---- 
  Item #01   X   X   X           O   O   O            X    X    X    X    X    X    X    X   
  Item #02       X   X   X   X   X                                                 

When I check for availablity [O] for 01-06-2019 to 01-08-2019, item #01 is not available, what am I missing here?

models.py

class Booking(models.Model):
    item = models.ForeignKey('Item', on_delete=models.SET_NULL, null=True)
    start_date = models.DateField()
    end_date = models.DateField()

class Item(models.Model):
    name = models.CharField(max_length=20, unique=True)

views.py

class AvailableItems(generic.ListView):
    model = Item

    def get_queryset(self):
        start_date = datetime.strptime(self.request.GET.get('start_date'), '%Y-%m-%d')
        end_date = datetime.strptime(self.request.GET.get('end_date'), '%Y-%m-%d')
        # As Willem suggested in the comments, it is easier to check for available items
        available_items = (
            Item.objects.filter(booking__start_date__gte = start_date, booking__end_date__lte = end_date)
        )

        if start_date and end_date:
            return available_items
        else:
            return Item.objects.all()
Kevin D.
  • 315
  • 2
  • 19
  • The condition here is not sufficient I think, since it is possible that nor the `start_date` is between the `form_start_date` and `form_end_date`, nor that `end_date` is between these two points, but still the booking overlaps. For example because the booking span is larger than the span of the form (starts earlier, ends later). – Willem Van Onsem Jan 06 '19 at 14:06
  • Thank you for noticing, I added another rule to capture this. – Kevin D. Jan 06 '19 at 16:36

1 Answers1

2

Let us first analyze when two intervals (f1, t1) and (f2, t2) overlap. A simpler problem to solve, is finding out when two intervals do not overlap. That holds for two cases:

  1. given t1 < f2, since then the first event ends before the second; or
  2. given t2 < f1, since then the first event ends before the second begins.

So that means that two events overlap given t1 ≥ f2 and t2 ≥ f1.

With this knowledge, we can design a filter like:

bookings = Booking.objects.filter(
    end_date__gte=form_start_date,
    start_date__lte=form_end_date
)

return Item.objects.exclude(
    booking__in=bookings
)

This then results in a query like:

SELECT item.*
FROM item
WHERE NOT (
    item.id IN (
        SELECT V1.item_id
        FROM booking V1
        WHERE (V1.id IN (
            SELECT U0.id FROM booking U0
            WHERE (U0.end_date >= 2019-01-01 AND U0.start_date <= 2019-02-02)
            AND V1.item_id IS NOT NULL
        )
    )
)

(here 2019-01-01 and 2019-02-02 are hypothetical start and end dates).

I think it is probably better to process the two dates through a Form however to do proper validation and cleaning.

For example if we populate an empty database with the data as provided in the question, we get:

>>> i1 = Item.objects.create(name='Item #01')
>>> i2 = Item.objects.create(name='Item #02')
>>> b1 = Booking.objects.create(item=i1, start_date=)
KeyboardInterrupt
>>> from datetime import date
>>> b1 = Booking.objects.create(item=i1, start_date=date(2019,1,1), end_date=date(2019, 1, 3))
>>> b2 = Booking.objects.create(item=i1, start_date=date(2019,1,11), end_date=date(2019, 1, 18))
>>> bookings = Booking.objects.filter(
...     end_date__gte=date(2019, 1, 6),
...     start_date__lte=date(2019, 1, 8)
... )
>>> Item.objects.exclude(
...     booking__in=bookings
... )
<QuerySet [<Item: Item object (2)>, <Item: Item object (3)>]>
>>> b3 = Booking.objects.create(item=i2, start_date=date(2019,1,2), end_date=date(2019, 1, 6))
>>> bookings = Booking.objects.filter(
...     end_date__gte=date(2019, 1, 6),
...     start_date__lte=date(2019, 1, 8)
... )
>>> Item.objects.exclude(
...     booking__in=bookings
... )
<QuerySet [<Item: Item object (2)>]>

So first I constructed two items, and made two bookings on the first item. If we then make a query, we see that both items pop up. If we then add an extra booking for Item #02, then if we perform the query again, we see that only the first item (I first made an item for test purposes that was then removed) is showing up, since for the given range, the second item is no longer available: it has been booked by booking b3.

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
Willem Van Onsem
  • 443,496
  • 30
  • 428
  • 555
  • Thank for the reply, other logic, to check if form_end_date > form_start_date will be handled by a clean_method. It is the availability I'm having issues with so i thought to simplify the example. The strange thing is, when I create a ListView for "Booking" instead of "Item", the logic seems to work, but showing a list with certain bookings within a time period is silly. It is the available items we're after – Kevin D. Jan 06 '19 at 17:01
  • So the above is is still not working? Because, well, the logic of the query seems to work, based on the SQL output it generates. – Willem Van Onsem Jan 06 '19 at 17:19
  • I've tested the logic on save method while creating bookings and it does work, however when i try to display a list with all available items at a given date, it does not display the available days between two bookings of the same item. – Kevin D. Jan 06 '19 at 17:24
  • @KevinD: that is a harder problem, since there is no "order" in the table. You can sort by booking dates (for example first on `start_date` then on `end_date`). But calculating the difference in days between two such rows is probably better done at Python level. In SQL it is rather hard to get the "previous" and "next" row and do processing between these. The above simply retrieves all items for which there is no booking that is "active" for the given range. – Willem Van Onsem Jan 06 '19 at 17:27
  • Dear Willem, I guess my explanation wasn't really clear, since I'm querying the Booking model, and checking for any conflicts between the users input and existing records it should not conflict. I've edited my example, item #01 shows as not available when i select dates [O], it shows a conflict where it shouldn't... – Kevin D. Jan 06 '19 at 18:08
  • @KevinD.: well I constructed a session with the sample data (and sample range) you provided, and got the expected results (see updated answer). Perhaps there is an issue with the dates (perhaps you swapped the month and day?) – Willem Van Onsem Jan 06 '19 at 18:15
  • I changed the filter to show available items, this is indeed an easier solution and does work. How can i return all items except for these? – Kevin D. Jan 07 '19 at 11:57
  • @KevinD.: you can obtain all "booked" items with: `Item.objects.filter(booking__end_date__gte=form_start_date, booking__start_date__lte=form_end_date).distinct()`. – Willem Van Onsem Jan 07 '19 at 12:00
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/186315/discussion-between-kevin-d-and-willem-van-onsem). – Kevin D. Jan 07 '19 at 16:54