root / pykota / trunk / bin / cupspykota @ 2449

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

The billing code attribute was not correctly initialized whenever the overwrite_jobticket directive
denied the job.

  • Property svn:eol-style set to native
  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Revision
Line 
1#! /usr/bin/env python
2# -*- coding: ISO-8859-15 -*-
3
4# CUPSPyKota accounting backend
5#
6# PyKota - Print Quotas for CUPS and LPRng
7#
8# (c) 2003, 2004, 2005 Jerome Alet <alet@librelogiciel.com>
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation; either version 2 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program; if not, write to the Free Software
21# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
22#
23# $Id$
24#
25#
26
27import sys
28import os
29import errno
30import tempfile
31import popen2
32import cStringIO
33import shlex
34import signal
35import md5
36import fnmatch
37import pwd
38
39from pykota.tool import PyKotaTool, PyKotaToolError, crashed
40from pykota.accounter import openAccounter
41from pykota.ipp import IPPRequest, IPPError
42from pykota.storage import PyKotaStorageError
43       
44class CUPSBackend(PyKotaTool) :
45    """Base class for tools with no database access."""
46    def __init__(self) :
47        """Initializes the CUPS backend wrapper."""
48        PyKotaTool.__init__(self)
49        signal.signal(signal.SIGTERM, signal.SIG_IGN)
50        signal.signal(signal.SIGPIPE, signal.SIG_IGN)
51        self.MyName = "PyKota"
52        self.myname = "cupspykota"
53        self.pid = os.getpid()
54       
55    def deferredInit(self) :   
56        """Deferred initialization."""
57        PyKotaTool.deferredInit(self)
58        self.gotSigTerm = 0
59        self.installSigTermHandler()
60       
61    def sigtermHandler(self, signum, frame) :
62        """Sets an attribute whenever SIGTERM is received."""
63        self.gotSigTerm = 1
64        self.printInfo(_("SIGTERM received, job %s cancelled.") % self.JobId)
65        os.environ["PYKOTASTATUS"] = "CANCELLED"
66       
67    def deinstallSigTermHandler(self) :           
68        """Deinstalls the SIGTERM handler."""
69        self.logdebug("Deinstalling SIGTERM handler...")
70        signal.signal(signal.SIGTERM, signal.SIG_IGN)
71        self.logdebug("SIGTERM handler deinstalled.")
72       
73    def installSigTermHandler(self) :           
74        """Installs the SIGTERM handler."""
75        self.logdebug("Installing SIGTERM handler...")
76        signal.signal(signal.SIGTERM, self.sigtermHandler)
77        self.logdebug("SIGTERM handler installed.")
78       
79    def discoverOtherBackends(self) :   
80        """Discovers the other CUPS backends.
81       
82           Executes each existing backend in turn in device enumeration mode.
83           Returns the list of available backends.
84        """
85        # Unfortunately this method can't output any debug information
86        # to stdout or stderr, else CUPS considers that the device is
87        # not available.
88        available = []
89        (directory, myname) = os.path.split(sys.argv[0])
90        if not directory :
91            directory = "./"
92        tmpdir = tempfile.gettempdir()
93        lockfilename = os.path.join(tmpdir, "%s..LCK" % myname)
94        if os.path.exists(lockfilename) :
95            lockfile = open(lockfilename, "r")
96            pid = int(lockfile.read())
97            lockfile.close()
98            try :
99                # see if the pid contained in the lock file is still running
100                os.kill(pid, 0)
101            except OSError, e :   
102                if e.errno != errno.EPERM :
103                    # process doesn't exist anymore
104                    os.remove(lockfilename)
105           
106        if not os.path.exists(lockfilename) :
107            lockfile = open(lockfilename, "w")
108            lockfile.write("%i" % self.pid)
109            lockfile.close()
110            allbackends = [ os.path.join(directory, b) \
111                                for b in os.listdir(directory) 
112                                    if os.access(os.path.join(directory, b), os.X_OK) \
113                                        and (b != myname)] 
114            for backend in allbackends :                           
115                answer = os.popen(backend, "r")
116                try :
117                    devices = [line.strip() for line in answer.readlines()]
118                except :   
119                    devices = []
120                status = answer.close()
121                if status is None :
122                    for d in devices :
123                        # each line is of the form :
124                        # 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
125                        # so we have to decompose it carefully
126                        fdevice = cStringIO.StringIO(d)
127                        tokenizer = shlex.shlex(fdevice)
128                        tokenizer.wordchars = tokenizer.wordchars + \
129                                                        r".:,?!~/\_$*-+={}[]()#"
130                        arguments = []
131                        while 1 :
132                            token = tokenizer.get_token()
133                            if token :
134                                arguments.append(token)
135                            else :
136                                break
137                        fdevice.close()
138                        try :
139                            (devicetype, device, name, fullname) = arguments
140                        except ValueError :   
141                            pass    # ignore this 'bizarre' device
142                        else :   
143                            if name.startswith('"') and name.endswith('"') :
144                                name = name[1:-1]
145                            if fullname.startswith('"') and fullname.endswith('"') :
146                                fullname = fullname[1:-1]
147                            available.append('%s %s:%s "%s+%s" "%s managed %s"' \
148                                                 % (devicetype, self.myname, \
149                                                    device, self.MyName, \
150                                                    name, self.MyName, \
151                                                    fullname))
152            os.remove(lockfilename)
153        available.append('direct %s:// "%s+Nothing" "%s managed Virtual Printer"' \
154                             % (self.myname, self.MyName, self.MyName))
155        return available
156                       
157    def initBackendParameters(self) :   
158        """Initializes the backend's attributes."""
159        # check that the DEVICE_URI environment variable's value is
160        # prefixed with self.myname otherwise don't touch it.
161        # If this is the case, we have to remove the prefix from
162        # the environment before launching the real backend
163        self.logdebug("Initializing backend...")
164        muststartwith = "%s:" % self.myname
165        device_uri = os.environ.get("DEVICE_URI", "")
166        if device_uri.startswith(muststartwith) :
167            fulldevice_uri = device_uri[:]
168            device_uri = fulldevice_uri[len(muststartwith):]
169            for i in range(2) :
170                if device_uri.startswith("/") : 
171                    device_uri = device_uri[1:]
172        try :
173            (backend, destination) = device_uri.split(":", 1) 
174        except ValueError :   
175            if not device_uri :
176                self.logdebug("Not attached to an existing print queue.")
177                backend = ""
178                printerhostname = ""
179            else :   
180                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
181        else :       
182            while destination.startswith("/") :
183                destination = destination[1:]
184            checkauth = destination.split("@", 1)   
185            if len(checkauth) == 2 :
186                destination = checkauth[1]
187            printerhostname = destination.split("/")[0].split(":")[0]
188       
189        self.Action = "ALLOW"   # job allowed by default
190        self.JobId = sys.argv[1].strip()
191        # use CUPS' user when printing test pages from CUPS' web interface
192        self.UserName = sys.argv[2].strip() or pwd.getpwuid(os.geteuid())[0]
193        self.Title = sys.argv[3].strip()
194        self.Copies = int(sys.argv[4].strip())
195        self.Options = sys.argv[5].strip()
196        if len(sys.argv) == 7 :
197            self.InputFile = sys.argv[6] # read job's datas from file
198        else :   
199            self.InputFile = None        # read job's datas from stdin
200           
201        self.PrinterHostName = printerhostname   
202        self.RealBackend = backend
203        self.DeviceURI = device_uri
204        self.PrinterName = os.environ.get("PRINTER", "")
205        self.Directory = self.config.getPrinterDirectory(self.PrinterName)
206        self.DataFile = os.path.join(self.Directory, "%s-%s-%s-%s" % \
207                   (self.myname, self.PrinterName, self.UserName, self.JobId))
208       
209        (ippfilename, ippmessage) = self.parseIPPRequestFile()
210        self.ControlFile = ippfilename
211        john = ippmessage.operation_attributes.get("job-originating-host-name", \
212               ippmessage.job_attributes.get("job-originating-host-name", \
213               (None, None)))
214        if type(john) == type([]) : 
215            john = john[-1]
216        (chtype, self.ClientHost) = john 
217        jbing = ippmessage.job_attributes.get("job-billing", (None, None))
218        if type(jbing) == type([]) : 
219            jbing = jbing[-1]
220        (jbtype, self.JobBillingCode) = jbing
221       
222        self.logdebug("Backend : %s" % self.RealBackend)
223        self.logdebug("DeviceURI : %s" % self.DeviceURI)
224        self.logdebug("Printername : %s" % self.PrinterName)
225        self.logdebug("Username : %s" % self.UserName)
226        self.logdebug("JobId : %s" % self.JobId)
227        self.logdebug("Title : %s" % self.Title)
228        self.logdebug("Filename : %s" % self.InputFile)
229        self.logdebug("Copies : %s" % self.Copies)
230        self.logdebug("Options : %s" % self.Options)
231        self.logdebug("Directory : %s" % self.Directory) 
232        self.logdebug("DataFile : %s" % self.DataFile)
233        self.logdebug("ControlFile : %s" % self.ControlFile)
234        self.logdebug("JobBillingCode : %s" % self.JobBillingCode)
235        self.logdebug("JobOriginatingHostName : %s" % self.ClientHost)
236       
237        self.logdebug("Backend initialized.")
238       
239    def overwriteJobAttributes(self) :
240        """Overwrites some of the job's attributes if needed."""
241        self.logdebug("Sanitizing job's attributes...")
242        # First overwrite the job ticket
243        self.overwriteJobTicket()
244       
245        # do we want to strip out the Samba/Winbind domain name ?
246        separator = self.config.getWinbindSeparator()
247        if separator is not None :
248            self.UserName = self.UserName.split(separator)[-1]
249           
250        # do we want to lowercase usernames ?   
251        if self.config.getUserNameToLower() :
252            self.UserName = self.UserName.lower()
253           
254        # do we want to strip some prefix off of titles ?   
255        stripprefix = self.config.getStripTitle(self.PrinterName)
256        if stripprefix :
257            if fnmatch.fnmatch(self.Title[:len(stripprefix)], stripprefix) :
258                self.logdebug("Prefix [%s] removed from job's title [%s]." \
259                                      % (stripprefix, self.Title))
260                self.Title = self.Title[len(stripprefix):]
261               
262        self.logdebug("Username : %s" % self.UserName)
263        self.logdebug("BillingCode : %s" % self.JobBillingCode)
264        self.logdebug("Title : %s" % self.Title)
265        self.logdebug("Job's attributes sanitizing done.")
266               
267    def overwriteJobTicket(self) :   
268        """Should we overwrite the job's ticket (username and billingcode) ?"""
269        self.logdebug("Checking if we need to overwrite the job ticket...")
270        jobticketcommand = self.config.getOverwriteJobTicket(self.PrinterName)
271        if jobticketcommand is not None :
272            username = billingcode = action = None
273            self.logdebug("Launching subprocess [%s] to overwrite the job ticket." \
274                                     % jobticketcommand)
275            inputfile = os.popen(jobticketcommand, "r")
276            for line in inputfile.xreadlines() :
277                line = line.strip()
278                if line == "DENY" :
279                    self.logdebug("Seen DENY command.")
280                    action = "DENY"
281                elif line.startswith("USERNAME=") :   
282                    username = line.split("=", 1)[1].strip()
283                    self.logdebug("Seen new username [%s]" % username)
284                    action = None
285                elif line.startswith("BILLINGCODE=") :   
286                    billingcode = line.split("=", 1)[1].strip()
287                    self.logdebug("Seen new billing code [%s]" % billingcode)
288                    action = None
289            inputfile.close()   
290           
291            # now overwrite the job's ticket if new data was supplied
292            if action :
293                self.Action = action
294            if username :
295                self.UserName = username
296            # NB : we overwrite the billing code even if empty   
297            self.JobBillingCode = billingcode 
298        self.logdebug("Job ticket overwriting done.")
299           
300    def saveDatasAndCheckSum(self) :
301        """Saves the input datas into a static file."""
302        self.logdebug("Duplicating data stream into %s" % self.DataFile)
303        mustclose = 0
304        outfile = open(self.DataFile, "wb")   
305        if self.InputFile is not None :
306            self.regainPriv()
307            infile = open(self.InputFile, "rb")
308            mustclose = 1
309        else :   
310            infile = sys.stdin
311        CHUNK = 64*1024         # read 64 Kb at a time
312        dummy = 0
313        sizeread = 0
314        checksum = md5.new()
315        while 1 :
316            data = infile.read(CHUNK) 
317            if not data :
318                break
319            sizeread += len(data)   
320            outfile.write(data)
321            checksum.update(data)   
322            if not (dummy % 32) : # Only display every 2 Mb
323                self.logdebug("%s bytes saved..." % sizeread)
324            dummy += 1   
325        if mustclose :   
326            infile.close()
327            self.dropPriv()
328           
329        outfile.close()
330        self.JobSizeBytes = sizeread   
331        self.JobMD5Sum = checksum.hexdigest()
332       
333        self.logdebug("JobSizeBytes : %s" % self.JobSizeBytes)
334        self.logdebug("JobMD5Sum : %s" % self.JobMD5Sum)
335        self.logdebug("Data stream duplicated into %s" % self.DataFile)
336           
337    def clean(self) :
338        """Cleans up the place."""
339        self.logdebug("Cleaning up...")
340        self.deinstallSigTermHandler()
341        if not self.config.getPrinterKeepFiles(self.PrinterName) :
342            try :
343                self.logdebug("Work file %s will be deleted." % self.DataFile)
344            except AttributeError :   
345                pass
346            else :   
347                os.remove(self.DataFile)
348                self.logdebug("Work file %s has been deleted." % self.DataFile)
349        else :   
350            self.logdebug("Work file %s will be kept." % self.DataFile)
351        PyKotaTool.clean(self)   
352        self.logdebug("Clean.")
353           
354    def precomputeJobSize(self) :   
355        """Computes the job size with a software method."""
356        self.logdebug("Precomputing job's size...")
357        jobsize = 0
358        if self.JobSizeBytes :
359            try :
360                from pkpgpdls import analyzer, pdlparser
361            except ImportError :   
362                self.printInfo("pkpgcounter is now distributed separately, please grab it from http://www.librelogiciel.com/software/pkpgcounter/action_Download", "error")
363                self.printInfo("Precomputed job size will be forced to 0 pages.", "error")
364            else :     
365                infile = open(self.DataFile, "rb")
366                try :
367                    parser = analyzer.PDLAnalyzer(infile)
368                    jobsize = parser.getJobSize()
369                except pdlparser.PDLParserError, msg :   
370                    # Here we just log the failure, but
371                    # we finally ignore it and return 0 since this
372                    # computation is just an indication of what the
373                    # job's size MAY be.
374                    self.printInfo(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
375                else :   
376                    if self.InputFile is not None :
377                        # when a filename is passed as an argument, the backend
378                        # must generate the correct number of copies.
379                        jobsize *= self.Copies
380                infile.close()       
381        self.softwareJobSize = jobsize
382        self.logdebug("Precomputed job's size is %s pages." % self.softwareJobSize)
383       
384    def precomputeJobPrice(self) :   
385        """Precomputes the job price with a software method."""
386        self.logdebug("Precomputing job's price...")
387        self.softwareJobPrice = self.UserPQuota.computeJobPrice(self.softwareJobSize)
388        self.logdebug("Precomputed job's price is %.3f credits." \
389                                   % self.softwareJobPrice)
390       
391    def getCupsConfigDirectives(self, directives=[]) :
392        """Retrieves some CUPS directives from its configuration file.
393       
394           Returns a mapping with lowercased directives as keys and
395           their setting as values.
396        """
397        self.logdebug("Parsing CUPS' configuration file...")
398        dirvalues = {} 
399        cupsroot = os.environ.get("CUPS_SERVERROOT", "/etc/cups")
400        cupsdconf = os.path.join(cupsroot, "cupsd.conf")
401        try :
402            conffile = open(cupsdconf, "r")
403        except IOError :   
404            raise PyKotaToolError, "Unable to open %s" % cupsdconf
405        else :   
406            for line in conffile.readlines() :
407                linecopy = line.strip().lower()
408                for di in [d.lower() for d in directives] :
409                    if linecopy.startswith("%s " % di) :
410                        try :
411                            val = line.split()[1]
412                        except :   
413                            pass # ignore errors, we take the last value in any case.
414                        else :   
415                            dirvalues[di] = val
416            conffile.close()           
417        self.logdebug("CUPS' configuration file parsed successfully.")
418        return dirvalues       
419           
420    def parseIPPRequestFile(self) :       
421        """Parses the IPP message file and returns a tuple (filename, parsedvalue)."""
422        self.logdebug("Parsing IPP request file...")
423       
424        class DummyClass :
425            operation_attributes = {}
426            job_attributes = {}
427           
428        ippmessage = DummyClass() # in case the code below fails
429       
430        self.regainPriv()
431        cupsdconf = self.getCupsConfigDirectives(["RequestRoot"])
432        requestroot = cupsdconf.get("requestroot", "/var/spool/cups")
433        if (len(self.JobId) < 5) and self.JobId.isdigit() :
434            ippmessagefile = "c%05i" % int(self.JobId)
435        else :   
436            ippmessagefile = "c%s" % self.JobId
437        ippmessagefile = os.path.join(requestroot, ippmessagefile)
438        try :
439            ippdatafile = open(ippmessagefile)
440        except :   
441            self.logdebug("Unable to open IPP request file %s" % ippmessagefile)
442        else :   
443            self.logdebug("Parsing of IPP request file %s begins." % ippmessagefile)
444            try :
445                ippmessage = IPPRequest(ippdatafile.read())
446                ippmessage.parse()
447            except IPPError, msg :   
448                self.printInfo("Error while parsing %s : %s" \
449                                      % (ippmessagefile, msg), "warn")
450            else :   
451                self.logdebug("Parsing of IPP request file %s ends." \
452                                       % ippmessagefile)
453            ippdatafile.close()
454        self.dropPriv()
455        self.logdebug("IPP request file parsed successfully.")
456        return (ippmessagefile, ippmessage)
457               
458    def exportJobInfo(self) :   
459        """Exports the actual job's attributes to the environment."""
460        self.logdebug("Exporting job information to the environment...")
461        os.environ["DEVICE_URI"] = self.DeviceURI       # WARNING !
462        os.environ["PYKOTAPRINTERNAME"] = self.PrinterName
463        os.environ["PYKOTADIRECTORY"] = self.Directory
464        os.environ["PYKOTADATAFILE"] = self.DataFile
465        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.JobSizeBytes)
466        os.environ["PYKOTAMD5SUM"] = self.JobMD5Sum
467        os.environ["PYKOTAJOBORIGINATINGHOSTNAME"] = self.ClientHost or ""
468        os.environ["PYKOTAJOBID"] = self.JobId
469        os.environ["PYKOTAUSERNAME"] = self.UserName
470        os.environ["PYKOTATITLE"] = self.Title
471        os.environ["PYKOTACOPIES"] = str(self.Copies)
472        os.environ["PYKOTAOPTIONS"] = self.Options
473        os.environ["PYKOTAFILENAME"] = self.InputFile or ""
474        os.environ["PYKOTAJOBBILLING"] = self.JobBillingCode or ""
475        os.environ["PYKOTACONTROLFILE"] = self.ControlFile
476        os.environ["PYKOTAPRINTERHOSTNAME"] = self.PrinterHostName
477        os.environ["PYKOTAPRECOMPUTEDJOBSIZE"] = str(self.softwareJobSize)
478        self.logdebug("Environment updated.")
479       
480    def exportUserInfo(self) :
481        """Exports user information to the environment."""
482        self.logdebug("Exporting user information to the environment...")
483        os.environ["PYKOTAOVERCHARGE"] = str(self.User.OverCharge)
484        os.environ["PYKOTALIMITBY"] = str(self.User.LimitBy)
485        os.environ["PYKOTABALANCE"] = str(self.User.AccountBalance or 0.0)
486        os.environ["PYKOTALIFETIMEPAID"] = str(self.User.LifeTimePaid or 0.0)
487       
488        os.environ["PYKOTAPAGECOUNTER"] = str(self.UserPQuota.PageCounter or 0)
489        os.environ["PYKOTALIFEPAGECOUNTER"] = str(self.UserPQuota.LifePageCounter or 0)
490        os.environ["PYKOTASOFTLIMIT"] = str(self.UserPQuota.SoftLimit)
491        os.environ["PYKOTAHARDLIMIT"] = str(self.UserPQuota.HardLimit)
492        os.environ["PYKOTADATELIMIT"] = str(self.UserPQuota.DateLimit)
493        os.environ["PYKOTAWARNCOUNT"] = str(self.UserPQuota.WarnCount)
494       
495        # TODO : move this elsewhere once software accounting is done only once.
496        os.environ["PYKOTAPRECOMPUTEDJOBPRICE"] = str(self.softwareJobPrice)
497       
498        self.logdebug("Environment updated.")
499       
500    def exportPrinterInfo(self) :
501        """Exports printer information to the environment."""
502        self.logdebug("Exporting printer information to the environment...")
503        # exports the list of printers groups the current
504        # printer is a member of
505        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(self.Printer)])
506        self.logdebug("Environment updated.")
507       
508    def exportPhaseInfo(self, phase) :
509        """Exports phase information to the environment."""
510        self.logdebug("Exporting phase information [%s] to the environment..." % phase)
511        os.environ["PYKOTAPHASE"] = phase
512        self.logdebug("Environment updated.")
513       
514    def exportJobSizeAndPrice(self) :
515        """Exports job's size and price information to the environment."""
516        self.logdebug("Exporting job's size and price information to the environment...")
517        os.environ["PYKOTAJOBSIZE"] = str(self.JobSize)
518        os.environ["PYKOTAJOBPRICE"] = str(self.JobPrice)
519        self.logdebug("Environment updated.")
520       
521    def acceptJob(self) :       
522        """Returns the appropriate exit code to tell CUPS all is OK."""
523        return 0
524           
525    def removeJob(self) :           
526        """Returns the appropriate exit code to let CUPS think all is OK.
527       
528           Returning 0 (success) prevents CUPS from stopping the print queue.
529        """   
530        return 0
531       
532    def launchPreHook(self) :
533        """Allows plugging of an external hook before the job gets printed."""
534        prehook = self.config.getPreHook(self.PrinterName)
535        if prehook :
536            self.logdebug("Executing pre-hook [%s]..." % prehook)
537            retcode = os.system(prehook)
538            self.logdebug("pre-hook exited with status %s." % retcode)
539       
540    def launchPostHook(self) :
541        """Allows plugging of an external hook after the job gets printed and/or denied."""
542        posthook = self.config.getPostHook(self.PrinterName)
543        if posthook :
544            self.logdebug("Executing post-hook [%s]..." % posthook)
545            retcode = os.system(posthook)
546            self.logdebug("post-hook exited with status %s." % retcode)
547           
548    def improveMessage(self, message) :       
549        """Improves a message by adding more informations in it if possible."""
550        try :
551            return "%s@%s(%s) => %s" % (self.UserName, \
552                                        self.PrinterName, \
553                                        self.JobId, \
554                                        message)
555        except :                                               
556            return message
557       
558    def logdebug(self, message) :       
559        """Improves the debug message before outputting it."""
560        PyKotaTool.logdebug(self, self.improveMessage(message))
561       
562    def printInfo(self, message, level="info") :       
563        """Improves the informational message before outputting it."""
564        self.logger.log_message(self.improveMessage(message), level)
565   
566    def startingBanner(self, withaccounting) :
567        """Retrieves a starting banner for current printer and returns its content."""
568        self.logdebug("Retrieving starting banner...")
569        self.printBanner(self.config.getStartingBanner(self.PrinterName), withaccounting)
570        self.logdebug("Starting banner retrieved.")
571   
572    def endingBanner(self, withaccounting) :
573        """Retrieves an ending banner for current printer and returns its content."""
574        self.logdebug("Retrieving ending banner...")
575        self.printBanner(self.config.getEndingBanner(self.PrinterName), withaccounting)
576        self.logdebug("Ending banner retrieved.")
577       
578    def printBanner(self, bannerfileorcommand, withaccounting) :
579        """Reads a banner or generates one through an external command.
580       
581           Returns the banner's content in a format which MUST be accepted
582           by the printer.
583        """
584        self.logdebug("Printing banner...")
585        if bannerfileorcommand :
586            if os.access(bannerfileorcommand, os.X_OK) or \
587                  not os.path.isfile(bannerfileorcommand) :
588                self.logdebug("Launching %s to generate a banner." % bannerfileorcommand)
589                child = popen2.Popen3(bannerfileorcommand, capturestderr=1)
590                self.runOriginalBackend(child.fromchild, isBanner=1)
591                child.tochild.close()
592                child.childerr.close()
593                child.fromchild.close()
594                status = child.wait()
595                if os.WIFEXITED(status) :
596                    status = os.WEXITSTATUS(status)
597                self.printInfo(_("Banner generator %s exit code is %s") \
598                                         % (bannerfileorcommand, str(status)))
599                if withaccounting :
600                    if self.accounter.isSoftware :
601                        self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
602            else :
603                self.logdebug("Using %s as the banner." % bannerfileorcommand)
604                try :
605                    fh = open(bannerfileorcommand, 'rb')
606                except IOError, msg :   
607                    self.printInfo("Impossible to open %s : %s" \
608                                       % (bannerfileorcommand, msg), "error")
609                else :   
610                    self.runOriginalBackend(fh, isBanner=1)
611                    fh.close()
612                    if withaccounting :
613                        if self.accounter.isSoftware :
614                            self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
615        self.logdebug("Banner printed...")
616               
617    def handleBanner(self, bannertype, withaccounting) :
618        """Handles the banner with or without accounting."""
619        if withaccounting :
620            acc = "with"
621        else :   
622            acc = "without"
623        self.logdebug("Handling %s banner %s accounting..." % (bannertype, acc))
624        if (self.Action == 'DENY') and \
625           (self.UserPQuota.WarnCount >= \
626                            self.config.getMaxDenyBanners(self.PrinterName)) :
627            self.printInfo(_("Banner won't be printed : maximum number of deny banners reached."), \
628                             "warn")
629        else :
630            if self.Action == 'DENY' :
631                self.logdebug("Incrementing the number of deny banners for user %s on printer %s" \
632                                  % (self.UserName, self.PrinterName))
633                self.UserPQuota.incDenyBannerCounter() # increments the warning counter
634                self.exportUserInfo()
635            getattr(self, "%sBanner" % bannertype)(withaccounting)
636        self.logdebug("%s banner done." % bannertype.title())
637       
638    def sanitizeJobSize(self) :   
639        """Sanitizes the job's size if needed."""
640        # TODO : there's a difficult to see bug here when banner accounting is activated and hardware accounting is used.
641        self.logdebug("Sanitizing job's size...")
642        if self.softwareJobSize and (self.JobSize != self.softwareJobSize) :
643            self.printInfo(_("Beware : computed job size (%s) != precomputed job size (%s)") % \
644                                       (self.JobSize, self.softwareJobSize), \
645                           "error")
646            (limit, replacement) = self.config.getTrustJobSize(self.PrinterName)
647            if limit is None :
648                self.printInfo(_("The job size will be trusted anyway according to the 'trustjobsize' directive"), "warn")
649            else :
650                if self.JobSize <= limit :
651                    self.printInfo(_("The job size will be trusted because it is inferior to the 'trustjobsize' directive's limit %s") % limit, "warn")
652                else :
653                    self.printInfo(_("The job size will be modified according to the 'trustjobsize' directive : %s") % replacement, "warn")
654                    if replacement == "PRECOMPUTED" :
655                        self.JobSize = self.softwareJobSize
656                    else :   
657                        self.JobSize = replacement
658        self.logdebug("Job's size sanitized.")
659                       
660    def getPrinterUserAndUserPQuota(self) :       
661        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
662       
663           "OK" is returned in the policy if both printer, user and user print quota
664           exist in the Quota Storage.
665           Otherwise, the policy as defined for this printer in pykota.conf is returned.
666           
667           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
668           doesn't exist in the Quota Storage, then an external command is launched, as
669           defined in the external policy for this printer in pykota.conf
670           This external command can do anything, like automatically adding printers
671           or users, for example, and finally extracting printer, user and user print
672           quota from the Quota Storage is tried a second time.
673           
674           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
675           was returned by the external command.
676        """
677        self.logdebug("Retrieving printer, user, and user print quota entry from database...")
678        for passnumber in range(1, 3) :
679            printer = self.storage.getPrinter(self.PrinterName)
680            user = self.storage.getUser(self.UserName)
681            userpquota = self.storage.getUserPQuota(user, printer)
682            if printer.Exists and user.Exists and userpquota.Exists :
683                policy = "OK"
684                break
685            (policy, args) = self.config.getPrinterPolicy(self.PrinterName)
686            if policy == "EXTERNAL" :   
687                commandline = self.formatCommandLine(args, user, printer)
688                if not printer.Exists :
689                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.PrinterName, commandline, self.PrinterName))
690                if not user.Exists :
691                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.UserName, commandline, self.PrinterName))
692                if not userpquota.Exists :
693                    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))
694                if os.system(commandline) :
695                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.PrinterName), "error")
696                    policy = "EXTERNALERROR"
697                    break
698            else :       
699                if not printer.Exists :
700                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.PrinterName, policy))
701                if not user.Exists :
702                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.UserName, policy, self.PrinterName))
703                if not userpquota.Exists :
704                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.UserName, self.PrinterName, policy))
705                break
706               
707        if policy == "EXTERNAL" :   
708            if not printer.Exists :
709                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.PrinterName)
710            if not user.Exists :
711                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.UserName, self.PrinterName))
712            if not userpquota.Exists :
713                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.UserName, self.PrinterName))
714        self.Policy = policy         
715        self.Printer = printer
716        self.User = user
717        self.UserPQuota = userpquota
718        self.logdebug("Retrieval of printer, user and user print quota entry done.")
719       
720    def getBillingCode(self) :   
721        """Extracts the billing code from the database.
722         
723           An optional script is launched to notify the user when
724           the billing code is unknown and PyKota was configured to
725           deny printing in this case.
726        """
727        self.logdebug("Retrieving billing code information from the database...")
728        self.BillingCode = None
729        if self.JobBillingCode :
730            self.BillingCode = self.storage.getBillingCode(self.JobBillingCode)
731            if self.BillingCode.Exists :
732                self.logdebug("Billing code [%s] found in database." % self.JobBillingCode)
733            else :
734                msg = "Unknown billing code [%s] : " % self.JobBillingCode
735                (newaction, script) = self.config.getUnknownBillingCode(self.PrinterName)
736                if newaction == "CREATE" :
737                    self.logdebug(msg + "will be created.")
738                    self.BillingCode = self.storage.addBillingCode(self.JobBillingCode)
739                    if self.BillingCode.Exists :
740                        self.logdebug(msg + "has been created.")
741                    else :   
742                        self.printInfo(msg + "couldn't be created.", "error")
743                else :   
744                    self.logdebug(msg + "job will be denied.")
745                    self.Action = newaction
746                    if script is not None : 
747                        self.logdebug(msg + "launching subprocess [%s] to notify user." % script)
748                        os.system(script)
749        self.logdebug("Retrieval of billing code information done.")
750       
751    def checkIfDupe(self) :   
752        """Checks if the job is a dupe, and handles the situation."""
753        self.logdebug("Checking if the job is a dupe...")
754        denyduplicates = self.config.getDenyDuplicates(self.PrinterName)
755        if not denyduplicates :
756            self.logdebug("We don't care about dupes after all.")
757        elif self.Printer.LastJob.Exists \
758             and (self.Printer.LastJob.UserName == self.UserName) \
759             and (self.Printer.LastJob.JobMD5Sum == self.JobMD5Sum) :
760            # TODO : use the current user's last job instead of 
761            # TODO : the current printer's last job. This would be
762            # TODO : better but requires an additional database query
763            # TODO : with SQL, and is much more complex with the
764            # TODO : actual LDAP schema. Maybe this is not very
765            # TODO : important, because usually dupes are rapidly sucessive.
766            msg = _("Job is a dupe")
767            if denyduplicates == 1 :
768                self.printInfo("%s : %s." % (msg, _("Printing is denied by configuration")), "warn")
769                self.Action = "DENY"
770            else :   
771                self.logdebug("Launching subprocess [%s] to see if dupes should be allowed or not." % denyduplicates)
772                fanswer = os.popen(denyduplicates, "r")
773                self.Action = fanswer.read().strip().upper()
774                fanswer.close()
775                if self.Action == "DENY" :     
776                    self.printInfo("%s : %s." % (msg, _("Subprocess denied printing of a dupe")), "warn")
777                else :   
778                    self.printInfo("%s : %s." % (msg, _("Subprocess allowed printing of a dupe")), "warn")
779        else :           
780            self.logdebug("Job doesn't seem to be a dupe.")
781        self.logdebug("Checking if the job is a dupe done.")
782       
783    def mainWork(self) :   
784        """Main work is done here."""
785        if not self.JobSizeBytes :
786            # if no data to pass to real backend, probably a filter
787            # higher in the chain failed because of a misconfiguration.
788            # we deny the job in this case (nothing to print anyway)
789            self.printInfo(_("Job contains no data. Printing is denied."), "error")
790            return self.removeJob()
791           
792        self.getPrinterUserAndUserPQuota()
793        if self.Policy == "EXTERNALERROR" :
794            # Policy was 'EXTERNAL' and the external command returned an error code
795            return self.removeJob()
796        elif self.Policy == "EXTERNAL" :
797            # Policy was 'EXTERNAL' and the external command wasn't able
798            # to add either the printer, user or user print quota
799            return self.removeJob()
800        elif self.Policy == "DENY" :   
801            # Either printer, user or user print quota doesn't exist,
802            # and the job should be rejected.
803            return self.removeJob()
804        elif self.Policy == "ALLOW" :
805            # ALLOW means : Either printer, user or user print quota doesn't exist,
806            #               but the job should be allowed anyway.
807            self.printInfo(_("Job allowed by printer policy. No accounting will be done."), "warn")
808            return self.printJobDatas()
809        elif self.Policy == "OK" :
810            # OK means : Both printer, user and user print quota exist, job should
811            #            be allowed if current user is allowed to print on this printer
812            return self.doWork()
813        else :   
814            self.printInfo(_("Invalid policy %s for printer %s") % (self.Policy, self.PrinterName), "error")
815            return self.removeJob()
816   
817    def doWork(self) :   
818        """The accounting work is done here."""
819        self.precomputeJobPrice()
820        self.exportUserInfo()
821        self.exportPrinterInfo()
822        self.exportPhaseInfo("BEFORE")
823       
824        if self.Action != "DENY" :
825            # If printing is still allowed at this time, we
826            # need to extract the billing code information from the database.
827            # No need to do this if the job is denied, this way we
828            # save some database queries.
829            self.getBillingCode()
830           
831        if self.Action != "DENY" :
832            # If printing is still allowed at this time, we
833            # need to check if the job is a dupe or not, and what to do then.
834            # No need to do this if the job is denied, this way we
835            # save some database queries.
836            self.checkIfDupe()
837                   
838        if self.Action != "DENY" :
839            # If printing is still allowed at this time, we
840            # need to check the user's print quota on the current printer.
841            # No need to do this if the job is denied, this way we
842            # save some database queries.
843            self.logdebug("Checking user %s print quota entry on printer %s" \
844                                    % (self.UserName, self.PrinterName))
845            self.Action = self.warnUserPQuota(self.UserPQuota)
846           
847        # exports some new environment variables
848        os.environ["PYKOTAACTION"] = str(self.Action)
849       
850        # launches the pre hook
851        self.launchPreHook()
852       
853        # handle starting banner pages without accounting
854        self.BannerSize = 0
855        accountbanner = self.config.getAccountBanner(self.PrinterName)
856        if accountbanner in ["ENDING", "NONE"] :
857            self.handleBanner("starting", 0)
858       
859        if self.Action == "DENY" :
860            self.printInfo(_("Job denied, no accounting will be done."))
861        else :
862            self.printInfo(_("Job accounting begins."))
863            self.deinstallSigTermHandler()
864            self.accounter.beginJob(self.Printer)
865            self.installSigTermHandler()
866       
867        # handle starting banner pages with accounting
868        if accountbanner in ["STARTING", "BOTH"] :
869            if not self.gotSigTerm :
870                self.handleBanner("starting", 1)
871       
872        # pass the job's data to the real backend   
873        if (not self.gotSigTerm) and (self.Action in ["ALLOW", "WARN"]) :
874            retcode = self.printJobDatas()
875        else :       
876            retcode = self.removeJob()
877       
878        # indicate phase change
879        self.exportPhaseInfo("AFTER")
880       
881        # handle ending banner pages with accounting
882        if accountbanner in ["ENDING", "BOTH"] :
883            if not self.gotSigTerm :
884                self.handleBanner("ending", 1)
885       
886        # stops accounting
887        if self.Action == "DENY" :
888            self.printInfo(_("Job denied, no accounting has been done."))
889        else :
890            self.deinstallSigTermHandler()
891            self.accounter.endJob(self.Printer)
892            self.installSigTermHandler()
893            self.printInfo(_("Job accounting ends."))
894       
895        # Do all these database changes within a single transaction   
896        # NB : we don't enclose ALL the changes within a single transaction
897        # because while waiting for the printer to answer its internal page
898        # counter, we would open the door to accounting problems for other
899        # jobs launched by the same user at the same time on other printers.
900        # All the code below doesn't take much time, so it's fine.
901        self.storage.beginTransaction()
902        try :
903            # retrieve the job size   
904            if self.Action == "DENY" :
905                self.JobSize = 0
906                self.printInfo(_("Job size forced to 0 because printing is denied."))
907            else :   
908                self.UserPQuota.resetDenyBannerCounter()
909                self.JobSize = self.accounter.getJobSize(self.Printer)
910                self.sanitizeJobSize()
911                self.JobSize += self.BannerSize
912            self.printInfo(_("Job size : %i") % self.JobSize)
913           
914            # update the quota for the current user on this printer
915            self.printInfo(_("Updating user %s's quota on printer %s") % (self.UserName, self.PrinterName))
916            self.JobPrice = self.UserPQuota.increasePagesUsage(self.JobSize)
917           
918            # adds the current job to history   
919            self.Printer.addJobToHistory(self.JobId, self.User, self.accounter.getLastPageCounter(), \
920                                    self.Action, self.JobSize, self.JobPrice, self.InputFile, \
921                                    self.Title, self.Copies, self.Options, self.ClientHost, \
922                                    self.JobSizeBytes, self.JobMD5Sum, None, self.JobBillingCode)
923            self.printInfo(_("Job added to history."))
924           
925            if hasattr(self, "BillingCode") and self.BillingCode and self.BillingCode.Exists :
926                self.BillingCode.consume(self.JobSize, self.JobPrice)
927                self.printInfo(_("Billing code %s was updated.") % self.BillingCode.BillingCode)
928        except :   
929            self.storage.rollbackTransaction()
930            raise
931        else :   
932            self.storage.commitTransaction()
933           
934        # exports some new environment variables
935        self.exportJobSizeAndPrice()
936       
937        # then re-export user information with new values
938        self.exportUserInfo()
939       
940        # handle ending banner pages without accounting
941        if accountbanner in ["STARTING", "NONE"] :
942            self.handleBanner("ending", 0)
943                   
944        self.launchPostHook()
945           
946        return retcode   
947               
948    def printJobDatas(self) :           
949        """Sends the job's datas to the real backend."""
950        self.logdebug("Sending job's datas to real backend...")
951        if self.InputFile is None :
952            infile = open(self.DataFile, "rb")
953        else :   
954            infile = None
955        self.runOriginalBackend(infile)
956        if self.InputFile is None :
957            infile.close()
958        self.logdebug("Job's datas sent to real backend.")
959       
960    def runOriginalBackend(self, filehandle=None, isBanner=0) :
961        """Launches the original backend."""
962        originalbackend = os.path.join(os.path.split(sys.argv[0])[0], self.RealBackend)
963        if not isBanner :
964            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:]
965        else :   
966            # For banners, we absolutely WANT
967            # to remove any filename from the command line !
968            self.logdebug("It looks like we try to print a banner.")
969            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:6]
970        arguments[2] = self.UserName # in case it was overwritten by external script
971        # TODO : do something about job-billing option, in case it was overwritten as well...
972       
973        self.logdebug("Starting original backend %s with args %s" % (originalbackend, " ".join(['"%s"' % a for a in arguments])))
974        self.regainPriv()   
975        pid = os.fork()
976        self.logdebug("Forked !")
977        if pid == 0 :
978            if filehandle is not None :
979                self.logdebug("Redirecting file handle to real backend's stdin")
980                os.dup2(filehandle.fileno(), 0)
981            try :
982                self.logdebug("Calling execve...")
983                os.execve(originalbackend, arguments, os.environ)
984            except OSError, msg :
985                self.logdebug("execve() failed: %s" % msg)
986            self.logdebug("We shouldn't be there !!!")   
987            os._exit(-1)
988        self.dropPriv()   
989       
990        self.logdebug("Waiting for original backend to exit...")   
991        killed = 0
992        status = -1
993        while status == -1 :
994            try :
995                status = os.waitpid(pid, 0)[1]
996            except OSError, (err, msg) :
997                if (err == 4) and self.gotSigTerm :
998                    os.kill(pid, signal.SIGTERM)
999                    killed = 1
1000                   
1001        if os.WIFEXITED(status) :
1002            status = os.WEXITSTATUS(status)
1003            if status :
1004                level = "error"
1005            else :   
1006                level = "info"
1007            self.printInfo("CUPS backend %s returned %d." % \
1008                                     (originalbackend, status), level)
1009            return status
1010        elif not killed :
1011            self.printInfo("CUPS backend %s died abnormally." % \
1012                               originalbackend, "error")
1013            return -1
1014        else :
1015            self.printInfo("CUPS backend %s was killed." % \
1016                               originalbackend, "warn")
1017            return 1
1018       
1019if __name__ == "__main__" :   
1020    # This is a CUPS backend, we should act and die like a CUPS backend
1021    wrapper = CUPSBackend()
1022    if len(sys.argv) == 1 :
1023        print "\n".join(wrapper.discoverOtherBackends())
1024        sys.exit(0)               
1025    elif len(sys.argv) not in (6, 7) :   
1026        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n"\
1027                              % sys.argv[0])
1028        sys.exit(1)
1029    else :   
1030        try :
1031            wrapper.deferredInit()
1032            wrapper.initBackendParameters()
1033            wrapper.saveDatasAndCheckSum()
1034            wrapper.accounter = openAccounter(wrapper)
1035            wrapper.precomputeJobSize()
1036            wrapper.exportJobInfo() # exports a first time to give hints to external scripts
1037            wrapper.overwriteJobAttributes()
1038            wrapper.exportJobInfo() # re-exports in case it was overwritten
1039            retcode = wrapper.mainWork()
1040        except SystemExit, e :   
1041            retcode = e.code
1042        except :   
1043            try :
1044                wrapper.crashed("cupspykota backend failed")
1045            except :   
1046                crashed("cupspykota backend failed")
1047            retcode = 1
1048        wrapper.clean()
1049        sys.exit(retcode)
Note: See TracBrowser for help on using the browser.