Merge lp:~xavier-antoviaque/ibid/scrummeeting-934376 into lp:ibid

Proposed by XavierAntoviaque
Status: Needs review
Proposed branch: lp:~xavier-antoviaque/ibid/scrummeeting-934376
Merge into: lp:ibid
Diff against target: 864 lines (+696/-34)
5 files modified
AUTHORS (+2/-1)
ibid/plugins/meetings.py (+401/-33)
ibid/templates/meetings/minutes.html (+23/-0)
ibid/templates/meetings/minutes.txt (+21/-0)
ibid/test/plugins/test_meetings.py (+249/-0)
To merge this branch: bzr merge lp:~xavier-antoviaque/ibid/scrummeeting-934376
Reviewer Review Type Date Requested Status
Ibid Core Team Pending
Review via email: mp+93661@code.launchpad.net
To post a comment you must log in.
1049. By XavierAntoviaque

Removes superflous line

1050. By XavierAntoviaque

Refs #934376 - SCRUM Meetings - Separator is now only ';', added reporting on behalf of another user

* Fix usability issue with separators, based on feedback from real-world usage. Dots and commas tend to split progress report items in unintended ways, for example with URLs or long items which need punctuation.

* Added reporting on behalf of another user, similarly to the 'topic <subject> [<user>]' command

1051. By XavierAntoviaque

Missing test commit for #934376

Unmerged revisions

1051. By XavierAntoviaque

Missing test commit for #934376

1050. By XavierAntoviaque

Refs #934376 - SCRUM Meetings - Separator is now only ';', added reporting on behalf of another user

* Fix usability issue with separators, based on feedback from real-world usage. Dots and commas tend to split progress report items in unintended ways, for example with URLs or long items which need punctuation.

* Added reporting on behalf of another user, similarly to the 'topic <subject> [<user>]' command

1049. By XavierAntoviaque

Removes superflous line

1048. By XavierAntoviaque

Implements #934376 - Commands to hold SCRUM meetings

Implements the following commands:

    (next | done | blocked by) <statement>[, <statement>[, ...]]
    what is my progress report?
    what's the progress report from <nick>?

"next|done|blocked by" work similarly to agreed|rejected|..., with the
following refinements:

