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

Revision 3429, 23.0 kB (checked in by jerome, 16 years ago)

Changed the way informations are output, especially to replace 'print'
statements which won't exist anymore in Python 3.

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