| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | |
|---|
| 3 | import datetime |
|---|
| 4 | from json.encoder import JSONEncoder |
|---|
| 5 | import pkg_resources |
|---|
| 6 | import re |
|---|
| 7 | try: |
|---|
| 8 | from StringIO import StringIO # Python 2 |
|---|
| 9 | except ImportError: |
|---|
| 10 | from io import StringIO # Python 3 |
|---|
| 11 | |
|---|
| 12 | |
|---|
| 13 | from trac import __version__ |
|---|
| 14 | from trac.db.api import DatabaseManager |
|---|
| 15 | from trac.config import OrderedExtensionsOption, ExtensionOption |
|---|
| 16 | from trac.core import * |
|---|
| 17 | from trac.env import IEnvironmentSetupParticipant |
|---|
| 18 | from trac.perm import IPermissionRequestor |
|---|
| 19 | from trac.web import IRequestHandler, RequestDone |
|---|
| 20 | from trac.web.chrome import ITemplateProvider, web_context |
|---|
| 21 | from trac.util.datefmt import format_date, format_datetime, parse_date, utc |
|---|
| 22 | from trac.util.text import CRLF |
|---|
| 23 | |
|---|
| 24 | from weekplan.model import SCHEMA, WeekPlanEvent |
|---|
| 25 | |
|---|
| 26 | |
|---|
| 27 | PLUGIN_NAME = 'WeekPlanPlugin' |
|---|
| 28 | PLUGIN_VERSION = 2 |
|---|
| 29 | |
|---|
| 30 | |
|---|
| 31 | class IWeekPlanEventProvider(Interface): |
|---|
| 32 | |
|---|
| 33 | def get_plan_prefixes(): |
|---|
| 34 | """Get all plan id prefixes handled by this provider. |
|---|
| 35 | |
|---|
| 36 | :return: a list of plan id prefix strings. |
|---|
| 37 | """ |
|---|
| 38 | |
|---|
| 39 | def get_events(req, plans_ids, range): |
|---|
| 40 | """Get all events for the given plans that the provider handles. |
|---|
| 41 | |
|---|
| 42 | :param req: the request object. |
|---|
| 43 | :param plan_ids: a list of plan id's for which events should be returned. |
|---|
| 44 | :param range: a tuple (datetime, datetime) or None to get all events. |
|---|
| 45 | |
|---|
| 46 | :return: a list of WeekPlanEvent items. |
|---|
| 47 | """ |
|---|
| 48 | |
|---|
| 49 | def add_event(req, event): |
|---|
| 50 | """Add a new event. Optional method. |
|---|
| 51 | |
|---|
| 52 | :param req: the request object. |
|---|
| 53 | :param event: a new WeekPlanEvent to be added. |
|---|
| 54 | """ |
|---|
| 55 | |
|---|
| 56 | def update_event(req, event): |
|---|
| 57 | """Update an event. Optional method. |
|---|
| 58 | |
|---|
| 59 | :param req: the request object. |
|---|
| 60 | :param event: a WeekPlanEvent to be updated. |
|---|
| 61 | """ |
|---|
| 62 | |
|---|
| 63 | def delete_event(req, event): |
|---|
| 64 | """Delete an event. Optional method. |
|---|
| 65 | |
|---|
| 66 | :param req: the request object. |
|---|
| 67 | :param event: a WeekPlanEvent to be deleted. |
|---|
| 68 | """ |
|---|
| 69 | |
|---|
| 70 | |
|---|
| 71 | class DBWeekPlanEventProvider(Component): |
|---|
| 72 | """Provides events from the dedicated DB table.""" |
|---|
| 73 | |
|---|
| 74 | implements(IWeekPlanEventProvider) |
|---|
| 75 | |
|---|
| 76 | def get_plan_prefixes(self): |
|---|
| 77 | return ['db:'] |
|---|
| 78 | |
|---|
| 79 | def get_events(self, req, plan_ids, range): |
|---|
| 80 | if range: |
|---|
| 81 | return WeekPlanEvent.select_by_plans_and_time(self.env, plan_ids, range[0], range[1]) |
|---|
| 82 | elif len(plan_ids) == 1: |
|---|
| 83 | return WeekPlanEvent.select_by_plan(self.env, plan_ids[0]) |
|---|
| 84 | elif db_plan_ids: |
|---|
| 85 | return WeekPlanEvent.select_by_plans(self.env, plan_ids) |
|---|
| 86 | else: |
|---|
| 87 | return [] |
|---|
| 88 | |
|---|
| 89 | def add_event(self, req, event): |
|---|
| 90 | WeekPlanEvent.add(self.env, event) |
|---|
| 91 | |
|---|
| 92 | def update_event(self, req, event): |
|---|
| 93 | WeekPlanEvent.update(self.env, event) |
|---|
| 94 | |
|---|
| 95 | def delete_event(self, req, event): |
|---|
| 96 | WeekPlanEvent.delete_by_id(self.env, event.id) |
|---|
| 97 | |
|---|
| 98 | |
|---|
| 99 | class WeekPlanModule(Component): |
|---|
| 100 | """Week-by-week plans.""" |
|---|
| 101 | |
|---|
| 102 | implements(IPermissionRequestor, IRequestHandler, IEnvironmentSetupParticipant, ITemplateProvider) |
|---|
| 103 | |
|---|
| 104 | event_providers = OrderedExtensionsOption('weekplan', 'event_providers', |
|---|
| 105 | IWeekPlanEventProvider, |
|---|
| 106 | 'DBWeekPlanEventProvider', |
|---|
| 107 | True, |
|---|
| 108 | """List of components implementing `IWeekPlanEventProvider`.""") |
|---|
| 109 | |
|---|
| 110 | default_event_provider = ExtensionOption('weekplan', 'default_event_provider', |
|---|
| 111 | IWeekPlanEventProvider, |
|---|
| 112 | 'DBWeekPlanEventProvider', |
|---|
| 113 | """Default component handling plans if no prefix matches.""") |
|---|
| 114 | |
|---|
| 115 | |
|---|
| 116 | def get_event_provider(self, plan_id): |
|---|
| 117 | for provider in self.event_providers: |
|---|
| 118 | for prefix in provider.get_plan_prefixes(): |
|---|
| 119 | if plan_id.startswith(prefix): |
|---|
| 120 | return provider |
|---|
| 121 | return self.default_event_provider |
|---|
| 122 | |
|---|
| 123 | def get_events(self, req, plan_ids, range=None): |
|---|
| 124 | events = [] |
|---|
| 125 | remaining_ids = set(plan_ids) |
|---|
| 126 | for provider in self.event_providers: |
|---|
| 127 | prefixes = provider.get_plan_prefixes() |
|---|
| 128 | ids = [id for id in remaining_ids |
|---|
| 129 | for prefix in prefixes |
|---|
| 130 | if id.startswith(prefix)] |
|---|
| 131 | remaining_ids -= set(ids) |
|---|
| 132 | if ids: |
|---|
| 133 | events.extend(provider.get_events(req, ids, range)) |
|---|
| 134 | if remaining_ids: |
|---|
| 135 | provider = self.default_event_provider |
|---|
| 136 | ids = list(remaining_ids) |
|---|
| 137 | events.extend(provider.get_events(req, ids, range)) |
|---|
| 138 | return events |
|---|
| 139 | |
|---|
| 140 | def can_plan_add_event(self, plan_id): |
|---|
| 141 | return hasattr(self.get_event_provider(plan_id), 'add_event') |
|---|
| 142 | |
|---|
| 143 | def can_plan_update_event(self, plan_id): |
|---|
| 144 | return hasattr(self.get_event_provider(plan_id), 'update_event') |
|---|
| 145 | |
|---|
| 146 | def can_plan_delete_event(self, plan_id): |
|---|
| 147 | return hasattr(self.get_event_provider(plan_id), 'delete_event') |
|---|
| 148 | |
|---|
| 149 | # IPermissionRequestor methods |
|---|
| 150 | |
|---|
| 151 | def get_permission_actions(self): |
|---|
| 152 | return ['WEEK_PLAN'] |
|---|
| 153 | |
|---|
| 154 | # IRequestHandler methods |
|---|
| 155 | |
|---|
| 156 | MATCH_REQUEST_RE = re.compile(r'^/weekplan(:/(.+))?$') |
|---|
| 157 | |
|---|
| 158 | def match_request(self, req): |
|---|
| 159 | match = self.MATCH_REQUEST_RE.match(req.path_info) |
|---|
| 160 | if match: |
|---|
| 161 | if match.group(1): |
|---|
| 162 | req.args['plan'] = match.group(1) |
|---|
| 163 | return True |
|---|
| 164 | |
|---|
| 165 | def process_request(self, req): |
|---|
| 166 | req.perm.require('WEEK_PLAN') |
|---|
| 167 | |
|---|
| 168 | action = req.args.get('action') |
|---|
| 169 | |
|---|
| 170 | if 'POST' == req.method: |
|---|
| 171 | if action == 'add_event': |
|---|
| 172 | event = self._parse_event(req) |
|---|
| 173 | provider = self.get_event_provider(event.plan) |
|---|
| 174 | provider.add_event(req, event) |
|---|
| 175 | self._send_event(req, event) |
|---|
| 176 | |
|---|
| 177 | elif action == 'update_event': |
|---|
| 178 | event = self._parse_event(req) |
|---|
| 179 | provider = self.get_event_provider(event.plan) |
|---|
| 180 | provider.update_event(req, event) |
|---|
| 181 | self._send_event(req, event) |
|---|
| 182 | |
|---|
| 183 | elif action == 'delete_event': |
|---|
| 184 | event = self._parse_event(req) |
|---|
| 185 | provider = self.get_event_provider(event.plan) |
|---|
| 186 | provider.delete_event(req, event) |
|---|
| 187 | req.send_no_content() |
|---|
| 188 | |
|---|
| 189 | elif 'GET' == req.method: |
|---|
| 190 | format = req.args.get('format') |
|---|
| 191 | if format == 'ics': |
|---|
| 192 | plan_id = req.args.get('plan') |
|---|
| 193 | events = self.get_events(req, [plan_id]) |
|---|
| 194 | self._render_ics(req, plan_id, events) |
|---|
| 195 | elif format == 'json': |
|---|
| 196 | plan_ids = req.args.get('plan', '').split('|') |
|---|
| 197 | start = parse_date(req.args.get('start'), utc) |
|---|
| 198 | end = parse_date(req.args.get('end'), utc) |
|---|
| 199 | events = self.get_events(req, plan_ids, (start, end)) |
|---|
| 200 | self._send_events(req, events) |
|---|
| 201 | raise TracError() |
|---|
| 202 | |
|---|
| 203 | def _parse_event(self, req): |
|---|
| 204 | return WeekPlanEvent( |
|---|
| 205 | req.args.get('id'), |
|---|
| 206 | req.args.get('plan'), |
|---|
| 207 | req.args.get('title'), |
|---|
| 208 | parse_date(req.args.get('start'), utc), |
|---|
| 209 | parse_date(req.args.get('end'), utc)) |
|---|
| 210 | |
|---|
| 211 | def _send_event(self, req, event): |
|---|
| 212 | context = web_context(req, 'weekplan') |
|---|
| 213 | self._send_json(req, event.serialized(self.env, context)) |
|---|
| 214 | |
|---|
| 215 | def _send_events(self, req, events): |
|---|
| 216 | context = web_context(req, 'weekplan') |
|---|
| 217 | self._send_json(req, [e.serialized(self.env, context) for e in events]) |
|---|
| 218 | |
|---|
| 219 | def _send_json(self, req, data): |
|---|
| 220 | content = JSONEncoder().encode(data).encode('utf-8') |
|---|
| 221 | req.send(content, 'application/json') |
|---|
| 222 | |
|---|
| 223 | # Derived from http://trac.edgewall.org/browser/tags/trac-1.1.1/trac/ticket/roadmap.py?marks=466-580#L466 |
|---|
| 224 | def _render_ics(self, req, plan_id, events): |
|---|
| 225 | req.send_response(200) |
|---|
| 226 | req.send_header('Content-Type', 'text/calendar;charset=utf-8') |
|---|
| 227 | buf = StringIO() |
|---|
| 228 | |
|---|
| 229 | def escape_value(text): |
|---|
| 230 | s = ''.join(map(lambda c: '\\' + c if c in ';,\\' else c, text)) |
|---|
| 231 | return '\\n'.join(re.split(r'[\r\n]+', s)) |
|---|
| 232 | |
|---|
| 233 | def write_prop(name, value, params={}): |
|---|
| 234 | text = ';'.join([name] + [k + '=' + v for k, v in params.items()]) \ |
|---|
| 235 | + ':' + escape_value(value) |
|---|
| 236 | firstline = 1 |
|---|
| 237 | while text: |
|---|
| 238 | if not firstline: |
|---|
| 239 | text = ' ' + text |
|---|
| 240 | else: |
|---|
| 241 | firstline = 0 |
|---|
| 242 | buf.write(text[:75] + CRLF) |
|---|
| 243 | text = text[75:] |
|---|
| 244 | |
|---|
| 245 | def write_date(name, value, params={}): |
|---|
| 246 | params['VALUE'] = 'DATE' |
|---|
| 247 | write_prop(name, format_date(value, '%Y%m%d', req.tz), params) |
|---|
| 248 | |
|---|
| 249 | def write_utctime(name, value, params={}): |
|---|
| 250 | write_prop(name, format_datetime(value, '%Y%m%dT%H%M%SZ', utc), |
|---|
| 251 | params) |
|---|
| 252 | |
|---|
| 253 | host = req.base_url[req.base_url.find('://') + 3:] |
|---|
| 254 | user = req.args.get('user', 'anonymous') |
|---|
| 255 | |
|---|
| 256 | write_prop('BEGIN', 'VCALENDAR') |
|---|
| 257 | write_prop('VERSION', '2.0') |
|---|
| 258 | write_prop('PRODID', '-//Edgewall Software//NONSGML Trac %s//EN' |
|---|
| 259 | % __version__) |
|---|
| 260 | write_prop('METHOD', 'PUBLISH') |
|---|
| 261 | write_prop('X-WR-CALNAME', |
|---|
| 262 | self.env.project_name + ' - WeekPlan ' + plan_id) |
|---|
| 263 | write_prop('X-WR-CALDESC', self.env.project_description) |
|---|
| 264 | write_prop('X-WR-TIMEZONE', str(req.tz)) |
|---|
| 265 | |
|---|
| 266 | for event in events: |
|---|
| 267 | uid = '<%s/%s/%s@%s>' % (req.base_path, plan_id, event.id, host) |
|---|
| 268 | |
|---|
| 269 | write_prop('BEGIN', 'VEVENT') |
|---|
| 270 | write_prop('UID', uid) |
|---|
| 271 | write_utctime('DTSTAMP', datetime.datetime.now()) |
|---|
| 272 | write_date('DTSTART', event.start) |
|---|
| 273 | write_date('DTEND', event.end) |
|---|
| 274 | write_prop('SUMMARY', event.title) |
|---|
| 275 | # write_prop('URL', req.abs_href.weekplan(plan_id, event.id)) |
|---|
| 276 | # write_prop('DESCRIPTION', event.description) |
|---|
| 277 | write_prop('END', 'VEVENT') |
|---|
| 278 | |
|---|
| 279 | write_prop('END', 'VCALENDAR') |
|---|
| 280 | |
|---|
| 281 | ics_str = buf.getvalue().encode('utf-8') |
|---|
| 282 | req.send_header('Content-Length', len(ics_str)) |
|---|
| 283 | req.end_headers() |
|---|
| 284 | req.write(ics_str) |
|---|
| 285 | raise RequestDone |
|---|
| 286 | |
|---|
| 287 | # IEnvironmentSetupParticipant |
|---|
| 288 | |
|---|
| 289 | def environment_created(self): |
|---|
| 290 | dbm = DatabaseManager(self.env) |
|---|
| 291 | dbm.create_tables(SCHEMA) |
|---|
| 292 | dbm.set_database_version(PLUGIN_VERSION, PLUGIN_NAME) |
|---|
| 293 | |
|---|
| 294 | def environment_needs_upgrade(self): |
|---|
| 295 | dbm = DatabaseManager(self.env) |
|---|
| 296 | return dbm.needs_upgrade(PLUGIN_VERSION, PLUGIN_NAME) |
|---|
| 297 | |
|---|
| 298 | def upgrade_environment(self): |
|---|
| 299 | dbm = DatabaseManager(self.env) |
|---|
| 300 | if dbm.get_database_version(PLUGIN_NAME) == 0: |
|---|
| 301 | dbm.create_tables(SCHEMA) |
|---|
| 302 | dbm.set_database_version(PLUGIN_VERSION, PLUGIN_NAME) |
|---|
| 303 | else: |
|---|
| 304 | dbm.upgrade(PLUGIN_VERSION, PLUGIN_NAME, 'weekplan.upgrades') |
|---|
| 305 | |
|---|
| 306 | # ITemplateProvider methods |
|---|
| 307 | |
|---|
| 308 | def get_htdocs_dirs(self): |
|---|
| 309 | return [('weekplan', pkg_resources.resource_filename('weekplan', 'htdocs'))] |
|---|
| 310 | |
|---|
| 311 | def get_templates_dirs(self): |
|---|
| 312 | return [] |
|---|