root / pykota / trunk / bin / cupspykota @ 2412

Revision 2412, 49.1 kB (checked in by jerome, 19 years ago)

Fixes the fix !

  • 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) :
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))
570        self.logdebug("Starting banner retrieved.")
571   
572    def endingBanner(self) :
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))
576        self.logdebug("Ending banner retrieved.")
577       
578    def printBanner(self, bannerfileorcommand) :
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            else :
600                self.logdebug("Using %s as the banner." % bannerfileorcommand)
601                try :
602                    fh = open(bannerfileorcommand, 'rb')
603                except IOError, msg :   
604                    self.printInfo("Impossible to open %s : %s" \
605                                       % (bannerfileorcommand, msg), "error")
606                else :   
607                    self.runOriginalBackend(fh, isBanner=1)
608                    fh.close()
609        self.logdebug("Banner printed...")
610               
611    def handleBanner(self, bannertype, withaccounting) :
612        """Handles the banner with or without accounting."""
613        if withaccounting :
614            acc = "with"
615        else :   
616            acc = "without"
617        self.logdebug("Handling %s banner %s accounting..." % (bannertype, acc))
618        if (self.Action == 'DENY') and \
619           (self.UserPQuota.WarnCount >= \
620                            self.config.getMaxDenyBanners(self.PrinterName)) :
621            self.printInfo(_("Banner won't be printed : maximum number of deny banners reached."), \
622                             "warn")
623        else :
624            if self.Action == 'DENY' :
625                self.logdebug("Incrementing the number of deny banners for user %s on printer %s" \
626                                  % (self.UserName, self.PrinterName))
627                self.UserPQuota.incDenyBannerCounter() # increments the warning counter
628                self.exportUserInfo()
629            getattr(self, "%sBanner" % bannertype)()
630            if withaccounting :
631                if self.accounter.isSoftware :
632                    self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
633        self.logdebug("%s banner done." % bannertype.title())
634       
635    def sanitizeJobSize(self) :   
636        """Sanitizes the job's size if needed."""
637        # TODO : there's a difficult to see bug here when banner accounting is activated and hardware accounting is used.
638        self.logdebug("Sanitizing job's size...")
639        if self.softwareJobSize and (self.JobSize != self.softwareJobSize) :
640            self.printInfo(_("Beware : computed job size (%s) != precomputed job size (%s)") % \
641                                       (self.JobSize, self.softwareJobSize), \
642                           "error")
643            (limit, replacement) = self.config.getTrustJobSize(self.PrinterName)
644            if limit is None :
645                self.printInfo(_("The job size will be trusted anyway according to the 'trustjobsize' directive"), "warn")
646            else :
647                if self.JobSize <= limit :
648                    self.printInfo(_("The job size will be trusted because it is inferior to the 'trustjobsize' directive's limit %s") % limit, "warn")
649                else :
650                    self.printInfo(_("The job size will be modified according to the 'trustjobsize' directive : %s") % replacement, "warn")
651                    if replacement == "PRECOMPUTED" :
652                        self.JobSize = self.softwareJobSize
653                    else :   
654                        self.JobSize = replacement
655        self.logdebug("Job's size sanitized.")
656                       
657    def getPrinterUserAndUserPQuota(self) :       
658        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
659       
660           "OK" is returned in the policy if both printer, user and user print quota
661           exist in the Quota Storage.
662           Otherwise, the policy as defined for this printer in pykota.conf is returned.
663           
664           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
665           doesn't exist in the Quota Storage, then an external command is launched, as
666           defined in the external policy for this printer in pykota.conf
667           This external command can do anything, like automatically adding printers
668           or users, for example, and finally extracting printer, user and user print
669           quota from the Quota Storage is tried a second time.
670           
671           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
672           was returned by the external command.
673        """
674        self.logdebug("Retrieving printer, user, and user print quota entry from database...")
675        for passnumber in range(1, 3) :
676            printer = self.storage.getPrinter(self.PrinterName)
677            user = self.storage.getUser(self.UserName)
678            userpquota = self.storage.getUserPQuota(user, printer)
679            if printer.Exists and user.Exists and userpquota.Exists :
680                policy = "OK"
681                break
682            (policy, args) = self.config.getPrinterPolicy(self.PrinterName)
683            if policy == "EXTERNAL" :   
684                commandline = self.formatCommandLine(args, user, printer)
685                if not printer.Exists :
686                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.PrinterName, commandline, self.PrinterName))
687                if not user.Exists :
688                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.UserName, commandline, self.PrinterName))
689                if not userpquota.Exists :
690                    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))
691                if os.system(commandline) :
692                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.PrinterName), "error")
693                    policy = "EXTERNALERROR"
694                    break
695            else :       
696                if not printer.Exists :
697                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.PrinterName, policy))
698                if not user.Exists :
699                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.UserName, policy, self.PrinterName))
700                if not userpquota.Exists :
701                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.UserName, self.PrinterName, policy))
702                break
703               
704        if policy == "EXTERNAL" :   
705            if not printer.Exists :
706                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.PrinterName)
707            if not user.Exists :
708                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.UserName, self.PrinterName))
709            if not userpquota.Exists :
710                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.UserName, self.PrinterName))
711        self.Policy = policy         
712        self.Printer = printer
713        self.User = user
714        self.UserPQuota = userpquota
715        self.logdebug("Retrieval of printer, user and user print quota entry done.")
716       
717    def getBillingCode(self) :   
718        """Extracts the billing code from the database.
719         
720           An optional script is launched to notify the user when
721           the billing code is unknown and PyKota was configured to
722           deny printing in this case.
723        """
724        self.logdebug("Retrieving billing code information from the database...")
725        self.BillingCode = None
726        if self.JobBillingCode :
727            self.BillingCode = self.storage.getBillingCode(self.JobBillingCode)
728            if self.BillingCode.Exists :
729                self.logdebug("Billing code [%s] found in database." % self.JobBillingCode)
730            else :
731                msg = "Unknown billing code [%s] : " % self.JobBillingCode
732                (newaction, script) = self.config.getUnknownBillingCode(self.PrinterName)
733                if newaction == "CREATE" :
734                    self.logdebug(msg + "will be created.")
735                    self.BillingCode = self.storage.addBillingCode(self.JobBillingCode)
736                    if self.BillingCode.Exists :
737                        self.logdebug(msg + "has been created.")
738                    else :   
739                        self.printInfo(msg + "couldn't be created.", "error")
740                else :   
741                    self.logdebug(msg + "job will be denied.")
742                    self.Action = newaction
743                    if script is not None : 
744                        self.logdebug(msg + "launching subprocess [%s] to notify user." % script)
745                        os.system(script)
746        self.logdebug("Retrieval of billing code information done.")
747       
748    def checkIfDupe(self) :   
749        """Checks if the job is a dupe, and handles the situation."""
750        self.logdebug("Checking if the job is a dupe...")
751        denyduplicates = self.config.getDenyDuplicates(self.PrinterName)
752        if not denyduplicates :
753            self.logdebug("We don't care about dupes after all.")
754        elif self.Printer.LastJob.Exists \
755             and (self.Printer.LastJob.UserName == self.UserName) \
756             and (self.Printer.LastJob.JobMD5Sum == self.JobMD5Sum) :
757            # TODO : use the current user's last job instead of 
758            # TODO : the current printer's last job. This would be
759            # TODO : better but requires an additional database query
760            # TODO : with SQL, and is much more complex with the
761            # TODO : actual LDAP schema. Maybe this is not very
762            # TODO : important, because usually dupes are rapidly sucessive.
763            msg = _("Job is a dupe")
764            if denyduplicates == 1 :
765                self.printInfo("%s : %s." % (msg, _("Printing is denied by configuration")), "warn")
766                self.Action = "DENY"
767            else :   
768                self.logdebug("Launching subprocess [%s] to see if dupes should be allowed or not." % denyduplicates)
769                fanswer = os.popen(denyduplicates, "r")
770                self.Action = fanswer.read().strip().upper()
771                fanswer.close()
772                if self.Action == "DENY" :     
773                    self.printInfo("%s : %s." % (msg, _("Subprocess denied printing of a dupe")), "warn")
774                else :   
775                    self.printInfo("%s : %s." % (msg, _("Subprocess allowed printing of a dupe")), "warn")
776        else :           
777            self.logdebug("Job doesn't seem to be a dupe.")
778        self.logdebug("Checking if the job is a dupe done.")
779       
780    def mainWork(self) :   
781        """Main work is done here."""
782        if not self.JobSizeBytes :
783            # if no data to pass to real backend, probably a filter
784            # higher in the chain failed because of a misconfiguration.
785            # we deny the job in this case (nothing to print anyway)
786            self.printInfo(_("Job contains no data. Printing is denied."), "error")
787            return self.removeJob()
788           
789        self.getPrinterUserAndUserPQuota()
790        if self.Policy == "EXTERNALERROR" :
791            # Policy was 'EXTERNAL' and the external command returned an error code
792            return self.removeJob()
793        elif self.Policy == "EXTERNAL" :
794            # Policy was 'EXTERNAL' and the external command wasn't able
795            # to add either the printer, user or user print quota
796            return self.removeJob()
797        elif self.Policy == "DENY" :   
798            # Either printer, user or user print quota doesn't exist,
799            # and the job should be rejected.
800            return self.removeJob()
801        elif self.Policy == "ALLOW" :
802            # ALLOW means : Either printer, user or user print quota doesn't exist,
803            #               but the job should be allowed anyway.
804            self.printInfo(_("Job allowed by printer policy. No accounting will be done."), "warn")
805            return self.printJobDatas()
806        elif self.Policy == "OK" :
807            # OK means : Both printer, user and user print quota exist, job should
808            #            be allowed if current user is allowed to print on this printer
809            return self.doWork()
810        else :   
811            self.printInfo(_("Invalid policy %s for printer %s") % (self.Policy, self.PrinterName), "error")
812            return self.removeJob()
813   
814    def doWork(self) :   
815        """The accounting work is done here."""
816        self.precomputeJobPrice()
817        self.exportUserInfo()
818        self.exportPrinterInfo()
819        self.exportPhaseInfo("BEFORE")
820       
821        if self.Action != "DENY" :
822            # If printing is still allowed at this time, we
823            # need to extract the billing code information from the database.
824            # No need to do this if the job is denied, this way we
825            # save some database queries.
826            self.getBillingCode()
827           
828        if self.Action != "DENY" :
829            # If printing is still allowed at this time, we
830            # need to check if the job is a dupe or not, and what to do then.
831            # No need to do this if the job is denied, this way we
832            # save some database queries.
833            self.checkIfDupe()
834                   
835        if self.Action != "DENY" :
836            # If printing is still allowed at this time, we
837            # need to check the user's print quota on the current printer.
838            # No need to do this if the job is denied, this way we
839            # save some database queries.
840            self.logdebug("Checking user %s print quota entry on printer %s" \
841                                    % (self.UserName, self.PrinterName))
842            self.Action = self.warnUserPQuota(self.UserPQuota)
843           
844        # exports some new environment variables
845        os.environ["PYKOTAACTION"] = str(self.Action)
846       
847        # launches the pre hook
848        self.launchPreHook()
849       
850        # handle starting banner pages without accounting
851        self.BannerSize = 0
852        accountbanner = self.config.getAccountBanner(self.PrinterName)
853        if accountbanner in ["ENDING", "NONE"] :
854            self.handleBanner("starting", 0)
855       
856        if self.Action == "DENY" :
857            self.printInfo(_("Job denied, no accounting will be done."))
858        else :
859            self.printInfo(_("Job accounting begins."))
860            self.deinstallSigTermHandler()
861            self.accounter.beginJob(self.Printer)
862            self.installSigTermHandler()
863       
864        # handle starting banner pages with accounting
865        if accountbanner in ["STARTING", "BOTH"] :
866            if not self.gotSigTerm :
867                self.handleBanner("starting", 1)
868       
869        # pass the job's data to the real backend   
870        if (not self.gotSigTerm) and (self.Action in ["ALLOW", "WARN"]) :
871            retcode = self.printJobDatas()
872        else :       
873            retcode = self.removeJob()
874       
875        # indicate phase change
876        self.exportPhaseInfo("AFTER")
877       
878        # handle ending banner pages with accounting
879        if accountbanner in ["ENDING", "BOTH"] :
880            if not self.gotSigTerm :
881                self.handleBanner("ending", 1)
882       
883        # stops accounting
884        if self.Action == "DENY" :
885            self.printInfo(_("Job denied, no accounting has been done."))
886        else :
887            self.deinstallSigTermHandler()
888            self.accounter.endJob(self.Printer)
889            self.installSigTermHandler()
890            self.printInfo(_("Job accounting ends."))
891       
892        # Do all these database changes within a single transaction   
893        # NB : we don't enclose ALL the changes within a single transaction
894        # because while waiting for the printer to answer its internal page
895        # counter, we would open the door to accounting problems for other
896        # jobs launched by the same user at the same time on other printers.
897        # All the code below doesn't take much time, so it's fine.
898        self.storage.beginTransaction()
899        try :
900            # retrieve the job size   
901            if self.Action == "DENY" :
902                self.JobSize = 0
903                self.printInfo(_("Job size forced to 0 because printing is denied."))
904            else :   
905                self.UserPQuota.resetDenyBannerCounter()
906                self.JobSize = self.accounter.getJobSize(self.Printer)
907                self.sanitizeJobSize()
908                self.JobSize += self.BannerSize
909            self.printInfo(_("Job size : %i") % self.JobSize)
910           
911            # update the quota for the current user on this printer
912            self.printInfo(_("Updating user %s's quota on printer %s") % (self.UserName, self.PrinterName))
913            self.JobPrice = self.UserPQuota.increasePagesUsage(self.JobSize)
914           
915            # adds the current job to history   
916            self.Printer.addJobToHistory(self.JobId, self.User, self.accounter.getLastPageCounter(), \
917                                    self.Action, self.JobSize, self.JobPrice, self.InputFile, \
918                                    self.Title, self.Copies, self.Options, self.ClientHost, \
919                                    self.JobSizeBytes, self.JobMD5Sum, None, self.JobBillingCode)
920            self.printInfo(_("Job added to history."))
921           
922            if self.BillingCode and self.BillingCode.Exists :
923                self.BillingCode.consume(self.JobSize, self.JobPrice)
924                self.printInfo(_("Billing code %s was updated.") % self.BillingCode.BillingCode)
925        except :   
926            self.storage.rollbackTransaction()
927            raise
928        else :   
929            self.storage.commitTransaction()
930           
931        # exports some new environment variables
932        self.exportJobSizeAndPrice()
933       
934        # then re-export user information with new values
935        self.exportUserInfo()
936       
937        # handle ending banner pages without accounting
938        if accountbanner in ["STARTING", "NONE"] :
939            self.handleBanner("ending", 0)
940                   
941        self.launchPostHook()
942           
943        return retcode   
944               
945    def printJobDatas(self) :           
946        """Sends the job's datas to the real backend."""
947        self.logdebug("Sending job's datas to real backend...")
948        if self.InputFile is None :
949            infile = open(self.DataFile, "rb")
950        else :   
951            infile = None
952        self.runOriginalBackend(infile)
953        if self.InputFile is None :
954            infile.close()
955        self.logdebug("Job's datas sent to real backend.")
956       
957    def runOriginalBackend(self, filehandle=None, isBanner=0) :
958        """Launches the original backend."""
959        originalbackend = os.path.join(os.path.split(sys.argv[0])[0], self.RealBackend)
960        if not isBanner :
961            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:]
962        else :   
963            # For banners, we absolutely WANT
964            # to remove any filename from the command line !
965            self.logdebug("It looks like we try to print a banner.")
966            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:6]
967        arguments[2] = self.UserName # in case it was overwritten by external script
968        # TODO : do something about job-billing option, in case it was overwritten as well...
969       
970        self.logdebug("Starting original backend %s with args %s" % (originalbackend, " ".join(['"%s"' % a for a in arguments])))
971        self.regainPriv()   
972        pid = os.fork()
973        self.logdebug("Forked !")
974        if pid == 0 :
975            if filehandle is not None :
976                self.logdebug("Redirecting file handle to real backend's stdin")
977                os.dup2(filehandle.fileno(), 0)
978            try :
979                self.logdebug("Calling execve...")
980                os.execve(originalbackend, arguments, os.environ)
981            except OSError, msg :
982                self.logdebug("execve() failed: %s" % msg)
983            self.logdebug("We shouldn't be there !!!")   
984            os._exit(-1)
985        self.dropPriv()   
986       
987        killed = 0
988        status = -1
989        while status == -1 :
990            try :
991                status = os.waitpid(pid, 0)[1]
992            except OSError, (err, msg) :
993                if (err == 4) and self.gotSigTerm :
994                    os.kill(pid, signal.SIGTERM)
995                    killed = 1
996                   
997        if os.WIFEXITED(status) :
998            status = os.WEXITSTATUS(status)
999            if status :
1000                level = "error"
1001            else :   
1002                level = "info"
1003            self.printInfo("CUPS backend %s returned %d." % \
1004                                     (originalbackend, status), level)
1005            return status
1006        elif not killed :
1007            self.printInfo("CUPS backend %s died abnormally." % \
1008                               originalbackend, "error")
1009            return -1
1010        else :
1011            self.printInfo("CUPS backend %s was killed." % \
1012                               originalbackend, "warn")
1013            return 1
1014       
1015if __name__ == "__main__" :   
1016    # This is a CUPS backend, we should act and die like a CUPS backend
1017    wrapper = CUPSBackend()
1018    if len(sys.argv) == 1 :
1019        print "\n".join(wrapper.discoverOtherBackends())
1020        sys.exit(0)               
1021    elif len(sys.argv) not in (6, 7) :   
1022        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n"\
1023                              % sys.argv[0])
1024        sys.exit(1)
1025    else :   
1026        try :
1027            wrapper.deferredInit()
1028            wrapper.initBackendParameters()
1029            wrapper.saveDatasAndCheckSum()
1030            wrapper.accounter = openAccounter(wrapper)
1031            wrapper.precomputeJobSize()
1032            wrapper.overwriteJobAttributes()
1033            wrapper.exportJobInfo()
1034            retcode = wrapper.mainWork()
1035        except SystemExit, e :   
1036            retcode = e.code
1037        except :   
1038            try :
1039                wrapper.crashed("cupspykota backend failed")
1040            except :   
1041                crashed("cupspykota backend failed")
1042            retcode = 1
1043        wrapper.clean()
1044        sys.exit(retcode)
Note: See TracBrowser for help on using the browser.