root / pykota / trunk / pykota / tool.py @ 3327

Revision 3327, 23.3 kB (checked in by jerome, 16 years ago)

warnpykota converted to new style.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
Line 
1# -*- coding: UTF-8 -*-
2#
3# PyKota : Print Quotas for CUPS
4#
5# (c) 2003, 2004, 2005, 2006, 2007, 2008 Jerome Alet <alet@librelogiciel.com>
6# This program is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program.  If not, see <http://www.gnu.org/licenses/>.
18#
19# $Id$
20#
21#
22
23"""This module defines the base classes for PyKota command line tools."""
24
25import sys
26import os
27import pwd
28import fnmatch
29import getopt
30import smtplib
31import locale
32import socket
33import time
34from email.MIMEText import MIMEText
35from email.Header import Header
36import email.Utils
37
38from mx import DateTime
39
40from pykota import utils
41from pykota.errors import PyKotaCommandLineError
42from pykota import config, storage, logger
43from pykota.version import __version__, __author__, __years__, __gplblurb__
44
45class Percent :
46    """A class to display progress."""
47    def __init__(self, app, size=None) :
48        """Initializes the engine."""
49        self.app = app
50        self.size = None
51        if size :
52            self.setSize(size)
53        self.previous = None
54        self.before = time.time()
55       
56    def setSize(self, size) :     
57        """Sets the total size."""
58        self.number = 0
59        self.size = size
60        if size :
61            self.factor = 100.0 / float(size)
62       
63    def display(self, msg) :   
64        """Displays the value."""
65        self.app.display(msg)
66       
67    def oneMore(self) :   
68        """Increments internal counter."""
69        if self.size :
70            self.number += 1
71            percent = "%.02f" % (float(self.number) * self.factor)
72            if percent != self.previous : # optimize for large number of items
73                self.display("\r%s%%" % percent)
74                self.previous = percent
75           
76    def done(self) :         
77        """Displays the 'done' message."""
78        after = time.time()
79        if self.size :
80            try :
81                speed = self.size / ((after - self.before) + 0.00000000001) # adds an epsilon to avoid an user's problem I can't reproduce...
82            except ZeroDivisionError :   
83                speed = 1 # Fake value in case of division by zero, shouldn't happen anyway with the epsilon above...
84            self.display("\r100.00%%\r        \r%s. %s : %.2f %s.\n" \
85                     % (_("Done"), _("Average speed"), speed, _("entries per second")))
86        else :             
87            self.display("\r100.00%%\r        \r%s.\n" % _("Done"))
88       
89class Tool :
90    """Base class for tools with no database access."""
91    def __init__(self, doc="PyKota v%(__version__)s (c) %(__years__)s %(__author__)s") :
92        """Initializes the command line tool."""
93        self.debug = True # in case of early failure
94        self.logger = logger.openLogger("stderr")
95       
96        # Saves a copy of the locale settings
97        (self.language, self.charset) = locale.getlocale()
98        if not self.language :
99            self.language = "C"
100        if not self.charset :
101            self.charset = "UTF-8"
102       
103        # pykota specific stuff
104        self.documentation = doc
105       
106        # Extract the effective username
107        uid = os.geteuid()
108        try :
109            self.effectiveUserName = pwd.getpwuid(uid)[0]
110        except (KeyError, IndexError), msg :   
111            self.printInfo(_("Strange problem with uid(%s) : %s") % (uid, msg), "warn")
112            self.effectiveUserName = os.getlogin()
113       
114    def deferredInit(self) :       
115        """Deferred initialization."""
116        confdir = os.environ.get("PYKOTA_HOME")
117        environHome = True
118        missingUser = False
119        if confdir is None :
120            environHome = False
121            # check for config files in the 'pykota' user's home directory.
122            try :
123                self.pykotauser = pwd.getpwnam("pykota")
124                confdir = self.pykotauser[5]
125            except KeyError :   
126                self.pykotauser = None
127                confdir = "/etc/pykota"
128                missingUser = True
129           
130        self.config = config.PyKotaConfig(confdir)
131        self.debug = self.config.getDebug()
132        self.smtpserver = self.config.getSMTPServer()
133        self.maildomain = self.config.getMailDomain()
134        self.logger = logger.openLogger(self.config.getLoggingBackend())
135           
136        # TODO : We NEED this here, even when not in an accounting filter/backend   
137        self.softwareJobSize = 0
138        self.softwareJobPrice = 0.0
139       
140        if environHome :
141            self.printInfo("PYKOTA_HOME environment variable is set. Configuration files were searched in %s" % confdir, "info")
142        else :
143            if missingUser :     
144                self.printInfo("The 'pykota' system account is missing. Configuration files were searched in %s instead." % confdir, "warn")
145       
146        self.logdebug("Language in use : %s" % self.language)
147        self.logdebug("Charset in use : %s" % self.charset)
148       
149        arguments = " ".join(['"%s"' % arg for arg in sys.argv])
150        self.logdebug("Command line arguments : %s" % arguments)
151       
152    def display(self, message) :
153        """Display a message but only if stdout is a tty."""
154        if sys.stdout.isatty() :
155            sys.stdout.write(message.encode(self.charset, \
156                                            "replace"))
157            sys.stdout.flush()
158           
159    def logdebug(self, message) :   
160        """Logs something to debug output if debug is enabled."""
161        if self.debug :
162            self.logger.log_message(message.encode(self.charset, \
163                                                   "replace"), \
164                                    "debug")
165           
166    def printInfo(self, message, level="info") :       
167        """Sends a message to standard error."""
168        sys.stderr.write("%s: %s\n" % (level.upper(), \
169                                       message.encode(self.charset, \
170                                                      "replace")))
171        sys.stderr.flush()
172       
173    def matchString(self, s, patterns) :
174        """Returns True if the string s matches one of the patterns, else False."""
175        if not patterns :
176            return True # No pattern, always matches.
177        else :   
178            for pattern in patterns :
179                if fnmatch.fnmatchcase(s, pattern) :
180                    return True
181            return False
182       
183    def sanitizeNames(self, options, names) :
184        """Ensures that an user can only see the datas he is allowed to see, by modifying the list of names."""
185        if not self.config.isAdmin :
186            username = pwd.getpwuid(os.geteuid())[0]
187            if not options["list"] :
188                raise PyKotaCommandLineError, "%s : %s" % (username, _("You're not allowed to use this command."))
189            else :
190                if options["groups"] :
191                    user = self.storage.getUser(username)
192                    if user.Exists :
193                        return [ g.Name for g in self.storage.getUserGroups(user) ]
194                return [ username ]
195        elif not names :       
196            return ["*"]
197        else :   
198            return names
199       
200    def display_version_and_quit(self) :
201        """Displays version number, then exists successfully."""
202        try :
203            self.clean()
204        except AttributeError :   
205            pass
206        print __version__
207        sys.exit(0)
208   
209    def display_usage_and_quit(self) :
210        """Displays command line usage, then exists successfully."""
211        try :
212            self.clean()
213        except AttributeError :   
214            pass
215        print _(self.documentation) % globals()
216        print __gplblurb__
217        print
218        print _("Please report bugs to :"), __author__
219        sys.exit(0)
220       
221    def crashed(self, message="Bug in PyKota") :   
222        """Outputs a crash message, and optionally sends it to software author."""
223        msg = utils.crashed(message)
224        fullmessage = "========== Traceback :\n\n%s\n\n========== sys.argv :\n\n%s\n\n========== Environment :\n\n%s\n" % \
225                        (msg, \
226                         "\n".join(["    %s" % repr(a) for a in sys.argv]), \
227                         "\n".join(["    %s=%s" % (k, repr(v)) for (k, v) in os.environ.items()]))
228        try :
229            crashrecipient = self.config.getCrashRecipient()
230            if crashrecipient :
231                admin = self.config.getAdminMail("global") # Nice trick, isn't it ?
232                server = smtplib.SMTP(self.smtpserver)
233                msg = MIMEText(fullmessage.encode(self.charset, "replace"), _charset=self.charset)
234                msg["Subject"] = Header("PyKota v%s crash traceback !" \
235                                        % __version__, charset=self.charset, errors="replace")
236                msg["From"] = admin
237                msg["To"] = crashrecipient
238                msg["Cc"] = admin
239                msg["Date"] = email.Utils.formatdate(localtime=True)
240                server.sendmail(admin, [admin, crashrecipient], msg.as_string())
241                server.quit()
242        except :
243            self.printInfo("PyKota double crash !", "error")
244            raise
245        return fullmessage   
246       
247    def parseCommandline(self, argv, short, long, allownothing=0) :
248        """Parses the command line, controlling options."""
249        # split options in two lists: those which need an argument, those which don't need any
250        short = "%sA:" % short
251        long.append("arguments=")
252        withoutarg = []
253        witharg = []
254        lgs = len(short)
255        i = 0
256        while i < lgs :
257            ii = i + 1
258            if (ii < lgs) and (short[ii] == ':') :
259                # needs an argument
260                witharg.append(short[i])
261                ii = ii + 1 # skip the ':'
262            else :
263                # doesn't need an argument
264                withoutarg.append(short[i])
265            i = ii
266               
267        for option in long :
268            if option[-1] == '=' :
269                # needs an argument
270                witharg.append(option[:-1])
271            else :
272                # doesn't need an argument
273                withoutarg.append(option)
274       
275        # then we parse the command line
276        done = 0
277        while not done :
278            # we begin with all possible options unset
279            parsed = {}
280            for option in withoutarg + witharg :
281                parsed[option] = None
282            args = []       # to not break if something unexpected happened
283            try :
284                options, args = getopt.getopt(argv, short, long)
285                if options :
286                    for (o, v) in options :
287                        # we skip the '-' chars
288                        lgo = len(o)
289                        i = 0
290                        while (i < lgo) and (o[i] == '-') :
291                            i = i + 1
292                        o = o[i:]
293                        if o in witharg :
294                            # needs an argument : set it
295                            parsed[o] = v
296                        elif o in withoutarg :
297                            # doesn't need an argument : boolean
298                            parsed[o] = 1
299                        else :
300                            # should never occur
301                            raise PyKotaCommandLineError, "Unexpected problem when parsing command line"
302                elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
303                    self.display_usage_and_quit()
304            except getopt.error, msg :
305                raise PyKotaCommandLineError, str(msg)
306            else :   
307                if parsed["arguments"] or parsed["A"] :
308                    # arguments are in a file, we ignore all other arguments
309                    # and reset the list of arguments to the lines read from
310                    # the file.
311                    argsfile = open(parsed["arguments"] or parsed["A"], "r") # TODO : charset decoding
312                    argv = [ l.strip() for l in argsfile.readlines() ]
313                    argsfile.close()
314                    for i in range(len(argv)) :
315                        argi = argv[i]
316                        if argi.startswith('"') and argi.endswith('"') :
317                            argv[i] = argi[1:-1]
318                else :   
319                    done = 1
320        return (parsed, args)
321   
322class PyKotaTool(Tool) :   
323    """Base class for all PyKota command line tools."""
324    def deferredInit(self) :   
325        """Deferred initialization."""
326        Tool.deferredInit(self)
327        self.storage = storage.openConnection(self)
328        if self.config.isAdmin : # TODO : We don't know this before, fix this !
329            self.logdebug("Beware : running as a PyKota administrator !")
330        else :   
331            self.logdebug("Don't Panic : running as a mere mortal !")
332       
333    def clean(self) :   
334        """Ensures that the database is closed."""
335        try :
336            self.storage.close()
337        except (TypeError, NameError, AttributeError) :   
338            pass
339           
340    def isValidName(self, name) :
341        """Checks if a user or printer name is valid."""
342        invalidchars = "/@?*,;&|"
343        for c in list(invalidchars) :
344            if c in name :
345                return 0
346        return 1       
347       
348    def _checkUserPQuota(self, userpquota) :           
349        """Checks the user quota on a printer and deny or accept the job."""
350        # then we check the user's own quota
351        # if we get there we are sure that policy is not EXTERNAL
352        user = userpquota.User
353        printer = userpquota.Printer
354        enforcement = self.config.getPrinterEnforcement(printer.Name)
355        self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
356        (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
357        if not userpquota.Exists :
358            # Unknown userquota
359            if policy == "ALLOW" :
360                action = "POLICY_ALLOW"
361            else :   
362                action = "POLICY_DENY"
363            self.printInfo(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
364        else :   
365            pagecounter = int(userpquota.PageCounter or 0)
366            if enforcement == "STRICT" :
367                pagecounter += self.softwareJobSize
368            if userpquota.SoftLimit is not None :
369                softlimit = int(userpquota.SoftLimit)
370                if pagecounter < softlimit :
371                    action = "ALLOW"
372                else :   
373                    if userpquota.HardLimit is None :
374                        # only a soft limit, this is equivalent to having only a hard limit
375                        action = "DENY"
376                    else :   
377                        hardlimit = int(userpquota.HardLimit)
378                        if softlimit <= pagecounter < hardlimit :   
379                            now = DateTime.now()
380                            if userpquota.DateLimit is not None :
381                                datelimit = DateTime.ISO.ParseDateTime(str(userpquota.DateLimit)[:19])
382                            else :
383                                datelimit = now + self.config.getGraceDelay(printer.Name)
384                                userpquota.setDateLimit(datelimit)
385                            if now < datelimit :
386                                action = "WARN"
387                            else :   
388                                action = "DENY"
389                        else :         
390                            action = "DENY"
391            else :       
392                if userpquota.HardLimit is not None :
393                    # no soft limit, only a hard one.
394                    hardlimit = int(userpquota.HardLimit)
395                    if pagecounter < hardlimit :
396                        action = "ALLOW"
397                    else :     
398                        action = "DENY"
399                else :
400                    # Both are unset, no quota, i.e. accounting only
401                    action = "ALLOW"
402        return action
403   
404    def checkGroupPQuota(self, grouppquota) :   
405        """Checks the group quota on a printer and deny or accept the job."""
406        group = grouppquota.Group
407        printer = grouppquota.Printer
408        enforcement = self.config.getPrinterEnforcement(printer.Name)
409        self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
410        if group.LimitBy and (group.LimitBy.lower() == "balance") : 
411            val = group.AccountBalance or 0.0
412            if enforcement == "STRICT" : 
413                val -= self.softwareJobPrice # use precomputed size.
414            balancezero = self.config.getBalanceZero()
415            if val <= balancezero :
416                action = "DENY"
417            elif val <= self.config.getPoorMan() :   
418                action = "WARN"
419            else :   
420                action = "ALLOW"
421            if (enforcement == "STRICT") and (val == balancezero) :
422                action = "WARN" # we can still print until account is 0
423        else :
424            val = grouppquota.PageCounter or 0
425            if enforcement == "STRICT" :
426                val += int(self.softwareJobSize) # TODO : this is not a fix, problem is elsewhere in grouppquota.PageCounter
427            if grouppquota.SoftLimit is not None :
428                softlimit = int(grouppquota.SoftLimit)
429                if val < softlimit :
430                    action = "ALLOW"
431                else :   
432                    if grouppquota.HardLimit is None :
433                        # only a soft limit, this is equivalent to having only a hard limit
434                        action = "DENY"
435                    else :   
436                        hardlimit = int(grouppquota.HardLimit)
437                        if softlimit <= val < hardlimit :   
438                            now = DateTime.now()
439                            if grouppquota.DateLimit is not None :
440                                datelimit = DateTime.ISO.ParseDateTime(str(grouppquota.DateLimit)[:19])
441                            else :
442                                datelimit = now + self.config.getGraceDelay(printer.Name)
443                                grouppquota.setDateLimit(datelimit)
444                            if now < datelimit :
445                                action = "WARN"
446                            else :   
447                                action = "DENY"
448                        else :         
449                            action = "DENY"
450            else :       
451                if grouppquota.HardLimit is not None :
452                    # no soft limit, only a hard one.
453                    hardlimit = int(grouppquota.HardLimit)
454                    if val < hardlimit :
455                        action = "ALLOW"
456                    else :     
457                        action = "DENY"
458                else :
459                    # Both are unset, no quota, i.e. accounting only
460                    action = "ALLOW"
461        return action
462   
463    def checkUserPQuota(self, userpquota) :
464        """Checks the user quota on a printer and all its parents and deny or accept the job."""
465        user = userpquota.User
466        printer = userpquota.Printer
467       
468        # indicates that a warning needs to be sent
469        warned = 0               
470       
471        # first we check any group the user is a member of
472        for group in self.storage.getUserGroups(user) :
473            # No need to check anything if the group is in noquota mode
474            if group.LimitBy != "noquota" :
475                grouppquota = self.storage.getGroupPQuota(group, printer)
476                # for the printer and all its parents
477                for gpquota in [ grouppquota ] + grouppquota.ParentPrintersGroupPQuota :
478                    if gpquota.Exists :
479                        action = self.checkGroupPQuota(gpquota)
480                        if action == "DENY" :
481                            return action
482                        elif action == "WARN" :   
483                            warned = 1
484                       
485        # Then we check the user's account balance
486        # if we get there we are sure that policy is not EXTERNAL
487        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
488        if user.LimitBy and (user.LimitBy.lower() == "balance") : 
489            self.logdebug("Checking account balance for user %s" % user.Name)
490            if user.AccountBalance is None :
491                if policy == "ALLOW" :
492                    action = "POLICY_ALLOW"
493                else :   
494                    action = "POLICY_DENY"
495                self.printInfo(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
496                return action       
497            else :   
498                if user.OverCharge == 0.0 :
499                    self.printInfo(_("User %s will not be charged for printing.") % user.Name)
500                    action = "ALLOW"
501                else :
502                    val = float(user.AccountBalance or 0.0)
503                    enforcement = self.config.getPrinterEnforcement(printer.Name)
504                    if enforcement == "STRICT" : 
505                        val -= self.softwareJobPrice # use precomputed size.
506                    balancezero = self.config.getBalanceZero()   
507                    if val <= balancezero :
508                        action = "DENY"
509                    elif val <= self.config.getPoorMan() :   
510                        action = "WARN"
511                    else :
512                        action = "ALLOW"
513                    if (enforcement == "STRICT") and (val == balancezero) :
514                        action = "WARN" # we can still print until account is 0
515                return action   
516        else :
517            # Then check the user quota on current printer and all its parents.               
518            policyallowed = 0
519            for upquota in [ userpquota ] + userpquota.ParentPrintersUserPQuota :               
520                action = self._checkUserPQuota(upquota)
521                if action in ("DENY", "POLICY_DENY") :
522                    return action
523                elif action == "WARN" :   
524                    warned = 1
525                elif action == "POLICY_ALLOW" :   
526                    policyallowed = 1
527            if warned :       
528                return "WARN"
529            elif policyallowed :   
530                return "POLICY_ALLOW" 
531            else :   
532                return "ALLOW"
533               
534    def externalMailTo(self, cmd, action, user, printer, message) :
535        """Warns the user with an external command."""
536        username = user.Name
537        printername = printer.Name
538        email = user.Email or user.Name
539        if "@" not in email :
540            email = "%s@%s" % (email, self.maildomain or self.smtpserver)
541        os.system(cmd % locals())
542   
543    def formatCommandLine(self, cmd, user, printer) :
544        """Executes an external command."""
545        username = user.Name
546        printername = printer.Name
547        return cmd % locals()
548       
Note: See TracBrowser for help on using the browser.