Merge lp:~aaronp/rnr-server/modify-only into lp:rnr-server

Proposed by Aaron Peachey
Status: Superseded
Proposed branch: lp:~aaronp/rnr-server/modify-only
Merge into: lp:rnr-server
Diff against target: 411 lines (+199/-15)
9 files modified
django_project/config_dev/config/main.cfg (+1/-0)
django_project/config_dev/schema.py (+1/-0)
src/reviewsapp/api/handlers.py (+32/-7)
src/reviewsapp/api/urls.py (+9/-1)
src/reviewsapp/forms.py (+16/-1)
src/reviewsapp/models/reviews.py (+8/-0)
src/reviewsapp/tests/test_handlers.py (+83/-1)
src/reviewsapp/tests/test_models.py (+10/-0)
src/reviewsapp/tests/test_rnrclient.py (+39/-5)
To merge this branch: bzr merge lp:~aaronp/rnr-server/modify-only
Reviewer Review Type Date Requested Status
Aaron Peachey (community) Needs Resubmitting
Ratings and Reviews Developers Pending
Review via email: mp+62115@code.launchpad.net

This proposal has been superseded by a proposal from 2011-05-28.

Description of the change

adds functionality for user to modify their existing review within 120 minutes (config option) of submission.

Diff of associated rnrclient changes:

=== modified file 'rnrclient.py'
--- rnrclient.py 2011-05-06 06:35:36 +0000
+++ rnrclient.py 2011-05-24 12:07:16 +0000
@@ -174,3 +174,14 @@
         """Delete a review"""
         return self._post('/reviews/delete/%s/' % review_id, data={},
             scheme=AUTHENTICATED_API_SCHEME)
+
+ @validate('review_id', int)
+ @validate('rating', int)
+ @validate_pattern('summary', r'[^\n]+')
+ @validate_pattern('review_text', r'[^\n]+')
+ @returns_json
+ def modify_review(self, review_id, rating, summary, review_text):
+ """Modify an existing review"""
+ data = {'rating':rating, 'summary':summary, 'review_text':review_text}
+ return self._put('/reviews/modify/%s/' % review_id, data=data,
+ scheme=AUTHENTICATED_API_SCHEME)

To post a comment you must log in.
Revision history for this message
Michael Nelson (michael.nelson) wrote :
Download full text (9.8 KiB)

Excellent work Aaron - as far as I can see, you've tested everything very well. I've only got a few small simplifications, formatting points or wording suggestions below. See what you think.

> === modified file 'src/reviewsapp/api/handlers.py'
> --- src/reviewsapp/api/handlers.py 2011-05-05 14:32:04 +0000
> +++ src/reviewsapp/api/handlers.py 2011-05-24 12:31:07 +0000
> @@ -239,6 +240,32 @@
>
> return review
>
> +class ModifyReviewHandler(BaseHandler):
> + allowed_methods = ('PUT',)
> +
> + def update(self, request, review_id):
> + """Uses PUT verb to modify an existing review"""
> + review = get_object_or_404(Review, id=review_id, reviewer=request.user)
> +
> + # check review modify window has not closed before allowing update
> + time_diff = datetime.utcnow() - review.date_created
> + time_limit = timedelta(minutes=settings.MODIFY_WINDOW_MINUTES)
> + if time_diff > time_limit:
> + return HttpResponseForbidden("Time to modify review has expired")

Hrm - I wonder if we can get a balance of simple language with the actual reason
in parenthesis? Maybe:

"This review can not be edited (edit window expired)."

> + elif review.is_awaiting_moderation():
> + return HttpResponseForbidden("Can't modify review with pending moderation")

"This review can not be edited (pending moderation)."

> + else:
> + form = ReviewEditForm(request.POST, instance=review)
> + if not form.is_valid():
> + errors = dict((key, [unicode(v) for v in values])
> + for (key, values) in form.errors.items())
> + response = simplejson.dumps({'errors': errors})
> + return HttpResponseBadRequest(response)

I noticed that we're already doing this for the SubmitReviewHandler - it'd be
nice to DRY it up. Feel free to leave it, but if you're keen, we could add an
'errors_json' to our forms. We'd only need to define it once if we use a mixin
like:

class JSONErrorMixin(object):
    def errors_json():
        ...

and then the forms that use this would simply do:

class ReviewEditForm(forms.ModelForm, JSONErrorMixin):
    ...

Then the above handler could be simplified to:

if not form.is_valid():
    return HttpResponse(form.errors_json())

Again, feel free to leave this - and tackle it only if it interests you.

> === modified file 'src/reviewsapp/models/reviews.py'
> --- src/reviewsapp/models/reviews.py 2011-05-06 01:17:56 +0000
> +++ src/reviewsapp/models/reviews.py 2011-05-24 12:31:07 +0000
> @@ -342,6 +342,15 @@
> def reviewer_displayname(self):
> return self.reviewer.get_full_name()
>
> + def is_awaiting_moderation(self):
> + """Return true if the review has any pending moderations"""
> + moderations = ReviewModeration.objects.filter(
> + review=self,
> + status=ReviewModeration.PENDING_STATUS).count()

Just a simplification, you should be able to do:
           moderations = self.reviewmoderation_set.filter(status=...).count()

> + if moderations == 0:
> + return False
> + return True
> +
> def delete(self):
> """...

lp:~aaronp/rnr-server/modify-only updated
188. By Aaron Peachey

* changes as per merge proposal feedback
* add JSONErrorMixin class to centralise returning
     of error json to multiple handlers that use it

Revision history for this message
Aaron Peachey (aaronp) wrote :

Thanks Michael. All changes made as per feedback.

review: Needs Resubmitting
lp:~aaronp/rnr-server/modify-only updated
189. By Aaron Peachey

update rnrclient test to cater for change to
rnrclient to return ReviewDetails insteadof json

Revision history for this message
Aaron Peachey (aaronp) wrote :

New commit (r189) with slight change to test case for rnrclient because I realised when testing my changes in the client that we should return a ReviewDetails object instead of json, for consistency.
Therefore, diff for the rnrclient branch is now:

=== modified file 'rnrclient.py'
--- rnrclient.py 2011-05-06 06:35:36 +0000
+++ rnrclient.py 2011-05-28 12:06:31 +0000
@@ -174,3 +174,14 @@
         """Delete a review"""
         return self._post('/reviews/delete/%s/' % review_id, data={},
             scheme=AUTHENTICATED_API_SCHEME)
+
+ @validate('review_id', int)
+ @validate('rating', int)
+ @validate_pattern('summary', r'[^\n]+')
+ @validate_pattern('review_text', r'[^\n]+')
+ @returns(ReviewDetails)
+ def modify_review(self, review_id, rating, summary, review_text):
+ """Modify an existing review"""
+ data = {'rating':rating, 'summary':summary, 'review_text':review_text}
+ return self._put('/reviews/modify/%s/' % review_id, data=data,
+ scheme=AUTHENTICATED_API_SCHEME)

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'django_project/config_dev/config/main.cfg'
2--- django_project/config_dev/config/main.cfg 2011-04-21 14:55:23 +0000
3+++ django_project/config_dev/config/main.cfg 2011-05-28 12:13:24 +0000
4@@ -100,6 +100,7 @@
5 token_cache_expiry_hours = 4
6 reviewsapp_media_root = src/reviewsapp/media
7 enable_artificial_oops = False
8+modify_window_minutes = 120
9
10 [sso_api]
11 sso_api_service_root = https://login.staging.ubuntu.com/api/1.0
12
13=== modified file 'django_project/config_dev/schema.py'
14--- django_project/config_dev/schema.py 2011-04-20 16:47:48 +0000
15+++ django_project/config_dev/schema.py 2011-05-28 12:13:24 +0000
16@@ -57,6 +57,7 @@
17 rnr.force_https_in_environment = BoolConfigOption()
18 rnr.allow_multiple_reviews_for_testing = BoolConfigOption(default=False)
19 rnr.enable_artificial_oops = BoolConfigOption()
20+ rnr.modify_window_minutes = IntConfigOption()
21
22 # Launchpad
23 launchpad = ConfigSection()
24
25=== modified file 'src/reviewsapp/api/handlers.py'
26--- src/reviewsapp/api/handlers.py 2011-05-05 14:32:04 +0000
27+++ src/reviewsapp/api/handlers.py 2011-05-28 12:13:24 +0000
28@@ -26,6 +26,7 @@
29 'ServerStatusHandler',
30 'ShowReviewsHandler',
31 'SubmitReviewHandler',
32+ 'ModifyReviewHandler',
33 'ShowUsefulnessHandler',
34 'DeleteReviewHandler',
35 ]
36@@ -44,7 +45,7 @@
37 from django.utils import simplejson
38 from piston.handler import BaseHandler
39
40-from reviewsapp.forms import ReviewForm
41+from reviewsapp.forms import ReviewForm, ReviewEditForm
42 from reviewsapp.models import (
43 Repository,
44 Review,
45@@ -219,11 +220,7 @@
46 review = Review(reviewer=request.user)
47 form = ReviewForm(request.data, instance=review)
48 if not form.is_valid():
49- errors = dict((key, [unicode(v) for v in values])
50- for (key, values) in form.errors.items())
51- response = simplejson.dumps({'errors': errors})
52- return HttpResponseBadRequest(response)
53-
54+ return HttpResponseBadRequest(form.errors_json())
55 review = form.save()
56
57 # Automatically flag when appropriate.
58@@ -239,11 +236,39 @@
59
60 return review
61
62+class ModifyReviewHandler(BaseHandler):
63+ allowed_methods = ('PUT',)
64+
65+ def update(self, request, review_id):
66+ """Uses PUT verb to modify an existing review"""
67+ review = get_object_or_404(Review, id=review_id, reviewer=request.user)
68+
69+
70+ # check review modify window has not closed before allowing update
71+ time_diff = datetime.utcnow() - review.date_created
72+ time_limit = timedelta(minutes=settings.MODIFY_WINDOW_MINUTES)
73+ if time_diff > time_limit:
74+ return HttpResponseForbidden(
75+ "This review cannot be edited (edit window expired).")
76+ elif review.is_awaiting_moderation():
77+ return HttpResponseForbidden(
78+ "This review cannot be edited (pending moderation).")
79+ else:
80+ form = ReviewEditForm(request.POST, instance=review)
81+ if not form.is_valid():
82+ return HttpResponseBadRequest(form.errors_json())
83+ else:
84+ review = form.save()
85+ review.softwareitem_in_repository.update_stats()
86+ return review
87+
88+
89 class DeleteReviewHandler(BaseHandler):
90 allowed_methods = ('POST',)
91
92 def create(self, request, review_id):
93- review = get_object_or_404(Review, id=review_id, reviewer=request.user, date_deleted=None)
94+ review = get_object_or_404(Review, id=review_id, reviewer=request.user,
95+ date_deleted=None)
96 return review.delete()
97
98 class FlagReviewHandler(BaseHandler):
99
100=== modified file 'src/reviewsapp/api/urls.py'
101--- src/reviewsapp/api/urls.py 2011-05-05 12:35:58 +0000
102+++ src/reviewsapp/api/urls.py 2011-05-28 12:13:24 +0000
103@@ -31,7 +31,8 @@
104 SubmitUsefulnessHandler,
105 RetrieveReviewHandler,
106 ShowUsefulnessHandler,
107- DeleteReviewHandler
108+ DeleteReviewHandler,
109+ ModifyReviewHandler,
110 )
111 from reviewsapp.auth import SSOOAuthAuthentication
112 from reviewsapp.api.decorators import dont_vary
113@@ -69,6 +70,8 @@
114 # The following POST handlers have POST data and will not be cached.
115 submit_review_resource = Resource(handler=SubmitReviewHandler,
116 authentication=auth)
117+modify_review_resource = Resource(handler=ModifyReviewHandler,
118+ authentication=auth)
119 flag_review_resource = Resource(handler=FlagReviewHandler,
120 authentication=auth)
121 submit_usefulness_resource = Resource(handler=SubmitUsefulnessHandler,
122@@ -133,6 +136,11 @@
123 # submits a new review for a given pkg/app
124 # POST /1.0/reviews/
125 url(r'^1.0/reviews/$', submit_review_resource, name='rnr-api-reviews'),
126+
127+ # modifies an existing review
128+ # PUT /1.0/reviews/modify/100
129+ url(r'^1.0/reviews/modify/(?P<review_id>\d+)/$',
130+ modify_review_resource, name='rnr-api-modify-review'),
131
132 # flag inappropriate reviews
133 # POST /1.0/reviews/1/+report-review/
134
135=== modified file 'src/reviewsapp/forms.py'
136--- src/reviewsapp/forms.py 2011-05-05 12:35:58 +0000
137+++ src/reviewsapp/forms.py 2011-05-28 12:13:24 +0000
138@@ -24,6 +24,7 @@
139 __all__ = [
140 'ReviewModerationFilterForm',
141 'ReviewModerationModerateForm',
142+ 'ReviewEditForm',
143 'ReviewForm',
144 ]
145
146@@ -31,6 +32,7 @@
147 from django.conf import settings
148 from django.db.models import Count
149 from django.utils.translation import ugettext_lazy as _
150+from django.utils import simplejson
151
152 from reviewsapp.models import (
153 Architecture,
154@@ -43,6 +45,13 @@
155 from reviewsapp.widgets import MultipleSubmitButton
156
157
158+class JSONErrorMixin(object):
159+ def errors_json(self):
160+ errs = dict((key, [unicode(v) for v in values])
161+ for (key, values) in self.errors.items())
162+ return simplejson.dumps({'errors': errs})
163+
164+
165 class ReviewModerationModerateForm(forms.Form):
166 """A simple form that can display itself."""
167 moderation_text = forms.CharField(required=False)
168@@ -53,7 +62,13 @@
169 coerce=int)
170
171
172-class ReviewForm(forms.ModelForm):
173+class ReviewEditForm(forms.ModelForm, JSONErrorMixin):
174+ class Meta:
175+ model = Review
176+ fields = ('summary', 'review_text', 'rating')
177+
178+
179+class ReviewForm(forms.ModelForm, JSONErrorMixin):
180
181 # The following extra fields are used to calculate their model
182 # equivalents.
183
184=== modified file 'src/reviewsapp/models/reviews.py'
185--- src/reviewsapp/models/reviews.py 2011-05-06 01:17:56 +0000
186+++ src/reviewsapp/models/reviews.py 2011-05-28 12:13:24 +0000
187@@ -342,6 +342,14 @@
188 def reviewer_displayname(self):
189 return self.reviewer.get_full_name()
190
191+ def is_awaiting_moderation(self):
192+ """Return true if the review has any pending moderations"""
193+ moderations = self.reviewmoderation_set.filter(
194+ status=ReviewModeration.PENDING_STATUS).count()
195+ if moderations == 0:
196+ return False
197+ return True
198+
199 def delete(self):
200 """Delete a review submitted by the user"""
201 self.hide = True
202
203=== modified file 'src/reviewsapp/tests/test_handlers.py'
204--- src/reviewsapp/tests/test_handlers.py 2011-05-06 01:17:56 +0000
205+++ src/reviewsapp/tests/test_handlers.py 2011-05-28 12:13:24 +0000
206@@ -26,6 +26,7 @@
207 'ServerStatusHandlerTestCase',
208 'ShowReviewsHandlerTestCase',
209 'SubmitReviewHandlerTestCase',
210+ 'ModifyReviewHandlerTestCase',
211 'UsefulnessHandlerTestCase',
212 'ShowUsefulnessHandlerTestCase',
213 'DeleteReviewHandlerTestCase',
214@@ -53,7 +54,8 @@
215 ShowReviewsHandler,
216 SubmitUsefulnessHandler,
217 ShowUsefulnessHandler,
218- DeleteReviewHandler
219+ DeleteReviewHandler,
220+ ModifyReviewHandler,
221 )
222 from reviewsapp.models import (
223 Repository,
224@@ -840,6 +842,86 @@
225 self.assertEqual(httplib.OK, first.status_code)
226 self.assertEqual(httplib.OK, second.status_code)
227
228+class ModifyReviewHandlerTestCase(TestCaseWithFactory):
229+
230+ post_data = {'rating':4, 'summary':'summary','review_text':'text'}
231+ bad_post_data = {'summary':'summary','review_text':'text'}
232+ oversize_post_data = {'rating': 4,'summary':'a'*82,'review_text':'text'}
233+
234+ def _modify_review_id(self, review_id, post_data, user=None):
235+ if user is None:
236+ user = self.factory.makeUser()
237+ request = Mock()
238+ request.POST = post_data
239+ request.user = user
240+ handler = ModifyReviewHandler()
241+ return handler.update(request, review_id)
242+
243+ def test_modify_review(self):
244+ review = self.factory.makeReview()
245+ response = self._modify_review_id(review.id, self.post_data,
246+ review.reviewer)
247+ review_reloaded = Review.objects.get(id=review.id)
248+ self.assertEqual(review.id, response.id)
249+ self.assertEqual(self.post_data['rating'], response.rating)
250+ self.assertEqual(self.post_data['summary'], response.summary)
251+ self.assertEqual(self.post_data['review_text'], response.review_text)
252+
253+ def test_non_existent_review(self):
254+ review = self.factory.makeReview()
255+ self.assertRaises(Http404, self._modify_review_id, review.id+1,
256+ self.post_data, review.reviewer)
257+
258+ def test_wrong_user(self):
259+ review = self.factory.makeReview()
260+ wrong_user = self.factory.makeUser(username='modifier')
261+ self.assertRaises(Http404, self._modify_review_id, review.id,
262+ self.post_data, wrong_user)
263+
264+ def test_invalid_data_modify_fails(self):
265+ review = self.factory.makeReview()
266+ #missing parameter
267+ response = self._modify_review_id(review.id, self.bad_post_data, review.reviewer)
268+ self.assertEqual(httplib.BAD_REQUEST, response.status_code)
269+ #overlength field
270+ response = self._modify_review_id(review.id, self.oversize_post_data,
271+ review.reviewer)
272+ self.assertEqual(httplib.BAD_REQUEST, response.status_code)
273+
274+ def test_review_awaiting_moderation_fails(self):
275+ review_moderation = self.factory.makeReviewModeration()
276+ review = review_moderation.review
277+ response = self._modify_review_id(review.id, self.post_data,
278+ review.reviewer)
279+ self.assertEqual(httplib.FORBIDDEN, response.status_code)
280+
281+ def test_modify_time_limits(self):
282+ old_time = datetime.utcnow() - timedelta(settings.MODIFY_WINDOW_MINUTES + 60)
283+ review = self.factory.makeReview(date_created=old_time)
284+ response = self._modify_review_id(review.id, self.post_data,
285+ review.reviewer)
286+ self.assertEqual(httplib.FORBIDDEN, response.status_code)
287+
288+ def test_stats_update_on_modify(self):
289+ software_item = self.factory.makeSoftwareItem(package_name='compiz-core',
290+ ratings_total=0,
291+ ratings_average=0)
292+ review = self.factory.makeReview(software_item=software_item, rating=2)
293+ review2 = self.factory.makeReview(software_item=software_item, rating=4)
294+ software_item = SoftwareItem.objects.get(id=software_item.id)
295+
296+ # 2 reviews with ratings of 2 + 4 = 6 (average of 3)
297+ self.assertEqual(2, software_item.ratings_total)
298+ self.assertEqual(3, software_item.ratings_average)
299+
300+ self._modify_review_id(review.id, self.post_data, review.reviewer)
301+ software_item = SoftwareItem.objects.get(id=software_item.id)
302+
303+ # 2 reviews with ratings of 4 + 4 = 8 (average of 4)
304+ self.assertEqual(2, software_item.ratings_total)
305+ self.assertEqual(4, software_item.ratings_average)
306+
307+
308 class DeleteReviewHandlerTestCase(TestCaseWithFactory):
309 def _delete_review_id(self, review_id, user=None):
310 if user is None:
311
312=== modified file 'src/reviewsapp/tests/test_models.py'
313--- src/reviewsapp/tests/test_models.py 2011-03-17 14:29:54 +0000
314+++ src/reviewsapp/tests/test_models.py 2011-05-28 12:13:24 +0000
315@@ -106,6 +106,16 @@
316 self.assertEqual(
317 u'lang: en, rating: 4, summary: \ufffdA summary\n'
318 u'\ufffdA review', unicode(review))
319+
320+ def test_pending_moderation(self):
321+ review = self.factory.makeReview()
322+ result = review.is_awaiting_moderation()
323+ self.assertEqual(False, result)
324+
325+ review_moderation = self.factory.makeReviewModeration()
326+ review = review_moderation.review
327+ result = review.is_awaiting_moderation()
328+ self.assertEqual(True, result)
329
330
331 class ReviewFlagTestCase(TestCaseWithFactory):
332
333=== modified file 'src/reviewsapp/tests/test_rnrclient.py'
334--- src/reviewsapp/tests/test_rnrclient.py 2011-05-05 14:32:04 +0000
335+++ src/reviewsapp/tests/test_rnrclient.py 2011-05-28 12:13:24 +0000
336@@ -29,6 +29,7 @@
337 'RnRReviewStatsTestCase',
338 'RnRServerStatusTestCase',
339 'RnRSubmitReviewTestCase',
340+ 'RnRModifyReviewTestCase',
341 'RnRDeleteReviewTestCase',
342 'UsefulnessAPITestCase',
343 ]
344@@ -248,30 +249,63 @@
345 invalidate_paginated_reviews_for(review)
346
347 class RnRDeleteReviewTestCase(RnRTxBaseTestCase):
348-
349+
350 def test_delete_review(self):
351 user = self.factory.makeUser()
352 review = self.factory.makeReview(reviewer=user)
353 token, consumer = self.factory.makeOAuthTokenAndConsumer(user=user)
354-
355+
356 auth = OAuthAuthorizer(token.token, token.token_secret, consumer.key,
357- consumer.secret)
358+ consumer.secret)
359
360 lp_verify_packagename_method = (
361 'reviewsapp.utilities.WebServices.'
362 'lp_verify_packagename_in_repository')
363-
364+
365 mock_verify_package = Mock()
366 mock_verify_package.return_value = True
367
368 api = RatingsAndReviewsAPI(auth=auth)
369-
370+
371 with patch(lp_verify_packagename_method, mock_verify_package):
372 response = api.delete_review(review_id=review.id)
373
374 self.assertEqual(review.id, response['id'])
375 self.assertNotEqual(None, response['date_deleted'])
376
377+class RnRModifyReviewTestCase(RnRTxBaseTestCase):
378+ def test_modify_review(self):
379+ user = self.factory.makeUser()
380+ review = self.factory.makeReview(reviewer=user)
381+ token, consumer = self.factory.makeOAuthTokenAndConsumer(user=user)
382+
383+ auth = OAuthAuthorizer(token.token, token.token_secret, consumer.key,
384+ consumer.secret)
385+
386+ lp_verify_packagename_method = (
387+ 'reviewsapp.utilities.WebServices.'
388+ 'lp_verify_packagename_in_repository')
389+
390+ mock_verify_package = Mock()
391+ mock_verify_package.return_value = True
392+
393+ api = RatingsAndReviewsAPI(auth=auth)
394+
395+ rating = 4
396+ summary = 'summary'
397+ review_text = 'review text'
398+
399+ with patch(lp_verify_packagename_method, mock_verify_package):
400+ response = api.modify_review(review_id=review.id,
401+ rating=rating,
402+ summary=summary,
403+ review_text=review_text)
404+
405+ self.assertEqual(review.id, response.id)
406+ self.assertEqual(rating, response.rating)
407+ self.assertEqual(summary, response.summary)
408+ self.assertEqual(review_text, response.review_text)
409+
410
411 class RnRSubmitReviewTestCase(RnRTxBaseTestCase):
412

Subscribers

People subscribed via source and target branches