* They are available to all participants
* They are sorted by participant in the minutes
* When a user has used all three commands, ibid prints a recap of his
  progress report (which can also be obtained manually with "what is...?"
* Ibid splits the statements when it encounters one of the following
  characters ";.,". This allows to present the items in a readable way, as
  unsplitted lists are pretty hard to read when there are more than 1-2
  items.
* Blockers are automatically added to the agenda for later discussion,
  except when it is "nothing"/

1047. By XavierAntoviaque

Adding myself to the AUTHORS file, as requested :p

1046. By XavierAntoviaque

Implements #931774 - Agenda tracking for meetings (adds tests)

Adding unit test for the meeting agenda feature, as per the code review.

1045. By XavierAntoviaque

Implements #931774 - Agenda tracking for meetings

* Implementation based on https://wiki.koumbit.net/IrcBotService/ToDo#Agenda_tracking
  For the complete list of supported commands, see below.

* Integrated the "topic <text>" command as part of the agenda tracking
  feature, in a manner which keeps backward compatibility, but allows
  to take advantage of the new features. It can be used alone, the same
  way as before, and will then create agenda topics and open them
  immediately, closing the previous one if any. If used with pre-defined
  agenda items, it will insert the new topic immediately after the current
  one, and move to it.

* Factored the meeting identification based on the current event in a single
  method

== List of implemented commands ==

Add a topic to the agenda
    agenda+ <text>
    topic+ <text>
    agenda+ <text> '['<proposer>']'
    topic+ <text> '['<proposer>']'

Add a topic and open it immediately
    topic <text>
    topic <text> '['<proposer>']'

Display the topics on the agenda
    what is [on] [the] agenda?
    what's [on] [the] agenda?
    list [the] agenda
    show [the] agenda
    agenda?
    what are the topics?
    list [the] topics
    show [the] topics
    topics?

Rename an agenda topic
    rename topic <number> to <newtext>

Drop a topic from the agenda
    agenda- <number>
    topic- <number>
    delete topic <number>
    drop topic <number>
    forget topic <number>
    remove topic <number>

Reorder the agenda
    [the] agenda order [is] a [, b]... [, m-[n]]...
    [the] topic[s] order [is] a [, b]... [, m-[n]]...

Clear the agenda
    [please] clear [the] agenda
    [please] clear [the] topics

Move to the next agenda topic
    [take up] next topic
    [open] next topic
    move to next topic

Open the discussion about an agenda topic
    take up topic <number|pattern>
    open topic <number|pattern>
    move to topic <number|pattern>

What is the current agenda topic?
    what topic [is open]?
    what topic is this?
    what topic are we on?
    current topic?
    topic?

Close an agenda topic
    close topic <number|pattern>
    close this topic
    close [the] current topic

Defer a topic on the agenda
    skip [this] topic

A sample usage can be found at http://paste.pocoo.org/show/551514/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'AUTHORS'
2--- AUTHORS 2011-03-23 16:11:13 +0000
3+++ AUTHORS 2012-02-27 20:39:17 +0000
4@@ -24,4 +24,5 @@
5 * Kevin Woodland
6 * Guy Halse
7 * Dominic Cleal
8- * Keegan Carruthers-Smith
9\ No newline at end of file
10+ * Keegan Carruthers-Smith
11+ * Xavier Antoviaque
12\ No newline at end of file
13
14=== modified file 'ibid/plugins/meetings.py'
15--- ibid/plugins/meetings.py 2012-02-13 22:04:05 +0000
16+++ ibid/plugins/meetings.py 2012-02-27 20:39:17 +0000
17@@ -1,4 +1,4 @@
18-# Copyright (c) 2009-2010, Stefano Rivera and Max Rabkin
19+# Copyright (c) 2009-2012, Stefano Rivera, Max Rabkin and Xavier Antoviaque
20 # Released under terms of the MIT/X/Expat Licence. See COPYING for details.
21
22 from datetime import datetime, timedelta
23@@ -9,6 +9,7 @@
24 import re
25 from urllib import quote
26 from xmlrpclib import ServerProxy
27+from copy import copy
28
29 from dateutil.parser import parse
30 from dateutil.tz import tzlocal, tzutc
31@@ -33,10 +34,26 @@
32 usage = u"""
33 (start | end) meeting [about <title>]
34 I am <True Name>
35- topic <topic>
36 (agreed | idea | accepted | rejected) <statement>
37+ (next | done | blocked by) <statement>[; <statement>[; ...]] '['<nickname>']'
38+ what is my progress report?
39+ what's the progress report from <nick>?
40 minutes so far
41 meeting title is <title>
42+ topic+ <text>
43+ topic+ <text> '['<proposer>']'
44+ what's on the agenda?
45+ topic- <number>
46+ the agenda order is a [, b]... [, m-[n]]...
47+ rename topic <number> to <newtext>
48+ move to next topic
49+ move to topic <number|pattern>
50+ what topic are we on?
51+ close this topic
52+ close topic <number|pattern>
53+ skip this topic
54+ topic <text>
55+ topic <text> '['<proposer>']'
56 """
57 features = ('meeting',)
58 permission = u'chairmeeting'
59@@ -57,6 +74,7 @@
60 file_mode = Option('file_mode', u'File Permissions mode, in octal', '644')
61 dir_mode = Option('dir_mode',
62 u'Directory Permissions mode, in octal', '755')
63+ progress_report_types = ['done', 'next', 'blockers']
64
65 @authorise(fallthrough=False)
66 @match(r'^start\s+meeting(?:\s+about\s+(.+))?$')
67@@ -75,12 +93,14 @@
68 .logging_name(event.channel),
69 'title': title,
70 'attendees': {},
71+ 'agenda': [],
72 'minutes': [{
73 'time': event.time,
74 'type': 'started',
75 'subject': None,
76 'nick': event.sender['nick'],
77 }],
78+ 'progress_report': {},
79 'log': [],
80 }
81 meetings[(event.source, event.channel)] = meeting
82@@ -88,6 +108,18 @@
83 event.addresponse(u'gets out his memo-pad and cracks his knuckles',
84 action=True)
85
86+ def locate_meeting(self, event):
87+ """Attempt to find the meeting corresponding to the event"""
88+ if not event.public:
89+ event.addresponse(u'Sorry, must be done in public')
90+ return
91+ if (event.source, event.channel) not in meetings:
92+ event.addresponse(u'Sorry, no meeting in progress.')
93+ return
94+
95+ meeting = meetings[(event.source, event.channel)]
96+ return meeting
97+
98 @match(r'^i\s+am\s+(.+)$')
99 def ident(self, event, name):
100 if not event.public or (event.source, event.channel) not in meetings:
101@@ -99,14 +131,13 @@
102 event.addresponse(True)
103
104 @authorise()
105- @match(r'^(topic|idea|agreed|accepted|rejected)\s+(.+)$')
106- def identify(self, event, action, subject):
107- if not event.public or (event.source, event.channel) not in meetings:
108+ @match(r'^(idea|agreed|accepted|rejected)\s+(.+)$')
109+ def record_action(self, event, action, subject):
110+ meeting = self.locate_meeting(event)
111+ if not meeting:
112 return
113
114 action = action.lower()
115-
116- meeting = meetings[(event.source, event.channel)]
117 meeting['minutes'].append({
118 'time': event.time,
119 'type': action,
120@@ -114,9 +145,7 @@
121 'nick': event.sender['nick'],
122 })
123
124- if action == 'topic':
125- message = u'Current Topic: %s'
126- elif action == 'idea':
127+ if action == 'idea':
128 message = u'Idea recorded: %s'
129 elif action == 'agreed':
130 message = u'Agreed: %s'
131@@ -126,28 +155,371 @@
132 message = u'Rejected: %s'
133 event.addresponse(message, subject, address=False)
134
135+ @match(r'^(done|next|blocked\s+by):?\s+(?!topic)([^\[]+)(?:\s+\[(.+)\])?$')
136+ def record_progress_report(self, event, action, subject, nick):
137+ meeting = self.locate_meeting(event)
138+ if not meeting:
139+ return
140+
141+ action = action.lower()
142+ nick = nick or event.sender['nick']
143+
144+ if nick not in meeting['progress_report']:
145+ meeting['progress_report'][nick] = {'done': [],
146+ 'next': [],
147+ 'blockers': []}
148+
149+ subject_list = re.split(';', subject.strip())
150+ message = u''
151+ for subject in subject_list:
152+ subject = subject.strip()
153+ if not subject:
154+ continue
155+
156+ if action == 'done':
157+ meeting['progress_report'][nick]['done'].append(subject)
158+ message = u'Good job!'
159+ elif action == 'next':
160+ meeting['progress_report'][nick]['next'].append(subject)
161+ message = u'Quite a plan.'
162+ elif re.match(r'blocked\s+by', action, re.I):
163+ meeting['progress_report'][nick]['blockers'].append(subject)
164+ if re.match(r'nothing|none|nada', subject, re.I):
165+ message = u'Glad to hear that'
166+ else:
167+ message = u'We\'ll need to solve that later, I\'m' \
168+ u' adding it to the agenda'
169+ self.add_agenda_topic_silently(event, subject, nick)
170+
171+ event.addresponse(message)
172+
173+ if meeting['progress_report'][nick]['done'] and \
174+ meeting['progress_report'][nick]['next'] and \
175+ meeting['progress_report'][nick]['blockers']:
176+ self.show_progress_report(event, nick)
177+
178+ @match(r'^(?:what\'s|what\s+is|show|show\s+me|list)(?:\s+the|\s+my)?'
179+ r'(?:\s+progress)?\s+report(?:\s+from\s+(.+))?$')
180+ def show_progress_report(self, event, nick):
181+ meeting = self.locate_meeting(event)
182+ if not meeting:
183+ return
184+
185+ if not nick:
186+ nick = event.sender['nick']
187+
188+ event.addresponse(u'My notes of %s\'s report:', nick, address=False)
189+ for report_type in self.progress_report_types:
190+ if report_type == 'done' and meeting['progress_report'][nick]['done']:
191+ event.addresponse(u' - What %s worked on:', nick, address=False)
192+ elif report_type == 'next' and meeting['progress_report'][nick]['next']:
193+ event.addresponse(u' - What %s will be working on next:', nick, address=False)
194+ elif report_type == 'blockers' and meeting['progress_report'][nick]['blockers']:
195+ event.addresponse(u' - What\'s blocking %s:', nick, address=False)
196+
197+ for subject in meeting['progress_report'][nick][report_type]:
198+ event.addresponse(u' * %s', subject, address=False)
199+
200 @authorise()
201 @match(r'^meeting\s+title\s+is\s+(.+)$')
202 def set_title(self, event, title):
203- if not event.public:
204- event.addresponse(u'Sorry, must be done in public')
205- return
206- if (event.source, event.channel) not in meetings:
207- event.addresponse(u'Sorry, no meeting in progress.')
208- return
209- meeting = meetings[(event.source, event.channel)]
210+ meeting = self.locate_meeting(event)
211+ if not meeting:
212+ return
213+
214 meeting['title'] = title
215 event.addresponse(True)
216
217+ @match(r'^(?:agenda|topic)\+\s+([^\[]+)(?:\s+\[(.+)\])?$')
218+ def add_agenda_topic(self, event, topic_name, proposer):
219+ meeting = self.locate_meeting(event)
220+ if not meeting:
221+ return
222+
223+ self.add_agenda_topic_silently(event, topic_name, proposer or event.sender['nick'])
224+ event.addresponse(u'Ok, I added "%(name)s" to the agenda',
225+ {'name': topic_name})
226+
227+ def add_agenda_topic_silently(self, event, topic_name, proposer):
228+ meeting = self.locate_meeting(event)
229+ if not meeting:
230+ return
231+
232+ meeting['agenda'].append({'name': topic_name,
233+ 'state': 'unopened',
234+ 'proposer': proposer})
235+
236+ @authorise()
237+ @match(r'^topic\s+([^\[]+)(?:\s+\[(.+)\])?$')
238+ def add_and_open_agenda_topic(self, event, topic_name, proposer):
239+ meeting = self.locate_meeting(event)
240+ if not meeting:
241+ return
242+
243+ open_topic_nb = self.get_open_agenda_topic_nb(meeting)
244+ if open_topic_nb is None:
245+ new_topic_nb = len(meeting['agenda'])
246+ else:
247+ new_topic_nb = open_topic_nb + 1
248+
249+ meeting['agenda'].insert(new_topic_nb, {'name': topic_name,
250+ 'state': 'unopened',
251+ 'proposer': proposer or event.sender['nick']})
252+ self.open_agenda_topic(event, str(new_topic_nb + 1))
253+
254+ @match(r'^(?:what(?:\s+is|\'s)\s+(?:on\s+)?(?:the\s+)?agenda\??|what\s+are\s+the\s+topics\??|'
255+ r'(?:show|list)\s+(?:the\s+)?(?:agenda|topics)|agenda\??|topics\??)$')
256+ def list_agenda(self, event):
257+ meeting = self.locate_meeting(event)
258+ if not meeting:
259+ return
260+
261+ if len(meeting['agenda']) < 1:
262+ event.addresponse(u'Well, the agenda is empty. Tell me what I '
263+ u'should add there with "topic+ <name>"')
264+ return
265+
266+ nb = 0
267+ event.addresponse(u'Here is the current agenda:')
268+ for topic in meeting['agenda']:
269+ nb += 1
270+
271+ if topic['state'] == 'unopened':
272+ state = u''
273+ elif topic['state'] == 'open':
274+ state = u'(current)'
275+ elif topic['state'] == 'closed':
276+ state = u'(closed)'
277+
278+ event.addresponse(u' %(nb)d. %(name)s %(state)s',
279+ {'nb': nb,
280+ 'state': state,
281+ 'name': topic['name']})
282+
283+ @authorise()
284+ @match(r'^rename\s+topic\s+(\d+)\s*to\s*(.+)$')
285+ def rename_agenda_topic(self, event, topic_nb, topic_name):
286+ meeting = self.locate_meeting(event)
287+ if not meeting:
288+ return
289+
290+ try:
291+ topic = meeting['agenda'][int(topic_nb) - 1]
292+ except IndexError:
293+ event.addresponse(u"Did you count right? I don't see any "
294+ u'topic on the agenda with the number %(topic_nb)s', \
295+ {'topic_nb': topic_nb})
296+ return
297+
298+ topic_previous_name = topic['name']
299+ topic['name'] = topic_name
300+ event.addresponse(u'Ok, I renamed it from "%(previous_name)s" '
301+ u'to "%(new_name)s"',
302+ {'previous_name': topic_previous_name,
303+ 'new_name': topic_name})
304+
305+ @authorise()
306+ @match(r'^(?:(?:agenda-|topic-)|(?:delete|drop|forget|remove)\s+'
307+ r'(?:agenda|topic))\s+(\d+)$')
308+ def delete_agenda_topic(self, event, topic_nb):
309+ meeting = self.locate_meeting(event)
310+ if not meeting:
311+ return
312+
313+ try:
314+ topic_name = meeting['agenda'][int(topic_nb) - 1]['name']
315+ del meeting['agenda'][int(topic_nb) - 1]
316+ except IndexError:
317+ event.addresponse(u'Um, there is no topic %(topic_nb)s', \
318+ {'topic_nb': topic_nb})
319+ return
320+
321+ event.addresponse(u'Ok, I removed "%(name)s" from the agenda',
322+ {'name': topic_name})
323+
324+ @authorise()
325+ @match(r'^(?:please\s+)?clear\s+(?:the\s+)?agenda$')
326+ def clear_agenda(self, event):
327+ meeting = self.locate_meeting(event)
328+ if not meeting:
329+ return
330+
331+ meeting['agenda'] = []
332+ event.addresponse(u'Better start over from scratch, yes. All neat and clean.')
333+
334+ @authorise()
335+ @match(r'^(?:the\s+)?(?:agenda|topics|topic)\s+order\s+(?:is\s+)([\d\s,]+)$')
336+ def reorder_agenda(self, event, agenda_order_str):
337+ meeting = self.locate_meeting(event)
338+ if not meeting:
339+ return
340+
341+ reordered_agenda = []
342+ remaining_agenda = copy(meeting['agenda'])
343+ for topic_nb in agenda_order_str.split(','):
344+ topic_nb = int(topic_nb.strip())
345+ try:
346+ reordered_agenda.append(meeting['agenda'][topic_nb - 1])
347+ remaining_agenda.remove(meeting['agenda'][topic_nb - 1])
348+ except IndexError:
349+ event.addresponse(u'Um, there is no topic %(topic_nb)s', \
350+ {'topic_nb': topic_nb})
351+ return
352+ reordered_agenda += remaining_agenda
353+ meeting['agenda'] = reordered_agenda
354+
355+ event.addresponse(u'First things first!')
356+
357+ @authorise()
358+ @match(r'^(?:(?:take\s+up|open|move\s+to)\s+)?next\s+topic$')
359+ def next_agenda_topic(self, event):
360+ meeting = self.locate_meeting(event)
361+ if not meeting:
362+ return
363+
364+ open_topic_nb = self.get_open_agenda_topic_nb(meeting)
365+ if open_topic_nb is None:
366+ open_topic_nb = 0
367+ else:
368+ meeting['agenda'][open_topic_nb]['state'] = 'closed'
369+
370+ next_topic = None
371+ for topic in meeting['agenda'][open_topic_nb:] \
372+ + meeting['agenda'][:open_topic_nb]:
373+ if topic['state'] == 'unopened':
374+ next_topic = topic
375+ break
376+
377+ if not next_topic:
378+ event.addresponse(u"That's all folks!")
379+ else:
380+ next_topic_nb = meeting['agenda'].index(next_topic) + 1
381+ self.open_agenda_topic(event, str(next_topic_nb))
382+
383+ @authorise()
384+ @match(r'^(?:take\s+up|open|move\s+to)\s+topic\s+(.+)$')
385+ def open_agenda_topic(self, event, topic_nb_or_pattern):
386+ meeting = self.locate_meeting(event)
387+ if not meeting:
388+ return
389+
390+ next_topic = self.get_agenda_topic_by_nb_or_pattern(event, topic_nb_or_pattern)
391+ if not next_topic:
392+ return
393+
394+ open_topic_nb = self.get_open_agenda_topic_nb(meeting)
395+ if open_topic_nb is not None:
396+ meeting['agenda'][open_topic_nb]['state'] = 'closed'
397+
398+ next_topic['state'] = 'open'
399+ meeting['minutes'].append({
400+ 'time': event.time,
401+ 'type': 'topic',
402+ 'subject': next_topic['name'],
403+ 'nick': next_topic['proposer'],
404+ })
405+ event.addresponse(u'The current topic is now "%(name)s" (from %(proposer)s)',
406+ {'name': next_topic['name'],
407+ 'proposer': next_topic['proposer']})
408+
409+ def get_agenda_topic_by_nb_or_pattern(self, event, topic_nb_or_pattern):
410+ meeting = self.locate_meeting(event)
411+ if not meeting:
412+ return
413+
414+ try:
415+ topic_nb = int(topic_nb_or_pattern.strip()) - 1
416+ found_topic = meeting['agenda'][topic_nb]
417+ except ValueError:
418+ found_topic = None
419+ for topic in meeting['agenda']:
420+ if re.search(topic_nb_or_pattern.strip(), topic['name']):
421+ found_topic = topic
422+ break
423+ if found_topic is None:
424+ event.addresponse(u"I don't see this topic in the agenda")
425+ return found_topic
426+
427+ @match(r'^(?:(?:what|current)\s+topic(?:\s+(?:is\s+this|is\s+open|are\s+we\s+on)\s*)?\??|topic\??)$')
428+ def current_agenda_topic(self, event):
429+ meeting = self.locate_meeting(event)
430+ if not meeting:
431+ return
432+
433+ open_topic_nb = self.get_open_agenda_topic_nb(meeting)
434+ if open_topic_nb is None:
435+ event.addresponse(u"I don't think we're discussing anything from the agenda")
436+ else:
437+ current_topic = meeting['agenda'][open_topic_nb]
438+ event.addresponse(u'We\'re discussing "%(name)s" (from %(proposer)s)',
439+ {'name': current_topic['name'],
440+ 'proposer': current_topic['proposer']})
441+
442+ @authorise()
443+ @match(r'^close\s+(?:this\s+|the\s+current\s+|current\s+)?topic$')
444+ def close_current_agenda_topic(self, event):
445+ meeting = self.locate_meeting(event)
446+ if not meeting:
447+ return
448+
449+ open_topic_nb = self.get_open_agenda_topic_nb(meeting)
450+ if open_topic_nb is None:
451+ event.addresponse(u"I don't think we're discussing anything from the agenda")
452+ else:
453+ open_topic = meeting['agenda'][open_topic_nb]
454+ open_topic['state'] = 'closed'
455+ event.addresponse(u"Yes, let's stop talking about %(name)s",
456+ {'name': open_topic['name'].lower()})
457+
458+ @authorise()
459+ @match(r'^close\s+topic\s+(.+)$')
460+ def close_agenda_topic(self, event, topic_nb_or_pattern):
461+ meeting = self.locate_meeting(event)
462+ if not meeting:
463+ return
464+
465+ topic = self.get_agenda_topic_by_nb_or_pattern(event, topic_nb_or_pattern)
466+ if not topic:
467+ return
468+
469+ if topic['state'] == 'closed':
470+ event.addresponse(u"We've already closed that topic")
471+ else:
472+ topic['state'] = 'closed'
473+ event.addresponse(u"We don't need to talk about %(name)s anymore",
474+ {'name': topic['name'].lower()})
475+
476+ @authorise()
477+ @match(r'^skip\s+(?:this\s+)?topic$')
478+ def skip_current_agenda_topic(self, event):
479+ meeting = self.locate_meeting(event)
480+ if not meeting:
481+ return
482+
483+ open_topic_nb = self.get_open_agenda_topic_nb(meeting)
484+ if open_topic_nb is None:
485+ event.addresponse(u"I don't think we're discussing anything from the agenda")
486+ else:
487+ open_topic = meeting['agenda'][open_topic_nb]
488+ event.addresponse(u"Ok, we'll discuss %(name)s later",
489+ {'name': open_topic['name'].lower()})
490+ self.next_agenda_topic(event)
491+ open_topic['state'] = 'unopened'
492+
493+ def get_open_agenda_topic_nb(self, meeting):
494+ nb = 0
495+ for topic in meeting['agenda']:
496+ if topic['state'] == 'open':
497+ return nb
498+ nb += 1
499+ return None
500+
501 @match(r'^minutes(?:\s+(?:so\s+far|please))?$')
502 def write_minutes(self, event):
503- if not event.public:
504- event.addresponse(u'Sorry, must be done in public')
505- return
506- if (event.source, event.channel) not in meetings:
507- event.addresponse(u'Sorry, no meeting in progress.')
508- return
509- meeting = meetings[(event.source, event.channel)]
510+ meeting = self.locate_meeting(event)
511+ if not meeting:
512+ return
513+
514 meeting['attendees'].update((e['nick'], None) for e in meeting['log']
515 if e['nick'] not in meeting['attendees']
516 and e['nick'] != ibid.config['botname'])
517@@ -168,8 +540,8 @@
518 indent=2)
519 else:
520 template = templates.get_template('meetings/minutes.' + format)
521- minutes[format] = template.render(meeting=meeting) \
522- .encode('utf-8')
523+ minutes[format] = template.render(meeting=meeting, \
524+ progress_report_types=self.progress_report_types).encode('utf-8')
525
526 filename = self.logfile % {
527 'source': event.source.replace('/', '-'),
528@@ -215,13 +587,9 @@
529 @authorise()
530 @match(r'^end\s+meeting$')
531 def end_meeting(self, event):
532- if not event.public:
533- event.addresponse(u'Sorry, must be done in public')
534- return
535- if (event.source, event.channel) not in meetings:
536- event.addresponse(u'Sorry, no meeting in progress.')
537- return
538- meeting = meetings[(event.source, event.channel)]
539+ meeting = self.locate_meeting(event)
540+ if not meeting:
541+ return
542
543 meeting['endtime'] = event.time
544 meeting['log'].append({
545
546=== modified file 'ibid/templates/meetings/minutes.html'
547--- ibid/templates/meetings/minutes.html 2009-10-25 23:54:25 +0000
548+++ ibid/templates/meetings/minutes.html 2012-02-27 20:39:17 +0000
549@@ -25,6 +25,29 @@
550 {%- endfor %}
551 </div>
552
553+{%- if meeting.progress_report %}
554+ <h2>Progress Report</h2>
555+ <ul id="progress-report">
556+ {%- for nick, report_list in meeting.progress_report.iteritems() %}
557+ <li><span class="nick">{{ nick|e }}</span>
558+ <ul>
559+ {%- for report_type in progress_report_types %}
560+ <li><span class="type">{{ report_type|e|upper }}</span>
561+ <ul>
562+ {%- if report_type in report_list %}
563+ {%- for subject in report_list[report_type] %}
564+ <li><span class="subject">{{ subject|e }}</span></li>
565+ {%- endfor %}
566+ {%- endif %}
567+ </ul>
568+ </li>
569+ {%- endfor %}
570+ </ul>
571+ </li>
572+ {%- endfor %}
573+ </ul>
574+{%- endif %}
575+
576 <h2>Present</h2>
577 <ul id="present">
578 {%- for nick, name in meeting.attendees.iteritems() %}
579
580=== modified file 'ibid/templates/meetings/minutes.txt'
581--- ibid/templates/meetings/minutes.txt 2009-10-25 23:54:25 +0000
582+++ ibid/templates/meetings/minutes.txt 2012-02-27 20:39:17 +0000
583@@ -9,6 +9,27 @@
584 [{{ event.time.strftime('%H:%M:%S') }}] {{ event.type | upper }}
585 {{- ': ' + event.subject if event.subject else '' }} ({{ event.nick }})
586 {% endfor %}
587+
588+{%- if meeting.progress_report %}
589+Progress Report
590+===============
591+
592+{%- for nick, report_list in meeting.progress_report.iteritems() %}
593+
594+[{{ nick|e }}]
595+
596+{%- for report_type in progress_report_types %}
597+{{ report_type|e|upper }}:
598+{%- if report_type in report_list %}
599+{%- for subject in report_list[report_type] %}
600+ * {{ subject|e }}
601+{%- endfor %}
602+{%- endif %}
603+{%- endfor %}
604+
605+{%- endfor %}
606+{%- endif %}
607+
608 Present
609 =======
610
611
612=== added file 'ibid/test/plugins/test_meetings.py'
613--- ibid/test/plugins/test_meetings.py 1970-01-01 00:00:00 +0000
614+++ ibid/test/plugins/test_meetings.py 2012-02-27 20:39:17 +0000
615@@ -0,0 +1,249 @@
616+# Copyright \(c) 2010-2011, Max Rabkin, Stefano Rivera
617+# Released under terms of the MIT/X/Expat Licence. See COPYING for details.
618+
619+import logging
620+
621+import ibid.test
622+
623+class MeetingsTest(ibid.test.PluginTestCase):
624+ load = ['meetings']
625+ public = True
626+
627+ def test_meeting_agenda(self):
628+ # Meeting start
629+ self.assertResponseMatches(u'Ibid: start meeting',
630+ u'gets out his memo-pad and cracks his knuckles')
631+ self.assertResponseMatches(u'Ibid: what\'s the agenda?',
632+ u'user: Well, the agenda is empty')
633+
634+ # Adding topics
635+ self.assertResponseMatches(u'Ibid: agenda+ test',
636+ u'user: Ok, I added "test" to the agenda')
637+ self.assertResponseMatches(u'Ibid: topic+ test 2',
638+ u'user: Ok, I added "test 2" to the agenda')
639+ self.assertResponseMatches(u'Ibid: what is on the agenda?',
640+ u'user: Here is the current agenda:',)
641+ self.assertResponseMatches(u'Ibid: what is on the agenda?',
642+ u'user: 1. test')
643+ self.assertResponseMatches(u'Ibid: what is on the agenda?',
644+ u'user: 2. test 2')
645+
646+ # Renaming
647+ self.assertResponseMatches(u'Ibid: rename topic 2 to test modified 2',
648+ u'user: Ok, I renamed it from "test 2" to "test modified 2"')
649+ self.assertResponseMatches(u'Ibid: what is on the agenda?',
650+ u'user: 1. test')
651+ self.assertResponseMatches(u'Ibid: what is on the agenda?',
652+ u'user: 2. test modified 2')
653+ self.failIfResponseMatches(u'Ibid: what is on the agenda?',
654+ u'user: 2. test 2')
655+ self.assertResponseMatches(u'Ibid: rename topic 3 to bogus agenda topic',
656+ u'user: Did you count right\? I don\'t see any topic on the agenda with the number 3')
657+
658+ # Removing topics
659+ self.assertResponseMatches(u'Ibid: agenda- 1',
660+ u'user: Ok, I removed "test" from the agenda')
661+ self.assertResponseMatches(u'Ibid: show the agenda',
662+ u'user: 1. test modified 2')
663+ self.failIfResponseMatches(u'Ibid: what is on the agenda?',
664+ u'user: 1. test$')
665+
666+ # Clearing the agenda
667+ self.assertResponseMatches(u'Ibid: agenda+ the 3rd item',
668+ u'user: Ok, I added "the 3rd item" to the agenda')
669+ self.assertResponseMatches(u'Ibid: what is the agenda',
670+ u'user: 2. the 3rd item')
671+ self.assertResponseMatches(u'Ibid: clear the agenda',
672+ u'user: Better start over from scratch, yes. All neat and clean.')
673+ self.assertResponseMatches(u'Ibid: what is the agenda',
674+ u'user: Well, the agenda is empty.')
675+
676+ # Closing topics
677+ self.assertResponseMatches(u'Ibid: topic+ test',
678+ u'user: Ok, I added "test" to the agenda')
679+ self.assertResponseMatches(u'Ibid: topic+ test 2',
680+ u'user: Ok, I added "test 2" to the agenda')
681+ self.assertResponseMatches(u'Ibid: what are the topics?',
682+ u'user: 1. test')
683+ self.assertResponseMatches(u'Ibid: what are the topics?',
684+ u'user: 2. test 2')
685+ self.assertResponseMatches(u'Ibid: topic+ test blah',
686+ u'user: Ok, I added "test blah" to the agenda')
687+ self.assertResponseMatches(u'Ibid: topic+ test 4 [toto]',
688+ u'user: Ok, I added "test 4" to the agenda')
689+ self.assertResponseMatches(u'Ibid: topic+ test 5',
690+ u'user: Ok, I added "test 5" to the agenda')
691+ self.assertResponseMatches(u'Ibid: topic+ test 6',
692+ u'user: Ok, I added "test 6" to the agenda')
693+ self.assertResponseMatches(u'Ibid: topic+ test seven',
694+ u'user: Ok, I added "test seven" to the agenda')
695+ self.assertResponseMatches(u'Ibid: close topic 6',
696+ u'user: We don\'t need to talk about test 6 anymore')
697+ self.assertResponseMatches(u'Ibid: close topic seven',
698+ u'user: We don\'t need to talk about test seven anymore')
699+ self.assertResponseMatches(u'Ibid: list the topics',
700+ u'user: 1. test')
701+ self.assertResponseMatches(u'Ibid: list the topics',
702+ u'user: 2. test 2')
703+ self.assertResponseMatches(u'Ibid: list the topics',
704+ u'user: 3. test blah')
705+ self.assertResponseMatches(u'Ibid: list the topics',
706+ u'user: 4. test 4')
707+ self.assertResponseMatches(u'Ibid: list the topics',
708+ u'user: 5. test 5')
709+ self.assertResponseMatches(u'Ibid: list the topics',
710+ u'user: 6. test 6 \(closed\)')
711+ self.assertResponseMatches(u'Ibid: list the topics',
712+ u'user: 7. test seven \(closed\)')
713+
714+ # Changing the agenda order
715+ self.assertResponseMatches(u'Ibid: the agenda order is 4, 5 ,3, 1,2',
716+ u'user: First things first!')
717+ self.assertResponseMatches(u'Ibid: show topics',
718+ u'user: 1. test 4')
719+ self.assertResponseMatches(u'Ibid: show topics',
720+ u'user: 2. test 5')
721+ self.assertResponseMatches(u'Ibid: show topics',
722+ u'user: 3. test blah')
723+ self.assertResponseMatches(u'Ibid: show topics',
724+ u'user: 4. test')
725+ self.assertResponseMatches(u'Ibid: show topics',
726+ u'user: 5. test 2')
727+ self.assertResponseMatches(u'Ibid: show topics',
728+ u'user: 6. test 6 \(closed\)')
729+ self.assertResponseMatches(u'Ibid: show topics',
730+ u'user: 7. test seven \(closed\)')
731+
732+ # Moving through topics
733+ self.assertResponseMatches(u'Ibid: take up topic 4',
734+ u'user: The current topic is now "test" \(from user\)')
735+ self.assertResponseMatches(u'Ibid: skip this topic',
736+ u'user: Ok, we\'ll discuss test later')
737+ self.assertResponseMatches(u'Ibid: topics?',
738+ u'user: 1. test 4')
739+ self.assertResponseMatches(u'Ibid: topics?',
740+ u'user: 2. test 5')
741+ self.assertResponseMatches(u'Ibid: topics?',
742+ u'user: 3. test blah')
743+ self.assertResponseMatches(u'Ibid: topics?',
744+ u'user: 4. test')
745+ self.assertResponseMatches(u'Ibid: topics?',
746+ u'user: 5. test 2 \(current\)')
747+ self.assertResponseMatches(u'Ibid: topics?',
748+ u'user: 6. test 6 \(closed\)')
749+ self.assertResponseMatches(u'Ibid: topics?',
750+ u'user: 7. test seven \(closed\)')
751+ self.assertResponseMatches(u'Ibid: move to topic bl.h',
752+ u'user: The current topic is now "test blah" \(from user\)')
753+ self.assertResponseMatches(u'Ibid: show the topics',
754+ u'user: 1. test 4')
755+ self.assertResponseMatches(u'Ibid: show the topics',
756+ u'user: 2. test 5')
757+ self.assertResponseMatches(u'Ibid: show the topics',
758+ u'user: 3. test blah \(current\)')
759+ self.assertResponseMatches(u'Ibid: show the topics',
760+ u'user: 4. test')
761+ self.assertResponseMatches(u'Ibid: show the topics',
762+ u'user: 5. test 2 \(closed\)')
763+ self.assertResponseMatches(u'Ibid: show the topics',
764+ u'user: 6. test 6 \(closed\)')
765+ self.assertResponseMatches(u'Ibid: show the topics',
766+ u'user: 7. test seven \(closed\)')
767+
768+ # Inserting and opening a topic immediately
769+ self.assertResponseMatches(u'Ibid: topic A new topic',
770+ u'user: The current topic is now "A new topic" \(from user\)')
771+ self.assertResponseMatches(u'Ibid: topics?',
772+ u'user: 1. test 4')
773+ self.assertResponseMatches(u'Ibid: topics?',
774+ u'user: 2. test 5')
775+ self.assertResponseMatches(u'Ibid: topics?',
776+ u'user: 3. test blah \(closed\)')
777+ self.assertResponseMatches(u'Ibid: topics?',
778+ u'user: 4. A new topic \(current\)')
779+ self.assertResponseMatches(u'Ibid: topics?',
780+ u'user: 5. test')
781+ self.assertResponseMatches(u'Ibid: topics?',
782+ u'user: 6. test 2 \(closed\)')
783+ self.assertResponseMatches(u'Ibid: topics?',
784+ u'user: 7. test 6 \(closed\)')
785+ self.assertResponseMatches(u'Ibid: topics?',
786+ u'user: 8. test seven \(closed\)')
787+ self.assertResponseMatches(u'Ibid: topic?',
788+ u'user: We\'re discussing "A new topic" \(from user\)')
789+
790+ # Moving throught topics \(closed topics, looping back to skipped topics)
791+ self.assertResponseMatches(u'Ibid: open next topic',
792+ u'user: The current topic is now "test" \(from user\)')
793+ self.assertResponseMatches(u'Ibid: current topic?',
794+ u'user: We\'re discussing "test" \(from user\)')
795+ self.assertResponseMatches(u'Ibid: close this topic',
796+ u'user: Yes, let\'s stop talking about test')
797+ self.assertResponseMatches(u'Ibid: move to next topic',
798+ u'user: The current topic is now "test 4" \(from toto\)')
799+ self.assertResponseMatches(u'Ibid: what topic is open?',
800+ u'user: We\'re discussing "test 4" \(from toto\)')
801+ self.assertResponseMatches(u'Ibid: next topic',
802+ u'user: The current topic is now "test 5" \(from user\)')
803+ self.assertResponseMatches(u'Ibid: what topic is this?',
804+ u'user: We\'re discussing "test 5" \(from user\)')
805+ self.assertResponseMatches(u'Ibid: take up next topic',
806+ u'user: That\'s all folks!')
807+ self.assertResponseMatches(u'Ibid: what topic are we on?',
808+ u'user: I don\'t think we\'re discussing anything from the agenda')
809+
810+ def test_meeting_progress_report(self):
811+ # next/done
812+ self.assertResponseMatches(u'Ibid: start meeting',
813+ u'gets out his memo-pad and cracks his knuckles')
814+ self.assertResponseMatches(u'Ibid: next test, dsf;;test 2; test.test',
815+ u'user: Quite a plan.')
816+ self.assertResponseMatches(u'Ibid: done: the next thing,',
817+ u'user: Good job!')
818+ self.assertResponseMatches(u'Ibid: what is my report',
819+ u'My notes of user\'s report:')
820+ self.assertResponseMatches(u'Ibid: what is my report',
821+ u' - What user worked on:')
822+ self.assertResponseMatches(u'Ibid: what\'s the report',
823+ u' \* the next thing')
824+ self.assertResponseMatches(u'Ibid: show my report',
825+ u' - What user will be working on next:')
826+ self.assertResponseMatches(u'Ibid: show me my report',
827+ u' \* test, dsf')
828+ self.assertResponseMatches(u'Ibid: show me the report from user',
829+ u' \* test 2')
830+ self.assertResponseMatches(u'Ibid: what is my progress report',
831+ u' \* test\.test')
832+ self.failIfResponseMatches(u'Ibid: what is my report',
833+ u' - What\'s blocking user:')
834+ self.assertResponseMatches(u'Ibid: blocked by nothing',
835+ u'user: Glad to hear that')
836+
837+ # Sending a report on behalf of someone else
838+ self.assertResponseMatches(u'Ibid: done bla,blahhh;blaaa [toto]',
839+ u'user: Good job!')
840+ self.assertResponseMatches(u'Ibid: show me the report from toto',
841+ u' \* bla,blahhh')
842+ self.assertResponseMatches(u'Ibid: show me the progress report from toto',
843+ u' \* blaaa')
844+ self.failIfResponseMatches(u'Ibid: what is my report',
845+ u' \* bla,blahhh')
846+
847+ # Blockers (added to agenda)
848+ self.assertResponseMatches(u'Ibid: agenda',
849+ u'user: Well, the agenda is empty.')
850+ self.assertResponseMatches(u'Ibid: blocked by test blocker 1;test blocker 2',
851+ u'user: We\'ll need to solve that later, I\'m adding it to the agenda')
852+ self.assertResponseMatches(u'Ibid: what is my report',
853+ u' - What\'s blocking user:')
854+ self.assertResponseMatches(u'Ibid: what is my report',
855+ u' \* test blocker 1')
856+ self.assertResponseMatches(u'Ibid: what is my report',
857+ u' \* test blocker 2')
858+ self.assertResponseMatches(u'Ibid: agenda',
859+ u'user: Here is the current agenda:')
860+ self.assertResponseMatches(u'Ibid: agenda',
861+ u'user: 1. test blocker 1')
862+ self.assertResponseMatches(u'Ibid: agenda',
863+ u'user: 2. test blocker 2')
864+

Subscribers

People subscribed via source and target branches