Merge ~pelpsi/launchpad:social-account-interface-and-implementation into launchpad:master
- Git
- lp:~pelpsi/launchpad
- social-account-interface-and-implementation
- Merge into master
Status: | Merged |
---|---|
Approved by: | Simone Pelosi |
Approved revision: | 0b2495a032a893a9c9baee3d086d19196d629015 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~pelpsi/launchpad:social-account-interface-and-implementation |
Merge into: | launchpad:master |
Diff against target: |
776 lines (+595/-0) 10 files modified
lib/lp/registry/browser/configure.zcml (+6/-0) lib/lp/registry/browser/person.py (+9/-0) lib/lp/registry/configure.zcml (+27/-0) lib/lp/registry/interfaces/person.py (+26/-0) lib/lp/registry/interfaces/socialaccount.py (+160/-0) lib/lp/registry/interfaces/webservice.py (+2/-0) lib/lp/registry/model/person.py (+86/-0) lib/lp/registry/stories/webservice/xx-person.rst (+14/-0) lib/lp/registry/tests/test_socialaccount.py (+259/-0) lib/lp/services/webservice/wadl-to-refhtml.xsl (+6/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Ines Almeida | Approve | ||
Guruprasad | Approve | ||
Review via email: mp+457527@code.launchpad.net |
Commit message
SocialAccount interface and implementation
SocialAccount interface to reflect DB table SocialAccount:
each row of this new table is storing SocialAccount infomation
associated to one person. One person could have more than one
SocialAccount. This change is required since we are implementing
Matrix.
Reference: https:/
Description of the change
Ines Almeida (ines-almeida) : | # |
Ines Almeida (ines-almeida) wrote : | # |
Ines Almeida (ines-almeida) wrote : | # |
Also to note, that this shouldn't be merged before the DB changes get merged and deployed. Will mark it as needs fixing to ensure we don't merge it too soon
Simone Pelosi (pelpsi) : | # |
Simone Pelosi (pelpsi) : | # |
Ines Almeida (ines-almeida) wrote : | # |
We need to find a way to define how the JSON for a platform should look like (since some platforms will require only a nickname, and others will require nick + network, and eventually we might have even more complex schemas).
I'm wondering where we should add that, but I feel like it should perhaps be somewhere in the `PlatformType` class
Ines Almeida (ines-almeida) wrote : | # |
LGTM!
Just a few comments regarding testing (won't approve until DB change is deployed)
Simone Pelosi (pelpsi) : | # |
Guruprasad (lgp171188) : | # |
Ines Almeida (ines-almeida) : | # |
Simone Pelosi (pelpsi) : | # |
Simone Pelosi (pelpsi) : | # |
Guruprasad (lgp171188) wrote : | # |
Can you rename all occurrences of "social media platform" to "social platform" to be consistent?
Simone Pelosi (pelpsi) : | # |
Ines Almeida (ines-almeida) wrote (last edit ): | # |
Looking good, just a few minor comments
I'd like to have a good way to define stuff per platform (required identity fields, etc etc). But I think I'd rather move forward with what we currently have and generalize afterwards
Ines Almeida (ines-almeida) : | # |
Simone Pelosi (pelpsi) wrote : | # |
I'm squashing commits into one.
Guruprasad (lgp171188) wrote : | # |
> We need to find a way to define how the JSON for a platform should look like
> (since some platforms will require only a nickname, and others will require
> nick + network, and eventually we might have even more complex schemas).
>
> I'm wondering where we should add that, but I feel like it should perhaps be
> somewhere in the `PlatformType` class
I am not a fan of using the IRC-specific (or specific to few platforms) naming conventions for other services like Matrix. Matrix user IDs (MXID) use the format '@<email address hidden>' (see https:/
Guruprasad (lgp171188) : | # |
Simone Pelosi (pelpsi) : | # |
Guruprasad (lgp171188) wrote : | # |
LGTM 👍
Please address the comments before merging.
Ines Almeida (ines-almeida) wrote : | # |
Looking good, just a small remark on the validation!
Ines Almeida (ines-almeida) wrote : | # |
Missing the "/" in the new username validation, otherwise all good!
Preview Diff
1 | diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml |
2 | index 6fd29fb..046db06 100644 |
3 | --- a/lib/lp/registry/browser/configure.zcml |
4 | +++ b/lib/lp/registry/browser/configure.zcml |
5 | @@ -665,6 +665,12 @@ |
6 | rootsite="api" |
7 | /> |
8 | <lp:url |
9 | + for="lp.registry.interfaces.socialaccount.ISocialAccount" |
10 | + path_expression="string:+socialaccount/${id}" |
11 | + attribute_to_parent="person" |
12 | + rootsite="api" |
13 | + /> |
14 | + <lp:url |
15 | for="lp.registry.interfaces.pillar.IPillarNameSet" |
16 | path_expression="string:pillars" |
17 | parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" |
18 | diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py |
19 | index a741540..8f044fd 100644 |
20 | --- a/lib/lp/registry/browser/person.py |
21 | +++ b/lib/lp/registry/browser/person.py |
22 | @@ -152,6 +152,7 @@ from lp.registry.interfaces.persontransferjob import ( |
23 | from lp.registry.interfaces.pillar import IPillarNameSet |
24 | from lp.registry.interfaces.poll import IPollSubset |
25 | from lp.registry.interfaces.product import InvalidProductName, IProduct |
26 | +from lp.registry.interfaces.socialaccount import ISocialAccountSet |
27 | from lp.registry.interfaces.ssh import ISSHKeySet, SSHKeyAdditionError |
28 | from lp.registry.interfaces.teammembership import ( |
29 | ITeamMembershipSet, |
30 | @@ -555,6 +556,14 @@ class PersonNavigation(BranchTraversalMixin, Navigation): |
31 | return None |
32 | return irc_nick |
33 | |
34 | + @stepthrough("+socialaccount") |
35 | + def traverse_socialaccount(self, id): |
36 | + """Traverse to this person's SocialAccount on the webservice layer.""" |
37 | + social_account = getUtility(ISocialAccountSet).get(id) |
38 | + if social_account is None or social_account.person != self.context: |
39 | + return None |
40 | + return social_account |
41 | + |
42 | @stepthrough("+oci-registry-credential") |
43 | def traverse_oci_registry_credential(self, id): |
44 | """Traverse to this person's OCI registry credentials.""" |
45 | diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml |
46 | index 3cf36a8..041e557 100644 |
47 | --- a/lib/lp/registry/configure.zcml |
48 | +++ b/lib/lp/registry/configure.zcml |
49 | @@ -650,6 +650,33 @@ |
50 | interface="lp.registry.interfaces.jabber.IJabberIDSet"/> |
51 | </lp:securedutility> |
52 | <class |
53 | + class="lp.registry.model.person.SocialAccount"> |
54 | + <allow |
55 | + interface="lp.registry.interfaces.role.IHasOwner"/> |
56 | + <allow |
57 | + attributes=" |
58 | + id |
59 | + person |
60 | + platform |
61 | + identity"/> |
62 | + <require |
63 | + permission="launchpad.Edit" |
64 | + set_schema="lp.registry.interfaces.socialaccount.ISocialAccount" |
65 | + attributes=" |
66 | + destroySelf"/> |
67 | + </class> |
68 | + <class |
69 | + class="lp.registry.model.person.SocialAccountSet"> |
70 | + <allow |
71 | + interface="lp.registry.interfaces.socialaccount.ISocialAccountSet"/> |
72 | + </class> |
73 | + <lp:securedutility |
74 | + class="lp.registry.model.person.SocialAccountSet" |
75 | + provides="lp.registry.interfaces.socialaccount.ISocialAccountSet"> |
76 | + <allow |
77 | + interface="lp.registry.interfaces.socialaccount.ISocialAccountSet"/> |
78 | + </lp:securedutility> |
79 | + <class |
80 | class="lp.registry.model.pillar.PillarName"> |
81 | <allow |
82 | interface="lp.registry.interfaces.pillar.IPillarName"/> |
83 | diff --git a/lib/lp/registry/interfaces/person.py b/lib/lp/registry/interfaces/person.py |
84 | index 3aeae4c..a3cb45b 100644 |
85 | --- a/lib/lp/registry/interfaces/person.py |
86 | +++ b/lib/lp/registry/interfaces/person.py |
87 | @@ -121,6 +121,10 @@ from lp.registry.interfaces.location import ( |
88 | from lp.registry.interfaces.mailinglistsubscription import ( |
89 | MailingListAutoSubscribePolicy, |
90 | ) |
91 | +from lp.registry.interfaces.socialaccount import ( |
92 | + ISocialAccount, |
93 | + SocialPlatformType, |
94 | +) |
95 | from lp.registry.interfaces.ssh import ISSHKey |
96 | from lp.registry.interfaces.teammembership import ( |
97 | ITeamMembership, |
98 | @@ -1030,6 +1034,27 @@ class IPersonViewRestricted( |
99 | ), |
100 | exported_as="jabber_ids", |
101 | ) |
102 | + social_accounts = exported( |
103 | + CollectionField( |
104 | + title=_("List of Social Accounts of this Person."), |
105 | + readonly=True, |
106 | + required=False, |
107 | + value_type=Reference(schema=ISocialAccount), |
108 | + ) |
109 | + ) |
110 | + |
111 | + @operation_parameters( |
112 | + platform=Choice( |
113 | + title=_("Social Platform Type"), |
114 | + required=True, |
115 | + vocabulary=SocialPlatformType, |
116 | + ) |
117 | + ) |
118 | + @export_read_operation() |
119 | + @operation_for_version("beta") |
120 | + def getSocialAccountsByPlatform(platform): |
121 | + """Return Social Accounts associated to the user.""" |
122 | + |
123 | team_memberships = exported( |
124 | CollectionField( |
125 | title=_( |
126 | @@ -3241,3 +3266,4 @@ patch_reference_property(IIrcID, "person", IPerson) |
127 | patch_reference_property(IJabberID, "person", IPerson) |
128 | patch_reference_property(IWikiName, "person", IPerson) |
129 | patch_reference_property(IEmailAddress, "person", IPerson) |
130 | +patch_reference_property(ISocialAccount, "person", IPerson) |
131 | diff --git a/lib/lp/registry/interfaces/socialaccount.py b/lib/lp/registry/interfaces/socialaccount.py |
132 | new file mode 100644 |
133 | index 0000000..1dcb7c8 |
134 | --- /dev/null |
135 | +++ b/lib/lp/registry/interfaces/socialaccount.py |
136 | @@ -0,0 +1,160 @@ |
137 | +# Copyright 2024 Canonical Ltd. This software is licensed under the |
138 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
139 | + |
140 | +"""SocialAccount interfaces.""" |
141 | + |
142 | +__all__ = [ |
143 | + "ISocialAccount", |
144 | + "ISocialAccountSet", |
145 | + "MatrixPlatform", |
146 | + "SocialPlatformType", |
147 | + "SocialAccountIdentityError", |
148 | + "validate_social_account_identity", |
149 | +] |
150 | + |
151 | +import http.client |
152 | +import re |
153 | + |
154 | +from lazr.enum import DBEnumeratedType, DBItem |
155 | +from lazr.restful.declarations import ( |
156 | + error_status, |
157 | + exported, |
158 | + exported_as_webservice_entry, |
159 | +) |
160 | +from lazr.restful.fields import Reference |
161 | +from zope.interface import Interface |
162 | +from zope.schema import Choice, Dict, Int, TextLine |
163 | + |
164 | +from lp import _ |
165 | +from lp.registry.interfaces.role import IHasOwner |
166 | + |
167 | + |
168 | +class SocialPlatformType(DBEnumeratedType): |
169 | + """Social Platform Type |
170 | + |
171 | + Social Account is associated with a SocialPlatformType. |
172 | + """ |
173 | + |
174 | + MATRIX = DBItem( |
175 | + 1, |
176 | + """ |
177 | + Matrix platform |
178 | + |
179 | + The Social Account will hold Matrix account info. |
180 | + """, |
181 | + ) |
182 | + |
183 | + |
184 | +# XXX pelpsi 2023-12-14 bug=760849: "beta" is a lie to get WADL generation |
185 | +# working. |
186 | +@exported_as_webservice_entry("social_account", as_of="beta") |
187 | +class ISocialAccount(IHasOwner): |
188 | + """Social Account""" |
189 | + |
190 | + id = Int(title=_("Database ID"), required=True, readonly=True) |
191 | + # schema=Interface will be overridden in person.py because of circular |
192 | + # dependencies. |
193 | + person = exported( |
194 | + Reference( |
195 | + title=_("Owner"), required=True, schema=Interface, readonly=True |
196 | + ) |
197 | + ) |
198 | + |
199 | + platform = exported( |
200 | + Choice( |
201 | + title=_("Social Platform Type"), |
202 | + required=True, |
203 | + vocabulary=SocialPlatformType, |
204 | + ) |
205 | + ) |
206 | + |
207 | + identity = exported( |
208 | + Dict( |
209 | + title=_("Identity"), |
210 | + key_type=TextLine(), |
211 | + required=True, |
212 | + readonly=False, |
213 | + description=_( |
214 | + "A dictionary with the identity attributes and values for the " |
215 | + "social account. The format is specific for each platform. " |
216 | + "Matrix account attributes: username, homeserver " |
217 | + ), |
218 | + ) |
219 | + ) |
220 | + |
221 | + def destroySelf(): |
222 | + """Delete this SocialAccount from the database.""" |
223 | + |
224 | + |
225 | +class ISocialAccountSet(Interface): |
226 | + """The set of SocialAccounts.""" |
227 | + |
228 | + def new(self, person, platform, identity): |
229 | + """Create a new SocialAccount pointing to the given Person.""" |
230 | + |
231 | + def getByPerson(person): |
232 | + """Return all SocialAccounts for the given person.""" |
233 | + |
234 | + def getByPersonAndSocialPlatform(person, social_platform): |
235 | + """Return all SocialAccounts for the given person and platform.""" |
236 | + |
237 | + def get(id): |
238 | + """Return the SocialAccount with the given id or None.""" |
239 | + |
240 | + |
241 | +class SocialPlatform: |
242 | + title = "" |
243 | + identity_fields = [] |
244 | + platform_type = None |
245 | + |
246 | + @classmethod |
247 | + def validate_identity(cls, identity): |
248 | + pass |
249 | + |
250 | + |
251 | +# XXX pelpsi: replace this with a pydantic validator |
252 | +class MatrixPlatform(SocialPlatform): |
253 | + title = "Matrix" |
254 | + identity_fields = ["username", "homeserver"] |
255 | + platform_type = SocialPlatformType.MATRIX |
256 | + |
257 | + @classmethod |
258 | + def validate_identity(cls, identity): |
259 | + if not all( |
260 | + identity.get(required_field) |
261 | + for required_field in cls.identity_fields |
262 | + ): |
263 | + raise SocialAccountIdentityError( |
264 | + f"You must provide the following fields: " |
265 | + f"{', '.join(cls.identity_fields)}." |
266 | + ) |
267 | + if not isinstance(identity["username"], str): |
268 | + raise SocialAccountIdentityError("Username must be a string.") |
269 | + # Matrix username can contain a-z, 0-9, ., _, =, -, and / |
270 | + # ref: https://spec.matrix.org/v1.1/appendices/#user-identifiers |
271 | + username_patter = r"^[A-z0-9-=_./]+" |
272 | + if not re.match(username_patter, identity["username"]): |
273 | + raise SocialAccountIdentityError("Username must be valid.") |
274 | + hs_pattern = r"^[A-z0-9][A-z0-9-]*(\.[A-z0-9]([A-z0-9-][A-z0-9])*)+$" |
275 | + if not isinstance(identity["homeserver"], str): |
276 | + raise SocialAccountIdentityError("Homeserver must be a string.") |
277 | + if not re.match(hs_pattern, identity["homeserver"]): |
278 | + raise SocialAccountIdentityError( |
279 | + "Homeserver must be a valid domain." |
280 | + ) |
281 | + |
282 | + |
283 | +@error_status(http.client.BAD_REQUEST) |
284 | +class SocialAccountIdentityError(Exception): |
285 | + """Raised when Social Account's identity is |
286 | + invalid for a given Social Platform Type. |
287 | + """ |
288 | + |
289 | + |
290 | +def validate_social_account_identity(obj, attr, value): |
291 | + social_account = obj |
292 | + |
293 | + social_platform = social_account.getSocialPlatform() |
294 | + social_platform.validate_identity(identity=value) |
295 | + |
296 | + return value |
297 | diff --git a/lib/lp/registry/interfaces/webservice.py b/lib/lp/registry/interfaces/webservice.py |
298 | index 215c9ef..fc783eb 100644 |
299 | --- a/lib/lp/registry/interfaces/webservice.py |
300 | +++ b/lib/lp/registry/interfaces/webservice.py |
301 | @@ -35,6 +35,7 @@ __all__ = [ |
302 | "IServiceFactory", |
303 | "ISharingService", |
304 | "ISSHKey", |
305 | + "ISocialAccount", |
306 | "ISourcePackage", |
307 | "ISourcePackageName", |
308 | "ITeam", |
309 | @@ -94,6 +95,7 @@ from lp.registry.interfaces.productseries import ( |
310 | ) |
311 | from lp.registry.interfaces.projectgroup import IProjectGroup, IProjectGroupSet |
312 | from lp.registry.interfaces.sharingservice import ISharingService |
313 | +from lp.registry.interfaces.socialaccount import ISocialAccount |
314 | from lp.registry.interfaces.sourcepackage import ( |
315 | ISourcePackage, |
316 | ISourcePackageEdit, |
317 | diff --git a/lib/lp/registry/model/person.py b/lib/lp/registry/model/person.py |
318 | index 779616d..1a1a122 100644 |
319 | --- a/lib/lp/registry/model/person.py |
320 | +++ b/lib/lp/registry/model/person.py |
321 | @@ -19,6 +19,8 @@ __all__ = [ |
322 | "PersonLanguage", |
323 | "PersonSet", |
324 | "PersonSettings", |
325 | + "SocialAccount", |
326 | + "SocialAccountSet", |
327 | "SSHKey", |
328 | "SSHKeySet", |
329 | "TeamInvitationEvent", |
330 | @@ -40,6 +42,7 @@ import transaction |
331 | from lazr.delegates import delegate_to |
332 | from lazr.restful.utils import get_current_browser_request, smartquote |
333 | from requests import PreparedRequest |
334 | +from storm.databases.postgres import JSON |
335 | from storm.expr import ( |
336 | SQL, |
337 | Alias, |
338 | @@ -161,6 +164,13 @@ from lp.registry.interfaces.persontransferjob import IPersonMergeJobSource |
339 | from lp.registry.interfaces.product import IProduct, IProductSet |
340 | from lp.registry.interfaces.projectgroup import IProjectGroup |
341 | from lp.registry.interfaces.role import IPersonRoles |
342 | +from lp.registry.interfaces.socialaccount import ( |
343 | + ISocialAccount, |
344 | + ISocialAccountSet, |
345 | + MatrixPlatform, |
346 | + SocialPlatformType, |
347 | + validate_social_account_identity, |
348 | +) |
349 | from lp.registry.interfaces.ssh import ( |
350 | SSH_TEXT_TO_KEY_TYPE, |
351 | ISSHKey, |
352 | @@ -646,6 +656,7 @@ class Person( |
353 | signedcocs = ReferenceSet("id", "SignedCodeOfConduct.owner_id") |
354 | _ircnicknames = ReferenceSet("id", "IrcID.person_id") |
355 | jabberids = ReferenceSet("id", "JabberID.person_id") |
356 | + _social_accounts = ReferenceSet("id", "SocialAccount.person_id") |
357 | |
358 | visibility = DBEnum( |
359 | enum=PersonVisibility, |
360 | @@ -688,6 +699,19 @@ class Person( |
361 | return list(self._ircnicknames) |
362 | |
363 | @cachedproperty |
364 | + def social_accounts(self): |
365 | + return list(self._social_accounts) |
366 | + |
367 | + # TODO: write test for this function once we have more |
368 | + # than one Social Platform Type. |
369 | + def getSocialAccountsByPlatform(self, platform): |
370 | + return list( |
371 | + getUtility(ISocialAccountSet).getByPersonAndSocialPlatform( |
372 | + self, platform |
373 | + ) |
374 | + ) |
375 | + |
376 | + @cachedproperty |
377 | def languages(self): |
378 | """See `IPerson`.""" |
379 | results = Store.of(self).find( |
380 | @@ -2779,6 +2803,7 @@ class Person( |
381 | ("sharingjob", "grantee"), |
382 | ("signedcodeofconduct", "owner"), |
383 | ("snapbuild", "requester"), |
384 | + ("socialaccount", "person"), |
385 | ("specificationsubscription", "person"), |
386 | ("sshkey", "person"), |
387 | ("structuralsubscription", "subscriber"), |
388 | @@ -5321,6 +5346,67 @@ class WikiNameSet: |
389 | return wiki_name |
390 | |
391 | |
392 | +@implementer(ISocialAccount) |
393 | +class SocialAccount(StormBase, HasOwnerMixin): |
394 | + __storm_table__ = "SocialAccount" |
395 | + |
396 | + id = Int(primary=True) |
397 | + person_id = Int(name="person", allow_none=False) |
398 | + person = Reference(person_id, "Person.id") |
399 | + platform = DBEnum( |
400 | + name="platform", |
401 | + allow_none=False, |
402 | + enum=SocialPlatformType, |
403 | + ) |
404 | + identity = JSON( |
405 | + name="identity", |
406 | + allow_none=False, |
407 | + validator=validate_social_account_identity, |
408 | + ) |
409 | + |
410 | + def __init__(self, person, platform, identity): |
411 | + super().__init__() |
412 | + self.person = person |
413 | + self.platform = platform |
414 | + self.identity = identity |
415 | + |
416 | + def getSocialPlatform(self): |
417 | + if self.platform == SocialPlatformType.MATRIX: |
418 | + return MatrixPlatform |
419 | + |
420 | + def destroySelf(self): |
421 | + IStore(self).remove(self) |
422 | + |
423 | + |
424 | +@implementer(ISocialAccountSet) |
425 | +class SocialAccountSet: |
426 | + def get(self, id): |
427 | + """See `ISocialAccountSet`.""" |
428 | + return IStore(SocialAccount).get(SocialAccount, int(id)) |
429 | + |
430 | + def getByPerson(self, person): |
431 | + """See `ISocialAccountSet`.""" |
432 | + return ( |
433 | + IStore(SocialAccount) |
434 | + .find(SocialAccount, person=person) |
435 | + .group_by(SocialAccount.platform) |
436 | + ) |
437 | + |
438 | + def getByPersonAndSocialPlatform(self, person, social_platform): |
439 | + """See `ISocialAccountSet`.""" |
440 | + return IStore(SocialAccount).find( |
441 | + SocialAccount, person=person, platform=social_platform |
442 | + ) |
443 | + |
444 | + def new(self, person, platform, identity): |
445 | + """See `ISocialAccountSet`.""" |
446 | + social_account = SocialAccount( |
447 | + person=person, platform=platform, identity=identity |
448 | + ) |
449 | + IStore(social_account).flush() |
450 | + return social_account |
451 | + |
452 | + |
453 | @implementer(IJabberID) |
454 | class JabberID(StormBase, HasOwnerMixin): |
455 | __storm_table__ = "JabberID" |
456 | diff --git a/lib/lp/registry/stories/webservice/xx-person.rst b/lib/lp/registry/stories/webservice/xx-person.rst |
457 | index 073c8a4..7e93f8b 100644 |
458 | --- a/lib/lp/registry/stories/webservice/xx-person.rst |
459 | +++ b/lib/lp/registry/stories/webservice/xx-person.rst |
460 | @@ -53,6 +53,7 @@ for teams (as they're defined in the ITeam interface). |
461 | recipes_collection_link: 'http://.../~salgado/recipes' |
462 | resource_type_link: 'http://.../#person' |
463 | self_link: 'http://.../~salgado' |
464 | + social_accounts_collection_link: 'http://.../~salgado/social_accounts' |
465 | sshkeys_collection_link: 'http://.../~salgado/sshkeys' |
466 | sub_teams_collection_link: 'http://.../~salgado/sub_teams' |
467 | super_teams_collection_link: 'http://.../~salgado/super_teams' |
468 | @@ -116,6 +117,7 @@ for teams (as they're defined in the ITeam interface). |
469 | renewal_policy: 'invite them to apply for renewal' |
470 | resource_type_link: 'http://.../#team' |
471 | self_link: 'http://.../~ubuntu-team' |
472 | + social_accounts_collection_link: 'http://.../~ubuntu-team/social_accounts' |
473 | sshkeys_collection_link: 'http://.../~ubuntu-team/sshkeys' |
474 | sub_teams_collection_link: 'http://.../~ubuntu-team/sub_teams' |
475 | subscription_policy: 'Moderated Team' |
476 | @@ -635,6 +637,18 @@ to, obviously. |
477 | HTTP/1.1 404 Not Found |
478 | ... |
479 | |
480 | +Social Accounts |
481 | +.......... |
482 | + |
483 | +Social Accounts of a person are also linked. |
484 | + |
485 | + >>> mark = webservice.get("/~mark").jsonBody() |
486 | + >>> social_accounts_link = mark["social_accounts_collection_link"] |
487 | + >>> print(social_accounts_link) |
488 | + http://.../~mark/social_accounts |
489 | + >>> print_self_link_of_entries( |
490 | + ... webservice.get(social_accounts_link).jsonBody() |
491 | + ... ) |
492 | |
493 | IRC nicknames |
494 | ............. |
495 | diff --git a/lib/lp/registry/tests/test_socialaccount.py b/lib/lp/registry/tests/test_socialaccount.py |
496 | new file mode 100644 |
497 | index 0000000..6e45017 |
498 | --- /dev/null |
499 | +++ b/lib/lp/registry/tests/test_socialaccount.py |
500 | @@ -0,0 +1,259 @@ |
501 | +from zope.component import getUtility |
502 | +from zope.interface.verify import verifyObject |
503 | + |
504 | +from lp.registry.interfaces.role import IHasOwner |
505 | +from lp.registry.interfaces.socialaccount import ( |
506 | + ISocialAccount, |
507 | + ISocialAccountSet, |
508 | + SocialAccountIdentityError, |
509 | + SocialPlatformType, |
510 | +) |
511 | +from lp.testing import TestCaseWithFactory, login_person |
512 | +from lp.testing.layers import DatabaseFunctionalLayer |
513 | + |
514 | + |
515 | +class TestSocialAccount(TestCaseWithFactory): |
516 | + layer = DatabaseFunctionalLayer |
517 | + |
518 | + def test_social_account(self): |
519 | + # Social Account is created as expected and |
520 | + # associated to the user. |
521 | + user = self.factory.makePerson() |
522 | + login_person(user) |
523 | + attributes = {} |
524 | + attributes["homeserver"] = "abc.org" |
525 | + attributes["username"] = "test-nickname" |
526 | + social_account = getUtility(ISocialAccountSet).new( |
527 | + user, SocialPlatformType.MATRIX, attributes |
528 | + ) |
529 | + |
530 | + self.assertTrue(verifyObject(IHasOwner, social_account)) |
531 | + self.assertTrue(verifyObject(ISocialAccount, social_account)) |
532 | + |
533 | + def test_matrix_account(self): |
534 | + # Matrix Social Account is created as expected and |
535 | + # associated to the user. |
536 | + user = self.factory.makePerson() |
537 | + attributes = {} |
538 | + attributes["homeserver"] = "abc.org" |
539 | + attributes["username"] = "test-nickname" |
540 | + social_account = getUtility(ISocialAccountSet).new( |
541 | + user, SocialPlatformType.MATRIX, attributes |
542 | + ) |
543 | + |
544 | + self.assertEqual(len(user.social_accounts), 1) |
545 | + social_account = user.social_accounts[0] |
546 | + |
547 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
548 | + self.assertEqual(social_account.identity["homeserver"], "abc.org") |
549 | + self.assertEqual(social_account.identity["username"], "test-nickname") |
550 | + |
551 | + def test_multilevel_domain_matrix_account(self): |
552 | + # Homeserver with a multi-level domain is allowed |
553 | + # Matrix username can contain a-z, 0-9, ., _, =, -, and / |
554 | + # ref: https://spec.matrix.org/v1.1/appendices/#user-identifiers |
555 | + user = self.factory.makePerson() |
556 | + attributes = {} |
557 | + attributes["homeserver"] = "abc-def.org-com" |
558 | + attributes["username"] = "test-n/ic.kn=am_e" |
559 | + social_account = getUtility(ISocialAccountSet).new( |
560 | + user, SocialPlatformType.MATRIX, attributes |
561 | + ) |
562 | + |
563 | + self.assertEqual(len(user.social_accounts), 1) |
564 | + social_account = user.social_accounts[0] |
565 | + |
566 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
567 | + self.assertEqual( |
568 | + social_account.identity["homeserver"], "abc-def.org-com" |
569 | + ) |
570 | + self.assertEqual( |
571 | + social_account.identity["username"], "test-n/ic.kn=am_e" |
572 | + ) |
573 | + |
574 | + def test_malformed_identity_matrix_account(self): |
575 | + # Matrix Identity must contain homeserver and username |
576 | + user = self.factory.makePerson() |
577 | + attributes = {} |
578 | + attributes["homeserver"] = "abc.org" |
579 | + attributes["name"] = "test-nickname" |
580 | + utility = getUtility(ISocialAccountSet) |
581 | + |
582 | + self.assertRaises( |
583 | + SocialAccountIdentityError, |
584 | + utility.new, |
585 | + user, |
586 | + SocialPlatformType.MATRIX, |
587 | + attributes, |
588 | + ) |
589 | + |
590 | + def test_malformed_username_matrix_account(self): |
591 | + # Username can contain a-z, 0-9, ., _, =, -, and / |
592 | + user = self.factory.makePerson() |
593 | + attributes = {} |
594 | + attributes["homeserver"] = "abc.org" |
595 | + attributes["username"] = r"<b>test-nickname<\b>" |
596 | + utility = getUtility(ISocialAccountSet) |
597 | + |
598 | + self.assertRaises( |
599 | + SocialAccountIdentityError, |
600 | + utility.new, |
601 | + user, |
602 | + SocialPlatformType.MATRIX, |
603 | + attributes, |
604 | + ) |
605 | + |
606 | + def test_malformed_multilevel_domain_matrix_account(self): |
607 | + # Homeserver cannot start with a special character |
608 | + user = self.factory.makePerson() |
609 | + attributes = {} |
610 | + attributes["homeserver"] = "-def.org-com" |
611 | + attributes["username"] = "test-nickname" |
612 | + utility = getUtility(ISocialAccountSet) |
613 | + |
614 | + self.assertRaises( |
615 | + SocialAccountIdentityError, |
616 | + utility.new, |
617 | + user, |
618 | + SocialPlatformType.MATRIX, |
619 | + attributes, |
620 | + ) |
621 | + |
622 | + attributes = {} |
623 | + attributes["homeserver"] = "def.-org-com" |
624 | + attributes["username"] = "test-nickname" |
625 | + |
626 | + self.assertRaises( |
627 | + SocialAccountIdentityError, |
628 | + utility.new, |
629 | + user, |
630 | + SocialPlatformType.MATRIX, |
631 | + attributes, |
632 | + ) |
633 | + |
634 | + def test_malformed_matrix_account_username(self): |
635 | + # Username must be a string |
636 | + user = self.factory.makePerson() |
637 | + attributes = {} |
638 | + attributes["homeserver"] = "abc.org" |
639 | + attributes["username"] = 123123 |
640 | + utility = getUtility(ISocialAccountSet) |
641 | + |
642 | + self.assertRaises( |
643 | + SocialAccountIdentityError, |
644 | + utility.new, |
645 | + user, |
646 | + SocialPlatformType.MATRIX, |
647 | + attributes, |
648 | + ) |
649 | + |
650 | + def test_malformed_matrix_account_homeserver(self): |
651 | + # Homeserver must be a valid address |
652 | + user = self.factory.makePerson() |
653 | + attributes = {} |
654 | + attributes["homeserver"] = "abc" |
655 | + attributes["username"] = "test-nickname" |
656 | + utility = getUtility(ISocialAccountSet) |
657 | + |
658 | + self.assertRaises( |
659 | + SocialAccountIdentityError, |
660 | + utility.new, |
661 | + user, |
662 | + SocialPlatformType.MATRIX, |
663 | + attributes, |
664 | + ) |
665 | + |
666 | + def test_empty_fields_matrix_account(self): |
667 | + # Identity field must be not empty |
668 | + user = self.factory.makePerson() |
669 | + attributes = {} |
670 | + attributes["homeserver"] = "" |
671 | + attributes["username"] = "test-nickname" |
672 | + utility = getUtility(ISocialAccountSet) |
673 | + |
674 | + self.assertRaises( |
675 | + SocialAccountIdentityError, |
676 | + utility.new, |
677 | + user, |
678 | + SocialPlatformType.MATRIX, |
679 | + attributes, |
680 | + ) |
681 | + |
682 | + def test_multiple_social_accounts(self): |
683 | + # Users can have multiple social accounts |
684 | + user = self.factory.makePerson() |
685 | + attributes = {} |
686 | + attributes["homeserver"] = "abc.org" |
687 | + attributes["username"] = "test-nickname" |
688 | + getUtility(ISocialAccountSet).new( |
689 | + user, SocialPlatformType.MATRIX, attributes |
690 | + ) |
691 | + attributes = {} |
692 | + attributes["homeserver"] = "def.org" |
693 | + attributes["username"] = "test-nickname" |
694 | + getUtility(ISocialAccountSet).new( |
695 | + user, SocialPlatformType.MATRIX, attributes |
696 | + ) |
697 | + |
698 | + self.assertEqual(len(user.social_accounts), 2) |
699 | + social_account = user.social_accounts[0] |
700 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
701 | + self.assertEqual(social_account.identity["homeserver"], "abc.org") |
702 | + self.assertEqual(social_account.identity["username"], "test-nickname") |
703 | + |
704 | + social_account = user.social_accounts[1] |
705 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
706 | + self.assertEqual(social_account.identity["homeserver"], "def.org") |
707 | + self.assertEqual(social_account.identity["username"], "test-nickname") |
708 | + |
709 | + def test_multiple_social_accounts_on_multiple_users(self): |
710 | + # Users can have multiple social accounts |
711 | + user = self.factory.makePerson() |
712 | + attributes = {} |
713 | + attributes["homeserver"] = "abc.org" |
714 | + attributes["username"] = "test-nickname" |
715 | + getUtility(ISocialAccountSet).new( |
716 | + user, SocialPlatformType.MATRIX, attributes |
717 | + ) |
718 | + attributes = {} |
719 | + attributes["homeserver"] = "def.org" |
720 | + attributes["username"] = "test-nickname" |
721 | + getUtility(ISocialAccountSet).new( |
722 | + user, SocialPlatformType.MATRIX, attributes |
723 | + ) |
724 | + |
725 | + user_two = self.factory.makePerson() |
726 | + attributes = {} |
727 | + attributes["homeserver"] = "ghi.org" |
728 | + attributes["username"] = "test-nickname" |
729 | + getUtility(ISocialAccountSet).new( |
730 | + user_two, SocialPlatformType.MATRIX, attributes |
731 | + ) |
732 | + attributes = {} |
733 | + attributes["homeserver"] = "lmn.org" |
734 | + attributes["username"] = "test-nickname" |
735 | + getUtility(ISocialAccountSet).new( |
736 | + user_two, SocialPlatformType.MATRIX, attributes |
737 | + ) |
738 | + |
739 | + self.assertEqual(len(user.social_accounts), 2) |
740 | + social_account = user.social_accounts[0] |
741 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
742 | + self.assertEqual(social_account.identity["homeserver"], "abc.org") |
743 | + self.assertEqual(social_account.identity["username"], "test-nickname") |
744 | + |
745 | + social_account = user.social_accounts[1] |
746 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
747 | + self.assertEqual(social_account.identity["homeserver"], "def.org") |
748 | + self.assertEqual(social_account.identity["username"], "test-nickname") |
749 | + |
750 | + self.assertEqual(len(user_two.social_accounts), 2) |
751 | + social_account = user_two.social_accounts[0] |
752 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
753 | + self.assertEqual(social_account.identity["homeserver"], "ghi.org") |
754 | + self.assertEqual(social_account.identity["username"], "test-nickname") |
755 | + |
756 | + social_account = user_two.social_accounts[1] |
757 | + self.assertEqual(social_account.platform, SocialPlatformType.MATRIX) |
758 | + self.assertEqual(social_account.identity["homeserver"], "lmn.org") |
759 | + self.assertEqual(social_account.identity["username"], "test-nickname") |
760 | diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl |
761 | index 0f6d973..4ddccb9 100644 |
762 | --- a/lib/lp/services/webservice/wadl-to-refhtml.xsl |
763 | +++ b/lib/lp/services/webservice/wadl-to-refhtml.xsl |
764 | @@ -425,6 +425,12 @@ |
765 | <xsl:text>/+jabberid/</xsl:text> |
766 | <var><id></var> |
767 | </xsl:when> |
768 | + <xsl:when test="@id = 'socialaccount_id'"> |
769 | + <xsl:text>/</xsl:text> |
770 | + <var><person.name></var> |
771 | + <xsl:text>/+socialaccount/</xsl:text> |
772 | + <var><id></var> |
773 | + </xsl:when> |
774 | <xsl:when test="@id = 'irc_id'"> |
775 | <xsl:text>/</xsl:text> |
776 | <var><person.name></var> |
From my initial look it looks generally good to me, just a few comments.
Also, as you had already mentioned, we will need to add more tests :)
Will get back to it and do a more in depth review in 2024 after the break!