Merge ~pelpsi/launchpad:social-account-interface-and-implementation into launchpad:master

Proposed by Simone Pelosi
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)
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://code.launchpad.net/~ines-almeida/launchpad/+git/launchpad/+merge/457221

To post a comment you must log in.
Revision history for this message
Ines Almeida (ines-almeida) :
Revision history for this message
Ines Almeida (ines-almeida) wrote :

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!

Revision history for this message
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

review: Needs Fixing
Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
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

Revision history for this message
Ines Almeida (ines-almeida) wrote :

LGTM!
Just a few comments regarding testing (won't approve until DB change is deployed)

Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Guruprasad (lgp171188) :
review: Needs Fixing
Revision history for this message
Ines Almeida (ines-almeida) :
Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Guruprasad (lgp171188) wrote :

Can you rename all occurrences of "social media platform" to "social platform" to be consistent?

review: Needs Fixing
Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
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

review: Approve
Revision history for this message
Ines Almeida (ines-almeida) :
Revision history for this message
Simone Pelosi (pelpsi) wrote :

I'm squashing commits into one.

Revision history for this message
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://matrix.org/docs/older/faq/). So I would prefer using similar naming here - username and homeserver. It is better to store those separately instead of as an MXID because it will be easier to query accounts on a specific homeserver.

Revision history for this message
Guruprasad (lgp171188) :
Revision history for this message
Simone Pelosi (pelpsi) :
Revision history for this message
Guruprasad (lgp171188) wrote :

LGTM 👍

Please address the comments before merging.

review: Approve
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Looking good, just a small remark on the validation!

review: Approve
Revision history for this message
Ines Almeida (ines-almeida) wrote :

Missing the "/" in the new username validation, otherwise all good!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml
2index 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"
18diff --git a/lib/lp/registry/browser/person.py b/lib/lp/registry/browser/person.py
19index 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."""
45diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
46index 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"/>
83diff --git a/lib/lp/registry/interfaces/person.py b/lib/lp/registry/interfaces/person.py
84index 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)
131diff --git a/lib/lp/registry/interfaces/socialaccount.py b/lib/lp/registry/interfaces/socialaccount.py
132new file mode 100644
133index 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
297diff --git a/lib/lp/registry/interfaces/webservice.py b/lib/lp/registry/interfaces/webservice.py
298index 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,
317diff --git a/lib/lp/registry/model/person.py b/lib/lp/registry/model/person.py
318index 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"
456diff --git a/lib/lp/registry/stories/webservice/xx-person.rst b/lib/lp/registry/stories/webservice/xx-person.rst
457index 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 .............
495diff --git a/lib/lp/registry/tests/test_socialaccount.py b/lib/lp/registry/tests/test_socialaccount.py
496new file mode 100644
497index 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")
760diff --git a/lib/lp/services/webservice/wadl-to-refhtml.xsl b/lib/lp/services/webservice/wadl-to-refhtml.xsl
761index 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>&lt;id&gt;</var>
767 </xsl:when>
768+ <xsl:when test="@id = 'socialaccount_id'">
769+ <xsl:text>/</xsl:text>
770+ <var>&lt;person.name&gt;</var>
771+ <xsl:text>/+socialaccount/</xsl:text>
772+ <var>&lt;id&gt;</var>
773+ </xsl:when>
774 <xsl:when test="@id = 'irc_id'">
775 <xsl:text>/</xsl:text>
776 <var>&lt;person.name&gt;</var>

Subscribers

People subscribed via source and target branches

to status/vote changes: