Merge lp:~wgrant/launchpad/faq-delete into lp:launchpad
- faq-delete
- Merge into devel
Proposed by
William Grant
Status: | Merged | ||||
---|---|---|---|---|---|
Merged at revision: | 17396 | ||||
Proposed branch: | lp:~wgrant/launchpad/faq-delete | ||||
Merge into: | lp:launchpad | ||||
Diff against target: |
453 lines (+220/-34) 10 files modified
lib/lp/answers/configure.zcml (+6/-2) lib/lp/answers/doc/faqtarget.txt (+3/-1) lib/lp/answers/interfaces/faq.py (+58/-28) lib/lp/answers/interfaces/faqtarget.py (+4/-0) lib/lp/answers/interfaces/webservice.py (+4/-0) lib/lp/answers/model/faq.py (+8/-1) lib/lp/answers/tests/test_faq.py (+52/-1) lib/lp/answers/tests/test_faq_webservice.py (+68/-0) lib/lp/security.py (+10/-1) lib/lp/services/webservice/wadl-to-refhtml.xsl (+7/-0) |
||||
To merge this branch: | bzr merge lp:~wgrant/launchpad/faq-delete | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+253008@code.launchpad.net |
Commit message
Allow deletion of unlinked FAQs via the webservice, and let ~registry edit and delete any of them.
Description of the change
Allow deletion of unlinked FAQs via the webservice. There's no UI for this, and questions must be unlinked first, but at least it's possible to get rid of them.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/lp/answers/configure.zcml' |
2 | --- lib/lp/answers/configure.zcml 2014-11-24 06:20:03 +0000 |
3 | +++ lib/lp/answers/configure.zcml 2015-03-17 07:19:55 +0000 |
4 | @@ -1,4 +1,4 @@ |
5 | -<!-- Copyright 2009 Canonical Ltd. This software is licensed under the |
6 | +<!-- Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
7 | GNU Affero General Public License version 3 (see the file LICENSE). |
8 | --> |
9 | |
10 | @@ -212,12 +212,16 @@ |
11 | |
12 | <class class=".model.faq.FAQ"> |
13 | <!-- IFAQ--> |
14 | - <allow interface=".interfaces.faq.IFAQ" /> |
15 | + <allow interface=".interfaces.faq.IFAQPublic" /> |
16 | <require |
17 | permission="launchpad.Edit" |
18 | set_attributes="title keywords content |
19 | last_updated_by date_last_updated" |
20 | /> |
21 | + <require |
22 | + permission="launchpad.Delete" |
23 | + attributes="destroySelf" |
24 | + /> |
25 | </class> |
26 | |
27 | <adapter |
28 | |
29 | === modified file 'lib/lp/answers/doc/faqtarget.txt' |
30 | --- lib/lp/answers/doc/faqtarget.txt 2011-12-24 17:49:30 +0000 |
31 | +++ lib/lp/answers/doc/faqtarget.txt 2015-03-17 07:19:55 +0000 |
32 | @@ -71,7 +71,9 @@ |
33 | The returned object provides the IFAQ interface: |
34 | |
35 | >>> from lp.answers.interfaces.faq import IFAQ |
36 | - >>> verifyObject(IFAQ, faq) |
37 | + >>> from lp.testing import admin_logged_in |
38 | + >>> with admin_logged_in(): |
39 | + ... verifyObject(IFAQ, faq) |
40 | True |
41 | |
42 | The newFAQ() requires an owner, title, and content parameter. It also |
43 | |
44 | === modified file 'lib/lp/answers/interfaces/faq.py' |
45 | --- lib/lp/answers/interfaces/faq.py 2013-01-07 02:40:55 +0000 |
46 | +++ lib/lp/answers/interfaces/faq.py 2015-03-17 07:19:55 +0000 |
47 | @@ -1,4 +1,4 @@ |
48 | -# Copyright 2009 Canonical Ltd. This software is licensed under the |
49 | +# Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
50 | # GNU Affero General Public License version 3 (see the file LICENSE). |
51 | |
52 | """Interface for FAQ document.""" |
53 | @@ -10,11 +10,20 @@ |
54 | 'IFAQSet', |
55 | ] |
56 | |
57 | +import httplib |
58 | + |
59 | +from lazr.restful.declarations import ( |
60 | + error_status, |
61 | + export_as_webservice_entry, |
62 | + export_destructor_operation, |
63 | + exported, |
64 | + operation_for_version, |
65 | + ) |
66 | +from lazr.restful.fields import Reference |
67 | from zope.interface import Attribute |
68 | from zope.schema import ( |
69 | Datetime, |
70 | Int, |
71 | - Object, |
72 | Text, |
73 | TextLine, |
74 | ) |
75 | @@ -29,54 +38,75 @@ |
76 | ) |
77 | |
78 | |
79 | -class IFAQ(IHasOwner): |
80 | - """A document containing the answer to a commonly asked question. |
81 | - |
82 | - The answer can be in the document itself or can be hosted on a separate |
83 | - web site and referred to by URL. |
84 | - """ |
85 | - |
86 | - id = Int( |
87 | +@error_status(httplib.BAD_REQUEST) |
88 | +class CannotDeleteFAQ(Exception): |
89 | + """The FAQ cannnot be deleted.""" |
90 | + |
91 | + |
92 | +class IFAQPublic(IHasOwner): |
93 | + |
94 | + id = exported(Int( |
95 | title=_('FAQ Number'), |
96 | description=_('The unique number identifying the FAQ in Launchpad.'), |
97 | - required=True, readonly=True) |
98 | + required=True, readonly=True), as_of='devel') |
99 | |
100 | - title = Title( |
101 | + title = exported(Title( |
102 | title=_('Title'), |
103 | description=_('The title describing this FAQ, often a question.'), |
104 | - required=True) |
105 | + required=True), as_of='devel') |
106 | |
107 | - keywords = TextLine( |
108 | + keywords = exported(TextLine( |
109 | title=_('Keywords'), |
110 | description=_('One or more terms that relate to this FAQ.'), |
111 | - required=False) |
112 | + required=False), as_of='devel') |
113 | |
114 | - content = Text( |
115 | + content = exported(Text( |
116 | title=_('Content'), |
117 | description=_( |
118 | 'The answer for this FAQ in plain text. You may choose to ' |
119 | 'include a URL to an external FAQ.'), |
120 | - required=True) |
121 | - |
122 | - date_created = Datetime(title=_('Created'), required=True, readonly=True) |
123 | - |
124 | - last_updated_by = PublicPersonChoice( |
125 | + required=True), as_of='devel') |
126 | + |
127 | + date_created = exported(Datetime( |
128 | + title=_('Created'), required=True, readonly=True), as_of='devel') |
129 | + |
130 | + last_updated_by = exported(PublicPersonChoice( |
131 | title=_('Last Updated By'), |
132 | description=_('The last person who modified the document.'), |
133 | - vocabulary='ValidPersonOrTeam', required=False) |
134 | - |
135 | - date_last_updated = Datetime(title=_('Last Updated'), required=False) |
136 | - |
137 | - target = Object( |
138 | + vocabulary='ValidPersonOrTeam', required=False, readonly=True), |
139 | + as_of='devel') |
140 | + |
141 | + date_last_updated = exported(Datetime( |
142 | + title=_('Last Updated'), required=False, readonly=True), |
143 | + as_of='devel') |
144 | + |
145 | + target = exported(Reference( |
146 | title=_('Target'), |
147 | description=_('Product or distribution containing this FAQ.'), |
148 | - schema=IFAQTarget, |
149 | - required=True) |
150 | + schema=IFAQTarget, required=True, readonly=True), as_of='devel') |
151 | |
152 | related_questions = Attribute( |
153 | _('The set of questions linked to this FAQ.')) |
154 | |
155 | |
156 | +class IFAQ(IFAQPublic): |
157 | + """A document containing the answer to a commonly asked question. |
158 | + |
159 | + The answer can be in the document itself or can be hosted on a separate |
160 | + web site and referred to by URL. |
161 | + """ |
162 | + |
163 | + export_as_webservice_entry('faq', as_of='beta') |
164 | + |
165 | + @export_destructor_operation() |
166 | + @operation_for_version('devel') |
167 | + def destroySelf(): |
168 | + """Delete this FAQ. |
169 | + |
170 | + Any questions linked to this FAQ must be unlinked beforehand. |
171 | + """ |
172 | + |
173 | + |
174 | class IFAQSet(IFAQCollection): |
175 | """`IFAQCollection` of all the FAQs existing in Launchpad. |
176 | |
177 | |
178 | === modified file 'lib/lp/answers/interfaces/faqtarget.py' |
179 | --- lib/lp/answers/interfaces/faqtarget.py 2013-01-07 02:40:55 +0000 |
180 | +++ lib/lp/answers/interfaces/faqtarget.py 2015-03-17 07:19:55 +0000 |
181 | @@ -10,12 +10,16 @@ |
182 | ] |
183 | |
184 | |
185 | +from lazr.restful.declarations import export_as_webservice_entry |
186 | + |
187 | from lp.answers.interfaces.faqcollection import IFAQCollection |
188 | |
189 | |
190 | class IFAQTarget(IFAQCollection): |
191 | """An object that can contain a FAQ document.""" |
192 | |
193 | + export_as_webservice_entry('faq_target', as_of='beta') |
194 | + |
195 | def newFAQ(owner, title, content, keywords=None, date_created=None): |
196 | """Create a new FAQ hosted in this target. |
197 | |
198 | |
199 | === modified file 'lib/lp/answers/interfaces/webservice.py' |
200 | --- lib/lp/answers/interfaces/webservice.py 2015-03-13 04:07:31 +0000 |
201 | +++ lib/lp/answers/interfaces/webservice.py 2015-03-17 07:19:55 +0000 |
202 | @@ -10,6 +10,8 @@ |
203 | """ |
204 | |
205 | __all__ = [ |
206 | + 'IFAQ', |
207 | + 'IFAQTarget', |
208 | 'IQuestion', |
209 | 'IQuestionSet', |
210 | 'IQuestionSubscription', |
211 | @@ -17,6 +19,8 @@ |
212 | |
213 | from lazr.restful.declarations import LAZR_WEBSERVICE_EXPORTED |
214 | |
215 | +from lp.answers.interfaces.faq import IFAQ |
216 | +from lp.answers.interfaces.faqtarget import IFAQTarget |
217 | from lp.answers.interfaces.question import IQuestion |
218 | from lp.answers.interfaces.questioncollection import ( |
219 | IQuestionCollection, |
220 | |
221 | === modified file 'lib/lp/answers/model/faq.py' |
222 | --- lib/lp/answers/model/faq.py 2015-01-29 10:39:32 +0000 |
223 | +++ lib/lp/answers/model/faq.py 2015-03-17 07:19:55 +0000 |
224 | @@ -1,4 +1,4 @@ |
225 | -# Copyright 2009 Canonical Ltd. This software is licensed under the |
226 | +# Copyright 2009-2015 Canonical Ltd. This software is licensed under the |
227 | # GNU Affero General Public License version 3 (see the file LICENSE). |
228 | |
229 | """FAQ document models.""" |
230 | @@ -23,6 +23,7 @@ |
231 | from zope.interface import implements |
232 | |
233 | from lp.answers.interfaces.faq import ( |
234 | + CannotDeleteFAQ, |
235 | IFAQ, |
236 | IFAQSet, |
237 | ) |
238 | @@ -93,6 +94,12 @@ |
239 | else: |
240 | return self.distribution |
241 | |
242 | + def destroySelf(self): |
243 | + if self.related_questions: |
244 | + raise CannotDeleteFAQ( |
245 | + "Cannot delete FAQ: questions must be unlinked first.") |
246 | + super(FAQ, self).destroySelf() |
247 | + |
248 | @staticmethod |
249 | def new(owner, title, content, keywords=keywords, date_created=None, |
250 | product=None, distribution=None): |
251 | |
252 | === modified file 'lib/lp/answers/tests/test_faq.py' |
253 | --- lib/lp/answers/tests/test_faq.py 2012-01-01 02:58:52 +0000 |
254 | +++ lib/lp/answers/tests/test_faq.py 2015-03-17 07:19:55 +0000 |
255 | @@ -1,15 +1,21 @@ |
256 | -# Copyright 2010 Canonical Ltd. This software is licensed under the |
257 | +# Copyright 2010-2015 Canonical Ltd. This software is licensed under the |
258 | # GNU Affero General Public License version 3 (see the file LICENSE). |
259 | |
260 | """Tests for IFAQ""" |
261 | |
262 | __metaclass__ = type |
263 | |
264 | +import transaction |
265 | from zope.component import getUtility |
266 | |
267 | +from lp.answers.interfaces.faq import CannotDeleteFAQ |
268 | +from lp.answers.model.faq import FAQ |
269 | +from lp.registry.interfaces.person import IPersonSet |
270 | +from lp.services.database.interfaces import IStore |
271 | from lp.services.webapp.authorization import check_permission |
272 | from lp.services.worlddata.interfaces.language import ILanguageSet |
273 | from lp.testing import ( |
274 | + admin_logged_in, |
275 | login_person, |
276 | person_logged_in, |
277 | TestCaseWithFactory, |
278 | @@ -75,3 +81,48 @@ |
279 | nonparticipant = self.factory.makePerson() |
280 | login_person(nonparticipant) |
281 | self.assertCannotEdit(nonparticipant, self.faq) |
282 | + |
283 | + def test_registry_can_edit(self): |
284 | + # A member of ~registry can edit any FAQ. |
285 | + expert = self.factory.makePerson( |
286 | + member_of=[getUtility(IPersonSet).getByName('registry')]) |
287 | + login_person(expert) |
288 | + self.assertCanEdit(expert, self.faq) |
289 | + |
290 | + def test_direct_answer_contact_cannot_delete(self): |
291 | + # Answer contacts are broad, and deletion is irreversible, so |
292 | + # they cannot do it themselves. |
293 | + direct_answer_contact = self.factory.makePerson() |
294 | + with person_logged_in(direct_answer_contact): |
295 | + self.addAnswerContact(direct_answer_contact) |
296 | + self.assertFalse(check_permission('launchpad.Delete', self.faq)) |
297 | + |
298 | + def test_registry_can_delete(self): |
299 | + # A member of ~registry can delete any FAQ. |
300 | + expert = self.factory.makePerson( |
301 | + member_of=[getUtility(IPersonSet).getByName('registry')]) |
302 | + with person_logged_in(expert): |
303 | + self.assertTrue(check_permission('launchpad.Delete', self.faq)) |
304 | + |
305 | + |
306 | +class TestFAQ(TestCaseWithFactory): |
307 | + |
308 | + layer = DatabaseFunctionalLayer |
309 | + |
310 | + def test_destroySelf(self): |
311 | + # An FAQ can be deleted. |
312 | + with admin_logged_in(): |
313 | + faq = self.factory.makeFAQ() |
314 | + faq.destroySelf() |
315 | + transaction.commit() |
316 | + self.assertIs(None, IStore(FAQ).get(FAQ, faq.id)) |
317 | + |
318 | + def test_destroySelf_rejected_if_questions_linked(self): |
319 | + # Questions must be unlinked before a FAQ is deleted. |
320 | + with admin_logged_in(): |
321 | + faq = self.factory.makeFAQ() |
322 | + question = self.factory.makeQuestion() |
323 | + question.linkFAQ(self.factory.makePerson(), faq, "foo") |
324 | + self.assertRaises(CannotDeleteFAQ, faq.destroySelf) |
325 | + transaction.commit() |
326 | + self.assertEqual(faq, IStore(FAQ).get(FAQ, faq.id)) |
327 | |
328 | === added file 'lib/lp/answers/tests/test_faq_webservice.py' |
329 | --- lib/lp/answers/tests/test_faq_webservice.py 1970-01-01 00:00:00 +0000 |
330 | +++ lib/lp/answers/tests/test_faq_webservice.py 2015-03-17 07:19:55 +0000 |
331 | @@ -0,0 +1,68 @@ |
332 | +# Copyright 2015 Canonical Ltd. This software is licensed under the |
333 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
334 | + |
335 | +__metaclass__ = type |
336 | + |
337 | +from lazr.lifecycle.event import ObjectModifiedEvent |
338 | +from testtools.matchers import ( |
339 | + Contains, |
340 | + ContainsDict, |
341 | + Equals, |
342 | + MatchesRegex, |
343 | + ) |
344 | +from zope.component import getUtility |
345 | +from zope.event import notify |
346 | + |
347 | +from lp.registry.interfaces.person import IPersonSet |
348 | +from lp.services.webapp.interfaces import OAuthPermission |
349 | +from lp.testing import ( |
350 | + admin_logged_in, |
351 | + api_url, |
352 | + TestCaseWithFactory, |
353 | + ) |
354 | +from lp.testing.layers import DatabaseFunctionalLayer |
355 | +from lp.testing.pages import webservice_for_person |
356 | + |
357 | + |
358 | +class TestFAQWebService(TestCaseWithFactory): |
359 | + |
360 | + layer = DatabaseFunctionalLayer |
361 | + |
362 | + def test_representation(self): |
363 | + with admin_logged_in(): |
364 | + faq = self.factory.makeFAQ(title="Nothing works") |
365 | + faq.keywords = "foo bar" |
366 | + faq.content = "It is all broken." |
367 | + notify(ObjectModifiedEvent( |
368 | + faq, faq, ['keywords', 'content'], user=faq.owner)) |
369 | + faq_url = api_url(faq) |
370 | + webservice = webservice_for_person(self.factory.makePerson()) |
371 | + repr = webservice.get(faq_url, api_version='devel').jsonBody() |
372 | + with admin_logged_in(): |
373 | + self.assertThat( |
374 | + repr, |
375 | + ContainsDict({ |
376 | + "id": Equals(faq.id), |
377 | + "title": Equals("Nothing works"), |
378 | + "keywords": Equals("foo bar"), |
379 | + "content": Equals("It is all broken."), |
380 | + "date_created": MatchesRegex("\d\d\d\d-\d\d-\d\dT.*"), |
381 | + "date_last_updated": MatchesRegex("\d\d\d\d-\d\d-\d\dT.*"), |
382 | + "last_updated_by_link": Contains( |
383 | + "/devel/~%s" % faq.owner.name), |
384 | + "target_link": Contains( |
385 | + "/devel/%s" % faq.target.name), |
386 | + })) |
387 | + |
388 | + def test_delete(self): |
389 | + with admin_logged_in(): |
390 | + faq = self.factory.makeFAQ() |
391 | + faq_url = api_url(faq) |
392 | + expert = self.factory.makePerson( |
393 | + member_of=[getUtility(IPersonSet).getByName('registry')]) |
394 | + webservice = webservice_for_person( |
395 | + expert, permission=OAuthPermission.WRITE_PRIVATE) |
396 | + response = webservice.delete(faq_url, api_version='devel') |
397 | + self.assertEqual(200, response.status) |
398 | + response = webservice.get(faq_url, api_version='devel') |
399 | + self.assertEqual(404, response.status) |
400 | |
401 | === modified file 'lib/lp/security.py' |
402 | --- lib/lp/security.py 2015-03-06 10:22:08 +0000 |
403 | +++ lib/lp/security.py 2015-03-17 07:19:55 +0000 |
404 | @@ -2045,7 +2045,8 @@ |
405 | |
406 | def checkAuthenticated(self, user): |
407 | """Allow people with launchpad.Edit or an answer contact.""" |
408 | - if EditByOwnersOrAdmins.checkAuthenticated(self, user): |
409 | + if (EditByOwnersOrAdmins.checkAuthenticated(self, user) |
410 | + or user.in_registry_experts): |
411 | return True |
412 | if IQuestionTarget.providedBy(self.obj): |
413 | # Adapt QuestionTargets to FAQTargets to ensure the correct |
414 | @@ -2071,6 +2072,14 @@ |
415 | return AppendFAQTarget(self.obj.target).checkAuthenticated(user) |
416 | |
417 | |
418 | +class DeleteFAQ(AuthorizationBase): |
419 | + permission = 'launchpad.Delete' |
420 | + usedfor = IFAQ |
421 | + |
422 | + def checkAuthenticated(self, user): |
423 | + return user.in_registry_experts or user.in_admin |
424 | + |
425 | + |
426 | def can_edit_team(team, user): |
427 | """Return True if the given user has edit rights for the given team.""" |
428 | if user.in_admin: |
429 | |
430 | === modified file 'lib/lp/services/webservice/wadl-to-refhtml.xsl' |
431 | --- lib/lp/services/webservice/wadl-to-refhtml.xsl 2015-03-05 16:23:26 +0000 |
432 | +++ lib/lp/services/webservice/wadl-to-refhtml.xsl 2015-03-17 07:19:55 +0000 |
433 | @@ -168,6 +168,7 @@ |
434 | <xsl:when test=" |
435 | @id = 'bug_link_target' |
436 | or @id = 'bug_target' |
437 | + or @id = 'faq_target' |
438 | or @id = 'git_target' |
439 | or @id = 'has_bugs' |
440 | or @id = 'has_milestones' |
441 | @@ -310,6 +311,12 @@ |
442 | <xsl:text>/+email/</xsl:text> |
443 | <var><email></var> |
444 | </xsl:when> |
445 | + <xsl:when test="@id = 'faq'"> |
446 | + <xsl:text>/</xsl:text> |
447 | + <var><target.name></var> |
448 | + <xsl:text>/+faq/</xsl:text> |
449 | + <var ><faq.id></var> |
450 | + </xsl:when> |
451 | <xsl:when test="@id = 'git_repository'"> |
452 | <xsl:text>/~</xsl:text> |
453 | <var><person.name></var> |
Please add something to lib/lp/ services/ webservice/ wadl-to- refhtml. xsl too so that the URLs are documented. faq is reasonably straightforward, and faq_target could go into the "depends on the underlying entry" set.