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

Revision 2395, 49.6 kB (checked in by jerome, 19 years ago)

Moved some code around so that when the job ticket can be overwritten, the
client hostname is known. This allows easier client/server user interactivity :-)
Severity : high, if you downloaded the code 5 minutes ago :-)

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
Line 
1# PyKota
2# -*- coding: ISO-8859-15 -*-
3
4# PyKota - Print Quotas for CUPS and LPRng
5#
6# (c) 2003-2004 Jerome Alet <alet@librelogiciel.com>
7# This program is free software; you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation; either version 2 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program; if not, write to the Free Software
19# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20#
21# $Id$
22#
23#
24
25import sys
26import os
27import pwd
28import fnmatch
29import getopt
30import smtplib
31import gettext
32import locale
33import signal
34import socket
35import tempfile
36import md5
37import ConfigParser
38import popen2
39
40from mx import DateTime
41
42from pykota import config, storage, logger, accounter
43from pykota.version import __version__, __author__, __years__, __gplblurb__
44
45try :
46    from pkpgpdls import analyzer, pdlparser
47except ImportError : # TODO : Remove the try/except after release 1.24.
48    sys.stderr.write("ERROR: pkpgcounter is now distributed separately, please grab it from http://www.librelogiciel.com/software/pkpgcounter/action_Download\n")
49   
50def N_(message) :
51    """Fake translation marker for translatable strings extraction."""
52    return message
53
54class PyKotaToolError(Exception):
55    """An exception for PyKota config related stuff."""
56    def __init__(self, message = ""):
57        self.message = message
58        Exception.__init__(self, message)
59    def __repr__(self):
60        return self.message
61    __str__ = __repr__
62   
63def crashed(message="Bug in PyKota") :   
64    """Minimal crash method."""
65    import traceback
66    lines = []
67    for line in traceback.format_exception(*sys.exc_info()) :
68        lines.extend([l for l in line.split("\n") if l])
69    msg = "ERROR: ".join(["%s\n" % l for l in (["ERROR: PyKota v%s" % __version__, message] + lines)])
70    sys.stderr.write(msg)
71    sys.stderr.flush()
72    return msg
73
74class Tool :
75    """Base class for tools with no database access."""
76    def __init__(self, lang="", charset=None, doc="PyKota v%(__version__)s (c) %(__years__)s %(__author__)s") :
77        """Initializes the command line tool."""
78        # did we drop priviledges ?
79        self.privdropped = 0
80       
81        # locale stuff
82        self.defaultToCLocale = 0
83        try :
84            locale.setlocale(locale.LC_ALL, lang)
85        except (locale.Error, IOError) :
86            locale.setlocale(locale.LC_ALL, "C")
87            self.defaultToCLocale = 1
88        try :
89            gettext.install("pykota")
90        except :
91            gettext.NullTranslations().install()
92           
93        # We can force the charset.   
94        # The CHARSET environment variable is set by CUPS when printing.
95        # Else we use the current locale's one.
96        # If nothing is set, we use ISO-8859-15 widely used in western Europe.
97        localecharset = None
98        try :
99            try :
100                localecharset = locale.nl_langinfo(locale.CODESET)
101            except AttributeError :   
102                try :
103                    localecharset = locale.getpreferredencoding()
104                except AttributeError :   
105                    try :
106                        localecharset = locale.getlocale()[1]
107                        localecharset = localecharset or locale.getdefaultlocale()[1]
108                    except ValueError :   
109                        pass        # Unknown locale, strange...
110        except locale.Error :           
111            pass
112        self.charset = charset or os.environ.get("CHARSET") or localecharset or "ISO-8859-15"
113   
114        # pykota specific stuff
115        self.documentation = doc
116       
117    def deferredInit(self) :       
118        """Deferred initialization."""
119        # try to find the configuration files in user's 'pykota' home directory.
120        try :
121            self.pykotauser = pwd.getpwnam("pykota")
122        except KeyError :   
123            self.pykotauser = None
124            confdir = "/etc/pykota"
125            missingUser = 1
126        else :   
127            confdir = self.pykotauser[5]
128            missingUser = 0
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        # now drop priviledge if possible
137        self.dropPriv()   
138       
139        # We NEED this here, even when not in an accounting filter/backend   
140        self.softwareJobSize = 0
141        self.softwareJobPrice = 0.0
142       
143        if self.defaultToCLocale :
144            self.printInfo("Incorrect locale settings. PyKota falls back to the 'C' locale.", "warn")
145        if missingUser :     
146            self.printInfo("The 'pykota' system account is missing. Configuration files were searched in /etc/pykota instead.", "warn")
147       
148        self.logdebug("Charset in use : %s" % self.charset)
149        arguments = " ".join(['"%s"' % arg for arg in sys.argv])
150        self.logdebug("Command line arguments : %s" % arguments)
151       
152    def dropPriv(self) :   
153        """Drops priviledges."""
154        uid = os.geteuid()
155        if uid :
156            try :
157                username = pwd.getpwuid(uid)[0]
158            except (KeyError, IndexError), msg :   
159                self.printInfo(_("Strange problem with uid(%s) : %s") % (uid, msg), "warn")
160            else :
161                self.logdebug(_("Running as user '%s'.") % username)
162        else :
163            if self.pykotauser is None :
164                self.logdebug(_("No user named 'pykota'. Not dropping priviledges."))
165            else :   
166                try :
167                    os.setegid(self.pykotauser[3])
168                    os.seteuid(self.pykotauser[2])
169                except OSError, msg :   
170                    self.printInfo(_("Impossible to drop priviledges : %s") % msg, "warn")
171                else :   
172                    self.logdebug(_("Priviledges dropped. Now running as user 'pykota'."))
173                    self.privdropped = 1
174           
175    def regainPriv(self) :   
176        """Drops priviledges."""
177        if self.privdropped :
178            try :
179                os.seteuid(0)
180                os.setegid(0)
181            except OSError, msg :   
182                self.printInfo(_("Impossible to regain priviledges : %s") % msg, "warn")
183            else :   
184                self.logdebug(_("Regained priviledges. Now running as root."))
185                self.privdropped = 0
186       
187    def getCharset(self) :   
188        """Returns the charset in use."""
189        return self.charset
190       
191    def logdebug(self, message) :   
192        """Logs something to debug output if debug is enabled."""
193        if self.debug :
194            self.logger.log_message(message, "debug")
195           
196    def printInfo(self, message, level="info") :       
197        """Sends a message to standard error."""
198        sys.stderr.write("%s: %s\n" % (level.upper(), message))
199        sys.stderr.flush()
200       
201    def matchString(self, s, patterns) :
202        """Returns 1 if the string s matches one of the patterns, else 0."""
203        for pattern in patterns :
204            if fnmatch.fnmatchcase(s, pattern) :
205                return 1
206        return 0
207       
208    def display_version_and_quit(self) :
209        """Displays version number, then exists successfully."""
210        try :
211            self.clean()
212        except AttributeError :   
213            pass
214        print __version__
215        sys.exit(0)
216   
217    def display_usage_and_quit(self) :
218        """Displays command line usage, then exists successfully."""
219        try :
220            self.clean()
221        except AttributeError :   
222            pass
223        print _(self.documentation) % globals()
224        print __gplblurb__
225        print
226        print _("Please report bugs to :"), __author__
227        sys.exit(0)
228       
229    def crashed(self, message="Bug in PyKota") :   
230        """Outputs a crash message, and optionally sends it to software author."""
231        msg = crashed(message)
232        fullmessage = "========== Traceback :\n\n%s\n\n========== sys.argv :\n\n%s\n\n========== Environment :\n\n%s\n" % \
233                        (msg, \
234                         "\n".join(["    %s" % repr(a) for a in sys.argv]), \
235                         "\n".join(["    %s=%s" % (k, v) for (k, v) in os.environ.items()]))
236        try :
237            crashrecipient = self.config.getCrashRecipient()
238            if crashrecipient :
239                admin = self.config.getAdminMail("global") # Nice trick, isn't it ?
240                server = smtplib.SMTP(self.smtpserver)
241                server.sendmail(admin, [admin, crashrecipient], \
242                                       "From: %s\nTo: %s\nCc: %s\nSubject: PyKota v%s crash traceback !\n\n%s" % \
243                                       (admin, crashrecipient, admin, __version__, fullmessage))
244                server.quit()
245        except :
246            pass
247        return fullmessage   
248       
249    def parseCommandline(self, argv, short, long, allownothing=0) :
250        """Parses the command line, controlling options."""
251        # split options in two lists: those which need an argument, those which don't need any
252        withoutarg = []
253        witharg = []
254        lgs = len(short)
255        i = 0
256        while i < lgs :
257            ii = i + 1
258            if (ii < lgs) and (short[ii] == ':') :
259                # needs an argument
260                witharg.append(short[i])
261                ii = ii + 1 # skip the ':'
262            else :
263                # doesn't need an argument
264                withoutarg.append(short[i])
265            i = ii
266               
267        for option in long :
268            if option[-1] == '=' :
269                # needs an argument
270                witharg.append(option[:-1])
271            else :
272                # doesn't need an argument
273                withoutarg.append(option)
274       
275        # we begin with all possible options unset
276        parsed = {}
277        for option in withoutarg + witharg :
278            parsed[option] = None
279       
280        # then we parse the command line
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 PyKotaToolError, "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            self.printInfo(msg)
305            self.display_usage_and_quit()
306        return (parsed, args)
307   
308class PyKotaTool(Tool) :   
309    """Base class for all PyKota command line tools."""
310    def __init__(self, lang="", charset=None, doc="PyKota v%(__version__)s (c) %(__years__)s %(__author__)s") :
311        """Initializes the command line tool and opens the database."""
312        Tool.__init__(self, lang, charset, doc)
313       
314    def deferredInit(self) :   
315        """Deferred initialization."""
316        Tool.deferredInit(self)
317        self.storage = storage.openConnection(self)
318        if self.config.isAdmin : # TODO : We don't know this before, fix this !
319            self.logdebug("Beware : running as a PyKota administrator !")
320        else :   
321            self.logdebug("Don't Panic : running as a mere mortal !")
322       
323    def clean(self) :   
324        """Ensures that the database is closed."""
325        try :
326            self.storage.close()
327        except (TypeError, NameError, AttributeError) :   
328            pass
329           
330    def isValidName(self, name) :
331        """Checks if a user or printer name is valid."""
332        invalidchars = "/@?*,;&|"
333        for c in list(invalidchars) :
334            if c in name :
335                return 0
336        return 1       
337       
338    def sendMessage(self, adminmail, touser, fullmessage) :
339        """Sends an email message containing headers to some user."""
340        if "@" not in touser :
341            touser = "%s@%s" % (touser, self.maildomain or self.smtpserver)
342        try :   
343            server = smtplib.SMTP(self.smtpserver)
344        except socket.error, msg :   
345            self.printInfo(_("Impossible to connect to SMTP server : %s") % msg, "error")
346        else :
347            try :
348                server.sendmail(adminmail, [touser], "From: %s\nTo: %s\n%s" % (adminmail, touser, fullmessage))
349            except smtplib.SMTPException, answer :   
350                for (k, v) in answer.recipients.items() :
351                    self.printInfo(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
352            server.quit()
353       
354    def sendMessageToUser(self, admin, adminmail, user, subject, message) :
355        """Sends an email message to a user."""
356        message += _("\n\nPlease contact your system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail)
357        self.sendMessage(adminmail, user.Email or user.Name, "Subject: %s\n\n%s" % (subject, message))
358       
359    def sendMessageToAdmin(self, adminmail, subject, message) :
360        """Sends an email message to the Print Quota administrator."""
361        self.sendMessage(adminmail, adminmail, "Subject: %s\n\n%s" % (subject, message))
362       
363    def _checkUserPQuota(self, userpquota) :           
364        """Checks the user quota on a printer and deny or accept the job."""
365        # then we check the user's own quota
366        # if we get there we are sure that policy is not EXTERNAL
367        user = userpquota.User
368        printer = userpquota.Printer
369        enforcement = self.config.getPrinterEnforcement(printer.Name)
370        self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
371        (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
372        if not userpquota.Exists :
373            # Unknown userquota
374            if policy == "ALLOW" :
375                action = "POLICY_ALLOW"
376            else :   
377                action = "POLICY_DENY"
378            self.printInfo(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
379        else :   
380            pagecounter = int(userpquota.PageCounter or 0)
381            if enforcement == "STRICT" :
382                pagecounter += self.softwareJobSize
383            if userpquota.SoftLimit is not None :
384                softlimit = int(userpquota.SoftLimit)
385                if pagecounter < softlimit :
386                    action = "ALLOW"
387                else :   
388                    if userpquota.HardLimit is None :
389                        # only a soft limit, this is equivalent to having only a hard limit
390                        action = "DENY"
391                    else :   
392                        hardlimit = int(userpquota.HardLimit)
393                        if softlimit <= pagecounter < hardlimit :   
394                            now = DateTime.now()
395                            if userpquota.DateLimit is not None :
396                                datelimit = DateTime.ISO.ParseDateTime(userpquota.DateLimit)
397                            else :
398                                datelimit = now + self.config.getGraceDelay(printer.Name)
399                                userpquota.setDateLimit(datelimit)
400                            if now < datelimit :
401                                action = "WARN"
402                            else :   
403                                action = "DENY"
404                        else :         
405                            action = "DENY"
406            else :       
407                if userpquota.HardLimit is not None :
408                    # no soft limit, only a hard one.
409                    hardlimit = int(userpquota.HardLimit)
410                    if pagecounter < hardlimit :
411                        action = "ALLOW"
412                    else :     
413                        action = "DENY"
414                else :
415                    # Both are unset, no quota, i.e. accounting only
416                    action = "ALLOW"
417        return action
418   
419    def checkGroupPQuota(self, grouppquota) :   
420        """Checks the group quota on a printer and deny or accept the job."""
421        group = grouppquota.Group
422        printer = grouppquota.Printer
423        enforcement = self.config.getPrinterEnforcement(printer.Name)
424        self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
425        if group.LimitBy and (group.LimitBy.lower() == "balance") : 
426            val = group.AccountBalance or 0.0
427            if enforcement == "STRICT" : 
428                val -= self.softwareJobPrice # use precomputed size.
429            if val <= 0.0 :
430                action = "DENY"
431            elif val <= self.config.getPoorMan() :   
432                action = "WARN"
433            else :   
434                action = "ALLOW"
435            if (enforcement == "STRICT") and (val == 0.0) :
436                action = "WARN" # we can still print until account is 0
437        else :
438            val = grouppquota.PageCounter or 0
439            if enforcement == "STRICT" :
440                val += self.softwareJobSize
441            if grouppquota.SoftLimit is not None :
442                softlimit = int(grouppquota.SoftLimit)
443                if val < softlimit :
444                    action = "ALLOW"
445                else :   
446                    if grouppquota.HardLimit is None :
447                        # only a soft limit, this is equivalent to having only a hard limit
448                        action = "DENY"
449                    else :   
450                        hardlimit = int(grouppquota.HardLimit)
451                        if softlimit <= val < hardlimit :   
452                            now = DateTime.now()
453                            if grouppquota.DateLimit is not None :
454                                datelimit = DateTime.ISO.ParseDateTime(grouppquota.DateLimit)
455                            else :
456                                datelimit = now + self.config.getGraceDelay(printer.Name)
457                                grouppquota.setDateLimit(datelimit)
458                            if now < datelimit :
459                                action = "WARN"
460                            else :   
461                                action = "DENY"
462                        else :         
463                            action = "DENY"
464            else :       
465                if grouppquota.HardLimit is not None :
466                    # no soft limit, only a hard one.
467                    hardlimit = int(grouppquota.HardLimit)
468                    if val < hardlimit :
469                        action = "ALLOW"
470                    else :     
471                        action = "DENY"
472                else :
473                    # Both are unset, no quota, i.e. accounting only
474                    action = "ALLOW"
475        return action
476   
477    def checkUserPQuota(self, userpquota) :
478        """Checks the user quota on a printer and all its parents and deny or accept the job."""
479        user = userpquota.User
480        printer = userpquota.Printer
481       
482        # indicates that a warning needs to be sent
483        warned = 0               
484       
485        # first we check any group the user is a member of
486        for group in self.storage.getUserGroups(user) :
487            grouppquota = self.storage.getGroupPQuota(group, printer)
488            # for the printer and all its parents
489            for gpquota in [ grouppquota ] + grouppquota.ParentPrintersGroupPQuota :
490                if gpquota.Exists :
491                    action = self.checkGroupPQuota(gpquota)
492                    if action == "DENY" :
493                        return action
494                    elif action == "WARN" :   
495                        warned = 1
496                       
497        # Then we check the user's account balance
498        # if we get there we are sure that policy is not EXTERNAL
499        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
500        if user.LimitBy and (user.LimitBy.lower() == "balance") : 
501            self.logdebug("Checking account balance for user %s" % user.Name)
502            if user.AccountBalance is None :
503                if policy == "ALLOW" :
504                    action = "POLICY_ALLOW"
505                else :   
506                    action = "POLICY_DENY"
507                self.printInfo(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
508                return action       
509            else :   
510                if user.OverCharge == 0.0 :
511                    self.printInfo(_("User %s will not be charged for printing.") % user.Name)
512                    action = "ALLOW"
513                else :
514                    val = float(user.AccountBalance or 0.0)
515                    enforcement = self.config.getPrinterEnforcement(printer.Name)
516                    if enforcement == "STRICT" : 
517                        val -= self.softwareJobPrice # use precomputed size.
518                    if val <= 0.0 :
519                        action = "DENY"
520                    elif val <= self.config.getPoorMan() :   
521                        action = "WARN"
522                    else :
523                        action = "ALLOW"
524                    if (enforcement == "STRICT") and (val == 0.0) :
525                        action = "WARN" # we can still print until account is 0
526                return action   
527        else :
528            # Then check the user quota on current printer and all its parents.               
529            policyallowed = 0
530            for upquota in [ userpquota ] + userpquota.ParentPrintersUserPQuota :               
531                action = self._checkUserPQuota(upquota)
532                if action in ("DENY", "POLICY_DENY") :
533                    return action
534                elif action == "WARN" :   
535                    warned = 1
536                elif action == "POLICY_ALLOW" :   
537                    policyallowed = 1
538            if warned :       
539                return "WARN"
540            elif policyallowed :   
541                return "POLICY_ALLOW" 
542            else :   
543                return "ALLOW"
544               
545    def externalMailTo(self, cmd, action, user, printer, message) :
546        """Warns the user with an external command."""
547        username = user.Name
548        printername = printer.Name
549        email = user.Email or user.Name
550        if "@" not in email :
551            email = "%s@%s" % (email, self.maildomain or self.smtpserver)
552        os.system(cmd % locals())
553   
554    def formatCommandLine(self, cmd, user, printer) :
555        """Executes an external command."""
556        username = user.Name
557        printername = printer.Name
558        return cmd % locals()
559       
560    def warnGroupPQuota(self, grouppquota) :
561        """Checks a group quota and send messages if quota is exceeded on current printer."""
562        group = grouppquota.Group
563        printer = grouppquota.Printer
564        admin = self.config.getAdmin(printer.Name)
565        adminmail = self.config.getAdminMail(printer.Name)
566        (mailto, arguments) = self.config.getMailTo(printer.Name)
567        action = self.checkGroupPQuota(grouppquota)
568        if action.startswith("POLICY_") :
569            action = action[7:]
570        if action == "DENY" :
571            adminmessage = _("Print Quota exceeded for group %s on printer %s") % (group.Name, printer.Name)
572            self.printInfo(adminmessage)
573            if mailto in [ "BOTH", "ADMIN" ] :
574                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
575            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
576                for user in self.storage.getGroupMembers(group) :
577                    if mailto != "EXTERNAL" :
578                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), self.config.getHardWarn(printer.Name))
579                    else :   
580                        self.externalMailTo(arguments, action, user, printer, self.config.getHardWarn(printer.Name))
581        elif action == "WARN" :   
582            adminmessage = _("Print Quota low for group %s on printer %s") % (group.Name, printer.Name)
583            self.printInfo(adminmessage)
584            if mailto in [ "BOTH", "ADMIN" ] :
585                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
586            if group.LimitBy and (group.LimitBy.lower() == "balance") : 
587                message = self.config.getPoorWarn()
588            else :     
589                message = self.config.getSoftWarn(printer.Name)
590            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
591                for user in self.storage.getGroupMembers(group) :
592                    if mailto != "EXTERNAL" :
593                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
594                    else :   
595                        self.externalMailTo(arguments, action, user, printer, message)
596        return action       
597       
598    def warnUserPQuota(self, userpquota) :
599        """Checks a user quota and send him a message if quota is exceeded on current printer."""
600        user = userpquota.User
601        printer = userpquota.Printer
602        admin = self.config.getAdmin(printer.Name)
603        adminmail = self.config.getAdminMail(printer.Name)
604        (mailto, arguments) = self.config.getMailTo(printer.Name)
605        action = self.checkUserPQuota(userpquota)
606        if action.startswith("POLICY_") :
607            action = action[7:]
608           
609        if action == "DENY" :
610            adminmessage = _("Print Quota exceeded for user %s on printer %s") % (user.Name, printer.Name)
611            self.printInfo(adminmessage)
612            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
613                message = self.config.getHardWarn(printer.Name)
614                if mailto != "EXTERNAL" :
615                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
616                else :   
617                    self.externalMailTo(arguments, action, user, printer, message)
618            if mailto in [ "BOTH", "ADMIN" ] :
619                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
620        elif action == "WARN" :   
621            adminmessage = _("Print Quota low for user %s on printer %s") % (user.Name, printer.Name)
622            self.printInfo(adminmessage)
623            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
624                if user.LimitBy and (user.LimitBy.lower() == "balance") : 
625                    message = self.config.getPoorWarn()
626                else :     
627                    message = self.config.getSoftWarn(printer.Name)
628                if mailto != "EXTERNAL" :   
629                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Low"), message)
630                else :   
631                    self.externalMailTo(arguments, action, user, printer, message)
632            if mailto in [ "BOTH", "ADMIN" ] :
633                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
634        return action       
635       
636class PyKotaFilterOrBackend(PyKotaTool) :   
637    """Class for the PyKota filter or backend."""
638    def __init__(self) :
639        """Initialize local datas from current environment."""
640        # We begin with ignoring signals, we may de-ignore them later on.
641        self.gotSigTerm = 0
642        signal.signal(signal.SIGTERM, signal.SIG_IGN)
643        # signal.signal(signal.SIGCHLD, signal.SIG_IGN)
644        signal.signal(signal.SIGPIPE, signal.SIG_IGN)
645       
646        PyKotaTool.__init__(self)
647        (self.printingsystem, \
648         self.printerhostname, \
649         self.printername, \
650         self.username, \
651         self.jobid, \
652         self.inputfile, \
653         self.copies, \
654         self.title, \
655         self.options, \
656         self.originalbackend) = self.extractInfoFromCupsOrLprng()
657         
658    def deferredInit(self) :
659        """Deferred initialization."""
660        PyKotaTool.deferredInit(self)
661       
662        arguments = " ".join(['"%s"' % arg for arg in sys.argv])
663        self.logdebug(_("Printing system %s, args=%s") % (str(self.printingsystem), arguments))
664       
665        self.username = self.username or pwd.getpwuid(os.geteuid())[0] # use CUPS' user when printing test page from CUPS web interface, otherwise username is empty
666       
667        if self.printingsystem == "CUPS" :
668            self.extractDatasFromCups()
669       
670        (newusername, newbillingcode, newaction) = self.overwriteJobTicket()
671        if newusername :
672            self.printInfo(_("Job ticket overwritten : new username = [%s]") % newusername)
673            self.username = newusername
674        if newbillingcode :
675            self.printInfo(_("Job ticket overwritten : new billing code = [%s]") % newbillingcode)
676            self.overwrittenBillingCode = newbillingcode
677        else :   
678            self.overwrittenBillingCode = None
679        if newaction :   
680            self.printInfo(_("Job ticket overwritten : job will be denied (but a bit later)."))
681            self.mustDeny = 1
682        else :   
683            self.mustDeny = 0
684       
685        # do we want to strip out the Samba/Winbind domain name ?
686        separator = self.config.getWinbindSeparator()
687        if separator is not None :
688            self.username = self.username.split(separator)[-1]
689           
690        # do we want to lowercase usernames ?   
691        if self.config.getUserNameToLower() :
692            self.username = self.username.lower()
693           
694        # do we want to strip some prefix off of titles ?   
695        stripprefix = self.config.getStripTitle(self.printername)
696        if stripprefix :
697            if fnmatch.fnmatch(self.title[:len(stripprefix)], stripprefix) :
698                self.logdebug("Prefix [%s] removed from job's title [%s]." % (stripprefix, self.title))
699                self.title = self.title[len(stripprefix):]
700           
701        self.preserveinputfile = self.inputfile 
702        try :
703            self.accounter = accounter.openAccounter(self)
704        except (config.PyKotaConfigError, accounter.PyKotaAccounterError), msg :   
705            self.crashed(msg)
706            raise
707        self.exportJobInfo()
708        self.jobdatastream = self.openJobDataStream()
709        self.checksum = self.computeChecksum()
710        self.softwareJobSize = self.precomputeJobSize()
711        os.environ["PYKOTAPRECOMPUTEDJOBSIZE"] = str(self.softwareJobSize)
712        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.jobSizeBytes)
713        self.logdebug("Job size is %s bytes on %s pages." % (self.jobSizeBytes, self.softwareJobSize))
714        self.logdebug("Capturing SIGTERM events.")
715        signal.signal(signal.SIGTERM, self.sigterm_handler)
716       
717    def overwriteJobTicket(self) :   
718        """Should we overwrite the job's ticket (username and billingcode) ?"""
719        jobticketcommand = self.config.getOverwriteJobTicket(self.printername)
720        if jobticketcommand is not None :
721            username = billingcode = action = None
722            self.logdebug("Launching subprocess [%s] to overwrite the job's ticket." % jobticketcommand)
723            inputfile = os.popen(jobticketcommand, "r")
724            for line in inputfile.xreadlines() :
725                line = line.strip()
726                if line == "DENY" :
727                    self.logdebug("Seen DENY command.")
728                    action = "DENY"
729                elif line.startswith("USERNAME=") :   
730                    username = line.split("=", 1)[1].strip()
731                    self.logdebug("Seen new username [%s]" % username)
732                    action = None
733                elif line.startswith("BILLINGCODE=") :   
734                    billingcode = line.split("=", 1)[1].strip()
735                    self.logdebug("Seen new billing code [%s]" % billingcode)
736                    action = None
737            inputfile.close()   
738            return (username, billingcode, action)
739        else :
740            return (None, None, None)
741       
742    def sendBackChannelData(self, message, level="info") :   
743        """Sends an informational message to CUPS via back channel stream (stderr)."""
744        sys.stderr.write("%s: PyKota (PID %s) : %s\n" % (level.upper(), os.getpid(), message.strip()))
745        sys.stderr.flush()
746       
747    def computeChecksum(self) :   
748        """Computes the MD5 checksum of the job's datas, to be able to detect and forbid duplicate jobs."""
749        self.logdebug("Computing MD5 checksum for job %s" % self.jobid)
750        MEGABYTE = 1024*1024
751        checksum = md5.new()
752        while 1 :
753            data = self.jobdatastream.read(MEGABYTE) 
754            if not data :
755                break
756            checksum.update(data)   
757        self.jobdatastream.seek(0)
758        digest = checksum.hexdigest()
759        self.logdebug("MD5 checksum for job %s is %s" % (self.jobid, digest))
760        os.environ["PYKOTAMD5SUM"] = digest
761        return digest
762       
763    def openJobDataStream(self) :   
764        """Opens the file which contains the job's datas."""
765        if self.preserveinputfile is None :
766            # Job comes from sys.stdin, but this is not
767            # seekable and complexifies our task, so create
768            # a temporary file and use it instead
769            self.logdebug("Duplicating data stream from stdin to temporary file")
770            dummy = 0
771            MEGABYTE = 1024*1024
772            self.jobSizeBytes = 0
773            infile = tempfile.TemporaryFile()
774            while 1 :
775                data = sys.stdin.read(MEGABYTE) 
776                if not data :
777                    break
778                self.jobSizeBytes += len(data)   
779                if not (dummy % 10) :
780                    self.logdebug("%s bytes read..." % self.jobSizeBytes)
781                dummy += 1   
782                infile.write(data)
783            self.logdebug("%s bytes read total." % self.jobSizeBytes)
784            infile.flush()   
785            infile.seek(0)
786            return infile
787        else :   
788            # real file, just open it
789            self.regainPriv()
790            self.logdebug("Opening data stream %s" % self.preserveinputfile)
791            self.jobSizeBytes = os.stat(self.preserveinputfile)[6]
792            infile = open(self.preserveinputfile, "rb")
793            self.dropPriv()
794            return infile
795       
796    def closeJobDataStream(self) :   
797        """Closes the file which contains the job's datas."""
798        self.logdebug("Closing data stream.")
799        try :
800            self.jobdatastream.close()
801        except :   
802            pass
803       
804    def precomputeJobSize(self) :   
805        """Computes the job size with a software method."""
806        self.logdebug("Precomputing job's size with generic PDL analyzer...")
807        self.jobdatastream.seek(0)
808        try :
809            parser = analyzer.PDLAnalyzer(self.jobdatastream)
810            jobsize = parser.getJobSize()
811        except pdlparser.PDLParserError, msg :   
812            # Here we just log the failure, but
813            # we finally ignore it and return 0 since this
814            # computation is just an indication of what the
815            # job's size MAY be.
816            self.printInfo(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
817            return 0
818        else :   
819            if ((self.printingsystem == "CUPS") \
820                and (self.preserveinputfile is not None)) \
821                or (self.printingsystem != "CUPS") :
822                return jobsize * self.copies
823            else :       
824                return jobsize
825           
826    def sigterm_handler(self, signum, frame) :
827        """Sets an attribute whenever SIGTERM is received."""
828        self.gotSigTerm = 1
829        os.environ["PYKOTASTATUS"] = "CANCELLED"
830        self.printInfo(_("SIGTERM received, job %s cancelled.") % self.jobid)
831       
832    def exportJobInfo(self) :   
833        """Exports job information to the environment."""
834        os.environ["PYKOTAUSERNAME"] = str(self.username)
835        os.environ["PYKOTAPRINTERNAME"] = str(self.printername)
836        os.environ["PYKOTAJOBID"] = str(self.jobid)
837        os.environ["PYKOTATITLE"] = self.title or ""
838        os.environ["PYKOTAFILENAME"] = self.preserveinputfile or ""
839        os.environ["PYKOTACOPIES"] = str(self.copies)
840        os.environ["PYKOTAOPTIONS"] = self.options or ""
841        os.environ["PYKOTAPRINTERHOSTNAME"] = self.printerhostname or "localhost"
842   
843    def exportUserInfo(self, userpquota) :
844        """Exports user information to the environment."""
845        os.environ["PYKOTAOVERCHARGE"] = str(userpquota.User.OverCharge)
846        os.environ["PYKOTALIMITBY"] = str(userpquota.User.LimitBy)
847        os.environ["PYKOTABALANCE"] = str(userpquota.User.AccountBalance or 0.0)
848        os.environ["PYKOTALIFETIMEPAID"] = str(userpquota.User.LifeTimePaid or 0.0)
849        os.environ["PYKOTAPAGECOUNTER"] = str(userpquota.PageCounter or 0)
850        os.environ["PYKOTALIFEPAGECOUNTER"] = str(userpquota.LifePageCounter or 0)
851        os.environ["PYKOTASOFTLIMIT"] = str(userpquota.SoftLimit)
852        os.environ["PYKOTAHARDLIMIT"] = str(userpquota.HardLimit)
853        os.environ["PYKOTADATELIMIT"] = str(userpquota.DateLimit)
854        os.environ["PYKOTAWARNCOUNT"] = str(userpquota.WarnCount)
855       
856        # not really an user information, but anyway
857        # exports the list of printers groups the current
858        # printer is a member of
859        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(userpquota.Printer)])
860       
861    def prehook(self, userpquota) :
862        """Allows plugging of an external hook before the job gets printed."""
863        prehook = self.config.getPreHook(userpquota.Printer.Name)
864        if prehook :
865            self.logdebug("Executing pre-hook [%s]" % prehook)
866            os.system(prehook)
867       
868    def posthook(self, userpquota) :
869        """Allows plugging of an external hook after the job gets printed and/or denied."""
870        posthook = self.config.getPostHook(userpquota.Printer.Name)
871        if posthook :
872            self.logdebug("Executing post-hook [%s]" % posthook)
873            os.system(posthook)
874           
875    def printInfo(self, message, level="info") :       
876        """Sends a message to standard error."""
877        self.logger.log_message("%s" % message, level)
878       
879    def printMoreInfo(self, user, printer, message, level="info") :           
880        """Prefixes the information printed with 'user@printer(jobid) =>'."""
881        self.printInfo("%s@%s(%s) => %s" % (getattr(user, "Name", None), getattr(printer, "Name", None), self.jobid, message), level)
882       
883    def extractInfoFromCupsOrLprng(self) :   
884        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
885       
886           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
887        """
888        # Try to detect CUPS
889        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
890            if len(sys.argv) == 7 :
891                inputfile = sys.argv[6]
892            else :   
893                inputfile = None
894               
895            # check that the DEVICE_URI environment variable's value is
896            # prefixed with "cupspykota:" otherwise don't touch it.
897            # If this is the case, we have to remove the prefix from
898            # the environment before launching the real backend in cupspykota
899            device_uri = os.environ.get("DEVICE_URI", "")
900            if device_uri.startswith("cupspykota:") :
901                fulldevice_uri = device_uri[:]
902                device_uri = fulldevice_uri[len("cupspykota:"):]
903                if device_uri.startswith("//") :    # lpd (at least)
904                    device_uri = device_uri[2:]
905                os.environ["DEVICE_URI"] = device_uri   # TODO : side effect !
906            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
907            try :
908                (backend, destination) = device_uri.split(":", 1) 
909            except ValueError :   
910                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
911            while destination.startswith("/") :
912                destination = destination[1:]
913            checkauth = destination.split("@", 1)   
914            if len(checkauth) == 2 :
915                destination = checkauth[1]
916            printerhostname = destination.split("/")[0].split(":")[0]
917            return ("CUPS", \
918                    printerhostname, \
919                    os.environ.get("PRINTER"), \
920                    sys.argv[2].strip(), \
921                    sys.argv[1].strip(), \
922                    inputfile, \
923                    int(sys.argv[4].strip()), \
924                    sys.argv[3], \
925                    sys.argv[5], \
926                    backend)
927        else :   
928            # Try to detect LPRng
929            # TODO : try to extract filename, options if available
930            jseen = Jseen = Pseen = nseen = rseen = Kseen = None
931            for arg in sys.argv :
932                if arg.startswith("-j") :
933                    jseen = arg[2:].strip()
934                elif arg.startswith("-n") :     
935                    nseen = arg[2:].strip()
936                elif arg.startswith("-P") :   
937                    Pseen = arg[2:].strip()
938                elif arg.startswith("-r") :   
939                    rseen = arg[2:].strip()
940                elif arg.startswith("-J") :   
941                    Jseen = arg[2:].strip()
942                elif arg.startswith("-K") or arg.startswith("-#") :   
943                    Kseen = int(arg[2:].strip())
944            if Kseen is None :       
945                Kseen = 1       # we assume the user wants at least one copy...
946            if (rseen is None) and jseen and Pseen and nseen :   
947                lparg = [arg for arg in "".join(os.environ.get("PRINTCAP_ENTRY", "").split()).split(":") if arg.startswith("rm=") or arg.startswith("lp=")]
948                try :
949                    rseen = lparg[0].split("=")[-1].split("@")[-1].split("%")[0]
950                except :   
951                    # Not found
952                    self.printInfo(_("Printer hostname undefined, set to 'localhost'"), "warn")
953                    rseen = "localhost"
954               
955            spooldir = os.environ.get("SPOOL_DIR", ".")   
956            df_name = os.environ.get("DATAFILES")
957            if not df_name :
958                try : 
959                    df_name = [line[10:] for line in os.environ.get("HF", "").split() if line.startswith("datafiles=")][0]
960                except IndexError :   
961                    try :   
962                        df_name = [line[8:] for line in os.environ.get("HF", "").split() if line.startswith("df_name=")][0]
963                    except IndexError :
964                        try :
965                            cftransfername = [line[15:] for line in os.environ.get("HF", "").split() if line.startswith("cftransfername=")][0]
966                        except IndexError :   
967                            try :
968                                df_name = [line[1:] for line in os.environ.get("CONTROL", "").split() if line.startswith("fdf") or line.startswith("Udf")][0]
969                            except IndexError :   
970                                raise PyKotaToolError, "Unable to find the file which holds the job's datas. Please file a bug report for PyKota."
971                            else :   
972                                inputfile = os.path.join(spooldir, df_name) # no need to strip()
973                        else :   
974                            inputfile = os.path.join(spooldir, "d" + cftransfername[1:]) # no need to strip()
975                    else :   
976                        inputfile = os.path.join(spooldir, df_name) # no need to strip()
977                else :   
978                    inputfile = os.path.join(spooldir, df_name) # no need to strip()
979            else :   
980                inputfile = os.path.join(spooldir, df_name.strip())
981               
982            if jseen and Pseen and nseen and rseen :       
983                options = os.environ.get("HF", "") or os.environ.get("CONTROL", "")
984                return ("LPRNG", rseen, Pseen, nseen, jseen, inputfile, Kseen, Jseen, options, None)
985        self.printInfo(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
986        return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system
987       
988    def getPrinterUserAndUserPQuota(self) :       
989        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
990       
991           "OK" is returned in the policy if both printer, user and user print quota
992           exist in the Quota Storage.
993           Otherwise, the policy as defined for this printer in pykota.conf is returned.
994           
995           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
996           doesn't exist in the Quota Storage, then an external command is launched, as
997           defined in the external policy for this printer in pykota.conf
998           This external command can do anything, like automatically adding printers
999           or users, for example, and finally extracting printer, user and user print
1000           quota from the Quota Storage is tried a second time.
1001           
1002           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
1003           was returned by the external command.
1004        """
1005        for passnumber in range(1, 3) :
1006            printer = self.storage.getPrinter(self.printername)
1007            user = self.storage.getUser(self.username)
1008            userpquota = self.storage.getUserPQuota(user, printer)
1009            if printer.Exists and user.Exists and userpquota.Exists :
1010                policy = "OK"
1011                break
1012            (policy, args) = self.config.getPrinterPolicy(self.printername)
1013            if policy == "EXTERNAL" :   
1014                commandline = self.formatCommandLine(args, user, printer)
1015                if not printer.Exists :
1016                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.printername, commandline, self.printername))
1017                if not user.Exists :
1018                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.username, commandline, self.printername))
1019                if not userpquota.Exists :
1020                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying external policy (%s) for printer %s") % (self.username, self.printername, commandline, self.printername))
1021                if os.system(commandline) :
1022                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.printername), "error")
1023                    policy = "EXTERNALERROR"
1024                    break
1025            else :       
1026                if not printer.Exists :
1027                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.printername, policy))
1028                if not user.Exists :
1029                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.username, policy, self.printername))
1030                if not userpquota.Exists :
1031                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.username, self.printername, policy))
1032                break
1033        if policy == "EXTERNAL" :   
1034            if not printer.Exists :
1035                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.printername)
1036            if not user.Exists :
1037                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.username, self.printername))
1038            if not userpquota.Exists :
1039                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.username, self.printername))
1040        return (policy, printer, user, userpquota)
1041       
1042    def mainWork(self) :   
1043        """Main work is done here."""
1044        (policy, printer, user, userpquota) = self.getPrinterUserAndUserPQuota()
1045        # TODO : check for last user's quota in case pykota filter is used with querying
1046        if policy == "EXTERNALERROR" :
1047            # Policy was 'EXTERNAL' and the external command returned an error code
1048            return self.removeJob()
1049        elif policy == "EXTERNAL" :
1050            # Policy was 'EXTERNAL' and the external command wasn't able
1051            # to add either the printer, user or user print quota
1052            return self.removeJob()
1053        elif policy == "DENY" :   
1054            # Either printer, user or user print quota doesn't exist,
1055            # and the job should be rejected.
1056            return self.removeJob()
1057        else :
1058            if policy not in ("OK", "ALLOW") :
1059                self.printInfo(_("Invalid policy %s for printer %s") % (policy, self.printername))
1060                return self.removeJob()
1061            else :
1062                return self.doWork(policy, printer, user, userpquota)
Note: See TracBrowser for help on using the browser.