Improve roster by coloring conflicts
- Author
- Maarten Vangeneugden
- Date
- July 26, 2018, 7:53 p.m.
- Hash
- 884292600fbfb4f1fc825646f85bf69fd48342e7
- Parent
- ed815ee93652b3db38a69833b17404be8b1eb389
- Modified files
- administration/new_roster.py
- administration/templates/administration/roster_t.djhtml
- static/css/base.scss
administration/new_roster.py ¶
1 addition and 1 deletion.
View changes Hide changes
1 |
1 |
building of the roster. """ |
2 |
2 |
from django.shortcuts import render |
3 |
3 |
from collections import OrderedDict |
4 |
4 |
import datetime |
5 |
5 |
from django.urls import reverse |
6 |
6 |
from django.utils.translation import gettext as _ |
7 |
7 |
from .models import * |
8 |
8 |
import administration |
9 |
9 |
|
10 |
10 |
def same_day(datetime_a, datetime_b): |
11 |
11 |
"""True if both a and b are on the same day, false otherwise.""" |
12 |
12 |
return ( |
13 |
13 |
datetime_a.day == datetime_b.day and |
14 |
14 |
datetime_a.month == datetime_b.month and |
15 |
15 |
datetime_a.year == datetime_b.year) |
16 |
16 |
|
17 |
17 |
def same_daytime(moment, hour, minute): |
18 |
18 |
return (moment.minute == minute and moment.hour == hour) |
19 |
19 |
|
20 |
20 |
def conflicting_events(events): |
21 |
21 |
"""Finds conflicting events in the given set, and returns a list of all |
22 |
22 |
conflicting events, grouped according to their conflicts in lists.""" |
23 |
23 |
conflicts = list() |
24 |
24 |
test_conflict = list() |
25 |
25 |
#for event in events.order_by("begin_time"): |
26 |
26 |
for event in events: |
27 |
27 |
if len(test_conflict) == 0: |
28 |
28 |
test_conflict.append(event) |
29 |
29 |
else: |
30 |
30 |
possible_conflict = test_conflict.pop() |
31 |
31 |
#print("comparing" + str(possible_conflict) + " - " + str(event)) |
32 |
32 |
if possible_conflict.end_time > event.begin_time: # New conflict! |
33 |
33 |
test_conflict.append(possible_conflict) |
34 |
34 |
test_conflict.append(event) |
35 |
35 |
#print(test_conflict) |
36 |
36 |
elif len(test_conflict) == 0: # No conflict |
37 |
37 |
test_conflict.append(event) |
38 |
38 |
else: # No conflict, but previous conflicting events exist |
39 |
39 |
test_conflict.append(possible_conflict) |
40 |
40 |
conflicts.append(test_conflict.copy()) |
41 |
41 |
test_conflict.clear() |
42 |
42 |
if len(test_conflict) >= 2: |
43 |
43 |
conflicts.append(test_conflict.copy()) |
44 |
44 |
return conflicts |
45 |
45 |
|
46 |
46 |
def replace_conflict(events, conflicts): |
47 |
47 |
"""Removes the conflicts from events, and replaces them with a new |
48 |
48 |
event. Returns the key number for this conflict set. Expects the conflicts |
49 |
49 |
to be in chronological order based on begin_time.""" |
50 |
50 |
for conflict in conflicts: |
51 |
51 |
events.remove(conflict) |
52 |
52 |
conflict_number = 0 |
53 |
53 |
for event in events: |
54 |
54 |
if event.note == "Conflict " + str(conflict_number): |
55 |
55 |
conflict_number += 1 |
56 |
56 |
|
57 |
57 |
replacement = Event( |
58 |
58 |
begin_time = conflicts[0].begin_time, |
59 |
59 |
end_time = conflicts[-1].end_time, |
60 |
60 |
note = "Conflict " + str(conflict_number), |
61 |
61 |
) |
62 |
62 |
events.append(replacement) |
63 |
63 |
return conflict_number |
64 |
64 |
|
65 |
65 |
def replace_conflicts(events): |
66 |
66 |
"""Searches for all events that are in conflict regarding timespans, and |
67 |
67 |
replaces them with a general event. Returns a dictionary with keys referencing |
68 |
68 |
a specific conflict block, and the values being a list of the events that are |
69 |
69 |
in conflict with each other.""" |
70 |
70 |
all_conflicts = conflicting_events(events) |
71 |
71 |
conflict_dict = dict() |
72 |
72 |
for conflict_list in all_conflicts: |
73 |
73 |
conflict_number = replace_conflict(events, conflict_list) |
74 |
74 |
conflict_dict[conflict_number] = conflict_list |
75 |
75 |
return conflict_dict |
76 |
76 |
|
77 |
77 |
def create_roster_event(event_type, quarters, content, style="", note=""): |
78 |
78 |
return '<td class="{event_type}" style="{style}" {title} rowspan="{quarters}">{content}</td>'.format( |
79 |
79 |
event_type=event_type, |
80 |
80 |
style=style, |
81 |
81 |
quarters=quarters, |
82 |
82 |
content=content, |
83 |
83 |
title=note) |
84 |
84 |
|
85 |
85 |
def create_roster_conflict_event(event): |
86 |
86 |
quarters = (event.end_time - event.begin_time).seconds // 60 // 15 |
87 |
87 |
content = _('<strong>Conflicting events!<br />See <a href="#'+event.note+'">'+event.note+'</a> for more information.</strong>') |
88 |
88 |
return create_roster_event("event-conflict", quarters, content) |
89 |
89 |
|
90 |
90 |
def create_roster_study_event(event): |
91 |
91 |
pass # TODO |
92 |
92 |
def create_roster_university_event(event): |
93 |
93 |
pass # TODO |
94 |
94 |
def create_roster_course_event(event): |
95 |
95 |
# FIXME: Currently not all users are equipped with a user_data object. |
96 |
96 |
# Because of this, reversing the link to the docent's page may throw a |
97 |
97 |
# RelatedObjectDoesNotExist error. Until that's resolved, I've wrapped |
98 |
98 |
# creating that link in a try catch. |
99 |
99 |
docent_link = "" |
100 |
100 |
try: |
101 |
101 |
docent_link = reverse('administration-user', args=(event.docent.user_data.slug_name(),)) |
102 |
102 |
except : |
103 |
103 |
pass |
104 |
104 |
|
105 |
105 |
quarters = (event.end_time - event.begin_time).seconds // 60 // 15 |
106 |
106 |
course_link = reverse('courses-course-index', args=(event.course.course.slug_name,)) |
107 |
107 |
room_link = reverse('administration-room-detail', args=(str(event.room),)) |
108 |
108 |
event_type = "event" |
109 |
109 |
|
110 |
110 |
content = "{course}<br /> {docent}<br />{begin} - {end}<br /> {room} ({subject})".format( |
111 |
111 |
course = '<a href="'+course_link+'">'+str(event.course)+'</a>', |
112 |
- | docent = '<a href="'+docent_link+'">'+str(event.docent)+'</a>', |
+ |
112 |
docent = '<a href="'+docent_link+'">'+str(event.docent)+'</a>', |
113 |
113 |
begin = event.begin_time.strftime("%H:%M"), |
114 |
114 |
end = event.end_time.strftime("%H:%M"), |
115 |
115 |
room = '<a href="'+room_link+'">'+str(event.room)+'</a>', |
116 |
116 |
subject = event.subject, |
117 |
117 |
) |
118 |
118 |
|
119 |
119 |
style = "background-color: #"+event.course.course.color+"; color: white;" |
120 |
120 |
|
121 |
121 |
if event.recently_created(): |
122 |
122 |
event_type = "event-new" |
123 |
123 |
style = "" |
124 |
124 |
elif event.recently_updated(): |
125 |
125 |
event_type = "event-update" |
126 |
126 |
style = "" |
127 |
127 |
elif event.note != "": |
128 |
128 |
event_type = "event-note" |
129 |
129 |
|
130 |
130 |
return create_roster_event(event_type, quarters, content, style, event.note) |
131 |
131 |
|
132 |
132 |
|
133 |
133 |
|
134 |
134 |
|
135 |
135 |
|
136 |
136 |
def first_last_day(events): |
137 |
137 |
"""Returns the first and last day of the starting times of the given events.""" |
138 |
138 |
earliest = events[0].begin_time |
139 |
139 |
latest = events[0].begin_time |
140 |
140 |
for event in events: |
141 |
141 |
if event.begin_time < earliest: |
142 |
142 |
earliest = event.begin_time |
143 |
143 |
if event.begin_time > latest: |
144 |
144 |
latest = event.begin_time |
145 |
145 |
first = datetime.date(earliest.year, earliest.month, earliest.day) |
146 |
146 |
last = datetime.date(latest.year, latest.month, latest.day) |
147 |
147 |
return first, last |
148 |
148 |
|
149 |
149 |
|
150 |
150 |
|
151 |
151 |
|
152 |
152 |
def create_roster_for_event(event, conflict=False): |
153 |
153 |
"""Determines which function to call to build the roster entry for the given event.""" |
154 |
154 |
if conflict and isinstance(event, Event): |
155 |
155 |
return create_roster_conflict_event(event) |
156 |
156 |
elif isinstance(event, CourseEvent): |
157 |
157 |
return create_roster_course_event(event) |
158 |
158 |
elif isinstance(event, UniversityEvent): |
159 |
159 |
return create_roster_university_event(event) |
160 |
160 |
elif isinstance(event, StudyEvent): |
161 |
161 |
return create_roster_study_event(event) |
162 |
162 |
elif isinstance(event, Event): |
163 |
163 |
return create_roster_event(event) |
164 |
164 |
else: |
165 |
165 |
raise TypeError("Given object is not of any Event type") |
166 |
166 |
|
167 |
167 |
def make_first_quarter_column(hour, quarter): |
168 |
168 |
"""Creates and returns the first part of the quarter row, which is the |
169 |
169 |
column with the current time in it.""" |
170 |
170 |
quarter_line = "<tr><td style='font-size:" |
171 |
171 |
if quarter != 0: |
172 |
172 |
quarter_line += "xx-small; color: grey;" |
173 |
173 |
else: |
174 |
174 |
quarter_line += "x-small;" |
175 |
175 |
quarter_line += "'>" |
176 |
176 |
if hour < 10: |
177 |
177 |
quarter_line += "0" |
178 |
178 |
quarter_line += str(hour) +":" |
179 |
179 |
if quarter == 0: |
180 |
180 |
quarter_line += "0" |
181 |
181 |
quarter_line += str(quarter) |
182 |
182 |
quarter_line += "</td>" |
183 |
183 |
return quarter_line |
184 |
184 |
|
185 |
185 |
|
186 |
186 |
def make_quarter_row(events, hour, quarter): |
187 |
187 |
"""Creates an HTML row for a quarter. Expects *NO conflicts!*""" |
188 |
188 |
if len(conflicting_events(events)) != 0: |
189 |
189 |
print(conflicting_events(events)) |
190 |
190 |
raise ValueError("events contains conflicts!") |
191 |
191 |
|
192 |
192 |
quarter_line = make_first_quarter_column(hour, quarter) |
193 |
193 |
|
194 |
194 |
first_day, last_day = first_last_day(events) |
195 |
195 |
for i in range((first_day - last_day).days, 1): |
196 |
196 |
column_added = False |
197 |
197 |
current_day = (last_day + datetime.timedelta(days=i)) |
198 |
198 |
current_daytime = datetime.datetime( |
199 |
199 |
current_day.year, |
200 |
200 |
current_day.month, |
201 |
201 |
current_day.day, |
202 |
202 |
hour=hour, |
203 |
203 |
minute=quarter, |
204 |
204 |
tzinfo = datetime.timezone.utc) |
205 |
205 |
for event in events: |
206 |
206 |
if (same_day(event.begin_time, current_day) and |
207 |
207 |
same_daytime(event.begin_time, hour, quarter)): # Event starts on this quarter |
208 |
208 |
quarter_line += create_roster_for_event( |
209 |
209 |
event, |
210 |
210 |
conflict = (event.note.startswith("Conflict "))) |
211 |
211 |
column_added = True |
212 |
212 |
elif (same_day(event.begin_time, current_day) and |
213 |
213 |
event.begin_time < current_daytime and |
214 |
214 |
event.end_time >= current_daytime): |
215 |
215 |
column_added = True |
216 |
216 |
break |
217 |
217 |
if not column_added: |
218 |
218 |
quarter_line += "<td></td>" |
219 |
219 |
quarter_line += "</tr>" |
220 |
220 |
return quarter_line |
221 |
221 |
|
222 |
222 |
|
223 |
223 |
|
224 |
224 |
|
225 |
225 |
def create_roster_rows(events): |
226 |
226 |
if len(events) == 0: |
227 |
227 |
return {}, [] |
228 |
228 |
events = list(events.order_by("begin_time")) |
229 |
229 |
conflict_dict = replace_conflicts(events) |
230 |
230 |
print(conflict_dict) |
231 |
231 |
for event in events: |
232 |
232 |
print(event.begin_time) |
233 |
233 |
table_code = [] |
234 |
234 |
current_quarter = datetime.datetime.now().replace(hour=8, minute=0) |
235 |
235 |
while current_quarter.hour != 20 or current_quarter.minute != 00: |
236 |
236 |
table_code.append(make_quarter_row(events, current_quarter.hour, current_quarter.minute)) |
237 |
237 |
current_quarter += datetime.timedelta(minutes=15) |
238 |
238 |
return conflict_dict, table_code |
239 |
239 |
administration/templates/administration/roster_t.djhtml ¶
7 additions and 13 deletions.
View changes Hide changes
1 |
1 |
This is the roster template. You can use this to display a hourly roster, |
2 |
2 |
spread over a series of days. To function properly, this template requires |
3 |
3 |
the following variables: |
4 |
4 |
- days :: A list of all days for which a column must be made. |
5 |
5 |
- time_blocks :: A list of all rows with their respective timings, that must |
6 |
6 |
be displayed in the roster. These elements must *not* contain |
7 |
7 |
any unsafe elements! |
8 |
8 |
- conflicts :: A dictionary with all conflicting events that need to be listed. |
9 |
9 |
{% endcomment %} |
10 |
10 |
|
11 |
11 |
<!--<style> |
12 |
- | table td { |
13 |
- | border-width: 0px 0px 1px 0px; |
14 |
- | border-bottom-style: solid; |
15 |
- | border-style: solid; |
16 |
- | border-color: red; |
17 |
- | } |
18 |
- | </style>--> |
19 |
- | <table> |
20 |
12 |
<th> |
21 |
13 |
{#<td></td> {# Empty row for hours #} {# Apparantly this isn't necessary with <th /> #} |
22 |
14 |
{% for day in days %} |
23 |
15 |
<td>{{ day|date:"l (d/m)" }}</td> |
24 |
- | {% endfor %} |
+ |
16 |
{% endfor %} |
25 |
17 |
</th> |
26 |
18 |
{% for element in time_blocks %} |
27 |
19 |
{{ element|safe }} |
28 |
20 |
{% endfor %} |
29 |
21 |
</table> |
30 |
22 |
{% for number, conflict_list in conflicts.items %} |
31 |
23 |
<h2 id="Conflict {{ number }}">Conflict {{ number }}</h2> |
32 |
24 |
{% for conflict in conflict_list %} |
33 |
25 |
{% if conflict.recently_created %} |
34 |
26 |
<div class="event-new"> |
35 |
- | {% elif conflict.recently_updated %} |
+ |
27 |
{% elif conflict.recently_updated %} |
36 |
28 |
<div class="event-update"> |
37 |
- | {% endif %} |
+ |
29 |
{% else %} |
+ |
30 |
<p class="conflict event" style="background-color: #{{ conflict.course.course.color }};"> |
+ |
31 |
{% endif %} |
38 |
32 |
<a href="{% url "courses-course-index" conflict.course.course.slug_name %}"> |
39 |
33 |
{{ conflict.course }}</a><br /> |
40 |
- | {# FIXME Temporarily disabled until all users have an associated user_data #} |
+ |
34 |
{# FIXME Temporarily disabled until all users have an associated user_data #} |
41 |
35 |
{#<a href="{% url "administration-user" conflict.docent.user_data.slug_name %}">#} |
42 |
36 |
{#{{ conflict.docent }}</a><br />#} |
43 |
37 |
{{ conflict.docent }}<br /> |
44 |
38 |
{{ conflict.begin_time|date:"H:i" }} - {{ conflict.end_time|date:"H:i" }}<br /> |
45 |
39 |
<a href="{% url "administration-room-detail" conflict.room %}"> |
46 |
40 |
{{ conflict.room }}</a> ({{ conflict.subject }}) |
47 |
41 |
</div> |
48 |
- | {% endfor %} |
+ |
42 |
{% endfor %} |
49 |
43 |
{% endfor %} |
50 |
44 |
static/css/base.scss ¶
45 additions and 4 deletions.
View changes Hide changes
1 |
1 |
font-family: ubuntu; |
2 |
2 |
border-style: solid; |
3 |
3 |
text-transform: uppercase; |
4 |
4 |
border-width: 0.3em; |
5 |
5 |
margin: 1em; |
6 |
6 |
border-color: $uhasselt-color; |
7 |
7 |
padding: 0.2em; |
8 |
8 |
text-decoration: none; |
9 |
9 |
color: $uhasselt-color; |
10 |
10 |
font-weight: bold; |
11 |
11 |
} |
12 |
12 |
a.btn:hover { |
13 |
13 |
background-color: $uhasselt-color; |
14 |
14 |
color: white; |
15 |
15 |
} |
16 |
16 |
|
17 |
17 |
dl dt { |
18 |
18 |
margin: 5px; |
19 |
19 |
} |
20 |
20 |
|
21 |
21 |
.event { |
22 |
22 |
padding: 5px; |
23 |
23 |
} |
+ |
24 |
a { |
+ |
25 |
text-decoration: none; |
+ |
26 |
color: inherit; |
+ |
27 |
&:hover { |
+ |
28 |
text-decoration: white underline dotted; |
+ |
29 |
font-style: italic; |
+ |
30 |
} |
+ |
31 |
} |
+ |
32 |
} |
24 |
33 |
|
25 |
34 |
td { |
26 |
35 |
padding-right: 1em; |
27 |
36 |
} |
28 |
37 |
|
29 |
38 |
.event-update { |
30 |
39 |
padding: 5px; |
31 |
40 |
background-color: yellow; |
32 |
41 |
color: red; |
33 |
42 |
border: medium dotted red; |
34 |
43 |
} |
+ |
44 |
text-decoration: none; |
+ |
45 |
color: inherit; |
+ |
46 |
&:hover { |
+ |
47 |
text-decoration: red underline dotted; |
+ |
48 |
font-style: italic; |
+ |
49 |
} |
+ |
50 |
} |
+ |
51 |
} |
35 |
52 |
.event-new { |
36 |
53 |
padding: 5px; |
37 |
54 |
background-color: white; |
38 |
55 |
color: black; |
39 |
56 |
border: medium dashed black; |
40 |
57 |
} |
+ |
58 |
text-decoration: none; |
+ |
59 |
color: inherit; |
+ |
60 |
&:hover { |
+ |
61 |
text-decoration: black underline dotted; |
+ |
62 |
font-style: italic; |
+ |
63 |
} |
+ |
64 |
} |
+ |
65 |
} |
41 |
66 |
.event-note { |
42 |
67 |
padding: 5px; |
43 |
68 |
color: purple; |
44 |
69 |
border: medium double purple; |
45 |
70 |
} |
+ |
71 |
text-decoration: none; |
+ |
72 |
color: inherit; |
+ |
73 |
&:hover { |
+ |
74 |
text-decoration: white underline dotted; |
+ |
75 |
font-style: italic; |
+ |
76 |
} |
+ |
77 |
} |
+ |
78 |
} |
46 |
79 |
.event-conflict { |
47 |
80 |
padding: 5px; |
48 |
81 |
background-color: red; |
49 |
82 |
color: white; |
50 |
83 |
border: medium dashed; |
51 |
84 |
border-color: inherit; |
52 |
85 |
} |
53 |
- | .event a { |
54 |
- | text-decoration: none; |
55 |
- | color: inherit; |
56 |
- | } |
+ |
86 |
text-decoration-color: blue; |
+ |
87 |
font-style: italic; |
+ |
88 |
} |
+ |
89 |
} |
57 |
90 |
|
58 |
91 |
p.conflict { |
+ |
92 |
width: 50%; |
+ |
93 |
@media screen |
+ |
94 |
and (max-device-width: 440px) |
+ |
95 |
and (max-device-height: 800px) { |
+ |
96 |
width: 100%; |
+ |
97 |
} |
+ |
98 |
} |
+ |
99 |