root / pykota / trunk / bin / cupspykota @ 2453

Revision 2452, 50.4 kB (checked in by jerome, 19 years ago)

Upgraded database schema.
Added -i | --ingroups command line option to repykota.
Added -C | --comment command line option to edpykota.
Added 'noquota', 'noprint', and 'nochange' as switches for edpykota's
-l | --limitby command line option.
Severity : entirely new features, in need of testers :-)

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