Django scheduler
Introduction
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.pyfrom 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.pyfrom 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