Merge lp:~wgrant/launchpad/faq-delete into lp:launchpad

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
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.
Revision history for this message
Colin Watson (cjwatson) wrote :

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.

review: Approve

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>&lt;email&gt;</var>
444 </xsl:when>
445+ <xsl:when test="@id = 'faq'">
446+ <xsl:text>/</xsl:text>
447+ <var>&lt;target.name&gt;</var>
448+ <xsl:text>/+faq/</xsl:text>
449+ <var >&lt;faq.id&gt;</var>
450+ </xsl:when>
451 <xsl:when test="@id = 'git_repository'">
452 <xsl:text>/~</xsl:text>
453 <var>&lt;person.name&gt;</var>