Provide list{addresses,aliases,users,relocated} subcommands v0.6.x
authormartin f. krafft <madduck@madduck.net>
Sun, 15 Apr 2012 13:17:21 +0200
branchv0.6.x
changeset 555 499c63f52462
parent 554 a93671970617
child 556 fbbb16476c5b
Provide list{addresses,aliases,users,relocated} subcommands The patch provides the list{addresses,aliases,users,relocated} subcommands to the UI. All commands actually take the same path in the code and only one query is run with different parameters for each case. There are still two shortcomings: 1. With alias domains, the output order is not as one might want it, e.g. foo@example.org bar@example.org foo@example.com bar@example.com when it should really be foo@ twice and then bar@ twice. I have not found a way to modify the SQL accordingly. 2. The SELECT queries for Accounts, Alias and Relocated are hard-coded in common.py.
TODO
VirtualMailManager/cli/handler.py
VirtualMailManager/cli/subcommands.py
VirtualMailManager/common.py
VirtualMailManager/constants.py
VirtualMailManager/handler.py
--- a/TODO	Sun Apr 15 19:56:21 2012 +0200
+++ b/TODO	Sun Apr 15 13:17:21 2012 +0200
@@ -40,9 +40,3 @@
       flexibility, there ought to be a m:n table connecting domains to sets of
       default aliases. These aliases then get treated like normal aliases
       unless they are overridden by a real alias.
-
-- list{aliases,accounts,relocated,addresses} subcommands [madduck@madduck.net]
-    - I would find it useful if I could query the database for all defined
-      users, aliases, relocated, and addresses (the union of all three).
-      Ideally, the commands would also take % wildcards, so that I can limit
-      the output
--- a/VirtualMailManager/cli/handler.py	Sun Apr 15 19:56:21 2012 +0200
+++ b/VirtualMailManager/cli/handler.py	Sun Apr 15 13:17:21 2012 +0200
@@ -11,11 +11,11 @@
 import os
 
 from VirtualMailManager.errors import VMMError
-from VirtualMailManager.handler import Handler, TYPE_ACCOUNT
+from VirtualMailManager.handler import Handler
 from VirtualMailManager.cli import read_pass
 from VirtualMailManager.cli.config import CliConfig as Cfg
 from VirtualMailManager.constants import ACCOUNT_EXISTS, INVALID_SECTION, \
-     NO_SUCH_ACCOUNT
+     NO_SUCH_ACCOUNT, TYPE_ACCOUNT
 from VirtualMailManager.password import randompw
 
 _ = lambda msg: msg
--- a/VirtualMailManager/cli/subcommands.py	Sun Apr 15 19:56:21 2012 +0200
+++ b/VirtualMailManager/cli/subcommands.py	Sun Apr 15 13:17:21 2012 +0200
@@ -20,7 +20,8 @@
      version_str, format_domain_default
 from VirtualMailManager.constants import __copyright__, __date__, \
      __version__, ACCOUNT_EXISTS, ALIAS_EXISTS, ALIASDOMAIN_ISDOMAIN, \
-     DOMAIN_ALIAS_EXISTS, INVALID_ARGUMENT, EX_MISSING_ARGS, RELOCATED_EXISTS
+     DOMAIN_ALIAS_EXISTS, INVALID_ARGUMENT, EX_MISSING_ARGS, \
+     RELOCATED_EXISTS, TYPE_ACCOUNT, TYPE_ALIAS, TYPE_RELOCATED
 from VirtualMailManager.errors import VMMError
 from VirtualMailManager.password import list_schemes
 from VirtualMailManager.serviceset import SERVICES
@@ -32,7 +33,8 @@
     'config_get', 'config_set', 'configure',
     'domain_add', 'domain_delete',  'domain_info', 'domain_quota',
     'domain_services', 'domain_transport', 'domain_note', 'get_user', 'help_',
-    'list_domains', 'list_pwschemes', 'relocated_add', 'relocated_delete',
+    'list_domains', 'list_pwschemes', 'list_users', 'list_aliases',
+    'list_relocated', 'list_addresses', 'relocated_add', 'relocated_delete',
     'relocated_info', 'user_add', 'user_delete', 'user_info', 'user_name',
     'user_password', 'user_quota', 'user_services', 'user_transport',
     'user_note', 'version',
@@ -491,6 +493,34 @@
     txt_wrpr.width = txt_wrpr.width + 8
 
 
