Skip to content

Commit 5c9d5fc

Browse files
committed
Merge pull request #45 from schwuk/subscriptions-ui
Add (minimal) UI for subscriptions
2 parents 98befa3 + 2583b92 commit 5c9d5fc

File tree

4 files changed

+188
-5
lines changed

4 files changed

+188
-5
lines changed

code_comments/htdocs/code-comments.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,11 @@ a.bubble span.ui-icon {
122122
padding: 10px;
123123
border-top: 1px solid #f5f5f5;
124124
}
125+
126+
/* Subscriptions */
127+
128+
button#subscribe {
129+
display: block;
130+
float: right;
131+
margin: 5px 0 5px 5px;
132+
}

code_comments/htdocs/code-comments.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,4 +315,53 @@ var underscore = _.noConflict();
315315
AddCommentDialog.render();
316316
LineCommentBubbles.render();
317317
Rows.render();
318+
319+
window.Subscription = Backbone.Model.extend({
320+
url: '/subscription' + location.pathname,
321+
});
322+
323+
window.SubscriptionView = Backbone.View.extend({
324+
el: $('button#subscribe'),
325+
326+
initialize: function(){
327+
_.bindAll(this, "render");
328+
this.model.listenTo(this.model, 'change', this.render);
329+
this.render();
330+
},
331+
332+
events: {
333+
"click": "doToggle"
334+
},
335+
336+
render: function(){
337+
if (this.model.get('notify') == true) {
338+
var options = {
339+
disabled: false,
340+
label: 'Unsubscribe',
341+
icons: {primary: 'ui-icon-check'}
342+
};
343+
var title = 'You receive notifications for comments';
344+
} else {
345+
var options = {
346+
disabled: false,
347+
label: 'Subscribe',
348+
icons: {primary: 'ui-icon-closethick'}
349+
};
350+
var title = 'You do not receive notifications for comments';
351+
}
352+
var button = $(this.$el).button(options);
353+
button.prop('title', title);
354+
},
355+
356+
doToggle: function( event ){
357+
this.model.save({'notify': !this.model.get('notify')}, {wait: true});
358+
if (this.model.isNew()) {
359+
this.model.fetch();
360+
}
361+
}
362+
});
363+
364+
window.subscription = new Subscription();
365+
window.subscriptionView = new SubscriptionView({model: subscription});
366+
subscription.fetch();
318367
}); }( jQuery.noConflict( true ) ) );

code_comments/subscription.py

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import json
2+
import re
3+
14
from trac.admin import IAdminCommandProvider
25
from trac.attachment import Attachment, IAttachmentChangeListener
36
from trac.core import Component, implements
47
from trac.versioncontrol import (
58
RepositoryManager, NoSuchChangeset, IRepositoryChangeListener)
9+
from trac.web.api import HTTPNotFound, IRequestHandler, ITemplateStreamFilter
10+
11+
from genshi.builder import tag
12+
from genshi.filters import Transformer
613

