Merge lp:~ursinha/launchpad/bug422056-add-translation-focus into lp:launchpad/db-devel
- bug422056-add-translation-focus
- Merge into db-devel
Status: | Merged |
---|---|
Approved by: | Edwin Grubbs |
Approved revision: | not available |
Merged at revision: | not available |
Proposed branch: | lp:~ursinha/launchpad/bug422056-add-translation-focus |
Merge into: | lp:launchpad/db-devel |
Diff against target: |
808 lines (+363/-86) 15 files modified
cronscripts/expire-ppa-files.py (+1/-1) database/schema/security.cfg (+3/-0) lib/lp/registry/browser/menu.py (+1/-1) lib/lp/registry/browser/peoplemerge.py (+4/-4) lib/lp/registry/browser/tests/peoplemerge-views.txt (+34/-4) lib/lp/registry/configure.zcml (+2/-1) lib/lp/registry/interfaces/product.py (+8/-0) lib/lp/registry/model/product.py (+10/-4) lib/lp/registry/stories/team/xx-adminteammerge.txt (+43/-34) lib/lp/registry/stories/webservice/xx-project-registry.txt (+2/-0) lib/lp/soyuz/scripts/expire_ppa_binaries.py (+57/-2) lib/lp/soyuz/scripts/tests/test_expire_ppa_bins.py (+70/-33) lib/lp/translations/browser/product.py (+7/-2) lib/lp/translations/stories/translationfocus/xx-product-translationfocus.txt (+87/-0) lib/lp/translations/stories/webservice/xx-translationfocus.txt (+34/-0) |
To merge this branch: | bzr merge lp:~ursinha/launchpad/bug422056-add-translation-focus |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Edwin Grubbs (community) | code | Approve | |
Review via email: mp+17744@code.launchpad.net |
Commit message
This branch fixes bug 422056, adding the ability of choosing the translation focus to a product, as it works with distributions now.
Description of the change
Ursula Junque (ursinha) wrote : | # |
Edwin Grubbs (edwin-grubbs) wrote : | # |
Hi Ursula,
This is a nice branch. All my comments are for minor changes.
merge-conditional
-Edwin
>=== modified file 'lib/lp/
>--- lib/lp/
>+++ lib/lp/
>@@ -16,7 +16,8 @@
> LaunchpadView, Link, canonical_url, enabled_
> from canonical.
> from canonical.
>-from lp.registry.
>+from lp.registry.
>+from lp.registry.
> from lp.registry.
> from lp.translations
>
>@@ -63,7 +64,8 @@
> class ProductChangeTr
> label = "Set permissions and policies"
> page_title = "Permissions and policies"
>- field_names = ["translationgr
>+ field_names = ["translationgr
>+ "translation_
This does not follow the style guide for multiline lists.
https:/
> @property
> def cancel_url(self):
>
>=== added directory 'lib/lp/
>=== added file 'lib/lp/
>--- lib/lp/
>+++ lib/lp/
>@@ -0,0 +1,89 @@
>+The translation focus of a product can be explicitly set to a specific series.
>+When not set, launchpad recommends the development focus to translate.
>+
>+ >>> login('<email address hidden>')
>+ >>> fooproject = factory.
>+ >>> fooproject.
>+ >>> fooproject_trunk = fooproject.
>+ >>> fooproject_url = canonical_url(
>+ ... fooproject, rootsite=
>+ >>> logout()
>+
>+Only admin users are able to change the translation focus of a product.
>+Unprivileged users visualize the recommended series for translation,
Whitespace at end of line.
s/visualize/can see/
>+but have no access to the 'Change permissions' menu.
>+
>+ >>> admin_browser.
>+ >>> print extract_text(
>+ ... find_tags_
>+ Change permissions
>+
>+ >>> browser.
>+ >>> print extract_text(
>+ ... find_tags_
>+ Traceback (most recent call last):
>+ ...
>+ IndexError: list index out of range
>+
Whitespace at end of line.
>+
>+== Setting the translation focus ==
>+
>+ >>> login('<email address hidden>')
>+ >>> from zope.security.proxy import removeSecurityProxy
>+ >>> pot_main = factory.
>+ ... productseries=
>+ >>> rem...
Ursula Junque (ursinha) wrote : | # |
Hi Edwin!
> Hi Ursula,
>
> This is a nice branch. All my comments are for minor changes.
All fixed and pushed. Thanks!
>
> merge-conditional
>
> -Edwin
>
>
> >=== modified file 'lib/lp/
> >--- lib/lp/
> >+++ lib/lp/
> >@@ -16,7 +16,8 @@
> > LaunchpadView, Link, canonical_url, enabled_
> > from canonical.
> > from canonical.
> >-from lp.registry.
> >+from lp.registry.
> >+from lp.registry.
> > from lp.registry.
> > from lp.translations
> >
> >@@ -63,7 +64,8 @@
> > class ProductChangeTr
> > label = "Set permissions and policies"
> > page_title = "Permissions and policies"
> >- field_names = ["translationgr
> >+ field_names = ["translationgr
> >+ "translation_
>
>
> This does not follow the style guide for multiline lists.
> https:/
>
>
> > @property
> > def cancel_url(self):
> >
> >=== added directory 'lib/lp/
> >=== added file 'lib/lp/
> translationfocu
> >--- lib/lp/
> translationfocu
> >+++ lib/lp/
> translationfocu
> >@@ -0,0 +1,89 @@
> >+The translation focus of a product can be explicitly set to a specific
> series.
> >+When not set, launchpad recommends the development focus to translate.
> >+
> >+ >>> login('<email address hidden>')
> >+ >>> fooproject = factory.
> >+ >>> fooproject.
> >+ >>> fooproject_trunk = fooproject.
> >+ >>> fooproject_url = canonical_url(
> >+ ... fooproject, rootsite=
> >+ >>> logout()
> >+
> >+Only admin users are able to change the translation focus of a product.
> >+Unprivileged users visualize the recommended series for translation,
>
>
> Whitespace at end of line.
> s/visualize/can see/
>
>
> >+but have no access to the 'Change permissions' menu.
> >+
> >+ >>> admin_browser.
> >+ >>> print extract_text(
> >+ ... find_tags_
> >+ Change permissions
> >+
> >+ >>> browser.
> >+ >>> print extract_text(
> >+ ... find_tags_
> >+ Traceback (most recent call last):
> >+ ...
> >+ IndexError: list index out of range
> >+
>
>
> Whitespace at end of line.
>
>
>
> >+
> >+== Setting the transla...
Preview Diff
1 | === renamed file 'cronscripts/expire-ppa-binaries.py' => 'cronscripts/expire-ppa-files.py' |
2 | --- cronscripts/expire-ppa-binaries.py 2009-10-13 14:38:07 +0000 |
3 | +++ cronscripts/expire-ppa-files.py 2010-01-20 20:27:21 +0000 |
4 | @@ -17,6 +17,6 @@ |
5 | |
6 | if __name__ == '__main__': |
7 | script = PPABinaryExpirer( |
8 | - 'expire-ppa-binaries', dbuser=config.binaryfile_expire.dbuser) |
9 | + 'expire-ppa-files', dbuser=config.binaryfile_expire.dbuser) |
10 | script.lock_and_run() |
11 | |
12 | |
13 | === modified file 'database/schema/security.cfg' |
14 | --- database/schema/security.cfg 2010-01-18 09:36:37 +0000 |
15 | +++ database/schema/security.cfg 2010-01-20 20:27:21 +0000 |
16 | @@ -1621,6 +1621,9 @@ |
17 | public.person = SELECT |
18 | public.libraryfilealias = SELECT, UPDATE |
19 | public.securebinarypackagepublishinghistory = SELECT |
20 | +public.sourcepackagereleasefile = SELECT |
21 | +public.sourcepackagepublishinghistory = SELECT |
22 | +public.sourcepackagerelease = SELECT |
23 | |
24 | [create-merge-proposals] |
25 | type=user |
26 | |
27 | === modified file 'lib/lp/registry/browser/menu.py' |
28 | --- lib/lp/registry/browser/menu.py 2009-08-21 20:04:25 +0000 |
29 | +++ lib/lp/registry/browser/menu.py 2010-01-20 20:27:21 +0000 |
30 | @@ -60,7 +60,7 @@ |
31 | text = 'Merge people' |
32 | return Link('/people/+adminpeoplemerge', text, icon='edit') |
33 | |
34 | - @enabled_with_permission('launchpad.Admin') |
35 | + @enabled_with_permission('launchpad.Moderate') |
36 | def admin_merge_teams(self): |
37 | text = 'Merge teams' |
38 | return Link('/people/+adminteammerge', text, icon='edit') |
39 | |
40 | === modified file 'lib/lp/registry/browser/peoplemerge.py' |
41 | --- lib/lp/registry/browser/peoplemerge.py 2010-01-12 00:19:20 +0000 |
42 | +++ lib/lp/registry/browser/peoplemerge.py 2010-01-20 20:27:21 +0000 |
43 | @@ -140,15 +140,15 @@ |
44 | from zope.security.proxy import removeSecurityProxy |
45 | for email in self.dupe_person_emails: |
46 | email = IMasterObject(email) |
47 | - # XXX: Guilherme Salgado 2007-10-15: Maybe this status change |
48 | - # should be done only when merging people but not when merging |
49 | - # teams. |
50 | - email.status = EmailAddressStatus.NEW |
51 | # EmailAddress.person and EmailAddress.account are readonly |
52 | # fields, so we need to remove the security proxy here. |
53 | naked_email = removeSecurityProxy(email) |
54 | naked_email.personID = self.target_person.id |
55 | naked_email.accountID = self.target_person.accountID |
56 | + # XXX: Guilherme Salgado 2007-10-15: Maybe this status change |
57 | + # should be done only when merging people but not when merging |
58 | + # teams. |
59 | + naked_email.status = EmailAddressStatus.NEW |
60 | flush_database_updates() |
61 | getUtility(IPersonSet).merge(self.dupe_person, self.target_person) |
62 | self.request.response.addInfoNotification(self.merge_message) |
63 | |
64 | === modified file 'lib/lp/registry/browser/tests/peoplemerge-views.txt' |
65 | --- lib/lp/registry/browser/tests/peoplemerge-views.txt 2010-01-06 17:52:45 +0000 |
66 | +++ lib/lp/registry/browser/tests/peoplemerge-views.txt 2010-01-20 20:27:21 +0000 |
67 | @@ -6,9 +6,17 @@ |
68 | Team Merges |
69 | ----------- |
70 | |
71 | +Create a member of the registry team that is not a member of the admins |
72 | +team. |
73 | + |
74 | >>> from lp.registry.interfaces.person import IPersonSet |
75 | + >>> from canonical.launchpad.interfaces import ILaunchpadCelebrities |
76 | + >>> registry_experts = getUtility(ILaunchpadCelebrities).registry_experts |
77 | >>> person_set = getUtility(IPersonSet) |
78 | - >>> registry_expert = person_set.getByName('mark') |
79 | + >>> registry_expert = factory.makePerson() |
80 | + >>> login_person(registry_experts.teamowner) |
81 | + >>> ignored = registry_experts.addMember( |
82 | + ... registry_expert, registry_experts.teamowner) |
83 | >>> login_person(registry_expert) |
84 | |
85 | A team (name21) can be merged into another (ubuntu-team). |
86 | @@ -39,6 +47,7 @@ |
87 | >>> parent_team = factory.makeTeam() |
88 | >>> child_team = factory.makeTeam(name='child-team') |
89 | >>> random_team = factory.makeTeam() |
90 | + >>> login('foo.bar@canonical.com') |
91 | >>> ignored = parent_team.addMember( |
92 | ... child_team, reviewer=parent_team.teamowner, force_team_add=True) |
93 | >>> form = {'field.dupe_person': child_team.name, |
94 | @@ -112,10 +121,8 @@ |
95 | Users with launchpad.Moderate such as team admins and registry experts |
96 | can delete teams. |
97 | |
98 | - >>> from canonical.launchpad.interfaces import ILaunchpadCelebrities |
99 | >>> from canonical.launchpad.webapp.authorization import check_permission |
100 | |
101 | - >>> registry_experts = getUtility(ILaunchpadCelebrities).registry_experts |
102 | >>> team_owner = factory.makePerson() |
103 | >>> team_member = factory.makePerson() |
104 | >>> deletable_team = factory.makeTeam(owner=team_owner, name='deletable') |
105 | @@ -127,7 +134,7 @@ |
106 | >>> check_permission('launchpad.Moderate', view) |
107 | False |
108 | |
109 | - >>> login_person(registry_experts.teamowner) |
110 | + >>> login_person(registry_expert) |
111 | >>> check_permission('launchpad.Moderate', view) |
112 | True |
113 | |
114 | @@ -241,3 +248,26 @@ |
115 | |
116 | >>> print find_tag_by_id(content, 'field.actions.delete') |
117 | None |
118 | + |
119 | +The registry experts should be able to delete a team with an |
120 | +validated email address, which will be invisible, since only |
121 | +preferred email addresses are shown for teams. |
122 | + |
123 | + >>> from canonical.launchpad.interfaces.emailaddress import ( |
124 | + ... IEmailAddressSet) |
125 | + >>> login_person(registry_expert) |
126 | + >>> admin = getUtility(ILaunchpadCelebrities).admin |
127 | + >>> registry_expert.inTeam(admin) |
128 | + False |
129 | + >>> deletable_team = factory.makeTeam(email="del@example.com") |
130 | + >>> deletable_team.setContactAddress(None) |
131 | + >>> for email in getUtility(IEmailAddressSet).getByPerson(deletable_team): |
132 | + ... print email.email, email.status.title |
133 | + del@example.com Validated Email Address |
134 | + >>> form = {'field.actions.delete': 'Delete'} |
135 | + >>> view = create_initialized_view(deletable_team, '+delete', form=form) |
136 | + >>> view.errors |
137 | + [] |
138 | + >>> for notification in view.request.response.notifications: |
139 | + ... print notification.message |
140 | + Team deleted. |
141 | |
142 | === modified file 'lib/lp/registry/configure.zcml' |
143 | --- lib/lp/registry/configure.zcml 2010-01-04 19:07:58 +0000 |
144 | +++ lib/lp/registry/configure.zcml 2010-01-20 20:27:21 +0000 |
145 | @@ -1081,7 +1081,8 @@ |
146 | remote_product screenshotsurl |
147 | security_contact sourceforgeproject |
148 | summary title translationgroup |
149 | - translationpermission wikiurl"/> |
150 | + translationpermission translation_focus |
151 | + wikiurl"/> |
152 | |
153 | <!-- mark 2006-04-10 I put "name" in the admin group because |
154 | with Bazaar now in place, lots of people can have personal |
155 | |
156 | === modified file 'lib/lp/registry/interfaces/product.py' |
157 | --- lib/lp/registry/interfaces/product.py 2009-12-05 18:37:28 +0000 |
158 | +++ lib/lp/registry/interfaces/product.py 2010-01-20 20:27:21 +0000 |
159 | @@ -586,6 +586,14 @@ |
160 | readonly=True, |
161 | value_type=Reference(schema=IProductRelease))) |
162 | |
163 | + translation_focus = exported( |
164 | + ReferenceChoice( |
165 | + title=_("Translation Focus"), required=False, |
166 | + vocabulary='FilteredProductSeries', |
167 | + schema=IProductSeries, |
168 | + description=_( |
169 | + 'The ProductSeries where translations are focused.'))) |
170 | + |
171 | translatable_packages = Attribute( |
172 | "A list of the source packages for this product that can be " |
173 | "translated sorted by distroseries.name and sourcepackage.name.") |
174 | |
175 | === modified file 'lib/lp/registry/model/product.py' |
176 | --- lib/lp/registry/model/product.py 2010-01-10 04:29:39 +0000 |
177 | +++ lib/lp/registry/model/product.py 2010-01-20 20:27:21 +0000 |
178 | @@ -232,6 +232,9 @@ |
179 | translationpermission = EnumCol( |
180 | dbName='translationpermission', notNull=True, |
181 | schema=TranslationPermission, default=TranslationPermission.OPEN) |
182 | + translation_focus = ForeignKey( |
183 | + dbName='translation_focus', foreignKey='ProductSeries', |
184 | + notNull=False, default=None) |
185 | bugtracker = ForeignKey( |
186 | foreignKey="BugTracker", dbName="bugtracker", notNull=False, |
187 | default=None) |
188 | @@ -746,11 +749,14 @@ |
189 | targetseries = ubuntu.currentseries |
190 | product_series = self.translatable_series |
191 | |
192 | - # First, go with development focus branch |
193 | - if product_series and self.development_focus in product_series: |
194 | - return self.development_focus |
195 | - # Next, go with the latest product series that has templates: |
196 | if product_series: |
197 | + # First, go with translation focus |
198 | + if self.translation_focus in product_series: |
199 | + return self.translation_focus |
200 | + # Next, go with development focus |
201 | + if self.development_focus in product_series: |
202 | + return self.development_focus |
203 | + # Next, go with the latest product series that has templates: |
204 | return product_series[-1] |
205 | # Otherwise, look for an Ubuntu package in the current distroseries: |
206 | for package in packages: |
207 | |
208 | === modified file 'lib/lp/registry/stories/team/xx-adminteammerge.txt' |
209 | --- lib/lp/registry/stories/team/xx-adminteammerge.txt 2009-11-22 15:43:16 +0000 |
210 | +++ lib/lp/registry/stories/team/xx-adminteammerge.txt 2010-01-20 20:27:21 +0000 |
211 | @@ -4,30 +4,39 @@ |
212 | active members, so the user will first have to confirm that the |
213 | members should be deactivated before the teams are merged. |
214 | |
215 | - >>> admin_browser.open('http://launchpad.dev/people/+adminteammerge') |
216 | - >>> admin_browser.getControl('Duplicated Team').value = ( |
217 | - ... 'landscape-developers') |
218 | - >>> admin_browser.getControl('Target Team').value = 'guadamen' |
219 | - >>> admin_browser.getControl('Merge').click() |
220 | + >>> from zope.component import getUtility |
221 | + >>> from lp.registry.interfaces.person import IPersonSet |
222 | + >>> login('foo.bar@canonical.com') |
223 | + >>> registry_expert = factory.makePerson( |
224 | + ... email='reg@example.com', password='test') |
225 | + >>> new_team = factory.makeTeam( |
226 | + ... name="new-team", email="new_team@example.com") |
227 | + >>> registry_experts = getUtility(IPersonSet).getByName('registry') |
228 | + >>> ignored = registry_experts.addMember( |
229 | + ... registry_expert, registry_experts.teamowner) |
230 | + >>> logout() |
231 | + >>> registry_browser = setupBrowser(auth='Basic reg@example.com:test') |
232 | + >>> registry_browser.open('http://launchpad.dev/people/+adminteammerge') |
233 | + >>> registry_browser.getControl('Duplicated Team').value = ( |
234 | + ... 'new-team') |
235 | + >>> registry_browser.getControl('Target Team').value = 'guadamen' |
236 | + >>> registry_browser.getControl('Merge').click() |
237 | |
238 | - >>> admin_browser.url |
239 | + >>> registry_browser.url |
240 | 'http://launchpad.dev/people/+adminteammerge' |
241 | - >>> print get_feedback_messages(admin_browser.contents)[0] |
242 | - Landscape Developers has 2 active members which will have to be |
243 | - deactivated before the teams can be merged. |
244 | + >>> print get_feedback_messages(registry_browser.contents)[0] |
245 | + New Team has 1 active members which will have to be deactivated |
246 | + before the teams can be merged. |
247 | |
248 | - >>> admin_browser.getControl('Deactivate Members and Merge').click() |
249 | - >>> admin_browser.url |
250 | + >>> registry_browser.getControl('Deactivate Members and Merge').click() |
251 | + >>> registry_browser.url |
252 | 'http://launchpad.dev/~guadamen' |
253 | |
254 | >>> from canonical.launchpad.ftests import ANONYMOUS, login, logout |
255 | - >>> from canonical.launchpad.interfaces import IMailingListSet, IPersonSet |
256 | + >>> from canonical.launchpad.interfaces import IMailingListSet |
257 | >>> from zope.component import getUtility |
258 | >>> login(ANONYMOUS) |
259 | - >>> person_set = getUtility(IPersonSet) |
260 | - >>> landscape = person_set.getByName( |
261 | - ... 'landscape-developers-merged', ignore_merged=False) |
262 | - >>> landscape.merged.name |
263 | + >>> new_team.merged.name |
264 | u'guadamen' |
265 | >>> logout() |
266 | |
267 | @@ -36,7 +45,7 @@ |
268 | |
269 | Merged teams are not visible anymore. |
270 | |
271 | - >>> browser.open("http://launchpad.dev/~landscape-developers-merged") |
272 | + >>> browser.open("http://launchpad.dev/~new-team-merged") |
273 | Traceback (most recent call last): |
274 | ... |
275 | NotFound:... |
276 | @@ -48,45 +57,45 @@ |
277 | merged, though. |
278 | |
279 | >>> login(ANONYMOUS) |
280 | - >>> guadamen = person_set.getByName('guadamen') |
281 | + >>> guadamen = getUtility(IPersonSet).getByName('guadamen') |
282 | >>> mailing_list = getUtility(IMailingListSet).new(guadamen) |
283 | >>> logout() |
284 | |
285 | - >>> admin_browser.open('http://launchpad.dev/people/+adminteammerge') |
286 | - >>> admin_browser.getControl('Duplicated Team').value = 'guadamen' |
287 | - >>> admin_browser.getControl('Target Team').value = 'ubuntu-team' |
288 | - >>> admin_browser.getControl('Merge').click() |
289 | + >>> registry_browser.open('http://launchpad.dev/people/+adminteammerge') |
290 | + >>> registry_browser.getControl('Duplicated Team').value = 'guadamen' |
291 | + >>> registry_browser.getControl('Target Team').value = 'ubuntu-team' |
292 | + >>> registry_browser.getControl('Merge').click() |
293 | |
294 | - >>> admin_browser.url |
295 | + >>> registry_browser.url |
296 | 'http://launchpad.dev/people/+adminteammerge' |
297 | |
298 | - >>> print get_feedback_messages(admin_browser.contents) |
299 | + >>> print get_feedback_messages(registry_browser.contents) |
300 | [u'There is 1 error.', |
301 | u"guadamen is associated with a Launchpad mailing list; |
302 | we can't merge it."] |
303 | |
304 | We also can't (for obvious reasons) merge any person/team into itself. |
305 | |
306 | - >>> admin_browser.getControl('Duplicated Team').value = 'shipit-admins' |
307 | - >>> admin_browser.getControl('Target Team').value = 'shipit-admins' |
308 | - >>> admin_browser.getControl('Merge').click() |
309 | + >>> registry_browser.getControl('Duplicated Team').value = 'shipit-admins' |
310 | + >>> registry_browser.getControl('Target Team').value = 'shipit-admins' |
311 | + >>> registry_browser.getControl('Merge').click() |
312 | |
313 | - >>> admin_browser.url |
314 | + >>> registry_browser.url |
315 | 'http://launchpad.dev/people/+adminteammerge' |
316 | |
317 | - >>> print get_feedback_messages(admin_browser.contents) |
318 | + >>> print get_feedback_messages(registry_browser.contents) |
319 | [u'There is 1 error.', u"You can't merge shipit-admins into itself."] |
320 | |
321 | Nor can we merge a person into a team or a team into a person. |
322 | |
323 | - >>> admin_browser.getControl('Duplicated Team').value = 'ubuntu-team' |
324 | - >>> admin_browser.getControl('Target Team').value = 'salgado' |
325 | - >>> admin_browser.getControl('Merge').click() |
326 | + >>> registry_browser.getControl('Duplicated Team').value = 'ubuntu-team' |
327 | + >>> registry_browser.getControl('Target Team').value = 'salgado' |
328 | + >>> registry_browser.getControl('Merge').click() |
329 | |
330 | - >>> admin_browser.url |
331 | + >>> registry_browser.url |
332 | 'http://launchpad.dev/people/+adminteammerge' |
333 | |
334 | # Yes, the error message is not of much help, but this UI is only for |
335 | # admins and they're supposed to know what they're doing. |
336 | - >>> print get_feedback_messages(admin_browser.contents) |
337 | + >>> print get_feedback_messages(registry_browser.contents) |
338 | [u'There is 1 error.', u'Constraint not satisfied'] |
339 | |
340 | === modified file 'lib/lp/registry/stories/webservice/xx-project-registry.txt' |
341 | --- lib/lp/registry/stories/webservice/xx-project-registry.txt 2009-11-10 01:00:23 +0000 |
342 | +++ lib/lp/registry/stories/webservice/xx-project-registry.txt 2010-01-20 20:27:21 +0000 |
343 | @@ -167,6 +167,7 @@ |
344 | sourceforge_project: None |
345 | summary: u'The Mozilla Firefox web browser' |
346 | title: u'Mozilla Firefox' |
347 | + translation_focus_link: None |
348 | wiki_url: None |
349 | |
350 | In Launchpad project names may not have uppercase letters in their |
351 | @@ -225,6 +226,7 @@ |
352 | sourceforge_project: None |
353 | summary: u'The Mozilla Firefox web browser' |
354 | title: u'Mozilla Firefox' |
355 | + translation_focus_link: None |
356 | wiki_url: None |
357 | |
358 | The milestones can be accessed through the |
359 | |
360 | === modified file 'lib/lp/soyuz/scripts/expire_ppa_binaries.py' |
361 | --- lib/lp/soyuz/scripts/expire_ppa_binaries.py 2009-12-11 14:35:28 +0000 |
362 | +++ lib/lp/soyuz/scripts/expire_ppa_binaries.py 2010-01-20 20:27:21 +0000 |
363 | @@ -54,7 +54,61 @@ |
364 | help=("The number of days after which to expire binaries. " |
365 | "Must be specified.")) |
366 | |
367 | - def determineExpirables(self, num_days): |
368 | + def determineSourceExpirables(self, num_days): |
369 | + """Return expirable libraryfilealias IDs.""" |
370 | + # Avoid circular imports. |
371 | + from lp.soyuz.interfaces.archive import ArchivePurpose |
372 | + |
373 | + stay_of_execution = '%d days' % num_days |
374 | + |
375 | + # The subquery here has to repeat the checks for privacy and |
376 | + # blacklisting on *other* publications that are also done in |
377 | + # the main loop for the archive being considered. |
378 | + results = self.store.execute(""" |
379 | + SELECT lfa.id |
380 | + FROM |
381 | + LibraryFileAlias AS lfa, |
382 | + Archive, |
383 | + SourcePackageReleaseFile AS sprf, |
384 | + SourcePackageRelease AS spr, |
385 | + SourcePackagePublishingHistory AS spph |
386 | + WHERE |
387 | + lfa.id = sprf.libraryfile |
388 | + AND spr.id = sprf.sourcepackagerelease |
389 | + AND spph.sourcepackagerelease = spr.id |
390 | + AND spph.dateremoved < ( |
391 | + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - interval %s) |
392 | + AND spph.archive = archive.id |
393 | + AND archive.purpose = %s |
394 | + AND lfa.expires IS NULL |
395 | + EXCEPT |
396 | + SELECT sprf.libraryfile |
397 | + FROM |
398 | + SourcePackageRelease AS spr, |
399 | + SourcePackageReleaseFile AS sprf, |
400 | + SourcePackagePublishingHistory AS spph, |
401 | + Archive AS a, |
402 | + Person AS p |
403 | + WHERE |
404 | + spr.id = sprf.sourcepackagerelease |
405 | + AND spph.sourcepackagerelease = spr.id |
406 | + AND spph.archive = a.id |
407 | + AND p.id = a.owner |
408 | + AND ( |
409 | + p.name IN %s |
410 | + OR a.private IS TRUE |
411 | + OR a.purpose != %s |
412 | + OR dateremoved > |
413 | + CURRENT_TIMESTAMP AT TIME ZONE 'UTC' - interval %s |
414 | + OR dateremoved IS NULL); |
415 | + """ % sqlvalues( |
416 | + stay_of_execution, ArchivePurpose.PPA, self.blacklist, |
417 | + ArchivePurpose.PPA, stay_of_execution)) |
418 | + |
419 | + lfa_ids = results.get_all() |
420 | + return lfa_ids |
421 | + |
422 | + def determineBinaryExpirables(self, num_days): |
423 | """Return expirable libraryfilealias IDs.""" |
424 | # Avoid circular imports. |
425 | from lp.soyuz.interfaces.archive import ArchivePurpose |
426 | @@ -116,7 +170,8 @@ |
427 | self.store = getUtility(IStoreSelector).get( |
428 | MAIN_STORE, DEFAULT_FLAVOR) |
429 | |
430 | - lfa_ids = self.determineExpirables(num_days) |
431 | + lfa_ids = self.determineSourceExpirables(num_days) |
432 | + lfa_ids.extend(self.determineBinaryExpirables(num_days)) |
433 | batch_count = 0 |
434 | batch_limit = 500 |
435 | for id in lfa_ids: |
436 | |
437 | === modified file 'lib/lp/soyuz/scripts/tests/test_expire_ppa_bins.py' |
438 | --- lib/lp/soyuz/scripts/tests/test_expire_ppa_bins.py 2009-12-11 14:35:28 +0000 |
439 | +++ lib/lp/soyuz/scripts/tests/test_expire_ppa_bins.py 2010-01-20 20:27:21 +0000 |
440 | @@ -74,54 +74,76 @@ |
441 | self.layer.switchDbUser(self.dbuser) |
442 | script.main() |
443 | |
444 | - def assertExpired(self, publication): |
445 | - self.assertNotEqual( |
446 | - publication.binarypackagerelease.files[0].libraryfile.expires, |
447 | - None, |
448 | - "lfa.expires should be set, but it's not.") |
449 | - |
450 | - def assertNotExpired(self, publication): |
451 | - self.assertEqual( |
452 | - publication.binarypackagerelease.files[0].libraryfile.expires, |
453 | + def assertBinaryExpired(self, publication): |
454 | + self.assertNotEqual( |
455 | + publication.binarypackagerelease.files[0].libraryfile.expires, |
456 | + None, |
457 | + "lfa.expires should be set, but it's not.") |
458 | + |
459 | + def assertBinaryNotExpired(self, publication): |
460 | + self.assertEqual( |
461 | + publication.binarypackagerelease.files[0].libraryfile.expires, |
462 | + None, |
463 | + "lfa.expires should be None, but it's not.") |
464 | + |
465 | + def assertSourceExpired(self, publication): |
466 | + self.assertNotEqual( |
467 | + publication.sourcepackagerelease.files[0].libraryfile.expires, |
468 | + None, |
469 | + "lfa.expires should be set, but it's not.") |
470 | + |
471 | + def assertSourceNotExpired(self, publication): |
472 | + self.assertEqual( |
473 | + publication.sourcepackagerelease.files[0].libraryfile.expires, |
474 | None, |
475 | "lfa.expires should be None, but it's not.") |
476 | |
477 | def testNoExpirationWithNoDateremoved(self): |
478 | """Test that no expiring happens if no dateremoved set.""" |
479 | pkg1 = self.stp.getPubSource( |
480 | - sourcename="pkg1", architecturehintlist="i386", archive=self.ppa) |
481 | + sourcename="pkg1", architecturehintlist="i386", archive=self.ppa, |
482 | + dateremoved=None) |
483 | [pub] = self.stp.getPubBinaries( |
484 | pub_source=pkg1, dateremoved=None, archive=self.ppa) |
485 | |
486 | self.runScript() |
487 | - self.assertNotExpired(pub) |
488 | + self.assertSourceNotExpired(pkg1) |
489 | + self.assertBinaryNotExpired(pub) |
490 | |
491 | def testNoExpirationWithDateUnderThreshold(self): |
492 | """Test no expiring if dateremoved too recent.""" |
493 | pkg2 = self.stp.getPubSource( |
494 | - sourcename="pkg2", architecturehintlist="i386", archive=self.ppa) |
495 | + sourcename="pkg2", architecturehintlist="i386", archive=self.ppa, |
496 | + dateremoved=self.under_threshold_date) |
497 | [pub] = self.stp.getPubBinaries( |
498 | pub_source=pkg2, dateremoved=self.under_threshold_date, |
499 | archive=self.ppa) |
500 | |
501 | self.runScript() |
502 | - self.assertNotExpired(pub) |
503 | + self.assertSourceNotExpired(pkg2) |
504 | + self.assertBinaryNotExpired(pub) |
505 | |
506 | def testExpirationWithDateOverThreshold(self): |
507 | """Test expiring works if dateremoved old enough.""" |
508 | pkg3 = self.stp.getPubSource( |
509 | - sourcename="pkg3", architecturehintlist="i386", archive=self.ppa) |
510 | + sourcename="pkg3", architecturehintlist="i386", archive=self.ppa, |
511 | + dateremoved=self.over_threshold_date) |
512 | [pub] = self.stp.getPubBinaries( |
513 | pub_source=pkg3, dateremoved=self.over_threshold_date, |
514 | archive=self.ppa) |
515 | |
516 | self.runScript() |
517 | - self.assertExpired(pub) |
518 | + self.assertSourceExpired(pkg3) |
519 | + self.assertBinaryExpired(pub) |
520 | |
521 | def testNoExpirationWithDateOverThresholdAndOtherValidPublication(self): |
522 | """Test no expiry if dateremoved old enough but other publication.""" |
523 | pkg4 = self.stp.getPubSource( |
524 | - sourcename="pkg4", architecturehintlist="i386", archive=self.ppa) |
525 | + sourcename="pkg4", architecturehintlist="i386", archive=self.ppa, |
526 | + dateremoved=self.over_threshold_date) |
527 | + other_source = pkg4.copyTo( |
528 | + pkg4.distroseries, pkg4.pocket, self.ppa2) |
529 | + other_source.secure_record.dateremoved = None |
530 | [pub] = self.stp.getPubBinaries( |
531 | pub_source=pkg4, dateremoved=self.over_threshold_date, |
532 | archive=self.ppa) |
533 | @@ -130,16 +152,21 @@ |
534 | other_binary.secure_record.dateremoved = None |
535 | |
536 | self.runScript() |
537 | - self.assertNotExpired(pub) |
538 | + self.assertSourceNotExpired(pkg4) |
539 | + self.assertBinaryNotExpired(pub) |
540 | |
541 | def testNoExpirationWithDateOverThresholdAndOtherPubUnderThreshold(self): |
542 | """Test no expiring. |
543 | - |
544 | + |
545 | Test no expiring if dateremoved old enough but other publication |
546 | not over date threshold. |
547 | """ |
548 | pkg5 = self.stp.getPubSource( |
549 | - sourcename="pkg5", architecturehintlist="i386", archive=self.ppa) |
550 | + sourcename="pkg5", architecturehintlist="i386", archive=self.ppa, |
551 | + dateremoved=self.over_threshold_date) |
552 | + other_source = pkg5.copyTo( |
553 | + pkg5.distroseries, pkg5.pocket, self.ppa2) |
554 | + other_source.secure_record.dateremoved = self.under_threshold_date |
555 | [pub] = self.stp.getPubBinaries( |
556 | pub_source=pkg5, dateremoved=self.over_threshold_date, |
557 | archive=self.ppa) |
558 | @@ -148,67 +175,77 @@ |
559 | other_binary.secure_record.dateremoved = self.under_threshold_date |
560 | |
561 | self.runScript() |
562 | - self.assertNotExpired(pub) |
563 | + self.assertSourceNotExpired(pkg5) |
564 | + self.assertBinaryNotExpired(pub) |
565 | |
566 | def _setUpExpirablePublications(self, archive=None): |
567 | """Helper to set up two publications that are both expirable.""" |
568 | if archive is None: |
569 | archive = self.ppa |
570 | pkg5 = self.stp.getPubSource( |
571 | - sourcename="pkg5", architecturehintlist="i386", archive=archive) |
572 | + sourcename="pkg5", architecturehintlist="i386", archive=archive, |
573 | + dateremoved=self.over_threshold_date) |
574 | + other_source = pkg5.copyTo( |
575 | + pkg5.distroseries, pkg5.pocket, self.ppa2) |
576 | + other_source.secure_record.dateremoved = self.over_threshold_date |
577 | [pub] = self.stp.getPubBinaries( |
578 | pub_source=pkg5, dateremoved=self.over_threshold_date, |
579 | archive=archive) |
580 | [other_binary] = pub.copyTo( |
581 | pub.distroarchseries.distroseries, pub.pocket, self.ppa2) |
582 | other_binary.secure_record.dateremoved = self.over_threshold_date |
583 | - return pub |
584 | + return pkg5, pub |
585 | |
586 | def testNoExpirationWithDateOverThresholdAndOtherPubOverThreshold(self): |
587 | """Test expiring works. |
588 | - |
589 | + |
590 | Test expiring works if dateremoved old enough and other publication |
591 | is over date threshold. |
592 | """ |
593 | - pub = self._setUpExpirablePublications() |
594 | + source, binary = self._setUpExpirablePublications() |
595 | self.runScript() |
596 | - self.assertExpired(pub) |
597 | + self.assertSourceExpired(source) |
598 | + self.assertBinaryExpired(binary) |
599 | |
600 | def testBlacklistingWorks(self): |
601 | """Test that blacklisted PPAs are not expired.""" |
602 | - pub = self._setUpExpirablePublications() |
603 | + source, binary = self._setUpExpirablePublications() |
604 | script = self.getScript() |
605 | script.blacklist = ["cprov",] |
606 | self.layer.txn.commit() |
607 | self.layer.switchDbUser(self.dbuser) |
608 | script.main() |
609 | - self.assertNotExpired(pub) |
610 | + self.assertSourceNotExpired(source) |
611 | + self.assertBinaryNotExpired(binary) |
612 | |
613 | def testPrivatePPAsNotExpired(self): |
614 | """Test that private PPAs are not expired.""" |
615 | self.ppa.private = True |
616 | self.ppa.buildd_secret = "foo" |
617 | - pub = self._setUpExpirablePublications() |
618 | + source, binary = self._setUpExpirablePublications() |
619 | self.runScript() |
620 | - self.assertNotExpired(pub) |
621 | + self.assertSourceNotExpired(source) |
622 | + self.assertBinaryNotExpired(binary) |
623 | |
624 | def testDryRun(self): |
625 | """Test that when dryrun is specified, nothing is expired.""" |
626 | - pub = self._setUpExpirablePublications() |
627 | + source, binary = self._setUpExpirablePublications() |
628 | # We have to commit here otherwise when the script aborts it |
629 | # will remove the test publications we just created. |
630 | self.layer.txn.commit() |
631 | script = self.getScript(['--dry-run']) |
632 | self.layer.switchDbUser(self.dbuser) |
633 | script.main() |
634 | - self.assertNotExpired(pub) |
635 | + self.assertSourceNotExpired(source) |
636 | + self.assertBinaryNotExpired(binary) |
637 | |
638 | def testDoesNotAffectNonPPA(self): |
639 | """Test that expiry does not happen for non-PPA publications.""" |
640 | ubuntu_archive = getUtility(IDistributionSet)['ubuntu'].main_archive |
641 | - pub = self._setUpExpirablePublications(ubuntu_archive) |
642 | + source, binary = self._setUpExpirablePublications(ubuntu_archive) |
643 | self.runScript() |
644 | - self.assertNotExpired(pub) |
645 | + self.assertSourceNotExpired(source) |
646 | + self.assertBinaryNotExpired(binary) |
647 | |
648 | |
649 | def test_suite(): |
650 | |
651 | === modified file 'lib/lp/translations/browser/product.py' |
652 | --- lib/lp/translations/browser/product.py 2010-01-15 14:44:39 +0000 |
653 | +++ lib/lp/translations/browser/product.py 2010-01-20 20:27:21 +0000 |
654 | @@ -16,7 +16,8 @@ |
655 | LaunchpadView, Link, canonical_url, enabled_with_permission) |
656 | from canonical.launchpad.webapp.authorization import check_permission |
657 | from canonical.launchpad.webapp.menu import NavigationMenu |
658 | -from lp.registry.interfaces.product import IProduct, IProductSeries |
659 | +from lp.registry.interfaces.product import IProduct |
660 | +from lp.registry.interfaces.productseries import IProductSeries |
661 | from lp.registry.browser.product import ProductEditView |
662 | from lp.translations.browser.translations import TranslationsMixin |
663 | |
664 | @@ -63,7 +64,11 @@ |
665 | class ProductChangeTranslatorsView(TranslationsMixin, ProductEditView): |
666 | label = "Set permissions and policies" |
667 | page_title = "Permissions and policies" |
668 | - field_names = ["translationgroup", "translationpermission"] |
669 | + field_names = [ |
670 | + "translationgroup", |
671 | + "translationpermission", |
672 | + "translation_focus" |
673 | + ] |
674 | |
675 | @property |
676 | def cancel_url(self): |
677 | |
678 | === added directory 'lib/lp/translations/stories/translationfocus' |
679 | === added file 'lib/lp/translations/stories/translationfocus/xx-product-translationfocus.txt' |
680 | --- lib/lp/translations/stories/translationfocus/xx-product-translationfocus.txt 1970-01-01 00:00:00 +0000 |
681 | +++ lib/lp/translations/stories/translationfocus/xx-product-translationfocus.txt 2010-01-20 20:27:21 +0000 |
682 | @@ -0,0 +1,87 @@ |
683 | +The translation focus of a product can be explicitly set to a specific series. |
684 | +When not set, launchpad recommends the development focus to translate. |
685 | + |
686 | + >>> login('admin@canonical.com') |
687 | + >>> fooproject = factory.makeProduct(name="fooproject") |
688 | + >>> fooproject.official_rosetta = True |
689 | + >>> fooproject_trunk = fooproject.getSeries("trunk") |
690 | + >>> fooproject_url = canonical_url( |
691 | + ... fooproject, rootsite="translations") |
692 | + >>> logout() |
693 | + |
694 | +Only admin users are able to change the translation focus of a product. |
695 | +Unprivileged users can see the recommended series for translation, |
696 | +but have no access to the 'Change permissions' menu. |
697 | + |
698 | + >>> admin_browser.open(fooproject_url) |
699 | + >>> print extract_text( |
700 | + ... find_tags_by_class(admin_browser.contents, 'edit sprite')[0]) |
701 | + Change permissions |
702 | + |
703 | + >>> browser.open(fooproject_url) |
704 | + >>> print extract_text( |
705 | + ... find_tags_by_class(browser.contents, 'edit sprite')[0]) |
706 | + Traceback (most recent call last): |
707 | + ... |
708 | + IndexError: list index out of range |
709 | + |
710 | +== Setting the translation focus == |
711 | + |
712 | + >>> login('admin@canonical.com') |
713 | + >>> from zope.security.proxy import removeSecurityProxy |
714 | + >>> pot_main = factory.makePOTemplate( |
715 | + ... productseries=fooproject_trunk, name="pot1") |
716 | + >>> removeSecurityProxy(pot_main).messagecount = 10 |
717 | + >>> pofile = factory.makePOFile("pt_BR", potemplate=pot_main) |
718 | + >>> logout() |
719 | + |
720 | +When the translation focus is not set, Launchpad suggests the |
721 | +development focus as the current series to be translated. |
722 | +It needs to be translatable. |
723 | + |
724 | + >>> print fooproject.translation_focus |
725 | + None |
726 | + |
727 | + >>> browser.open(fooproject_url) |
728 | + >>> print extract_text(find_tags_by_class(browser.contents, 'portlet')[0]) |
729 | + Translation details... |
730 | + Launchpad currently recommends translating... Fooproject trunk series. |
731 | + ... |
732 | + |
733 | +We can set an untranslatable series as the translation focus, but Launchpad |
734 | +won't consider it because there'll be nothing to translate. |
735 | + |
736 | + >>> login('admin@canonical.com') |
737 | + >>> fooproject_untranslatableseries = factory.makeProductSeries( |
738 | + ... product=fooproject, |
739 | + ... name="untranslatable-series") |
740 | + >>> fooproject.translation_focus = fooproject_untranslatableseries |
741 | + >>> logout() |
742 | + |
743 | + >>> print removeSecurityProxy(fooproject.translation_focus.name) |
744 | + untranslatable-series |
745 | + |
746 | + >>> browser.open(fooproject_url) |
747 | + >>> print extract_text(find_tags_by_class(browser.contents, 'portlet')[0]) |
748 | + Translation details... |
749 | + Launchpad currently recommends translating... Fooproject trunk series. |
750 | + ... |
751 | + |
752 | +We need to create a translatable series so we can set it as translation focus. |
753 | + |
754 | + >>> login('admin@canonical.com') |
755 | + >>> fooproject_otherseries = factory.makeProductSeries(product=fooproject, |
756 | + ... name="other-series") |
757 | + >>> pot_other = factory.makePOTemplate( |
758 | + ... productseries=fooproject_otherseries, name="pot2") |
759 | + >>> removeSecurityProxy(pot_other).messagecount = 10 |
760 | + >>> pofile1 = factory.makePOFile('pt_BR', potemplate=pot_other) |
761 | + |
762 | + >>> fooproject.translation_focus = fooproject_otherseries |
763 | + >>> logout() |
764 | + |
765 | + >>> browser.open(fooproject_url) |
766 | + >>> print extract_text(find_tags_by_class(browser.contents, 'portlet')[0]) |
767 | + Translation details... |
768 | + Launchpad currently recommends translating... Fooproject other-series series. |
769 | + ... |
770 | |
771 | === added file 'lib/lp/translations/stories/webservice/xx-translationfocus.txt' |
772 | --- lib/lp/translations/stories/webservice/xx-translationfocus.txt 1970-01-01 00:00:00 +0000 |
773 | +++ lib/lp/translations/stories/webservice/xx-translationfocus.txt 2010-01-20 20:27:21 +0000 |
774 | @@ -0,0 +1,34 @@ |
775 | += Translation focus = |
776 | + |
777 | +The translation focus of a project is the series chosen as the preferred |
778 | +one to be translated. It's optional. When not set, Launchpad suggests |
779 | +the development focus as the preferred series to translate, which is |
780 | +outside the scope of this test. |
781 | + |
782 | + >>> evolution = webservice.get('/evolution').jsonBody() |
783 | + >>> print evolution['development_focus_link'] |
784 | + http://.../evolution/trunk |
785 | + >>> print evolution['translation_focus_link'] |
786 | + None |
787 | + |
788 | +It's possible to set the translation focus through the API |
789 | +if you're an admin. The translation focus should be a project series. |
790 | + |
791 | + >>> from simplejson import dumps |
792 | + >>> print webservice.patch( |
793 | + ... evolution['self_link'], 'application/json', |
794 | + ... dumps({'translation_focus_link' : |
795 | + ... evolution['development_focus_link']})) |
796 | + HTTP/1.1 209 Content Returned |
797 | + ... |
798 | + |
799 | + >>> print webservice.get('/evolution').jsonBody()['translation_focus_link'] |
800 | + http://.../evolution/trunk |
801 | + |
802 | +Unprivileged users cannot set the translation focus. |
803 | + |
804 | + >>> print user_webservice.patch( |
805 | + ... evolution['self_link'], 'application/json', |
806 | + ... dumps({'translation_focus_link': None})) |
807 | + HTTP... 401 Unauthorized |
808 | + ... |
= Summary =
This branch fixes bug 422056, adding a way to set translation focus of a product, as we can do with distributions.
I've started another branch, so there's another one that bac reviewed, and his suggestions were applied here. /code.edge. launchpad. net/~ursinha/ launchpad/ add-translation -focus/ +merge/ 15520.
Here's the old MP: https:/
== Proposed fix ==
Adding a field with all series of a project on its +changetranslators page.
== Implementation details ==
The implementation is simple: I've added the translation_focus field in product model and interface, and changed the primary_ translatable to choose translation_focus if available (and translatable).
== Tests ==
./bin/test -vvt lp.translations.*
./bin/test -vvt lp.registry.*
== Demo and Q/A ==
Preparing: /launchpad. dev/alsa- utils
1) Go to a project page, such as https:/
2) Add another series to the project
A) Choosing non-translatable series as translation focus: /translations. launchpad. dev/alsa- utils /translations. launchpad. dev/alsa- utils/+ changetranslato rs
1) Go to the translations page of the project, https:/
2) As an admin user, you'll see the Change permissions option, choose it: https:/
3) Choose the series you just added, and save it
Result: You should notice the message in the project translations page saying "Launchpad currently recommends translating alsa-utils trunk series", which means that it chose the development focus series because the one chosen as translation focus isn't translatable.
B) Choosing translatable series as translation focus:
1) Add information to the series you added so it becomes translatable
Result: You should notice the message in the project translations page saying "Launchpad currently recommends translating <the series you added>", which means that it chose the translation focus now that it's a translatable series.
C) Choosing no translation focus at all:
1) Choose no series as translation focus
Result: You should notice that it will recommend to translate the development focus.
= Launchpad lint =
Checking for conflicts. and issues in doctests and templates.
Running jslint, xmllint, pyflakes, and pylint.
Using normal rules.
Linting changed files: registry/ configure. zcml registry/ interfaces/ product. py translations/ stories/ translationfocu s/xx-product- translationfocu s.txt registry/ stories/ webservice/ xx-project- registry. txt translations/ browser/ product. py registry/ model/product. py translations/ stories/ webservice/ xx-translationf ocus.txt
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
lib/lp/
== Pylint notices ==
lib/lp/ registry/ interfaces/ product. py fields' (No module named restful) interface' (No module named restful) declarations' (No module named restful) createProduct] Operator not preceded by a space oject=' freshmeat_ project' , wikiurl='wiki_url', ='download_ url', project= 'sourceforge_ project' , lang='programm. ..
34: [F0401] Unable to import 'lazr.enum' (No module named enum)
72: [F0401] Unable to import 'lazr.restful.
73: [F0401] Unable to import 'lazr.restful.
74: [F0401] Unable to import 'lazr.restful.
778: [C0322, IProductSet.
freshmeatpr
^
downloadurl
sourceforge
programming