Merge lp:~edwin-grubbs/launchpad/bug-553384-deactivated-project-oops into lp:launchpad

Proposed by Edwin Grubbs
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~edwin-grubbs/launchpad/bug-553384-deactivated-project-oops
Merge into: lp:launchpad
Diff against target: 654 lines (+190/-101)
20 files modified
lib/lp/answers/doc/person.txt (+4/-0)
lib/lp/blueprints/doc/specification.txt (+12/-8)
lib/lp/blueprints/doc/sprint.txt (+12/-8)
lib/lp/bugs/browser/bugalsoaffects.py (+0/-20)
lib/lp/bugs/browser/tests/bugtask-adding-views.txt (+0/-36)
lib/lp/registry/browser/product.py (+18/-4)
lib/lp/registry/browser/tests/product-views.txt (+23/-5)
lib/lp/registry/doc/milestone.txt (+4/-0)
lib/lp/registry/doc/person.txt (+4/-0)
lib/lp/registry/doc/pillar.txt (+3/-0)
lib/lp/registry/doc/product.txt (+9/-3)
lib/lp/registry/doc/project.txt (+9/-1)
lib/lp/registry/model/product.py (+12/-1)
lib/lp/registry/stories/pillar/xx-pillar-deactivation.txt (+15/-8)
lib/lp/registry/stories/product/xx-product-edit.txt (+7/-7)
lib/lp/registry/stories/project/xx-project-index.txt (+6/-0)
lib/lp/registry/tests/test_product.py (+28/-0)
lib/lp/testing/__init__.py (+14/-0)
lib/lp/translations/doc/translationimportqueue.txt (+3/-0)
lib/lp/translations/stories/translationgroups/30-show-group-translation-targets.txt (+7/-0)
To merge this branch: bzr merge lp:~edwin-grubbs/launchpad/bug-553384-deactivated-project-oops
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Brad Crittenden (community) code Approve
Review via email: mp+23794@code.launchpad.net

Commit message

Don't let a project linked to source packages be deactivated, since it will cause an oops on the $sourcepackage/+edit-packaging page.

Description of the change

Summary
-------

Fixed bug 553384 and bug 140526.

The $sourcepackage/+edit-packaging has an oops when it is linked to an
inactive project. To prevent this, it should not be possible to deactivate
a project until all its links to source packages have been removed.

Implementation details
----------------------

Don't allow deactivation of products linked to source packages in the
model or in the views.
    lib/lp/registry/model/product.py
    lib/lp/registry/browser/product.py
    lib/lp/registry/browser/tests/product-views.txt
    lib/lp/registry/tests/test_product.py

Removed workaround for bug 140526:
    lib/lp/bugs/browser/bugalsoaffects.py
    lib/lp/bugs/browser/tests/bugtask-adding-views.txt

Fixed tests:
    lib/lp/testing/__init__.py
    lib/lp/answers/doc/person.txt
    lib/lp/blueprints/doc/specification.txt
    lib/lp/blueprints/doc/sprint.txt
    lib/lp/registry/doc/milestone.txt
    lib/lp/registry/doc/person.txt
    lib/lp/registry/doc/pillar.txt
    lib/lp/registry/doc/product.txt
    lib/lp/registry/doc/project.txt
    lib/lp/registry/stories/pillar/xx-pillar-deactivation.txt
    lib/lp/registry/stories/product/xx-product-edit.txt
    lib/lp/registry/stories/project/xx-project-index.txt
    lib/lp/translations/doc/translationimportqueue.txt
    lib/lp/translations/stories/translationgroups/30-show-group-translation-targets.txt

Tests
-----

./bin/test -vv -t 'test_product|product-views.txt|xx-product-edit|doc/product.txt'

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

* Open http://launchpad.dev/firefox/+review-license
  * Uncheck the "active" input.
  * Click "Change"
  * The "active" field should have the error message:
    "This project cannot be deactivated since it is still linked to
    source packages."
* Open http://launchpad.dev/firefox/+admin
  * Uncheck the "active" input.
  * Click "Change"
  * The "active" field should have the error message:
    "This project cannot be deactivated since it is still linked to
    source packages."

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (27.1 KiB)

Hi Edwin,

This is nice, and it's good to see some XXXs go in the process. The
view code with the Storm validator as a backstop is elegant.

I've got a few questions and comments, so Needs Information for now.

Cheers, Gavin.

