Merge lp:~cprov/launchpad/strict-usernames into lp:launchpad

Proposed by Celso Providelo
Status: Needs review
Proposed branch: lp:~cprov/launchpad/strict-usernames
Merge into: lp:launchpad
Diff against target: 674 lines (+179/-77)
17 files modified
lib/lp/app/validators/name.py (+63/-0)
lib/lp/bugs/browser/tests/test_bugtarget_filebug.py (+3/-3)
lib/lp/bugs/doc/bugsummary.txt (+4/-4)
lib/lp/bugs/doc/bugtracker-person.txt (+1/-2)
lib/lp/bugs/model/bugtracker.py (+12/-3)
lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py (+2/-2)
lib/lp/code/model/tests/test_branchlookup.py (+21/-21)
lib/lp/code/model/tests/test_gitlookup.py (+9/-9)
lib/lp/registry/browser/tests/test_person.py (+20/-0)
lib/lp/registry/browser/tests/test_team.py (+4/-2)
lib/lp/registry/doc/person.txt (+2/-2)
lib/lp/registry/interfaces/person.py (+6/-3)
lib/lp/registry/model/person.py (+7/-7)
lib/lp/registry/tests/test_nickname.py (+4/-4)
lib/lp/registry/tests/test_notification.py (+10/-10)
lib/lp/services/database/tests/test_transaction_policy.py (+2/-1)
lib/lp/translations/stories/standalone/xx-person-activity.txt (+9/-4)
To merge this branch: bzr merge lp:~cprov/launchpad/strict-usernames
Reviewer Review Type Date Requested Status
Celso Providelo (community) Disapprove
Colin Watson (community) Needs Fixing
Review via email: mp+366985@code.launchpad.net

Commit message

Setting stricter restrictions for LP usernames.

Description of the change

This is part of wider effort to have cleaner usernames across all Canonical services.

https://docs.google.com/document/d/11qu6Mc6My6AKvIn8-VnnbA5Esp7IvqHMldeFpomUVSA/edit

The current goal is to stop new users to get in with "unclean" names. SSO already forces clean names for new users, with this MP so will LP.

Next we will introduce a way to allow Store users to pick a alternative "clean" username and eventually switch to it.

To post a comment you must log in.
lp:~cprov/launchpad/strict-usernames updated
18961. By Celso Providelo

Ensure legacy username field is not omitted in test.

Revision history for this message
Colin Watson (cjwatson) :
review: Needs Fixing
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Celso Providelo (cprov) wrote :

There is a less intrusive approach to prevent 'unclean' usernames in https://code.launchpad.net/~cprov/launchpad/cleaner-usernames/+merge/367101

review: Disapprove

Unmerged revisions

18961. By Celso Providelo

Ensure legacy username field is not omitted in test.

18960. By Celso Providelo

Extend IBugTracker.ensurePersonForSelf() to perform extra text mangling to fit into the 32-char username limit.

18959. By Celso Providelo

Mechanical test fixes, len(username) >= 3

18958. By Celso Providelo

Legacy usernames do not block account details changes.

18957. By Celso Providelo

