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
=== modified file 'lib/lp/answers/configure.zcml'
--- lib/lp/answers/configure.zcml 2014-11-24 06:20:03 +0000
+++ lib/lp/answers/configure.zcml 2015-03-17 07:19:55 +0000
@@ -1,4 +1,4 @@
1<!-- Copyright 2009 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2015 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -212,12 +212,16 @@
212212
213 <class class=".model.faq.FAQ">213 <class class=".model.faq.FAQ">
214 <!-- IFAQ-->214 <!-- IFAQ-->
215 <allow interface=".interfaces.faq.IFAQ" />215 <allow interface=".interfaces.faq.IFAQPublic" />
216 <require216 <require
217 permission="launchpad.Edit"217 permission="launchpad.Edit"
218 set_attributes="title keywords content218 set_attributes="title keywords content
219 last_updated_by date_last_updated"219 last_updated_by date_last_updated"
220 />220 />
221 <require
222 permission="launchpad.Delete"
223 attributes="destroySelf"
224 />
221 </class>225 </class>
222226
223 <adapter227 <adapter
224228
=== modified file 'lib/lp/answers/doc/faqtarget.txt'
--- lib/lp/answers/doc/faqtarget.txt 2011-12-24 17:49:30 +0000
+++ lib/lp/answers/doc/faqtarget.txt 2015-03-17 07:19:55 +0000
@@ -71,7 +71,9 @@
71The returned object provides the IFAQ interface:71The returned object provides the IFAQ interface:
7272
73 >>> from lp.answers.interfaces.faq import IFAQ73 >>> from lp.answers.interfaces.faq import IFAQ
74 >>> verifyObject(IFAQ, faq)74 >>> from lp.testing import admin_logged_in
75 >>> with admin_logged_in():
76 ... verifyObject(IFAQ, faq)
75 True77 True
7678
77The newFAQ() requires an owner, title, and content parameter. It also79The newFAQ() requires an owner, title, and content parameter. It also
7880
=== modified file 'lib/lp/answers/interfaces/faq.py'
--- lib/lp/answers/interfaces/faq.py 2013-01-07 02:40:55 +0000
+++ lib/lp/answers/interfaces/faq.py 2015-03-17 07:19:55 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Interface for FAQ document."""4"""Interface for FAQ document."""
@@ -10,11 +10,20 @@
10 'IFAQSet',10 'IFAQSet',
11 ]11 ]
1212
13import httplib
14
15from lazr.restful.declarations import (
16 error_status,
17 export_as_webservice_entry,
18 export_destructor_operation,
19 exported,
20 operation_for_version,
21 )
22from lazr.restful.fields import Reference
13from zope.interface import Attribute23from zope.interface import Attribute
14from zope.schema import (24from zope.schema import (
15 Datetime,25 Datetime,
16 Int,26 Int,
17 Object,
18 Text,27 Text,
19 TextLine,28 TextLine,
20 )29 )
@@ -29,54 +38,75 @@
29 )38 )
3039
3140
32class IFAQ(IHasOwner):41@error_status(httplib.BAD_REQUEST)
33 """A document containing the answer to a commonly asked question.42class CannotDeleteFAQ(Exception):
3443 """The FAQ cannnot be deleted."""
35 The answer can be in the document itself or can be hosted on a separate44
36 web site and referred to by URL.45
37 """46class IFAQPublic(IHasOwner):
3847
39 id = Int(48 id = exported(Int(
40 title=_('FAQ Number'),49 title=_('FAQ Number'),
41 description=_('The unique number identifying the FAQ in Launchpad.'),50 description=_('The unique number identifying the FAQ in Launchpad.'),
42 required=True, readonly=True)51 required=True, readonly=True), as_of='devel')
4352
44 title = Title(53 title = exported(Title(
45 title=_('Title'),54 title=_('Title'),
46 description=_('The title describing this FAQ, often a question.'),55 description=_('The title describing this FAQ, often a question.'),
47 required=True)56 required=True), as_of='devel')
4857
49 keywords = TextLine(58 keywords = exported(TextLine(
50 title=_('Keywords'),59 title=_('Keywords'),
51 description=_('One or more terms that relate to this FAQ.'),60 description=_('One or more terms that relate to this FAQ.'),
52 required=False)61 required=False), as_of='devel')
5362
54 content = Text(63 content = exported(Text(
55 title=_('Content'),64 title=_('Content'),
56 description=_(65 description=_(
57 'The answer for this FAQ in plain text. You may choose to '66 'The answer for this FAQ in plain text. You may choose to '
58 'include a URL to an external FAQ.'),67 'include a URL to an external FAQ.'),
59 required=True)68 required=True), as_of='devel')
6069
61 date_created = Datetime(title=_('Created'), required=True, readonly=True)70 date_created = exported(Datetime(
6271 title=_('Created'), required=True, readonly=True), as_of='devel')
63 last_updated_by = PublicPersonChoice(72
73 last_updated_by = exported(PublicPersonChoice(
64 title=_('Last Updated By'),74 title=_('Last Updated By'),
65 description=_('The last person who modified the document.'),75 description=_('The last person who modified the document.'),
66 vocabulary='ValidPersonOrTeam', required=False)76 vocabulary='ValidPersonOrTeam', required=False, readonly=True),
6777 as_of='devel')
68 date_last_updated = Datetime(title=_('Last Updated'), required=False)78
6979 date_last_updated = exported(Datetime(
70 target = Object(80 title=_('Last Updated'), required=False, readonly=True),
81 as_of='devel')
82
83 target = exported(Reference(
71 title=_('Target'),84 title=_('Target'),
72 description=_('Product or distribution containing this FAQ.'),85 description=_('Product or distribution containing this FAQ.'),
73 schema=IFAQTarget,86 schema=IFAQTarget, required=True, readonly=True), as_of='devel')
74 required=True)
7587
76 related_questions = Attribute(88 related_questions = Attribute(
77 _('The set of questions linked to this FAQ.'))89 _('The set of questions linked to this FAQ.'))
7890
7991
92class IFAQ(IFAQPublic):
93 """A document containing the answer to a commonly asked question.
94
95 The answer can be in the document itself or can be hosted on a separate
96 web site and referred to by URL.
97 """
98
99 export_as_webservice_entry('faq', as_of='beta')
100
101 @export_destructor_operation()
102 @operation_for_version('devel')
103 def destroySelf():
104 """Delete this FAQ.
105
106 Any questions linked to this FAQ must be unlinked beforehand.
107 """
108
109
80class IFAQSet(IFAQCollection):110class IFAQSet(IFAQCollection):
81 """`IFAQCollection` of all the FAQs existing in Launchpad.111 """`IFAQCollection` of all the FAQs existing in Launchpad.
82112
83113
=== modified file 'lib/lp/answers/interfaces/faqtarget.py'
--- lib/lp/answers/interfaces/faqtarget.py 2013-01-07 02:40:55 +0000
+++ lib/lp/answers/interfaces/faqtarget.py 2015-03-17 07:19:55 +0000
@@ -10,12 +10,16 @@
10 ]10 ]
1111
1212
13from lazr.restful.declarations import export_as_webservice_entry
14
13from lp.answers.interfaces.faqcollection import IFAQCollection15from lp.answers.interfaces.faqcollection import IFAQCollection
1416
1517
16class IFAQTarget(IFAQCollection):18class IFAQTarget(IFAQCollection):
17 """An object that can contain a FAQ document."""19 """An object that can contain a FAQ document."""
1820
21 export_as_webservice_entry('faq_target', as_of='beta')
22
19 def newFAQ(owner, title, content, keywords=None, date_created=None):23 def newFAQ(owner, title, content, keywords=None, date_created=None):
20 """Create a new FAQ hosted in this target.24 """Create a new FAQ hosted in this target.
2125
2226
=== modified file 'lib/lp/answers/interfaces/webservice.py'
--- lib/lp/answers/interfaces/webservice.py 2015-03-13 04:07:31 +0000
+++ lib/lp/answers/interfaces/webservice.py 2015-03-17 07:19:55 +0000
@@ -10,6 +10,8 @@
10"""10"""
1111
12__all__ = [12__all__ = [
13 'IFAQ',
14 'IFAQTarget',
13 'IQuestion',15 'IQuestion',
14 'IQuestionSet',16 'IQuestionSet',
15 'IQuestionSubscription',17 'IQuestionSubscription',
@@ -17,6 +19,8 @@
1719
18from lazr.restful.declarations import LAZR_WEBSERVICE_EXPORTED20from lazr.restful.declarations import LAZR_WEBSERVICE_EXPORTED
1921
22from lp.answers.interfaces.faq import IFAQ
23from lp.answers.interfaces.faqtarget import IFAQTarget
20from lp.answers.interfaces.question import IQuestion24from lp.answers.interfaces.question import IQuestion
21from lp.answers.interfaces.questioncollection import (25from lp.answers.interfaces.questioncollection import (
22 IQuestionCollection,26 IQuestionCollection,
2327
=== modified file 'lib/lp/answers/model/faq.py'
--- lib/lp/answers/model/faq.py 2015-01-29 10:39:32 +0000
+++ lib/lp/answers/model/faq.py 2015-03-17 07:19:55 +0000
@@ -1,4 +1,4 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""FAQ document models."""4"""FAQ document models."""
@@ -23,6 +23,7 @@
23from zope.interface import implements23from zope.interface import implements
2424
25from lp.answers.interfaces.faq import (25from lp.answers.interfaces.faq import (
26 CannotDeleteFAQ,
26 IFAQ,27 IFAQ,
27 IFAQSet,28 IFAQSet,
28 )29 )
@@ -93,6 +94,12 @@
93 else:94 else:
94 return self.distribution95 return self.distribution
9596
97 def destroySelf(self):
98 if self.related_questions:
99 raise CannotDeleteFAQ(
100 "Cannot delete FAQ: questions must be unlinked first.")
101 super(FAQ, self).destroySelf()
102
96 @staticmethod103 @staticmethod
97 def new(owner, title, content, keywords=keywords, date_created=None,104 def new(owner, title, content, keywords=keywords, date_created=None,
98 product=None, distribution=None):105 product=None, distribution=None):
99106
=== modified file 'lib/lp/answers/tests/test_faq.py'
--- lib/lp/answers/tests/test_faq.py 2012-01-01 02:58:52 +0000
+++ lib/lp/answers/tests/test_faq.py 2015-03-17 07:19:55 +0000
@@ -1,15 +1,21 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the1# Copyright 2010-2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for IFAQ"""4"""Tests for IFAQ"""
55
6__metaclass__ = type6__metaclass__ = type
77
8import transaction
8from zope.component import getUtility9from zope.component import getUtility
910
11from lp.answers.interfaces.faq import CannotDeleteFAQ
12from lp.answers.model.faq import FAQ
13from lp.registry.interfaces.person import IPersonSet
14from lp.services.database.interfaces import IStore
10from lp.services.webapp.authorization import check_permission15from lp.services.webapp.authorization import check_permission
11from lp.services.worlddata.interfaces.language import ILanguageSet16from lp.services.worlddata.interfaces.language import ILanguageSet
12from lp.testing import (17from lp.testing import (
18 admin_logged_in,
13 login_person,19 login_person,
14 person_logged_in,20 person_logged_in,
15 TestCaseWithFactory,21 TestCaseWithFactory,
@@ -75,3 +81,48 @@
75 nonparticipant = self.factory.makePerson()81 nonparticipant = self.factory.makePerson()
76 login_person(nonparticipant)82 login_person(nonparticipant)
77 self.assertCannotEdit(nonparticipant, self.faq)83 self.assertCannotEdit(nonparticipant, self.faq)
84
85 def test_registry_can_edit(self):
86 # A member of ~registry can edit any FAQ.
87 expert = self.factory.makePerson(
88 member_of=[getUtility(IPersonSet).getByName('registry')])
89 login_person(expert)
90 self.assertCanEdit(expert, self.faq)
91
92 def test_direct_answer_contact_cannot_delete(self):
93 # Answer contacts are broad, and deletion is irreversible, so
94 # they cannot do it themselves.
95 direct_answer_contact = self.factory.makePerson()
96 with person_logged_in(direct_answer_contact):
97 self.addAnswerContact(direct_answer_contact)
98 self.assertFalse(check_permission('launchpad.Delete', self.faq))
99
100 def test_registry_can_delete(self):
101 # A member of ~registry can delete any FAQ.
102 expert = self.factory.makePerson(
103 member_of=[getUtility(IPersonSet).getByName('registry')])
104 with person_logged_in(expert):
105 self.assertTrue(check_permission('launchpad.Delete', self.faq))
106
107
108class TestFAQ(TestCaseWithFactory):
109
110 layer = DatabaseFunctionalLayer
111
112 def test_destroySelf(self):
113 # An FAQ can be deleted.
114 with admin_logged_in():
115 faq = self.factory.makeFAQ()
116 faq.destroySelf()
117 transaction.commit()
118 self.assertIs(None, IStore(FAQ).get(FAQ, faq.id))
119
120 def test_destroySelf_rejected_if_questions_linked(self):
121 # Questions must be unlinked before a FAQ is deleted.
122 with admin_logged_in():
123 faq = self.factory.makeFAQ()
124 question = self.factory.makeQuestion()
125 question.linkFAQ(self.factory.makePerson(), faq, "foo")
126 self.assertRaises(CannotDeleteFAQ, faq.destroySelf)
127 transaction.commit()
128 self.assertEqual(faq, IStore(FAQ).get(FAQ, faq.id))
78129
=== added file 'lib/lp/answers/tests/test_faq_webservice.py'
--- lib/lp/answers/tests/test_faq_webservice.py 1970-01-01 00:00:00 +0000
+++ lib/lp/answers/tests/test_faq_webservice.py 2015-03-17 07:19:55 +0000
@@ -0,0 +1,68 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6from lazr.lifecycle.event import ObjectModifiedEvent
7from testtools.matchers import (
8 Contains,
9 ContainsDict,
10 Equals,
11 MatchesRegex,
12 )
13from zope.component import getUtility
14from zope.event import notify
15
16from lp.registry.interfaces.person import IPersonSet
17from lp.services.webapp.interfaces import OAuthPermission
18from lp.testing import (
19 admin_logged_in,
20 api_url,
21 TestCaseWithFactory,
22 )
23from lp.testing.layers import DatabaseFunctionalLayer
24from lp.testing.pages import webservice_for_person
25
26
27class TestFAQWebService(TestCaseWithFactory):
28
29 layer = DatabaseFunctionalLayer
30
31 def test_representation(self):
32 with admin_logged_in():
33 faq = self.factory.makeFAQ(title="Nothing works")
34 faq.keywords = "foo bar"
35 faq.content = "It is all broken."
36 notify(ObjectModifiedEvent(
37 faq, faq, ['keywords', 'content'], user=faq.owner))
38 faq_url = api_url(faq)
39 webservice = webservice_for_person(self.factory.makePerson())
40 repr = webservice.get(faq_url, api_version='devel').jsonBody()
41 with admin_logged_in():
42 self.assertThat(
43 repr,
44 ContainsDict({
45 "id": Equals(faq.id),
46 "title": Equals("Nothing works"),
47 "keywords": Equals("foo bar"),
48 "content": Equals("It is all broken."),
49 "date_created": MatchesRegex("\d\d\d\d-\d\d-\d\dT.*"),
50 "date_last_updated": MatchesRegex("\d\d\d\d-\d\d-\d\dT.*"),
51 "last_updated_by_link": Contains(
52 "/devel/~%s" % faq.owner.name),
53 "target_link": Contains(
54 "/devel/%s" % faq.target.name),
55 }))
56
57 def test_delete(self):
58 with admin_logged_in():
59 faq = self.factory.makeFAQ()
60 faq_url = api_url(faq)
61 expert = self.factory.makePerson(
62 member_of=[getUtility(IPersonSet).getByName('registry')])
63 webservice = webservice_for_person(
64 expert, permission=OAuthPermission.WRITE_PRIVATE)
65 response = webservice.delete(faq_url, api_version='devel')
66 self.assertEqual(200, response.status)
67 response = webservice.get(faq_url, api_version='devel')
68 self.assertEqual(404, response.status)
069
=== modified file 'lib/lp/security.py'
--- lib/lp/security.py 2015-03-06 10:22:08 +0000
+++ lib/lp/security.py 2015-03-17 07:19:55 +0000
@@ -2045,7 +2045,8 @@
20452045
2046 def checkAuthenticated(self, user):2046 def checkAuthenticated(self, user):
2047 """Allow people with launchpad.Edit or an answer contact."""2047 """Allow people with launchpad.Edit or an answer contact."""
2048 if EditByOwnersOrAdmins.checkAuthenticated(self, user):2048 if (EditByOwnersOrAdmins.checkAuthenticated(self, user)
2049 or user.in_registry_experts):
2049 return True2050 return True
2050 if IQuestionTarget.providedBy(self.obj):2051 if IQuestionTarget.providedBy(self.obj):
2051 # Adapt QuestionTargets to FAQTargets to ensure the correct2052 # Adapt QuestionTargets to FAQTargets to ensure the correct
@@ -2071,6 +2072,14 @@
2071 return AppendFAQTarget(self.obj.target).checkAuthenticated(user)2072 return AppendFAQTarget(self.obj.target).checkAuthenticated(user)
20722073
20732074
2075class DeleteFAQ(AuthorizationBase):
2076 permission = 'launchpad.Delete'
2077 usedfor = IFAQ
2078
2079 def checkAuthenticated(self, user):
2080 return user.in_registry_experts or user.in_admin
2081
2082
2074def can_edit_team(team, user):2083def can_edit_team(team, user):
2075 """Return True if the given user has edit rights for the given team."""2084 """Return True if the given user has edit rights for the given team."""
2076 if user.in_admin:2085 if user.in_admin:
20772086
=== modified file 'lib/lp/services/webservice/wadl-to-refhtml.xsl'
--- lib/lp/services/webservice/wadl-to-refhtml.xsl 2015-03-05 16:23:26 +0000
+++ lib/lp/services/webservice/wadl-to-refhtml.xsl 2015-03-17 07:19:55 +0000
@@ -168,6 +168,7 @@
168 <xsl:when test="168 <xsl:when test="
169 @id = 'bug_link_target'169 @id = 'bug_link_target'
170 or @id = 'bug_target'170 or @id = 'bug_target'
171 or @id = 'faq_target'
171 or @id = 'git_target'172 or @id = 'git_target'
172 or @id = 'has_bugs'173 or @id = 'has_bugs'
173 or @id = 'has_milestones'174 or @id = 'has_milestones'
@@ -310,6 +311,12 @@
310 <xsl:text>/+email/</xsl:text>311 <xsl:text>/+email/</xsl:text>
311 <var>&lt;email&gt;</var>312 <var>&lt;email&gt;</var>
312 </xsl:when>313 </xsl:when>
314 <xsl:when test="@id = 'faq'">
315 <xsl:text>/</xsl:text>
316 <var>&lt;target.name&gt;</var>
317 <xsl:text>/+faq/</xsl:text>
318 <var >&lt;faq.id&gt;</var>
319 </xsl:when>
313 <xsl:when test="@id = 'git_repository'">320 <xsl:when test="@id = 'git_repository'">
314 <xsl:text>/~</xsl:text>321 <xsl:text>/~</xsl:text>
315 <var>&lt;person.name&gt;</var>322 <var>&lt;person.name&gt;</var>