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

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

Removed unnecessary spaces at EOL.

  • 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 adminOnly(self, restricted=True) :
174        """Raises an exception if the user is not a PyKota administrator."""
175        if restricted and not self.config.isAdmin :
176            raise PyKotaCommandLineError, "%s : %s" % (pwd.getpwuid(os.geteuid())[0], _("You're not allowed to use this command."))
177
178    def matchString(self, s, patterns) :
179        """Returns True if the string s matches one of the patterns, else False."""
180        if not patterns :
181            return True # No pattern, always matches.
182        else :
183            for pattern in patterns :
184                if fnmatch.fnmatchcase(s, pattern) :
185                    return True
186            return False
187
188    def sanitizeNames(self, options, names) :
189        """Ensures that an user can only see the datas he is allowed to see, by modifying the list of names."""
190        if not self.config.isAdmin :
191            username = pwd.getpwuid(os.geteuid())[0]
192            if not options["list"] :
193                raise PyKotaCommandLineError, "%s : %s" % (username, _("You're not allowed to use this command."))
194            else :
195                if options["groups"] :
196                    user = self.storage.getUser(username)
197                    if user.Exists :
198                        return [ g.Name for g in self.storage.getUserGroups(user) ]
199                return [ username ]
200        elif not names :
201            return ["*"]
202        else :
203            return names
204
205    def display_version_and_quit(self) :
206        """Displays version number, then exists successfully."""
207        try :
208            self.clean()
209        except AttributeError :
210            pass
211        print __version__
212        sys.exit(0)
213
214    def display_usage_and_quit(self) :
215        """Displays command line usage, then exists successfully."""
216        try :
217            self.clean()
218        except AttributeError :
219            pass
220        print _(self.documentation) % globals()
221        print __gplblurb__
222        print
223        print _("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.