+def list_addresses(ctx, limit=None):
+    """List all addresses / search addresses by pattern. The output can be
+    limited with TYPE_ACCOUNT, TYPE_ALIAS and TYPE_RELOCATED, which can be
+    bitwise ORed as a combination. Not specifying a limit is the same as
+    combining all three."""
+    if limit is None:
+        limit = TYPE_ACCOUNT | TYPE_ALIAS | TYPE_RELOCATED
+    matching = ctx.argc > 2
+    if matching:
+        gids, addresses = ctx.hdlr.address_list(limit, ctx.args[2].lower())
+    else:
+        gids, addresses = ctx.hdlr.address_list(limit)
+    _print_address_list(limit, gids, addresses, matching)
+
+
+def list_users(ctx):
+    """list all user accounts / search user accounts by pattern"""
+    return list_addresses(ctx, TYPE_ACCOUNT)
+
+def list_aliases(ctx):
+    """list all aliases / search aliases by pattern"""
+    return list_addresses(ctx, TYPE_ALIAS)
+
+def list_relocated(ctx):
+    """list all relocated records / search relocated records by pattern"""
+    return list_addresses(ctx, TYPE_RELOCATED)
+
+
 def relocated_add(ctx):
     """create a new record for a relocated user"""
     if ctx.argc < 3:
@@ -811,8 +841,17 @@
     'domainnote': cmd('domainnote', 'do', domain_note,
                       'fqdn note',
                       _(u'update the note of the given domain')),
+    # List commands
     'listdomains': cmd('listdomains', 'ld', list_domains, '[pattern]',
                       _(u'list all domains or search for domains by pattern')),
