Django scheduler

Introduction

Scheduler

Final view

This project is an example of how to replace an Excel spreadsheet with Django. That's actually how it came to be. I have embarked on a quest to unite six different databases my current employer is using at the moment into a single Django application. One of them was a schedule the owner was keeping in a spreadsheet. So I styled this app to look exactly like the original, hence some of the design solutions. This could be a good guide for someone who just finished a Python/Django bootcamp and wants to push his skills to an intermediate level, because we will use some of the more advanced techniques in this project, while keeping it "pure" Django, no additional JS packages, HTMX, etc.

Database

We will be using three models in this app. The main model will be Event. The other one called Trip is already part of the bigger project. But we will connect it to our app, so that whenever someone creates a Trip instance, it will automatically create an Event instance linked to it in OneToOne. Then we will create an auxiliary model called EventCol to represent columns in our table.


models.py
from django.db import models
from django.db.models import Max
from django.urls import reverse
from datetime import timedelta


class Trip(models.Model):
    ...
    start_date = models.DateField()
    end_date = models.DateField()
    ...


class EventCol(models.Model):
    name = models.CharField(max_length=255)
    user = models.OneToOneField(
        User,
        on_delete=models.CASCADE,
        null=True,
        blank=True,
    )
    order = models.PositiveSmallIntegerField(null=True, blank=True)

    class Meta:
        ordering = ['order', 'user', 'pk']

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.order:
            get_max = self._meta.model.objects.aggregate(
                Max("order", default=0))
            self.order = get_max['order__max'] + 10
        super().save(*args, **kwargs)


def type_choices():
    choices = [
        ('T', 'Service Trip'),
        ('S', 'Sales Trip'),
        ('P', 'Paid Service'),
        ('E', 'Exhibition'),
        ('H', 'Holiday'),
        ('V', 'Vacation'),
        ('O', 'Sick day'),
        ('C', 'Comptime'),
        ('M', 'Misc'),
    ]
    return choices


class Event(models.Model):
    cols = models.ManyToManyField(EventCol, blank=True)
    title = models.CharField(max_length=255, blank=True)
    desc = models.TextField(blank=True, verbose_name="Description")
    etype = models.CharField(
        max_length=1,
        choices=type_choices(),
        verbose_name="Type",
    )
    start_date = models.DateField()
    end_date = models.DateField()
    closed = models.BooleanField(default=False)
    bgcolor = models.CharField(
        max_length=9, blank=True, verbose_name="Fill color")
    color = models.CharField(max_length=9, blank=True,
                             verbose_name="Text color")
    trip = models.OneToOneField(
        Trip,
        on_delete=models.SET_NULL,
        null=True,
        blank=True
    )

    class Meta:
        ordering = ['-end_date', '-pk']

    def __str__(self):
        if self.title:
            return self.title
        elif self.trip:
            return self.trip.company.short_name
        else:
            return self.get_etype_display()

    @property
    def short_name(self):
        return self.__str__().split()[0]

    def get_absolute_url(self):
        return reverse('scheduler:update-event', args=[str(self.id)])

    def get_days(self, start_date, end_date):
        days = 0
        delta = self.end_date - self.start_date
        for i in range(delta.days + 1):
            day = self.start_date + timedelta(days=i)
            if (day >= start_date and day <= end_date) \
                    and day.weekday() not in (5, 6):
                days += 1
        return days

    @property
    def tooltip(self):
        return self.__str__() + '\n\n' + self.desc

EventCol is linked OneToOne to user profiles and has an order field to allow us to arrange the columns. We override class method save to generate a new order value on creation based on the highest we have at the moment. The Event model has M2M relation with Eventcol and has some methods we will need later.

Calendar

Now we need to make a function to generate a list of objects representing days that we will use to fill rows in our table. We will use Python datetime and calendar libraries to achieve that.


from calendar import Calendar
from datetime import timedelta
from django.db.models import Q


