Merge lp:~edwin-grubbs/launchpad/bug-446074-blacklist-form into lp:launchpad

Proposed by Edwin Grubbs
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 11921
Proposed branch: lp:~edwin-grubbs/launchpad/bug-446074-blacklist-form
Merge into: lp:launchpad
Diff against target: 952 lines (+679/-64)
13 files modified
lib/canonical/launchpad/browser/launchpad.py (+10/-8)
lib/canonical/launchpad/security.py (+25/-1)
lib/lp/registry/browser/configure.zcml (+51/-0)
lib/lp/registry/browser/nameblacklist.py (+175/-0)
lib/lp/registry/browser/tests/nameblacklist-views.txt (+96/-0)
lib/lp/registry/browser/tests/test_peoplemerge.py (+2/-0)
lib/lp/registry/configure.zcml (+23/-0)
lib/lp/registry/interfaces/nameblacklist.py (+44/-0)
lib/lp/registry/model/nameblacklist.py (+70/-0)
lib/lp/registry/templates/nameblacklists-index.pt (+39/-0)
lib/lp/registry/tests/test_nameblacklist.py (+132/-40)
lib/lp/soyuz/tests/test_archive_subscriptions.py (+5/-0)
lib/lp/testing/_login.py (+7/-15)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-446074-blacklist-form
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+40405@code.launchpad.net

Description of the change

Summary
-------

Added ability for the Registry Experts team to view, add, and edit the
NameBlacklist table.

Tests
-----

./bin/test -vv -t nameblacklist

Demo and Q/A
------------

* Open http://launchpad.dev/+nameblacklist
  * Click on "Add blacklist expression"
  * It should not be possible to add "(" as an expression.
* Open http://launchpad.dev/+nameblacklist
  * Click on a yellow edit icon.
  * It should not be possible to add "(" as an expression.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :
Download full text (6.5 KiB)

Hi Edwin.

Thank you very much for providing this fine implementation. There are many
places where I discovered you had implemented the feature as I would hope
(using RegistryEditFormView, and making the table sortable). I think there
is a chance for an oops in the code, and there are some historic code that
I hope you can clean up.

The page titles in the browser are broken, breadcrumbs are missing. This is
because the there is no breadcrumb adapter This might help:

{{{

@adapter(INameBlackListSet)
class NameBlacklistSetBreadcrumb(Breadcrumb):
    """Return a breadcrumb for an `INameBlackListSet`."""
    text = "Name Blacklist"

@adapter(INameBlackList)
class NameBlacklistBreadcrumb(Breadcrumb):
    """Return a breadcrumb for an `INameBlackList`."""

    @property
    def text(self):
        return self.context.regexp

}}}

> === added file 'lib/lp/registry/browser/nameblacklist.py'
> --- lib/lp/registry/browser/nameblacklist.py 1970-01-01 00:00:00 +0000
> +++ lib/lp/registry/browser/nameblacklist.py 2010-11-09 05:26:23 +0000
...
> +class NameBlacklistAddView(NameBlacklistValidationMixin, LaunchpadFormView):
> + """View for adding a blacklist expression."""
> +
> + schema = INameBlacklist
> + field_names = ['regexp', 'comment']
> + label = "Add a new blacklist expression"
> +
> + custom_widget('regexp', TextWidget, displayWidth=60)
> +
> + @property
> + def page_title(self):
> + """The page title."""
> + return self.label

This could be
    page_title = label

...

> +class NameBlacklistSetView(LaunchpadView):
> + """View for /+nameblacklists top level collection."""
> +
> + page_title = (
> + 'Blacklist for names of Launchpad pillars, persons, and teams')
> + label = page_title

I think
    Blacklist for names of Launchpad pillars and persons
is good enough. I think user and team are really subclasses of IPerson.
One day we will also have agent for the bots we have impersonating users.

> +class NameBlacklistSetNavigation(Navigation):
> +
> + usedfor = INameBlacklistSet
> +
> + def traverse(self, name):
> + return self.context.get(int(name))

Do I get a 404 or a 500 for
    http://launchpad.net/+nameblacklist/word

A 404 is correct. The interface says None will be returned (404), but int()
will raise an Exception. Maybe int() should be pushed into get() or this
method chooses to raise the 404 itself when the name cannot be cast.

...

> === added file 'lib/lp/registry/model/nameblacklist.py'
> --- lib/lp/registry/model/nameblacklist.py 1970-01-01 00:00:00 +0000
> +++ lib/lp/registry/model/nameblacklist.py 2010-11-09 05:26:23 +0000
...
> + def get(self, id):
> + """See `INameBlacklistSet`."""
> + store = IStore(NameBlacklist)
> + return store.find(NameBlacklist, NameBlacklist.id == id).one()

I think this method could cast the id to an int and return None when an
exception is raised to avoid a query that will always return None.

...

> === added file 'lib/lp/registry/templates/nameblacklists-index.pt'
> --- lib/lp/registry/templates/nameblacklists-index.pt 1970-01-01 00:00:00 +0000
> +++ lib/lp/registry/templates/nameblacklists-index.pt 2010-11-09 05:2...

Read more...

review: Needs Fixing (code)
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (18.0 KiB)

On Wed, Nov 10, 2010 at 9:42 AM, Curtis Hovey
<email address hidden> wrote:
> Review: Needs Fixing code
> Hi Edwin.
>
> Thank you very much for providing this fine implementation. There are many
> places where I discovered you had implemented the feature as I would hope
> (using RegistryEditFormView, and making the table sortable). I think there
> is a chance for an oops in the code, and there are some historic code that
> I hope you can clean up.
>
> The page titles in the browser are broken, breadcrumbs are missing. This is
> because the there is no breadcrumb adapter This might help:
>
> {{{
>
> @adapter(INameBlackListSet)
> class NameBlacklistSetBreadcrumb(Breadcrumb):
>    """Return a breadcrumb for an `INameBlackListSet`."""
>    text = "Name Blacklist"
>
> @adapter(INameBlackList)
> class NameBlacklistBreadcrumb(Breadcrumb):
>    """Return a breadcrumb for an `INameBlackList`."""
>
>    @property
>    def text(self):
>        return self.context.regexp
>
> }}}