> === modified file 'lib/lp/answers/doc/person.txt'
> --- lib/lp/answers/doc/person.txt 2010-02-05 21:25:23 +0000
> +++ lib/lp/answers/doc/person.txt 2010-04-21 11:03:32 +0000
> @@ -351,6 +351,10 @@
> supported projects.
>
> >>> login('<email address hidden>')
> +
> + # A product cannot be deactivated if it is linked to source packages.
> + >>> from lp.testing import unlink_source_packages
> + >>> unlink_source_packages(firefox)
> >>> firefox.active = False
> >>> sorted(target.name
> ... for target in no_priv.getDirectAnswerQuestionTargets())
>
> === modified file 'lib/lp/blueprints/doc/specification.txt'
> --- lib/lp/blueprints/doc/specification.txt 2009-08-13 19:03:36 +0000
> +++ lib/lp/blueprints/doc/specification.txt 2010-04-21 11:03:32 +0000
> @@ -205,14 +205,18 @@
>
> Specs from inactive products are filtered out.
>
> - >>> from canonical.database.sqlbase import flush_database_updates
> - >>> login('<email address hidden>')
> - >>> upstream_firefox.active = False
> - >>> flush_database_updates()
> - >>> for spec in specset.specifications(filter=['install']):
> - ... print spec.name, spec.target.name
> - cluster-installation kubuntu
> - media-integrity-check ubuntu
> + >>> from canonical.database.sqlbase import flush_database_updates
> + >>> login('<email address hidden>')
> +
> + # A product cannot be deactivated if it is linked to source packages.
> + >>> from lp.testing import unlink_source_packages
> + >>> unlink_source_packages(upstream_firefox)
> + >>> upstream_firefox.active = False
> + >>> flush_database_updates()
> + >>> for spec in specset.specifications(filter=['install']):
> + ... print spec.name, spec.target.name
> + cluster-installation kubuntu
> + media-integrity-check ubuntu
>
>
> Reset firefox so we don't mess up later tests.
>
> === modified file 'lib/lp/blueprints/doc/sprint.txt'
> --- lib/lp/blueprints/doc/sprint.txt 2010-02-17 11:13:06 +0000
> +++ lib/lp/blueprints/doc/sprint.txt 2010-04-21 11:03:32 +0000
> @@ -151,14 +151,18 @@
>
> Inactive products are excluded from the listings.
>
> - >>> from canonical.launchpad.interfaces import IProductSet
> - >>> from canonical.launchpad.ftests import login
> - >>> firefox = getUtility(IProductSet).getByName('firefox')
> - >>> login("<email address hidden>")
> - >>> firefox.active = False
> - >>> flush_database_updates()
> - >>> ubz.specifications().count()
> - 0
> + >>> from canonical.launchpad.interfaces import IProductSet
> + >>> from canonical.launchpad.ftests import login
> + >>> firefox = getUtility(IProductSet).getByName('firefox')
> + >>> login("<email address hidden>")
> +
> + # A product cannot be deactivated if it is linked to source packages.
> + >>> from lp.testing import unlink_source_packages
> + >>> unlink_source_packages(firefox)
> + >>> firefox.active = False
> + >>> flush_database_updates()
> + >>> ubz.specifications().count()
> + 0
>
> ...

review: Needs Information (code)
Revision history for this message
Brad Crittenden (bac) wrote :
Download full text (7.0 KiB)

Hi Edwin,

Thanks for making this fix...it sure had lots of tentacles.

> === modified file 'lib/lp/answers/doc/person.txt'
> --- lib/lp/answers/doc/person.txt 2010-02-05 21:25:23 +0000
+++ lib/lp/answers/doc/person.txt 2010-04-20 20:36:22 +0000
> @@ -351,6 +351,10 @@
> supported projects.
>
> >>> login('<email address hidden>')
> +
> + # A product cannot be deactivated if it is linked to source packages.

I think this comment would be easier to read in context if it were
phrased positively, i.e.

# Unlink the source packages so the firefox project can be deactivated.

I see you've repeated the phrase many, many times in your changes.
I'll leave it up to you to decide whether you want to make the wording
change everywhere as it is only a minor improvement.

> + >>> from lp.testing import unlink_source_packages
> + >>> unlink_source_packages(firefox)
> >>> firefox.active = False
> >>> sorted(target.name
> ... for target in no_priv.getDirectAnswerQuestionTargets())

> === modified file 'lib/lp/bugs/browser/bugalsoaffects.py'
> --- lib/lp/bugs/browser/bugalsoaffects.py 2010-01-15 03:32:46 +0000
> +++ lib/lp/bugs/browser/bugalsoaffects.py 2010-04-20 20:36:22 +0000
> @@ -103,26 +103,6 @@
> bugtask = self.context
> upstream = bugtask.target.upstream_product
> if upstream is not None:
> - if not upstream.active:
> - # XXX: Guilherme Salgado 2007-09-18 bug=140526: This is only
> - # possible because of bug 140526, which allows packages to
> - # be linked to inactive products.
> - series = bugtask.distribution.currentseries
> - assert series is not None, (
> - "This package is linked to a product series so this "
> - "package's distribution must have at least one distro "
> - "series.")
> - sourcepackage = series.getSourcePackage(
> - bugtask.sourcepackagename)
> - self.request.response.addWarningNotification(
> - structured(
> - _("""
> - This package is linked to an inactive upstream. You
> - can <a href="%(package_url)s/+edit-packaging">fix it</a>
> - to avoid this step in the future."""),
> - package_url=canonical_url(sourcepackage)))
> - return
> -

I'm really glad you found and removed these.

> try:
> valid_upstreamtask(bugtask.bug, upstream)
> except WidgetsError:

> === modified file 'lib/lp/registry/browser/product.py'
> --- lib/lp/registry/browser/product.py 2010-04-14 17:16:56 +0000
> +++ lib/lp/registry/browser/product.py 2010-04-20 20:36:22 +0000
> @@ -1444,6 +1444,15 @@
> """See `LaunchpadFormView`."""
> self.validate_private_bugs(data)
>
> + if data['active'] == False and self.context.active == True:
> + if len(self.context.sourcepackages) > 0:
> + self.setFieldError('active',
> + structured(
> + 'This project cannot be dea...

Read more...

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

=== modified file 'lib/lp/answers/doc/person.txt'
--- lib/lp/answers/doc/person.txt 2010-04-20 20:27:09 +0000
+++ lib/lp/answers/doc/person.txt 2010-04-21 16:05:41 +0000
@@ -352,7 +352,7 @@

     >>> login('<email address hidden>')

- # A product cannot be deactivated if it is linked to source packages.
+ # Unlink the source packages so the project can be deactivated.
     >>> from lp.testing import unlink_source_packages
     >>> unlink_source_packages(firefox)
     >>> firefox.active = False

=== modified file 'lib/lp/blueprints/doc/specification.txt'
--- lib/lp/blueprints/doc/specification.txt 2010-04-20 20:27:09 +0000
+++ lib/lp/blueprints/doc/specification.txt 2010-04-21 16:05:41 +0000
@@ -208,7 +208,7 @@
     >>> from canonical.database.sqlbase import flush_database_updates
     >>> login('<email address hidden>')

- # A product cannot be deactivated if it is linked to source packages.
+ # Unlink the source packages so the project can be deactivated.
     >>> from lp.testing import unlink_source_packages
     >>> unlink_source_packages(upstream_firefox)
     >>> upstream_firefox.active = False

=== modified file 'lib/lp/blueprints/doc/sprint.txt'
--- lib/lp/blueprints/doc/sprint.txt 2010-04-20 20:27:09 +0000
+++ lib/lp/blueprints/doc/sprint.txt 2010-04-21 16:05:41 +0000
@@ -156,7 +156,7 @@
     >>> firefox = getUtility(IProductSet).getByName('firefox')
     >>> login("<email address hidden>")

- # A product cannot be deactivated if it is linked to source packages.
+ # Unlink the source packages so the project can be deactivated.
     >>> from lp.testing import unlink_source_packages
     >>> unlink_source_packages(firefox)
     >>> firefox.active = False

=== modified file 'lib/lp/registry/browser/product.py'
--- lib/lp/registry/browser/product.py 2010-04-19 20:03:36 +0000
+++ lib/lp/registry/browser/product.py 2010-04-21 14:34:21 +0000
@@ -1376,7 +1376,7 @@
         return self.next_url

-class EditPrivateBugsMixin:
+class ProductValidationMixin:

     def validate_private_bugs(self, data):
         """Perform validation for the private bugs setting."""
@@ -1387,8 +1387,20 @@
                     'for this project first.',
                     canonical_url(self.context, rootsite="bugs")))

-
-class ProductAdminView(ProductEditView, EditPrivateBugsMixin):
+ def validate_deactivation(self, data):
+ """Verify whether a product can be safely deactivated."""
+ if data['active'] == False and self.context.active == True:
+ if len(self.context.sourcepackages) > 0:
+ self.setFieldError('active',
+ structured(
+ 'This project cannot be deactivated since it is '
+ 'linked to one or more '
+ '<a href="%s">source packages</a>.',
+ canonical_url(self.context, view_name='+packages')))
+
+
+class ProductAdminView(ProductEditView, ProductValidationMixin):
+ """View for $project/+admin"""
     label = "Administer project details"
     field_names = ["name", "owner", "active", "autoupdate", "private_bugs"]

