root / pykota / trunk / pykota / storages / ldapstorage.py @ 2452

Revision 2452, 76.8 kB (checked in by jerome, 19 years ago)

Upgraded database schema.
Added -i | --ingroups command line option to repykota.
Added -C | --comment command line option to edpykota.
Added 'noquota', 'noprint', and 'nochange' as switches for edpykota's
-l | --limitby command line option.
Severity : entirely new features, in need of testers :-)

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
Line 
1# PyKota
2# -*- coding: ISO-8859-15 -*-
3#
4# PyKota : Print Quotas for CUPS and LPRng
5#
6# (c) 2003-2004 Jerome Alet <alet@librelogiciel.com>
7# This program is free software; you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation; either version 2 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program; if not, write to the Free Software
19# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20#
21# $Id$
22#
23#
24
25#
26# My IANA assigned number, for
27# "Conseil Internet & Logiciels Libres, J�me Alet"
28# is 16868. Use this as a base to create the LDAP schema.
29#
30
31import sys
32import os
33import types
34import time
35import md5
36from mx import DateTime
37
38from pykota.storage import PyKotaStorageError, BaseStorage, StorageObject, \
39                           StorageUser, StorageGroup, StoragePrinter, \
40                           StorageJob, StorageLastJob, StorageUserPQuota, \
41                           StorageGroupPQuota, StorageBillingCode
42
43try :
44    import ldap
45    import ldap.modlist
46except ImportError :   
47    raise PyKotaStorageError, "This python version (%s) doesn't seem to have the python-ldap module installed correctly." % sys.version.split()[0]
48else :   
49    try :
50        from ldap.cidict import cidict
51    except ImportError :   
52        import UserDict
53        sys.stderr.write("ERROR: PyKota requires a newer version of python-ldap. Workaround activated. Please upgrade python-ldap !\n")
54        class cidict(UserDict.UserDict) :
55            pass # Fake it all, and don't care for case insensitivity : users who need it will have to upgrade.
56   
57class Storage(BaseStorage) :
58    def __init__(self, pykotatool, host, dbname, user, passwd) :
59        """Opens the LDAP connection."""
60        self.savedtool = pykotatool
61        self.savedhost = host
62        self.saveddbname = dbname
63        self.saveduser = user
64        self.savedpasswd = passwd
65        self.secondStageInit()
66       
67    def secondStageInit(self) :   
68        """Second stage initialisation."""
69        BaseStorage.__init__(self, self.savedtool)
70        self.info = self.tool.config.getLDAPInfo()
71        message = ""
72        for tryit in range(3) :
73            try :
74                self.tool.logdebug("Trying to open database (host=%s, dbname=%s, user=%s)..." % (self.savedhost, self.saveddbname, self.saveduser))
75                self.database = ldap.initialize(self.savedhost) 
76                if self.info["ldaptls"] :
77                    # we want TLS
78                    ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.info["cacert"])
79                    self.database.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
80                    self.database.start_tls_s()
81                self.database.simple_bind_s(self.saveduser, self.savedpasswd)
82                self.basedn = self.saveddbname
83            except ldap.SERVER_DOWN :   
84                message = "LDAP backend for PyKota seems to be down !"
85                self.tool.printInfo("%s" % message, "error")
86                self.tool.printInfo("Trying again in 2 seconds...", "warn")
87                time.sleep(2)
88            except ldap.LDAPError :   
89                message = "Unable to connect to LDAP server %s as %s." % (self.savedhost, self.saveduser)
90                self.tool.printInfo("%s" % message, "error")
91                self.tool.printInfo("Trying again in 2 seconds...", "warn")
92                time.sleep(2)
93            else :   
94                self.useldapcache = self.tool.config.getLDAPCache()
95                if self.useldapcache :
96                    self.tool.logdebug("Low-Level LDAP Caching enabled.")
97                    self.ldapcache = {} # low-level cache specific to LDAP backend
98                self.closed = 0
99                self.tool.logdebug("Database opened (host=%s, dbname=%s, user=%s)" % (self.savedhost, self.saveddbname, self.saveduser))
100                return # All is fine here.
101        raise PyKotaStorageError, message         
102           
103    def close(self) :   
104        """Closes the database connection."""
105        if not self.closed :
106            self.database.unbind_s()
107            self.closed = 1
108            self.tool.logdebug("Database closed.")
109       
110    def genUUID(self) :   
111        """Generates an unique identifier.
112       
113           TODO : this one is not unique accross several print servers, but should be sufficient for testing.
114        """
115        return md5.md5("%s" % time.time()).hexdigest()
116       
117    def normalizeFields(self, fields) :   
118        """Ensure all items are lists."""
119        for (k, v) in fields.items() :
120            if type(v) not in (types.TupleType, types.ListType) :
121                if not v :
122                    del fields[k]
123                else :   
124                    fields[k] = [ v ]
125        return fields       
126       
127    def beginTransaction(self) :   
128        """Starts a transaction."""
129        self.tool.logdebug("Transaction begins... WARNING : No transactions in LDAP !")
130       
131    def commitTransaction(self) :   
132        """Commits a transaction."""
133        self.tool.logdebug("Transaction committed. WARNING : No transactions in LDAP !")
134       
135    def rollbackTransaction(self) :     
136        """Rollbacks a transaction."""
137        self.tool.logdebug("Transaction aborted. WARNING : No transaction in LDAP !")
138       
139    def doSearch(self, key, fields=None, base="", scope=ldap.SCOPE_SUBTREE, flushcache=0) :
140        """Does an LDAP search query."""
141        message = ""
142        for tryit in range(3) :
143            try :
144                base = base or self.basedn
145                if self.useldapcache :
146                    # Here we overwrite the fields the app want, to try and
147                    # retrieve ALL user defined attributes ("*")
148                    # + the createTimestamp attribute, needed by job history
149                    #
150                    # This may not work with all LDAP servers
151                    # but works at least in OpenLDAP (2.1.25)
152                    # and iPlanet Directory Server (5.1 SP3)
153                    fields = ["*", "createTimestamp"]         
154                   
155                if self.useldapcache and (not flushcache) and (scope == ldap.SCOPE_BASE) and self.ldapcache.has_key(base) :
156                    entry = self.ldapcache[base]
157                    self.tool.logdebug("LDAP cache hit %s => %s" % (base, entry))
158                    result = [(base, entry)]
159                else :
160                    self.tool.logdebug("QUERY : Filter : %s, BaseDN : %s, Scope : %s, Attributes : %s" % (key, base, scope, fields))
161                    result = self.database.search_s(base, scope, key, fields)
162            except ldap.NO_SUCH_OBJECT, msg :       
163                raise PyKotaStorageError, (_("Search base %s doesn't seem to exist. Probable misconfiguration. Please double check /etc/pykota/pykota.conf : %s") % (base, msg))
164            except ldap.LDAPError, msg :   
165                message = (_("Search for %s(%s) from %s(scope=%s) returned no answer.") % (key, fields, base, scope)) + " : %s" % str(msg)
166                self.tool.printInfo("LDAP error : %s" % message, "error")
167                self.tool.printInfo("LDAP connection will be closed and reopened.", "warn")
168                self.close()
169                self.secondStageInit()
170            else :     
171                self.tool.logdebug("QUERY : Result : %s" % result)
172                result = [ (dn, cidict(attrs)) for (dn, attrs) in result ]
173                if self.useldapcache :
174                    for (dn, attributes) in result :
175                        self.tool.logdebug("LDAP cache store %s => %s" % (dn, attributes))
176                        self.ldapcache[dn] = attributes
177                return result
178        raise PyKotaStorageError, message
179           
180    def doAdd(self, dn, fields) :
181        """Adds an entry in the LDAP directory."""
182        fields = self.normalizeFields(cidict(fields))
183        message = ""
184        for tryit in range(3) :
185            try :
186                self.tool.logdebug("QUERY : ADD(%s, %s)" % (dn, str(fields)))
187                entry = ldap.modlist.addModlist(fields)
188                self.tool.logdebug("%s" % entry)
189                self.database.add_s(dn, entry)
190            except ldap.LDAPError, msg :
191                message = (_("Problem adding LDAP entry (%s, %s)") % (dn, str(fields))) + " : %s" % str(msg)
192                self.tool.printInfo("LDAP error : %s" % message, "error")
193                self.tool.printInfo("LDAP connection will be closed and reopened.", "warn")
194                self.close()
195                self.secondStageInit()
196            else :
197                if self.useldapcache :
198                    self.tool.logdebug("LDAP cache add %s => %s" % (dn, fields))
199                    self.ldapcache[dn] = fields
200                return dn
201        raise PyKotaStorageError, message
202           
203    def doDelete(self, dn) :
204        """Deletes an entry from the LDAP directory."""
205        message = ""
206        for tryit in range(3) :
207            try :
208                self.tool.logdebug("QUERY : Delete(%s)" % dn)
209                self.database.delete_s(dn)
210            except ldap.LDAPError, msg :
211                message = (_("Problem deleting LDAP entry (%s)") % dn) + " : %s" % str(msg)
212                self.tool.printInfo("LDAP error : %s" % message, "error")
213                self.tool.printInfo("LDAP connection will be closed and reopened.", "warn")
214                self.close()
215                self.secondStageInit()
216            else :   
217                if self.useldapcache :
218                    try :
219                        self.tool.logdebug("LDAP cache del %s" % dn)
220                        del self.ldapcache[dn]
221                    except KeyError :   
222                        pass
223                return       
224        raise PyKotaStorageError, message
225           
226    def doModify(self, dn, fields, ignoreold=1, flushcache=0) :
227        """Modifies an entry in the LDAP directory."""
228        fields = cidict(fields)
229        for tryit in range(3) :
230            try :
231                # TODO : take care of, and update LDAP specific cache
232                if self.useldapcache and not flushcache :
233                    if self.ldapcache.has_key(dn) :
234                        old = self.ldapcache[dn]
235                        self.tool.logdebug("LDAP cache hit %s => %s" % (dn, old))
236                        oldentry = {}
237                        for (k, v) in old.items() :
238                            if k != "createTimestamp" :
239                                oldentry[k] = v
240                    else :   
241                        self.tool.logdebug("LDAP cache miss %s" % dn)
242                        oldentry = self.doSearch("objectClass=*", base=dn, scope=ldap.SCOPE_BASE)[0][1]
243                else :       
244                    oldentry = self.doSearch("objectClass=*", base=dn, scope=ldap.SCOPE_BASE, flushcache=flushcache)[0][1]
245                for (k, v) in fields.items() :
246                    if type(v) == type({}) :
247                        try :
248                            oldvalue = v["convert"](oldentry.get(k, [0])[0])
249                        except ValueError :   
250                            self.tool.logdebug("Error converting %s with %s(%s)" % (oldentry.get(k), k, v))
251                            oldvalue = 0
252                        if v["operator"] == '+' :
253                            newvalue = oldvalue + v["value"]
254                        else :   
255                            newvalue = oldvalue - v["value"]
256                        fields[k] = str(newvalue)
257                fields = self.normalizeFields(fields)
258                self.tool.logdebug("QUERY : Modify(%s, %s ==> %s)" % (dn, oldentry, fields))
259                entry = ldap.modlist.modifyModlist(oldentry, fields, ignore_oldexistent=ignoreold)
260                modentry = []
261                for (mop, mtyp, mval) in entry :
262                    if mtyp and (mtyp.lower() != "createtimestamp") :
263                        modentry.append((mop, mtyp, mval))
264                self.tool.logdebug("MODIFY : %s ==> %s ==> %s" % (fields, entry, modentry))
265                if modentry :
266                    self.database.modify_s(dn, modentry)
267            except ldap.LDAPError, msg :
268                message = (_("Problem modifying LDAP entry (%s, %s)") % (dn, fields)) + " : %s" % str(msg)
269                self.tool.printInfo("LDAP error : %s" % message, "error")
270                self.tool.printInfo("LDAP connection will be closed and reopened.", "warn")
271                self.close()
272                self.secondStageInit()
273            else :
274                if self.useldapcache :
275                    cachedentry = self.ldapcache[dn]
276                    for (mop, mtyp, mval) in entry :
277                        if mop in (ldap.MOD_ADD, ldap.MOD_REPLACE) :
278                            cachedentry[mtyp] = mval
279                        else :
280                            try :
281                                del cachedentry[mtyp]
282                            except KeyError :   
283                                pass
284                    self.tool.logdebug("LDAP cache update %s => %s" % (dn, cachedentry))
285                return dn
286        raise PyKotaStorageError, message
287           
288    def filterNames(self, records, attribute) :       
289        """Returns a list of 'attribute' from a list of records.
290       
291           Logs any missing attribute.
292        """   
293        result = []
294        for (dn, record) in records :
295            attrval = record.get(attribute, [None])[0]
296            if attrval is None :
297                self.tool.printInfo("Object %s has no %s attribute !" % (dn, attribute), "error")
298            else :   
299                result.append(attrval)
300        return result       
301               
302    def getAllBillingCodes(self, billingcode=None) :   
303        """Extracts all billing codes or only the billing codes matching the optional parameter."""
304        billingcodes = []
305        ldapfilter = "objectClass=pykotaBilling"
306        if billingcode :
307            ldapfilter = "(&(%s)(pykotaBillingCode=%s))" % (ldapfilter, self.userCharsetToDatabase(billingcode))
308        result = self.doSearch(ldapfilter, ["pykotaBillingCode"], base=self.info["billingcodebase"])
309        if result :
310            billingcodes = [self.databaseToUserCharset(bc) for bc in self.filterNames(result, "pykotaBillingCode")]
311        return billingcodes
312       
313    def getAllPrintersNames(self, printername=None) :   
314        """Extracts all printer names or only the printers' names matching the optional parameter."""
315        printernames = []
316        ldapfilter = "objectClass=pykotaPrinter"
317        if printername :
318            ldapfilter = "(&(%s)(pykotaPrinterName=%s))" % (ldapfilter, printername)
319        result = self.doSearch(ldapfilter, ["pykotaPrinterName"], base=self.info["printerbase"])
320        if result :
321            printernames = self.filterNames(result, "pykotaPrinterName")
322        return printernames
323       
324    def getAllUsersNames(self, username=None) :   
325        """Extracts all user names or only the users' names matching the optional parameter."""
326        usernames = []
327        ldapfilter = "objectClass=pykotaAccount"
328        if username :
329            ldapfilter = "(&(%s)(pykotaUserName=%s))" % (ldapfilter, username)
330        result = self.doSearch(ldapfilter, ["pykotaUserName"], base=self.info["userbase"])
331        if result :
332            usernames = self.filterNames(result, "pykotaUserName")
333        return usernames
334       
335    def getAllGroupsNames(self, groupname=None) :   
336        """Extracts all group names or only the groups' names matching the optional parameter."""
337        groupnames = []
338        ldapfilter = "objectClass=pykotaGroup"
339        if groupname :
340            ldapfilter = "(&(%s)(pykotaGroupName=%s))" % (ldapfilter, groupname)
341        result = self.doSearch(ldapfilter, ["pykotaGroupName"], base=self.info["groupbase"])
342        if result :
343            groupnames = self.filterNames(result, "pykotaGroupName")
344        return groupnames
345       
346    def getUserNbJobsFromHistory(self, user) :
347        """Returns the number of jobs the user has in history."""
348        result = self.doSearch("(&(pykotaUserName=%s)(objectClass=pykotaJob))" % user.Name, None, base=self.info["jobbase"])
349        return len(result)
350       
351    def getUserFromBackend(self, username) :   
352        """Extracts user information given its name."""
353        user = StorageUser(self, username)
354        result = self.doSearch("(&(objectClass=pykotaAccount)(|(pykotaUserName=%s)(%s=%s)))" % (username, self.info["userrdn"], username), ["pykotaUserName", "pykotaLimitBy", self.info["usermail"], "pykotaOverCharge"], base=self.info["userbase"])
355        if result :
356            fields = result[0][1]
357            user.ident = result[0][0]
358            user.Name = fields.get("pykotaUserName", [username])[0] 
359            user.Email = fields.get(self.info["usermail"], [None])[0]
360            user.LimitBy = fields.get("pykotaLimitBy", ["quota"])[0]
361            user.OverCharge = float(fields.get("pykotaOverCharge", [1.0])[0])
362            result = self.doSearch("(&(objectClass=pykotaAccountBalance)(|(pykotaUserName=%s)(%s=%s)))" % (username, self.info["balancerdn"], username), ["pykotaBalance", "pykotaLifeTimePaid", "pykotaPayments"], base=self.info["balancebase"])
363            if not result :
364                raise PyKotaStorageError, _("No pykotaAccountBalance object found for user %s. Did you create LDAP entries manually ?") % username
365            else :
366                fields = result[0][1]
367                user.idbalance = result[0][0]
368                user.AccountBalance = fields.get("pykotaBalance")
369                if user.AccountBalance is not None :
370                    if user.AccountBalance[0].upper() == "NONE" :
371                        user.AccountBalance = None
372                    else :   
373                        user.AccountBalance = float(user.AccountBalance[0])
374                user.AccountBalance = user.AccountBalance or 0.0       
375                user.LifeTimePaid = fields.get("pykotaLifeTimePaid")
376                if user.LifeTimePaid is not None :
377                    if user.LifeTimePaid[0].upper() == "NONE" :
378                        user.LifeTimePaid = None
379                    else :   
380                        user.LifeTimePaid = float(user.LifeTimePaid[0])
381                user.LifeTimePaid = user.LifeTimePaid or 0.0       
382                user.Payments = []
383                for payment in fields.get("pykotaPayments", []) :
384                    try :
385                        (date, amount, description) = payment.split(" # ")
386                    except ValueError :
387                        # Payment with no description (old Payment)
388                        (date, amount) = payment.split(" # ")
389                        description = ""
390                    else :   
391                        description = self.databaseToUserCharset(description)
392                    user.Payments.append((date, float(amount), description))
393            user.Exists = 1
394        return user
395       
396    def getGroupFromBackend(self, groupname) :   
397        """Extracts group information given its name."""
398        group = StorageGroup(self, groupname)
399        result = self.doSearch("(&(objectClass=pykotaGroup)(|(pykotaGroupName=%s)(%s=%s)))" % (groupname, self.info["grouprdn"], groupname), ["pykotaGroupName", "pykotaLimitBy"], base=self.info["groupbase"])
400        if result :
401            fields = result[0][1]
402            group.ident = result[0][0]
403            group.Name = fields.get("pykotaGroupName", [groupname])[0] 
404            group.LimitBy = fields.get("pykotaLimitBy", ["quota"])[0]
405            group.AccountBalance = 0.0
406            group.LifeTimePaid = 0.0
407            for member in self.getGroupMembers(group) :
408                if member.Exists :
409                    group.AccountBalance += member.AccountBalance
410                    group.LifeTimePaid += member.LifeTimePaid
411            group.Exists = 1
412        return group
413       
414    def getPrinterFromBackend(self, printername) :       
415        """Extracts printer information given its name : returns first matching printer."""
416        printer = StoragePrinter(self, printername)
417        result = self.doSearch("(&(objectClass=pykotaPrinter)(|(pykotaPrinterName=%s)(%s=%s)))" % (printername, self.info["printerrdn"], printername), ["pykotaPrinterName", "pykotaPricePerPage", "pykotaPricePerJob", "uniqueMember", "description"], base=self.info["printerbase"])
418        if result :
419            fields = result[0][1]       # take only first matching printer, ignore the rest
420            printer.ident = result[0][0]
421            printer.Name = fields.get("pykotaPrinterName", [printername])[0] 
422            printer.PricePerJob = float(fields.get("pykotaPricePerJob", [0.0])[0])
423            printer.PricePerPage = float(fields.get("pykotaPricePerPage", [0.0])[0])
424            printer.uniqueMember = fields.get("uniqueMember", [])
425            printer.Description = self.databaseToUserCharset(fields.get("description", [""])[0]) 
426            printer.Exists = 1
427        return printer   
428       
429    def getUserPQuotaFromBackend(self, user, printer) :       
430        """Extracts a user print quota."""
431        userpquota = StorageUserPQuota(self, user, printer)
432        if printer.Exists and user.Exists :
433            if self.info["userquotabase"].lower() == "user" :
434                base = user.ident
435            else :   
436                base = self.info["userquotabase"]
437            result = self.doSearch("(&(objectClass=pykotaUserPQuota)(pykotaUserName=%s)(pykotaPrinterName=%s))" % (user.Name, printer.Name), ["pykotaPageCounter", "pykotaLifePageCounter", "pykotaSoftLimit", "pykotaHardLimit", "pykotaDateLimit", "pykotaWarnCount"], base=base)
438            if result :
439                fields = result[0][1]
440                userpquota.ident = result[0][0]
441                userpquota.PageCounter = int(fields.get("pykotaPageCounter", [0])[0])
442                userpquota.LifePageCounter = int(fields.get("pykotaLifePageCounter", [0])[0])
443                userpquota.WarnCount = int(fields.get("pykotaWarnCount", [0])[0])
444                userpquota.SoftLimit = fields.get("pykotaSoftLimit")
445                if userpquota.SoftLimit is not None :
446                    if userpquota.SoftLimit[0].upper() == "NONE" :
447                        userpquota.SoftLimit = None
448                    else :   
449                        userpquota.SoftLimit = int(userpquota.SoftLimit[0])
450                userpquota.HardLimit = fields.get("pykotaHardLimit")
451                if userpquota.HardLimit is not None :
452                    if userpquota.HardLimit[0].upper() == "NONE" :
453                        userpquota.HardLimit = None
454                    elif userpquota.HardLimit is not None :   
455                        userpquota.HardLimit = int(userpquota.HardLimit[0])
456                userpquota.DateLimit = fields.get("pykotaDateLimit")
457                if userpquota.DateLimit is not None :
458                    if userpquota.DateLimit[0].upper() == "NONE" : 
459                        userpquota.DateLimit = None
460                    else :   
461                        userpquota.DateLimit = userpquota.DateLimit[0]
462                userpquota.Exists = 1
463        return userpquota
464       
465    def getGroupPQuotaFromBackend(self, group, printer) :       
466        """Extracts a group print quota."""
467        grouppquota = StorageGroupPQuota(self, group, printer)
468        if group.Exists :
469            if self.info["groupquotabase"].lower() == "group" :
470                base = group.ident
471            else :   
472                base = self.info["groupquotabase"]
473            result = self.doSearch("(&(objectClass=pykotaGroupPQuota)(pykotaGroupName=%s)(pykotaPrinterName=%s))" % (group.Name, printer.Name), ["pykotaSoftLimit", "pykotaHardLimit", "pykotaDateLimit"], base=base)
474            if result :
475                fields = result[0][1]
476                grouppquota.ident = result[0][0]
477                grouppquota.SoftLimit = fields.get("pykotaSoftLimit")
478                if grouppquota.SoftLimit is not None :
479                    if grouppquota.SoftLimit[0].upper() == "NONE" :
480                        grouppquota.SoftLimit = None
481                    else :   
482                        grouppquota.SoftLimit = int(grouppquota.SoftLimit[0])
483                grouppquota.HardLimit = fields.get("pykotaHardLimit")
484                if grouppquota.HardLimit is not None :
485                    if grouppquota.HardLimit[0].upper() == "NONE" :
486                        grouppquota.HardLimit = None
487                    else :   
488                        grouppquota.HardLimit = int(grouppquota.HardLimit[0])
489                grouppquota.DateLimit = fields.get("pykotaDateLimit")
490                if grouppquota.DateLimit is not None :
491                    if grouppquota.DateLimit[0].upper() == "NONE" : 
492                        grouppquota.DateLimit = None
493                    else :   
494                        grouppquota.DateLimit = grouppquota.DateLimit[0]
495                grouppquota.PageCounter = 0
496                grouppquota.LifePageCounter = 0
497                usernamesfilter = "".join(["(pykotaUserName=%s)" % member.Name for member in self.getGroupMembers(group)])
498                if usernamesfilter :
499                    usernamesfilter = "(|%s)" % usernamesfilter
500                if self.info["userquotabase"].lower() == "user" :
501                    base = self.info["userbase"]
502                else :
503                    base = self.info["userquotabase"]
504                result = self.doSearch("(&(objectClass=pykotaUserPQuota)(pykotaPrinterName=%s)%s)" % (printer.Name, usernamesfilter), ["pykotaPageCounter", "pykotaLifePageCounter"], base=base)
505                if result :
506                    for userpquota in result :   
507                        grouppquota.PageCounter += int(userpquota[1].get("pykotaPageCounter", [0])[0] or 0)
508                        grouppquota.LifePageCounter += int(userpquota[1].get("pykotaLifePageCounter", [0])[0] or 0)
509                grouppquota.Exists = 1
510        return grouppquota
511       
512    def getPrinterLastJobFromBackend(self, printer) :       
513        """Extracts a printer's last job information."""
514        lastjob = StorageLastJob(self, printer)
515        result = self.doSearch("(&(objectClass=pykotaLastjob)(|(pykotaPrinterName=%s)(%s=%s)))" % (printer.Name, self.info["printerrdn"], printer.Name), ["pykotaLastJobIdent"], base=self.info["lastjobbase"])
516        if result :
517            lastjob.lastjobident = result[0][0]
518            lastjobident = result[0][1]["pykotaLastJobIdent"][0]
519            result = None
520            try :
521                result = self.doSearch("objectClass=pykotaJob", [ "pykotaJobSizeBytes", 
522                                                                  "pykotaHostName", 
523                                                                  "pykotaUserName", 
524                                                                  "pykotaPrinterName", 
525                                                                  "pykotaJobId", 
526                                                                  "pykotaPrinterPageCounter", 
527                                                                  "pykotaJobSize", 
528                                                                  "pykotaAction", 
529                                                                  "pykotaJobPrice", 
530                                                                  "pykotaFileName", 
531                                                                  "pykotaTitle", 
532                                                                  "pykotaCopies", 
533                                                                  "pykotaOptions", 
534                                                                  "pykotaBillingCode", 
535                                                                  "pykotaPages", 
536                                                                  "pykotaMD5Sum", 
537                                                                  "createTimestamp" ], 
538                                                                base="cn=%s,%s" % (lastjobident, self.info["jobbase"]), scope=ldap.SCOPE_BASE)
539            except PyKotaStorageError :   
540                pass # Last job entry exists, but job probably doesn't exist anymore.
541            if result :
542                fields = result[0][1]
543                lastjob.ident = result[0][0]
544                lastjob.JobId = fields.get("pykotaJobId")[0]
545                lastjob.UserName = fields.get("pykotaUserName")[0]
546                lastjob.PrinterPageCounter = int(fields.get("pykotaPrinterPageCounter", [0])[0])
547                try :
548                    lastjob.JobSize = int(fields.get("pykotaJobSize", [0])[0])
549                except ValueError :   
550                    lastjob.JobSize = None
551                try :   
552                    lastjob.JobPrice = float(fields.get("pykotaJobPrice", [0.0])[0])
553                except ValueError :   
554                    lastjob.JobPrice = None
555                lastjob.JobAction = fields.get("pykotaAction", [""])[0]
556                lastjob.JobFileName = self.databaseToUserCharset(fields.get("pykotaFileName", [""])[0]) 
557                lastjob.JobTitle = self.databaseToUserCharset(fields.get("pykotaTitle", [""])[0]) 
558                lastjob.JobCopies = int(fields.get("pykotaCopies", [0])[0])
559                lastjob.JobOptions = self.databaseToUserCharset(fields.get("pykotaOptions", [""])[0]) 
560                lastjob.JobHostName = fields.get("pykotaHostName", [""])[0]
561                lastjob.JobSizeBytes = fields.get("pykotaJobSizeBytes", [0L])[0]
562                lastjob.JobBillingCode = self.databaseToUserCharset(fields.get("pykotaBillingCode", [None])[0])
563                lastjob.JobMD5Sum = fields.get("pykotaMD5Sum", [None])[0]
564                lastjob.JobPages = fields.get("pykotaPages", [""])[0]
565                if lastjob.JobTitle == lastjob.JobFileName == lastjob.JobOptions == "hidden" :
566                    (lastjob.JobTitle, lastjob.JobFileName, lastjob.JobOptions) = (_("Hidden because of privacy concerns"),) * 3
567                date = fields.get("createTimestamp", ["19700101000000"])[0]
568                year = int(date[:4])
569                month = int(date[4:6])
570                day = int(date[6:8])
571                hour = int(date[8:10])
572                minute = int(date[10:12])
573                second = int(date[12:14])
574                lastjob.JobDate = "%04i-%02i-%02i %02i:%02i:%02i" % (year, month, day, hour, minute, second)
575                lastjob.Exists = 1
576        return lastjob
577       
578    def getGroupMembersFromBackend(self, group) :       
579        """Returns the group's members list."""
580        groupmembers = []
581        result = self.doSearch("(&(objectClass=pykotaGroup)(|(pykotaGroupName=%s)(%s=%s)))" % (group.Name, self.info["grouprdn"], group.Name), [self.info["groupmembers"]], base=self.info["groupbase"])
582        if result :
583            for username in result[0][1].get(self.info["groupmembers"], []) :
584                groupmembers.append(self.getUser(username))
585        return groupmembers       
586       
587    def getUserGroupsFromBackend(self, user) :       
588        """Returns the user's groups list."""
589        groups = []
590        result = self.doSearch("(&(objectClass=pykotaGroup)(%s=%s))" % (self.info["groupmembers"], user.Name), [self.info["grouprdn"], "pykotaGroupName", "pykotaLimitBy"], base=self.info["groupbase"])
591        if result :
592            for (groupid, fields) in result :
593                groupname = (fields.get("pykotaGroupName", [None]) or fields.get(self.info["grouprdn"], [None]))[0]
594                group = self.getFromCache("GROUPS", groupname)
595                if group is None :
596                    group = StorageGroup(self, groupname)
597                    group.ident = groupid
598                    group.LimitBy = fields.get("pykotaLimitBy")
599                    if group.LimitBy is not None :
600                        group.LimitBy = group.LimitBy[0]
601                    else :   
602                        group.LimitBy = "quota"
603                    group.AccountBalance = 0.0
604                    group.LifeTimePaid = 0.0
605                    for member in self.getGroupMembers(group) :
606                        if member.Exists :
607                            group.AccountBalance += member.AccountBalance
608                            group.LifeTimePaid += member.LifeTimePaid
609                    group.Exists = 1
610                    self.cacheEntry("GROUPS", group.Name, group)
611                groups.append(group)
612        return groups       
613       
614    def getParentPrintersFromBackend(self, printer) :   
615        """Get all the printer groups this printer is a member of."""
616        pgroups = []
617        result = self.doSearch("(&(objectClass=pykotaPrinter)(uniqueMember=%s))" % printer.ident, ["pykotaPrinterName"], base=self.info["printerbase"])
618        if result :
619            for (printerid, fields) in result :
620                if printerid != printer.ident : # In case of integrity violation.
621                    parentprinter = self.getPrinter(fields.get("pykotaPrinterName")[0])
622                    if parentprinter.Exists :
623                        pgroups.append(parentprinter)
624        return pgroups
625       
626    def getMatchingPrinters(self, printerpattern) :
627        """Returns the list of all printers for which name matches a certain pattern."""
628        printers = []
629        # see comment at the same place in pgstorage.py
630        result = self.doSearch("(&(objectClass=pykotaPrinter)(|%s))" % "".join(["(pykotaPrinterName=%s)(%s=%s)" % (pname, self.info["printerrdn"], pname) for pname in printerpattern.split(",")]), ["pykotaPrinterName", "pykotaPricePerPage", "pykotaPricePerJob", "uniqueMember", "description"], base=self.info["printerbase"])
631        if result :
632            for (printerid, fields) in result :
633                printername = fields.get("pykotaPrinterName", [""])[0] or fields.get(self.info["printerrdn"], [""])[0]
634                printer = StoragePrinter(self, printername)
635                printer.ident = printerid
636                printer.PricePerJob = float(fields.get("pykotaPricePerJob", [0.0])[0] or 0.0)
637                printer.PricePerPage = float(fields.get("pykotaPricePerPage", [0.0])[0] or 0.0)
638                printer.uniqueMember = fields.get("uniqueMember", [])
639                printer.Description = self.databaseToUserCharset(fields.get("description", [""])[0]) 
640                printer.Exists = 1
641                printers.append(printer)
642                self.cacheEntry("PRINTERS", printer.Name, printer)
643        return printers       
644       
645    def getPrinterUsersAndQuotas(self, printer, names=["*"]) :       
646        """Returns the list of users who uses a given printer, along with their quotas."""
647        usersandquotas = []
648        if self.info["userquotabase"].lower() == "user" :
649           base = self.info["userbase"]
650        else :
651           base = self.info["userquotabase"]
652        result = self.doSearch("(&(objectClass=pykotaUserPQuota)(pykotaPrinterName=%s)(|%s))" % (printer.Name, "".join(["(pykotaUserName=%s)" % uname for uname in names])), ["pykotaUserName", "pykotaPageCounter", "pykotaLifePageCounter", "pykotaSoftLimit", "pykotaHardLimit", "pykotaDateLimit", "pykotaWarnCount"], base=base)
653        if result :
654            for (userquotaid, fields) in result :
655                user = self.getUser(fields.get("pykotaUserName")[0])
656                userpquota = StorageUserPQuota(self, user, printer)
657                userpquota.ident = userquotaid
658                userpquota.PageCounter = int(fields.get("pykotaPageCounter", [0])[0])
659                userpquota.LifePageCounter = int(fields.get("pykotaLifePageCounter", [0])[0])
660                userpquota.WarnCount = int(fields.get("pykotaWarnCount", [0])[0])
661                userpquota.SoftLimit = fields.get("pykotaSoftLimit")
662                if userpquota.SoftLimit is not None :
663                    if userpquota.SoftLimit[0].upper() == "NONE" :
664                        userpquota.SoftLimit = None
665                    else :   
666                        userpquota.SoftLimit = int(userpquota.SoftLimit[0])
667                userpquota.HardLimit = fields.get("pykotaHardLimit")
668                if userpquota.HardLimit is not None :
669                    if userpquota.HardLimit[0].upper() == "NONE" :
670                        userpquota.HardLimit = None
671                    elif userpquota.HardLimit is not None :   
672                        userpquota.HardLimit = int(userpquota.HardLimit[0])
673                userpquota.DateLimit = fields.get("pykotaDateLimit")
674                if userpquota.DateLimit is not None :
675                    if userpquota.DateLimit[0].upper() == "NONE" : 
676                        userpquota.DateLimit = None
677                    else :   
678                        userpquota.DateLimit = userpquota.DateLimit[0]
679                userpquota.Exists = 1
680                usersandquotas.append((user, userpquota))
681                self.cacheEntry("USERPQUOTAS", "%s@%s" % (user.Name, printer.Name), userpquota)
682        usersandquotas.sort(lambda x, y : cmp(x[0].Name, y[0].Name))           
683        return usersandquotas
684               
685    def getPrinterGroupsAndQuotas(self, printer, names=["*"]) :       
686        """Returns the list of groups which uses a given printer, along with their quotas."""
687        groupsandquotas = []
688        if self.info["groupquotabase"].lower() == "group" :
689           base = self.info["groupbase"]
690        else :
691           base = self.info["groupquotabase"]
692        result = self.doSearch("(&(objectClass=pykotaGroupPQuota)(pykotaPrinterName=%s)(|%s))" % (printer.Name, "".join(["(pykotaGroupName=%s)" % gname for gname in names])), ["pykotaGroupName"], base=base)
693        if result :
694            for (groupquotaid, fields) in result :
695                group = self.getGroup(fields.get("pykotaGroupName")[0])
696                grouppquota = self.getGroupPQuota(group, printer)
697                groupsandquotas.append((group, grouppquota))
698        groupsandquotas.sort(lambda x, y : cmp(x[0].Name, y[0].Name))           
699        return groupsandquotas
700       
701    def addPrinter(self, printername) :       
702        """Adds a printer to the quota storage, returns it."""
703        fields = { self.info["printerrdn"] : printername,
704                   "objectClass" : ["pykotaObject", "pykotaPrinter"],
705                   "cn" : printername,
706                   "pykotaPrinterName" : printername,
707                   "pykotaPricePerPage" : "0.0",
708                   "pykotaPricePerJob" : "0.0",
709                 } 
710        dn = "%s=%s,%s" % (self.info["printerrdn"], printername, self.info["printerbase"])
711        self.doAdd(dn, fields)
712        return self.getPrinter(printername)
713       
714    def addUser(self, user) :       
715        """Adds a user to the quota storage, returns it."""
716        newfields = {
717                       "pykotaUserName" : user.Name,
718                       "pykotaLimitBy" : (user.LimitBy or "quota"),
719                       "pykotaOverCharge" : str(user.OverCharge),
720                    }   
721                       
722        if user.Email :
723            newfields.update({self.info["usermail"]: user.Email})
724        mustadd = 1
725        if self.info["newuser"].lower() != 'below' :
726            try :
727                (where, action) = [s.strip() for s in self.info["newuser"].split(",")]
728            except ValueError :
729                (where, action) = (self.info["newuser"].strip(), "fail")
730            result = self.doSearch("(&(objectClass=%s)(%s=%s))" % (where, self.info["userrdn"], user.Name), None, base=self.info["userbase"])
731            if result :
732                (dn, fields) = result[0]
733                oc = fields.get("objectClass", fields.get("objectclass", []))
734                oc.extend(["pykotaAccount", "pykotaAccountBalance"])
735                fields.update(newfields)
736                fields.update({ "pykotaBalance" : str(user.AccountBalance or 0.0),
737                                "pykotaLifeTimePaid" : str(user.LifeTimePaid or 0.0), })   
738                self.doModify(dn, fields)
739                mustadd = 0
740            else :
741                message = _("Unable to find an existing objectClass %s entry with %s=%s to attach pykotaAccount objectClass") % (where, self.info["userrdn"], user.Name)
742                if action.lower() == "warn" :   
743                    self.tool.printInfo(_("%s. A new entry will be created instead.") % message, "warn")
744                else : # 'fail' or incorrect setting
745                    raise PyKotaStorageError, "%s. Action aborted. Please check your configuration." % message
746               
747        if mustadd :
748            if self.info["userbase"] == self.info["balancebase"] :           
749                fields = { self.info["userrdn"] : user.Name,
750                           "objectClass" : ["pykotaObject", "pykotaAccount", "pykotaAccountBalance"],
751                           "cn" : user.Name,
752                           "pykotaBalance" : str(user.AccountBalance or 0.0),
753                           "pykotaLifeTimePaid" : str(user.LifeTimePaid or 0.0), 
754                         } 
755            else :             
756                fields = { self.info["userrdn"] : user.Name,
757                           "objectClass" : ["pykotaObject", "pykotaAccount"],
758                           "cn" : user.Name,
759                         } 
760            fields.update(newfields)         
761            dn = "%s=%s,%s" % (self.info["userrdn"], user.Name, self.info["userbase"])
762            self.doAdd(dn, fields)
763            if self.info["userbase"] != self.info["balancebase"] :           
764                fields = { self.info["balancerdn"] : user.Name,
765                           "objectClass" : ["pykotaObject", "pykotaAccountBalance"],
766                           "cn" : user.Name,
767                           "pykotaBalance" : str(user.AccountBalance or 0.0),
768                           "pykotaLifeTimePaid" : str(user.LifeTimePaid or 0.0), 
769                         } 
770                dn = "%s=%s,%s" % (self.info["balancerdn"], user.Name, self.info["balancebase"])
771                self.doAdd(dn, fields)
772           
773        return self.getUser(user.Name)
774       
775    def addGroup(self, group) :       
776        """Adds a group to the quota storage, returns it."""
777        newfields = { 
778                      "pykotaGroupName" : group.Name,
779                      "pykotaLimitBy" : (group.LimitBy or "quota"),
780                    } 
781        mustadd = 1
782        if self.info["newgroup"].lower() != 'below' :
783            try :
784                (where, action) = [s.strip() for s in self.info["newgroup"].split(",")]
785            except ValueError :
786                (where, action) = (self.info["newgroup"].strip(), "fail")
787            result = self.doSearch("(&(objectClass=%s)(%s=%s))" % (where, self.info["grouprdn"], group.Name), None, base=self.info["groupbase"])
788            if result :
789                (dn, fields) = result[0]
790                oc = fields.get("objectClass", fields.get("objectclass", []))
791                oc.extend(["pykotaGroup"])
792                fields.update(newfields)
793                self.doModify(dn, fields)
794                mustadd = 0
795            else :
796                message = _("Unable to find an existing entry to attach pykotaGroup objectclass %s") % group.Name
797                if action.lower() == "warn" :   
798                    self.tool.printInfo("%s. A new entry will be created instead." % message, "warn")
799                else : # 'fail' or incorrect setting
800                    raise PyKotaStorageError, "%s. Action aborted. Please check your configuration." % message
801               
802        if mustadd :
803            fields = { self.info["grouprdn"] : group.Name,
804                       "objectClass" : ["pykotaObject", "pykotaGroup"],
805                       "cn" : group.Name,
806                     } 
807            fields.update(newfields)         
808            dn = "%s=%s,%s" % (self.info["grouprdn"], group.Name, self.info["groupbase"])
809            self.doAdd(dn, fields)
810        return self.getGroup(group.Name)
811       
812    def addUserToGroup(self, user, group) :   
813        """Adds an user to a group."""
814        if user.Name not in [u.Name for u in self.getGroupMembers(group)] :
815            result = self.doSearch("objectClass=pykotaGroup", None, base=group.ident, scope=ldap.SCOPE_BASE)   
816            if result :
817                fields = result[0][1]
818                if not fields.has_key(self.info["groupmembers"]) :
819                    fields[self.info["groupmembers"]] = []
820                fields[self.info["groupmembers"]].append(user.Name)
821                self.doModify(group.ident, fields)
822                group.Members.append(user)
823               
824    def addUserPQuota(self, user, printer) :
825        """Initializes a user print quota on a printer."""
826        uuid = self.genUUID()
827        fields = { "cn" : uuid,
828                   "objectClass" : ["pykotaObject", "pykotaUserPQuota"],
829                   "pykotaUserName" : user.Name,
830                   "pykotaPrinterName" : printer.Name,
831                   "pykotaDateLimit" : "None",
832                   "pykotaPageCounter" : "0",
833                   "pykotaLifePageCounter" : "0",
834                   "pykotaWarnCount" : "0",
835                 } 
836        if self.info["userquotabase"].lower() == "user" :
837            dn = "cn=%s,%s" % (uuid, user.ident)
838        else :   
839            dn = "cn=%s,%s" % (uuid, self.info["userquotabase"])
840        self.doAdd(dn, fields)
841        return self.getUserPQuota(user, printer)
842       
843    def addGroupPQuota(self, group, printer) :
844        """Initializes a group print quota on a printer."""
845        uuid = self.genUUID()
846        fields = { "cn" : uuid,
847                   "objectClass" : ["pykotaObject", "pykotaGroupPQuota"],
848                   "pykotaGroupName" : group.Name,
849                   "pykotaPrinterName" : printer.Name,
850                   "pykotaDateLimit" : "None",
851                 } 
852        if self.info["groupquotabase"].lower() == "group" :
853            dn = "cn=%s,%s" % (uuid, group.ident)
854        else :   
855            dn = "cn=%s,%s" % (uuid, self.info["groupquotabase"])
856        self.doAdd(dn, fields)
857        return self.getGroupPQuota(group, printer)
858       
859    def writePrinterPrices(self, printer) :   
860        """Write the printer's prices back into the storage."""
861        fields = {
862                   "pykotaPricePerPage" : str(printer.PricePerPage),
863                   "pykotaPricePerJob" : str(printer.PricePerJob),
864                 }
865        self.doModify(printer.ident, fields)
866       
867    def writePrinterDescription(self, printer) :   
868        """Write the printer's description back into the storage."""
869        fields = {
870                   "description" : self.userCharsetToDatabase(printer.Description or ""),
871                 }
872        if fields["description"] :
873            self.doModify(printer.ident, fields)
874       
875    def writeUserOverCharge(self, user, factor) :
876        """Sets the user's overcharging coefficient."""
877        fields = {
878                   "pykotaOverCharge" : str(factor),
879                 }
880        self.doModify(user.ident, fields)
881       
882    def writeUserLimitBy(self, user, limitby) :   
883        """Sets the user's limiting factor."""
884        fields = {
885                   "pykotaLimitBy" : limitby,
886                 }
887        self.doModify(user.ident, fields)         
888       
889    def writeGroupLimitBy(self, group, limitby) :   
890        """Sets the group's limiting factor."""
891        fields = {
892                   "pykotaLimitBy" : limitby,
893                 }
894        self.doModify(group.ident, fields)         
895       
896    def writeUserPQuotaDateLimit(self, userpquota, datelimit) :   
897        """Sets the date limit permanently for a user print quota."""
898        fields = {
899                   "pykotaDateLimit" : datelimit,
900                 }
901        return self.doModify(userpquota.ident, fields)
902           
903    def writeGroupPQuotaDateLimit(self, grouppquota, datelimit) :   
904        """Sets the date limit permanently for a group print quota."""
905        fields = {
906                   "pykotaDateLimit" : datelimit,
907                 }
908        return self.doModify(grouppquota.ident, fields)
909       
910    def increaseUserPQuotaPagesCounters(self, userpquota, nbpages) :   
911        """Increase page counters for a user print quota."""
912        fields = {
913                   "pykotaPageCounter" : { "operator" : "+", "value" : nbpages, "convert" : int },
914                   "pykotaLifePageCounter" : { "operator" : "+", "value" : nbpages, "convert" : int },
915                 }
916        return self.doModify(userpquota.ident, fields)         
917       
918    def writeUserPQuotaPagesCounters(self, userpquota, newpagecounter, newlifepagecounter) :   
919        """Sets the new page counters permanently for a user print quota."""
920        fields = {
921                   "pykotaPageCounter" : str(newpagecounter),
922                   "pykotaLifePageCounter" : str(newlifepagecounter),
923                   "pykotaDateLimit" : None,
924                   "pykotaWarnCount" : "0",
925                 } 
926        return self.doModify(userpquota.ident, fields)         
927       
928    def decreaseUserAccountBalance(self, user, amount) :   
929        """Decreases user's account balance from an amount."""
930        fields = {
931                   "pykotaBalance" : { "operator" : "-", "value" : amount, "convert" : float },
932                 }
933        return self.doModify(user.idbalance, fields, flushcache=1)         
934       
935    def writeUserAccountBalance(self, user, newbalance, newlifetimepaid=None) :   
936        """Sets the new account balance and eventually new lifetime paid."""
937        fields = {
938                   "pykotaBalance" : str(newbalance),
939                 }
940        if newlifetimepaid is not None :
941            fields.update({ "pykotaLifeTimePaid" : str(newlifetimepaid) })
942        return self.doModify(user.idbalance, fields)         
943           
944    def writeNewPayment(self, user, amount, comment="") :
945        """Adds a new payment to the payments history."""
946        payments = []
947        for payment in user.Payments :
948            payments.append("%s # %s # %s" % (payment[0], str(payment[1]), payment[2]))
949        payments.append("%s # %s # %s" % (str(DateTime.now()), str(amount), self.userCharsetToDatabase(comment)))
950        fields = {
951                   "pykotaPayments" : payments,
952                 }
953        return self.doModify(user.idbalance, fields)         
954       
955    def writeLastJobSize(self, lastjob, jobsize, jobprice) :       
956        """Sets the last job's size permanently."""
957        fields = {
958                   "pykotaJobSize" : str(jobsize),
959                   "pykotaJobPrice" : str(jobprice),
960                 }
961        self.doModify(lastjob.ident, fields)         
962       
963    def writeJobNew(self, printer, user, jobid, pagecounter, action, jobsize=None, jobprice=None, filename=None, title=None, copies=None, options=None, clienthost=None, jobsizebytes=None, jobmd5sum=None, jobpages=None, jobbilling=None) :
964        """Adds a job in a printer's history."""
965        if (not self.disablehistory) or (not printer.LastJob.Exists) :
966            uuid = self.genUUID()
967            dn = "cn=%s,%s" % (uuid, self.info["jobbase"])
968        else :   
969            uuid = printer.LastJob.ident[3:].split(",")[0]
970            dn = printer.LastJob.ident
971        if self.privacy :   
972            # For legal reasons, we want to hide the title, filename and options
973            title = filename = options = "hidden"
974        fields = {
975                   "objectClass" : ["pykotaObject", "pykotaJob"],
976                   "cn" : uuid,
977                   "pykotaUserName" : user.Name,
978                   "pykotaPrinterName" : printer.Name,
979                   "pykotaJobId" : jobid,
980                   "pykotaPrinterPageCounter" : str(pagecounter),
981                   "pykotaAction" : action,
982                   "pykotaFileName" : ((filename is None) and "None") or self.userCharsetToDatabase(filename), 
983                   "pykotaTitle" : ((title is None) and "None") or self.userCharsetToDatabase(title), 
984                   "pykotaCopies" : str(copies), 
985                   "pykotaOptions" : ((options is None) and "None") or self.userCharsetToDatabase(options), 
986                   "pykotaHostName" : str(clienthost), 
987                   "pykotaJobSizeBytes" : str(jobsizebytes),
988                   "pykotaMD5Sum" : str(jobmd5sum),
989                   "pykotaPages" : jobpages,            # don't add this attribute if it is not set, so no string conversion
990                   "pykotaBillingCode" : self.userCharsetToDatabase(jobbilling), # don't add this attribute if it is not set, so no string conversion
991                 }
992        if (not self.disablehistory) or (not printer.LastJob.Exists) :
993            if jobsize is not None :         
994                fields.update({ "pykotaJobSize" : str(jobsize), "pykotaJobPrice" : str(jobprice) })
995            self.doAdd(dn, fields)
996        else :   
997            # here we explicitly want to reset jobsize to 'None' if needed
998            fields.update({ "pykotaJobSize" : str(jobsize), "pykotaJobPrice" : str(jobprice) })
999            self.doModify(dn, fields)
1000           
1001        if printer.LastJob.Exists :
1002            fields = {
1003                       "pykotaLastJobIdent" : uuid,
1004                     }
1005            self.doModify(printer.LastJob.lastjobident, fields)         
1006        else :   
1007            lastjuuid = self.genUUID()
1008            lastjdn = "cn=%s,%s" % (lastjuuid, self.info["lastjobbase"])
1009            fields = {
1010                       "objectClass" : ["pykotaObject", "pykotaLastJob"],
1011                       "cn" : lastjuuid,
1012                       "pykotaPrinterName" : printer.Name,
1013                       "pykotaLastJobIdent" : uuid,
1014                     } 
1015            self.doAdd(lastjdn, fields)         
1016           
1017    def writeUserPQuotaLimits(self, userpquota, softlimit, hardlimit) :
1018        """Sets soft and hard limits for a user quota."""
1019        fields = { 
1020                   "pykotaSoftLimit" : str(softlimit),
1021                   "pykotaHardLimit" : str(hardlimit),
1022                   "pykotaDateLimit" : "None",
1023                   "pykotaWarnCount" : "0",
1024                 }
1025        self.doModify(userpquota.ident, fields)
1026       
1027    def writeUserPQuotaWarnCount(self, userpquota, warncount) :
1028        """Sets the warn counter value for a user quota."""
1029        fields = { 
1030                   "pykotaWarnCount" : str(warncount or 0),
1031                 }
1032        self.doModify(userpquota.ident, fields)
1033       
1034    def increaseUserPQuotaWarnCount(self, userpquota) :
1035        """Increases the warn counter value for a user quota."""
1036        fields = {
1037                   "pykotaWarnCount" : { "operator" : "+", "value" : 1, "convert" : int },
1038                 }
1039        return self.doModify(userpquota.ident, fields)         
1040       
1041    def writeGroupPQuotaLimits(self, grouppquota, softlimit, hardlimit) :
1042        """Sets soft and hard limits for a group quota on a specific printer."""
1043        fields = { 
1044                   "pykotaSoftLimit" : str(softlimit),
1045                   "pykotaHardLimit" : str(hardlimit),
1046                   "pykotaDateLimit" : "None",
1047                 }
1048        self.doModify(grouppquota.ident, fields)
1049           
1050    def writePrinterToGroup(self, pgroup, printer) :
1051        """Puts a printer into a printer group."""
1052        if printer.ident not in pgroup.uniqueMember :
1053            pgroup.uniqueMember.append(printer.ident)
1054            fields = {
1055                       "uniqueMember" : pgroup.uniqueMember
1056                     } 
1057            self.doModify(pgroup.ident, fields)         
1058           
1059    def removePrinterFromGroup(self, pgroup, printer) :
1060        """Removes a printer from a printer group."""
1061        try :
1062            pgroup.uniqueMember.remove(printer.ident)
1063        except ValueError :   
1064            pass
1065        else :   
1066            fields = {
1067                       "uniqueMember" : pgroup.uniqueMember,
1068                     } 
1069            self.doModify(pgroup.ident, fields)         
1070           
1071    def retrieveHistory(self, user=None, printer=None, hostname=None, billingcode=None, limit=100, start=None, end=None) :
1072        """Retrieves all print jobs for user on printer (or all) between start and end date, limited to first 100 results."""
1073        precond = "(objectClass=pykotaJob)"
1074        where = []
1075        if user is not None :
1076            where.append("(pykotaUserName=%s)" % user.Name)
1077        if printer is not None :
1078            where.append("(pykotaPrinterName=%s)" % printer.Name)
1079        if hostname is not None :
1080            where.append("(pykotaHostName=%s)" % hostname)
1081        if billingcode is not None :
1082            where.append("(pykotaBillingCode=%s)" % self.userCharsetToDatabase(billingcode))
1083        if where :   
1084            where = "(&%s)" % "".join([precond] + where)
1085        else :   
1086            where = precond
1087        jobs = []   
1088        result = self.doSearch(where, fields=[ "pykotaJobSizeBytes", 
1089                                               "pykotaHostName", 
1090                                               "pykotaUserName", 
1091                                               "pykotaPrinterName", 
1092                                               "pykotaJobId", 
1093                                               "pykotaPrinterPageCounter", 
1094                                               "pykotaAction", 
1095                                               "pykotaJobSize", 
1096                                               "pykotaJobPrice", 
1097                                               "pykotaFileName", 
1098                                               "pykotaTitle", 
1099                                               "pykotaCopies", 
1100                                               "pykotaOptions", 
1101                                               "pykotaBillingCode", 
1102                                               "pykotaPages", 
1103                                               "pykotaMD5Sum", 
1104                                               "createTimestamp" ], 
1105                                      base=self.info["jobbase"])
1106        if result :
1107            for (ident, fields) in result :
1108                job = StorageJob(self)
1109                job.ident = ident
1110                job.JobId = fields.get("pykotaJobId")[0]
1111                job.PrinterPageCounter = int(fields.get("pykotaPrinterPageCounter", [0])[0] or 0)
1112                try :
1113                    job.JobSize = int(fields.get("pykotaJobSize", [0])[0])
1114                except ValueError :   
1115                    job.JobSize = None
1116                try :   
1117                    job.JobPrice = float(fields.get("pykotaJobPrice", [0.0])[0])
1118                except ValueError :
1119                    job.JobPrice = None
1120                job.JobAction = fields.get("pykotaAction", [""])[0]
1121                job.JobFileName = self.databaseToUserCharset(fields.get("pykotaFileName", [""])[0]) 
1122                job.JobTitle = self.databaseToUserCharset(fields.get("pykotaTitle", [""])[0]) 
1123                job.JobCopies = int(fields.get("pykotaCopies", [0])[0])
1124                job.JobOptions = self.databaseToUserCharset(fields.get("pykotaOptions", [""])[0]) 
1125                job.JobHostName = fields.get("pykotaHostName", [""])[0]
1126                job.JobSizeBytes = fields.get("pykotaJobSizeBytes", [0L])[0]
1127                job.JobBillingCode = self.databaseToUserCharset(fields.get("pykotaBillingCode", [None])[0])
1128                job.JobMD5Sum = fields.get("pykotaMD5Sum", [None])[0]
1129                job.JobPages = fields.get("pykotaPages", [""])[0]
1130                if job.JobTitle == job.JobFileName == job.JobOptions == "hidden" :
1131                    (job.JobTitle, job.JobFileName, job.JobOptions) = (_("Hidden because of privacy concerns"),) * 3
1132                date = fields.get("createTimestamp", ["19700101000000"])[0]
1133                year = int(date[:4])
1134                month = int(date[4:6])
1135                day = int(date[6:8])
1136                hour = int(date[8:10])
1137                minute = int(date[10:12])
1138                second = int(date[12:14])
1139                job.JobDate = "%04i%02i%02i %02i:%02i:%02i" % (year, month, day, hour, minute, second)
1140                if ((start is None) and (end is None)) or \
1141                   ((start is None) and (job.JobDate <= end)) or \
1142                   ((end is None) and (job.JobDate >= start)) or \
1143                   ((job.JobDate >= start) and (job.JobDate <= end)) :
1144                    job.UserName = fields.get("pykotaUserName")[0]
1145                    job.PrinterName = fields.get("pykotaPrinterName")[0]
1146                    job.Exists = 1
1147                    jobs.append(job)
1148            jobs.sort(lambda x, y : cmp(y.JobDate, x.JobDate))       
1149            if limit :   
1150                jobs = jobs[:int(limit)]
1151        return jobs
1152       
1153    def deleteUser(self, user) :   
1154        """Completely deletes an user from the Quota Storage."""
1155        todelete = []   
1156        result = self.doSearch("(&(objectClass=pykotaJob)(pykotaUserName=%s))" % user.Name, base=self.info["jobbase"])
1157        for (ident, fields) in result :
1158            todelete.append(ident)
1159        if self.info["userquotabase"].lower() == "user" :
1160            base = self.info["userbase"]
1161        else :
1162            base = self.info["userquotabase"]
1163        result = self.doSearch("(&(objectClass=pykotaUserPQuota)(pykotaUserName=%s))" % user.Name, ["pykotaPrinterName", "pykotaUserName"], base=base)
1164        for (ident, fields) in result :
1165            # ensure the user print quota entry will be deleted
1166            todelete.append(ident)
1167           
1168            # if last job of current printer was printed by the user
1169            # to delete, we also need to delete the printer's last job entry.
1170            printername = fields["pykotaPrinterName"][0]
1171            printer = self.getPrinter(printername)
1172            if printer.LastJob.UserName == user.Name :
1173                todelete.append(printer.LastJob.lastjobident)
1174           
1175        for ident in todelete :   
1176            self.doDelete(ident)
1177           
1178        result = self.doSearch("objectClass=pykotaAccount", None, base=user.ident, scope=ldap.SCOPE_BASE)   
1179        if result :
1180            fields = result[0][1]
1181            for k in fields.keys() :
1182                if k.startswith("pykota") :
1183                    del fields[k]
1184                elif k.lower() == "objectclass" :   
1185                    todelete = []
1186                    for i in range(len(fields[k])) :
1187                        if fields[k][i].startswith("pykota") : 
1188                            todelete.append(i)
1189                    todelete.sort()       
1190                    todelete.reverse()
1191                    for i in todelete :
1192                        del fields[k][i]
1193            if fields.get("objectClass") or fields.get("objectclass") :
1194                self.doModify(user.ident, fields, ignoreold=0)       
1195            else :   
1196                self.doDelete(user.ident)
1197        result = self.doSearch("(&(objectClass=pykotaAccountBalance)(pykotaUserName=%s))" % user.Name, ["pykotaUserName"], base=self.info["balancebase"])
1198        for (ident, fields) in result :
1199            self.doDelete(ident)
1200       
1201    def deleteGroup(self, group) :   
1202        """Completely deletes a group from the Quota Storage."""
1203        if self.info["groupquotabase"].lower() == "group" :
1204            base = self.info["groupbase"]
1205        else :
1206            base = self.info["groupquotabase"]
1207        result = self.doSearch("(&(objectClass=pykotaGroupPQuota)(pykotaGroupName=%s))" % group.Name, ["pykotaGroupName"], base=base)
1208        for (ident, fields) in result :
1209            self.doDelete(ident)
1210        result = self.doSearch("objectClass=pykotaGroup", None, base=group.ident, scope=ldap.SCOPE_BASE)   
1211        if result :
1212            fields = result[0][1]
1213            for k in fields.keys() :
1214                if k.startswith("pykota") :
1215                    del fields[k]
1216                elif k.lower() == "objectclass" :   
1217                    todelete = []
1218                    for i in range(len(fields[k])) :
1219                        if fields[k][i].startswith("pykota") : 
1220                            todelete.append(i)
1221                    todelete.sort()       
1222                    todelete.reverse()
1223                    for i in todelete :
1224                        del fields[k][i]
1225            if fields.get("objectClass") or fields.get("objectclass") :
1226                self.doModify(group.ident, fields, ignoreold=0)       
1227            else :   
1228                self.doDelete(group.ident)
1229               
1230    def deletePrinter(self, printer) :   
1231        """Completely deletes a printer from the Quota Storage."""
1232        result = self.doSearch("(&(objectClass=pykotaLastJob)(pykotaPrinterName=%s))" % printer.Name, base=self.info["lastjobbase"])
1233        for (ident, fields) in result :
1234            self.doDelete(ident)
1235        result = self.doSearch("(&(objectClass=pykotaJob)(pykotaPrinterName=%s))" % printer.Name, base=self.info["jobbase"])
1236        for (ident, fields) in result :
1237            self.doDelete(ident)
1238        if self.info["groupquotabase"].lower() == "group" :
1239            base = self.info["groupbase"]
1240        else :
1241            base = self.info["groupquotabase"]
1242        result = self.doSearch("(&(objectClass=pykotaGroupPQuota)(pykotaPrinterName=%s))" % printer.Name, base=base)
1243        for (ident, fields) in result :
1244            self.doDelete(ident)
1245        if self.info["userquotabase"].lower() == "user" :
1246            base = self.info["userbase"]
1247        else :
1248            base = self.info["userquotabase"]
1249        result = self.doSearch("(&(objectClass=pykotaUserPQuota)(pykotaPrinterName=%s))" % printer.Name, base=base)
1250        for (ident, fields) in result :
1251            self.doDelete(ident)
1252        for parent in self.getParentPrinters(printer) : 
1253            try :
1254                parent.uniqueMember.remove(printer.ident)
1255            except ValueError :   
1256                pass
1257            else :   
1258                fields = {
1259                           "uniqueMember" : parent.uniqueMember,
1260                         } 
1261                self.doModify(parent.ident, fields)         
1262        self.doDelete(printer.ident)   
1263       
1264    def deleteBillingCode(self, code) :
1265        """Deletes a billing code from the Quota Storage (no entries are deleted from the history)"""
1266        self.doDelete(code.ident)
1267       
1268    def extractPrinters(self, extractonly={}) :
1269        """Extracts all printer records."""
1270        pname = extractonly.get("printername")
1271        entries = [p for p in [self.getPrinter(name) for name in self.getAllPrintersNames(pname)] if p.Exists]
1272        if entries :
1273            result = [ ("dn", "printername", "priceperpage", "priceperjob", "description") ]
1274            for entry in entries :
1275                result.append((entry.ident, entry.Name, entry.PricePerPage, entry.PricePerJob, entry.Description))
1276            return result 
1277       
1278    def extractUsers(self, extractonly={}) :
1279        """Extracts all user records."""
1280        uname = extractonly.get("username")
1281        entries = [u for u in [self.getUser(name) for name in self.getAllUsersNames(uname)] if u.Exists]
1282        if entries :
1283            result = [ ("dn", "username", "balance", "lifetimepaid", "limitby", "email") ]
1284            for entry in entries :
1285                result.append((entry.ident, entry.Name, entry.AccountBalance, entry.LifeTimePaid, entry.LimitBy, entry.Email))
1286            return result 
1287       
1288    def extractBillingcodes(self, extractonly={}) :
1289        """Extracts all billing codes records."""
1290        billingcode = extractonly.get("billingcode")
1291        entries = [b for b in [self.getBillingCode(label) for label in self.getAllBillingCodes(billingcode)] if b.Exists]
1292        if entries :
1293            result = [ ("dn", "billingcode", "balance", "pagecounter", "description") ]
1294            for entry in entries :
1295                result.append((entry.ident, entry.BillingCode, entry.Balance, entry.PageCounter, entry.Description))
1296            return result 
1297       
1298    def extractGroups(self, extractonly={}) :
1299        """Extracts all group records."""
1300        gname = extractonly.get("groupname")
1301        entries = [g for g in [self.getGroup(name) for name in self.getAllGroupsNames(gname)] if g.Exists]
1302        if entries :
1303            result = [ ("dn", "groupname", "limitby", "balance", "lifetimepaid") ]
1304            for entry in entries :
1305                result.append((entry.ident, entry.Name, entry.LimitBy, entry.AccountBalance, entry.LifeTimePaid))
1306            return result 
1307       
1308    def extractPayments(self, extractonly={}) :
1309        """Extracts all payment records."""
1310        uname = extractonly.get("username")
1311        entries = [u for u in [self.getUser(name) for name in self.getAllUsersNames(uname)] if u.Exists]
1312        if entries :
1313            result = [ ("username", "amount", "date", "description") ]
1314            for entry in entries :
1315                for (date, amount, description) in entry.Payments :
1316                    result.append((entry.Name, amount, date, description))
1317            return result       
1318       
1319    def extractUpquotas(self, extractonly={}) :
1320        """Extracts all userpquota records."""
1321        pname = extractonly.get("printername")
1322        entries = [p for p in [self.getPrinter(name) for name in self.getAllPrintersNames(pname)] if p.Exists]
1323        if entries :
1324            result = [ ("username", "printername", "dn", "userdn", "printerdn", "lifepagecounter", "pagecounter", "softlimit", "hardlimit", "datelimit") ]
1325            uname = extractonly.get("username")
1326            for entry in entries :
1327                for (user, userpquota) in self.getPrinterUsersAndQuotas(entry, names=[uname or "*"]) :
1328                    result.append((user.Name, entry.Name, userpquota.ident, user.ident, entry.ident, userpquota.LifePageCounter, userpquota.PageCounter, userpquota.SoftLimit, userpquota.HardLimit, userpquota.DateLimit))
1329            return result
1330       
1331    def extractGpquotas(self, extractonly={}) :
1332        """Extracts all grouppquota records."""
1333        pname = extractonly.get("printername")
1334        entries = [p for p in [self.getPrinter(name) for name in self.getAllPrintersNames(pname)] if p.Exists]
1335        if entries :
1336            result = [ ("groupname", "printername", "dn", "groupdn", "printerdn", "lifepagecounter", "pagecounter", "softlimit", "hardlimit", "datelimit") ]
1337            gname = extractonly.get("groupname")
1338            for entry in entries :
1339                for (group, grouppquota) in self.getPrinterGroupsAndQuotas(entry, names=[gname or "*"]) :
1340                    result.append((group.Name, entry.Name, grouppquota.ident, group.ident, entry.ident, grouppquota.LifePageCounter, grouppquota.PageCounter, grouppquota.SoftLimit, grouppquota.HardLimit, grouppquota.DateLimit))
1341            return result
1342       
1343    def extractUmembers(self, extractonly={}) :
1344        """Extracts all user groups members."""
1345        gname = extractonly.get("groupname")
1346        entries = [g for g in [self.getGroup(name) for name in self.getAllGroupsNames(gname)] if g.Exists]
1347        if entries :
1348            result = [ ("groupname", "username", "groupdn", "userdn") ]
1349            uname = extractonly.get("username")
1350            for entry in entries :
1351                for member in entry.Members :
1352                    if (uname is None) or (member.Name == uname) :
1353                        result.append((entry.Name, member.Name, entry.ident, member.ident))
1354            return result       
1355               
1356    def extractPmembers(self, extractonly={}) :
1357        """Extracts all printer groups members."""
1358        pname = extractonly.get("printername")
1359        entries = [p for p in [self.getPrinter(name) for name in self.getAllPrintersNames(pname)] if p.Exists]
1360        if entries :
1361            result = [ ("pgroupname", "printername", "pgroupdn", "printerdn") ]
1362            pgname = extractonly.get("pgroupname")
1363            for entry in entries :
1364                for parent in self.getParentPrinters(entry) :
1365                    if (pgname is None) or (parent.Name == pgname) :
1366                        result.append((parent.Name, entry.Name, parent.ident, entry.ident))
1367            return result       
1368       
1369    def extractHistory(self, extractonly={}) :
1370        """Extracts all jobhistory records."""
1371        uname = extractonly.get("username")
1372        if uname :
1373            user = self.getUser(uname)
1374        else :   
1375            user = None
1376        pname = extractonly.get("printername")
1377        if pname :
1378            printer = self.getPrinter(pname)
1379        else :   
1380            printer = None
1381        startdate = extractonly.get("start")
1382        enddate = extractonly.get("end")
1383        (startdate, enddate) = self.cleanDates(startdate, enddate)
1384        entries = self.retrieveHistory(user, printer, hostname=extractonly.get("hostname"), billingcode=extractonly.get("billingcode"), limit=None, start=startdate, end=enddate)
1385        if entries :
1386            result = [ ("username", "printername", "dn", "jobid", "pagecounter", "jobsize", "action", "jobdate", "filename", "title", "copies", "options", "jobprice", "hostname", "jobsizebytes", "md5sum", "pages", "billingcode") ] 
1387            for entry in entries :
1388                result.append((entry.UserName, entry.PrinterName, entry.ident, entry.JobId, entry.PrinterPageCounter, entry.JobSize, entry.JobAction, entry.JobDate, entry.JobFileName, entry.JobTitle, entry.JobCopies, entry.JobOptions, entry.JobPrice, entry.JobHostName, entry.JobSizeBytes, entry.JobMD5Sum, entry.JobPages, entry.JobBillingCode)) 
1389            return result
1390           
1391    def getBillingCodeFromBackend(self, label) :
1392        """Extracts billing code information given its label : returns first matching billing code."""
1393        code = StorageBillingCode(self, label)
1394        ulabel = self.userCharsetToDatabase(label)
1395        result = self.doSearch("(&(objectClass=pykotaBilling)(pykotaBillingCode=%s))" % ulabel, ["pykotaBillingCode", "pykotaBalance", "pykotaPageCounter", "description"], base=self.info["billingcodebase"])
1396        if result :
1397            fields = result[0][1]       # take only first matching code, ignore the rest
1398            code.ident = result[0][0]
1399            code.BillingCode = self.databaseToUserCharset(fields.get("pykotaBillingCode", [ulabel])[0])
1400            code.PageCounter = int(fields.get("pykotaPageCounter", [0])[0])
1401            code.Balance = float(fields.get("pykotaBalance", [0.0])[0])
1402            code.Description = self.databaseToUserCharset(fields.get("description", [""])[0]) 
1403            code.Exists = 1
1404        return code   
1405       
1406    def addBillingCode(self, label) :
1407        """Adds a billing code to the quota storage, returns it."""
1408        uuid = self.genUUID()
1409        dn = "cn=%s,%s" % (uuid, self.info["billingcodebase"])
1410        fields = { "objectClass" : ["pykotaObject", "pykotaBilling"],
1411                   "cn" : uuid,
1412                   "pykotaBillingCode" : self.userCharsetToDatabase(label),
1413                   "pykotaPageCounter" : "0",
1414                   "pykotaBalance" : "0.0",
1415                 } 
1416        self.doAdd(dn, fields)
1417        return self.getBillingCode(label)
1418       
1419    def writeBillingCodeDescription(self, code) :
1420        """Sets the new description for a billing code."""
1421        fields = {
1422                   "description" : self.userCharsetToDatabase(code.Description or ""), 
1423                 }
1424        if fields["description"] :
1425            self.doModify(code.ident, fields)
1426           
1427    def getMatchingBillingCodes(self, billingcodepattern) :
1428        """Returns the list of all billing codes which match a certain pattern."""
1429        codes = []
1430        result = self.doSearch("(&(objectClass=pykotaBilling)(|%s))" % "".join(["(pykotaBillingCode=%s)" % self.userCharsetToDatabase(bcode) for bcode in billingcodepattern.split(",")]), \
1431                                ["pykotaBillingCode", "description", "pykotaPageCounter", "pykotaBalance"], \
1432                                base=self.info["billingcodebase"])
1433        if result :
1434            for (codeid, fields) in result :
1435                codename = self.databaseToUserCharset(fields.get("pykotaBillingCode", [""])[0])
1436                code = StorageBillingCode(self, codename)
1437                code.ident = codeid
1438                code.BillingCode = codename
1439                code.PageCounter = int(fields.get("pykotaPageCounter", [0])[0])
1440                code.Balance = float(fields.get("pykotaBalance", [0.0])[0])
1441                code.Description = self.databaseToUserCharset(fields.get("description", [""])[0]) 
1442                code.Exists = 1
1443                codes.append(code)
1444                self.cacheEntry("BILLINGCODES", code.BillingCode, code)
1445        return codes       
1446       
1447    def setBillingCodeValues(self, code, newpagecounter, newbalance) :
1448        """Sets the new page counter and balance for a billing code."""
1449        fields = {
1450                   "pykotaPageCounter" : str(newpagecounter),
1451                   "pykotaBalance" : str(newbalance),
1452                 } 
1453        return self.doModify(code.ident, fields)         
1454       
1455    def consumeBillingCode(self, code, pagecounter, balance) :
1456        """Consumes from a billing code."""
1457        fields = {
1458                   "pykotaBalance" : { "operator" : "-", "value" : balance, "convert" : float },
1459                   "pykotaPageCounter" : { "operator" : "+", "value" : pagecounter, "convert" : int },
1460                 }
1461        return self.doModify(code.ident, fields)         
Note: See TracBrowser for help on using the browser.