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

Revision 3432, 22.8 kB (checked in by jerome, 15 years ago)

edpykota now supports new style command line handling.

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