Fixed.

>> === added file 'lib/lp/registry/browser/nameblacklist.py'
>> --- lib/lp/registry/browser/nameblacklist.py  1970-01-01 00:00:00 +0000
>> +++ lib/lp/registry/browser/nameblacklist.py  2010-11-09 05:26:23 +0000
> ...
>> +class NameBlacklistAddView(NameBlacklistValidationMixin, LaunchpadFormView):
>> +    """View for adding a blacklist expression."""
>> +
>> +    schema = INameBlacklist
>> +    field_names = ['regexp', 'comment']
>> +    label = "Add a new blacklist expression"
>> +
>> +    custom_widget('regexp', TextWidget, displayWidth=60)
>> +
>> +    @property
>> +    def page_title(self):
>> +        """The page title."""
>> +        return self.label
>
> This could be
>    page_title = label

Fixed.

> ...
>
>> +class NameBlacklistSetView(LaunchpadView):
>> +    """View for /+nameblacklists top level collection."""
>> +
>> +    page_title = (
>> +        'Blacklist for names of Launchpad pillars, persons, and teams')
>> +    label = page_title
>
> I think
>    Blacklist for names of Launchpad pillars and persons
> is good enough. I think user and team are really subclasses of IPerson.
> One day we will also have agent for the bots we have impersonating users.

Fixed.

>> +class NameBlacklistSetNavigation(Navigation):
>> +
>> +    usedfor = INameBlacklistSet
>> +
>> +    def traverse(self, name):
>> +        return self.context.get(int(name))
>
> Do I get a 404 or a 500 for
>    http://launchpad.net/+nameblacklist/word
>
> A 404 is correct. The interface says None will be returned (404), but int()
> will raise an Exception. Maybe int() should be pushed into get() or this
> method chooses to raise the 404 itself when the name cannot be cast.

Fixed.

> ...
>
>> === added file 'lib/lp/registry/model/nameblacklist.py'
>> --- lib/lp/registry/model/nameblacklist.py    1970-01-01 00:00:00 +0000
>> +++ lib/lp/registry/model/nameblacklist.py    2010-11-09 05:26:23 +0000
> ...
>> +    def get(self, id):
>> +        """See `INameBlacklistSet`."""
>> +        store = IStore(NameBlacklist)
>> +        return store.find(NameBlacklist, NameBlacklist.id == id).one()
>
> I think this method could cast the id to an int and return None when an
> exception is raised to av...

Revision history for this message
Curtis Hovey (sinzui) wrote :

Thanks for addressing my concerns.

review: Approve (code)
Revision history for this message
Robert Collins (lifeless) wrote :

>> + self.failUnlessEqual(
>> + self.name_blacklist_match("barfoo"),
>> + self.foo_exp.id)
>
> from https://dev.launchpad.net/TestsStyleGuide:
> When asserting for equality use the form assertEqual
> Can you update all these old tests to assertEqual?

This might be nicer still as

self.assertThat('barfoo', IsBlacklisted())

(with a trivial IsBlacklisted matcher).

-Rob

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/browser/launchpad.py'
2--- lib/canonical/launchpad/browser/launchpad.py 2010-11-02 20:10:56 +0000
3+++ lib/canonical/launchpad/browser/launchpad.py 2010-11-12 06:04:05 +0000
4@@ -89,6 +89,14 @@
5 INavigationMenu,
6 )
7 from canonical.launchpad.webapp.publisher import RedirectionView
8+from canonical.launchpad.webapp.url import urlappend
9+from canonical.launchpad.webapp.vhosts import allvhosts
10+from canonical.lazr import (
11+ ExportedFolder,
12+ ExportedImageFolder,
13+ )
14+from canonical.widgets.project import ProjectScopeWidget
15+from lp.answers.interfaces.questioncollection import IQuestionSet
16 # XXX SteveAlexander 2005-09-22: this is imported here because there is no
17 # general timedelta to duration format adapter available. This should
18 # be factored out into a generally available adapter for both this
19@@ -99,14 +107,6 @@
20 MenuAPI,
21 PageTemplateContextsAPI,
22 )
23-from canonical.launchpad.webapp.url import urlappend
24-from canonical.launchpad.webapp.vhosts import allvhosts
25-from canonical.lazr import (
26- ExportedFolder,
27- ExportedImageFolder,
28- )
29-from canonical.widgets.project import ProjectScopeWidget
30-from lp.answers.interfaces.questioncollection import IQuestionSet
31 from lp.app.errors import (
32 GoneError,
33 NotFoundError,
34@@ -131,6 +131,7 @@
35 from lp.registry.interfaces.codeofconduct import ICodeOfConductSet
36 from lp.registry.interfaces.distribution import IDistributionSet
37 from lp.registry.interfaces.karma import IKarmaActionSet
38+from lp.registry.interfaces.nameblacklist import INameBlacklistSet
39 from lp.registry.interfaces.person import IPersonSet
40 from lp.registry.interfaces.pillar import IPillarNameSet
41 from lp.registry.interfaces.product import (
42@@ -583,6 +584,7 @@
43 'karmaaction': IKarmaActionSet,
44 '+imports': ITranslationImportQueue,
45 '+languages': ILanguageSet,
46+ '+nameblacklist': INameBlacklistSet,
47 'package-sets': IPackagesetSet,
48 'people': IPersonSet,
49 'pillars': IPillarNameSet,
50
51=== modified file 'lib/canonical/launchpad/security.py'
52--- lib/canonical/launchpad/security.py 2010-10-21 01:42:14 +0000
53+++ lib/canonical/launchpad/security.py 2010-11-12 06:04:05 +0000
54@@ -51,6 +51,7 @@
55 )
56 from lp.blueprints.interfaces.sprint import ISprint
57 from lp.blueprints.interfaces.sprintspecification import ISprintSpecification
58+from lp.bugs.interfaces.bugtarget import IOfficialBugTagTargetRestricted
59 from lp.buildmaster.interfaces.builder import (
60 IBuilder,
61 IBuilderSet,
62@@ -61,7 +62,6 @@
63 IBuildFarmJobOld,
64 )
65 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
66-from lp.bugs.interfaces.bugtarget import IOfficialBugTagTargetRestricted
67 from lp.code.interfaces.branch import (
68 IBranch,
69 user_has_special_branch_access,
70@@ -114,6 +114,10 @@
71 IMilestone,
72 IProjectGroupMilestone,
73 )
74+from lp.registry.interfaces.nameblacklist import (
75+ INameBlacklist,
76+ INameBlacklistSet,
77+ )
78 from lp.registry.interfaces.packaging import IPackaging
79 from lp.registry.interfaces.person import (
80 IPerson,
81@@ -1664,6 +1668,26 @@
82 return team in user.person.getAdministratedTeams()
83
84
85+class ViewNameBlacklist(EditByRegistryExpertsOrAdmins):
86+ permission = 'launchpad.View'
87+ usedfor = INameBlacklist
88+
89+
90+class EditNameBlacklist(EditByRegistryExpertsOrAdmins):
91+ permission = 'launchpad.Edit'
92+ usedfor = INameBlacklist
93+
94+
95+class ViewNameBlacklistSet(EditByRegistryExpertsOrAdmins):
96+ permission = 'launchpad.View'
97+ usedfor = INameBlacklistSet
98+
99+
100+class EditNameBlacklistSet(EditByRegistryExpertsOrAdmins):
101+ permission = 'launchpad.Edit'
102+ usedfor = INameBlacklistSet
103+
104+
105 class ViewLanguageSet(AnonymousAuthorization):
106 """Anyone can view an ILangaugeSet."""
107 usedfor = ILanguageSet
108
109=== modified file 'lib/lp/registry/browser/configure.zcml'
110--- lib/lp/registry/browser/configure.zcml 2010-10-11 09:35:29 +0000
111+++ lib/lp/registry/browser/configure.zcml 2010-11-12 06:04:05 +0000
112@@ -1560,6 +1560,57 @@
113 class="lp.registry.browser.product.ProductReviewLicenseView"
114 permission="launchpad.Moderate"
115 template="../templates/product-review-license.pt"/>
116+
117+ <browser:page
118+ for="lp.registry.interfaces.nameblacklist.INameBlacklist"
119+ permission="launchpad.Edit"
120+ facet="overview"
121+ class="lp.registry.browser.nameblacklist.NameBlacklistEditView"
122+ name="+edit"
123+ template="../../app/templates/generic-edit.pt"/>
124+ <browser:url
125+ for="lp.registry.interfaces.nameblacklist.INameBlacklistSet"
126+ path_expression="string:+nameblacklist"
127+ parent_utility="canonical.launchpad.webapp.interfaces.ILaunchpadRoot"
128+ />
129+ <browser:url
130+ for="lp.registry.interfaces.nameblacklist.INameBlacklist"
131+ path_expression="string:${id}"
132+ parent_utility="lp.registry.interfaces.nameblacklist.INameBlacklistSet"
133+ />
134+ <browser:defaultView
135+ for="lp.registry.interfaces.nameblacklist.INameBlacklistSet"
136+ name="+index"/>
137+ <browser:page
138+ for="lp.registry.interfaces.nameblacklist.INameBlacklistSet"
139+ permission="launchpad.View"
140+ facet="overview"
141+ class="lp.registry.browser.nameblacklist.NameBlacklistSetView"
142+ name="+index"
143+ template="../templates/nameblacklists-index.pt"/>
144+ <browser:page
145+ for="lp.registry.interfaces.nameblacklist.INameBlacklistSet"
146+ permission="launchpad.Edit"
147+ facet="overview"
148+ class="lp.registry.browser.nameblacklist.NameBlacklistAddView"
149+ name="+add"
150+ template="../../app/templates/generic-edit.pt"/>
151+ <browser:navigation
152+ module="lp.registry.browser.nameblacklist"
153+ classes="NameBlacklistSetNavigation"/>
154+ <browser:menus
155+ classes="
156+ NameBlacklistNavigationMenu
157+ NameBlacklistSetNavigationMenu
158+ "
159+ module="lp.registry.browser.nameblacklist"/>
160+ <adapter
161+ factory="lp.registry.browser.nameblacklist.NameBlacklistSetBreadcrumb"
162+ />
163+ <adapter
164+ factory="lp.registry.browser.nameblacklist.NameBlacklistBreadcrumb"
165+ />
166+
167 <browser:page
168 name="+addseries"
169 for="lp.registry.interfaces.product.IProduct"
170
171=== added file 'lib/lp/registry/browser/nameblacklist.py'
172--- lib/lp/registry/browser/nameblacklist.py 1970-01-01 00:00:00 +0000
173+++ lib/lp/registry/browser/nameblacklist.py 2010-11-12 06:04:05 +0000
174@@ -0,0 +1,175 @@
175+# Copyright 2010 Canonical Ltd. This software is licensed under the
176+# GNU Affero General Public License version 3 (see the file LICENSE).
177+
178+__metaclass__ = type
179+__all__ = [
180+ 'NameBlacklistAddView',
181+ 'NameBlacklistEditView',
182+ 'NameBlacklistNavigationMenu',
183+ 'NameBlacklistSetNavigationMenu',
184+ 'NameBlacklistSetView',
185+ ]
186+
187+import re
188+
189+from zope.app.form.browser import TextWidget
190+from zope.component import (
191+ adapter,
192+ getUtility,
193+ )
194+from zope.interface import implements
195+
196+from canonical.launchpad.webapp import action
197+from canonical.launchpad.webapp.breadcrumb import Breadcrumb
198+from canonical.launchpad.webapp.interfaces import IBreadcrumb
199+from canonical.launchpad.webapp.launchpadform import (
200+ custom_widget,
201+ LaunchpadFormView,
202+ )
203+from canonical.launchpad.webapp.menu import (
204+ ApplicationMenu,
205+ enabled_with_permission,
206+ Link,
207+ NavigationMenu,
208+ )
209+from canonical.launchpad.webapp.publisher import (
210+ canonical_url,
211+ LaunchpadView,
212+ Navigation,
213+ )
214+from lp.registry.browser import RegistryEditFormView
215+from lp.registry.interfaces.nameblacklist import (
216+ INameBlacklist,
217+ INameBlacklistSet,
218+ )
219+
220+
221+class NameBlacklistValidationMixin:
222+ """Validate regular expression when adding or editing."""
223+
224+ def validate(self, data):
225+ """Validate regular expression."""
226+ regexp = data['regexp']
227+ try:
228+ re.compile(regexp)
229+ name_blacklist_set = getUtility(INameBlacklistSet)
230+ if (INameBlacklistSet.providedBy(self.context)
231+ or self.context.regexp != regexp):
232+ # Check if the regular expression already exists if a
233+ # new expression is being created or if an existing
234+ # regular expression has been modified.
235+ if name_blacklist_set.getByRegExp(regexp) is not None:
236+ self.setFieldError(
237+ 'regexp',
238+ 'This regular expression already exists.')
239+ except re.error, e:
240+ self.setFieldError(
241+ 'regexp',
242+ 'Invalid regular expression: %s' % e)
243+
244+
245+class NameBlacklistEditView(NameBlacklistValidationMixin,
246+ RegistryEditFormView):
247+ """View for editing a blacklist expression."""
248+
249+ schema = INameBlacklist
250+ field_names = ['regexp', 'comment']
251+ label = "Edit a blacklist expression"
252+ page_title = label
253+
254+ @property
255+ def cancel_url(self):
256+ return canonical_url(getUtility(INameBlacklistSet))
257+
258+ next_url = cancel_url
259+
260+
261+class NameBlacklistAddView(NameBlacklistValidationMixin, LaunchpadFormView):
262+ """View for adding a blacklist expression."""
263+
264+ schema = INameBlacklist
265+ field_names = ['regexp', 'comment']
266+ label = "Add a new blacklist expression"
267+ page_title = label
268+
269+ custom_widget('regexp', TextWidget, displayWidth=60)
270+
271+ @property
272+ def cancel_url(self):
273+ """See `LaunchpadFormView`."""
274+ return canonical_url(self.context)
275+
276+ next_url = cancel_url
277+
278+ @action("Add to blacklist", name='add')
279+ def add_action(self, action, data):
280+ name_blacklist_set = getUtility(INameBlacklistSet)
281+ name_blacklist_set.create(
282+ regexp=data['regexp'],
283+ comment=data['comment'],
284+ )
285+ self.request.response.addInfoNotification(
286+ 'Regular expression "%s" has been added to the name blacklist.'
287+ % data['regexp'])
288+
289+
290+class NameBlacklistSetView(LaunchpadView):
291+ """View for /+nameblacklists top level collection."""
292+
293+ label = (
294+ 'Blacklist for names of Launchpad pillars and persons')
295+ page_title = label
296+
297+
298+class NameBlacklistSetNavigation(Navigation):
299+
300+ usedfor = INameBlacklistSet
301+
302+ def traverse(self, name):
303+ return self.context.get(name)
304+
305+
306+class NameBlacklistSetNavigationMenu(NavigationMenu):
307+ """Action menu for NameBlacklistSet."""
308+ usedfor = INameBlacklistSet
309+ facet = 'overview'
310+ links = [
311+ 'add_blacklist_expression',
312+ ]
313+
314+ @enabled_with_permission('launchpad.Edit')
315+ def add_blacklist_expression(self):
316+ return Link('+add', 'Add blacklist expression', icon='add')
317+
318+
319+class NameBlacklistNavigationMenu(ApplicationMenu):
320+ """Action menu for NameBlacklist."""
321+ usedfor = INameBlacklist
322+ facet = 'overview'
323+ links = [
324+ 'edit_blacklist_expression',
325+ ]
326+
327+ @enabled_with_permission('launchpad.Edit')
328+ def edit_blacklist_expression(self):
329+ return Link('+edit', 'Edit blacklist expression', icon='edit')
330+
331+
332+@adapter(INameBlacklistSet)
333+class NameBlacklistSetBreadcrumb(Breadcrumb):
334+ """Return a breadcrumb for an `INameBlackListSet`."""
335+
336+ implements(IBreadcrumb)
337+
338+ text = "Name Blacklist"
339+
340+
341+@adapter(INameBlacklist)
342+class NameBlacklistBreadcrumb(Breadcrumb):
343+ """Return a breadcrumb for an `INameBlackList`."""
344+
345+ implements(IBreadcrumb)
346+
347+ @property
348+ def text(self):
349+ return self.context.regexp
350
351=== added file 'lib/lp/registry/browser/tests/nameblacklist-views.txt'
352--- lib/lp/registry/browser/tests/nameblacklist-views.txt 1970-01-01 00:00:00 +0000
353+++ lib/lp/registry/browser/tests/nameblacklist-views.txt 2010-11-12 06:04:05 +0000
354@@ -0,0 +1,96 @@
355+NameBlacklist pages
356+===================
357+
358+ >>> from zope.component import getUtility
359+ >>> from lp.testing.sampledata import ADMIN_EMAIL
360+ >>> from canonical.launchpad.interfaces.launchpad import (
361+ ... ILaunchpadCelebrities)
362+ >>> from lp.registry.interfaces.nameblacklist import INameBlacklistSet
363+ >>> name_blacklist_set = getUtility(INameBlacklistSet)
364+ >>> from canonical.launchpad.testing.pages import (
365+ ... extract_text, find_tag_by_id)
366+ >>> registry_experts = getUtility(ILaunchpadCelebrities).registry_experts
367+ >>> registry_expert = factory.makePerson()
368+ >>> login(ADMIN_EMAIL)
369+ >>> ignore = registry_experts.addMember(registry_expert, registry_expert)
370+
371+
372+View all
373+--------
374+
375+All the blacklisted regular expressions that filter pillar names and
376+person names can be seen on the /+nameblacklist page.
377+
378+ >>> login_person(registry_expert)
379+ >>> view = create_initialized_view(name_blacklist_set, '+index',
380+ ... principal=registry_expert)
381+ >>> print extract_text(find_tag_by_id(view.render(), 'blacklist'))
382+ Regular Expression Comment
383+ ^admin Edit blacklist expression
384+ blacklist Edit blacklist expression For testing purposes
385+
386+
387+Add expression to blacklist
388+---------------------------
389+
390+An invalid regular expression cannot be added.
391+
392+ >>> form = {
393+ ... 'field.regexp': u'(',
394+ ... 'field.comment': u'old-comment',
395+ ... 'field.actions.add': 'Add to blacklist',
396+ ... }
397+ >>> view = create_initialized_view(name_blacklist_set, '+add', form=form)
398+ >>> for error in view.errors:
399+ ... print error
400+ Invalid regular expression: unbalanced parenthesis
401+
402+A duplicate regular expression cannot be added.
403+
404+ >>> form['field.regexp'] = u'blacklist'
405+ >>> view = create_initialized_view(name_blacklist_set, '+add', form=form)
406+ >>> for error in view.errors:
407+ ... print error
408+ This regular expression already exists.
409+
410+After adding a regular expression, a notification will be displayed.
411+
412+ >>> form['field.regexp'] = u'foo'
413+ >>> view = create_initialized_view(name_blacklist_set, '+add', form=form)
414+ >>> for notification in view.request.response.notifications:
415+ ... print notification.message
416+ Regular expression "foo" has been added to the name blacklist.
417+
418+
419+Edit expression in blacklist
420+----------------------------
421+
422+When a regular expression is edited, it still must be valid.
423+
424+ >>> import transaction
425+ >>> transaction.commit()
426+ >>> foo_exp = name_blacklist_set.getByRegExp(u'foo')
427+ >>> form = {
428+ ... 'field.regexp': u'(',
429+ ... 'field.comment': u'new-comment',
430+ ... 'field.actions.change': 'Change',
431+ ... }
432+ >>> view = create_initialized_view(foo_exp, '+edit', form=form)
433+ >>> for error in view.errors:
434+ ... print error
435+ Invalid regular expression: unbalanced parenthesis
436+
437+It cannot changed to conflict with another regular expression.
438+
439+ >>> form['field.regexp'] = u'blacklist'
440+ >>> view = create_initialized_view(foo_exp, '+edit', form=form)
441+ >>> for error in view.errors:
442+ ... print error
443+ This regular expression already exists.
444+
445+Otherwise, the change will be successful.
446+
447+ >>> form['field.regexp'] = u'bar'
448+ >>> view = create_initialized_view(foo_exp, '+edit', form=form)
449+ >>> print foo_exp.regexp, foo_exp.comment
450+ bar new-comment
451
452=== modified file 'lib/lp/registry/browser/tests/test_peoplemerge.py'
453--- lib/lp/registry/browser/tests/test_peoplemerge.py 2010-11-01 20:39:17 +0000
454+++ lib/lp/registry/browser/tests/test_peoplemerge.py 2010-11-12 06:04:05 +0000
455@@ -128,7 +128,9 @@
456 # Super team memberships are removed.
457 self.target_team = getUtility(ILaunchpadCelebrities).registry_experts
458 super_team = self.factory.makeTeam()
459+ login_celebrity('admin')
460 self.dupe_team.join(super_team, self.dupe_team.teamowner)
461+ login_celebrity('registry_experts')
462 view = self.getView()
463 self.assertEqual([], view.errors)
464 self.assertEqual(self.target_team, self.dupe_team.merged)
465
466=== modified file 'lib/lp/registry/configure.zcml'
467--- lib/lp/registry/configure.zcml 2010-11-09 16:26:11 +0000
468+++ lib/lp/registry/configure.zcml 2010-11-12 06:04:05 +0000
469@@ -102,6 +102,29 @@
470 interface="lp.registry.interfaces.persontransferjob.IMembershipNotificationJob"/>
471 </class>
472
473+ <!-- INameBlacklist -->
474+ <securedutility
475+ class="lp.registry.model.nameblacklist.NameBlacklistSet"
476+ provides="lp.registry.interfaces.nameblacklist.INameBlacklistSet">
477+ <allow
478+ interface="lp.registry.interfaces.nameblacklist.INameBlacklistSet"/>
479+ </securedutility>
480+
481+ <class class="lp.registry.model.nameblacklist.NameBlacklistSet">
482+ <require
483+ permission="launchpad.Edit"
484+ interface="lp.registry.interfaces.nameblacklist.INameBlacklistSet"/>
485+ </class>
486+
487+ <class class="lp.registry.model.nameblacklist.NameBlacklist">
488+ <require
489+ permission="launchpad.View"
490+ interface="lp.registry.interfaces.nameblacklist.INameBlacklist"/>
491+ <require
492+ permission="launchpad.Edit"
493+ set_schema="lp.registry.interfaces.nameblacklist.INameBlacklist"/>
494+ </class>
495+
496 <!-- Location -->
497
498 <class
499
500=== added file 'lib/lp/registry/interfaces/nameblacklist.py'
501--- lib/lp/registry/interfaces/nameblacklist.py 1970-01-01 00:00:00 +0000
502+++ lib/lp/registry/interfaces/nameblacklist.py 2010-11-12 06:04:05 +0000
503@@ -0,0 +1,44 @@
504+# Copyright 2010 Canonical Ltd. This software is licensed under the
505+# GNU Affero General Public License version 3 (see the file LICENSE).
506+
507+"""NameBlacklist interfaces."""
508+
509+__metaclass__ = type
510+
511+__all__ = [
512+ 'INameBlacklist',
513+ 'INameBlacklistSet',
514+ ]
515+
516+from zope.interface import Interface
517+from zope.schema import (
518+ Int,
519+ Text,
520+ TextLine,
521+ )
522+
523+from canonical.launchpad import _
524+
525+
526+class INameBlacklist(Interface):
527+ """The interface for the NameBlacklist table."""
528+
529+ id = Int(title=_('ID'), required=True, readonly=True)
530+ regexp = TextLine(title=_('Regular expression'), required=True)
531+ comment = Text(title=_('Comment'), required=False)
532+
533+
534+class INameBlacklistSet(Interface):
535+ """The set of INameBlacklist objects."""
536+
537+ def getAll():
538+ """Return all the name blacklist expressions."""
539+
540+ def create(regexp, comment=None):
541+ """Create and return a new NameBlacklist with given arguments."""
542+
543+ def get(id):
544+ """Return the NameBlacklist with the given id or None."""
545+
546+ def getByRegExp(regexp):
547+ """Return the NameBlacklist with the given regexp or None."""
548
549=== added file 'lib/lp/registry/model/nameblacklist.py'
550--- lib/lp/registry/model/nameblacklist.py 1970-01-01 00:00:00 +0000
551+++ lib/lp/registry/model/nameblacklist.py 2010-11-12 06:04:05 +0000
552@@ -0,0 +1,70 @@
553+# Copyright 2010 Canonical Ltd. This software is licensed under the
554+# GNU Affero General Public License version 3 (see the file LICENSE).
555+
556+"""Classes for managing the NameBlacklist table."""
557+
558+__metaclass__ = type
559+__all__ = [
560+ 'NameBlacklist',
561+ 'NameBlacklistSet',
562+ ]
563+
564+
565+from storm.base import Storm
566+from storm.locals import (
567+ Int,
568+ Unicode,
569+ )
570+from zope.interface import implements
571+
572+from canonical.launchpad.interfaces.lpstorm import IStore
573+from lp.registry.interfaces.nameblacklist import (
574+ INameBlacklist,
575+ INameBlacklistSet,
576+ )
577+
578+
579+class NameBlacklist(Storm):
580+ """Class for the NameBlacklist table."""
581+
582+ implements(INameBlacklist)
583+
584+ __storm_table__ = 'NameBlacklist'
585+
586+ id = Int(primary=True)
587+ regexp = Unicode(name='regexp', allow_none=False)
588+ comment = Unicode(name='comment', allow_none=True)
589+
590+
591+class NameBlacklistSet:
592+ """Class for creating and retrieving NameBlacklist objects."""
593+
594+ implements(INameBlacklistSet)
595+
596+ def getAll(self):
597+ """See `INameBlacklistSet`."""
598+ store = IStore(NameBlacklist)
599+ return store.find(NameBlacklist).order_by(NameBlacklist.regexp)
600+
601+ def create(self, regexp, comment=None):
602+ """See `INameBlacklistSet`."""
603+ nameblacklist = NameBlacklist()
604+ nameblacklist.regexp = regexp
605+ nameblacklist.comment = comment
606+ store = IStore(NameBlacklist)
607+ store.add(nameblacklist)
608+ return nameblacklist
609+
610+ def get(self, id):
611+ """See `INameBlacklistSet`."""
612+ try:
613+ id = int(id)
614+ except ValueError:
615+ return None
616+ store = IStore(NameBlacklist)
617+ return store.find(NameBlacklist, NameBlacklist.id == id).one()
618+
619+ def getByRegExp(self, regexp):
620+ """See `INameBlacklistSet`."""
621+ store = IStore(NameBlacklist)
622+ return store.find(NameBlacklist, NameBlacklist.regexp == regexp).one()
623
624=== renamed file 'lib/canonical/launchpad/pagetests/standalone/xx-nameblacklist.txt' => 'lib/lp/registry/stories/object/xx-nameblacklist.txt'
625=== added file 'lib/lp/registry/templates/nameblacklists-index.pt'
626--- lib/lp/registry/templates/nameblacklists-index.pt 1970-01-01 00:00:00 +0000
627+++ lib/lp/registry/templates/nameblacklists-index.pt 2010-11-12 06:04:05 +0000
628@@ -0,0 +1,39 @@
629+<html
630+ xmlns="http://www.w3.org/1999/xhtml"
631+ xmlns:tal="http://xml.zope.org/namespaces/tal"
632+ xmlns:metal="http://xml.zope.org/namespaces/metal"
633+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
634+ xml:lang="en"
635+ lang="en"
636+ dir="ltr"
637+ metal:use-macro="view/macro:page/main_side"
638+ i18n:domain="launchpad">
639+
640+ <body>
641+
642+ <tal:side metal:fill-slot="side">
643+ <tal:menu replace="structure context/@@+global-actions" />
644+ </tal:side>
645+
646+ <div metal:fill-slot="main" class="main-portlet">
647+ <table id="blacklist" class="listing sortable">
648+ <thead>
649+ <th>Regular Expression</th>
650+ <th>Comment</th>
651+ </thead>
652+ <tbody>
653+ <tr tal:repeat="item context/getAll">
654+ <td>
655+ <tt tal:content="item/regexp"/>
656+ <tal:link replace="
657+ structure
658+ item/menu:overview/edit_blacklist_expression/fmt:icon"/>
659+ </td>
660+ <td tal:content="item/comment"/>
661+ </tr>
662+ </tbody>
663+ </table>
664+ </div>
665+
666+ </body>
667+</html>
668
669=== modified file 'lib/lp/registry/tests/test_nameblacklist.py'
670--- lib/lp/registry/tests/test_nameblacklist.py 2010-10-04 19:50:45 +0000
671+++ lib/lp/registry/tests/test_nameblacklist.py 2010-11-12 06:04:05 +0000
672@@ -5,42 +5,52 @@
673
674 __metaclass__ = type
675
676-import unittest
677-
678-from canonical.testing.layers import LaunchpadLayer
679-
680-
681-class TestNameBlacklist(unittest.TestCase):
682- layer = LaunchpadLayer
683+
684+from zope.component import getUtility
685+from zope.interface.verify import verifyObject
686+
687+from canonical.launchpad.interfaces.lpstorm import IStore
688+from canonical.launchpad.webapp.authorization import check_permission
689+from canonical.testing.layers import (
690+ DatabaseFunctionalLayer,
691+ ZopelessDatabaseLayer,
692+ )
693+from lp.registry.interfaces.nameblacklist import (
694+ INameBlacklist,
695+ INameBlacklistSet,
696+ )
697+from lp.testing import (
698+ ANONYMOUS,
699+ login,
700+ login_celebrity,
701+ TestCaseWithFactory,
702+ )
703+
704+
705+class TestNameBlacklist(TestCaseWithFactory):
706+ layer = ZopelessDatabaseLayer
707
708 def setUp(self):
709- self.con = self.layer.connect()
710- self.cur = self.con.cursor()
711-
712- # Create a couple of blacklist entries
713- self.cur.execute("""
714- INSERT INTO NameBlacklist(id, regexp) VALUES (-200, '^foo')
715- """)
716- self.cur.execute("""
717- INSERT INTO NameBlacklist(id, regexp) VALUES (-100, 'foo')
718- """)
719- self.cur.execute("""
720- INSERT INTO NameBlacklist(id, regexp) VALUES (-50, 'v e r b o s e')
721- """)
722-
723- def tearDown(self):
724- self.con.close()
725+ super(TestNameBlacklist, self).setUp()
726+ self.name_blacklist_set = getUtility(INameBlacklistSet)
727+ self.caret_foo_exp = self.name_blacklist_set.create(u'^foo')
728+ self.foo_exp = self.name_blacklist_set.create(u'foo')
729+ self.verbose_exp = self.name_blacklist_set.create(u'v e r b o s e')
730+ self.store = IStore(self.foo_exp)
731+ self.store.flush()
732
733 def name_blacklist_match(self, name):
734 '''Return the result of the name_blacklist_match stored procedure.'''
735- self.cur.execute("SELECT name_blacklist_match(%(name)s)", vars())
736- return self.cur.fetchone()[0]
737+ result = self.store.execute(
738+ "SELECT name_blacklist_match(%s)", (name,))
739+ return result.get_one()[0]
740
741 def is_blacklisted_name(self, name):
742 '''Call the is_blacklisted_name stored procedure and return the result
743 '''
744- self.cur.execute("SELECT is_blacklisted_name(%(name)s)", vars())
745- blacklisted = self.cur.fetchone()[0]
746+ result = self.store.execute(
747+ "SELECT is_blacklisted_name(%s)", (name,))
748+ blacklisted = result.get_one()[0]
749 self.failIf(blacklisted is None, 'is_blacklisted_name returned NULL')
750 return bool(blacklisted)
751
752@@ -52,21 +62,25 @@
753 # A name that is blacklisted returns the id of the row in the
754 # NameBlacklist table that matched. Rows are tried in order, and the
755 # first match is returned.
756- self.failUnlessEqual(self.name_blacklist_match("foobar"), -200)
757- self.failUnlessEqual(self.name_blacklist_match("barfoo"), -100)
758+ self.assertEqual(
759+ self.name_blacklist_match("foobar"),
760+ self.caret_foo_exp.id)
761+ self.assertEqual(
762+ self.name_blacklist_match("barfoo"),
763+ self.foo_exp.id)
764
765 def test_name_blacklist_match_cache(self):
766 # If the blacklist is changed in the DB, these changes are noticed.
767 # This test is needed because the stored procedure keeps a cache
768 # of the compiled regular expressions.
769- self.failUnlessEqual(self.name_blacklist_match("foobar"), -200)
770- self.cur.execute(
771- "UPDATE NameBlacklist SET regexp='nomatch' where id=-200"
772- )
773- self.failUnlessEqual(self.name_blacklist_match("foobar"), -100)
774- self.cur.execute(
775- "UPDATE NameBlacklist SET regexp='nomatch2' where id=-100"
776- )
777+ self.assertEqual(
778+ self.name_blacklist_match("foobar"),
779+ self.caret_foo_exp.id)
780+ self.caret_foo_exp.regexp = u'nomatch'
781+ self.assertEqual(
782+ self.name_blacklist_match("foobar"),
783+ self.foo_exp.id)
784+ self.foo_exp.regexp = u'nomatch2'
785 self.failUnless(self.name_blacklist_match("foobar") is None)
786
787 def test_is_blacklisted_name(self):
788@@ -74,7 +88,8 @@
789 # that is friendlier to use in a boolean context.
790 self.failUnless(self.is_blacklisted_name("bar") is False)
791 self.failUnless(self.is_blacklisted_name("foo") is True)
792- self.cur.execute("UPDATE NameBlacklist SET regexp='bar' || regexp")
793+ self.caret_foo_exp.regexp = u'bar'
794+ self.foo_exp.regexp = u'bar2'
795 self.failUnless(self.is_blacklisted_name("foo") is False)
796
797 def test_case_insensitive(self):
798@@ -85,5 +100,82 @@
799 self.failUnless(self.is_blacklisted_name("verbose") is True)
800
801
802-def test_suite():
803- return unittest.TestLoader().loadTestsFromName(__name__)
804+class TestNameBlacklistSet(TestCaseWithFactory):
805+
806+ layer = DatabaseFunctionalLayer
807+
808+ def setUp(self):
809+ super(TestNameBlacklistSet, self).setUp()
810+ login_celebrity('registry_experts')
811+ self.name_blacklist_set = getUtility(INameBlacklistSet)
812+
813+ def test_create_with_one_arg(self):
814+ # Test NameBlacklistSet.create(regexp).
815+ name_blacklist = self.name_blacklist_set.create(u'foo')
816+ self.assertTrue(verifyObject(INameBlacklist, name_blacklist))
817+ self.assertEqual(u'foo', name_blacklist.regexp)
818+ self.assertIs(None, name_blacklist.comment)
819+
820+ def test_create_with_two_args(self):
821+ # Test NameBlacklistSet.create(regexp, comment).
822+ name_blacklist = self.name_blacklist_set.create(u'foo', u'bar')
823+ self.assertTrue(verifyObject(INameBlacklist, name_blacklist))
824+ self.assertEqual(u'foo', name_blacklist.regexp)
825+ self.assertEqual(u'bar', name_blacklist.comment)
826+
827+ def test_get_int(self):
828+ # Test NameBlacklistSet.get() with int id.
829+ name_blacklist = self.name_blacklist_set.create(u'foo', u'bar')
830+ store = IStore(name_blacklist)
831+ store.flush()
832+ retrieved = self.name_blacklist_set.get(name_blacklist.id)
833+ self.assertEqual(name_blacklist, retrieved)
834+
835+ def test_get_string(self):
836+ # Test NameBlacklistSet.get() with string id.
837+ name_blacklist = self.name_blacklist_set.create(u'foo', u'bar')
838+ store = IStore(name_blacklist)
839+ store.flush()
840+ retrieved = self.name_blacklist_set.get(str(name_blacklist.id))
841+ self.assertEqual(name_blacklist, retrieved)
842+
843+ def test_get_returns_None_instead_of_ValueError(self):
844+ # Test that NameBlacklistSet.get() will return None instead of
845+ # raising a ValueError when it tries to cast the id to an int,
846+ # so that traversing an invalid url causes a Not Found error
847+ # instead of an error that is recorded as an oops.
848+ self.assertIs(None, self.name_blacklist_set.get('asdf'))
849+
850+ def test_getAll(self):
851+ # Test NameBlacklistSet.getAll().
852+ result = [
853+ (item.regexp, item.comment)
854+ for item in self.name_blacklist_set.getAll()]
855+ expected = [
856+ ('^admin', None),
857+ ('blacklist', 'For testing purposes'),
858+ ]
859+ self.assertEqual(expected, result)
860+
861+ def test_NameBlacklistSet_permissions(self):
862+ # Verify that non-registry-experts do not have permission to
863+ # access the NameBlacklistSet.
864+ self.assertTrue(
865+ check_permission('launchpad.View', self.name_blacklist_set))
866+ self.assertTrue(
867+ check_permission('launchpad.Edit', self.name_blacklist_set))
868+ login(ANONYMOUS)
869+ self.assertFalse(
870+ check_permission('launchpad.View', self.name_blacklist_set))
871+ self.assertFalse(
872+ check_permission('launchpad.Edit', self.name_blacklist_set))
873+
874+ def test_NameBlacklist_permissions(self):
875+ # Verify that non-registry-experts do not have permission to
876+ # access the NameBlacklist.
877+ name_blacklist = self.name_blacklist_set.create(u'foo')
878+ self.assertTrue(check_permission('launchpad.View', name_blacklist))
879+ self.assertTrue(check_permission('launchpad.Edit', name_blacklist))
880+ login(ANONYMOUS)
881+ self.assertFalse(check_permission('launchpad.View', name_blacklist))
882+ self.assertFalse(check_permission('launchpad.Edit', name_blacklist))
883
884=== modified file 'lib/lp/soyuz/tests/test_archive_subscriptions.py'
885--- lib/lp/soyuz/tests/test_archive_subscriptions.py 2010-10-26 15:47:24 +0000
886+++ lib/lp/soyuz/tests/test_archive_subscriptions.py 2010-11-12 06:04:05 +0000
887@@ -72,6 +72,11 @@
888 with celebrity_logged_in('commercial_admin'):
889 self.archive.commercial = True
890
891+ # Logging in as a celebrity team causes an email to be sent
892+ # because a person is added as a member of the team, so this
893+ # needs to be cleared out before calling newSubscription().
894+ pop_notifications()
895+
896 self.archive.newSubscription(
897 self.subscriber, registrant=self.archive.owner)
898
899
900=== modified file 'lib/lp/testing/_login.py'
901--- lib/lp/testing/_login.py 2010-10-26 15:47:24 +0000
902+++ lib/lp/testing/_login.py 2010-11-12 06:04:05 +0000
903@@ -23,7 +23,6 @@
904 ]
905
906 from contextlib import contextmanager
907-import random
908
909 from zope.component import getUtility
910 from zope.security.management import endInteraction
911@@ -39,10 +38,12 @@
912 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
913 from canonical.launchpad.webapp.vhosts import allvhosts
914 from lp.services.utils import decorate_with
915+from lp.testing.sampledata import ADMIN_EMAIL
916
917
918 _logged_in = False
919
920+
921 def is_logged_in():
922 global _logged_in
923 return _logged_in
924@@ -93,24 +94,15 @@
925 setupInteractionForPerson(person, participation)
926
927
928-def _get_arbitrary_team_member(team):
929- """Get an arbitrary member of 'team'.
930-
931- :param team: An `ITeam`.
932- """
933- # Set up the interaction.
934- login(ANONYMOUS)
935- return random.choice(list(team.allmembers))
936-
937-
938 def login_team(team, participation=None):
939 """Login as a member of 'team'."""
940- # This check isn't strictly necessary (it depends on the implementation of
941- # _get_arbitrary_team_member), but this gives us a nice error message,
942- # which can save time when debugging.
943+ # Prevent import loop.
944+ from lp.testing.factory import LaunchpadObjectFactory
945 if not team.is_team:
946 raise ValueError("Got person, expected team: %r" % (team,))
947- person = _get_arbitrary_team_member(team)
948+ login(ADMIN_EMAIL)
949+ person = LaunchpadObjectFactory().makePerson()
950+ team.addMember(person, person)
951 login_person(person, participation=participation)
952 return person
953