714
from code_comments.api import ICodeCommentChangeListener
815
from code_comments.comments import Comments
@@ -45,8 +52,10 @@ def select(cls, env, args={}, notify=None):
4552
Retrieve existing subscription(s).
4653
"""
4754
select = 'SELECT * FROM code_comments_subscriptions'
55+
4856
if notify:
4957
args['notify'] = bool(notify)
58+
5059
if len(args) > 0:
5160
select += ' WHERE '
5261
criteria = []
@@ -61,6 +70,7 @@ def select(cls, env, args={}, notify=None):
6170
value = int(value)
6271
criteria.append(template.format(key, value))
6372
select += ' AND '.join(criteria)
73+
6474
cursor = env.get_read_db().cursor()
6575
cursor.execute(select)
6676
for row in cursor:
@@ -141,9 +151,9 @@ def _from_row(cls, env, row):
141151
return None
142152

143153
@classmethod
144-
def _from_dict(cls, env, dict_):
154+
def _from_dict(cls, env, dict_, create=True):
145155
"""
146-
Creates a subscription from a dict.
156+
Retrieves or (optionally) creates a subscription from a dict.
147157
"""
148158
subscription = None
149159

@@ -161,16 +171,16 @@ def _from_dict(cls, env, dict_):
161171
for _subscription in subscriptions:
162172
if subscription is None:
163173
subscription = _subscription
164-
env.log.info('Subscription already exists: [%d] %s',
174+
env.log.info('Subscription found: [%d] %s',
165175
subscription.id, subscription)
166176
else:
167177
# The unique constraint on the table should prevent this ever
168178
# occurring
169179
env.log.warning('Multiple subscriptions found: [%d] %s',
170180
subscription.id, subscription)
171181

172-
# Create a new subscription if we didn't find one
173-
if subscription is None:
182+
# (Optionally) create a new subscription if we didn't find one
183+
if subscription is None and create:
174184
subscription = cls(env, dict_)
175185
subscription.insert()
176186
env.log.info('Subscription created: [%d] %s',
@@ -298,6 +308,53 @@ def for_comment(cls, env, comment, notify=None):
298308

299309
return cls.select(env, args, notify)
300310

311+
@classmethod
312+
def for_request(cls, env, req, create=False):
313+
"""
314+
Return a **single** subscription for a HTTP request.
315+
"""
316+
reponame = req.args.get('reponame')
317+
rm = RepositoryManager(env)
318+
repos = rm.get_repository(reponame)
319+
320+
path = req.args.get('path') or ''
321+
rev = req.args.get('rev') or repos.youngest_rev
322+
323+
dict_ = {
324+
'user': req.authname,
325+
'type': req.args.get('realm'),
326+
'path': '',
327+
'rev': '',
328+
'repos': '',
329+
}
330+
331+
if dict_['type'] == 'attachment':
332+
dict_['path'] = path
333+
334+
if dict_['type'] == 'changeset':
335+
dict_['rev'] = path[1:]
336+
dict_['repos'] = repos.reponame
337+
338+
if dict_['type'] == 'browser':
339+
if len(path) == 0:
340+
dict_['path'] = '/'
341+
else:
342+
dict_['path'] = path[1:]
343+
dict_['rev'] = rev
344+
dict_['repos'] = repos.reponame
345+
346+
return cls._from_dict(env, dict_, create=create)
347+
348+
349+
class SubscriptionJSONEncoder(json.JSONEncoder):
350+
"""
351+
JSON Encoder for a Subscription object.
352+
"""
353+
def default(self, o):
354+
data = o.__dict__.copy()
355+
del data['env']
356+
return data
357+
301358

302359
class SubscriptionAdmin(Component):
303360
"""
@@ -388,3 +445,70 @@ def changeset_modified(self, repos, changeset, old_changeset):
388445

389446
def comment_created(self, comment):
390447
Subscription.from_comment(self.env, comment)
448+
449+
450+
class SubscriptionModule(Component):
451+
implements(IRequestHandler, ITemplateStreamFilter)
452+
453+
# IRequestHandler methods
454+
455+
def match_request(self, req):
456+
match = re.match(r'\/subscription\/(\w+)(\/?.*)$', req.path_info)
457+
if match:
458+
if match.group(1):
459+
req.args['realm'] = match.group(1)
460+
if match.group(2):
461+
req.args['path'] = match.group(2)
462+
return True
463+
464+
def process_request(self, req):
465+
if req.method == 'POST':
466+
return self._do_POST(req)
467+
elif req.method == 'PUT':
468+
return self._do_PUT(req)
469+
return self._do_GET(req)
470+
471+
# ITemplateStreamFilter methods
472+
473+
def filter_stream(self, req, method, filename, stream, data):
474+
if re.match(r'^/(changeset|browser|attachment).*', req.path_info):
475+
filter = Transformer('//h1')
476+
stream |= filter.before(self._subscription_button())
477+
return stream
478+
479+
# Internal methods
480+
481+
def _do_GET(self, req):
482+
subscription = Subscription.for_request(self.env, req)
483+
if subscription is None:
484+
req.send('', 'application/json', 204)
485+
req.send(json.dumps(subscription, cls=SubscriptionJSONEncoder),
486+
'application/json')
487+
488+
def _do_POST(self, req):
489+
subscription = Subscription.for_request(self.env, req, create=True)
490+
status = 201
491+
req.send(json.dumps(subscription, cls=SubscriptionJSONEncoder),
492+
'application/json', status)
493+
494+
def _do_PUT(self, req):
495+
subscription = Subscription.for_request(self.env, req)
496+
if subscription is None:
497+
raise HTTPNotFound('Subscription to /%s%s for %s not found',
498+
req.args.get('realm'), req.args.get('path'),
499+
req.authname)
500+
content = req.read()
501+
if len(content) > 0:
502+
data = json.loads(content)
503+
subscription.notify = data['notify']
504+
subscription.update()
505+
req.send(json.dumps(subscription, cls=SubscriptionJSONEncoder),
506+
'application/json')
507+
508+
def _subscription_button(self):
509+
"""
510+
Generates a (disabled) button to connect JavaScript to.
511+
"""
512+
return tag.button('Subscribe', id_='subscribe', disabled=True,
513+
title=('Code comment subscriptions require '
514+
'JavaScript to be enabled'))

code_comments/templates/code_comment_notify_email.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ ${comment.text}
22

33
View the comment: ${comment_url}
44

5+
To unsubscribe from future notifications, click the link above and use the "Unsubscribe" button.
6+
57
--
68
${project.name} <${project_url}>
79
${project.descr}

0 commit comments

Comments
 (0)