+    'listaddresses': cmd('listaddresses', 'll', list_addresses, '[pattern]',
+                      _(u'list all addresses or search for addresses by pattern')),
+    'listusers': cmd('listusers', 'lu', list_users, '[pattern]',
+                      _(u'list all user accounts or search for accounts by pattern')),
+    'listaliases': cmd('listaliases', 'la', list_aliases, '[pattern]',
+                      _(u'list all aliases or search for aliases by pattern')),
+    'listrelocated': cmd('listrelocated', 'lr', list_relocated, '[pattern]',
+                      _(u'list all relocated entries or search for entries by pattern')),
     # Relocated commands
     'relocatedadd': cmd('relocatedadd', 'ra', relocated_add,
                         'address newaddress',
@@ -987,6 +1026,52 @@
     print
 
 
+def _print_address_list(which, dids, addresses, matching):
+    """Print a list of (matching) addresses."""
+    _trans = { TYPE_ACCOUNT                  : _('user accounts')
+             , TYPE_ALIAS                    : _('aliases')
+             , TYPE_RELOCATED                : _('relocated entries')
+             , TYPE_ACCOUNT | TYPE_ALIAS
+                 : _('user accounts and aliases')
+             , TYPE_ACCOUNT | TYPE_RELOCATED
+                 : _('user accounts and relocated entries')
+             , TYPE_ALIAS | TYPE_RELOCATED
+                 : _('aliases and relocated entries')
+             , TYPE_ACCOUNT | TYPE_ALIAS | TYPE_RELOCATED : _('addresses')
+             }
+    try:
+        if matching:
+            title = _(u'Matching %s') % _trans[which]
+        else:
+            title = _(u'Existing %s') % _trans[which]
+        w_std(title, '-' * len(title))
+    except KeyError:
+        raise VMMError(_("Invalid address type for list: '%s'") % which,
+                       INVALID_ARGUMENT)
+    if addresses:
+        if which & (which - 1) == 0:
+            # only one type is requested, so no type indicator
+            _trans = { TYPE_ACCOUNT   : _('')
+                     , TYPE_ALIAS     : _('')
+                     , TYPE_RELOCATED : _('')
+                     }
+        else:
+            _trans = { TYPE_ACCOUNT   : _('u')
+                     , TYPE_ALIAS     : _('a')
+                     , TYPE_RELOCATED : _('r')
+                     }
+        for did in dids:
+            for addr, atype, aliasdomain in addresses[did]:
+                if aliasdomain:
+                    leader = '[%s-]' % _trans[atype]
+                else:
+                    leader = '[%s+]' % _trans[atype]
+                w_std('\t%s %s' % (leader, addr))
+    else:
+        w_std(_('\tNone'))
+    print
+
+
 def _print_aliasdomain_info(info):
     """Print alias domain information."""
     title = _(u'Alias domain information')
--- a/VirtualMailManager/common.py	Sun Apr 15 19:56:21 2012 +0200
+++ b/VirtualMailManager/common.py	Sun Apr 15 13:17:21 2012 +0200
@@ -14,10 +14,10 @@
 import stat
 
 from VirtualMailManager import ENCODING
-from VirtualMailManager.constants import NOT_EXECUTABLE, NO_SUCH_BINARY
+from VirtualMailManager.constants import NOT_EXECUTABLE, NO_SUCH_BINARY, \
+     TYPE_ACCOUNT, TYPE_ALIAS, TYPE_RELOCATED
 from VirtualMailManager.errors import VMMError
 
-
 VERSION_RE = re.compile(r'^(\d+)\.(\d+)\.(?:(\d+)|(alpha|beta|rc)(\d+))$')
 
 _version_level = dict(alpha=0xA, beta=0xB, rc=0xC)
@@ -190,4 +190,73 @@
     """Format info output when the value displayed is the domain default."""
     return _(u'%s [domain default]') % domaindata
 
+
+def search_addresses(dbh, typelimit=None, lpattern=None, llike=False,
+                     dpattern=None, dlike=False):
+    """'Search' for addresses by *pattern* in the database.
+
+    The search is limited by *typelimit*, a bitfield with values TYPE_ACCOUNT,
+    TYPE_ALIAS, TYPE_RELOCATED, or a bitwise OR thereof. If no limit is
+    specified, all types will be searched.
+
+    *lpattern* may be a local part or a partial local part - starting and/or
+    ending with a '%' sign.  When the *lpattern* starts or ends with a '%' sign
+    *llike* has to be `True` to perform a wildcard search. To retrieve all
+    available addresses use the arguments' default values.
+
+    *dpattern* and *dlike* behave analogously for the domain part of an
+    address, allowing for separate pattern matching: testuser%@example.%
+
+    The return value of this function is a tuple. The first element is a list
+    of domain IDs sorted alphabetically by the corresponding domain names. The
+    second element is a dictionary indexed by domain ID, holding lists to
+    associated addresses. Each address is itself actually a tuple of address,
+    type, and boolean indicating whether the address stems from an alias
+    domain.
+    """
+    if typelimit == None:
+            typelimit = TYPE_ACCOUNT | TYPE_ALIAS | TYPE_RELOCATED
+    queries = []
+    if typelimit & TYPE_ACCOUNT:
+        queries.append('SELECT gid, local_part, %d AS type FROM users'
+                       % TYPE_ACCOUNT)
+    if typelimit & TYPE_ALIAS:
+        queries.append('SELECT gid, address as local_part, %d AS type '
+                       'FROM alias' % TYPE_ALIAS)
+    if typelimit & TYPE_RELOCATED:
+        queries.append('SELECT gid, address as local_part, %d AS type '
+                       'FROM relocated' % TYPE_RELOCATED)
+    sql  = "SELECT gid, local_part || '@' || domainname AS address, "
+    sql += 'type, NOT is_primary AS from_aliasdomain FROM ('
+    sql += ' UNION '.join(queries)
+    sql += ') a JOIN domain_name USING (gid)'
+    nextkw = 'WHERE'
+    sqlargs = []
+    for like, field, pattern in ((dlike, 'domainname', dpattern),
+                                 (llike, 'local_part', lpattern)):
+        if like:
+            match = 'LIKE'
+        else:
+            if not pattern: continue
+            match = '='
+        sql += ' %s %s %s %%s' % (nextkw, field, match)
+        sqlargs.append(pattern)
+        nextkw = 'AND'
+    sql += ' ORDER BY domainname, local_part'
+    dbc = dbh.cursor()
+    dbc.execute(sql, sqlargs)
+    result = dbc.fetchall()
+    dbc.close()
+
+    gids = []
+    daddrs = {}
+    lastgid = None
+    for gid, address, addrtype, aliasdomain in result:
+        if gid != lastgid:
+            gids.append(gid)
+            lastgid = gid
+            daddrs[gid] = []
+        daddrs[gid].append((address, addrtype, aliasdomain))
+    return gids, daddrs
+
 del _
--- a/VirtualMailManager/constants.py	Sun Apr 15 19:56:21 2012 +0200
+++ b/VirtualMailManager/constants.py	Sun Apr 15 13:17:21 2012 +0200
@@ -81,3 +81,9 @@
 UNKNOWN_SERVICE = 65
 VMM_ERROR = 67
 VMM_TOO_MANY_FAILURES = 68
+
+# address types
+
+TYPE_ACCOUNT = 0x1
+TYPE_ALIAS = 0x2
+TYPE_RELOCATED = 0x4
--- a/VirtualMailManager/handler.py	Sun Apr 15 19:56:21 2012 +0200
+++ b/VirtualMailManager/handler.py	Sun Apr 15 13:17:21 2012 +0200
@@ -30,10 +30,10 @@
      FOUND_DOTS_IN_PATH, INVALID_ARGUMENT, MAILDIR_PERM_MISMATCH, \
      NOT_EXECUTABLE, NO_SUCH_ACCOUNT, NO_SUCH_ALIAS, NO_SUCH_BINARY, \
      NO_SUCH_DIRECTORY, NO_SUCH_RELOCATED, RELOCATED_EXISTS, UNKNOWN_SERVICE, \
-     VMM_ERROR
+     VMM_ERROR, LOCALPART_INVALID, TYPE_ACCOUNT, TYPE_ALIAS, TYPE_RELOCATED
 from VirtualMailManager.domain import Domain
 from VirtualMailManager.emailaddress import DestinationEmailAddress, \
-     EmailAddress
+     EmailAddress, RE_LOCALPART
 from VirtualMailManager.errors import \
      DomainError, NotRootError, PermissionError, VMMError
 from VirtualMailManager.mailbox import new as new_mailbox
@@ -50,16 +50,12 @@
 CFG_FILE = 'vmm.cfg'
 CFG_PATH = '/root:/usr/local/etc:/etc'
 RE_DOMAIN_SEARCH = """^[a-z0-9-\.]+$"""
-TYPE_ACCOUNT = 0x1
-TYPE_ALIAS = 0x2
-TYPE_RELOCATED = 0x4
 OTHER_TYPES = {
     TYPE_ACCOUNT: (_(u'an account'), ACCOUNT_EXISTS),
     TYPE_ALIAS: (_(u'an alias'), ALIAS_EXISTS),
     TYPE_RELOCATED: (_(u'a relocated user'), RELOCATED_EXISTS),
 }
 
-
 class Handler(object):
     """Wrapper class to simplify the access on all the stuff from
     VirtualMailManager"""
@@ -593,6 +589,46 @@
         self._db_connect()
         return search(self._dbh, pattern=pattern, like=like)
 
+    def address_list(self, typelimit, pattern=None):
+        """TODO"""
+        llike = dlike = False
+        lpattern = dpattern = None
+        if pattern:
+            parts = pattern.split('@', 2)
+            if len(parts) == 2:
+                # The pattern includes '@', so let's treat the
+                # parts separately to allow for pattern search like %@domain.%
+                lpattern = parts[0]
+                llike = lpattern.startswith('%') or lpattern.endswith('%')
+                dpattern = parts[1]
+                dlike = dpattern.startswith('%') or dpattern.endswith('%')
+
+                if llike:
+                    checkp = lpattern.strip('%')
+                else:
+                    checkp = lpattern
+                if len(checkp) > 0 and re.search(RE_LOCALPART, checkp):
+                    raise VMMError(_(u"The pattern '%s' contains invalid "
+                                     u"characters.") % pattern, LOCALPART_INVALID)
+            else:
+                # else just match on domains
+                # (or should that be local part, I don't know…)
+                dpattern = parts[0]
+                dlike = dpattern.startswith('%') or dpattern.endswith('%')
+
+            if dlike:
+                checkp = dpattern.strip('%')
+            else:
+                checkp = dpattern
+            if len(checkp) > 0 and not re.match(RE_DOMAIN_SEARCH, checkp):
+                raise VMMError(_(u"The pattern '%s' contains invalid "
+                                 u"characters.") % pattern, DOMAIN_INVALID)
+        self._db_connect()
+        from VirtualMailManager.common import search_addresses
+        return search_addresses(self._dbh, typelimit=typelimit,
+                                lpattern=lpattern, llike=llike,
+                                dpattern=dpattern, dlike=dlike)
+
     def user_add(self, emailaddress, password):
         """Wrapper around Account.set_password() and Account.save()."""
         acc = self._get_account(emailaddress)