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

Revision 3430, 23.1 kB (checked in by jerome, 14 years ago)

Another round of removing print statements.

  • 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        self.display("%s\n" % __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        self.display("%s\n" % (_(self.documentation) % globals()))
222        self.display("%s\n\n" % __gplblurb__)
223        self.display("%s %s\n" % (_("Please report bugs to :"), __author__))
224        sys.exit(0)
225
226    def crashed(self, message="Bug in PyKota") :
227        """Outputs a crash message, and optionally sends it to software author."""
228        msg = utils.crashed(message)
229        fullmessage = "========== Traceback :\n\n%s\n\n========== sys.argv :\n\n%s\n\n========== Environment :\n\n%s\n" % \
230                        (msg, \
231                         "\n".join(["    %s" % repr(a) for a in sys.argv]), \
232                         "\n".join(["    %s=%s" % (k, repr(v)) for (k, v) in os.environ.items()]))
233        try :
234            crashrecipient = self.config.getCrashRecipient()
235            if crashrecipient :
236                admin = self.config.getAdminMail("global") # Nice trick, isn't it ?
237                server = smtplib.SMTP(self.smtpserver)
238                msg = MIMEText(fullmessage.encode(self.charset, "replace"), _charset=self.charset)
239                msg["Subject"] = Header("PyKota v%s crash traceback !" \
240                                        % __version__, charset=self.charset, errors="replace")
241                msg["From"] = admin
242                msg["To"] = crashrecipient
243                msg["Cc"] = admin
244                msg["Date"] = email.Utils.formatdate(localtime=True)
245                server.sendmail(admin, [admin, crashrecipient], msg.as_string())
246                server.quit()
247        except :
248            self.printInfo("PyKota double crash !", "error")
249            raise
250        return fullmessage
251
252    def parseCommandline(self, argv, short, long, allownothing=0) :
253        """Parses the command line, controlling options."""
254        # split options in two lists: those which need an argument, those which don't need any
255        short = "%sA:" % short
256        long.append("arguments=")
257        withoutarg = []
258        witharg = []
259        lgs = len(short)
260        i = 0
261        while i < lgs :
262            ii = i + 1
263            if (ii < lgs) and (short[ii] == ':') :
264                # needs an argument
265                witharg.append(short[i])
266                ii = ii + 1 # skip the ':'
267            else :
268                # doesn't need an argument
269                withoutarg.append(short[i])
270            i = ii
271
272        for option in long :
273            if option[-1] == '=' :
274                # needs an argument
275                witharg.append(option[:-1])
276            else :
277                # doesn't need an argument
278                withoutarg.append(option)
279
280        # then we parse the command line
281        done = 0
282        while not done :
283            # we begin with all possible options unset
284            parsed = {}
285            for option in withoutarg + witharg :
286                parsed[option] = None
287            args = []       # to not break if something unexpected happened
288            try :
289                options, args = getopt.getopt(argv, short, long)
290                if options :
291                    for (o, v) in options :
292                        # we skip the '-' chars
293                        lgo = len(o)
294                        i = 0
295                        while (i < lgo) and (o[i] == '-') :
296                            i = i + 1
297                        o = o[i:]
298                        if o in witharg :
299                            # needs an argument : set it
300                            parsed[o] = v
301                        elif o in withoutarg :
302                            # doesn't need an argument : boolean
303                            parsed[o] = 1
304                        else :
305                            # should never occur
306                            raise PyKotaCommandLineError, "Unexpected problem when parsing command line"
307                elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
308                    self.display_usage_and_quit()
309            except getopt.error, msg :
310                raise PyKotaCommandLineError, str(msg)
311            else :
312                if parsed["arguments"] or parsed["A"] :
313                    # arguments are in a file, we ignore all other arguments
314                    # and reset the list of arguments to the lines read from
315                    # the file.
316                    argsfile = open(parsed["arguments"] or parsed["A"], "r") # TODO : charset decoding
317                    argv = [ l.strip() for l in argsfile.readlines() ]
318                    argsfile.close()
319                    for i in range(len(argv)) :
320                        argi = argv[i]
321                        if argi.startswith('"') and argi.endswith('"') :
322                            argv[i] = argi[1:-1]
323                else :
324                    done = 1
325        return (parsed, args)
326
327class PyKotaTool(Tool) :
328    """Base class for all PyKota command line tools."""
329    def deferredInit(self) :
330        """Deferred initialization."""
331        Tool.deferredInit(self)
332        self.storage = storage.openConnection(self)
333        if self.config.isAdmin : # TODO : We don't know this before, fix this !
334            self.logdebug("Beware : running as a PyKota administrator !")
335        else :
336            self.logdebug("Don't Panic : running as a mere mortal !")
337
338    def clean(self) :
339        """Ensures that the database is closed."""
340        try :
341            self.storage.close()
342        except (TypeError, NameError, AttributeError) :
343            pass
344
345    def isValidName(self, name) :
346        """Checks if a user or printer name is valid."""
347        invalidchars = "/@?*,;&|"
348        for c in list(invalidchars) :
349            if c in name :
350                return 0
351        return 1
352
353    def _checkUserPQuota(self, userpquota) :
354        """Checks the user quota on a printer and deny or accept the job."""
355        # then we check the user's own quota
356        # if we get there we are sure that policy is not EXTERNAL
357        user = userpquota.User
358        printer = userpquota.Printer
359        enforcement = self.config.getPrinterEnforcement(printer.Name)
360        self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
361        (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
362        if not userpquota.Exists :
363            # Unknown userquota
364            if policy == "ALLOW" :
365                action = "POLICY_ALLOW"
366            else :
367                action = "POLICY_DENY"
368            self.printInfo(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
369        else :
370            pagecounter = int(userpquota.PageCounter or 0)
371            if enforcement == "STRICT" :
372                pagecounter += self.softwareJobSize
373            if userpquota.SoftLimit is not None :
374                softlimit = int(userpquota.SoftLimit)
375                if pagecounter < softlimit :
376                    action = "ALLOW"
377                else :
378                    if userpquota.HardLimit is None :
379                        # only a soft limit, this is equivalent to having only a hard limit
380                        action = "DENY"
381                    else :
382                        hardlimit = int(userpquota.HardLimit)
383                        if softlimit <= pagecounter < hardlimit :
384                            now = DateTime.now()
385                            if userpquota.DateLimit is not None :
386                                datelimit = DateTime.ISO.ParseDateTime(str(userpquota.DateLimit)[:19])
387                            else :
388                                datelimit = now + self.config.getGraceDelay(printer.Name)
389                                userpquota.setDateLimit(datelimit)
390                            if now < datelimit :
391                                action = "WARN"
392                            else :
393                                action = "DENY"
394                        else :
395                            action = "DENY"
396            else :
397                if userpquota.HardLimit is not None :
398                    # no soft limit, only a hard one.
399                    hardlimit = int(userpquota.HardLimit)
400                    if pagecounter < hardlimit :
401                        action = "ALLOW"
402                    else :
403                        action = "DENY"
404                else :
405                    # Both are unset, no quota, i.e. accounting only
406                    action = "ALLOW"
407        return action
408
409    def checkGroupPQuota(self, grouppquota) :
410        """Checks the group quota on a printer and deny or accept the job."""
411        group = grouppquota.Group
412        printer = grouppquota.Printer
413        enforcement = self.config.getPrinterEnforcement(printer.Name)
414        self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
415        if group.LimitBy and (group.LimitBy.lower() == "balance") :
416            val = group.AccountBalance or 0.0
417            if enforcement == "STRICT" :
418                val -= self.softwareJobPrice # use precomputed size.
419            balancezero = self.config.getBalanceZero()
420            if val <= balancezero :
421                action = "DENY"
422            elif val <= self.config.getPoorMan() :
423                action = "WARN"
424            else :
425                action = "ALLOW"
426            if (enforcement == "STRICT") and (val == balancezero) :
427                action = "WARN" # we can still print until account is 0
428        else :
429            val = grouppquota.PageCounter or 0
430            if enforcement == "STRICT" :
431                val += int(self.softwareJobSize) # TODO : this is not a fix, problem is elsewhere in grouppquota.PageCounter
432            if grouppquota.SoftLimit is not None :
433                softlimit = int(grouppquota.SoftLimit)
434                if val < softlimit :
435                    action = "ALLOW"
436                else :
437                    if grouppquota.HardLimit is None :
438                        # only a soft limit, this is equivalent to having only a hard limit
439                        action = "DENY"
440                    else :
441                        hardlimit = int(grouppquota.HardLimit)
442                        if softlimit <= val < hardlimit :
443                            now = DateTime.now()
444                            if grouppquota.DateLimit is not None :
445                                datelimit = DateTime.ISO.ParseDateTime(str(grouppquota.DateLimit)[:19])
446                            else :
447                                datelimit = now + self.config.getGraceDelay(printer.Name)
448                                grouppquota.setDateLimit(datelimit)
449                            if now < datelimit :
450                                action = "WARN"
451                            else :
452                                action = "DENY"
453                        else :
454                            action = "DENY"
455            else :
456                if grouppquota.HardLimit is not None :
457                    # no soft limit, only a hard one.
458                    hardlimit = int(grouppquota.HardLimit)
459                    if val < hardlimit :
460                        action = "ALLOW"
461                    else :
462                        action = "DENY"
463                else :
464                    # Both are unset, no quota, i.e. accounting only
465                    action = "ALLOW"
466        return action
467
468    def checkUserPQuota(self, userpquota) :
469        """Checks the user quota on a printer and all its parents and deny or accept the job."""
470        user = userpquota.User
471        printer = userpquota.Printer
472
473        # indicates that a warning needs to be sent
474        warned = 0
475
476        # first we check any group the user is a member of
477        for group in self.storage.getUserGroups(user) :
478            # No need to check anything if the group is in noquota mode
479            if group.LimitBy != "noquota" :
480                grouppquota = self.storage.getGroupPQuota(group, printer)
481                # for the printer and all its parents
482                for gpquota in [ grouppquota ] + grouppquota.ParentPrintersGroupPQuota :
483                    if gpquota.Exists :
484                        action = self.checkGroupPQuota(gpquota)
485                        if action == "DENY" :
486                            return action
487                        elif action == "WARN" :
488                            warned = 1
489
490        # Then we check the user's account balance
491        # if we get there we are sure that policy is not EXTERNAL
492        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
493        if user.LimitBy and (user.LimitBy.lower() == "balance") :
494            self.logdebug("Checking account balance for user %s" % user.Name)
495            if user.AccountBalance is None :
496                if policy == "ALLOW" :
497                    action = "POLICY_ALLOW"
498                else :
499                    action = "POLICY_DENY"
500                self.printInfo(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
501                return action
502            else :
503                if user.OverCharge == 0.0 :
504                    self.printInfo(_("User %s will not be charged for printing.") % user.Name)
505                    action = "ALLOW"
506                else :
507                    val = float(user.AccountBalance or 0.0)
508                    enforcement = self.config.getPrinterEnforcement(printer.Name)
509                    if enforcement == "STRICT" :
510                        val -= self.softwareJobPrice # use precomputed size.
511                    balancezero = self.config.getBalanceZero()
512                    if val <= balancezero :
513                        action = "DENY"
514                    elif val <= self.config.getPoorMan() :
515                        action = "WARN"
516                    else :
517                        action = "ALLOW"
518                    if (enforcement == "STRICT") and (val == balancezero) :
519                        action = "WARN" # we can still print until account is 0
520                return action
521        else :
522            # Then check the user quota on current printer and all its parents.
523            policyallowed = 0
524            for upquota in [ userpquota ] + userpquota.ParentPrintersUserPQuota :
525                action = self._checkUserPQuota(upquota)
526                if action in ("DENY", "POLICY_DENY") :
527                    return action
528                elif action == "WARN" :
529                    warned = 1
530                elif action == "POLICY_ALLOW" :
531                    policyallowed = 1
532            if warned :
533                return "WARN"
534            elif policyallowed :
535                return "POLICY_ALLOW"
536            else :
537                return "ALLOW"
538
539    def externalMailTo(self, cmd, action, user, printer, message) :
540        """Warns the user with an external command."""
541        username = user.Name
542        printername = printer.Name
543        email = user.Email or user.Name
544        if "@" not in email :
545            email = "%s@%s" % (email, self.maildomain or self.smtpserver)
546        os.system(cmd % locals())
547
548    def formatCommandLine(self, cmd, user, printer) :
549        """Executes an external command."""
550        username = user.Name
551        printername = printer.Name
552        return cmd % locals()
Note: See TracBrowser for help on using the browser.