diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/app/bounces.py | 2 | ||||
| -rw-r--r-- | src/mailman/app/docs/subscriptions.rst | 146 | ||||
| -rw-r--r-- | src/mailman/app/subscriptions.py (renamed from src/mailman/rest/adapters.py) | 60 | ||||
| -rw-r--r-- | src/mailman/config/configure.zcml | 4 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 4 | ||||
| -rw-r--r-- | src/mailman/interfaces/subscriptions.py (renamed from src/mailman/interfaces/membership.py) | 29 | ||||
| -rw-r--r-- | src/mailman/rest/docs/membership.rst (renamed from src/mailman/rest/docs/membership.txt) | 95 | ||||
| -rw-r--r-- | src/mailman/rest/members.py | 60 | ||||
| -rw-r--r-- | src/mailman/rest/root.py | 10 | ||||
| -rw-r--r-- | src/mailman/runners/outgoing.py | 2 |
10 files changed, 385 insertions, 27 deletions
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py index e227dfc32..611ab48b9 100644 --- a/src/mailman/app/bounces.py +++ b/src/mailman/app/bounces.py @@ -44,8 +44,8 @@ from mailman.core.i18n import _ from mailman.email.message import OwnerNotification, UserNotification from mailman.interfaces.bounce import UnrecognizedBounceDisposition from mailman.interfaces.listmanager import IListManager -from mailman.interfaces.membership import ISubscriptionService from mailman.interfaces.pending import IPendable, IPendings +from mailman.interfaces.subscriptions import ISubscriptionService from mailman.utilities.email import split_email from mailman.utilities.i18n import make from mailman.utilities.string import oneline diff --git a/src/mailman/app/docs/subscriptions.rst b/src/mailman/app/docs/subscriptions.rst new file mode 100644 index 000000000..378dd0a40 --- /dev/null +++ b/src/mailman/app/docs/subscriptions.rst @@ -0,0 +1,146 @@ +===================== +Subscription services +===================== + +The `ISubscriptionService` utility provides higher level convenience methods +useful for searching, retrieving, iterating, adding, and removing +memberships. + + >>> from mailman.interfaces.subscriptions import ISubscriptionService + >>> from zope.component import getUtility + >>> service = getUtility(ISubscriptionService) + +You can use the service to get all members of all mailing lists, for any +membership role. At first, there are no memberships. + + >>> service.get_members() + [] + >>> sum(1 for member in service) + 0 + >>> print service.get_member(801) + None + +The service can be used to subscribe new members, but only with the `member` +role. At a minimum, a mailing list and an address for the new user is +required. + + >>> mlist = create_list('test@example.com') + >>> anne = service.join('test@example.com', 'anne@example.com') + >>> anne + <Member: anne <anne@example.com> on test@example.com as MemberRole.member> + +The real name of the new member can be given. + + >>> bart = service.join('test@example.com', 'bart@example.com', + ... 'Bart Person') + >>> bart + <Member: Bart Person <bart@example.com> + on test@example.com as MemberRole.member> + +Other roles can be subscribed using the more traditional interfaces. + + >>> from mailman.interfaces.member import MemberRole + >>> from mailman.utilities.datetime import now + >>> address = list(anne.user.addresses)[0] + >>> address.verified_on = now() + >>> anne.user.preferred_address = address + >>> anne_owner = mlist.subscribe(anne.user, MemberRole.owner) + +And all the subscribed members can now be displayed. + + >>> service.get_members() + [<Member: anne <anne@example.com> on test@example.com as MemberRole.owner>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.member>, + <Member: Bart Person <bart@example.com> on test@example.com + as MemberRole.member>] + >>> sum(1 for member in service) + 3 + >>> print service.get_member(3) + <Member: anne <anne@example.com> on test@example.com as MemberRole.owner> + +Regular members can also be removed. + + >>> service.leave('test@example.com', 'anne@example.com') + >>> service.get_members() + [<Member: anne <anne@example.com> on test@example.com as MemberRole.owner>, + <Member: Bart Person <bart@example.com> on test@example.com + as MemberRole.member>] + >>> sum(1 for member in service) + 2 + + +Finding members +=============== + +If you know the member id for a specific member, you can get that member. + + >>> service.get_member(3) + <Member: anne <anne@example.com> on test@example.com as MemberRole.owner> + +If you know the member's address, you can find all their memberships, based on +specific search criteria. At a minimum, you need the member's email address. +:: + + >>> mlist2 = create_list('foo@example.com') + >>> mlist3 = create_list('bar@example.com') + >>> mlist.subscribe(anne.user, MemberRole.member) + <Member: anne <anne@example.com> on test@example.com as MemberRole.member> + >>> mlist.subscribe(anne.user, MemberRole.moderator) + <Member: anne <anne@example.com> on test@example.com + as MemberRole.moderator> + >>> mlist2.subscribe(anne.user, MemberRole.member) + <Member: anne <anne@example.com> on foo@example.com as MemberRole.member> + >>> mlist3.subscribe(anne.user, MemberRole.owner) + <Member: anne <anne@example.com> on bar@example.com as MemberRole.owner> + + >>> service.find_members('anne@example.com') + [<Member: anne <anne@example.com> on bar@example.com as MemberRole.owner>, + <Member: anne <anne@example.com> on foo@example.com as MemberRole.member>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.member>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.owner>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.moderator>] + +There may be no matching memberships. + + >>> service.find_members('cris@example.com') + [] + +Memberships can also be searched for by user id. + + >>> service.find_members(1) + [<Member: anne <anne@example.com> on bar@example.com as MemberRole.owner>, + <Member: anne <anne@example.com> on foo@example.com as MemberRole.member>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.member>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.owner>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.moderator>] + +You can find all the memberships for an address on a specific mailing list. + + >>> service.find_members('anne@example.com', 'test@example.com') + [<Member: anne <anne@example.com> on test@example.com + as MemberRole.member>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.owner>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.moderator>] + +You can find all the memberships for an address with a specific role. + + >>> service.find_members('anne@example.com', role=MemberRole.owner) + [<Member: anne <anne@example.com> on bar@example.com as MemberRole.owner>, + <Member: anne <anne@example.com> on test@example.com + as MemberRole.owner>] + +You can also find a specific membership by all three criteria. + + >>> service.find_members('anne@example.com', 'test@example.com', + ... MemberRole.owner) + [<Member: anne <anne@example.com> on test@example.com + as MemberRole.owner>] diff --git a/src/mailman/rest/adapters.py b/src/mailman/app/subscriptions.py index 5be128d3d..ab8c3d53c 100644 --- a/src/mailman/rest/adapters.py +++ b/src/mailman/app/subscriptions.py @@ -26,7 +26,7 @@ __all__ = [ from operator import attrgetter - +from storm.expr import And, Or from zope.component import getUtility from zope.interface import implements @@ -36,7 +36,7 @@ from mailman.core.constants import system_preferences from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.listmanager import IListManager, NoSuchListError from mailman.interfaces.member import DeliveryMode -from mailman.interfaces.membership import ( +from mailman.interfaces.subscriptions import ( ISubscriptionService, MissingUserError) from mailman.interfaces.usermanager import IUserManager from mailman.model.member import Member @@ -44,6 +44,20 @@ from mailman.utilities.passwords import make_user_friendly_password +def _membership_sort_key(member): + """Sort function for get_memberships(). + + The members are sorted first by fully-qualified mailing list name, + then by subscribed email address, then by role. + """ + # member.mailing_list is already the fqdn_listname, not the IMailingList + # object. + return (member.mailing_list, + member.address.email, + int(member.role)) + + + class SubscriptionService: """Subscription services for the REST API.""" @@ -72,13 +86,52 @@ class SubscriptionService: """See `ISubscriptionService`.""" members = config.db.store.find( Member, - Member._member_id == member_id) + Member._member_id == unicode(member_id)) if members.count() == 0: return None else: assert members.count() == 1, 'Too many matching members' return members[0] + def find_members(self, subscriber, fqdn_listname=None, role=None): + """See `ISubscriptionService`.""" + # If `subscriber` is a user id, then we'll search for all addresses + # which are controlled by the user, otherwise we'll just search for + # the given address. + user_manager = getUtility(IUserManager) + query = None + if isinstance(subscriber, basestring): + # subscriber is an email address. + address = user_manager.get_address(subscriber) + user = user_manager.get_user(subscriber) + # This probably could be made more efficient. + if address is None or user is None: + return [] + or_clause = Or(Member.address_id == address.id, + Member.user_id == user.id) + else: + # subscriber is a user id. + user = user_manager.get_user_by_id(unicode(subscriber)) + address_ids = list(address.id for address in user.addresses + if address.id is not None) + if len(address_ids) == 0 or user is None: + return [] + or_clause = Or(Member.user_id == user.id, + Member.address_id.is_in(address_ids)) + # The rest is the same, based on the given criteria. + if fqdn_listname is None and role is None: + query = or_clause + elif fqdn_listname is None: + query = And(Member.role == role, or_clause) + elif role is None: + query = And(Member.mailing_list == fqdn_listname, or_clause) + else: + query = And(Member.mailing_list == fqdn_listname, + Member.role == role, + or_clause) + results = config.db.store.find(Member, query) + return sorted(results, key=_membership_sort_key) + def __iter__(self): for member in self.get_members(): yield member @@ -124,4 +177,3 @@ class SubscriptionService: raise NoSuchListError(fqdn_listname) # XXX for now, no notification or user acknowledgement. delete_member(mlist, address, False, False) - return '' diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml index f34076b78..b5317aa69 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -67,8 +67,8 @@ /> <utility - factory="mailman.rest.adapters.SubscriptionService" - provides="mailman.interfaces.membership.ISubscriptionService" + factory="mailman.app.subscriptions.SubscriptionService" + provides="mailman.interfaces.subscriptions.ISubscriptionService" /> <utility diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 83e740f56..57e3ad43d 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -25,6 +25,10 @@ REST * The IMailingList attribute ``host_name`` has been renamed to ``mail_host`` for consistency. This changes the REST API for mailing list resources. (LP: #787599) + * New REST resource http://.../members/find can be POSTed to in order to find + member records. Arguments are `subscriber` (email address to search for - + required), `fqdn_listname` (optional), and `role` (i.e. MemberRole - + optional). (LP: #799612) Commands -------- diff --git a/src/mailman/interfaces/membership.py b/src/mailman/interfaces/subscriptions.py index 233d248fd..c6bc47d5d 100644 --- a/src/mailman/interfaces/membership.py +++ b/src/mailman/interfaces/subscriptions.py @@ -44,7 +44,7 @@ class MissingUserError(MailmanError): class ISubscriptionService(Interface): - """Subscription services for the REST API.""" + """General Subscription services.""" def get_members(): """Return a sequence of all members of all mailing lists. @@ -63,11 +63,31 @@ class ISubscriptionService(Interface): """Return a member record matching the member id. :param member_id: A member id. - :type member_id: unicode + :type member_id: int :return: The matching member, or None if no matching member is found. :rtype: `IMember` """ + def find_members(subscriber, fqdn_listname=None, role=None): + """Search for and return a specific member. + + The members are sorted first by fully-qualified mailing list name, + then by subscribed email address, then by role. Because the user may + be a member of the list under multiple roles (e.g. as an owner and as + a digest member), the member can appear multiple times in this list. + + :param subscriber: The email address or user id of the user getting + subscribed. + :type subscriber: string or int + :param fqdn_listname: The posting address of the mailing list to + search for the subscriber's memberships on. + :type fqdn_listname: string + :param role: The member role. + :type role: `MemberRole` + :return: The list of all memberships, which may be empty. + :rtype: list of `IMember` + """ + def __iter__(): """See `get_members()`.""" @@ -83,8 +103,9 @@ class ISubscriptionService(Interface): :param fqdn_listname: The posting address of the mailing list to subscribe the user to. :type fqdn_listname: string - :param address: The address of the user getting subscribed. - :type address: string + :param subscriber: The email address or user id of the user getting + subscribed. + :type subscriber: string :param real_name: The name of the user. This is only used if a new user is created, and it defaults to the local part of the email address if not given. diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.rst index 80b5a563d..179ac99fe 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.rst @@ -255,6 +255,101 @@ mailing list. total_size: 7 +Finding members +=============== + +You can find a specific member based on several different criteria. For +example, we can search for all the memberships of a particular address. + + >>> dump_json('http://localhost:9001/3.0/members/find', { + ... 'subscriber': 'aperson@example.com', + ... }) + entry 0: + address: aperson@example.com + fqdn_listname: ant@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/4 + user: http://localhost:9001/3.0/users/3 + entry 1: + address: aperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/3 + user: http://localhost:9001/3.0/users/3 + http_etag: ... + start: 0 + total_size: 2 + +Or, we can find all the memberships for an address on a particular mailing +list. + + >>> dump_json('http://localhost:9001/3.0/members/find', { + ... 'subscriber': 'cperson@example.com', + ... 'fqdn_listname': 'bee@example.com', + ... }) + entry 0: + address: cperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/2 + user: http://localhost:9001/3.0/users/2 + entry 1: + address: cperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: owner + self_link: http://localhost:9001/3.0/members/7 + user: http://localhost:9001/3.0/users/2 + http_etag: ... + start: 0 + total_size: 2 + +Or, we can find all the memberships for an address with a specific role. + + >>> dump_json('http://localhost:9001/3.0/members/find', { + ... 'subscriber': 'cperson@example.com', + ... 'role': 'member', + ... }) + entry 0: + address: cperson@example.com + fqdn_listname: ant@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/5 + user: http://localhost:9001/3.0/users/2 + entry 1: + address: cperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/2 + user: http://localhost:9001/3.0/users/2 + http_etag: ... + start: 0 + total_size: 2 + +Finally, we can search for a specific member given all three criteria. + + >>> dump_json('http://localhost:9001/3.0/members/find', { + ... 'subscriber': 'cperson@example.com', + ... 'fqdn_listname': 'bee@example.com', + ... 'role': 'member', + ... }) + entry 0: + address: cperson@example.com + fqdn_listname: bee@example.com + http_etag: ... + role: member + self_link: http://localhost:9001/3.0/members/2 + user: http://localhost:9001/3.0/users/2 + http_etag: ... + start: 0 + total_size: 1 + + Joining a mailing list ====================== diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index 67495874b..aea5df843 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -23,10 +23,12 @@ __metaclass__ = type __all__ = [ 'AMember', 'AllMembers', + 'FindMembers', 'MemberCollection', ] +from operator import attrgetter from restish import http, resource from zope.component import getUtility @@ -36,7 +38,7 @@ from mailman.interfaces.listmanager import IListManager, NoSuchListError from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError, NotAMemberError) -from mailman.interfaces.membership import ISubscriptionService +from mailman.interfaces.subscriptions import ISubscriptionService from mailman.interfaces.user import UnverifiedAddressError from mailman.interfaces.usermanager import IUserManager from mailman.rest.helpers import ( @@ -65,6 +67,25 @@ class _MemberBase(resource.Resource, CollectionMixin): +class MemberCollection(_MemberBase): + """Abstract class for supporting submemberships. + + This is used for example to return a resource representing all the + memberships of a mailing list, or all memberships for a specific email + address. + """ + def _get_collection(self, request): + """See `CollectionMixin`.""" + raise NotImplementedError + + @resource.GET() + def container(self, request): + """roster/[members|owners|moderators]""" + resource = self._make_collection(request) + return http.ok([], etag(resource)) + + + class AMember(_MemberBase): """A member.""" @@ -150,19 +171,34 @@ class AllMembers(_MemberBase): -class MemberCollection(_MemberBase): - """Abstract class for supporting submemberships. +class _FoundMembers(MemberCollection): + """The found members collection.""" + + def __init__(self, members): + super(_FoundMembers, self).__init__() + self._members = members - This is used for example to return a resource representing all the - memberships of a mailing list, or all memberships for a specific email - address. - """ def _get_collection(self, request): """See `CollectionMixin`.""" - raise NotImplementedError + address_of_member = attrgetter('address.email') + return list(sorted(self._members, key=address_of_member)) - @resource.GET() - def container(self, request): - """roster/[members|owners|moderators]""" - resource = self._make_collection(request) + +class FindMembers(_MemberBase): + """/members/find""" + + @resource.POST() + def find(self, request): + """Find a member""" + service = getUtility(ISubscriptionService) + validator = Validator(fqdn_listname=unicode, + subscriber=unicode, + role=enum_validator(MemberRole), + _optional=('fqdn_listname', 'role')) + members = service.find_members(**validator(request)) + # We can't just return the _FoundMembers instance, because + # CollectionMixins have only a GET method, which is incompatible with + # this POSTed resource. IOW, without doing this here, restish would + # throw a 405 Method Not Allowed. + resource = _FoundMembers(members)._make_collection(request) return http.ok([], etag(resource)) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index caa44f872..cd4c8ceef 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -34,7 +34,7 @@ from mailman.rest.addresses import AllAddresses, AnAddress from mailman.rest.domains import ADomain, AllDomains from mailman.rest.helpers import etag, path_to from mailman.rest.lists import AList, AllLists -from mailman.rest.members import AMember, AllMembers +from mailman.rest.members import AMember, AllMembers, FindMembers from mailman.rest.users import AUser, AllUsers @@ -120,9 +120,13 @@ class TopLevel(resource.Resource): """/<api>/members""" if len(segments) == 0: return AllMembers() + # Either the next segment is the string "find" or a member id. They + # cannot collide. + segment = segments.pop(0) + if segment == 'find': + return FindMembers(), segments else: - member_id = segments.pop(0) - return AMember(member_id), segments + return AMember(segment), segments @resource.child() def users(self, request, segments): diff --git a/src/mailman/runners/outgoing.py b/src/mailman/runners/outgoing.py index f611ccf92..65d8928a6 100644 --- a/src/mailman/runners/outgoing.py +++ b/src/mailman/runners/outgoing.py @@ -28,9 +28,9 @@ from mailman.config import config from mailman.core.runner import Runner from mailman.interfaces.bounce import BounceContext, IBounceProcessor from mailman.interfaces.mailinglist import Personalization -from mailman.interfaces.membership import ISubscriptionService from mailman.interfaces.mta import SomeRecipientsFailed from mailman.interfaces.pending import IPendings +from mailman.interfaces.subscriptions import ISubscriptionService from mailman.utilities.datetime import now from mailman.utilities.modules import find_name |