@@ -1443,15 +1455,7 @@
     def validate(self, data):
         """See `La...

Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :
Download full text (11.4 KiB)

> Hi Edwin,
>
> This is nice, and it's good to see some XXXs go in the process. The
> view code with the Storm validator as a backstop is elegant.
>
> I've got a few questions and comments, so Needs Information for now.
>
> Cheers, Gavin.
>

Hi Gavin and Brad,

Thanks for the reviews. The incremental diff is in the previous comment.

> > === modified file 'lib/lp/registry/browser/product.py'
> > --- lib/lp/registry/browser/product.py 2010-04-14 17:16:56 +0000
> > +++ lib/lp/registry/browser/product.py 2010-04-21 11:03:32 +0000
> > @@ -1444,6 +1444,15 @@
> > """See `LaunchpadFormView`."""
> > self.validate_private_bugs(data)
> >
> > + if data['active'] == False and self.context.active == True:
> > + if len(self.context.sourcepackages) > 0:
> > + self.setFieldError('active',
> > + structured(
> > + 'This project cannot be deactivated since it is '
> > + 'linked to '
> > + '<a href="%s/+packages">source packages</a>.',
> > + canonical_url(self.context)))
>
> You could change this to instead pass view_name='+packages' to
> canonical_url().

Fixed.

> It is worth showing this message before the user tries to deactivate
> the project? Perhaps a message next to the control, and the control
> disabled.

That's a nice idea, however, the validation would still be necessary for
race conditions when the form is displayed before the source package is
linked. Since deactivation will be done much more often by reviewers
than regular users, I don't know if it is worth it. I believe Curtis
still reviews most of the projects, so I'll ask him.

> Also, the text "This project cannot be deactivated since it is linked
> to source packages" doesn't sound quite right. How about "... linked
> to one or more source packages"?

Fixed.

> Perhaps it's worth getting a UI review?

Since I'm already talking to Curtis, and he's a UI reviewer, I'll ask him.

> > +
> > @property
> > def cancel_url(self):
> > """See `LaunchpadFormView`."""
> > @@ -1491,6 +1500,15 @@
> > # supervisor.
> > self.validate_private_bugs(data)
> >
> > + if data['active'] == False and self.context.active == True:
> > + if len(self.context.sourcepackages) > 0:
> > + self.setFieldError('active',
> > + structured(
> > + 'This project cannot be deactivated since it is '
> > + 'linked to '
> > + '<a href="%s/+packages">source packages</a>.',
> > + canonical_url(self.context)))
>
> Same here, re. view_name.
>
> ProductReviewLicenseView.validate() uses the same validation code as
> in ProductAdminView.validate(). Consider putting this in a mixin class
> that both ProductAdminView and ProductReviewLicenseView can use.

Done.

> > +
> >
> > class ProductAddSeriesView(LaunchpadFormView):
> > """A form to add new product series"""
> >
> > === modified file 'lib/lp/registry/tests/test_product.py'
> > --- lib/lp/registry/tests/test_product.py 2...

Revision history for this message
Gavin Panella (allenap) wrote :

Thanks Edwin, looks good :)

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

Hello Edwin, et al.

Regarding who and how projects are deactivated...

Users cannot deactivate a project, reviewers and admins can. The rule of not deactivating a linked project seemed obvious 6 months ago and I have always deleted bad packaging links /before/ deactivating a project. It is clear from the oops this was not obvious to everyone so I reported a bug to stop other reviewers from doing the wrong thing.

I do not deactivate projects with legitimate links to Ubuntu. This has frustrated two users who think their desires outweigh the community. We *will not* deactivate any linked project because someone else will just have it reactivated.

This issue relates to series deletion. Users cannot delete a series that is linked to a package. The user can delete the packaing links. In the case of the sugar project, the user recreated links for the correct series.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/answers/doc/person.txt'
2--- lib/lp/answers/doc/person.txt 2010-02-05 21:25:23 +0000
3+++ lib/lp/answers/doc/person.txt 2010-04-22 14:31:33 +0000
4@@ -351,6 +351,10 @@
5 supported projects.
6
7 >>> login('foo.bar@canonical.com')
8+
9+ # Unlink the source packages so the project can be deactivated.
10+ >>> from lp.testing import unlink_source_packages
11+ >>> unlink_source_packages(firefox)
12 >>> firefox.active = False
13 >>> sorted(target.name
14 ... for target in no_priv.getDirectAnswerQuestionTargets())
15
16=== modified file 'lib/lp/blueprints/doc/specification.txt'
17--- lib/lp/blueprints/doc/specification.txt 2009-08-13 19:03:36 +0000
18+++ lib/lp/blueprints/doc/specification.txt 2010-04-22 14:31:33 +0000
19@@ -205,14 +205,18 @@
20
21 Specs from inactive products are filtered out.
22
23- >>> from canonical.database.sqlbase import flush_database_updates
24- >>> login('mark@example.com')
25- >>> upstream_firefox.active = False
26- >>> flush_database_updates()
27- >>> for spec in specset.specifications(filter=['install']):
28- ... print spec.name, spec.target.name
29- cluster-installation kubuntu
30- media-integrity-check ubuntu
31+ >>> from canonical.database.sqlbase import flush_database_updates
32+ >>> login('mark@example.com')
33+
34+ # Unlink the source packages so the project can be deactivated.
35+ >>> from lp.testing import unlink_source_packages
36+ >>> unlink_source_packages(upstream_firefox)
37+ >>> upstream_firefox.active = False
38+ >>> flush_database_updates()
39+ >>> for spec in specset.specifications(filter=['install']):
40+ ... print spec.name, spec.target.name
41+ cluster-installation kubuntu
42+ media-integrity-check ubuntu
43
44
45 Reset firefox so we don't mess up later tests.
46
47=== modified file 'lib/lp/blueprints/doc/sprint.txt'
48--- lib/lp/blueprints/doc/sprint.txt 2010-02-17 11:13:06 +0000
49+++ lib/lp/blueprints/doc/sprint.txt 2010-04-22 14:31:33 +0000
50@@ -151,14 +151,18 @@
51
52 Inactive products are excluded from the listings.
53
54- >>> from canonical.launchpad.interfaces import IProductSet
55- >>> from canonical.launchpad.ftests import login
56- >>> firefox = getUtility(IProductSet).getByName('firefox')
57- >>> login("foo.bar@canonical.com")
58- >>> firefox.active = False
59- >>> flush_database_updates()
60- >>> ubz.specifications().count()
61- 0
62+ >>> from canonical.launchpad.interfaces import IProductSet
63+ >>> from canonical.launchpad.ftests import login
64+ >>> firefox = getUtility(IProductSet).getByName('firefox')
65+ >>> login("foo.bar@canonical.com")
66+
67+ # Unlink the source packages so the project can be deactivated.
68+ >>> from lp.testing import unlink_source_packages
69+ >>> unlink_source_packages(firefox)
70+ >>> firefox.active = False
71+ >>> flush_database_updates()
72+ >>> ubz.specifications().count()
73+ 0
74
75 Reset firefox so we don't mess up later tests.
76
77
78=== modified file 'lib/lp/bugs/browser/bugalsoaffects.py'
79--- lib/lp/bugs/browser/bugalsoaffects.py 2010-01-15 03:32:46 +0000
80+++ lib/lp/bugs/browser/bugalsoaffects.py 2010-04-22 14:31:33 +0000
81@@ -103,26 +103,6 @@
82 bugtask = self.context
83 upstream = bugtask.target.upstream_product
84 if upstream is not None:
85- if not upstream.active:
86- # XXX: Guilherme Salgado 2007-09-18 bug=140526: This is only
87- # possible because of bug 140526, which allows packages to
88- # be linked to inactive products.
89- series = bugtask.distribution.currentseries
90- assert series is not None, (
91- "This package is linked to a product series so this "
92- "package's distribution must have at least one distro "
93- "series.")
94- sourcepackage = series.getSourcePackage(
95- bugtask.sourcepackagename)
96- self.request.response.addWarningNotification(
97- structured(
98- _("""
99- This package is linked to an inactive upstream. You
100- can <a href="%(package_url)s/+edit-packaging">fix it</a>
101- to avoid this step in the future."""),
102- package_url=canonical_url(sourcepackage)))
103- return
104-
105 try:
106 valid_upstreamtask(bugtask.bug, upstream)
107 except WidgetsError:
108
109=== modified file 'lib/lp/bugs/browser/tests/bugtask-adding-views.txt'
110--- lib/lp/bugs/browser/tests/bugtask-adding-views.txt 2009-07-06 16:23:49 +0000
111+++ lib/lp/bugs/browser/tests/bugtask-adding-views.txt 2010-04-22 14:31:33 +0000
112@@ -162,42 +162,6 @@
113 >>> len(add_task_view.request.response.notifications)
114 0
115
116-XXX: Because of bug 140526, it's possible to have a package linked to a series
117-of an inactive upstream. In a case like that we must ask the user to specify
118-the upstream, but we also display a warning telling the user there's a
119-package linked to an inactive upstream. -- Guilherme Salgado, 2007-09-18
120-
121- # Create the view manually just so we can get the correct upstream
122- # easily.
123- >>> from lp.bugs.browser.bugalsoaffects import (
124- ... ChooseProductStep)
125- >>> view = ChooseProductStep(
126- ... ubuntu_firefox_task, LaunchpadTestRequest(method='GET', form={}))
127- >>> upstream = view.context.target.upstream_product
128- >>> upstream.active
129- True
130-
131- # Mark the upstream as inactive.
132- >>> from zope.security.proxy import removeSecurityProxy
133- >>> removeSecurityProxy(upstream).active = False
134- >>> removeSecurityProxy(upstream).syncUpdate()
135-
136- >>> add_task_view = get_and_setup_view(
137- ... ubuntu_firefox_task, '+choose-affected-product', form={},
138- ... method='GET')
139-
140- >>> add_task_view.step_name
141- 'choose_product'
142- >>> print add_task_view.widgets['product']._getFormInput()
143- None
144- >>> for notification in add_task_view.request.response.notifications:
145- ... print notification.message.strip()
146- This package is linked to an inactive upstream...
147-
148- # Mark the upstream back as active.
149- >>> removeSecurityProxy(upstream).active = True
150- >>> removeSecurityProxy(upstream).syncUpdate()
151-
152 Let's take a look at the second step now, where we may enter the URL of
153 the remote bug and confirm the bugtask creation.
154 In order to show that all the events get fired off, let's create an
155
156=== modified file 'lib/lp/registry/browser/product.py'
157--- lib/lp/registry/browser/product.py 2010-04-14 17:16:56 +0000
158+++ lib/lp/registry/browser/product.py 2010-04-22 14:31:33 +0000
159@@ -1376,7 +1376,7 @@
160 return self.next_url
161
162
163-class EditPrivateBugsMixin:
164+class ProductValidationMixin:
165
166 def validate_private_bugs(self, data):
167 """Perform validation for the private bugs setting."""
168@@ -1387,8 +1387,20 @@
169 'for this project first.',
170 canonical_url(self.context, rootsite="bugs")))
171
172-
173-class ProductAdminView(ProductEditView, EditPrivateBugsMixin):
174+ def validate_deactivation(self, data):
175+ """Verify whether a product can be safely deactivated."""
176+ if data['active'] == False and self.context.active == True:
177+ if len(self.context.sourcepackages) > 0:
178+ self.setFieldError('active',
179+ structured(
180+ 'This project cannot be deactivated since it is '
181+ 'linked to one or more '
182+ '<a href="%s">source packages</a>.',
183+ canonical_url(self.context, view_name='+packages')))
184+
185+
186+class ProductAdminView(ProductEditView, ProductValidationMixin):
187+ """View for $project/+admin"""
188 label = "Administer project details"
189 field_names = ["name", "owner", "active", "autoupdate", "private_bugs"]
190
191@@ -1443,6 +1455,7 @@
192 def validate(self, data):
193 """See `LaunchpadFormView`."""
194 self.validate_private_bugs(data)
195+ self.validate_deactivation(data)
196
197 @property
198 def cancel_url(self):
199@@ -1451,7 +1464,7 @@
200
201
202 class ProductReviewLicenseView(ReturnToReferrerMixin,
203- ProductEditView, EditPrivateBugsMixin):
204+ ProductEditView, ProductValidationMixin):
205 """A view to review a project and change project privileges."""
206 label = "Review project"
207 field_names = [
208@@ -1490,6 +1503,7 @@
209 # Private bugs can only be enabled if the product has a bug
210 # supervisor.
211 self.validate_private_bugs(data)
212+ self.validate_deactivation(data)
213
214
215 class ProductAddSeriesView(LaunchpadFormView):
216
217=== modified file 'lib/lp/registry/browser/tests/product-views.txt'
218--- lib/lp/registry/browser/tests/product-views.txt 2010-04-14 20:54:40 +0000
219+++ lib/lp/registry/browser/tests/product-views.txt 2010-04-22 14:31:33 +0000
220@@ -155,9 +155,27 @@
221 ['license_reviewed', 'license_approved', 'active', 'private_bugs',
222 'reviewer_whiteboard']
223
224+The reviewer cannot deactivate a project if it is linked
225+to a source package.
226+
227+ >>> firefox.active
228+ True
229+
230+ >>> form = {
231+ ... 'field.active.used': '', # unchecked
232+ ... 'field.reviewer_whiteboard': 'Looks bogus',
233+ ... 'field.actions.change': 'Change',
234+ ... }
235+ >>> view = create_initialized_view(
236+ ... firefox, name='+review-license', form=form)
237+ >>> view.errors
238+ [...This project cannot be deactivated since it is linked to
239+ ...source packages</a>.']
240+
241 The reviewer can deactivate a project if he concludes it is bogus.
242
243- >>> firefox.active
244+ >>> product = factory.makeProduct(name='tomato', title='Tomato')
245+ >>> product.active
246 True
247
248 >>> form = {
249@@ -166,12 +184,12 @@
250 ... 'field.actions.change': 'Change',
251 ... }
252 >>> view = create_initialized_view(
253- ... firefox, name='+review-license', form=form)
254+ ... product, name='+review-license', form=form)
255 >>> view.errors
256 []
257- >>> firefox.active
258+ >>> product.active
259 False
260- >>> print firefox.reviewer_whiteboard
261+ >>> print product.reviewer_whiteboard
262 Looks bogus
263
264 The reviewer can enable privileged features like private bugs. He can
265@@ -439,8 +457,8 @@
266 officially supports.
267
268 >>> from canonical.launchpad.testing.pages import find_tag_by_id
269+ >>> product = factory.makeProduct(name='tomato', title='Tomato')
270
271- >>> product = factory.makeProduct(name='tomato', title='Tomato')
272 >>> owner = product.owner
273 >>> login_person(owner)
274 >>> question = factory.makeQuestion(target=product)
275
276=== modified file 'lib/lp/registry/doc/milestone.txt'
277--- lib/lp/registry/doc/milestone.txt 2010-04-16 15:06:55 +0000
278+++ lib/lp/registry/doc/milestone.txt 2010-04-22 14:31:33 +0000
279@@ -221,6 +221,10 @@
280
281 >>> print [milestone.name for milestone in gnome.milestones]
282 [u'1.1.']
283+
284+ # Unlink the source packages so the project can be deactivated.
285+ >>> from lp.testing import unlink_source_packages
286+ >>> unlink_source_packages(netapplet)
287 >>> netapplet.active = False
288 >>> syncUpdate(netapplet)
289 >>> print [milestone.name for milestone in gnome.milestones]
290
291=== modified file 'lib/lp/registry/doc/person.txt'
292--- lib/lp/registry/doc/person.txt 2010-04-18 17:20:47 +0000
293+++ lib/lp/registry/doc/person.txt 2010-04-22 14:31:33 +0000
294@@ -1344,6 +1344,10 @@
295 >>> from canonical.launchpad.ftests import login
296 >>> firefox = getUtility(IProductSet).getByName('firefox')
297 >>> login("mark@example.com")
298+
299+ # Unlink the source packages so the project can be deactivated.
300+ >>> from lp.testing import unlink_source_packages
301+ >>> unlink_source_packages(firefox)
302 >>> firefox.active = False
303 >>> flush_database_updates()
304 >>> cprov.specifications(filter=['svg']).count()
305
306=== modified file 'lib/lp/registry/doc/pillar.txt'
307--- lib/lp/registry/doc/pillar.txt 2010-04-19 15:13:39 +0000
308+++ lib/lp/registry/doc/pillar.txt 2010-04-22 14:31:33 +0000
309@@ -113,6 +113,9 @@
310
311 But only if the pillar which they point to is active.
312
313+ # Unlink the source packages so the project can be deactivated.
314+ >>> from lp.testing import unlink_source_packages
315+ >>> unlink_source_packages(firefox)
316 >>> firefox.active = False
317 >>> 'iceweasel' in pillar_set
318 False
319
320=== modified file 'lib/lp/registry/doc/product.txt'
321--- lib/lp/registry/doc/product.txt 2010-04-16 15:06:55 +0000
322+++ lib/lp/registry/doc/product.txt 2010-04-22 14:31:33 +0000
323@@ -54,7 +54,7 @@
324 >>> evo = p
325
326 To fetch a product we use IProductSet.getByName() or IProductSet.__getitem__.
327-The former will, by default, reeturn active and inactive products, while the
328+The former will, by default, return active and inactive products, while the
329 later returns only active ones. Both can be used to look up products by their
330 aliases, though.
331
332@@ -91,6 +91,9 @@
333
334 Now, to test the active flag. If we disabled a product:
335
336+ # Unlink the source packages so the project can be deactivated.
337+ >>> from lp.testing import unlink_source_packages
338+ >>> unlink_source_packages(a52dec)
339 >>> a52dec.active = False
340
341 It should no longer be retrievable via ProductSet's __getitem__:
342@@ -193,6 +196,9 @@
343
344 Only active products are listed as translatables.
345
346+ # Unlink the source packages so the project can be deactivated.
347+ >>> from lp.testing import unlink_source_packages
348+ >>> unlink_source_packages(evo)
349 >>> evo.active = False
350 >>> for product in productset.getTranslatables():
351 ... print product.name
352@@ -517,12 +523,12 @@
353
354 Only active products are returned.
355
356- >>> firefox.active = False
357+ >>> evo.active = False
358 >>> from canonical.launchpad.ftests import syncUpdate
359 >>> syncUpdate(firefox)
360 >>> for product in productset.getProductsWithBranches():
361 ... print product.name
362- evolution
363+ firefox
364 gnome-terminal
365 iso-codes
366 landscape
367
368=== modified file 'lib/lp/registry/doc/project.txt'
369--- lib/lp/registry/doc/project.txt 2010-04-19 09:39:29 +0000
370+++ lib/lp/registry/doc/project.txt 2010-04-22 14:31:33 +0000
371@@ -31,7 +31,7 @@
372 == Looking up existing projects ==
373
374 To fetch a project we use IProjectGroupSet.getByName() or
375-IProjectGroupSet.__getitem__. The former will, by default, reeturn active and
376+IProjectGroupSet.__getitem__. The former will, by default, return active and
377 inactive projects, while the latter returns only active ones. Both can be
378 used to look up projects by their aliases, though.
379
380@@ -108,6 +108,10 @@
381 [u'applets', u'evolution', u'gnome-terminal', u'gnomebaker', u'netapplet']
382
383 >>> netapplet = gnome.getProduct('netapplet')
384+
385+ # Unlink the source packages so the project can be deactivated.
386+ >>> from lp.testing import unlink_source_packages
387+ >>> unlink_source_packages(netapplet)
388 >>> netapplet.active = False
389 >>> flush_database_updates()
390 >>> [product.name for product in gnome.products]
391@@ -161,6 +165,10 @@
392
393 >>> from canonical.launchpad.interfaces import IProductSet
394 >>> firefox = getUtility(IProductSet).getByName('firefox')
395+
396+ # Unlink the source packages so the project can be deactivated.
397+ >>> from lp.testing import unlink_source_packages
398+ >>> unlink_source_packages(firefox)
399 >>> firefox.active = False
400 >>> flush_database_updates()
401 >>> filter = [SpecificationFilter.INCOMPLETE]
402
403=== modified file 'lib/lp/registry/model/product.py'
404--- lib/lp/registry/model/product.py 2010-04-19 09:39:29 +0000
405+++ lib/lp/registry/model/product.py 2010-04-22 14:31:33 +0000
406@@ -273,7 +273,6 @@
407
408 enable_bug_expiration = BoolCol(dbName='enable_bug_expiration',
409 notNull=True, default=False)
410- active = BoolCol(dbName='active', notNull=True, default=True)
411 license_reviewed = BoolCol(dbName='reviewed', notNull=True, default=False)
412 reviewer_whiteboard = StringCol(notNull=False, default=None)
413 private_bugs = BoolCol(
414@@ -290,6 +289,18 @@
415 bug_reporting_guidelines = StringCol(default=None)
416 _cached_licenses = None
417
418+ def _validate_active(self, attr, value):
419+ # Validate deactivation.
420+ if self.active == True and value == False:
421+ if len(self.sourcepackages) > 0:
422+ raise AssertionError(
423+ 'This project cannot be deactivated since it is '
424+ 'linked to source packages.')
425+ return value
426+
427+ active = BoolCol(dbName='active', notNull=True, default=True,
428+ storm_validator=_validate_active)
429+
430 def _validate_license_info(self, attr, value):
431 if not self._SO_creating and value != self.license_info:
432 # Clear the license_reviewed and license_approved flags
433
434=== modified file 'lib/lp/registry/stories/pillar/xx-pillar-deactivation.txt'
435--- lib/lp/registry/stories/pillar/xx-pillar-deactivation.txt 2008-01-16 19:27:48 +0000
436+++ lib/lp/registry/stories/pillar/xx-pillar-deactivation.txt 2010-04-22 14:31:33 +0000
437@@ -7,12 +7,20 @@
438 cover that part of the change. See bug #156263 for more details.
439 -- kiko, 2007-10-23
440
441+ >>> from lp.testing import unlink_source_packages
442+ >>> from lp.registry.interfaces.pillar import IPillarNameSet
443+ >>> from lp.registry.interfaces.product import IProduct
444+ >>> from zope.component import getUtility
445+ >>> pillar_set = getUtility(IPillarNameSet)
446 >>> def toggleProject(name):
447- ... admin_browser.open('http://launchpad.dev/%s' % name)
448- ... admin_browser.getLink(url='+review').click()
449- ... state = admin_browser.getControl('Active').selected
450- ... admin_browser.getControl('Active').selected = (not state)
451- ... admin_browser.getControl('Change').click()
452+ ... login('admin@canonical.com')
453+ ... pillar = pillar_set.getByName(name)
454+ ... if IProduct.providedBy(pillar) and pillar.active:
455+ ... # A product cannot be deactivated if
456+ ... # it is linked to source packages.
457+ ... unlink_source_packages(pillar)
458+ ... pillar.active = not pillar.active
459+ ... logout()
460
461 We start off with active and visible projects:
462
463@@ -72,13 +80,12 @@
464 >>> anon_browser.getLink(url='/firefox').click()
465 >>> anon_browser.title
466 'Mozilla Firefox in Launchpad'
467- >>> print find_tag_by_id(admin_browser.contents, 'project-inactive')
468+ >>> print find_tag_by_id(anon_browser.contents, 'project-inactive')
469 None
470
471 >>> anon_browser.open('http://launchpad.dev/projects/+index?text=mozilla')
472 >>> anon_browser.getLink(url='/mozilla').click()
473 >>> anon_browser.title
474 'The Mozilla Project in Launchpad'
475- >>> print find_tag_by_id(admin_browser.contents, 'project-inactive')
476+ >>> print find_tag_by_id(anon_browser.contents, 'project-inactive')
477 None
478-
479
480=== modified file 'lib/lp/registry/stories/product/xx-product-edit.txt'
481--- lib/lp/registry/stories/product/xx-product-edit.txt 2010-03-16 22:07:45 +0000
482+++ lib/lp/registry/stories/product/xx-product-edit.txt 2010-04-22 14:31:33 +0000
483@@ -332,27 +332,27 @@
484
485 The Admins and Registry Experts can deactivate a project.
486
487- >>> registry_browser.open('http://launchpad.dev/firefox/+review-license')
488+ >>> registry_browser.open('http://launchpad.dev/bzr/+review-license')
489 >>> registry_browser.getControl(name='field.active').value = False
490 >>> registry_browser.getControl(name='field.actions.change').click()
491 >>> print registry_browser.url
492- http://launchpad.dev/firefox
493+ http://launchpad.dev/bzr
494
495 The product overview page should show a notice that a product is
496 inactive with a link to a form to re-activate it. Admins and Commercial
497 Admins can still see the product, but regular users can't.
498
499- >>> registry_browser.open('http://launchpad.dev/firefox')
500+ >>> registry_browser.open('http://launchpad.dev/bzr')
501 >>> contents = find_main_content(registry_browser.contents)
502 >>> print extract_text(contents.find(id='project-inactive'))
503 This project is currently inactive ...
504
505- >>> admin_browser.open('http://launchpad.dev/firefox')
506+ >>> admin_browser.open('http://launchpad.dev/bzr')
507 >>> contents = find_main_content(admin_browser.contents)
508 >>> print extract_text(contents.find(id='project-inactive'))
509 This project is currently inactive ...
510
511- >>> user_browser.open('http://launchpad.dev/firefox')
512+ >>> user_browser.open('http://launchpad.dev/bzr')
513 Traceback (most recent call last):
514 ...
515 NotFound...
516@@ -361,12 +361,12 @@
517
518 >>> registry_browser.getLink('Review project').click()
519 >>> print registry_browser.url
520- http://launchpad.dev/firefox/+review-license
521+ http://launchpad.dev/bzr/+review-license
522
523 >>> registry_browser.getControl(name='field.active').value = True
524 >>> registry_browser.getControl(name='field.actions.change').click()
525 >>> print registry_browser.url
526- http://launchpad.dev/firefox
527+ http://launchpad.dev/bzr
528
529 >>> contents = find_main_content(registry_browser.contents)
530 >>> print contents.find(id='project-inactive')
531
532=== modified file 'lib/lp/registry/stories/project/xx-project-index.txt'
533--- lib/lp/registry/stories/project/xx-project-index.txt 2010-04-19 08:11:52 +0000
534+++ lib/lp/registry/stories/project/xx-project-index.txt 2010-04-22 14:31:33 +0000
535@@ -150,6 +150,12 @@
536 # (i.e. login()) and bypass the security proxy.
537 >>> from lp.registry.model.product import Product
538 >>> firefox = Product.byName('firefox')
539+
540+ # Unlink the source packages so the project can be deactivated.
541+ >>> from lp.testing import unlink_source_packages
542+ >>> login('admin@canonical.com')
543+ >>> unlink_source_packages(firefox)
544+ >>> logout()
545 >>> firefox.active = False
546 >>> firefox.syncUpdate()
547
548
549=== modified file 'lib/lp/registry/tests/test_product.py'
550--- lib/lp/registry/tests/test_product.py 2009-10-23 13:48:28 +0000
551+++ lib/lp/registry/tests/test_product.py 2010-04-22 14:31:33 +0000
552@@ -26,6 +26,34 @@
553 CommercialSubscription)
554 from lp.testing import TestCaseWithFactory
555
556+class TestProduct(TestCaseWithFactory):
557+ """Tests product object."""
558+
559+ layer = LaunchpadFunctionalLayer
560+
561+ def test_deactivation_failure(self):
562+ # Ensure that a product cannot be deactivated if
563+ # it is linked to source packages.
564+ login('admin@canonical.com')
565+ product = self.factory.makeProduct()
566+ source_package = self.factory.makeSourcePackage()
567+ self.assertEqual(True, product.active)
568+ source_package.setPackaging(
569+ product.development_focus, self.factory.makePerson())
570+ self.assertRaises(
571+ AssertionError,
572+ setattr, product, 'active', False)
573+
574+ def test_deactivation_success(self):
575+ # Ensure that a product can be deactivated if
576+ # it is not linked to source packages.
577+ login('admin@canonical.com')
578+ product = self.factory.makeProduct()
579+ self.assertEqual(True, product.active)
580+ product.active = False
581+ self.assertEqual(False, product.active)
582+
583+
584 class TestProductFiles(unittest.TestCase):
585 """Tests for downloadable product files."""
586
587
588=== modified file 'lib/lp/testing/__init__.py'
589--- lib/lp/testing/__init__.py 2010-04-18 22:31:40 +0000
590+++ lib/lp/testing/__init__.py 2010-04-22 14:31:33 +0000
591@@ -31,6 +31,7 @@
592 'TestCaseWithFactory',
593 'test_tales',
594 'time_counter',
595+ 'unlink_source_packages',
596 # XXX: This really shouldn't be exported from here. People should import
597 # it from Zope.
598 'verifyObject',
599@@ -83,6 +84,7 @@
600 from canonical.launchpad.webapp.interfaces import ILaunchBag
601 from canonical.launchpad.windmill.testing import constants
602 from lp.codehosting.vfs import branch_id_to_path, get_multi_server
603+from lp.registry.interfaces.packaging import IPackagingUtil
604 # Import the login and logout functions here as it is a much better
605 # place to import them from in tests.
606 from lp.testing._login import (
607@@ -930,3 +932,15 @@
608 name, mock_args, real_args))
609 else:
610 break
611+
612+def unlink_source_packages(product):
613+ """Remove all links between the product and source packages.
614+
615+ A product cannot be deactivated if it is linked to source packages.
616+ """
617+ packaging_util = getUtility(IPackagingUtil)
618+ for source_package in product.sourcepackages:
619+ packaging_util.deletePackaging(
620+ source_package.productseries,
621+ source_package.sourcepackagename,
622+ source_package.distroseries)
623
624=== modified file 'lib/lp/translations/doc/translationimportqueue.txt'
625--- lib/lp/translations/doc/translationimportqueue.txt 2010-02-05 15:28:42 +0000
626+++ lib/lp/translations/doc/translationimportqueue.txt 2010-04-22 14:31:33 +0000
627@@ -1043,6 +1043,9 @@
628 An administrator deactivates Firefox, thinking that the project is being
629 run in violation of Launchpad policies.
630
631+ # Unlink the source packages so the project can be deactivated.
632+ >>> from lp.testing import unlink_source_packages
633+ >>> unlink_source_packages(firefox)
634 >>> firefox.active = False
635 >>> syncUpdate(firefox)
636
637
638=== modified file 'lib/lp/translations/stories/translationgroups/30-show-group-translation-targets.txt'
639--- lib/lp/translations/stories/translationgroups/30-show-group-translation-targets.txt 2009-08-25 16:42:56 +0000
640+++ lib/lp/translations/stories/translationgroups/30-show-group-translation-targets.txt 2010-04-22 14:31:33 +0000
641@@ -26,6 +26,13 @@
642 >>> admin_browser.url
643 'http://launchpad.dev/projectgroups'
644
645+ # Unlink the source packages so the project can be deactivated.
646+ >>> from lp.testing import unlink_source_packages
647+ >>> from lp.registry.interfaces.product import IProductSet
648+ >>> from zope.component import getUtility
649+ >>> login('admin@canonical.com')
650+ >>> unlink_source_packages(getUtility(IProductSet).getByName('netapplet'))
651+ >>> logout()
652 >>> admin_browser.open("http://launchpad.dev/netapplet/+admin")
653 >>> admin_browser.getControl("Active").click()
654 >>> admin_browser.getControl("Change").click()