Stricter (and tastier) LP usernames.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/validators/name.py'
2--- lib/lp/app/validators/name.py 2012-11-29 05:52:36 +0000
3+++ lib/lp/app/validators/name.py 2019-05-07 00:19:42 +0000
4@@ -19,6 +19,7 @@
5 valid_name_pattern = re.compile(r"^[a-z0-9][a-z0-9\+\.\-]+$")
6 valid_bug_name_pattern = re.compile(r"^[a-z][a-z0-9\+\.\-]+$")
7 invalid_name_pattern = re.compile(r"^[^a-z0-9]+|[^a-z0-9\\+\\.\\-]+")
8+valid_username_pattern = re.compile(r"(?!^[\d-]+$)^[a-z\d](-?[a-z\d]){2,31}$")
9
10
11 def sanitize_name(name):
12@@ -67,6 +68,53 @@
13 return False
14
15
16+def valid_username(name):
17+ """Return True if the username is valid, otherwise False.
18+
19+ Lauchpad username (`Person.name`) attribute is designed to be used
20+ in URLs across products (via SSO) and thus complies with very specific
21+ validation (and taste) criteria.
22+
23+ >>> valid_username('hello')
24+ True
25+ >>> valid_username('a12')
26+ True
27+ >>> valid_username('a-12')
28+ True
29+ >>> valid_username('a-1-2')
30+ True
31+ >>> valid_username('a' * 32)
32+ True
33+
34+
35+ >>> valid_username('a-1')
36+ False
37+ >>> valid_username('1-1')
38+ False
39+ >>> valid_username('a-a')
40+ False
41+ >>> valid_username('he--o')
42+ False
43+ >>> valid_username('-ello')
44+ False
45+ >>> valid_username('hell-')
46+ False
47+ >>> valid_username('helLo')
48+ False
49+ >>> valid_username('he.lo')
50+ False
51+ >>> valid_username('he+lo')
52+ False
53+ >>> valid_username('hh')
54+ False
55+ >>> valid_username(33 * 'h')
56+ False
57+ """
58+ if valid_username_pattern.match(name):
59+ return True
60+ return False
61+
62+
63 def name_validator(name):
64 """Return True if the name is valid, or raise a
65 LaunchpadValidationError.
66@@ -97,3 +145,18 @@
67
68 raise LaunchpadValidationError(structured(message))
69 return True
70+
71+
72+def username_validator(name):
73+ """Return True if the `Person.name` is valid, or raise a
74+ LaunchpadValidationError.
75+ """
76+ if not valid_username(name):
77+ message = _(dedent("""
78+ Invalid username '${name}'. Usernames must be at least two characters long
79+ and start with a letter or number. All letters must be lower-case.
80+ The character <samp>-</samp> is allowed after the first character."""),
81+ mapping={'name': html_escape(name)})
82+
83+ raise LaunchpadValidationError(structured(message))
84+ return True
85
86=== modified file 'lib/lp/bugs/browser/tests/test_bugtarget_filebug.py'
87--- lib/lp/bugs/browser/tests/test_bugtarget_filebug.py 2017-10-21 18:14:14 +0000
88+++ lib/lp/bugs/browser/tests/test_bugtarget_filebug.py 2019-05-07 00:19:42 +0000
89@@ -635,12 +635,12 @@
90
91 def test_subscribers_with_name(self):
92 # The extra data can add bug subscribers via Launchpad Id..
93- subscriber_1 = self.factory.makePerson(name='me')
94+ subscriber_1 = self.factory.makePerson(name='you')
95 subscriber_2 = self.factory.makePerson(name='him')
96- token = self.process_extra_data(command='Subscribers: me him')
97+ token = self.process_extra_data(command='Subscribers: you him')
98 view = self.create_initialized_view()
99 view.publishTraverse(view.request, token)
100- self.assertContentEqual(['me', 'him'], view.extra_data.subscribers)
101+ self.assertContentEqual(['you', 'him'], view.extra_data.subscribers)
102 view.submit_bug_action.success(self.get_form())
103 transaction.commit()
104 bug = view.added_bug
105
106=== modified file 'lib/lp/bugs/doc/bugsummary.txt'
107--- lib/lp/bugs/doc/bugsummary.txt 2018-06-29 23:10:57 +0000
108+++ lib/lp/bugs/doc/bugsummary.txt 2019-05-07 00:19:42 +0000
109@@ -416,9 +416,9 @@
110 For our examples, first create three people. person_z will not
111 be subscribed to any bugs, so will have no access to any private bugs.
112
113- >>> person_a = factory.makePerson(name='p-a')
114- >>> person_b = factory.makePerson(name='p-b')
115- >>> person_z = factory.makePerson(name='p-z')
116+ >>> person_a = factory.makePerson(name='pe-a')
117+ >>> person_b = factory.makePerson(name='pe-b')
118+ >>> person_z = factory.makePerson(name='pe-z')
119 >>> owner = factory.makePerson(name='own')
120
121 Create some teams too. team_a just has person_a as a member. team_c
122@@ -470,7 +470,7 @@
123 --------------------------------------------------------------------
124 prod ps dist ds spn tag mile status import pa gra pol #
125 --------------------------------------------------------------------
126- x x di-p x x x x New Undeci F p-b x 1
127+ x x di-p x x x x New Undeci F pe-b x 1
128 x x di-p x x x x New Undeci F own x 3
129 x x di-p x x x x New Undeci F t-a x 1
130 x x di-p x x x x New Undeci F t-c x 1
131
132=== modified file 'lib/lp/bugs/doc/bugtracker-person.txt'
133--- lib/lp/bugs/doc/bugtracker-person.txt 2018-06-29 23:10:57 +0000
134+++ lib/lp/bugs/doc/bugtracker-person.txt 2019-05-07 00:19:42 +0000
135@@ -117,7 +117,7 @@
136 ... creation_comment='whilst testing ensurePersonForSelf().')
137
138 >>> print(noemail_person.name)
139- no-email-person-bugzilla-checkwatches
140+ no-email-bugzilla-checkwatches
141
142 A BugTrackerPerson record will have been created to map
143 'No-Email-Person' on our example bugtracker to
144@@ -183,4 +183,3 @@
145
146 >>> print(new_person.name)
147 noemail-bugzilla-checkwatches-1
148-
149
150=== modified file 'lib/lp/bugs/model/bugtracker.py'
151--- lib/lp/bugs/model/bugtracker.py 2019-02-23 08:15:45 +0000
152+++ lib/lp/bugs/model/bugtracker.py 2019-05-07 00:19:42 +0000
153@@ -645,9 +645,18 @@
154 if bugtracker_person is not None:
155 return bugtracker_person.person
156
157- # Generate a valid Launchpad name for the Person.
158- base_canonical_name = (
159- "%s-%s" % (sanitize_name(display_name.lower()), self.name))
160+ # Generate a valid Launchpad name for the Person extracting an
161+ # username from the given display_name smaller enough to be joined
162+ # with the tracker name and up to 2 digits (99) for disambiguation
163+ # and fit in 32 characters limit and avoid consecutive hyphens.
164+ remaining = 32 - len(self.name) - 2
165+ username = sanitize_name(display_name.lower())[:remaining]
166+ username = username if not username.endswith('-') else username[:-1]
167+
168+ # XXX cprov 2019-05-06: bugtracker names may be longer than 32-chars,
169+ # we need extra name-cropping if this simple strategy gets accepted.
170+
171+ base_canonical_name = "%s-%s" % (username, self.name)
172 canonical_name = base_canonical_name
173
174 person_set = getUtility(IPersonSet)
175
176=== modified file 'lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py'
177--- lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py 2018-01-26 14:38:31 +0000
178+++ lib/lp/bugs/model/tests/test_bugsubscriptioninfo.py 2019-05-07 00:19:42 +0000
179@@ -54,8 +54,8 @@
180
181 layer = DatabaseFunctionalLayer
182
183- name_pairs = ("A", "xa"), ("C", "xd"), ("B", "xb"), ("C", "xc")
184- name_pairs_sorted = ("A", "xa"), ("B", "xb"), ("C", "xc"), ("C", "xd")
185+ name_pairs = ("A", "xaa"), ("C", "xdd"), ("B", "xbb"), ("C", "xcc")
186+ name_pairs_sorted = ("A", "xaa"), ("B", "xbb"), ("C", "xcc"), ("C", "xdd")
187
188 def setUp(self):
189 super(TestSubscriptionRelatedSets, self).setUp()
190
191=== modified file 'lib/lp/code/model/tests/test_branchlookup.py'
192--- lib/lp/code/model/tests/test_branchlookup.py 2017-10-04 01:53:48 +0000
193+++ lib/lp/code/model/tests/test_branchlookup.py 2019-05-07 00:19:42 +0000
194@@ -285,7 +285,7 @@
195 # XXX: JonathanLange 2009-01-13 spec=package-branches: This test is
196 # bad because it assumes that the interesting branches for testing are
197 # product branches.
198- owner = self.factory.makePerson(name='aa')
199+ owner = self.factory.makePerson(name='aaa')
200 product = self.factory.makeProduct('b')
201 return self.factory.makeProductBranch(
202 owner=owner, product=product, name='c')
203@@ -298,14 +298,14 @@
204 # Trailing slashes are stripped from the url prior to searching.
205 branch = self.makeProductBranch()
206 lookup = getUtility(IBranchLookup)
207- branch2 = lookup.getByUrl('http://bazaar.launchpad.dev/~aa/b/c/')
208+ branch2 = lookup.getByUrl('http://bazaar.launchpad.dev/~aaa/b/c/')
209 self.assertEqual(branch, branch2)
210
211 def test_getByUrl_with_http(self):
212 """getByUrl recognizes LP branches for http URLs."""
213 branch = self.makeProductBranch()
214 branch_set = getUtility(IBranchLookup)
215- branch2 = branch_set.getByUrl('http://bazaar.launchpad.dev/~aa/b/c')
216+ branch2 = branch_set.getByUrl('http://bazaar.launchpad.dev/~aaa/b/c')
217 self.assertEqual(branch, branch2)
218
219 def test_getByUrl_with_ssh(self):
220@@ -313,14 +313,14 @@
221 branch = self.makeProductBranch()
222 branch_set = getUtility(IBranchLookup)
223 branch2 = branch_set.getByUrl(
224- 'bzr+ssh://bazaar.launchpad.dev/~aa/b/c')
225+ 'bzr+ssh://bazaar.launchpad.dev/~aaa/b/c')
226 self.assertEqual(branch, branch2)
227
228 def test_getByUrl_with_sftp(self):
229 """getByUrl recognizes LP branches for sftp URLs."""
230 branch = self.makeProductBranch()
231 branch_set = getUtility(IBranchLookup)
232- branch2 = branch_set.getByUrl('sftp://bazaar.launchpad.dev/~aa/b/c')
233+ branch2 = branch_set.getByUrl('sftp://bazaar.launchpad.dev/~aaa/b/c')
234 self.assertEqual(branch, branch2)
235
236 def test_getByUrl_with_ftp(self):
237@@ -330,15 +330,15 @@
238 """
239 self.makeProductBranch()
240 branch_set = getUtility(IBranchLookup)
241- branch2 = branch_set.getByUrl('ftp://bazaar.launchpad.dev/~aa/b/c')
242+ branch2 = branch_set.getByUrl('ftp://bazaar.launchpad.dev/~aaa/b/c')
243 self.assertIs(None, branch2)
244
245 def test_getByURL_with_lp_prefix(self):
246 """lp: URLs for the configured prefix are supported."""
247 branch_set = getUtility(IBranchLookup)
248- url = '%s~aa/b/c' % config.codehosting.bzr_lp_prefix
249+ url = '%s~aaa/b/c' % config.codehosting.bzr_lp_prefix
250 self.assertIs(None, branch_set.getByUrl(url))
251- owner = self.factory.makePerson(name='aa')
252+ owner = self.factory.makePerson(name='aaa')
253 product = self.factory.makeProduct('b')
254 branch2 = branch_set.getByUrl(url)
255 self.assertIs(None, branch2)
256@@ -352,13 +352,13 @@
257 branch_set = getUtility(IBranchLookup)
258 branch = self.makeProductBranch()
259 self.pushConfig('codehosting', lp_url_hosts='production,,')
260- branch2 = branch_set.getByUrl('lp://staging/~aa/b/c')
261- self.assertIs(None, branch2)
262- branch2 = branch_set.getByUrl('lp://asdf/~aa/b/c')
263- self.assertIs(None, branch2)
264- branch2 = branch_set.getByUrl('lp:~aa/b/c')
265+ branch2 = branch_set.getByUrl('lp://staging/~aaa/b/c')
266+ self.assertIs(None, branch2)
267+ branch2 = branch_set.getByUrl('lp://asdf/~aaa/b/c')
268+ self.assertIs(None, branch2)
269+ branch2 = branch_set.getByUrl('lp:~aaa/b/c')
270 self.assertEqual(branch, branch2)
271- branch2 = branch_set.getByUrl('lp://production/~aa/b/c')
272+ branch2 = branch_set.getByUrl('lp://production/~aaa/b/c')
273 self.assertEqual(branch, branch2)
274
275 def test_getByUrl_with_alias(self):
276@@ -542,13 +542,13 @@
277 # product component isn't, then `NoSuchBranch` if the first two
278 # components are found.
279 self.assertRaises(
280- NoSuchPerson, self.branch_lookup.getByLPPath, '~aa/bb/c')
281- self.factory.makePerson(name='aa')
282+ NoSuchPerson, self.branch_lookup.getByLPPath, '~aaa/bb/c')
283+ self.factory.makePerson(name='aaa')
284 self.assertRaises(
285- NoSuchProduct, self.branch_lookup.getByLPPath, '~aa/bb/c')
286+ NoSuchProduct, self.branch_lookup.getByLPPath, '~aaa/bb/c')
287 self.factory.makeProduct(name='bb')
288 self.assertRaises(
289- NoSuchBranch, self.branch_lookup.getByLPPath, '~aa/bb/c')
290+ NoSuchBranch, self.branch_lookup.getByLPPath, '~aaa/bb/c')
291
292 def test_private_branch(self):
293 # If the unique name refers to an invisible branch, getByLPPath raises
294@@ -590,10 +590,10 @@
295 # match an existing person, and `NoSuchBranch` if the last component
296 # doesn't match an existing branch.
297 self.assertRaises(
298- NoSuchPerson, self.branch_lookup.getByLPPath, '~aa/+junk/c')
299- self.factory.makePerson(name='aa')
300+ NoSuchPerson, self.branch_lookup.getByLPPath, '~aaa/+junk/c')
301+ self.factory.makePerson(name='aaa')
302 self.assertRaises(
303- NoSuchBranch, self.branch_lookup.getByLPPath, '~aa/+junk/c')
304+ NoSuchBranch, self.branch_lookup.getByLPPath, '~aaa/+junk/c')
305
306 def test_resolve_personal_branch_unique_name(self):
307 # getByLPPath returns the branch, no trailing path and no series if
308
309=== modified file 'lib/lp/code/model/tests/test_gitlookup.py'
310--- lib/lp/code/model/tests/test_gitlookup.py 2018-07-23 10:28:33 +0000
311+++ lib/lp/code/model/tests/test_gitlookup.py 2019-05-07 00:19:42 +0000
312@@ -195,7 +195,7 @@
313 self.lookup = getUtility(IGitLookup)
314
315 def makeProjectRepository(self):
316- owner = self.factory.makePerson(name="aa")
317+ owner = self.factory.makePerson(name="aaa")
318 project = self.factory.makeProduct(name="bb")
319 return self.factory.makeGitRepository(
320 owner=owner, target=project, name="cc")
321@@ -211,47 +211,47 @@
322 # Trailing slashes are stripped from the URL prior to searching.
323 repository = self.makeProjectRepository()
324 self.assertUrlMatches(
325- "git://git.launchpad.dev/~aa/bb/+git/cc/", repository)
326+ "git://git.launchpad.dev/~aaa/bb/+git/cc/", repository)
327
328 def test_getByUrl_with_trailing_segments(self):
329 # URLs with trailing segments beyond the repository are rejected.
330 self.makeProjectRepository()
331 self.assertIsNone(
332- self.lookup.getByUrl("git://git.launchpad.dev/~aa/bb/+git/cc/foo"))
333+ self.lookup.getByUrl("git://git.launchpad.dev/~aaa/bb/+git/cc/foo"))
334
335 def test_getByUrl_with_git(self):
336 # getByUrl recognises LP repositories for git URLs.
337 repository = self.makeProjectRepository()
338 self.assertUrlMatches(
339- "git://git.launchpad.dev/~aa/bb/+git/cc", repository)
340+ "git://git.launchpad.dev/~aaa/bb/+git/cc", repository)
341
342 def test_getByUrl_with_git_ssh(self):
343 # getByUrl recognises LP repositories for git+ssh URLs.
344 repository = self.makeProjectRepository()
345 self.assertUrlMatches(
346- "git+ssh://git.launchpad.dev/~aa/bb/+git/cc", repository)
347+ "git+ssh://git.launchpad.dev/~aaa/bb/+git/cc", repository)
348
349 def test_getByUrl_with_https(self):
350 # getByUrl recognises LP repositories for https URLs.
351 repository = self.makeProjectRepository()
352 self.assertUrlMatches(
353- "https://git.launchpad.dev/~aa/bb/+git/cc", repository)
354+ "https://git.launchpad.dev/~aaa/bb/+git/cc", repository)
355
356 def test_getByUrl_with_ssh(self):
357 # getByUrl recognises LP repositories for ssh URLs.
358 repository = self.makeProjectRepository()
359 self.assertUrlMatches(
360- "ssh://git.launchpad.dev/~aa/bb/+git/cc", repository)
361+ "ssh://git.launchpad.dev/~aaa/bb/+git/cc", repository)
362
363 def test_getByUrl_with_ftp(self):
364 # getByUrl does not recognise LP repositories for ftp URLs.
365 self.makeProjectRepository()
366 self.assertIsNone(
367- self.lookup.getByUrl("ftp://git.launchpad.dev/~aa/bb/+git/cc"))
368+ self.lookup.getByUrl("ftp://git.launchpad.dev/~aaa/bb/+git/cc"))
369
370 def test_getByUrl_with_lp(self):
371 # getByUrl supports lp: URLs.
372- url = "lp:~aa/bb/+git/cc"
373+ url = "lp:~aaa/bb/+git/cc"
374 self.assertIsNone(self.lookup.getByUrl(url))
375 repository = self.makeProjectRepository()
376 self.assertUrlMatches(url, repository)
377
378=== modified file 'lib/lp/registry/browser/tests/test_person.py'
379--- lib/lp/registry/browser/tests/test_person.py 2018-01-02 16:10:26 +0000
380+++ lib/lp/registry/browser/tests/test_person.py 2019-05-07 00:19:42 +0000
381@@ -567,6 +567,26 @@
382 self.ppa = self.factory.makeArchive(owner=self.person)
383 self.view = create_initialized_view(self.person, '+edit')
384
385+ def test_legacy_usernames_do_not_block_edit(self):
386+ # Users with legacy usernames (less restrictive) are not forced
387+ # to update it along with other details of their account.
388+ removeSecurityProxy(self.person).name = 'legacy+name'
389+
390+ form = {
391+ 'field.display_name': 'Even Nicer DisplayName',
392+ 'field.name': 'legacy+name',
393+ 'field.actions.save': 'Save Changes',
394+ }
395+ view = create_initialized_view(self.person, '+edit', form=form)
396+
397+ notifications = view.request.response.notifications
398+ self.assertEqual(1, len(notifications))
399+ self.assertEqual(
400+ 'The changes to your personal details have been saved.',
401+ notifications[0].message)
402+ self.assertEqual('Even Nicer DisplayName', self.person.displayname)
403+ self.assertEqual('legacy+name', self.person.name)
404+
405 def createAddEmailView(self, email_address):
406 """Test helper to create +editemails view."""
407 form = {
408
409=== modified file 'lib/lp/registry/browser/tests/test_team.py'
410--- lib/lp/registry/browser/tests/test_team.py 2015-10-26 14:54:43 +0000
411+++ lib/lp/registry/browser/tests/test_team.py 2019-05-07 00:19:42 +0000
412@@ -523,7 +523,8 @@
413 team = self.factory.makeTeam(
414 membership_policy=TeamMembershipPolicy.MODERATED)
415 self.factory.grantCommercialSubscription(team)
416- team_name = self.factory.getUniqueString()
417+ # Pass a prefix to keep team name way under 32-char limit.
418+ team_name = self.factory.getUniqueString("simple-team")
419 form = {
420 'field.name': team_name,
421 'field.display_name': 'New Team',
422@@ -575,7 +576,8 @@
423
424 def test_create_team(self):
425 personset = getUtility(IPersonSet)
426- team_name = self.factory.getUniqueString()
427+ # Pass a prefix to keep team name way under 32-char limit.
428+ team_name = self.factory.getUniqueString("simple-team")
429 form = {
430 'field.name': team_name,
431 'field.display_name': 'New Team',
432
433=== modified file 'lib/lp/registry/doc/person.txt'
434--- lib/lp/registry/doc/person.txt 2016-04-14 06:14:59 +0000
435+++ lib/lp/registry/doc/person.txt 2019-05-07 00:19:42 +0000
436@@ -1346,12 +1346,12 @@
437
438 >>> foo_bar = getUtility(IPersonSet).getByEmail('foo.bar@canonical.com')
439 >>> new_person = person_set.createPersonWithoutEmail(
440- ... 'ix', PersonCreationRationale.BUGIMPORT,
441+ ... 'ixx', PersonCreationRationale.BUGIMPORT,
442 ... comment="when importing bugs", displayname="Ford Prefect",
443 ... registrant=foo_bar)
444
445 >>> print new_person.name
446- ix
447+ ixx
448
449 >>> print new_person.displayname
450 Ford Prefect
451
452=== modified file 'lib/lp/registry/interfaces/person.py'
453--- lib/lp/registry/interfaces/person.py 2018-08-02 16:12:57 +0000
454+++ lib/lp/registry/interfaces/person.py 2019-05-07 00:19:42 +0000
455@@ -101,7 +101,10 @@
456 )
457 from lp.app.validators import LaunchpadValidationError
458 from lp.app.validators.email import email_validator
459-from lp.app.validators.name import name_validator
460+from lp.app.validators.name import (
461+ name_validator,
462+ username_validator,
463+ )
464 from lp.blueprints.interfaces.specificationtarget import IHasSpecifications
465 from lp.bugs.interfaces.bugtarget import IHasBugs
466 from lp.code.interfaces.hasbranches import (
467@@ -662,11 +665,11 @@
468 name = exported(
469 PersonNameField(
470 title=_('Name'), required=True, readonly=False,
471- constraint=name_validator,
472+ constraint=username_validator,
473 description=_(
474 "A short unique name, beginning with a lower-case "
475 "letter or number, and containing only letters, "
476- "numbers, dots, hyphens, or plus signs.")))
477+ "numbers and non-consecutive hyphens.")))
478 display_name = exported(
479 StrippedTextLine(
480 title=_('Display Name'), required=True, readonly=False,
481
482=== modified file 'lib/lp/registry/model/person.py'
483--- lib/lp/registry/model/person.py 2018-11-13 03:48:13 +0000
484+++ lib/lp/registry/model/person.py 2019-05-07 00:19:42 +0000
485@@ -118,7 +118,7 @@
486 from lp.app.validators.email import valid_email
487 from lp.app.validators.name import (
488 sanitize_name,
489- valid_name,
490+ valid_username,
491 )
492 from lp.blueprints.enums import SpecificationFilter
493 from lp.blueprints.model.specification import (
494@@ -3478,7 +3478,7 @@
495 """See `IPersonSet`."""
496 if user != getUtility(ILaunchpadCelebrities).ubuntu_sso:
497 raise Unauthorized()
498- self._validateName(name)
499+ self._validateUsername(name)
500 try:
501 account = getUtility(IAccountSet).getByOpenIDIdentifier(
502 openid_identifier)
503@@ -3618,8 +3618,8 @@
504 rationale=PersonCreationRationale.USERNAME_PLACEHOLDER,
505 comment="when setting a username in SSO", account=account)
506
507- def _validateName(self, name):
508- if not valid_name(name):
509+ def _validateUsername(self, name):
510+ if not valid_username(name):
511 raise InvalidName(
512 "%s is not a valid name for a person." % name)
513 else:
514@@ -3632,7 +3632,7 @@
515 def _newPerson(self, name, displayname, hide_email_addresses,
516 rationale, comment=None, registrant=None, account=None):
517 """Create and return a new Person with the given attributes."""
518- self._validateName(name)
519+ self._validateUsername(name)
520
521 if not displayname:
522 displayname = name.capitalize()
523@@ -4312,12 +4312,12 @@
524 "%s is not a valid email address" % email_addr)
525
526 user = re.match("^(\S+)@(?:\S+)$", email_addr).groups()[0]
527- user = user.replace(".", "-").replace("_", "-")
528+ user = user.replace(".", "-").replace("_", "-").replace("+", "-")
529
530 person_set = PersonSet()
531
532 def _valid_nick(nick):
533- if not valid_name(nick):
534+ if not valid_username(nick):
535 return False
536 elif is_registered(nick):
537 return False
538
539=== modified file 'lib/lp/registry/tests/test_nickname.py'
540--- lib/lp/registry/tests/test_nickname.py 2015-10-26 14:54:43 +0000
541+++ lib/lp/registry/tests/test_nickname.py 2019-05-07 00:19:42 +0000
542@@ -36,14 +36,14 @@
543 # valid nick that doesn't start with symbols.
544 parts = ['---bar', 'foo.bar', 'foo-bar', 'foo+bar']
545 nicks = [generate_nick("%s@example.com" % part) for part in parts]
546- self.assertEqual(['bar', 'foo-bar', 'foo-bar', 'foo+bar'], nicks)
547+ self.assertEqual(['bar', 'foo-bar', 'foo-bar', 'foo-bar'], nicks)
548
549 def test_enforces_minimum_length(self):
550 # Nicks must be a minimum length. generate_nick enforces this by
551 # adding random suffixes to the required length.
552- self.assertIs(None, getUtility(IPersonSet).getByName('i'))
553- nick = generate_nick('i@example.com')
554- self.assertEqual('i-b', nick)
555+ self.assertIs(None, getUtility(IPersonSet).getByName('hi'))
556+ nick = generate_nick('hi@example.com')
557+ self.assertEqual('hi-5', nick)
558
559 def test_can_create_noncolliding_nicknames(self):
560 # Given the same email address, generate_nick doesn't recreate the
561
562=== modified file 'lib/lp/registry/tests/test_notification.py'
563--- lib/lp/registry/tests/test_notification.py 2018-02-02 10:06:24 +0000
564+++ lib/lp/registry/tests/test_notification.py 2019-05-07 00:19:42 +0000
565@@ -21,7 +21,7 @@
566 layer = DatabaseFunctionalLayer
567
568 def test_send_message(self):
569- self.factory.makePerson(email='me@eg.dom', name='me')
570+ self.factory.makePerson(email='you@eg.dom', name='you')
571 user = self.factory.makePerson(email='him@eg.dom', name='him')
572 subject = 'test subject'
573 body = 'test body'
574@@ -29,11 +29,11 @@
575 recipients_set.add(user, 'test reason', 'test rationale')
576 pop_notifications()
577 send_direct_contact_email(
578- 'me@eg.dom', recipients_set, user, subject, body)
579+ 'you@eg.dom', recipients_set, user, subject, body)
580 notifications = pop_notifications()
581 notification = notifications[0]
582 self.assertEqual(1, len(notifications))
583- self.assertEqual('Me <me@eg.dom>', notification['From'])
584+ self.assertEqual('You <you@eg.dom>', notification['From'])
585 self.assertEqual('Him <him@eg.dom>', notification['To'])
586 self.assertEqual(subject, notification['Subject'])
587 self.assertEqual(
588@@ -46,7 +46,7 @@
589 '%s' % body,
590 '-- ',
591 'This message was sent from Launchpad by',
592- 'Me (http://launchpad.dev/~me)',
593+ 'You (http://launchpad.dev/~you)',
594 'test reason.',
595 'For more information see',
596 'https://help.launchpad.net/YourAccount/ContactingPeople']),
597@@ -54,30 +54,30 @@
598
599 def test_quota_reached_error(self):
600 # An error is raised if the user has reached the daily quota.
601- self.factory.makePerson(email='me@eg.dom', name='me')
602+ self.factory.makePerson(email='you@eg.dom', name='you')
603 user = self.factory.makePerson(email='him@eg.dom', name='him')
604 recipients_set = NotificationRecipientSet()
605- old_message = self.factory.makeSignedMessage(email_address='me@eg.dom')
606+ old_message = self.factory.makeSignedMessage(email_address='you@eg.dom')
607 authorization = IDirectEmailAuthorization(user)
608 for action in range(authorization.message_quota):
609 authorization.record(old_message)
610 self.assertRaises(
611 QuotaReachedError, send_direct_contact_email,
612- 'me@eg.dom', recipients_set, user, 'subject', 'body')
613+ 'you@eg.dom', recipients_set, user, 'subject', 'body')
614
615 def test_empty_recipient_set(self):
616 # The recipient set can be empty. No messages are sent and the
617 # action does not count toward the daily quota.
618- self.factory.makePerson(email='me@eg.dom', name='me')
619+ self.factory.makePerson(email='you@eg.dom', name='you')
620 user = self.factory.makePerson(email='him@eg.dom', name='him')
621 recipients_set = NotificationRecipientSet()
622- old_message = self.factory.makeSignedMessage(email_address='me@eg.dom')
623+ old_message = self.factory.makeSignedMessage(email_address='you@eg.dom')
624 authorization = IDirectEmailAuthorization(user)
625 for action in range(authorization.message_quota - 1):
626 authorization.record(old_message)
627 pop_notifications()
628 send_direct_contact_email(
629- 'me@eg.dom', recipients_set, user, 'subject', 'body')
630+ 'you@eg.dom', recipients_set, user, 'subject', 'body')
631 notifications = pop_notifications()
632 self.assertEqual(0, len(notifications))
633 self.assertTrue(authorization.is_allowed)
634
635=== modified file 'lib/lp/services/database/tests/test_transaction_policy.py'
636--- lib/lp/services/database/tests/test_transaction_policy.py 2013-06-20 05:50:00 +0000
637+++ lib/lp/services/database/tests/test_transaction_policy.py 2019-05-07 00:19:42 +0000
638@@ -37,7 +37,8 @@
639
640 :return: A token that `hasDatabaseBeenWrittenTo` can look for.
641 """
642- name = self.factory.getUniqueString()
643+ # Pass a prefix to keep team name way under 32-char limit.
644+ name = self.factory.getUniqueString('test-transaction')
645 self.factory.makePerson(name=name)
646 return name
647
648
649=== modified file 'lib/lp/translations/stories/standalone/xx-person-activity.txt'
650--- lib/lp/translations/stories/standalone/xx-person-activity.txt 2018-06-02 22:39:54 +0000
651+++ lib/lp/translations/stories/standalone/xx-person-activity.txt 2019-05-07 00:19:42 +0000
652@@ -51,13 +51,18 @@
653 URL-escaped user names
654 ----------------------
655
656-Since the user's name is included in the URL, and user names can contain
657-some slightly weird characters, it is escaped especially for this usage.
658+Since the user's name is included in the URL, and legacy user names can
659+contain some slightly weird characters, it is escaped especially for this
660+usage.
661
662-For instance, here's a user called a+b.
663+For instance, here's a legacy user called "a+b".
664
665 >>> login('carlos@canonical.com')
666- >>> ab = factory.makePerson(name='a+b')
667+ >>> ab = factory.makePerson()
668+ >>> from zope.security.proxy import removeSecurityProxy
669+ >>> naked_person = removeSecurityProxy(ab)
670+ >>> naked_person.name = 'a+b'
671+ >>> naked_person.display_name = 'A+b'
672 >>> sr_pofile = factory.makePOFile('sr')
673 >>> message = factory.makeCurrentTranslationMessage(
674 ... pofile=sr_pofile, translator=ab)