def generate_calendar(start_month, end_month, year):
    cal = Calendar()

    # Make a list of week objects inside the required range
    week_list = []
    for month in range(start_month, end_month + 1):
        week_list += cal.monthdatescalendar(year, month)
    first_day = week_list[0][0]
    last_day = week_list[-1][-1]

    # Get a list of columns
    cols = EventCol.objects.all()
    colsn = cols.count()

    # Convert a list of weeks into a dict of date objects
    week_dict = {}
    for w in week_list:
        for d in w:
            week_dict[d] = {}
            for c in cols:
                week_dict[d][c] = {'date': d.isoformat(), 'class': 'day ',
                                   'col': c.id, }

    # Get a list of events inside the required range
    events = Event.objects.filter(Q(
        start_date__range=(first_day, last_day)) | Q(
        end_date__range=(first_day, last_day)))

    # Multiply the weeks dict by a number of columns
    for e in events:
        days = [e.start_date + timedelta(days=x) for x in range(
                (e.end_date - e.start_date).days + 1)]
        for c in e.cols.all():
            for d in days:
                week_dict[d][c]['class'] += 'event ' + \
                    e.get_etype_display().lower().replace(' ', '_') + ' '
                if e.closed:
                    week_dict[d][c]['class'] += 'closed '
                week_dict[d][c].update({
                    'event': e.id,
                    'name': e.short_name,
                    'title': e.tooltip,
                    'color': e.color,
                    'bgcolor': e.bgcolor,
                })

    # Build a list of new week objects padded with extra week info fields
    cal_list = []
    i = 1
    for d, c in week_dict.items():
        cal_week = []
        weekday = d.strftime('%a')
        if i == 1:
            cal_week.append({
                'name': d.isocalendar()[1],
                'class': 'week ',
            })
        elif i == 2:
            cal_week.append({
                'name': d.strftime('%b'),
                'class': 'week ',
            })
        elif i in (6, 0):
            cal_week.append({'class': 'weekend ', })
            cal_week.append({'class': 'weekend ', 'name': weekday})
            cal_week.append({'class': 'weekend ', 'name': d.day})
            for n in range(colsn):
                cal_week.append({'class': 'weekend ', })
            cal_list.append(cal_week)
            i += 1
            i %= 7
            continue
        else:
            cal_week.append({'class': 'week ', })
        cal_week.append({'class': 'week ', 'name': weekday})
        cal_week.append({
            'date': d.isoformat(),
            'class': 'date  ',
            'name': d.day,
        })
        for k, v in c.items():
            cal_week.append(v)
        cal_list.append(cal_week)
        i += 1
        i %= 7
    return cal_list

This function generates a list of lists-weeks containing day-objects filled with all the necessary information from the Event model and padded with columns to represent week number, day of the week and date. A quick word about the i variable. A more obvious way would be to use a method like date.weekday() to identify day of the week, but since we are using Django version 4, our dictionary is already sorted and we know that the first object will be Monday, second Tuesday and so on. So to save us some electricity we replace relatively expensive date.weekday() with a simple counter.

Forms

We need to make two forms: one to handle the filters and one to create/update Event objects. The first one is pretty straight-forward. The second one makes sure we can't create overlapping events.

forms.py
from django import forms
from datetime import date
from django.db.models import Q
from django.utils.safestring import mark_safe

months = [
    ('1', 'January'),
    ('2', 'February'),
    ('3', 'March'),
    ('4', 'April'),
    ('5', 'May'),
    ('6', 'June'),
    ('7', 'July'),
    ('8', 'August'),
    ('9', 'September'),
    ('10', 'October'),
    ('11', 'November'),
    ('12', 'December'),
]


class SchedulerForm(forms.Form):
    start_month = forms.ChoiceField(choices=months)
    end_month = forms.ChoiceField(choices=months)
    year = forms.ChoiceField()

    def __init__(self, *args, **kwargs):
        today = kwargs.pop("today")
        start_month = kwargs.pop("start_month")
        end_month = kwargs.pop("end_month")
        year = kwargs.pop("year")
        super().__init__(*args, **kwargs)
        years = [(str(y), str(y)) for y in range(2023, today.year + 2)]
        self.fields['start_month'].initial = start_month
        self.fields['end_month'].initial = end_month
        self.fields['year'].initial = year
        self.fields['year'].choices = years


