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

Revision 2393, 49.5 kB (checked in by jerome, 19 years ago)

Added the overwrite_jobticket directive.
Severity : How Powerful !!! :-)

  • 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        (newusername, newbillingcode, newaction) = self.overwriteJobTicket()
668        if newusername :
669            self.printInfo(_("Job ticket overwritten : new username = [%s]") % newusername)
670            self.username = newusername
671        if newbillingcode :
672            self.printInfo(_("Job ticket overwritten : new billing code = [%s]") % newbillingcode)
673            self.overwrittenBillingCode = newbillingcode
674        else :   
675            self.overwrittenBillingCode = None
676        if newaction :   
677            self.printInfo(_("Job ticket overwritten : job will be denied (but a bit later)."))
678            self.mustDeny = 1
679        else :   
680            self.mustDeny = 0
681       
682        # do we want to strip out the Samba/Winbind domain name ?
683        separator = self.config.getWinbindSeparator()
684        if separator is not None :
685            self.username = self.username.split(separator)[-1]
686           
687        # do we want to lowercase usernames ?   
688        if self.config.getUserNameToLower() :
689            self.username = self.username.lower()
690           
691        # do we want to strip some prefix off of titles ?   
692        stripprefix = self.config.getStripTitle(self.printername)
693        if stripprefix :
694            if fnmatch.fnmatch(self.title[:len(stripprefix)], stripprefix) :
695                self.logdebug("Prefix [%s] removed from job's title [%s]." % (stripprefix, self.title))
696                self.title = self.title[len(stripprefix):]
697           
698        self.preserveinputfile = self.inputfile 
699        try :
700            self.accounter = accounter.openAccounter(self)
701        except (config.PyKotaConfigError, accounter.PyKotaAccounterError), msg :   
702            self.crashed(msg)
703            raise
704        self.exportJobInfo()
705        self.jobdatastream = self.openJobDataStream()
706        self.checksum = self.computeChecksum()
707        self.softwareJobSize = self.precomputeJobSize()
708        os.environ["PYKOTAPRECOMPUTEDJOBSIZE"] = str(self.softwareJobSize)
709        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.jobSizeBytes)
710        self.logdebug("Job size is %s bytes on %s pages." % (self.jobSizeBytes, self.softwareJobSize))
711        self.logdebug("Capturing SIGTERM events.")
712        signal.signal(signal.SIGTERM, self.sigterm_handler)
713       
714    def overwriteJobTicket(self) :   
715        """Should we overwrite the job's ticket (username and billingcode) ?"""
716        jobticketcommand = self.config.getOverwriteJobTicket(self.printername)
717        if jobticketcommand is not None :
718            username = billingcode = action = None
719            self.logdebug("Launching subprocess [%s] to overwrite the job's ticket." % jobticketcommand)
720            inputfile = os.popen(jobticketcommand, "r")
721            for line in inputfile.xreadlines() :
722                line = line.strip()
723                if line == "DENY" :
724                    self.logdebug("Seen DENY command.")
725                    action = "DENY"
726                elif line.startswith("USERNAME=") :   
727                    username = line.split("=", 1)[1].strip()
728                    self.logdebug("Seen new username [%s]" % username)
729                    action = None
730                elif line.startswith("BILLINGCODE=") :   
731                    billingcode = line.split("=", 1)[1].strip()
732                    self.logdebug("Seen new billing code [%s]" % billingcode)
733                    action = None
734            inputfile.close()   
735            return (username, billingcode, action)
736        else :
737            return (None, None, None)
738       
739    def sendBackChannelData(self, message, level="info") :   
740        """Sends an informational message to CUPS via back channel stream (stderr)."""
741        sys.stderr.write("%s: PyKota (PID %s) : %s\n" % (level.upper(), os.getpid(), message.strip()))
742        sys.stderr.flush()
743       
744    def computeChecksum(self) :   
745        """Computes the MD5 checksum of the job's datas, to be able to detect and forbid duplicate jobs."""
746        self.logdebug("Computing MD5 checksum for job %s" % self.jobid)
747        MEGABYTE = 1024*1024
748        checksum = md5.new()
749        while 1 :
750            data = self.jobdatastream.read(MEGABYTE) 
751            if not data :
752                break
753            checksum.update(data)   
754        self.jobdatastream.seek(0)
755        digest = checksum.hexdigest()
756        self.logdebug("MD5 checksum for job %s is %s" % (self.jobid, digest))
757        os.environ["PYKOTAMD5SUM"] = digest
758        return digest
759       
760    def openJobDataStream(self) :   
761        """Opens the file which contains the job's datas."""
762        if self.preserveinputfile is None :
763            # Job comes from sys.stdin, but this is not
764            # seekable and complexifies our task, so create
765            # a temporary file and use it instead
766            self.logdebug("Duplicating data stream from stdin to temporary file")
767            dummy = 0
768            MEGABYTE = 1024*1024
769            self.jobSizeBytes = 0
770            infile = tempfile.TemporaryFile()
771            while 1 :
772                data = sys.stdin.read(MEGABYTE) 
773                if not data :
774                    break
775                self.jobSizeBytes += len(data)   
776                if not (dummy % 10) :
777                    self.logdebug("%s bytes read..." % self.jobSizeBytes)
778                dummy += 1   
779                infile.write(data)
780            self.logdebug("%s bytes read total." % self.jobSizeBytes)
781            infile.flush()   
782            infile.seek(0)
783            return infile
784        else :   
785            # real file, just open it
786            self.regainPriv()
787            self.logdebug("Opening data stream %s" % self.preserveinputfile)
788            self.jobSizeBytes = os.stat(self.preserveinputfile)[6]
789            infile = open(self.preserveinputfile, "rb")
790            self.dropPriv()
791            return infile
792       
793    def closeJobDataStream(self) :   
794        """Closes the file which contains the job's datas."""
795        self.logdebug("Closing data stream.")
796        try :
797            self.jobdatastream.close()
798        except :   
799            pass
800       
801    def precomputeJobSize(self) :   
802        """Computes the job size with a software method."""
803        self.logdebug("Precomputing job's size with generic PDL analyzer...")
804        self.jobdatastream.seek(0)
805        try :
806            parser = analyzer.PDLAnalyzer(self.jobdatastream)
807            jobsize = parser.getJobSize()
808        except pdlparser.PDLParserError, msg :   
809            # Here we just log the failure, but
810            # we finally ignore it and return 0 since this
811            # computation is just an indication of what the
812            # job's size MAY be.
813            self.printInfo(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
814            return 0
815        else :   
816            if ((self.printingsystem == "CUPS") \
817                and (self.preserveinputfile is not None)) \
818                or (self.printingsystem != "CUPS") :
819                return jobsize * self.copies
820            else :       
821                return jobsize
822           
823    def sigterm_handler(self, signum, frame) :
824        """Sets an attribute whenever SIGTERM is received."""
825        self.gotSigTerm = 1
826        os.environ["PYKOTASTATUS"] = "CANCELLED"
827        self.printInfo(_("SIGTERM received, job %s cancelled.") % self.jobid)
828       
829    def exportJobInfo(self) :   
830        """Exports job information to the environment."""
831        os.environ["PYKOTAUSERNAME"] = str(self.username)
832        os.environ["PYKOTAPRINTERNAME"] = str(self.printername)
833        os.environ["PYKOTAJOBID"] = str(self.jobid)
834        os.environ["PYKOTATITLE"] = self.title or ""
835        os.environ["PYKOTAFILENAME"] = self.preserveinputfile or ""
836        os.environ["PYKOTACOPIES"] = str(self.copies)
837        os.environ["PYKOTAOPTIONS"] = self.options or ""
838        os.environ["PYKOTAPRINTERHOSTNAME"] = self.printerhostname or "localhost"
839   
840    def exportUserInfo(self, userpquota) :
841        """Exports user information to the environment."""
842        os.environ["PYKOTAOVERCHARGE"] = str(userpquota.User.OverCharge)
843        os.environ["PYKOTALIMITBY"] = str(userpquota.User.LimitBy)
844        os.environ["PYKOTABALANCE"] = str(userpquota.User.AccountBalance or 0.0)
845        os.environ["PYKOTALIFETIMEPAID"] = str(userpquota.User.LifeTimePaid or 0.0)
846        os.environ["PYKOTAPAGECOUNTER"] = str(userpquota.PageCounter or 0)
847        os.environ["PYKOTALIFEPAGECOUNTER"] = str(userpquota.LifePageCounter or 0)
848        os.environ["PYKOTASOFTLIMIT"] = str(userpquota.SoftLimit)
849        os.environ["PYKOTAHARDLIMIT"] = str(userpquota.HardLimit)
850        os.environ["PYKOTADATELIMIT"] = str(userpquota.DateLimit)
851        os.environ["PYKOTAWARNCOUNT"] = str(userpquota.WarnCount)
852       
853        # not really an user information, but anyway
854        # exports the list of printers groups the current
855        # printer is a member of
856        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(userpquota.Printer)])
857       
858    def prehook(self, userpquota) :
859        """Allows plugging of an external hook before the job gets printed."""
860        prehook = self.config.getPreHook(userpquota.Printer.Name)
861        if prehook :
862            self.logdebug("Executing pre-hook [%s]" % prehook)
863            os.system(prehook)
864       
865    def posthook(self, userpquota) :
866        """Allows plugging of an external hook after the job gets printed and/or denied."""
867        posthook = self.config.getPostHook(userpquota.Printer.Name)
868        if posthook :
869            self.logdebug("Executing post-hook [%s]" % posthook)
870            os.system(posthook)
871           
872    def printInfo(self, message, level="info") :       
873        """Sends a message to standard error."""
874        self.logger.log_message("%s" % message, level)
875       
876    def printMoreInfo(self, user, printer, message, level="info") :           
877        """Prefixes the information printed with 'user@printer(jobid) =>'."""
878        self.printInfo("%s@%s(%s) => %s" % (getattr(user, "Name", None), getattr(printer, "Name", None), self.jobid, message), level)
879       
880    def extractInfoFromCupsOrLprng(self) :   
881        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
882       
883           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
884        """
885        # Try to detect CUPS
886        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
887            if len(sys.argv) == 7 :
888                inputfile = sys.argv[6]
889            else :   
890                inputfile = None
891               
892            # check that the DEVICE_URI environment variable's value is
893            # prefixed with "cupspykota:" otherwise don't touch it.
894            # If this is the case, we have to remove the prefix from
895            # the environment before launching the real backend in cupspykota
896            device_uri = os.environ.get("DEVICE_URI", "")
897            if device_uri.startswith("cupspykota:") :
898                fulldevice_uri = device_uri[:]
899                device_uri = fulldevice_uri[len("cupspykota:"):]
900                if device_uri.startswith("//") :    # lpd (at least)
901                    device_uri = device_uri[2:]
902                os.environ["DEVICE_URI"] = device_uri   # TODO : side effect !
903            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
904            try :
905                (backend, destination) = device_uri.split(":", 1) 
906            except ValueError :   
907                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
908            while destination.startswith("/") :
909                destination = destination[1:]
910            checkauth = destination.split("@", 1)   
911            if len(checkauth) == 2 :
912                destination = checkauth[1]
913            printerhostname = destination.split("/")[0].split(":")[0]
914            return ("CUPS", \
915                    printerhostname, \
916                    os.environ.get("PRINTER"), \
917                    sys.argv[2].strip(), \
918                    sys.argv[1].strip(), \
919                    inputfile, \
920                    int(sys.argv[4].strip()), \
921                    sys.argv[3], \
922                    sys.argv[5], \
923                    backend)
924        else :   
925            # Try to detect LPRng
926            # TODO : try to extract filename, options if available
927            jseen = Jseen = Pseen = nseen = rseen = Kseen = None
928            for arg in sys.argv :
929                if arg.startswith("-j") :
930                    jseen = arg[2:].strip()
931                elif arg.startswith("-n") :     
932                    nseen = arg[2:].strip()
933                elif arg.startswith("-P") :   
934                    Pseen = arg[2:].strip()
935                elif arg.startswith("-r") :   
936                    rseen = arg[2:].strip()
937                elif arg.startswith("-J") :   
938                    Jseen = arg[2:].strip()
939                elif arg.startswith("-K") or arg.startswith("-#") :   
940                    Kseen = int(arg[2:].strip())
941            if Kseen is None :       
942                Kseen = 1       # we assume the user wants at least one copy...
943            if (rseen is None) and jseen and Pseen and nseen :   
944                lparg = [arg for arg in "".join(os.environ.get("PRINTCAP_ENTRY", "").split()).split(":") if arg.startswith("rm=") or arg.startswith("lp=")]
945                try :
946                    rseen = lparg[0].split("=")[-1].split("@")[-1].split("%")[0]
947                except :   
948                    # Not found
949                    self.printInfo(_("Printer hostname undefined, set to 'localhost'"), "warn")
950                    rseen = "localhost"
951               
952            spooldir = os.environ.get("SPOOL_DIR", ".")   
953            df_name = os.environ.get("DATAFILES")
954            if not df_name :
955                try : 
956                    df_name = [line[10:] for line in os.environ.get("HF", "").split() if line.startswith("datafiles=")][0]
957                except IndexError :   
958                    try :   
959                        df_name = [line[8:] for line in os.environ.get("HF", "").split() if line.startswith("df_name=")][0]
960                    except IndexError :
961                        try :
962                            cftransfername = [line[15:] for line in os.environ.get("HF", "").split() if line.startswith("cftransfername=")][0]
963                        except IndexError :   
964                            try :
965                                df_name = [line[1:] for line in os.environ.get("CONTROL", "").split() if line.startswith("fdf") or line.startswith("Udf")][0]
966                            except IndexError :   
967                                raise PyKotaToolError, "Unable to find the file which holds the job's datas. Please file a bug report for PyKota."
968                            else :   
969                                inputfile = os.path.join(spooldir, df_name) # no need to strip()
970                        else :   
971                            inputfile = os.path.join(spooldir, "d" + cftransfername[1:]) # no need to strip()
972                    else :   
973                        inputfile = os.path.join(spooldir, df_name) # no need to strip()
974                else :   
975                    inputfile = os.path.join(spooldir, df_name) # no need to strip()
976            else :   
977                inputfile = os.path.join(spooldir, df_name.strip())
978               
979            if jseen and Pseen and nseen and rseen :       
980                options = os.environ.get("HF", "") or os.environ.get("CONTROL", "")
981                return ("LPRNG", rseen, Pseen, nseen, jseen, inputfile, Kseen, Jseen, options, None)
982        self.printInfo(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
983        return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system
984       
985    def getPrinterUserAndUserPQuota(self) :       
986        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
987       
988           "OK" is returned in the policy if both printer, user and user print quota
989           exist in the Quota Storage.
990           Otherwise, the policy as defined for this printer in pykota.conf is returned.
991           
992           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
993           doesn't exist in the Quota Storage, then an external command is launched, as
994           defined in the external policy for this printer in pykota.conf
995           This external command can do anything, like automatically adding printers
996           or users, for example, and finally extracting printer, user and user print
997           quota from the Quota Storage is tried a second time.
998           
999           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
1000           was returned by the external command.
1001        """
1002        for passnumber in range(1, 3) :
1003            printer = self.storage.getPrinter(self.printername)
1004            user = self.storage.getUser(self.username)
1005            userpquota = self.storage.getUserPQuota(user, printer)
1006            if printer.Exists and user.Exists and userpquota.Exists :
1007                policy = "OK"
1008                break
1009            (policy, args) = self.config.getPrinterPolicy(self.printername)
1010            if policy == "EXTERNAL" :   
1011                commandline = self.formatCommandLine(args, user, printer)
1012                if not printer.Exists :
1013                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.printername, commandline, self.printername))
1014                if not user.Exists :
1015                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.username, commandline, self.printername))
1016                if not userpquota.Exists :
1017                    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))
1018                if os.system(commandline) :
1019                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.printername), "error")
1020                    policy = "EXTERNALERROR"
1021                    break
1022            else :       
1023                if not printer.Exists :
1024                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.printername, policy))
1025                if not user.Exists :
1026                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.username, policy, self.printername))
1027                if not userpquota.Exists :
1028                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.username, self.printername, policy))
1029                break
1030        if policy == "EXTERNAL" :   
1031            if not printer.Exists :
1032                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.printername)
1033            if not user.Exists :
1034                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.username, self.printername))
1035            if not userpquota.Exists :
1036                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.username, self.printername))
1037        return (policy, printer, user, userpquota)
1038       
1039    def mainWork(self) :   
1040        """Main work is done here."""
1041        (policy, printer, user, userpquota) = self.getPrinterUserAndUserPQuota()
1042        # TODO : check for last user's quota in case pykota filter is used with querying
1043        if policy == "EXTERNALERROR" :
1044            # Policy was 'EXTERNAL' and the external command returned an error code
1045            return self.removeJob()
1046        elif policy == "EXTERNAL" :
1047            # Policy was 'EXTERNAL' and the external command wasn't able
1048            # to add either the printer, user or user print quota
1049            return self.removeJob()
1050        elif policy == "DENY" :   
1051            # Either printer, user or user print quota doesn't exist,
1052            # and the job should be rejected.
1053            return self.removeJob()
1054        else :
1055            if policy not in ("OK", "ALLOW") :
1056                self.printInfo(_("Invalid policy %s for printer %s") % (policy, self.printername))
1057                return self.removeJob()
1058            else :
1059                return self.doWork(policy, printer, user, userpquota)
Note: See TracBrowser for help on using the browser.