| 1 | #!/usr/bin/python |
|---|
| 2 | # -*- coding: utf-8 -*- |
|---|
| 3 | """Daily Report of Basecamp activity |
|---|
| 4 | |
|---|
| 5 | Logs into a Basecamp site and posts a daily activity report from one |
|---|
| 6 | (source) project to another (target) project. |
|---|
| 7 | |
|---|
| 8 | It is meant to run as a cronjob, but may be invoked manually from the |
|---|
| 9 | command line, if needed, thus: |
|---|
| 10 | |
|---|
| 11 | $ python basecamp_daily_report.py \\ |
|---|
| 12 | --hostname=hostname \\ |
|---|
| 13 | --username=username \\ |
|---|
| 14 | --password=password \\ |
|---|
| 15 | --source="Source Project" \\ |
|---|
| 16 | --target="Target Project" \\ |
|---|
| 17 | --category="Target Category" \\ |
|---|
| 18 | --subject="Daily Report" |
|---|
| 19 | |
|---|
| 20 | Requires ElementTree [1], httplib2 [2] and Basecamp Wrapper [3]. |
|---|
| 21 | |
|---|
| 22 | [1] http://effbot.org/zone/element-index.htm |
|---|
| 23 | [2] httplib2: http://bitworking.org/projects/httplib2/ |
|---|
| 24 | [3] Basecamp Wrapper: http://homework.nwsnet.de/products/79/ |
|---|
| 25 | """ |
|---|
| 26 | |
|---|
| 27 | __author__ = "Antonio Cavedoni <antonio@cavedoni.org>" |
|---|
| 28 | __svnid__ = "$Id$" |
|---|
| 29 | |
|---|
| 30 | import sys |
|---|
| 31 | import time |
|---|
| 32 | import locale |
|---|
| 33 | import datetime |
|---|
| 34 | import elementtree.ElementTree as ET |
|---|
| 35 | import httplib2 |
|---|
| 36 | import getopt |
|---|
| 37 | from basecamp import Basecamp |
|---|
| 38 | |
|---|
| 39 | # Python 2.3 on Windows barks on locale names with underscores |
|---|
| 40 | # and Python 2.4.1 on OS X barks on them *without* underscores, oh well |
|---|
| 41 | if sys.platform == 'win32' and sys.version_info[0] >= 2 \ |
|---|
| 42 | and sys.version_info[1] <= 3: |
|---|
| 43 | loc = 'it' |
|---|
| 44 | else: |
|---|
| 45 | loc = 'it_IT' |
|---|
| 46 | # this stuff shouldn’t be here, this is a library, we’re not |
|---|
| 47 | # supposed to mess with the environment locale settings |
|---|
| 48 | locale.setlocale(locale.LC_ALL, loc) |
|---|
| 49 | |
|---|
| 50 | HOSTNAME = '' |
|---|
| 51 | USERNAME = '' |
|---|
| 52 | PASSWORD = '' |
|---|
| 53 | SOURCE_PROJECT = '' |
|---|
| 54 | TARGET_PROJECT = '' |
|---|
| 55 | TARGET_CATEGORY = '' |
|---|
| 56 | SUBJECT = '' |
|---|
| 57 | |
|---|
| 58 | def iso2datetime(isotime): |
|---|
| 59 | """Parses an ISO datetime like 2006-01-19T12:47:20Z |
|---|
| 60 | and returns a datetime with no timezone |
|---|
| 61 | (Basecamp claims to return UTC dates) |
|---|
| 62 | """ |
|---|
| 63 | return datetime.datetime(*time.strptime(isotime, '%Y-%m-%dT%H:%M:%SZ')[:6]) |
|---|
| 64 | |
|---|
| 65 | class Post: |
|---|
| 66 | def __init__(self, xml_tree): |
|---|
| 67 | self.id = int(xml_tree.find('id').text) |
|---|
| 68 | self.title = xml_tree.find('title').text |
|---|
| 69 | if xml_tree.find('body'): self.body = xml_tree.find('body').text |
|---|
| 70 | self.posted_on = iso2datetime(xml_tree.find('posted-on').text) |
|---|
| 71 | #if xml_tree.find('category'): |
|---|
| 72 | # self.category_id = int(xml_tree.find('category.id').text) |
|---|
| 73 | # self.category_name = xml_tree.find('category.name').text |
|---|
| 74 | self.attachments_count = int(xml_tree.find('attachments-count').text) |
|---|
| 75 | |
|---|
| 76 | def __repr__(self): |
|---|
| 77 | return '<Post: %d from %s>' % \ |
|---|
| 78 | (self.id, self.posted_on.strftime('%Y-%m-%d')) |
|---|
| 79 | |
|---|
| 80 | class TodoItem: |
|---|
| 81 | def __init__(self, xml_tree): |
|---|
| 82 | self.id = int(xml_tree.find('id').text) |
|---|
| 83 | self.content = xml_tree.find('content').text |
|---|
| 84 | self.created_on = iso2datetime(xml_tree.find('created-on').text) |
|---|
| 85 | if xml_tree.find('completed').text == 'true': |
|---|
| 86 | self.completed = True |
|---|
| 87 | self.completed_on = iso2datetime(xml_tree.find('completed-on').text) |
|---|
| 88 | else: |
|---|
| 89 | self.completed = False |
|---|
| 90 | self.completed_on = datetime.datetime(1950, 12, 12) |
|---|
| 91 | |
|---|
| 92 | |
|---|
| 93 | def __repr__(self): |
|---|
| 94 | return '<TodoItem: %d>' % self.id |
|---|
| 95 | |
|---|
| 96 | def get_project_id(name): |
|---|
| 97 | projects = ET.fromstring(bc.projects()) |
|---|
| 98 | for proj in projects.getiterator('project'): |
|---|
| 99 | if proj.find('name').text == name: |
|---|
| 100 | return int(proj.find('id').text) |
|---|
| 101 | |
|---|
| 102 | def get_messages(proj): |
|---|
| 103 | messages = ET.fromstring(bc.message_archive(proj)) |
|---|
| 104 | for m in messages.getiterator('post'): |
|---|
| 105 | yield Post(m) |
|---|
| 106 | |
|---|
| 107 | def get_todo_list_ids(): |
|---|
| 108 | todo_lists = ET.fromstring(bc.todo_lists(source_project_id)) |
|---|
| 109 | for todo_list in todo_lists.getiterator('todo-list'): |
|---|
| 110 | yield int(todo_list.find('id').text) |
|---|
| 111 | |
|---|
| 112 | def get_todo_items(): |
|---|
| 113 | for todo_list_id in get_todo_list_ids(): |
|---|
| 114 | todo_list = ET.fromstring(bc.todo_list(todo_list_id)) |
|---|
| 115 | for todo_item in todo_list.getiterator('todo-item'): |
|---|
| 116 | yield TodoItem(todo_item) |
|---|
| 117 | |
|---|
| 118 | def get_today_todo_items(): |
|---|
| 119 | for todo_item in get_todo_items(): |
|---|
| 120 | if ((todo_item.created_on - datetime.datetime.utcnow()) \ |
|---|
| 121 | > datetime.timedelta(days=-1)) or \ |
|---|
| 122 | ((todo_item.completed_on - datetime.datetime.utcnow()) \ |
|---|
| 123 | > datetime.timedelta(days=-1)): |
|---|
| 124 | yield todo_item |
|---|
| 125 | |
|---|
| 126 | def get_today_messages(): |
|---|
| 127 | for message in get_messages(get_project_id(SOURCE_PROJECT)): |
|---|
| 128 | # if posted in the last week |
|---|
| 129 | if (message.posted_on - datetime.datetime.utcnow()) \ |
|---|
| 130 | > datetime.timedelta(days=-1): |
|---|
| 131 | yield message |
|---|
| 132 | |
|---|
| 133 | def get_target_category_id(name): |
|---|
| 134 | categories = ET.fromstring(bc.message_categories(get_project_id(TARGET_PROJECT))) |
|---|
| 135 | for cat in categories.getiterator('post-category'): |
|---|
| 136 | if cat.find('name').text == name: |
|---|
| 137 | return int(cat.find('id').text) |
|---|
| 138 | |
|---|
| 139 | def posts_today(): |
|---|
| 140 | post = [] |
|---|
| 141 | messages = [] |
|---|
| 142 | for m in get_today_messages(): |
|---|
| 143 | messages.append(m) |
|---|
| 144 | if len(messages) != 0: |
|---|
| 145 | post.append("I messaggi delle ultime 24 ore:\n") |
|---|
| 146 | for m in messages: |
|---|
| 147 | post.append("* %s" % m.title.encode('utf-8')) |
|---|
| 148 | todo_items = [] |
|---|
| 149 | for i in get_today_todo_items(): |
|---|
| 150 | todo_items.append(i) |
|---|
| 151 | |
|---|
| 152 | if (len(todo_items) + len(messages)) == 0: |
|---|
| 153 | print "There was no activity in the past 24 hours, abort." |
|---|
| 154 | sys.exit(0) |
|---|
| 155 | |
|---|
| 156 | post.append("") |
|---|
| 157 | if len(todo_items) != 0: |
|---|
| 158 | post.append("I todo-items delle ultime 24 ore:\n") |
|---|
| 159 | for item in todo_items: |
|---|
| 160 | if item.completed: |
|---|
| 161 | # @@HACK! We’re doing decode('latin1') because we |
|---|
| 162 | # know that the locale will be 'it' or 'it_IT', |
|---|
| 163 | # but that is hardcoded and… well, just wrong |
|---|
| 164 | post.append("* -%s- _(completato %s)_" % ( |
|---|
| 165 | item.content.encode('utf-8'), |
|---|
| 166 | item.completed_on.strftime( |
|---|
| 167 | '%A %d/%m/%Y').decode('latin1').lower().encode('utf-8') |
|---|
| 168 | )) |
|---|
| 169 | else: |
|---|
| 170 | post.append("* %s _(creato %s)_" % ( |
|---|
| 171 | item.content.encode('utf-8'), |
|---|
| 172 | item.created_on.strftime( |
|---|
| 173 | '%A %d/%m/%Y').decode('latin1').lower().encode('utf-8') |
|---|
| 174 | )) |
|---|
| 175 | return '\n'.join(post) |
|---|
| 176 | |
|---|
| 177 | def create_message(proj_id, category_id, subject, message): |
|---|
| 178 | """Brute-force attempt to create a new Basecamp post, since the |
|---|
| 179 | method in the Python Basecamp library is not working properly |
|---|
| 180 | """ |
|---|
| 181 | POST_CREATE_URI = "http://%s/projects/%s/msg/create" % \ |
|---|
| 182 | (HOSTNAME, proj_id) |
|---|
| 183 | h = httplib2.Http() |
|---|
| 184 | h.add_credentials(USERNAME, PASSWORD) |
|---|
| 185 | request_body = """ |
|---|
| 186 | <request> |
|---|
| 187 | <post> |
|---|
| 188 | <category-id>%s</category-id> |
|---|
| 189 | <title>%s</title> |
|---|
| 190 | <body>%s</body> |
|---|
| 191 | <extended-body/> |
|---|
| 192 | <use-textile>1</use-textile> |
|---|
| 193 | <private>0</private> |
|---|
| 194 | </post> |
|---|
| 195 | </request> |
|---|
| 196 | """ % (category_id, subject, message) |
|---|
| 197 | response, content = h.request(POST_CREATE_URI, 'POST', body=request_body, |
|---|
| 198 | headers={'Accept': 'application/xml', |
|---|
| 199 | 'Content-type': 'application/xml'} |
|---|
| 200 | ) |
|---|
| 201 | |
|---|
| 202 | def usage(): |
|---|
| 203 | print __doc__ |
|---|
| 204 | |
|---|
| 205 | if __name__ == "__main__": |
|---|
| 206 | try: |
|---|
| 207 | opts, args = getopt.getopt( |
|---|
| 208 | sys.argv[1:], |
|---|
| 209 | "hupstcj", |
|---|
| 210 | ("hostname=", "username=", "password=", "source=", "target=", |
|---|
| 211 | "category=", "subject=") |
|---|
| 212 | ) |
|---|
| 213 | except getopt.GetoptError: |
|---|
| 214 | usage() |
|---|
| 215 | sys.exit(2) |
|---|
| 216 | if not opts: |
|---|
| 217 | usage() |
|---|
| 218 | else: |
|---|
| 219 | for o, a in opts: |
|---|
| 220 | if o in "--hostname": |
|---|
| 221 | HOSTNAME = a |
|---|
| 222 | elif o in "--username": |
|---|
| 223 | USERNAME = a |
|---|
| 224 | elif o in "--password": |
|---|
| 225 | PASSWORD = a |
|---|
| 226 | elif o in "--source": |
|---|
| 227 | SOURCE_PROJECT = a |
|---|
| 228 | elif o in "--target": |
|---|
| 229 | TARGET_PROJECT = a |
|---|
| 230 | elif o in "--category": |
|---|
| 231 | TARGET_CATEGORY = a |
|---|
| 232 | elif o in "--subject": |
|---|
| 233 | SUBJECT = a |
|---|
| 234 | # @@refactor this! all these globals make my head hurt |
|---|
| 235 | bc = Basecamp("http://%s/" % HOSTNAME, USERNAME, PASSWORD) |
|---|
| 236 | source_project_id = get_project_id(SOURCE_PROJECT) |
|---|
| 237 | create_message(get_project_id(TARGET_PROJECT), |
|---|
| 238 | get_target_category_id(TARGET_CATEGORY), |
|---|
| 239 | SUBJECT, |
|---|
| 240 | posts_today()) |
|---|
| 241 | |
|---|