class EventForm(forms.ModelForm):
    reset = forms.BooleanField(initial=True, required=False)

    class Meta:
        model = Event
        exclude = ('users',)
        widgets = {
            'title': forms.TextInput(attrs={
                'style': 'width: 100%;',
                'placeholder': 'Title',
            }),
            'desc': forms.Textarea(attrs={
                'placeholder': 'Description',
                'rows': 5,
                'style': 'width: 100%;',
            }),
            'start_date': forms.DateInput(attrs={'type': 'date'}),
            'end_date': forms.DateInput(attrs={'type': 'date'}),
            'color': forms.TextInput(attrs={'type': 'color'}),
            'bgcolor': forms.TextInput(attrs={'type': 'color'}),
            'cols': forms.CheckboxSelectMultiple(),
        }

    def __init__(self, *args, **kwargs):
        request = kwargs.pop('request', None)
        super().__init__(*args, **kwargs)
        self.label_suffix = ''
        # Set initial dates
        if get_date := request.GET.get('date', None):
            get_date = date.fromisoformat(get_date)
            self.fields['start_date'].initial = get_date
            self.fields['end_date'].initial = get_date
        # If updating an Event filter Trip qs by dates
        if self.instance.id:
            self.fields['trip'].queryset = Trip.objects.filter(
                start_date__lte=self.instance.start_date,
                end_date__gte=self.instance.end_date,
            )
            # Uncheck reset switch
            if self.instance.color or self.instance.bgcolor:
                self.fields['reset'].initial = False
        # Pre-select fields based on which column-row have been clicked
        if col := request.GET.get('col', None):
            self.fields['cols'].initial = EventCol.objects.get(id=int(col))
            self.fields['trip'].queryset = Trip.objects.filter(
                start_date__lte=get_date,
                end_date__gte=get_date,
            )
        # Make sure only authorized users can make changes
        if self.instance.id and request.user not in self.instance.users.all() \
                and not request.user.is_admin():
            for f in self.fields:
                self.fields[f].disabled = True
        # If linked to a trip, use trip form to change dates
        if self.instance.trip:
            self.fields['start_date'].disabled = True
            self.fields['end_date'].disabled = True

    def clean(self):
        cleaned_data = super().clean()
        cc_title = cleaned_data.get("title")
        cc_trip = cleaned_data.get("trip")
        cc_etype = cleaned_data.get("etype")
        cc_start = cleaned_data.get("start_date")
        cc_end = cleaned_data.get("end_date")
        cc_cols = cleaned_data.get("cols")
        # Make sure events don't overlap
        break_out_flag = False
        start_date = cc_trip.start_date if cc_trip else cc_start
        end_date = cc_trip.end_date if cc_trip else cc_end
        if events := Event.objects.filter(
                Q(start_date__range=(start_date, end_date)) |
                Q(end_date__range=(start_date, end_date))):
            for e in events:
                if e != self.instance:
                    for c in e.cols.all():
                        if cc_cols.contains(c):
                            msg = forms.ValidationError(
                                mark_safe(('Event overlaps with another \
                                    scheduled <a href="{0}" target="_blank">\
                                    event</a>.').format(
                                    e.get_absolute_url())),
                                code='invalid')
                            self.add_error('start_date', msg)
                            break_out_flag = True
                            break
                    if break_out_flag:
                        break

Views

We make a ListView to render the calendar and CreateView/UpdateView to handle events. The edit CBVs are pretty similar, so I am only showing the first one.

views.py
from django.shortcuts import redirect
from django.contrib.auth.mixins import LoginRequiredMixin
from datetime import date
from django.views.generic import ListView, CreateView, TemplateView


class EventListView(LoginRequiredMixin, ListView):
    model = Event

    def get_context_data(self, *args, **kwargs):
        context = super().get_context_data(*args, **kwargs)
        today = date.today()
        start_month = int(self.request.GET.get('start_month', today.month))
        end_month = int(self.request.GET.get('end_month', today.month + 1))
        if end_month < start_month:
            start_month = end_month
        year = int(self.request.GET.get('year', today.year))
        context['cols'] = EventCol.objects.all()
        context['dict'] = generate_calendar(start_month, end_month, year)
        context['form'] = SchedulerForm(
            today=today, start_month=start_month, end_month=end_month, year=year)
        return context


