diff options
| -rw-r--r-- | lp1423756.txt | 309 | ||||
| -rw-r--r-- | src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py | 34 | ||||
| -rw-r--r-- | src/mailman/model/docs/users.rst | 15 | ||||
| -rw-r--r-- | src/mailman/model/user.py | 8 | ||||
| -rw-r--r-- | src/mailman/rest/docs/domains.rst | 76 | ||||
| -rw-r--r-- | src/mailman/rest/docs/users.rst | 82 | ||||
| -rw-r--r-- | src/mailman/rest/domains.py | 1 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 2 | ||||
| -rw-r--r-- | src/mailman/rest/users.py | 25 |
9 files changed, 525 insertions, 27 deletions
diff --git a/lp1423756.txt b/lp1423756.txt new file mode 100644 index 000000000..50227c1df --- /dev/null +++ b/lp1423756.txt @@ -0,0 +1,309 @@ +=== modified file 'src/mailman/rest/docs/domains.rst' +--- src/mailman/rest/docs/domains.rst 2014-12-16 01:01:53 +0000 ++++ src/mailman/rest/docs/domains.rst 2015-04-06 15:37:49 +0000 +@@ -29,14 +29,12 @@ + >>> domain_manager.add( + ... 'example.com', 'An example domain', 'http://lists.example.com') + <Domain example.com, An example domain, +- base_url: http://lists.example.com, +- contact_address: postmaster@example.com> ++ base_url: http://lists.example.com> + >>> transaction.commit() + + >>> dump_json('http://localhost:9001/3.0/domains') + entry 0: + base_url: http://lists.example.com +- contact_address: postmaster@example.com + description: An example domain + http_etag: "..." + mail_host: example.com +@@ -51,24 +49,19 @@ + + >>> domain_manager.add( + ... 'example.org', +- ... base_url='http://mail.example.org', +- ... contact_address='listmaster@example.org') +- <Domain example.org, base_url: http://mail.example.org, +- contact_address: listmaster@example.org> ++ ... base_url='http://mail.example.org') ++ <Domain example.org, base_url: http://mail.example.org> + >>> domain_manager.add( + ... 'lists.example.net', + ... 'Porkmasters', +- ... 'http://example.net', +- ... 'porkmaster@example.net') ++ ... 'http://example.net') + <Domain lists.example.net, Porkmasters, +- base_url: http://example.net, +- contact_address: porkmaster@example.net> ++ base_url: http://example.net> + >>> transaction.commit() + + >>> dump_json('http://localhost:9001/3.0/domains') + entry 0: + base_url: http://lists.example.com +- contact_address: postmaster@example.com + description: An example domain + http_etag: "..." + mail_host: example.com +@@ -76,7 +69,6 @@ + url_host: lists.example.com + entry 1: + base_url: http://mail.example.org +- contact_address: listmaster@example.org + description: None + http_etag: "..." + mail_host: example.org +@@ -84,7 +76,6 @@ + url_host: mail.example.org + entry 2: + base_url: http://example.net +- contact_address: porkmaster@example.net + description: Porkmasters + http_etag: "..." + mail_host: lists.example.net +@@ -103,7 +94,6 @@ + + >>> dump_json('http://localhost:9001/3.0/domains/lists.example.net') + base_url: http://example.net +- contact_address: porkmaster@example.net + description: Porkmasters + http_etag: "..." + mail_host: lists.example.net +@@ -165,7 +155,6 @@ + + >>> dump_json('http://localhost:9001/3.0/domains/lists.example.com') + base_url: http://lists.example.com +- contact_address: postmaster@lists.example.com + description: None + http_etag: "..." + mail_host: lists.example.com +@@ -176,9 +165,7 @@ + :: + + >>> domain_manager['lists.example.com'] +- <Domain lists.example.com, +- base_url: http://lists.example.com, +- contact_address: postmaster@lists.example.com> ++ <Domain lists.example.com, base_url: http://lists.example.com> + + # Unlock the database. + >>> transaction.abort() +@@ -190,8 +177,7 @@ + >>> dump_json('http://localhost:9001/3.0/domains', { + ... 'mail_host': 'my.example.com', + ... 'description': 'My new domain', +- ... 'base_url': 'http://allmy.example.com', +- ... 'contact_address': 'helpme@example.com' ++ ... 'base_url': 'http://allmy.example.com' + ... }) + content-length: 0 + date: ... +@@ -200,7 +186,6 @@ + + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com') + base_url: http://allmy.example.com +- contact_address: helpme@example.com + description: My new domain + http_etag: "..." + mail_host: my.example.com +@@ -208,9 +193,7 @@ + url_host: allmy.example.com + + >>> domain_manager['my.example.com'] +- <Domain my.example.com, My new domain, +- base_url: http://allmy.example.com, +- contact_address: helpme@example.com> ++ <Domain my.example.com, My new domain, base_url: http://allmy.example.com> + + # Unlock the database. + >>> transaction.abort() + +=== modified file 'src/mailman/rest/domains.py' +--- src/mailman/rest/domains.py 2015-01-05 01:40:47 +0000 ++++ src/mailman/rest/domains.py 2015-04-06 15:37:49 +0000 +@@ -25,10 +25,12 @@ + + from mailman.interfaces.domain import ( + BadDomainSpecificationError, IDomainManager) ++from mailman.interfaces.usermanager import IUserManager + from mailman.rest.helpers import ( + BadRequest, CollectionMixin, NotFound, bad_request, child, created, etag, + no_content, not_found, okay, path_to) + from mailman.rest.lists import ListsForDomain ++from mailman.rest.users import OwnersForDomain + from mailman.rest.validator import Validator + from zope.component import getUtility + +@@ -41,7 +43,6 @@ + """See `CollectionMixin`.""" + return dict( + base_url=domain.base_url, +- contact_address=domain.contact_address, + description=domain.description, + mail_host=domain.mail_host, + self_link=path_to('domains/{0}'.format(domain.mail_host)), +@@ -88,6 +89,17 @@ + else: + return BadRequest(), [] + ++ @child() ++ def owners(self, request, segments): ++ """/domains/<domain>/owners""" ++ if len(segments) == 0: ++ domain = getUtility(IDomainManager).get(self._domain) ++ if domain is None: ++ return NotFound() ++ return OwnersForDomain(domain) ++ else: ++ return BadRequest(), [] ++ + + class AllDomains(_DomainBase): + """The domains.""" +@@ -99,12 +111,13 @@ + validator = Validator(mail_host=str, + description=str, + base_url=str, +- contact_address=str, ++ owners=list, + _optional=('description', 'base_url', +- 'contact_address')) +- domain = domain_manager.add(**validator(request)) +- except BadDomainSpecificationError: +- bad_request(response, b'Domain exists') ++ 'owners')) ++ values = validator(request) ++ domain = domain_manager.add(**values) ++ except BadDomainSpecificationError as error: ++ bad_request(response, str(error)) + except ValueError as error: + bad_request(response, str(error)) + else: + +=== modified file 'src/mailman/rest/tests/test_domains.py' +--- src/mailman/rest/tests/test_domains.py 2015-01-05 01:40:47 +0000 ++++ src/mailman/rest/tests/test_domains.py 2015-04-06 15:37:49 +0000 +@@ -27,6 +27,7 @@ + from mailman.app.lifecycle import create_list + from mailman.database.transaction import transaction + from mailman.interfaces.listmanager import IListManager ++from mailman.interfaces.domain import IDomainManager + from mailman.testing.helpers import call_api + from mailman.testing.layers import RESTLayer + from urllib.error import HTTPError +@@ -41,6 +42,17 @@ + with transaction(): + self._mlist = create_list('test@example.com') + ++ def test_create_domains(self): ++ """Test Create domain via REST""" ++ data = {'mail_host': 'example.org', ++ 'description': 'Example domain', ++ 'base_url': 'http://example.org', ++ 'owners': ['someone@example.com', ++ 'secondowner@example.com',]} ++ content, response = call_api('http://localhost:9001/3.0/domains', ++ data, method="POST") ++ self.assertEqual(response.status, 201) ++ + def test_bogus_endpoint_extension(self): + # /domains/<domain>/lists/<anything> is not a valid endpoint. + with self.assertRaises(HTTPError) as cm: + +=== modified file 'src/mailman/rest/users.py' +--- src/mailman/rest/users.py 2015-03-20 16:38:00 +0000 ++++ src/mailman/rest/users.py 2015-04-06 15:37:49 +0000 +@@ -22,6 +22,7 @@ + 'AddressUser', + 'AllUsers', + 'Login', ++ 'OwnersForDomain', + ] + + +@@ -67,8 +68,9 @@ + email=str, + display_name=str, + password=str, +- _optional=('display_name', 'password'), +- ) ++ is_server_owner=bool, ++ _optional=('display_name', 'password', 'is_server_owner'), ++) + + + +@@ -108,7 +110,8 @@ + user_id=user_id, + created_on=user.created_on, + self_link=path_to('users/{}'.format(user_id)), +- ) ++ is_server_owner=user.is_server_owner, ++ ) + # Add the password attribute, only if the user has a password. Same + # with the real name. These could be None or the empty string. + if user.password: +@@ -293,7 +296,8 @@ + del fields['email'] + fields['user_id'] = int + fields['auto_create'] = as_boolean +- fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create') ++ fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create', ++ 'is_server_owner') + try: + validator = Validator(**fields) + arguments = validator(request) +@@ -328,7 +332,8 @@ + # Process post data and check for an existing user. + fields = CREATION_FIELDS.copy() + fields['user_id'] = int +- fields['_optional'] = fields['_optional'] + ('user_id', 'email') ++ fields['_optional'] = fields['_optional'] + ('user_id', 'email', ++ 'is_server_owner') + try: + validator = Validator(**fields) + arguments = validator(request) +@@ -377,3 +382,41 @@ + no_content(response) + else: + forbidden(response) ++ ++class OwnersForDomain(_UserBase): ++ """Owners for a particular domain.""" ++ ++ def __init__(self, domain): ++ self._domain = domain ++ ++ def on_get(self, request, response): ++ """/domains/<domain>/owners""" ++ resource = self._make_collection(request) ++ okay(response, etag(resource)) ++ ++ def on_post(self, request, response): ++ """POST to /domains/<domain>/owners """ ++ validator = Validator(owner=GetterSetter(str)) ++ try: ++ values = validator(request) ++ except ValueError as error: ++ bad_request(response, str(error)) ++ return ++ self._domain.add_owner(values['owner']) ++ return no_content(response) ++ ++ def on_delete(self, request, response): ++ """DELETE to /domains/<domain>/owners""" ++ validator = Validator(owner=GetterSetter(str)) ++ try: ++ values = validator(request) ++ except ValueError as error: ++ bad_request(response, str(error)) ++ return ++ self._domain.remove_owner(owner) ++ return no_content(response) ++ ++ @paginate ++ def _get_collection(self, request): ++ """See `CollectionMixin`.""" ++ return list(self._domain.owners) + diff --git a/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py b/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py index e4dc25ab8..fc489cae5 100644 --- a/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py +++ b/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py @@ -1,3 +1,20 @@ +# Copyright (C) 2015 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + """add_serverowner_domainowner Revision ID: 46e92facee7 @@ -6,7 +23,7 @@ Create Date: 2015-03-20 16:01:25.007242 """ -# revision identifiers, used by Alembic. +# Revision identifiers, used by Alembic. revision = '46e92facee7' down_revision = '33e1f5f6fa8' @@ -15,15 +32,17 @@ import sqlalchemy as sa def upgrade(): - op.create_table('domain_owner', + op.create_table( + 'domain_owner', sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('domain_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['domain_id'], ['domain.id'], ), sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), sa.PrimaryKeyConstraint('user_id', 'domain_id') - ) - op.add_column('user', sa.Column('is_server_owner', sa.Boolean(), - nullable=True)) + ) + op.add_column( + 'user', + sa.Column('is_server_owner', sa.Boolean(), nullable=True)) if op.get_bind().dialect.name != 'sqlite': op.drop_column('domain', 'contact_address') @@ -31,6 +50,7 @@ def upgrade(): def downgrade(): if op.get_bind().dialect.name != 'sqlite': op.drop_column('user', 'is_server_owner') - op.add_column('domain', sa.Column('contact_address', sa.VARCHAR(), - nullable=True)) + op.add_column( + 'domain', + sa.Column('contact_address', sa.VARCHAR(), nullable=True)) op.drop_table('domain_owner') diff --git a/src/mailman/model/docs/users.rst b/src/mailman/model/docs/users.rst index 0b926d6a7..0d6a0f368 100644 --- a/src/mailman/model/docs/users.rst +++ b/src/mailman/model/docs/users.rst @@ -295,4 +295,19 @@ membership role. zperson@example.org xtest_2.example.com MemberRole.owner +Server owners +============= + +Some users are server owners. Zoe is not yet a server owner. + + >>> user_1.is_server_owner + False + +So, let's make her one. + + >>> user_1.is_server_owner = True + >>> user_1.is_server_owner + True + + .. _`usermanager.txt`: usermanager.html diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py index 5fecc1836..f6aedd132 100644 --- a/src/mailman/model/user.py +++ b/src/mailman/model/user.py @@ -18,8 +18,8 @@ """Model for users.""" __all__ = [ + 'DomainOwner', 'User', - 'DomainOwner' ] @@ -35,7 +35,7 @@ from mailman.model.preferences import Preferences from mailman.model.roster import Memberships from mailman.utilities.datetime import factory as date_factory from mailman.utilities.uid import UniqueIDFactory -from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode, Boolean +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Unicode from sqlalchemy.orm import relationship, backref from zope.event import notify from zope.interface import implementer @@ -180,9 +180,11 @@ class User(Model): return Memberships(self) + class DomainOwner(Model): - """Domain to owners(user) association class""" + """Internal table for associating domains to their owners.""" __tablename__ = 'domain_owner' + user_id = Column(Integer, ForeignKey('user.id'), primary_key=True) domain_id = Column(Integer, ForeignKey('domain.id'), primary_key=True) diff --git a/src/mailman/rest/docs/domains.rst b/src/mailman/rest/docs/domains.rst index 16204548c..f57dee572 100644 --- a/src/mailman/rest/docs/domains.rst +++ b/src/mailman/rest/docs/domains.rst @@ -28,8 +28,7 @@ Once a domain is added, it is accessible through the API. >>> domain_manager.add( ... 'example.com', 'An example domain', 'http://lists.example.com') - <Domain example.com, An example domain, - base_url: http://lists.example.com> + <Domain example.com, An example domain, base_url: http://lists.example.com> >>> transaction.commit() >>> dump_json('http://localhost:9001/3.0/domains') @@ -55,8 +54,7 @@ At the top level, all domains are returned as separate entries. ... 'lists.example.net', ... 'Porkmasters', ... 'http://example.net') - <Domain lists.example.net, Porkmasters, - base_url: http://example.net> + <Domain lists.example.net, Porkmasters, base_url: http://example.net> >>> transaction.commit() >>> dump_json('http://localhost:9001/3.0/domains') @@ -212,4 +210,74 @@ Domains can also be deleted via the API. status: 204 +Domain owners +============= + +Domains can have owners. By posting some addresses to the owners resource, +you can add some domain owners. Currently our domain has no owners: + + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners') + http_etag: ... + start: 0 + total_size: 0 + +Anne and Bart volunteer to be a domain owners. +:: + + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners', { + ... 'owner': 'anne@example.com', + ... }) + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners', { + ... 'owner': 'bart@example.com', + ... }) + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners') + entry 0: + created_on: 2005-08-01T07:49:23 + http_etag: ... + is_server_owner: False + self_link: http://localhost:9001/3.0/users/1 + user_id: 1 + entry 1: + created_on: 2005-08-01T07:49:23 + http_etag: ... + is_server_owner: False + self_link: http://localhost:9001/3.0/users/2 + user_id: 2 + http_etag: ... + start: 0 + total_size: 2 + +We can delete all the domain owners. + + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners', + ... method='DELETE') + + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners') + entry 0: + created_on: 2005-08-01T07:49:23 + http_etag: ... + is_server_owner: False + self_link: http://localhost:9001/3.0/users/1 + user_id: 1 + entry 1: + created_on: 2005-08-01T07:49:23 + http_etag: ... + is_server_owner: False + self_link: http://localhost:9001/3.0/users/2 + user_id: 2 + http_etag: ... + start: 0 + total_size: 2 + + .. _Domains: ../../model/docs/domains.html diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst index 343598518..13390a00f 100644 --- a/src/mailman/rest/docs/users.rst +++ b/src/mailman/rest/docs/users.rst @@ -257,8 +257,9 @@ You can change both the display name and the password by PUTing the full resource. >>> dump_json('http://localhost:9001/3.0/users/4', { - ... 'display_name': 'David Personhood', ... 'cleartext_password': 'the garden', + ... 'display_name': 'David Personhood', + ... 'is_server_owner': False, ... }, method='PUT') content-length: 0 date: ... @@ -416,3 +417,82 @@ This time, Elly successfully logs into Mailman. date: ... server: ... status: 204 + + +Server owners +============= + +Users can be designated as server owners. Elly is not currently a server +owner. + + >>> dump_json('http://localhost:9001/3.0/users/5') + created_on: 2005-08-01T07:49:23 + display_name: Elly Person + http_etag: "..." + is_server_owner: False + password: {plaintext}supersekrit + self_link: http://localhost:9001/3.0/users/5 + user_id: 5 + +Let's make her a server owner. +:: + + >>> dump_json('http://localhost:9001/3.0/users/5', { + ... 'is_server_owner': True, + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/users/5') + created_on: 2005-08-01T07:49:23 + display_name: Elly Person + http_etag: "..." + is_server_owner: True + password: {plaintext}supersekrit + self_link: http://localhost:9001/3.0/users/5 + user_id: 5 + +Elly later retires as server owner. +:: + + >>> dump_json('http://localhost:9001/3.0/users/5', { + ... 'is_server_owner': False, + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/users/5') + created_on: 2005-08-01T07:49:23 + display_name: Elly Person + http_etag: "..." + is_server_owner: False + password: {plaintext}... + self_link: http://localhost:9001/3.0/users/5 + user_id: 5 + +Gwen, a new users, takes over as a server owner. +:: + + >>> dump_json('http://localhost:9001/3.0/users', { + ... 'display_name': 'Gwen Person', + ... 'email': 'gwen@example.com', + ... 'is_server_owner': True, + ... }) + content-length: 0 + date: ... + location: http://localhost:9001/3.0/users/7 + server: ... + status: 201 + + >>> dump_json('http://localhost:9001/3.0/users/7') + created_on: 2005-08-01T07:49:23 + display_name: Gwen Person + http_etag: "..." + is_server_owner: True + password: {plaintext}... + self_link: http://localhost:9001/3.0/users/7 + user_id: 7 diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py index 41a1c50bd..dff2f2073 100644 --- a/src/mailman/rest/domains.py +++ b/src/mailman/rest/domains.py @@ -25,7 +25,6 @@ __all__ = [ from mailman.interfaces.domain import ( BadDomainSpecificationError, IDomainManager) -from mailman.interfaces.usermanager import IUserManager from mailman.rest.helpers import ( BadRequest, CollectionMixin, NotFound, bad_request, child, created, etag, no_content, not_found, okay, path_to) diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index 641ddec8e..f6bc27917 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -1,4 +1,4 @@ - # Copyright (C) 2010-2015 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2015 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py index b8eaee448..a38299d00 100644 --- a/src/mailman/rest/users.py +++ b/src/mailman/rest/users.py @@ -59,18 +59,19 @@ class PasswordEncrypterGetterSetter(GetterSetter): ATTRIBUTES = dict( - display_name=GetterSetter(str), cleartext_password=PasswordEncrypterGetterSetter(), + display_name=GetterSetter(str), + is_server_owner=GetterSetter(as_boolean), ) CREATION_FIELDS = dict( - email=str, display_name=str, - password=str, + email=str, is_server_owner=bool, + password=str, _optional=('display_name', 'password', 'is_server_owner'), -) + ) @@ -80,6 +81,7 @@ def create_user(arguments, response): # strip that out (if it exists), then create the user, adding the password # after the fact if successful. password = arguments.pop('password', None) + is_server_owner = arguments.pop('is_server_owner', False) try: user = getUtility(IUserManager).create_user(**arguments) except ExistingAddressError as error: @@ -90,6 +92,7 @@ def create_user(arguments, response): # This will have to be reset since it cannot be retrieved. password = generate(int(config.passwords.password_length)) user.password = config.password_context.encrypt(password) + user.is_server_owner = is_server_owner location = path_to('users/{}'.format(user.user_id.int)) created(response, location) return user @@ -107,10 +110,10 @@ class _UserBase(CollectionMixin): # but we serialize its integer equivalent. user_id = user.user_id.int resource = dict( - user_id=user_id, created_on=user.created_on, - self_link=path_to('users/{}'.format(user_id)), is_server_owner=user.is_server_owner, + self_link=path_to('users/{}'.format(user_id)), + user_id=user_id, ) # Add the password attribute, only if the user has a password. Same # with the real name. These could be None or the empty string. @@ -296,8 +299,8 @@ class AddressUser(_UserBase): del fields['email'] fields['user_id'] = int fields['auto_create'] = as_boolean - fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create', - 'is_server_owner') + fields['_optional'] = fields['_optional'] + ( + 'user_id', 'auto_create', 'is_server_owner') try: validator = Validator(**fields) arguments = validator(request) @@ -332,8 +335,8 @@ class AddressUser(_UserBase): # Process post data and check for an existing user. fields = CREATION_FIELDS.copy() fields['user_id'] = int - fields['_optional'] = fields['_optional'] + ('user_id', 'email', - 'is_server_owner') + fields['_optional'] = fields['_optional'] + ( + 'user_id', 'email', 'is_server_owner') try: validator = Validator(**fields) arguments = validator(request) @@ -383,6 +386,8 @@ class Login: else: forbidden(response) + + class OwnersForDomain(_UserBase): """Owners for a particular domain.""" |
