Skip to content

Commit 63ac305

Browse files
authored
Merge pull request #185 from HackSoc/improve-termdates
termdates: improve and refactor the plugin, fixing a couple of subtle bugs
2 parents cc86784 + 4ae8c55 commit 63ac305

File tree

3 files changed

+184
-164
lines changed

3 files changed

+184
-164
lines changed

src/csbot/plugins/termdates.py

Lines changed: 122 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,95 @@
11
from csbot.plugin import Plugin
2-
from datetime import datetime, timedelta
2+
import datetime
33
import math
4+
import typing as _t
45

56
from ..util import ordinal
67

78

9+
class Term:
10+
def __init__(self, key: str, start_date: datetime.datetime):
11+
self.key = key
12+
self.start_date = start_date
13+
14+
@property
15+
def first_monday(self) -> datetime.datetime:
16+
return self.start_date - datetime.timedelta(days=self.start_date.weekday())
17+
18+
@property
19+
def last_friday(self) -> datetime.datetime:
20+
return self.first_monday + datetime.timedelta(days=4, weeks=9)
21+
22+
def get_week_number(self, date: datetime.date) -> int:
23+
"""Get the "term week number" of a date relative to this term.
24+
25+
The first week of term is week 1, not week 0. Week 1 starts at the
26+
Monday of the term's start date, even if the term's start date is not
27+
Monday. Any date before the start of the term gives a negative week
28+
number.
29+
"""
30+
delta = date - self.first_monday.date()
31+
week_number = math.floor(delta.days / 7.0)
32+
if week_number >= 0:
33+
return week_number + 1
34+
else:
35+
return week_number
36+
37+
def get_week_start(self, week_number: int) -> datetime.datetime:
38+
"""Get the start date of a specific week number relative to this term.
39+
40+
The first week of term is week 1, not week 0, although this method
41+
allows both. When referring to the first week of term, the start date is
42+
the term start date (which may not be a Monday). All other weeks start
43+
on their Monday.
44+
"""
45+
if week_number in (0, 1):
46+
return self.start_date
47+
elif week_number > 1:
48+
return self.first_monday + datetime.timedelta(weeks=week_number - 1)
49+
else:
50+
return self.first_monday + datetime.timedelta(weeks=week_number)
51+
52+
853
class TermDates(Plugin):
954
"""
1055
A wonderful plugin allowing old people (graduates) to keep track of the
1156
ever-changing calendar.
1257
"""
1358
DATE_FORMAT = '%Y-%m-%d'
59+
TERM_KEYS = ('aut', 'spr', 'sum')
1460

1561
db_terms = Plugin.use('mongodb', collection='terms')
16-
db_weeks = Plugin.use('mongodb', collection='weeks')
62+
63+
terms = None
64+
_doc_id = None
1765

1866
def setup(self):
1967
super(TermDates, self).setup()
68+
self._load()
69+
70+
def _load(self):
71+
doc = self.db_terms.find_one()
72+
if not doc:
73+
return False
74+
self.terms = {key: Term(key, doc[key][0]) for key in self.TERM_KEYS}
75+
self._doc_id = doc['_id']
76+
return True
77+
78+
def _save(self):
79+
if not self.terms:
80+
return False
81+
doc = {key: (self.terms[key].start_date, self.terms[key].last_friday) for key in self.TERM_KEYS}
82+
if self._doc_id:
83+
self.db_terms.replace_one({'_id': self._doc_id}, doc, upsert=True)
84+
else:
85+
res = self.db_terms.insert_one(doc)
86+
self._doc_id = res.inserted_id
87+
return True
2088

21-
# If we have stuff in mongodb, we can just load it directly.
22-
if self.db_terms.find_one():
23-
self.initialised = True
24-
self.terms = self.db_terms.find_one()
25-
self.weeks = self.db_weeks.find_one()
26-
return
27-
28-
# If no term dates have been set, the calendar is uninitialised and
29-
# can't be asked about term things.
30-
self.initialised = False
31-
32-
# Each term is represented as a tuple of the date of the first Monday
33-
# and the last Friday in it.
34-
self.terms = {term: (None, None)
35-
for term in ['aut', 'spr', 'sum']}
36-
37-
# And each week is just the date of the Monday
38-
self.weeks = {'{} {}'.format(term, week): None
39-
for term in ['aut', 'spr', 'sum']
40-
for week in range(1, 11)}
89+
@property
90+
def initialised(self) -> bool:
91+
"""If no term dates have been set, the calendar is uninitialised and can't be asked about term thing."""
92+
return self._doc_id is not None
4193

