root / pykota / trunk / bin / cupspykota @ 2410

Revision 2410, 49.0 kB (checked in by jerome, 19 years ago)

Small fixes after a pychecker pass.

  • 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        if self.InputFile is not None :
305            infile = open(self.InputFile, "rb")
306            mustclose = 1
307        else :   
308            infile = sys.stdin
309        CHUNK = 64*1024         # read 64 Kb at a time
310        dummy = 0
311        sizeread = 0
312        checksum = md5.new()
313        outfile = open(self.DataFile, "wb")   
314        while 1 :
315            data = infile.read(CHUNK) 
316            if not data :
317                break
318            sizeread += len(data)   
319            outfile.write(data)
320            checksum.update(data)   
321            if not (dummy % 32) : # Only display every 2 Mb
322                self.logdebug("%s bytes saved..." % sizeread)
323            dummy += 1   
324        outfile.close()
325        if mustclose :   
326            infile.close()
327           
328        self.JobSizeBytes = sizeread   
329        self.JobMD5Sum = checksum.hexdigest()
330       
331        self.logdebug("JobSizeBytes : %s" % self.JobSizeBytes)
332        self.logdebug("JobMD5Sum : %s" % self.JobMD5Sum)
333        self.logdebug("Data stream duplicated into %s" % self.DataFile)
334           
335    def clean(self) :
336        """Cleans up the place."""
337        self.logdebug("Cleaning up...")
338        self.deinstallSigTermHandler()
339        if not self.config.getPrinterKeepFiles(self.PrinterName) :
340            try :
341                self.logdebug("Work file %s will be deleted." % self.DataFile)
342            except AttributeError :   
343                pass
344            else :   
345                os.remove(self.DataFile)
346                self.logdebug("Work file %s has been deleted." % self.DataFile)
347        else :   
348            self.logdebug("Work file %s will be kept." % self.DataFile)
349        PyKotaTool.clean(self)   
350        self.logdebug("Clean.")
351           
352    def precomputeJobSize(self) :   
353        """Computes the job size with a software method."""
354        self.logdebug("Precomputing job's size...")
355        jobsize = 0
356        if self.JobSizeBytes :
357            try :
358                from pkpgpdls import analyzer, pdlparser
359            except ImportError :   
360                self.printInfo("pkpgcounter is now distributed separately, please grab it from http://www.librelogiciel.com/software/pkpgcounter/action_Download", "error")
361                self.printInfo("Precomputed job size will be forced to 0 pages.", "error")
362            else :     
363                infile = open(self.DataFile, "rb")
364                try :
365                    parser = analyzer.PDLAnalyzer(infile)
366                    jobsize = parser.getJobSize()
367                except pdlparser.PDLParserError, msg :   
368                    # Here we just log the failure, but
369                    # we finally ignore it and return 0 since this
370                    # computation is just an indication of what the
371                    # job's size MAY be.
372                    self.printInfo(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
373                else :   
374                    if self.InputFile is not None :
375                        # when a filename is passed as an argument, the backend
376                        # must generate the correct number of copies.
377                        jobsize *= self.Copies
378                infile.close()       
379        self.softwareJobSize = jobsize
380        self.logdebug("Precomputed job's size is %s pages." % self.softwareJobSize)
381       
382    def precomputeJobPrice(self) :   
383        """Precomputes the job price with a software method."""
384        self.logdebug("Precomputing job's price...")
385        self.softwareJobPrice = self.UserPQuota.computeJobPrice(self.softwareJobSize)
386        self.logdebug("Precomputed job's price is %.3f credits." \
387                                   % self.softwareJobPrice)
388       
389    def getCupsConfigDirectives(self, directives=[]) :
390        """Retrieves some CUPS directives from its configuration file.
391       
392           Returns a mapping with lowercased directives as keys and
393           their setting as values.
394        """
395        self.logdebug("Parsing CUPS' configuration file...")
396        dirvalues = {} 
397        cupsroot = os.environ.get("CUPS_SERVERROOT", "/etc/cups")
398        cupsdconf = os.path.join(cupsroot, "cupsd.conf")
399        try :
400            conffile = open(cupsdconf, "r")
401        except IOError :   
402            raise PyKotaToolError, "Unable to open %s" % cupsdconf
403        else :   
404            for line in conffile.readlines() :
405                linecopy = line.strip().lower()
406                for di in [d.lower() for d in directives] :
407                    if linecopy.startswith("%s " % di) :
408                        try :
409                            val = line.split()[1]
410                        except :   
411                            pass # ignore errors, we take the last value in any case.
412                        else :   
413                            dirvalues[di] = val
414            conffile.close()           
415        self.logdebug("CUPS' configuration file parsed successfully.")
416        return dirvalues       
417           
418    def parseIPPRequestFile(self) :       
419        """Parses the IPP message file and returns a tuple (filename, parsedvalue)."""
420        self.logdebug("Parsing IPP request file...")
421       
422        class DummyClass :
423            operation_attributes = {}
424            job_attributes = {}
425           
426        ippmessage = DummyClass() # in case the code below fails
427       
428        self.regainPriv()
429        cupsdconf = self.getCupsConfigDirectives(["RequestRoot"])
430        requestroot = cupsdconf.get("requestroot", "/var/spool/cups")
431        if (len(self.JobId) < 5) and self.JobId.isdigit() :
432            ippmessagefile = "c%05i" % int(self.JobId)
433        else :   
434            ippmessagefile = "c%s" % self.JobId
435        ippmessagefile = os.path.join(requestroot, ippmessagefile)
436        try :
437            ippdatafile = open(ippmessagefile)
438        except :   
439            self.logdebug("Unable to open IPP request file %s" % ippmessagefile)
440        else :   
441            self.logdebug("Parsing of IPP request file %s begins." % ippmessagefile)
442            try :
443                ippmessage = IPPRequest(ippdatafile.read())
444                ippmessage.parse()
445            except IPPError, msg :   
446                self.printInfo("Error while parsing %s : %s" \
447                                      % (ippmessagefile, msg), "warn")
448            else :   
449                self.logdebug("Parsing of IPP request file %s ends." \
450                                       % ippmessagefile)
451            ippdatafile.close()
452        self.dropPriv()
453        self.logdebug("IPP request file parsed successfully.")
454        return (ippmessagefile, ippmessage)
455               
456    def exportJobInfo(self) :   
457        """Exports the actual job's attributes to the environment."""
458        self.logdebug("Exporting job information to the environment...")
459        os.environ["DEVICE_URI"] = self.DeviceURI       # WARNING !
460        os.environ["PYKOTAPRINTERNAME"] = self.PrinterName
461        os.environ["PYKOTADIRECTORY"] = self.Directory
462        os.environ["PYKOTADATAFILE"] = self.DataFile
463        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.JobSizeBytes)
464        os.environ["PYKOTAMD5SUM"] = self.JobMD5Sum
465        os.environ["PYKOTAJOBORIGINATINGHOSTNAME"] = self.ClientHost or ""
466        os.environ["PYKOTAJOBID"] = self.JobId
467        os.environ["PYKOTAUSERNAME"] = self.UserName
468        os.environ["PYKOTATITLE"] = self.Title
469        os.environ["PYKOTACOPIES"] = str(self.Copies)
470        os.environ["PYKOTAOPTIONS"] = self.Options
471        os.environ["PYKOTAFILENAME"] = self.InputFile or ""
472        os.environ["PYKOTAJOBBILLING"] = self.JobBillingCode or ""
473        os.environ["PYKOTACONTROLFILE"] = self.ControlFile
474        os.environ["PYKOTAPRINTERHOSTNAME"] = self.PrinterHostName
475        os.environ["PYKOTAPRECOMPUTEDJOBSIZE"] = str(self.softwareJobSize)
476        self.logdebug("Environment updated.")
477       
478    def exportUserInfo(self) :
479        """Exports user information to the environment."""
480        self.logdebug("Exporting user information to the environment...")
481        os.environ["PYKOTAOVERCHARGE"] = str(self.User.OverCharge)
482        os.environ["PYKOTALIMITBY"] = str(self.User.LimitBy)
483        os.environ["PYKOTABALANCE"] = str(self.User.AccountBalance or 0.0)
484        os.environ["PYKOTALIFETIMEPAID"] = str(self.User.LifeTimePaid or 0.0)
485       
486        os.environ["PYKOTAPAGECOUNTER"] = str(self.UserPQuota.PageCounter or 0)
487        os.environ["PYKOTALIFEPAGECOUNTER"] = str(self.UserPQuota.LifePageCounter or 0)
488        os.environ["PYKOTASOFTLIMIT"] = str(self.UserPQuota.SoftLimit)
489        os.environ["PYKOTAHARDLIMIT"] = str(self.UserPQuota.HardLimit)
490        os.environ["PYKOTADATELIMIT"] = str(self.UserPQuota.DateLimit)
491        os.environ["PYKOTAWARNCOUNT"] = str(self.UserPQuota.WarnCount)
492       
493        # TODO : move this elsewhere once software accounting is done only once.
494        os.environ["PYKOTAPRECOMPUTEDJOBPRICE"] = str(self.softwareJobPrice)
495       
496        self.logdebug("Environment updated.")
497       
498    def exportPrinterInfo(self) :
499        """Exports printer information to the environment."""
500        self.logdebug("Exporting printer information to the environment...")
501        # exports the list of printers groups the current
502        # printer is a member of
503        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(self.Printer)])
504        self.logdebug("Environment updated.")
505       
506    def exportPhaseInfo(self, phase) :
507        """Exports phase information to the environment."""
508        self.logdebug("Exporting phase information [%s] to the environment..." % phase)
509        os.environ["PYKOTAPHASE"] = phase
510        self.logdebug("Environment updated.")
511       
512    def exportJobSizeAndPrice(self) :
513        """Exports job's size and price information to the environment."""
514        self.logdebug("Exporting job's size and price information to the environment...")
515        os.environ["PYKOTAJOBSIZE"] = str(self.JobSize)
516        os.environ["PYKOTAJOBPRICE"] = str(self.JobPrice)
517        self.logdebug("Environment updated.")
518       
519    def acceptJob(self) :       
520        """Returns the appropriate exit code to tell CUPS all is OK."""
521        return 0
522           
523    def removeJob(self) :           
524        """Returns the appropriate exit code to let CUPS think all is OK.
525       
526           Returning 0 (success) prevents CUPS from stopping the print queue.
527        """   
528        return 0
529       
530    def launchPreHook(self) :
531        """Allows plugging of an external hook before the job gets printed."""
532        prehook = self.config.getPreHook(self.PrinterName)
533        if prehook :
534            self.logdebug("Executing pre-hook [%s]..." % prehook)
535            retcode = os.system(prehook)
536            self.logdebug("pre-hook exited with status %s." % retcode)
537       
538    def launchPostHook(self) :
539        """Allows plugging of an external hook after the job gets printed and/or denied."""
540        posthook = self.config.getPostHook(self.PrinterName)
541        if posthook :
542            self.logdebug("Executing post-hook [%s]..." % posthook)
543            retcode = os.system(posthook)
544            self.logdebug("post-hook exited with status %s." % retcode)
545           
546    def improveMessage(self, message) :       
547        """Improves a message by adding more informations in it if possible."""
548        try :
549            return "%s@%s(%s) => %s" % (self.UserName, \
550                                        self.PrinterName, \
551                                        self.JobId, \
552                                        message)
553        except :                                               
554            return message
555       
556    def logdebug(self, message) :       
557        """Improves the debug message before outputting it."""
558        PyKotaTool.logdebug(self, self.improveMessage(message))
559       
560    def printInfo(self, message, level="info") :       
561        """Improves the informational message before outputting it."""
562        self.logger.log_message(self.improveMessage(message), level)
563   
564    def startingBanner(self) :
565        """Retrieves a starting banner for current printer and returns its content."""
566        self.logdebug("Retrieving starting banner...")
567        self.printBanner(self.config.getStartingBanner(self.PrinterName))
568        self.logdebug("Starting banner retrieved.")
569   
570    def endingBanner(self) :
571        """Retrieves an ending banner for current printer and returns its content."""
572        self.logdebug("Retrieving ending banner...")
573        self.printBanner(self.config.getEndingBanner(self.PrinterName))
574        self.logdebug("Ending banner retrieved.")
575       
576    def printBanner(self, bannerfileorcommand) :
577        """Reads a banner or generates one through an external command.
578       
579           Returns the banner's content in a format which MUST be accepted
580           by the printer.
581        """
582        self.logdebug("Printing banner...")
583        if bannerfileorcommand :
584            if os.access(bannerfileorcommand, os.X_OK) or \
585                  not os.path.isfile(bannerfileorcommand) :
586                self.logdebug("Launching %s to generate a banner." % bannerfileorcommand)
587                child = popen2.Popen3(bannerfileorcommand, capturestderr=1)
588                self.runOriginalBackend(child.fromchild, isBanner=1)
589                child.tochild.close()
590                child.childerr.close()
591                child.fromchild.close()
592                status = child.wait()
593                if os.WIFEXITED(status) :
594                    status = os.WEXITSTATUS(status)
595                self.printInfo(_("Banner generator %s exit code is %s") \
596                                         % (bannerfileorcommand, str(status)))
597            else :
598                self.logdebug("Using %s as the banner." % bannerfileorcommand)
599                try :
600                    fh = open(bannerfileorcommand, 'rb')
601                except IOError, msg :   
602                    self.printInfo("Impossible to open %s : %s" \
603                                       % (bannerfileorcommand, msg), "error")
604                else :   
605                    self.runOriginalBackend(fh, isBanner=1)
606                    fh.close()
607        self.logdebug("Banner printed...")
608               
609    def handleBanner(self, bannertype, withaccounting) :
610        """Handles the banner with or without accounting."""
611        if withaccounting :
612            acc = "with"
613        else :   
614            acc = "without"
615        self.logdebug("Handling %s banner %s accounting..." % (bannertype, acc))
616        if (self.Action == 'DENY') and \
617           (self.UserPQuota.WarnCount >= \
618                            self.config.getMaxDenyBanners(self.PrinterName)) :
619            self.printInfo(_("Banner won't be printed : maximum number of deny banners reached."), \
620                             "warn")
621        else :
622            if self.Action == 'DENY' :
623                self.logdebug("Incrementing the number of deny banners for user %s on printer %s" \
624                                  % (self.UserName, self.PrinterName))
625                self.UserPQuota.incDenyBannerCounter() # increments the warning counter
626                self.exportUserInfo()
627            getattr(self, "%sBanner" % bannertype)()
628            if withaccounting :
629                if self.accounter.isSoftware :
630                    self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
631        self.logdebug("%s banner done." % bannertype.title())
632       
633    def sanitizeJobSize(self) :   
634        """Sanitizes the job's size if needed."""
635        # TODO : there's a difficult to see bug here when banner accounting is activated and hardware accounting is used.
636        self.logdebug("Sanitizing job's size...")
637        if self.softwareJobSize and (self.JobSize != self.softwareJobSize) :
638            self.printInfo(_("Beware : computed job size (%s) != precomputed job size (%s)") % \
639                                       (self.JobSize, self.softwareJobSize), \
640                           "error")
641            (limit, replacement) = self.config.getTrustJobSize(self.PrinterName)
642            if limit is None :
643                self.printInfo(_("The job size will be trusted anyway according to the 'trustjobsize' directive"), "warn")
644            else :
645                if self.JobSize <= limit :
646                    self.printInfo(_("The job size will be trusted because it is inferior to the 'trustjobsize' directive's limit %s") % limit, "warn")
647                else :
648                    self.printInfo(_("The job size will be modified according to the 'trustjobsize' directive : %s") % replacement, "warn")
649                    if replacement == "PRECOMPUTED" :
650                        self.JobSize = self.softwareJobSize
651                    else :   
652                        self.JobSize = replacement
653        self.logdebug("Job's size sanitized.")
654                       
655    def getPrinterUserAndUserPQuota(self) :       
656        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
657       
658           "OK" is returned in the policy if both printer, user and user print quota
659           exist in the Quota Storage.
660           Otherwise, the policy as defined for this printer in pykota.conf is returned.
661           
662           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
663           doesn't exist in the Quota Storage, then an external command is launched, as
664           defined in the external policy for this printer in pykota.conf
665           This external command can do anything, like automatically adding printers
666           or users, for example, and finally extracting printer, user and user print
667           quota from the Quota Storage is tried a second time.
668           
669           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
670           was returned by the external command.
671        """
672        self.logdebug("Retrieving printer, user, and user print quota entry from database...")
673        for passnumber in range(1, 3) :
674            printer = self.storage.getPrinter(self.PrinterName)
675            user = self.storage.getUser(self.UserName)
676            userpquota = self.storage.getUserPQuota(user, printer)
677            if printer.Exists and user.Exists and userpquota.Exists :
678                policy = "OK"
679                break
680            (policy, args) = self.config.getPrinterPolicy(self.PrinterName)
681            if policy == "EXTERNAL" :   
682                commandline = self.formatCommandLine(args, user, printer)
683                if not printer.Exists :
684                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.PrinterName, commandline, self.PrinterName))
685                if not user.Exists :
686                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.UserName, commandline, self.PrinterName))
687                if not userpquota.Exists :
688                    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))
689                if os.system(commandline) :
690                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.PrinterName), "error")
691                    policy = "EXTERNALERROR"
692                    break
693            else :       
694                if not printer.Exists :
695                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.PrinterName, policy))
696                if not user.Exists :
697                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.UserName, policy, self.PrinterName))
698                if not userpquota.Exists :
699                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.UserName, self.PrinterName, policy))
700                break
701               
702        if policy == "EXTERNAL" :   
703            if not printer.Exists :
704                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.PrinterName)
705            if not user.Exists :
706                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.UserName, self.PrinterName))
707            if not userpquota.Exists :
708                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.UserName, self.PrinterName))
709        self.Policy = policy         
710        self.Printer = printer
711        self.User = user
712        self.UserPQuota = userpquota
713        self.logdebug("Retrieval of printer, user and user print quota entry done.")
714       
715    def getBillingCode(self) :   
716        """Extracts the billing code from the database.
717         
718           An optional script is launched to notify the user when
719           the billing code is unknown and PyKota was configured to
720           deny printing in this case.
721        """
722        self.logdebug("Retrieving billing code information from the database...")
723        self.BillingCode = None
724        if self.JobBillingCode :
725            self.BillingCode = self.storage.getBillingCode(self.JobBillingCode)
726            if self.BillingCode.Exists :
727                self.logdebug("Billing code [%s] found in database." % self.JobBillingCode)
728            else :
729                msg = "Unknown billing code [%s] : " % self.JobBillingCode
730                (newaction, script) = self.config.getUnknownBillingCode(self.PrinterName)
731                if newaction == "CREATE" :
732                    self.logdebug(msg + "will be created.")
733                    self.BillingCode = self.storage.addBillingCode(self.JobBillingCode)
734                    if self.BillingCode.Exists :
735                        self.logdebug(msg + "has been created.")
736                    else :   
737                        self.printInfo(msg + "couldn't be created.", "error")
738                else :   
739                    self.logdebug(msg + "job will be denied.")
740                    self.Action = newaction
741                    if script is not None : 
742                        self.logdebug(msg + "launching subprocess [%s] to notify user." % script)
743                        os.system(script)
744        self.logdebug("Retrieval of billing code information done.")
745       
746    def checkIfDupe(self) :   
747        """Checks if the job is a dupe, and handles the situation."""
748        self.logdebug("Checking if the job is a dupe...")
749        denyduplicates = self.config.getDenyDuplicates(self.PrinterName)
750        if not denyduplicates :
751            self.logdebug("We don't care about dupes after all.")
752        elif self.Printer.LastJob.Exists \
753             and (self.Printer.LastJob.UserName == self.UserName) \
754             and (self.Printer.LastJob.JobMD5Sum == self.JobMD5Sum) :
755            # TODO : use the current user's last job instead of 
756            # TODO : the current printer's last job. This would be
757            # TODO : better but requires an additional database query
758            # TODO : with SQL, and is much more complex with the
759            # TODO : actual LDAP schema. Maybe this is not very
760            # TODO : important, because usually dupes are rapidly sucessive.
761            msg = _("Job is a dupe")
762            if denyduplicates == 1 :
763                self.printInfo("%s : %s." % (msg, _("Printing is denied by configuration")), "warn")
764                self.Action = "DENY"
765            else :   
766                self.logdebug("Launching subprocess [%s] to see if dupes should be allowed or not." % denyduplicates)
767                fanswer = os.popen(denyduplicates, "r")
768                self.Action = fanswer.read().strip().upper()
769                fanswer.close()
770                if self.Action == "DENY" :     
771                    self.printInfo("%s : %s." % (msg, _("Subprocess denied printing of a dupe")), "warn")
772                else :   
773                    self.printInfo("%s : %s." % (msg, _("Subprocess allowed printing of a dupe")), "warn")
774        else :           
775            self.logdebug("Job doesn't seem to be a dupe.")
776        self.logdebug("Checking if the job is a dupe done.")
777       
778    def mainWork(self) :   
779        """Main work is done here."""
780        if not self.JobSizeBytes :
781            # if no data to pass to real backend, probably a filter
782            # higher in the chain failed because of a misconfiguration.
783            # we deny the job in this case (nothing to print anyway)
784            self.printInfo(_("Job contains no data. Printing is denied."), "error")
785            return self.removeJob()
786           
787        self.getPrinterUserAndUserPQuota()
788        if self.Policy == "EXTERNALERROR" :
789            # Policy was 'EXTERNAL' and the external command returned an error code
790            return self.removeJob()
791        elif self.Policy == "EXTERNAL" :
792            # Policy was 'EXTERNAL' and the external command wasn't able
793            # to add either the printer, user or user print quota
794            return self.removeJob()
795        elif self.Policy == "DENY" :   
796            # Either printer, user or user print quota doesn't exist,
797            # and the job should be rejected.
798            return self.removeJob()
799        elif self.Policy == "ALLOW" :
800            # ALLOW means : Either printer, user or user print quota doesn't exist,
801            #               but the job should be allowed anyway.
802            self.printInfo(_("Job allowed by printer policy. No accounting will be done."), "warn")
803            return self.printJobDatas()
804        elif self.Policy == "OK" :
805            # OK means : Both printer, user and user print quota exist, job should
806            #            be allowed if current user is allowed to print on this printer
807            return self.doWork()
808        else :   
809            self.printInfo(_("Invalid policy %s for printer %s") % (self.Policy, self.PrinterName), "error")
810            return self.removeJob()
811   
812    def doWork(self) :   
813        """The accounting work is done here."""
814        self.precomputeJobPrice()
815        self.exportUserInfo()
816        self.exportPrinterInfo()
817        self.exportPhaseInfo("BEFORE")
818       
819        if self.Action != "DENY" :
820            # If printing is still allowed at this time, we
821            # need to extract the billing code information from the database.
822            # No need to do this if the job is denied, this way we
823            # save some database queries.
824            self.getBillingCode()
825           
826        if self.Action != "DENY" :
827            # If printing is still allowed at this time, we
828            # need to check if the job is a dupe or not, and what to do then.
829            # No need to do this if the job is denied, this way we
830            # save some database queries.
831            self.checkIfDupe()
832                   
833        if self.Action != "DENY" :
834            # If printing is still allowed at this time, we
835            # need to check the user's print quota on the current printer.
836            # No need to do this if the job is denied, this way we
837            # save some database queries.
838            self.logdebug("Checking user %s print quota entry on printer %s" \
839                                    % (self.UserName, self.PrinterName))
840            self.Action = self.warnUserPQuota(self.UserPQuota)
841           
842        # exports some new environment variables
843        os.environ["PYKOTAACTION"] = str(self.Action)
844       
845        # launches the pre hook
846        self.launchPreHook()
847       
848        # handle starting banner pages without accounting
849        self.BannerSize = 0
850        accountbanner = self.config.getAccountBanner(self.PrinterName)
851        if accountbanner in ["ENDING", "NONE"] :
852            self.handleBanner("starting", 0)
853       
854        if self.Action == "DENY" :
855            self.printInfo(_("Job denied, no accounting will be done."))
856        else :
857            self.printInfo(_("Job accounting begins."))
858            self.deinstallSigTermHandler()
859            self.accounter.beginJob(self.Printer)
860            self.installSigTermHandler()
861       
862        # handle starting banner pages with accounting
863        if accountbanner in ["STARTING", "BOTH"] :
864            if not self.gotSigTerm :
865                self.handleBanner("starting", 1)
866       
867        # pass the job's data to the real backend   
868        if (not self.gotSigTerm) and (self.Action in ["ALLOW", "WARN"]) :
869            retcode = self.printJobDatas()
870        else :       
871            retcode = self.removeJob()
872       
873        # indicate phase change
874        self.exportPhaseInfo("AFTER")
875       
876        # handle ending banner pages with accounting
877        if accountbanner in ["ENDING", "BOTH"] :
878            if not self.gotSigTerm :
879                self.handleBanner("ending", 1)
880       
881        # stops accounting
882        if self.Action == "DENY" :
883            self.printInfo(_("Job denied, no accounting has been done."))
884        else :
885            self.deinstallSigTermHandler()
886            self.accounter.endJob(self.Printer)
887            self.installSigTermHandler()
888            self.printInfo(_("Job accounting ends."))
889       
890        # Do all these database changes within a single transaction   
891        # NB : we don't enclose ALL the changes within a single transaction
892        # because while waiting for the printer to answer its internal page
893        # counter, we would open the door to accounting problems for other
894        # jobs launched by the same user at the same time on other printers.
895        # All the code below doesn't take much time, so it's fine.
896        self.storage.beginTransaction()
897        try :
898            # retrieve the job size   
899            if self.Action == "DENY" :
900                self.JobSize = 0
901                self.printInfo(_("Job size forced to 0 because printing is denied."))
902            else :   
903                self.UserPQuota.resetDenyBannerCounter()
904                self.JobSize = self.accounter.getJobSize(self.Printer)
905                self.sanitizeJobSize()
906                self.JobSize += self.BannerSize
907            self.printInfo(_("Job size : %i") % self.JobSize)
908           
909            # update the quota for the current user on this printer
910            self.printInfo(_("Updating user %s's quota on printer %s") % (self.UserName, self.PrinterName))
911            self.JobPrice = self.UserPQuota.increasePagesUsage(self.JobSize)
912           
913            # adds the current job to history   
914            self.Printer.addJobToHistory(self.JobId, self.User, self.accounter.getLastPageCounter(), \
915                                    self.Action, self.JobSize, self.JobPrice, self.InputFile, \
916                                    self.Title, self.Copies, self.Options, self.ClientHost, \
917                                    self.JobSizeBytes, self.JobMD5Sum, None, self.JobBillingCode)
918            self.printInfo(_("Job added to history."))
919           
920            if self.BillingCode and self.BillingCode.Exists :
921                self.BillingCode.consume(self.JobSize, self.JobPrice)
922                self.printInfo(_("Billing code %s was updated.") % self.BillingCode.BillingCode)
923        except :   
924            self.storage.rollbackTransaction()
925            raise
926        else :   
927            self.storage.commitTransaction()
928           
929        # exports some new environment variables
930        self.exportJobSizeAndPrice()
931       
932        # then re-export user information with new values
933        self.exportUserInfo()
934       
935        # handle ending banner pages without accounting
936        if accountbanner in ["STARTING", "NONE"] :
937            self.handleBanner("ending", 0)
938                   
939        self.launchPostHook()
940           
941        return retcode   
942               
943    def printJobDatas(self) :           
944        """Sends the job's datas to the real backend."""
945        self.logdebug("Sending job's datas to real backend...")
946        if self.InputFile is None :
947            infile = open(self.DataFile, "rb")
948        else :   
949            infile = None
950        self.runOriginalBackend(infile)
951        if self.InputFile is None :
952            infile.close()
953        self.logdebug("Job's datas sent to real backend.")
954       
955    def runOriginalBackend(self, filehandle=None, isBanner=0) :
956        """Launches the original backend."""
957        originalbackend = os.path.join(os.path.split(sys.argv[0])[0], self.RealBackend)
958        if not isBanner :
959            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:]
960        else :   
961            # For banners, we absolutely WANT
962            # to remove any filename from the command line !
963            self.logdebug("It looks like we try to print a banner.")
964            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:6]
965        arguments[2] = self.UserName # in case it was overwritten by external script
966        # TODO : do something about job-billing option, in case it was overwritten as well...
967       
968        self.logdebug("Starting original backend %s with args %s" % (originalbackend, " ".join(['"%s"' % a for a in arguments])))
969        self.regainPriv()   
970        pid = os.fork()
971        self.logdebug("Forked !")
972        if pid == 0 :
973            if filehandle is not None :
974                self.logdebug("Redirecting file handle to real backend's stdin")
975                os.dup2(filehandle.fileno(), 0)
976            try :
977                self.logdebug("Calling execve...")
978                os.execve(originalbackend, arguments, os.environ)
979            except OSError, msg :
980                self.logdebug("execve() failed: %s" % msg)
981            self.logdebug("We shouldn't be there !!!")   
982            os._exit(-1)
983        self.dropPriv()   
984       
985        killed = 0
986        status = -1
987        while status == -1 :
988            try :
989                status = os.waitpid(pid, 0)[1]
990            except OSError, (err, msg) :
991                if (err == 4) and self.gotSigTerm :
992                    os.kill(pid, signal.SIGTERM)
993                    killed = 1
994                   
995        if os.WIFEXITED(status) :
996            status = os.WEXITSTATUS(status)
997            if status :
998                level = "error"
999            else :   
1000                level = "info"
1001            self.printInfo("CUPS backend %s returned %d." % \
1002                                     (originalbackend, status), level)
1003            return status
1004        elif not killed :
1005            self.printInfo("CUPS backend %s died abnormally." % \
1006                               originalbackend, "error")
1007            return -1
1008        else :
1009            self.printInfo("CUPS backend %s was killed." % \
1010                               originalbackend, "warn")
1011            return 1
1012       
1013if __name__ == "__main__" :   
1014    # This is a CUPS backend, we should act and die like a CUPS backend
1015    wrapper = CUPSBackend()
1016    if len(sys.argv) == 1 :
1017        print "\n".join(wrapper.discoverOtherBackends())
1018        sys.exit(0)               
1019    elif len(sys.argv) not in (6, 7) :   
1020        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n"\
1021                              % sys.argv[0])
1022        sys.exit(1)
1023    else :   
1024        try :
1025            wrapper.deferredInit()
1026            wrapper.initBackendParameters()
1027            wrapper.saveDatasAndCheckSum()
1028            wrapper.accounter = openAccounter(wrapper)
1029            wrapper.precomputeJobSize()
1030            wrapper.overwriteJobAttributes()
1031            wrapper.exportJobInfo()
1032            retcode = wrapper.mainWork()
1033        except SystemExit, e :   
1034            retcode = e.code
1035        except :   
1036            try :
1037                wrapper.crashed("cupspykota backend failed")
1038            except :   
1039                crashed("cupspykota backend failed")
1040            retcode = 1
1041        wrapper.clean()
1042        sys.exit(retcode)
Note: See TracBrowser for help on using the browser.