class EventCreateView(LoginRequiredMixin, CreateView):
    model = Event
    form_class = EventForm

    def form_valid(self, form):
        self.object = form.save(commit=False)
        # Set dates from linked trip
        if self.object.trip:
            self.object.start_date = self.object.trip.start_date
            self.object.end_date = self.object.trip.end_date
        # Reset colors
        if form.cleaned_data['reset']:
            self.object.color = ''
            self.object.bgcolor = ''
        self.object.save()
        # If event is holiday apply it to all columns       
        if self.object.etype == 'H':
            self.object.cols.set(EventCol.objects.all())
        return redirect('scheduler:index')

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs.update(request=self.request)
        return kwargs

Template

Since we will be using table cells to represent our day-objects, we will have to use JavaScript to create links for us. Then we style it a bit with CSS and our scheduler is ready. Clicking on any cell opens a create/update form, pre-fills the columns form based on which column we clicked and the dates based on the row, and pre-filters Trips queryset for the date. We can override the default colors. The events are protected from unauthorized change (only the person who created the event and people who are linked to it can make changes). Plus, in my own implementation it handles some other business logic (for example, if the event type is changed from "Service Trip" to "Paid Service" it automatically changes the "Warranty" flag on the Report linked to the Trip).

event-list.html
{% extends "base.html" %}
{% load static %}

{% block filters %}
  <div class="filter">
    <div><b>From: </b>{{ form.start_month }}</div>
    <div><b>To: </b>{{ form.end_month }}</div>
    <div>{{ form.year }}</div>
    <div><button type="submit"> FILTER </button></div>
  </div>
{% endblock filters %}

{% block content %}
<div id="scheduler">
  <table>
    <thead>
      <tr>
        <th></th>
        <th></th>
        <th></th>
        {% for col in cols %}
          <th class="{% if col.user.tech %}tech{% elif col.user.sales %}sales{% elif col.user.office %}office{% else %}other{% endif %}">{{ col }}</th>
        {% endfor %}
      </tr>
    </thead>
    <tbody>
      {% for line in dict %}
        <tr>
          {% for rec in line %}
            {% if 'day' in rec.class and not rec.event %}
              <td class="{{ rec.class }}" onclick="window.location='{% url 'scheduler:new-event' %}?col={{rec.col}}&date={{rec.date}}'">{{ rec.name|default_if_none:'' }}</td>            
            {% elif 'day' in rec.class and rec.event %}
              <td class="{{ rec.class }}" onclick="window.location='{% url 'scheduler:update-event' rec.event %}'" title="{{ rec.title }}" style="{% if rec.color %}color:{{ rec.color }};{% endif %}{% if rec.bgcolor %}background-color:{{ rec.bgcolor }};{% endif %}">{{ rec.name|default_if_none:'' }}</td>
            {% else %}
              <td class="{{ rec.class }}">{{ rec.name|default_if_none:'' }}</td>
            {% endif %}
          {% endfor %}
        </tr>
      {% endfor %}
    </tbody>
  </table>        
</div>
{% endblock content %}

{% block button_panel_extra %}
    <a href="{% url 'scheduler:report' %}" title="Report"><img src="{% static 'img/vault.png' %}"></a>
{% endblock button_panel_extra %}

<style>
{% block page_css %}
th {
  position: sticky;
  top: 0;
}
td, th {
  border: solid #80808047 0.01rem;
  padding: 0.1rem 0.2rem;
  font-size: 0.75rem;
  text-align: center;
}
.week,.weekday,.date{
  background-color: #ffff0057;
}
.weekend {
  background-color: #00ffe957;
}
...
.closed {
  text-decoration: line-through;
}
.day {
  width: 3rem;
}
.day:hover, .closed:hover{
  cursor: pointer;
  background-color: #8080804f;
}
{% endblock page_css %}
</style>

Conclusion

This is just a basic set-up, but you can modify it to add more functionality. I've also added a report view that uses the provided get_days() method for the Event model to sum all event-days for each column and display it in a table. You can also add some JS to handle events with AJAX in a Modal. Also check out my tutorial if you want to deploy it on AWS.




Log in to leave comments