| 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | # |
|---|
| 3 | # Copyright (c) 2010, Robert Corsaro |
|---|
| 4 | # Copyright (c) 2010, Steffen Hoffmann |
|---|
| 5 | # |
|---|
| 6 | # This software is licensed as described in the file COPYING, which |
|---|
| 7 | # you should have received as part of this distribution. |
|---|
| 8 | # |
|---|
| 9 | |
|---|
| 10 | import re |
|---|
| 11 | |
|---|
| 12 | from genshi.template import NewTextTemplate, TemplateLoader |
|---|
| 13 | from tracfullblog.api import IBlogChangeListener |
|---|
| 14 | from tracfullblog.model import BlogPost, BlogComment |
|---|
| 15 | from trac.config import BoolOption, Option |
|---|
| 16 | from trac.core import Component, implements |
|---|
| 17 | from trac.util.html import html |
|---|
| 18 | from trac.web.api import IRequestFilter, IRequestHandler |
|---|
| 19 | from trac.web.chrome import Chrome, add_notice, add_ctxtnav |
|---|
| 20 | |
|---|
| 21 | from announcer.api import _, AnnouncementSystem, AnnouncementEvent,\ |
|---|
| 22 | IAnnouncementFormatter, IAnnouncementSubscriber, \ |
|---|
| 23 | IAnnouncementPreferenceProvider |
|---|
| 24 | from announcer.distributors.mail import IAnnouncementEmailDecorator |
|---|
| 25 | from announcer.model import Subscription, SubscriptionAttribute |
|---|
| 26 | from announcer.util.mail import set_header, next_decorator |
|---|
| 27 | |
|---|
| 28 | |
|---|
| 29 | class BlogChangeEvent(AnnouncementEvent): |
|---|
| 30 | |
|---|
| 31 | def __init__(self, blog_post, category, url, blog_comment=None): |
|---|
| 32 | AnnouncementEvent.__init__(self, 'blog', category, blog_post) |
|---|
| 33 | if blog_comment: |
|---|
| 34 | if 'comment deleted' == category: |
|---|
| 35 | self.comment = blog_comment['comment'] |
|---|
| 36 | self.author = blog_comment['author'] |
|---|
| 37 | self.timestamp = blog_comment['time'] |
|---|
| 38 | else: |
|---|
| 39 | self.comment = blog_comment.comment |
|---|
| 40 | self.author = blog_comment.author |
|---|
| 41 | self.timestamp = blog_comment.time |
|---|
| 42 | else: |
|---|
| 43 | self.comment = blog_post.version_comment |
|---|
| 44 | self.author = blog_post.version_author |
|---|
| 45 | self.timestamp = blog_post.version_time |
|---|
| 46 | self.remote_addr = url |
|---|
| 47 | self.version = blog_post.version |
|---|
| 48 | self.blog_post = blog_post |
|---|
| 49 | self.blog_comment = blog_comment |
|---|
| 50 | |
|---|
| 51 | |
|---|
| 52 | class FullBlogAllSubscriber(Component): |
|---|
| 53 | """Subscriber for any blog changes.""" |
|---|
| 54 | |
|---|
| 55 | implements(IAnnouncementSubscriber) |
|---|
| 56 | |
|---|
| 57 | def matches(self, event): |
|---|
| 58 | if event.realm != 'blog': |
|---|
| 59 | return |
|---|
| 60 | if event.category not in ('post created', 'post changed', |
|---|
| 61 | 'post deleted', 'comment created', |
|---|
| 62 | 'comment changed', 'comment deleted'): |
|---|
| 63 | return |
|---|
| 64 | |
|---|
| 65 | klass = self.__class__.__name__ |
|---|
| 66 | for i in Subscription.find_by_class(self.env, klass): |
|---|
| 67 | yield i.subscription_tuple() |
|---|
| 68 | |
|---|
| 69 | def description(self): |
|---|
| 70 | return _("notify me when any blog is modified, " |
|---|
| 71 | "changed, deleted or commented on.") |
|---|
| 72 | |
|---|
| 73 | |
|---|
| 74 | class FullBlogNewSubscriber(Component): |
|---|
| 75 | """Subscriber for any blog post creation.""" |
|---|
| 76 | |
|---|
| 77 | implements(IAnnouncementSubscriber) |
|---|
| 78 | |
|---|
| 79 | def matches(self, event): |
|---|
| 80 | if event.realm != 'blog': |
|---|
| 81 | return |
|---|
| 82 | if event.category != 'post created': |
|---|
| 83 | return |
|---|
| 84 | |
|---|
| 85 | klass = self.__class__.__name__ |
|---|
| 86 | for i in Subscription.find_by_class(self.env, klass): |
|---|
| 87 | yield i.subscription_tuple() |
|---|
| 88 | |
|---|
| 89 | def description(self): |
|---|
| 90 | return "notify me when any blog post is created." |
|---|
| 91 | |
|---|
| 92 | |
|---|
| 93 | class FullBlogMyPostSubscriber(Component): |
|---|
| 94 | """Subscriber for any blog changes to my posts.""" |
|---|
| 95 | |
|---|
| 96 | implements(IAnnouncementSubscriber) |
|---|
| 97 | |
|---|
| 98 | always_notify_author = BoolOption('fullblog-announcement', |
|---|
| 99 | 'always_notify_author', True, |
|---|
| 100 | """Notify the blog author of any changes to her blogs, |
|---|
| 101 | including changes to comments. |
|---|
| 102 | """) |
|---|
| 103 | |
|---|
| 104 | def matches(self, event): |
|---|
| 105 | if event.realm != 'blog': |
|---|
| 106 | return |
|---|
| 107 | if event.category not in ('post changed', 'post deleted', |
|---|
| 108 | 'comment created', 'comment changed', |
|---|
| 109 | 'comment deleted'): |
|---|
| 110 | return |
|---|
| 111 | |
|---|
| 112 | sids = ((event.blog_post.author, 1),) |
|---|
| 113 | klass = self.__class__.__name__ |
|---|
| 114 | for i in Subscription.find_by_sids_and_class(self.env, sids, klass): |
|---|
| 115 | yield i.subscription_tuple() |
|---|
| 116 | |
|---|
| 117 | def description(self): |
|---|
| 118 | return _("notify me when any blog that I posted " |
|---|
| 119 | "is modified or commented on.") |
|---|
| 120 | |
|---|
| 121 | |
|---|
| 122 | class FullBlogWatchSubscriber(Component): |
|---|
| 123 | """Subscriber to watch individual blogs.""" |
|---|
| 124 | |
|---|
| 125 | implements(IAnnouncementSubscriber, IRequestFilter, IRequestHandler) |
|---|
| 126 | |
|---|
| 127 | # IAnnouncementSubscriber methods |
|---|
| 128 | |
|---|
| 129 | def matches(self, event): |
|---|
| 130 | if event.realm != 'blog': |
|---|
| 131 | return |
|---|
| 132 | if event.category not in ('post created', 'post changed', |
|---|
| 133 | 'post deleted', 'comment created', |
|---|
| 134 | 'comment changed', 'comment deleted'): |
|---|
| 135 | return |
|---|
| 136 | |
|---|
| 137 | klass = self.__class__.__name__ |
|---|
| 138 | |
|---|
| 139 | attrs = SubscriptionAttribute.\ |
|---|
| 140 | find_by_class_realm_and_target(self.env, klass, 'blog', |
|---|
| 141 | event.blog_post.name) |
|---|
| 142 | sids = set(map(lambda x: (x['sid'], x['authenticated']), attrs)) |
|---|
| 143 | |
|---|
| 144 | for i in Subscription.find_by_sids_and_class(self.env, sids, klass): |
|---|
| 145 | yield i.subscription_tuple() |
|---|
| 146 | |
|---|
| 147 | def description(self): |
|---|
| 148 | return _("notify me when a blog that I'm watching changes.") |
|---|
| 149 | |
|---|
| 150 | # IRequestFilter methods |
|---|
| 151 | |
|---|
| 152 | def pre_process_request(self, req, handler): |
|---|
| 153 | return handler |
|---|
| 154 | |
|---|
| 155 | def post_process_request(self, req, template, data, content_type): |
|---|
| 156 | if 'BLOG_VIEW' not in req.perm: |
|---|
| 157 | return template, data, content_type |
|---|
| 158 | |
|---|
| 159 | if '_blog_watch_message_' in req.session: |
|---|
| 160 | add_notice(req, req.session['_blog_watch_message_']) |
|---|
| 161 | del req.session['_blog_watch_message_'] |
|---|
| 162 | |
|---|
| 163 | if req.authname == 'anonymous': |
|---|
| 164 | return template, data, content_type |
|---|
| 165 | |
|---|
| 166 | # FullBlogPlugin sets the blog_path arg in pre_process_request |
|---|
| 167 | name = req.args.get('blog_path') |
|---|
| 168 | if not name: |
|---|
| 169 | return template, data, content_type |
|---|
| 170 | |
|---|
| 171 | klass = self.__class__.__name__ |
|---|
| 172 | |
|---|
| 173 | attrs = SubscriptionAttribute.find_by_sid_class_and_target( |
|---|
| 174 | self.env, req.session.sid, req.session.authenticated, klass, name) |
|---|
| 175 | if attrs: |
|---|
| 176 | add_ctxtnav(req, html.a(_("Unwatch This"), |
|---|
| 177 | href=req.href.blog_watch(name))) |
|---|
| 178 | else: |
|---|
| 179 | add_ctxtnav(req, html.a(_("Watch This"), |
|---|
| 180 | href=req.href.blog_watch(name))) |
|---|
| 181 | |
|---|
| 182 | return template, data, content_type |
|---|
| 183 | |
|---|
| 184 | # IRequestHandler methods |
|---|
| 185 | |
|---|
| 186 | def match_request(self, req): |
|---|
| 187 | return re.match(r'^/blog_watch/(.*)', req.path_info) |
|---|
| 188 | |
|---|
| 189 | def process_request(self, req): |
|---|
| 190 | klass = self.__class__.__name__ |
|---|
| 191 | |
|---|
| 192 | m = re.match(r'^/blog_watch/(.*)', req.path_info) |
|---|
| 193 | (name,) = m.groups() |
|---|
| 194 | |
|---|
| 195 | with self.env.db_transaction: |
|---|
| 196 | attrs = SubscriptionAttribute.find_by_sid_class_and_target( |
|---|
| 197 | self.env, req.session.sid, req.session.authenticated, |
|---|
| 198 | klass, name) |
|---|
| 199 | if attrs: |
|---|
| 200 | SubscriptionAttribute.delete_by_sid_class_and_target( |
|---|
| 201 | self.env, req.session.sid, req.session.authenticated, |
|---|
| 202 | klass, name) |
|---|
| 203 | req.session['_blog_watch_message_'] = \ |
|---|
| 204 | _("You are no longer watching this blog post.") |
|---|
| 205 | else: |
|---|
| 206 | SubscriptionAttribute.add( |
|---|
| 207 | self.env, req.session.sid, req.session.authenticated, |
|---|
| 208 | klass, 'blog', (name,)) |
|---|
| 209 | req.session['_blog_watch_message_'] = \ |
|---|
| 210 | _("You are now watching this blog post.") |
|---|
| 211 | |
|---|
| 212 | req.redirect(req.href.blog(name)) |
|---|
| 213 | |
|---|
| 214 | |
|---|
| 215 | class FullBlogBloggerSubscriber(Component): |
|---|
| 216 | """Subscriber for any blog changes to bloggers that I follow.""" |
|---|
| 217 | |
|---|
| 218 | implements(IAnnouncementPreferenceProvider, IAnnouncementSubscriber) |
|---|
| 219 | |
|---|
| 220 | def matches(self, event): |
|---|
| 221 | if event.realm != 'blog': |
|---|
| 222 | return |
|---|
| 223 | if event.category not in ('post created', 'post changed', |
|---|
| 224 | 'post deleted', 'comment created', |
|---|
| 225 | 'comment changed', 'comment deleted'): |
|---|
| 226 | return |
|---|
| 227 | |
|---|
| 228 | klass = self.__class__.__name__ |
|---|
| 229 | |
|---|
| 230 | sids = set(map(lambda x: (x['sid'], x['authenticated']), |
|---|
| 231 | SubscriptionAttribute.find_by_class_realm_and_target( |
|---|
| 232 | self.env, klass, 'blog', event.blog_post.author))) |
|---|
| 233 | |
|---|
| 234 | for i in Subscription.find_by_sids_and_class(self.env, sids, klass): |
|---|
| 235 | yield i.subscription_tuple() |
|---|
| 236 | |
|---|
| 237 | def description(self): |
|---|
| 238 | return _("notify me when any blogger that I follow has a blog " |
|---|
| 239 | "update.") |
|---|
| 240 | |
|---|
| 241 | # IAnnouncementPreferenceProvider methods |
|---|
| 242 | |
|---|
| 243 | def get_announcement_preference_boxes(self, req): |
|---|
| 244 | if req.authname == 'anonymous' and 'email' not in req.session: |
|---|
| 245 | return |
|---|
| 246 | yield 'bloggers', _("Followed Bloggers") |
|---|
| 247 | |
|---|
| 248 | def render_announcement_preference_box(self, req, panel): |
|---|
| 249 | klass = self.__class__.__name__ |
|---|
| 250 | |
|---|
| 251 | if req.method == "POST": |
|---|
| 252 | with self.env.db_transaction: |
|---|
| 253 | SubscriptionAttribute.delete_by_sid_and_class( |
|---|
| 254 | self.env, req.session.sid, req.session.authenticated, |
|---|
| 255 | klass) |
|---|
| 256 | blogs = set(map(lambda x: x.strip(), |
|---|
| 257 | req.args.get( |
|---|
| 258 | 'announcer_watch_bloggers').split(','))) |
|---|
| 259 | SubscriptionAttribute.add(self.env, req.session.sid, |
|---|
| 260 | req.session.authenticated, klass, |
|---|
| 261 | 'blog', blogs) |
|---|
| 262 | |
|---|
| 263 | attrs = SubscriptionAttribute.\ |
|---|
| 264 | find_by_sid_and_class(self.env, req.session.sid, |
|---|
| 265 | req.session.authenticated, klass) |
|---|
| 266 | data = {'sids': ','.join(set(map(lambda x: x['target'], attrs)))} |
|---|
| 267 | return 'prefs_announcer_watch_bloggers.html', dict(data=data) |
|---|
| 268 | |
|---|
| 269 | |
|---|
| 270 | class FullBlogAnnouncement(Component): |
|---|
| 271 | """Send announcements on blog events.""" |
|---|
| 272 | |
|---|
| 273 | implements(IAnnouncementEmailDecorator, IAnnouncementFormatter, |
|---|
| 274 | IBlogChangeListener) |
|---|
| 275 | |
|---|
| 276 | blog_email_subject = Option('fullblog-announcement', 'blog_email_subject', |
|---|
| 277 | _("Blog: ${blog.name} ${action}"), |
|---|
| 278 | """Format string for the blog email subject. |
|---|
| 279 | |
|---|
| 280 | This is a mini genshi template and it is passed the blog_post and |
|---|
| 281 | action objects. |
|---|
| 282 | """) |
|---|
| 283 | |
|---|
| 284 | # IBlogChangeListener methods |
|---|
| 285 | def blog_post_changed(self, postname, version): |
|---|
| 286 | """Called when a new blog post 'postname' with 'version' is added. |
|---|
| 287 | |
|---|
| 288 | version==1 denotes a new post, version>1 is a new version on existing |
|---|
| 289 | post. |
|---|
| 290 | """ |
|---|
| 291 | blog_post = BlogPost(self.env, postname, version) |
|---|
| 292 | action = 'post created' |
|---|
| 293 | if version > 1: |
|---|
| 294 | action = 'post changed' |
|---|
| 295 | announcer = AnnouncementSystem(self.env) |
|---|
| 296 | announcer.send( |
|---|
| 297 | BlogChangeEvent( |
|---|
| 298 | blog_post, |
|---|
| 299 | action, |
|---|
| 300 | self.env.abs_href.blog(blog_post.name) |
|---|
| 301 | ) |
|---|
| 302 | ) |
|---|
| 303 | |
|---|
| 304 | def blog_post_deleted(self, postname, version, fields): |
|---|
| 305 | """Called when a blog post is deleted: |
|---|
| 306 | |
|---|
| 307 | version==0 means all versions (or last remaining) version is deleted. |
|---|
| 308 | Any version>0 denotes a specific version only. |
|---|
| 309 | Fields is a dict with the pre-existing values of the blog post. |
|---|
| 310 | If all (or last) the dict will contain the 'current' version |
|---|
| 311 | contents. |
|---|
| 312 | """ |
|---|
| 313 | blog_post = BlogPost(self.env, postname, version) |
|---|
| 314 | announcer = AnnouncementSystem(self.env) |
|---|
| 315 | announcer.send( |
|---|
| 316 | BlogChangeEvent( |
|---|
| 317 | blog_post, |
|---|
| 318 | 'post deleted', |
|---|
| 319 | self.env.abs_href.blog(blog_post.name) |
|---|
| 320 | ) |
|---|
| 321 | ) |
|---|
| 322 | |
|---|
| 323 | def blog_comment_added(self, postname, number): |
|---|
| 324 | """Called when Blog comment number N on post 'postname' is added.""" |
|---|
| 325 | blog_post = BlogPost(self.env, postname, 0) |
|---|
| 326 | blog_comment = BlogComment(self.env, postname, number) |
|---|
| 327 | announcer = AnnouncementSystem(self.env) |
|---|
| 328 | announcer.send( |
|---|
| 329 | BlogChangeEvent( |
|---|
| 330 | blog_post, |
|---|
| 331 | 'comment created', |
|---|
| 332 | self.env.abs_href.blog(blog_post.name), |
|---|
| 333 | blog_comment |
|---|
| 334 | ) |
|---|
| 335 | ) |
|---|
| 336 | |
|---|
| 337 | def blog_comment_deleted(self, postname, number, fields): |
|---|
| 338 | """Called when blog post comment 'number' is deleted. |
|---|
| 339 | |
|---|
| 340 | number==0 denotes all comments is deleted and fields will be empty. |
|---|
| 341 | (usually follows a delete of the blog post). |
|---|
| 342 | |
|---|
| 343 | number>0 denotes a specific comment is deleted, and fields will |
|---|
| 344 | contain the values of the fields as they existed pre-delete. |
|---|
| 345 | """ |
|---|
| 346 | blog_post = BlogPost(self.env, postname, 0) |
|---|
| 347 | announcer = AnnouncementSystem(self.env) |
|---|
| 348 | announcer.send( |
|---|
| 349 | BlogChangeEvent( |
|---|
| 350 | blog_post, |
|---|
| 351 | 'comment deleted', |
|---|
| 352 | self.env.abs_href.blog(blog_post.name), |
|---|
| 353 | fields |
|---|
| 354 | ) |
|---|
| 355 | ) |
|---|
| 356 | |
|---|
| 357 | # IAnnouncementEmailDecorator methods |
|---|
| 358 | |
|---|
| 359 | def decorate_message(self, event, message, decorates=None): |
|---|
| 360 | if event.realm == "blog": |
|---|
| 361 | template = NewTextTemplate(self.blog_email_subject.encode('utf8')) |
|---|
| 362 | subject = template.generate( |
|---|
| 363 | blog=event.blog_post, |
|---|
| 364 | action=event.category |
|---|
| 365 | ).render('text', encoding=None) |
|---|
| 366 | set_header(message, 'Subject', subject) |
|---|
| 367 | return next_decorator(event, message, decorates) |
|---|
| 368 | |
|---|
| 369 | # IAnnouncementFormatter methods |
|---|
| 370 | |
|---|
| 371 | def styles(self, transport, realm): |
|---|
| 372 | if realm == 'blog': |
|---|
| 373 | yield 'text/plain' |
|---|
| 374 | |
|---|
| 375 | def alternative_style_for(self, transport, realm, style): |
|---|
| 376 | if realm == 'blog' and style != 'text/plain': |
|---|
| 377 | return 'text/plain' |
|---|
| 378 | |
|---|
| 379 | def format(self, transport, realm, style, event): |
|---|
| 380 | if realm == 'blog' and style == 'text/plain': |
|---|
| 381 | return self._format_plaintext(event) |
|---|
| 382 | |
|---|
| 383 | def _format_plaintext(self, event): |
|---|
| 384 | blog_post = event.blog_post |
|---|
| 385 | data = dict( |
|---|
| 386 | name=blog_post.name, |
|---|
| 387 | author=event.author, |
|---|
| 388 | time=event.timestamp, |
|---|
| 389 | category=event.category, |
|---|
| 390 | version=event.version, |
|---|
| 391 | link=event.remote_addr, |
|---|
| 392 | title=blog_post.title, |
|---|
| 393 | body=blog_post.body, |
|---|
| 394 | comment=event.comment, |
|---|
| 395 | ) |
|---|
| 396 | chrome = Chrome(self.env) |
|---|
| 397 | dirs = [] |
|---|
| 398 | for provider in chrome.template_providers: |
|---|
| 399 | dirs += provider.get_templates_dirs() |
|---|
| 400 | templates = TemplateLoader(dirs, variable_lookup='lenient') |
|---|
| 401 | template = templates.load( |
|---|
| 402 | 'fullblog_plaintext.txt', |
|---|
| 403 | cls=NewTextTemplate |
|---|
| 404 | ) |
|---|
| 405 | if template: |
|---|
| 406 | stream = template.generate(**data) |
|---|
| 407 | return stream.render('text') |
|---|