Add support for starring groups and review requests
Updated 1 year, 4 months ago
| Christian Hammond | Reviewers | ||
| trunk | reviewboard | ||
| 200 | |||
| None | Review Board SVN | ||
Added support for starring groups and review requests. Starring a group puts it on your dashboard under a "Watched Groups" list, which is useful when you want to keep track of a group but don't want the group's review requests appearing under your Incoming Reviews list or sending you e-mails. Starring a review request puts it in your Starred Reviews list and sends any e-mails your way. This is useful if you're not a reviewer of a review request but want to keep track of what's going on with it without having to get involved.
Tested that starring and unstarring works in each area, and that they appear on the dashboard.
Diff revision 3 (Latest)
- /trunk/reviewboard/accounts/models.py: 3 changes [ 1 2 3 ]
- /trunk/reviewboard/htdocs/css/common.css: 2 changes [ 1 2 ]
- /trunk/reviewboard/htdocs/css/dashboard.css: 4 changes [ 1 2 3 4 ]
- /trunk/reviewboard/htdocs/css/reviews.css: 2 changes [ 1 2 ]
- /trunk/reviewboard/htdocs/images/star.png: binary file
- /trunk/reviewboard/htdocs/images/star_off.png: binary file
- /trunk/reviewboard/htdocs/images/star_on.png: binary file
- /trunk/reviewboard/htdocs/scripts/rb/common.js: 2 changes [ 1 2 ]
- /trunk/reviewboard/reviews/email.py: 1 change [ 1 ]
- /trunk/reviewboard/reviews/json.py: 3 changes [ 1 2 3 ]
- /trunk/reviewboard/reviews/views.py: 11 changes [ 1 2 3 4 5 6 7 8 9 10 11 ]
- /trunk/reviewboard/reviews/templatetags/reviewtags.py: 7 changes [ 1 2 3 4 5 6 7 ]
- /trunk/reviewboard/reviews/urls/json.py: 2 changes [ 1 2 ]
- /trunk/reviewboard/templates/reviews/dashboard.html: 7 changes [ 1 2 3 4 5 6 7 ]
- /trunk/reviewboard/templates/reviews/dashboard_entry.html: 1 change [ 1 ]
- /trunk/reviewboard/templates/reviews/group_list.html: 5 changes [ 1 2 3 4 5 ]
- /trunk/reviewboard/templates/reviews/review_list.html: 5 changes [ 1 2 3 4 5 ]
- /trunk/reviewboard/templates/reviews/review_request_box.html: 1 change [ 1 ]
- /trunk/reviewboard/templates/reviews/star.html: 1 change [ new content ]
| /trunk/reviewboard/accounts/models.py | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| 1 |
|
1 |
|
| 2 | 2 | ||
| 3 | from django.db import models |
3 | from django.db import models |
| 4 | from django.contrib.auth.models import User |
4 | from django.contrib.auth.models import User |
| 5 | 5 | ||
| 6 | from reviewboard.reviews.models import ReviewRequest |
6 | from reviewboard.reviews.models import Group, ReviewRequest |
| 7 | 7 | ||
| 8 | 8 | ||
| 9 | class ReviewRequestVisit(models.Model): |
9 | class ReviewRequestVisit(models.Model): |
| 10 | """A review request visit.""" |
10 | """A review request visit.""" |
| 11 | user = models.ForeignKey(User, related_name="review_request_visits") |
11 | user = models.ForeignKey(User, related_name="review_request_visits") |
| ... | 24 lines hidden [Expand] | ||
| 36 | 36 | ||
| 37 | #review_request_columns = models.CharField(maxlength=128, blank=True) |
37 | #review_request_columns = models.CharField(maxlength=128, blank=True) |
| 38 | #submitter_columns = models.CharField(maxlength=128, blank=True) |
38 | #submitter_columns = models.CharField(maxlength=128, blank=True) |
| 39 | #group_columns = models.CharField(maxlength=128, blank=True) |
39 | #group_columns = models.CharField(maxlength=128, blank=True) |
| 40 | 40 | ||
| 41 | starred_review_requests = models.ManyToManyField(ReviewRequest, core=False, |
41 | starred_review_requests = models.ManyToManyField( |
| 42 | blank=True) |
42 | ReviewRequest, core=False, blank=True, |
| 43 | filter_interface=models.HORIZONTAL, |
||
| 44 | related_name="starred_by") |
||
| 45 | starred_groups = models.ManyToManyField( |
||
| 46 | Group, core=False, blank=True, filter_interface=models.HORIZONTAL, |
||
| 47 | related_name="starred_by") |
||
| 43 | 48 | ||
| 44 | def __unicode__(self): |
49 | def __unicode__(self): |
| 45 | return self.user.username |
50 | return self.user.username |
| 46 | 51 | ||
| 47 | class Admin: |
52 | class Admin: |
| 48 | list_display = ('__unicode__', 'first_time_setup_done') |
53 | list_display = ('__unicode__', 'first_time_setup_done') |
| /trunk/reviewboard/htdocs/css/common.css | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| ... | 271 lines hidden [Expand] | ||
| 272 | padding: 0; |
272 | padding: 0; |
| 273 | position: relative; |
273 | position: relative; |
| 274 | top: -23px; |
274 | top: -23px; |
| 275 | } |
275 | } |
| 276 | 276 | ||
| 277 | .box .star { |
||
| 278 | cursor: pointer; |
||
| 279 | } |
||
| 280 | |||
| 277 | .box h1.title, |
281 | .box h1.title, |
| 278 | .box .titlebox { |
282 | .box .titlebox { |
| 279 | background: #a2bedc url('/images/title_box_top_bg.png') repeat-x top left; |
283 | background: #a2bedc url('/images/title_box_top_bg.png') repeat-x top left; |
| 280 | border-bottom: 1px #728eac solid; |
284 | border-bottom: 1px #728eac solid; |
| 281 | margin: 0; |
285 | margin: 0; |
| 282 | padding: 5px 10px; |
286 | padding: 5px 10px 5px 5px; |
| 283 | } |
287 | } |
| 284 | 288 | ||
| 285 | .box h1.title { |
289 | .box h1.title { |
| 286 | font-size: 120%; |
290 | font-size: 120%; |
| 287 | } |
291 | } |
| ... | 295 lines hidden [Expand] | ||
| /trunk/reviewboard/htdocs/css/dashboard.css | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| ... | 35 lines hidden [Expand] | ||
| 36 | 36 | ||
| 37 | #dashboard-navbar .main-item { |
37 | #dashboard-navbar .main-item { |
| 38 | font-weight: bold; |
38 | font-weight: bold; |
| 39 | } |
39 | } |
| 40 | 40 | ||
| 41 | #dashboard-navbar .header { |
||
| 42 | font-weight: bold; |
||
| 43 | padding-top: 15px; |
||
| 44 | } |
||
| 45 | |||
| 41 | #dashboard-navbar .sub-item { |
46 | #dashboard-navbar .sub-item { |
| 42 | padding-left: 25px; |
47 | padding-left: 25px; |
| 43 | } |
48 | } |
| 44 | 49 | ||
| 45 | #dashboard-navbar .sub-sub-item { |
50 | #dashboard-navbar .star-item { |
| 46 | padding-left: 45px; |
51 | background: url("/images/star_on.png") no-repeat 5px 4px; |
| 52 | padding-left: 25px; |
||
| 47 | } |
53 | } |
| 48 | 54 | ||
| 49 | #dashboard-navbar tr, |
55 | #dashboard-navbar tr, |
| 50 | #dashboard-wrapper { |
56 | #dashboard-wrapper { |
| 51 | background-color: #E9E9E9; |
57 | background-color: #E9E9E9; |
| 52 | } |
58 | } |
| 53 | 59 | ||
| 54 | #dashboard-navbar tr.selected { |
60 | #dashboard-navbar tr.selected { |
| 55 | background: #b2ceec; |
61 | background: #b2ceec; |
| 56 | } |
62 | } |
| 63 | |||
| 64 | #dashboard-navbar td img { |
||
| 65 | vertical-align: bottom; |
||
| 66 | } |
||
| 67 | |||
| 68 | #dashboard-navbar tbody { |
||
| 69 | } |
||
| /trunk/reviewboard/htdocs/css/reviews.css | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| ... | 108 lines hidden [Expand] | ||
| 109 | 109 | ||
| 110 | .review-request .header { |
110 | .review-request .header { |
| 111 | margin: 5px; |
111 | margin: 5px; |
| 112 | } |
112 | } |
| 113 | 113 | ||
| 114 | .review-request .star { |
||
| 115 | float: left; |
||
| 116 | margin-right: 10px; |
||
| 117 | } |
||
| 118 | |||
| 114 | .review-request #details { |
119 | .review-request #details { |
| 115 | border-bottom: 1px #c2c1b0 solid; |
120 | border-bottom: 1px #c2c1b0 solid; |
| 116 | border-spacing: 8px; |
121 | border-spacing: 8px; |
| 117 | border-top: 1px #c2c1b0 solid; |
122 | border-top: 1px #c2c1b0 solid; |
| 118 | margin-top: 5px; |
123 | margin-top: 5px; |
| ... | 39 lines hidden [Expand] | ||
| 158 | .review-request #summary_wrapper { |
163 | .review-request #summary_wrapper { |
| 159 | margin-right: 10em; |
164 | margin-right: 10em; |
| 160 | padding: 0px; |
165 | padding: 0px; |
| 161 | } |
166 | } |
| 162 | 167 | ||
| 168 | .review-request #summary_wrapper label { |
||
| 169 | font-size: 130%; |
||
| 170 | } |
||
| 171 | |||
| 163 | .review-request #topcontrols { |
172 | .review-request #topcontrols { |
| 164 | float: right; |
173 | float: right; |
| 165 | list-style: none; |
174 | list-style: none; |
| 166 | margin: -1px -1px 0 0; |
175 | margin: -1px -1px 0 0; |
| 167 | padding: 0px; |
176 | padding: 0px; |
| ... | 240 lines hidden [Expand] | ||
| /trunk/reviewboard/htdocs/images/star.png | |||
|---|---|---|---|
| Revision UNKNOWN | New Change | ||
| This is a binary file. The content cannot be displayed. | |||
| /trunk/reviewboard/htdocs/images/star_off.png | |||
|---|---|---|---|
| Revision UNKNOWN | New Change | ||
| This is a binary file. The content cannot be displayed. | |||
| /trunk/reviewboard/htdocs/images/star_on.png | |||
|---|---|---|---|
| Revision UNKNOWN | New Change | ||
| This is a binary file. The content cannot be displayed. | |||
| /trunk/reviewboard/htdocs/scripts/rb/common.js | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| 1 |
|
1 |
|
| 2 | 2 | ||
| 3 | |||
| 4 | // Constants |
||
| 5 | var STAR_ON_IMG = "/images/star_on.png"; |
||
| 6 | var STAR_OFF_IMG = "/images/star_off.png"; |
||
| 7 | |||
| 8 | |||
| 3 | RB.utils.String = function() {}; |
9 | RB.utils.String = function() {}; |
| 4 | RB.utils.String.prototype = { |
10 | RB.utils.String.prototype = { |
| 5 | strip: function() { |
11 | strip: function() { |
| 6 | return this.replace(/^\s+/, '').replace(/\s+$/, ''); |
12 | return this.replace(/^\s+/, '').replace(/\s+$/, ''); |
| 7 | }, |
13 | }, |
| ... | 118 lines hidden [Expand] | ||
| 126 | callbacks.failure(res.statusText); |
132 | callbacks.failure(res.statusText); |
| 127 | } |
133 | } |
| 128 | } |
134 | } |
| 129 | }, postData || "dummy"); |
135 | }, postData || "dummy"); |
| 130 | }; |
136 | }; |
| 137 | |||
| 138 | |||
| 139 | /* |
||
| 140 | * Toggles whether an object is starred. Right now, we support |
||
| 141 | * "reviewrequests" and "groups" types. |
||
| 142 | * |
||
| 143 | * @param {HTMLElement} el The star img element. |
||
| 144 | * @param {string} type The type used for constructing the path. |
||
| 145 | * @param {string} objid The object ID to star/unstar. |
||
| 146 | */ |
||
| 147 | function toggleStar(el, type, objid) { |
||
| 148 | var isStarred = (el.src.indexOf(STAR_ON_IMG) != -1); |
||
| 149 | var url = "/api/json/" + type + "/" + objid + "/"; |
||
| 150 | |||
| 151 | if (isStarred) { |
||
| 152 | url += "unstar/"; |
||
| 153 | } else { |
||
| 154 | url += "star/"; |
||
| 155 | } |
||
| 156 | |||
| 157 | asyncJsonRequest("GET", url, { |
||
| 158 | success: function(rsp) { |
||
| 159 | if (isStarred) { |
||
| 160 | el.src = STAR_OFF_IMG; |
||
| 161 | } else { |
||
| 162 | el.src = STAR_ON_IMG; |
||
| 163 | } |
||
| 164 | }, |
||
| 165 | failure: function(errmsg) { |
||
| 166 | alert(errmsg); |
||
| 167 | } |
||
| 168 | }); |
||
| 169 | } |
||
| /trunk/reviewboard/reviews/email.py | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| ... | 65 lines hidden [Expand] | ||
| 66 | 66 | ||
| 67 | for group in review_request.target_groups.all(): |
67 | for group in review_request.target_groups.all(): |
| 68 | for address in get_email_addresses_for_group(group): |
68 | for address in get_email_addresses_for_group(group): |
| 69 | recipient_table[address] = 1 |
69 | recipient_table[address] = 1 |
| 70 | 70 | ||
| 71 | for profile in review_request.starred_by.all(): |
||
| 72 | recipient_table[get_email_address_for_user(profile.user)] = 1 |
||
| 73 | |||
| 71 | if extra_recipients: |
74 | if extra_recipients: |
| 72 | for recipient in extra_recipients: |
75 | for recipient in extra_recipients: |
| 73 | recipient_table[get_email_address_for_user(recipient)] = 1 |
76 | recipient_table[get_email_address_for_user(recipient)] = 1 |
| 74 | 77 | ||
| 75 | recipient_list = recipient_table.keys() |
78 | recipient_list = recipient_table.keys() |
| ... | 123 lines hidden [Expand] | ||
| /trunk/reviewboard/reviews/json.py | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| ... | 12 lines hidden [Expand] | ||
| 13 | from django.template.defaultfilters import timesince |
13 | from django.template.defaultfilters import timesince |
| 14 | from django.utils import simplejson |
14 | from django.utils import simplejson |
| 15 | from django.views.decorators.http import require_POST |
15 | from django.views.decorators.http import require_POST |
| 16 | 16 | ||
| 17 | from djblets.util.decorators import simple_decorator |
17 | from djblets.util.decorators import simple_decorator |
| 18 | from reviewboard.accounts.models import Profile |
||
| 18 | from reviewboard.diffviewer.forms import UploadDiffForm, EmptyDiffError |
19 | from reviewboard.diffviewer.forms import UploadDiffForm, EmptyDiffError |
| 19 | from reviewboard.diffviewer.models import FileDiff, DiffSet |
20 | from reviewboard.diffviewer.models import FileDiff, DiffSet |
| 20 | from reviewboard.reviews.email import mail_review, mail_review_request, \ |
21 | from reviewboard.reviews.email import mail_review, mail_review_request, \ |
| 21 | mail_reply, mail_diff_update |
22 | mail_reply, mail_diff_update |
| 22 | from reviewboard.reviews.forms import UploadScreenshotForm |
23 | from reviewboard.reviews.forms import UploadScreenshotForm |
| ... | 301 lines hidden [Expand] | ||
| 324 | 'repositories': Repository.objects.all(), |
325 | 'repositories': Repository.objects.all(), |
| 325 | }) |
326 | }) |
| 326 | 327 | ||
| 327 | 328 | ||
| 328 | @json_login_required |
329 | @json_login_required |
| 330 | def group_star(request, group_name): |
||
| 331 | try: |
||
| 332 | group = Group.objects.get(name=group_name) |
||
| 333 | except Group.DoesNotExist: |
||
| 334 | return JsonResponseError(request, DOES_NOT_EXIST) |
||
| 335 | |||
| 336 | profile, profile_is_new = Profile.objects.get_or_create(user=request.user) |
||
| 337 | profile.starred_groups.add(group) |
||
| 338 | profile.save() |
||
| 339 | |||
| 340 | return JsonResponse(request) |
||
| 341 | |||
| 342 | |||
| 343 | @json_login_required |
||
| 344 | def group_unstar(request, group_name): |
||
| 345 | try: |
||
| 346 | group = Group.objects.get(name=group_name) |
||
| 347 | except Group.DoesNotExist: |
||
| 348 | return JsonResponseError(request, DOES_NOT_EXIST) |
||
| 349 | |||
| 350 | profile, profile_is_new = Profile.objects.get_or_create(user=request.user) |
||
| 351 | |||
| 352 | if not profile_is_new: |
||
| 353 | profile.starred_groups.remove(group) |
||
| 354 | profile.save() |
||
| 355 | |||
| 356 | return JsonResponse(request) |
||
| 357 | |||
| 358 | |||
| 359 | @json_login_required |
||
| 329 | @require_POST |
360 | @require_POST |
| 330 | def new_review_request(request): |
361 | def new_review_request(request): |
| 331 | try: |
362 | try: |
| 332 | repository_path = request.POST.get('repository_path', |
363 | repository_path = request.POST.get('repository_path', |
| 333 | settings.DEFAULT_REPOSITORY_PATH) |
364 | settings.DEFAULT_REPOSITORY_PATH) |
| ... | 44 lines hidden [Expand] | ||
| 378 | return JsonResponse(request, {'review_request': review_request}) |
409 | return JsonResponse(request, {'review_request': review_request}) |
| 379 | except ReviewRequest.DoesNotExist: |
410 | except ReviewRequest.DoesNotExist: |
| 380 | return JsonResponseError(request, INVALID_CHANGE_NUMBER) |
411 | return JsonResponseError(request, INVALID_CHANGE_NUMBER) |
| 381 | 412 | ||
| 382 | 413 | ||
| 414 | @json_login_required |
||
| 415 | def review_request_star(request, review_request_id): |
||
| 416 | try: |
||
| 417 | review_request = ReviewRequest.objects.get(pk=review_request_id) |
||
| 418 | except ReviewRequest.DoesNotExist: |
||
| 419 | return JsonResponseError(request, DOES_NOT_EXIST) |
||
| 420 | |||
| 421 | profile, profile_is_new = Profile.objects.get_or_create(user=request.user) |
||
| 422 | profile.starred_review_requests.add(review_request) |
||
| 423 | profile.save() |
||
| 424 | |||
| 425 | return JsonResponse(request) |
||
| 426 | |||
| 427 | |||
| 428 | @json_login_required |
||
| 429 | def review_request_unstar(request, review_request_id): |
||
| 430 | try: |
||
| 431 | review_request = ReviewRequest.objects.get(pk=review_request_id) |
||
| 432 | except ReviewRequest.DoesNotExist: |
||
| 433 | return JsonResponseError(request, DOES_NOT_EXIST) |
||
| 434 | |||
| 435 | profile, profile_is_new = Profile.objects.get_or_create(user=request.user) |
||
| 436 | |||
| 437 | if not profile_is_new: |
||
| 438 | profile.starred_review_requests.remove(review_request) |
||
| 439 | profile.save() |
||
| 440 | |||
| 441 | return JsonResponse(request) |
||
| 442 | |||
| 443 | |||
| 383 | @json_permission_required('reviews.delete_reviewrequest') |
444 | @json_permission_required('reviews.delete_reviewrequest') |
| 384 | def review_request_delete(request, review_request_id): |
445 | def review_request_delete(request, review_request_id): |
| 385 | try: |
446 | try: |
| 386 | review_request = ReviewRequest.objects.get(pk=review_request_id) |
447 | review_request = ReviewRequest.objects.get(pk=review_request_id) |
| 387 | review_request.delete() |
448 | review_request.delete() |
| ... | 747 lines hidden [Expand] | ||
| /trunk/reviewboard/reviews/views.py | |||
|---|---|---|---|
| Revision 916 | New Change | ||
| ... | 210 lines hidden [Expand] | ||
| 211 | @login_required |
211 | @login_required |
| 212 | @valid_prefs_required |
212 | @valid_prefs_required |
| 213 | def dashboard(request, template_name='reviews/dashboard.html'): |
213 | def dashboard(request, template_name='reviews/dashboard.html'): |
| 214 | view = request.GET.get('view', 'incoming') |
214 | view = request.GET.get('view', 'incoming') |
| 215 | group = request.GET.get('group', "") |
215 | group = request.GET.get('group', "") |
| 216 | user = request.user |
||
| 216 | 217 | ||
| 217 | if view == 'outgoing': |
218 | if view == 'outgoing': |
| 218 | review_requests = \ |
219 | review_requests = ReviewRequest.objects.from_user(user.username, user) |
| 219 | ReviewRequest.objects.from_user(request.user.username, |
||
| 220 | request.user) |
||
| 221 | title = "All Outgoing Review Requests" |
220 | title = "All Outgoing Review Requests" |
| 222 | elif view == 'to-me': |
221 | elif view == 'to-me': |
| 223 | review_requests = ReviewRequest.objects.to_user_directly( |
222 | review_requests = \ |
| 224 | request.user.username, request.user) |
223 | ReviewRequest.objects.to_user_directly(user.username, user) |
| 225 | title = "Incoming Review Requests to Me" |
224 | title = "Incoming Review Requests to Me" |
| 226 | elif view == 'to-group': |
225 | elif view == 'to-group': |
| 227 | if group != "": |
226 | if group != "": |
| 228 | review_requests = ReviewRequest.objects.to_group(group, |
227 | review_requests = ReviewRequest.objects.to_group(group, user) |
| 229 | request.user) |
||
| 230 | title = "Incoming Review Requests to %s" % group |
228 | title = "Incoming Review Requests to %s" % group |
| 231 | else: |
229 | else: |
| 232 | review_requests = ReviewRequest.objects.to_user_groups( |
230 | review_requests = \ |
| 233 | request.user.username, request.user) |
231 | ReviewRequest.objects.to_user_groups(user.username, user) |
| 234 | title = "All Incoming Review Requests to My Groups" |
232 | title = "All Incoming Review Requests to My Groups" |
| 233 | elif view == 'starred': |
||
| 234 | review_requests = \ |
||
| 235 | user.get_profile().starred_review_requests.public(user) |
||
| 236 | title = "Starred Review Requests" |
||
| 235 | else: # "incoming" or invalid |
237 | else: # "incoming" or invalid |
| 236 | review_requests = ReviewRequest.objects.to_user(request.user.username, |
238 | review_requests = ReviewRequest.objects.to_user(user.username, user) |
| 237 | request.user) |
||
| 238 | title = "All Incoming Review Requests" |
239 | title = "All Incoming Review Requests" |
| 239 | 240 | ||
| 240 | class BogusQuerySet: |
241 | class BogusQuerySet: |
| 241 | """ |
242 | """ |
| 242 | Simple class to fool the object_list generic view into thinking a |
243 | Simple class to fool the object_list generic view into thinking a |
| ... | 58 lines hidden [Expand] | ||
| 301 | return review_list(request, |
302 | return review_list(request, |
| 302 | queryset=ReviewRequest.objects.to_group(name, status=None), |
303 | queryset=ReviewRequest.objects.to_group(name, status=None), |
| 303 | template_name=template_name, |
304 | template_name=template_name, |
| 304 | extra_context={ |
305 | extra_context={ |
| 305 | 'source': name, |
306 | 'source': name, |
| 307 | 'group': get_object_or_none(Group, name=name), |
||
| 306 | }) |
308 | }) |
| 307 | 309 | ||
| 308 | 310 | ||
| 309 | @check_login_required |
311 | @check_login_required |
| 310 | def submitter(request, username, template_name='reviews/review_list.html'): |
312 | def submitter(request, username, template_name='reviews/review_list.html'): |
| ... | 260 lines hidden [Expand] | ||