4294
@Plugin.command('termdates', help='termdates: show the current term dates')
4395
def termdates(self, e):
@@ -55,15 +107,15 @@ def _term_start(self, term):
55107
"""
56108

57109
term = term.lower()
58-
return self.terms[term][0].strftime(self.DATE_FORMAT)
110+
return self.terms[term].start_date.strftime(self.DATE_FORMAT)
59111

60112
def _term_end(self, term):
61113
"""
62114
Get the end date (last Friday) of a term as a string.
63115
"""
64116

65117
term = term.lower()
66-
return self.terms[term][1].strftime(self.DATE_FORMAT)
118+
return self.terms[term].last_friday.strftime(self.DATE_FORMAT)
67119

68120
@Plugin.command('week',
69121
help='week [term] [num]: info about a week, '
@@ -80,81 +132,74 @@ def week(self, e):
80132
# !week term n - get the date of week n in the given term
81133
# !week n term - as above
82134

83-
week = e['data'].split()
135+
week = e['data'].lower().split()
84136
if len(week) == 0:
85-
term, weeknum = self._current_week()
137+
term, week_number = self._current_week()
86138
elif len(week) == 1:
87139
try:
88-
term = self._current_term()
89-
weeknum = int(week[0])
90-
if weeknum < 1:
140+
term = self._current_or_next_term()
141+
week_number = int(week[0])
142+
if week_number < 1:
91143
e.reply('error: bad week format')
92144
return
93145
except ValueError:
94-
term = week[0][:3]
95-
term, weeknum = self._current_week(term)
146+
term_key = week[0][:3]
147+
term, week_number = self._current_week(term_key)
96148
elif len(week) >= 2:
97149
try:
98-
term = week[0][:3]
99-
weeknum = int(week[1])
150+
term_key = week[0][:3]
151+
week_number = int(week[1])
100152
except ValueError:
101153
try:
102-
term = week[1][:3]
103-
weeknum = int(week[0])
154+
term_key = week[1][:3]
155+
week_number = int(week[0])
104156
except ValueError:
105157
e.reply('error: bad week format')
106158
return
159+
try:
160+
term = self.terms[term_key]
161+
except KeyError:
162+
e.reply('error: bad week format')
163+
return
107164
else:
108165
e.reply('error: bad week format')
109166
return
110167

111-
if weeknum > 0:
112-
e.reply('{} {}: {}'.format(term.capitalize(),
113-
weeknum,
114-
self._week_start(term, weeknum)))
168+
if term is None:
169+
e.reply('error: no term dates (see termdates.set)')
170+
elif week_number > 0:
171+
e.reply('{} {}: {}'.format(term.key.capitalize(),
172+
week_number,
173+
term.get_week_start(week_number).strftime(self.DATE_FORMAT)))
115174
else:
116175
e.reply('{} week before {} (starts {})'
117-
.format(ordinal(-weeknum),
118-
term.capitalize(),
119-
self._week_start(term, 1)))
176+
.format(ordinal(-week_number),
177+
term.key.capitalize(),
178+
term.start_date.strftime(self.DATE_FORMAT)))
120179

121-
def _current_term(self):
180+
def _current_or_next_term(self) -> _t.Optional[Term]:
122181
"""
123182
Get the name of the current term
124183
"""
125184

126-
now = datetime.now().date()
127-
for term in ['aut', 'spr', 'sum']:
128-
dates = self.terms[term]
129-
if now >= dates[0].date() and now <= dates[1].date():
185+
now = datetime.datetime.now().date()
186+
for key in self.TERM_KEYS:
187+
term = self.terms[key]
188+
if now < term.first_monday.date():
130189
return term
131-
elif now <= dates[0].date():
132-
# We can do this because the terms are ordered
190+
elif now <= term.last_friday.date():
133191
return term
192+
return None
134193

135-
def _current_week(self, term=None):
136-
if term is None:
137-
term = self._current_term()
138-
start, _ = self.terms[term]
139-
now = datetime.now()
140-
delta = now.date() - start.date()
141-
weeknum = math.floor(delta.days / 7.0)
142-
if weeknum >= 0:
143-
weeknum += 1
144-
return term, weeknum
145-
146-
def _week_start(self, term, week):
147-
"""
148-
Get the start date of a week as a string.
149-
"""
150-
151-
term = term.lower()
152-
start = self.weeks['{} 1'.format(term)]
153-
if week > 0:
154-
offset = timedelta(weeks=week - 1)
194+
def _current_week(self, key: _t.Optional[str] = None) -> (_t.Optional[Term], _t.Optional[int]):
195+
if key:
196+
term = self.terms.get(key.lower())
155197
else:
156-
offset = timedelta(weeks=week)
157-
return (start + offset).strftime(self.DATE_FORMAT)
198+
term = self._current_or_next_term()
199+
if term:
200+
return term, term.get_week_number(datetime.date.today())
201+
else:
202+
return None, None
158203

159204
@Plugin.command('termdates.set',
160205
help='termdates.set <aut> <spr> <sum>: set the term dates')
@@ -165,47 +210,15 @@ def termdates_set(self, e):
165210
e.reply('error: all three dates must be provided')
166211
return
167212

168-
# Firstly compute the start and end dates of each term
169-
for term, date in zip(['aut', 'spr', 'sum'], dates):
213+
terms = {}
214+
for key, date in zip(self.TERM_KEYS, dates):
170215
try:
171-
term_start = datetime.strptime(date, self.DATE_FORMAT)
216+
term_start = datetime.datetime.strptime(date, self.DATE_FORMAT)
172217
except ValueError:
173218
e.reply('error: dates must be in %Y-%M-%d format.')
174219
return
175220

176-
# Not all terms start on a monday, so we need to compute the "real"
177-
# term start used in all the other calculations.
178-
# Fortunately Monday is used as the start of the week in Python's
179-
# datetime stuff, which makes this really simple.
180-
real_start = term_start - timedelta(days=term_start.weekday())
181-
182-
# Log for informational purposes
183-
if not term_start == real_start:
184-
self.log.info('Computed real_start as {} (from {})'.format(
185-
repr(real_start), repr(term_start)))
186-
187-
term_end = real_start + timedelta(days=4, weeks=9)
188-
self.terms[term] = (term_start, term_end)
189-
190-
# Then the start of each week
191-
self.weeks['{} 1'.format(term)] = term_start
192-
for week in range(2, 11):
193-
week_start = real_start + timedelta(weeks=week-1)
194-
self.weeks['{} {}'.format(term, week)] = week_start
195-
196-
# Save to the database. As we don't touch the _id attribute in this
197-
# method, this will cause `save` to override the previously-loaded
198-
# entry (if there is one).
199-
if '_id' in self.terms:
200-
self.db_terms.replace_one({'_id': self.terms['_id']}, self.terms, upsert=True)
201-
else:
202-
res = self.db_terms.insert_one(self.terms)
203-
self.terms['_id'] = res.inserted_id
204-
if '_id' in self.weeks:
205-
self.db_weeks.replace_one({'_id': self.weeks['_id']}, self.weeks, upsert=True)
206-
else:
207-
res = self.db_weeks.insert_one(self.weeks)
208-
self.weeks['_id'] = res.inserted_id
221+
terms[key] = Term(key, term_start)
209222

210-
# Finally, we're initialised!
211-
self.initialised = True
223+
self.terms = terms
224+
self._save()

tests/conftest.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,30 @@ async def irc_client(request, event_loop, config_example_mode, irc_client_class,
8181
return client
8282

8383

84+
class LineMatcher:
85+
def __init__(self, f, description):
86+
self.f = f
87+
self.description = description
88+
89+
def __call__(self, line):
90+
return self.f(line)
91+
92+
def __repr__(self):
93+
return self.description
94+
95+
@classmethod
96+
def equals(cls, other):
97+
return cls(lambda line: line == other, f"`line == {other!r}`")
98+
99+
@classmethod
100+
def contains(cls, other):
101+
return cls(lambda line: other in line, f"`{other!r} in line`")
102+
103+
@classmethod
104+
def endswith(cls, other):
105+
return cls(lambda line: line.endswith(other), f"`line.endswith({other!r})`")
106+
107+
84108
class IRCClientHelper:
85109
def __init__(self, irc_client):
86110
self.client = irc_client
@@ -155,21 +179,7 @@ def assert_sent(self, matchers, *, any_order=False, reset_mock=True):
155179
if reset_mock:
156180
self.client.send_line.reset_mock()
157181

158-
159-
class LineMatcher:
160-
def __init__(self, f, description):
161-
self.f = f
162-
self.description = description
163-
164-
def __call__(self, line):
165-
return self.f(line)
166-
167-
def __repr__(self):
168-
return self.description
169-
170-
@classmethod
171-
def equals(cls, other):
172-
return cls(lambda line: line == other, f"`line == {other!r}`")
182+
match_line = LineMatcher
173183

174184

175185
@pytest.fixture

0 commit comments

Comments
 (0)