root / pykota / trunk / bin / cupspykota @ 2919

Revision 2919, 66.6 kB (checked in by jerome, 18 years ago)

Added support for pkipplib v0.06

  • 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, 2006 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 fcntl
30import time
31import errno
32import tempfile
33import popen2
34import cStringIO
35import shlex
36import signal
37import md5
38import fnmatch
39import pwd
40import socket
41import smtplib
42from email.MIMEText import MIMEText
43from email.Header import Header
44
45from mx import DateTime
46
47from pykota.tool import PyKotaTool, PyKotaToolError, crashed
48from pykota.accounter import openAccounter
49# TODO : remove the three lines below and the code which handles
50# TODO : them in a future release.
51from pykota.ipp import IPPRequest as oldIPPRequest
52from pykota.ipp import IPPError as oldIPPError
53
54try :
55    from pkipplib import pkipplib
56except ImportError :       
57    haspkipplib = False
58else :   
59    haspkipplib = True
60   
61class FakeObject :       
62    """Fake object."""
63    def __init__(self, name) :
64        """Fake init."""
65        self.Name = name
66       
67class FakePrinter(FakeObject) :       
68    """Fake printer instance."""
69    pass
70   
71class FakeUser(FakeObject) :   
72    """Fake user instance."""
73    def __init__(self, name) :
74        """Fake init."""
75        self.Email = name
76        FakeObject.__init__(self, name)
77   
78class CUPSBackend(PyKotaTool) :
79    """Base class for tools with no database access."""
80    def __init__(self) :
81        """Initializes the CUPS backend wrapper."""
82        PyKotaTool.__init__(self)
83        signal.signal(signal.SIGTERM, signal.SIG_IGN)
84        signal.signal(signal.SIGPIPE, signal.SIG_IGN)
85        self.MyName = "PyKota"
86        self.myname = "cupspykota"
87        self.pid = os.getpid()
88        self.DataFile = None
89       
90    def deferredInit(self) :   
91        """Deferred initialization."""
92        PyKotaTool.deferredInit(self)
93        self.gotSigTerm = 0
94        self.disableSigInt()
95        self.installSigTermHandler()
96       
97    def sigtermHandler(self, signum, frame) :
98        """Sets an attribute whenever SIGTERM is received."""
99        self.gotSigTerm = 1
100        self.printInfo(_("SIGTERM received, job %s cancelled.") % self.JobId)
101        os.environ["PYKOTASTATUS"] = "CANCELLED"
102       
103    def deinstallSigTermHandler(self) :           
104        """Deinstalls the SIGTERM handler."""
105        self.logdebug("Deinstalling SIGTERM handler...")
106        signal.signal(signal.SIGTERM, signal.SIG_IGN)
107        self.logdebug("SIGTERM handler deinstalled.")
108       
109    def installSigTermHandler(self) :           
110        """Installs the SIGTERM handler."""
111        self.logdebug("Installing SIGTERM handler...")
112        signal.signal(signal.SIGTERM, self.sigtermHandler)
113        self.logdebug("SIGTERM handler installed.")
114       
115    def disableSigInt(self) :   
116        """Disables the SIGINT signal (which raises KeyboardInterrupt)."""
117        self.logdebug("Disabling SIGINT...")
118        self.oldSigIntHandler = signal.signal(signal.SIGINT, signal.SIG_IGN)
119        self.logdebug("SIGINT disabled.")
120       
121    def enableSigInt(self) :   
122        """Enables the SIGINT signal (which raises KeyboardInterrupt)."""
123        self.logdebug("Enabling SIGINT...")
124        signal.signal(signal.SIGINT, self.oldSigIntHandler)
125        self.logdebug("SIGINT enabled.")
126       
127    def waitForLock(self) :   
128        """Waits until we can acquire the lock file."""
129        self.logdebug("Waiting for lock...")
130        stillrunning = True
131        while 1 :
132            self.lockfile = open(self.lockfilename, "a+")
133            fcntl.lockf(self.lockfile, fcntl.LOCK_EX) # Will wait until lock available
134            self.lockfile.seek(0, 0)     
135            try : 
136                oldpid = int(self.lockfile.readlines()[-1].strip())
137            except :   
138                stillrunning = False
139            else :   
140                try :
141                    os.kill(oldpid, 0)
142                except OSError :   
143                    stillrunning = False
144            if not stillrunning :       
145                self.lockfile.truncate(0)
146                self.lockfile.seek(0, 0)
147                self.lockfile.write("%s\n" % self.pid)
148                self.lockfile.flush()
149                break
150            else :   
151                time.sleep(0.1)
152        self.logdebug("Lock acquired.")
153                   
154    def discoverOtherBackends(self) :   
155        """Discovers the other CUPS backends.
156       
157           Executes each existing backend in turn in device enumeration mode.
158           Returns the list of available backends.
159        """
160        # Unfortunately this method can't output any debug information
161        # to stdout or stderr, else CUPS considers that the device is
162        # not available.
163        available = []
164        (directory, myname) = os.path.split(sys.argv[0])
165        if not directory :
166            directory = "./"
167        tmpdir = tempfile.gettempdir()
168        lockfilename = os.path.join(tmpdir, "%s..LCK" % myname)
169        if os.path.exists(lockfilename) :
170            lockfile = open(lockfilename, "r")
171            pid = int(lockfile.read())
172            lockfile.close()
173            try :
174                # see if the pid contained in the lock file is still running
175                os.kill(pid, 0)
176            except OSError, err :
177                if err.errno != errno.EPERM :
178                    # process doesn't exist anymore
179                    os.remove(lockfilename)
180           
181        if not os.path.exists(lockfilename) :
182            lockfile = open(lockfilename, "w")
183            lockfile.write("%i" % self.pid)
184            lockfile.close()
185            allbackends = [ os.path.join(directory, b) \
186                                for b in os.listdir(directory) \
187                                    if os.access(os.path.join(directory, b), os.X_OK) \
188                                        and (b != myname)] 
189            for backend in allbackends :                           
190                answer = os.popen(backend, "r")
191                try :
192                    devices = [line.strip() for line in answer.readlines()]
193                except :   
194                    devices = []
195                status = answer.close()
196                if status is None :
197                    for d in devices :
198                        # each line is of the form :
199                        # 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
200                        # so we have to decompose it carefully
201                        fdevice = cStringIO.StringIO(d)
202                        tokenizer = shlex.shlex(fdevice)
203                        tokenizer.wordchars = tokenizer.wordchars + \
204                                                        r".:,?!~/\_$*-+={}[]()#"
205                        arguments = []
206                        while 1 :
207                            token = tokenizer.get_token()
208                            if token :
209                                arguments.append(token)
210                            else :
211                                break
212                        fdevice.close()
213                        try :
214                            (devicetype, device, name, fullname) = arguments
215                        except ValueError :   
216                            pass    # ignore this 'bizarre' device
217                        else :   
218                            if name.startswith('"') and name.endswith('"') :
219                                name = name[1:-1]
220                            if fullname.startswith('"') and fullname.endswith('"') :
221                                fullname = fullname[1:-1]
222                            available.append('%s %s:%s "%s+%s" "%s managed %s"' \
223                                                 % (devicetype, self.myname, \
224                                                    device, self.MyName, \
225                                                    name, self.MyName, \
226                                                    fullname))
227            os.remove(lockfilename)
228        available.append('direct %s:// "%s+Nothing" "%s managed Virtual Printer"' \
229                             % (self.myname, self.MyName, self.MyName))
230        return available
231                       
232    def initBackendParameters(self) :   
233        """Initializes the backend's attributes."""
234        # check that the DEVICE_URI environment variable's value is
235        # prefixed with self.myname otherwise don't touch it.
236        # If this is the case, we have to remove the prefix from
237        # the environment before launching the real backend
238        self.logdebug("Initializing backend...")
239        muststartwith = "%s:" % self.myname
240        device_uri = os.environ.get("DEVICE_URI", "")
241        if device_uri.startswith(muststartwith) :
242            fulldevice_uri = device_uri[:]
243            device_uri = fulldevice_uri[len(muststartwith):]
244            for i in range(2) :
245                if device_uri.startswith("/") : 
246                    device_uri = device_uri[1:]
247        try :
248            (backend, destination) = device_uri.split(":", 1) 
249        except ValueError :   
250            if not device_uri :
251                self.logdebug("Not attached to an existing print queue.")
252                backend = ""
253                printerhostname = ""
254            else :   
255                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
256        else :       
257            while destination.startswith("/") :
258                destination = destination[1:]
259            checkauth = destination.split("@", 1)   
260            if len(checkauth) == 2 :
261                destination = checkauth[1]
262            printerhostname = destination.split("/")[0].split(":")[0]
263       
264        self.Action = "ALLOW"   # job allowed by default
265        self.Reason = None
266        self.JobId = sys.argv[1].strip()
267        # use CUPS' user when printing test pages from CUPS' web interface
268        self.UserName = sys.argv[2].strip() or self.originalUserName or pwd.getpwuid(os.geteuid())[0]
269        self.Title = sys.argv[3].strip()
270        self.Copies = int(sys.argv[4].strip())
271        self.Options = sys.argv[5].strip()
272        if len(sys.argv) == 7 :
273            self.InputFile = sys.argv[6] # read job's datas from file
274        else :   
275            self.InputFile = None        # read job's datas from stdin
276           
277        self.PrinterHostName = printerhostname   
278        self.RealBackend = backend
279        self.DeviceURI = device_uri
280        self.PrinterName = os.environ.get("PRINTER", "")
281        self.Directory = self.config.getPrinterDirectory(self.PrinterName)
282        self.DataFile = os.path.join(self.Directory, "%s-%s-%s-%s" % \
283                   (self.myname, self.PrinterName, self.UserName, self.JobId))
284       
285        connerror = False
286        if haspkipplib :
287            self.ControlFile = "NotUsedAnymore"
288            self.logdebug("Querying CUPS server...")
289            cupsserver = pkipplib.CUPS() # TODO : username and password and/or encryption
290            answer = cupsserver.getJobAttributes(self.JobId)
291            if answer is None :
292                self.printInfo(_("Network error while querying the CUPS server : %s") \
293                                          % cupsserver.lastErrorMessage, "error")
294                connerror = True                       
295            else :   
296                self.logdebug("CUPS server answered without error.")
297                try :
298                    john = answer.job["job-originating-host-name"]
299                except KeyError :   
300                    try :
301                        john = answer.operation["job-originating-host-name"]
302                    except KeyError :   
303                        john = (None, None)
304                try :       
305                    jbing = answer.job["job-billing"]
306                except KeyError :   
307                    jbing = (None, None)
308                   
309        if connerror or not haspkipplib :       
310            (ippfilename, ippmessage) = self.parseIPPRequestFile()
311            self.ControlFile = ippfilename
312            john = ippmessage.operation_attributes.get("job-originating-host-name", \
313                   ippmessage.job_attributes.get("job-originating-host-name", \
314                   (None, None)))
315            jbing = ippmessage.job_attributes.get("job-billing", (None, None))
316               
317        if type(john) == type([]) : 
318            john = john[-1]
319        (chtype, self.ClientHost) = john 
320        if type(jbing) == type([]) : 
321            jbing = jbing[-1]
322        (jbtype, self.JobBillingCode) = jbing
323       
324        baselockfilename = self.DeviceURI.replace("/", ".")
325        baselockfilename = baselockfilename.replace(":", ".")
326        baselockfilename = baselockfilename.replace("?", ".")
327        baselockfilename = baselockfilename.replace("&", ".")
328        baselockfilename = baselockfilename.replace("@", ".")
329        self.lockfilename = os.path.join(self.Directory, "%s-%s..LCK" % (self.myname, baselockfilename))
330       
331        self.logdebug("Backend : %s" % self.RealBackend)
332        self.logdebug("DeviceURI : %s" % self.DeviceURI)
333        self.logdebug("Printername : %s" % self.PrinterName)
334        self.logdebug("Username : %s" % self.UserName)
335        self.logdebug("JobId : %s" % self.JobId)
336        self.logdebug("Title : %s" % self.Title)
337        self.logdebug("Filename : %s" % self.InputFile)
338        self.logdebug("Copies : %s" % self.Copies)
339        self.logdebug("Options : %s" % self.Options)
340        self.logdebug("Directory : %s" % self.Directory) 
341        self.logdebug("DataFile : %s" % self.DataFile)
342        self.logdebug("ControlFile : %s" % self.ControlFile)
343        self.logdebug("JobBillingCode : %s" % self.JobBillingCode)
344        self.logdebug("JobOriginatingHostName : %s" % self.ClientHost)
345       
346        # fakes some entries to allow for external mailto
347        # before real entries are extracted from the database.
348        self.User = FakeUser(self.UserName)
349        self.Printer = FakePrinter(self.PrinterName)
350       
351        self.enableSigInt()
352        self.logdebug("Backend initialized.")
353       
354    def overwriteJobAttributes(self) :
355        """Overwrites some of the job's attributes if needed."""
356        self.logdebug("Sanitizing job's attributes...")
357        # First overwrite the job ticket
358        self.overwriteJobTicket()
359       
360        # do we want to strip out the Samba/Winbind domain name ?
361        separator = self.config.getWinbindSeparator()
362        if separator is not None :
363            self.UserName = self.UserName.split(separator)[-1]
364           
365        # do we want to lowercase usernames ?   
366        if self.config.getUserNameToLower() :
367            self.UserName = self.UserName.lower()
368           
369        # do we want to strip some prefix off of titles ?   
370        stripprefix = self.config.getStripTitle(self.PrinterName)
371        if stripprefix :
372            if fnmatch.fnmatch(self.Title[:len(stripprefix)], stripprefix) :
373                self.logdebug("Prefix [%s] removed from job's title [%s]." \
374                                      % (stripprefix, self.Title))
375                self.Title = self.Title[len(stripprefix):]
376               
377        self.logdebug("Username : %s" % self.UserName)
378        self.logdebug("BillingCode : %s" % self.JobBillingCode)
379        self.logdebug("Title : %s" % self.Title)
380        self.logdebug("Job's attributes sanitizing done.")
381               
382    def didUserConfirm(self) :
383        """Asks for user confirmation through an external script.
384       
385           returns False if the end user wants to cancel the job, else True.
386        """
387        self.logdebug("Checking if we have to ask for user's confirmation...")
388        answer = None                         
389        confirmationcommand = self.config.getAskConfirmation(self.PrinterName)
390        if confirmationcommand :
391            self.logdebug("Launching subprocess [%s] to ask for user confirmation." \
392                                     % confirmationcommand)
393            inputfile = os.popen(confirmationcommand, "r")
394            try :
395                for answer in inputfile.xreadlines() :
396                    answer = answer.strip().upper()
397                    if answer == "CANCEL" :
398                        break
399            except IOError, msg :           
400                self.logdebug("IOError while reading subprocess' output : %s" % msg)
401            inputfile.close()   
402            self.logdebug("User's confirmation received : %s" % (((answer == "CANCEL") and "CANCEL") or "CONTINUE"))
403        else :   
404            self.logdebug("No need to ask for user's confirmation, job processing will continue.")
405        return (answer != "CANCEL")   
406       
407    def overwriteJobTicket(self) :   
408        """Should we overwrite the job's ticket (username and billingcode) ?"""
409        self.logdebug("Checking if we need to overwrite the job ticket...")
410        jobticketcommand = self.config.getOverwriteJobTicket(self.PrinterName)
411        if jobticketcommand :
412            username = billingcode = action = None
413            self.logdebug("Launching subprocess [%s] to overwrite the job ticket." \
414                                     % jobticketcommand)
415            self.regainPriv()                         
416            inputfile = os.popen(jobticketcommand, "r")
417            try :
418                for line in inputfile.xreadlines() :
419                    line = line.strip()
420                    if line in ("DENY", "AUTH=NO", "AUTH=IMPOSSIBLE") :
421                        self.logdebug("Seen %s command." % line)
422                        action = "DENY"
423                    elif line == "CANCEL" :
424                        self.logdebug("Seen CANCEL command.")
425                        action = "CANCEL"
426                    elif line.startswith("USERNAME=") :   
427                        username = line.split("=", 1)[1].strip()
428                        self.logdebug("Seen new username [%s]" % username)
429                    elif line.startswith("BILLINGCODE=") :   
430                        billingcode = line.split("=", 1)[1].strip()
431                        self.logdebug("Seen new billing code [%s]" % billingcode)
432            except IOError, msg :           
433                self.logdebug("IOError while reading subprocess' output : %s" % msg)
434            inputfile.close()   
435            self.dropPriv()
436           
437            # now overwrite the job's ticket if new data was supplied
438            if action == "DENY" :
439                self.Action = action
440                self.Reason = _("You are not allowed to print at this time.")
441            elif action == "CANCEL" :
442                self.Action = action
443                self.Reason = _("Print job cancelled.")
444                os.environ["PYKOTASTATUS"] = "CANCELLED"
445            elif username :
446                self.UserName = username # If cancelled or denied, we don't allow username to be overwritten
447            # NB : we overwrite the billing code even if empty   
448            self.JobBillingCode = billingcode 
449        self.logdebug("Job ticket overwriting done.")
450           
451    def saveDatasAndCheckSum(self) :
452        """Saves the input datas into a static file."""
453        self.logdebug("Duplicating data stream into %s" % self.DataFile)
454        mustclose = 0
455        outfile = open(self.DataFile, "wb")   
456        if self.InputFile is not None :
457            self.regainPriv()
458            infile = open(self.InputFile, "rb")
459            mustclose = 1
460        else :   
461            infile = sys.stdin
462        CHUNK = 64*1024         # read 64 Kb at a time
463        dummy = 0
464        sizeread = 0
465        checksum = md5.new()
466        while 1 :
467            data = infile.read(CHUNK) 
468            if not data :
469                break
470            sizeread += len(data)   
471            outfile.write(data)
472            checksum.update(data)   
473            if not (dummy % 32) : # Only display every 2 Mb
474                self.logdebug("%s bytes saved..." % sizeread)
475            dummy += 1   
476        if mustclose :   
477            infile.close()
478            self.dropPriv()
479           
480        outfile.close()
481        self.JobSizeBytes = sizeread   
482        self.JobMD5Sum = checksum.hexdigest()
483       
484        self.logdebug("JobSizeBytes : %s" % self.JobSizeBytes)
485        self.logdebug("JobMD5Sum : %s" % self.JobMD5Sum)
486        self.logdebug("Data stream duplicated into %s" % self.DataFile)
487           
488    def clean(self) :
489        """Cleans up the place."""
490        self.logdebug("Cleaning up...")
491        self.regainPriv()
492        self.deinstallSigTermHandler()
493        if not self.config.getPrinterKeepFiles(self.PrinterName) :
494            self.logdebug("Work file %s will be deleted." % self.DataFile)
495            try :
496                os.remove(self.DataFile)
497            except OSError, msg :
498                self.logdebug("Problem while deleting work file %s : %s" % (self.DataFile, msg))
499            else :
500                self.logdebug("Work file %s has been deleted." % self.DataFile)
501        else :   
502            self.logdebug("Work file %s will be kept." % self.DataFile)
503        PyKotaTool.clean(self)   
504        self.logdebug("Removing lock...")
505        try :
506            fcntl.flock(self.lockfile, fcntl.LOCK_UN)
507            self.lockfile.close()
508            # NB : we don't remove the lock file, since it might already be opened by another process.
509        except :   
510            pass
511        else :   
512            self.logdebug("Lock removed.")
513        self.logdebug("Clean.")
514           
515    def precomputeJobSize(self) :   
516        """Computes the job size with a software method."""
517        self.logdebug("Precomputing job's size...")
518        self.preaccounter.beginJob(None)
519        self.preaccounter.endJob(None)
520        self.softwareJobSize = self.preaccounter.getJobSize(None)
521        self.logdebug("Precomputed job's size is %s pages." % self.softwareJobSize)
522       
523    def precomputeJobPrice(self) :   
524        """Precomputes the job price with a software method."""
525        self.logdebug("Precomputing job's price...")
526        self.softwareJobPrice = self.UserPQuota.computeJobPrice(self.softwareJobSize)
527        self.logdebug("Precomputed job's price is %.3f credits." \
528                                   % self.softwareJobPrice)
529       
530    def getCupsConfigDirectives(self, directives=[]) :
531        """Retrieves some CUPS directives from its configuration file.
532       
533           Returns a mapping with lowercased directives as keys and
534           their setting as values.
535        """
536        self.logdebug("Parsing CUPS' configuration file...")
537        dirvalues = {} 
538        cupsroot = os.environ.get("CUPS_SERVERROOT", "/etc/cups")
539        cupsdconf = os.path.join(cupsroot, "cupsd.conf")
540        try :
541            conffile = open(cupsdconf, "r")
542        except IOError :   
543            raise PyKotaToolError, "Unable to open %s" % cupsdconf
544        else :   
545            for line in conffile.readlines() :
546                linecopy = line.strip().lower()
547                for di in [d.lower() for d in directives] :
548                    if linecopy.startswith("%s " % di) :
549                        try :
550                            val = line.split()[1]
551                        except :   
552                            pass # ignore errors, we take the last value in any case.
553                        else :   
554                            dirvalues[di] = val
555            conffile.close()           
556        self.logdebug("CUPS' configuration file parsed successfully.")
557        return dirvalues       
558           
559    def parseIPPRequestFile(self) :       
560        """Parses the IPP message file and returns a tuple (filename, parsedvalue)."""
561        self.logdebug("Parsing IPP request file...")
562       
563        class DummyClass :
564            """Class used to avoid errors."""
565            operation_attributes = {}
566            job_attributes = {}
567           
568        ippmessage = DummyClass() # in case the code below fails
569       
570        self.regainPriv()
571        cupsdconf = self.getCupsConfigDirectives(["RequestRoot"])
572        requestroot = cupsdconf.get("requestroot", "/var/spool/cups")
573        if (len(self.JobId) < 5) and self.JobId.isdigit() :
574            ippmessagefile = "c%05i" % int(self.JobId)
575        else :   
576            ippmessagefile = "c%s" % self.JobId
577        ippmessagefile = os.path.join(requestroot, ippmessagefile)
578        try :
579            ippdatafile = open(ippmessagefile)
580        except :   
581            self.logdebug("Unable to open IPP request file %s" % ippmessagefile)
582        else :   
583            self.logdebug("Parsing of IPP request file %s begins." % ippmessagefile)
584            try :
585                ippmessage = oldIPPRequest(ippdatafile.read())
586                ippmessage.parse()
587            except oldIPPError, msg :   
588                self.printInfo("Error while parsing %s : %s" \
589                                      % (ippmessagefile, msg), "warn")
590            else :   
591                self.logdebug("Parsing of IPP request file %s ends." \
592                                       % ippmessagefile)
593            ippdatafile.close()
594        self.dropPriv()
595        self.logdebug("IPP request file parsed successfully.")
596        return (ippmessagefile, ippmessage)
597               
598    def exportJobInfo(self) :   
599        """Exports the actual job's attributes to the environment."""
600        self.logdebug("Exporting job information to the environment...")
601        os.environ["DEVICE_URI"] = self.DeviceURI       # WARNING !
602        os.environ["PYKOTAPRINTERNAME"] = self.PrinterName
603        os.environ["PYKOTADIRECTORY"] = self.Directory
604        os.environ["PYKOTADATAFILE"] = self.DataFile
605        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.JobSizeBytes)
606        os.environ["PYKOTAMD5SUM"] = self.JobMD5Sum
607        os.environ["PYKOTAJOBORIGINATINGHOSTNAME"] = self.ClientHost or ""
608        os.environ["PYKOTAJOBID"] = self.JobId
609        os.environ["PYKOTAUSERNAME"] = self.UserName
610        os.environ["PYKOTATITLE"] = self.Title
611        os.environ["PYKOTACOPIES"] = str(self.Copies)
612        os.environ["PYKOTAOPTIONS"] = self.Options
613        os.environ["PYKOTAFILENAME"] = self.InputFile or ""
614        os.environ["PYKOTAJOBBILLING"] = self.JobBillingCode or ""
615        os.environ["PYKOTACONTROLFILE"] = self.ControlFile
616        os.environ["PYKOTAPRINTERHOSTNAME"] = self.PrinterHostName
617        os.environ["PYKOTAPRECOMPUTEDJOBSIZE"] = str(self.softwareJobSize)
618        self.logdebug("Environment updated.")
619       
620    def exportUserInfo(self) :
621        """Exports user information to the environment."""
622        self.logdebug("Exporting user information to the environment...")
623        os.environ["PYKOTAOVERCHARGE"] = str(self.User.OverCharge)
624        os.environ["PYKOTALIMITBY"] = str(self.User.LimitBy)
625        os.environ["PYKOTABALANCE"] = str(self.User.AccountBalance or 0.0)
626        os.environ["PYKOTALIFETIMEPAID"] = str(self.User.LifeTimePaid or 0.0)
627        os.environ["PYKOTAUSERDESCRIPTION"] = str(self.User.Description or "")
628       
629        os.environ["PYKOTAPAGECOUNTER"] = str(self.UserPQuota.PageCounter or 0)
630        os.environ["PYKOTALIFEPAGECOUNTER"] = str(self.UserPQuota.LifePageCounter or 0)
631        os.environ["PYKOTASOFTLIMIT"] = str(self.UserPQuota.SoftLimit)
632        os.environ["PYKOTAHARDLIMIT"] = str(self.UserPQuota.HardLimit)
633        os.environ["PYKOTADATELIMIT"] = str(self.UserPQuota.DateLimit)
634        os.environ["PYKOTAWARNCOUNT"] = str(self.UserPQuota.WarnCount)
635       
636        # TODO : move this elsewhere once software accounting is done only once.
637        os.environ["PYKOTAPRECOMPUTEDJOBPRICE"] = str(self.softwareJobPrice)
638       
639        self.logdebug("Environment updated.")
640       
641    def exportPrinterInfo(self) :
642        """Exports printer information to the environment."""
643        self.logdebug("Exporting printer information to the environment...")
644        # exports the list of printers groups the current
645        # printer is a member of
646        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(self.Printer)])
647        os.environ["PYKOTAPRINTERDESCRIPTION"] = str(self.Printer.Description or "")
648        os.environ["PYKOTAPRINTERMAXJOBSIZE"] = str(self.Printer.MaxJobSize or _("Unlimited"))
649        os.environ["PYKOTAPRINTERPASSTHROUGHMODE"] = (self.Printer.PassThrough and _("ON")) or _("OFF")
650        os.environ["PYKOTAPRICEPERPAGE"] = str(self.Printer.PricePerPage or 0)
651        os.environ["PYKOTAPRICEPERJOB"] = str(self.Printer.PricePerJob or 0)
652        self.logdebug("Environment updated.")
653       
654    def exportPhaseInfo(self, phase) :
655        """Exports phase information to the environment."""
656        self.logdebug("Exporting phase information [%s] to the environment..." % phase)
657        os.environ["PYKOTAPHASE"] = phase
658        self.logdebug("Environment updated.")
659       
660    def exportJobSizeAndPrice(self) :
661        """Exports job's size and price information to the environment."""
662        self.logdebug("Exporting job's size and price information to the environment...")
663        os.environ["PYKOTAJOBSIZE"] = str(self.JobSize)
664        os.environ["PYKOTAJOBPRICE"] = str(self.JobPrice)
665        self.logdebug("Environment updated.")
666       
667    def exportReason(self) :
668        """Exports the job's action status and optional reason."""
669        self.logdebug("Exporting job's action status...")
670        os.environ["PYKOTAACTION"] = str(self.Action)
671        if self.Reason :
672            os.environ["PYKOTAREASON"] = str(self.Reason)
673        self.logdebug("Environment updated.")
674       
675    def acceptJob(self) :       
676        """Returns the appropriate exit code to tell CUPS all is OK."""
677        return 0
678           
679    def removeJob(self) :           
680        """Returns the appropriate exit code to let CUPS think all is OK.
681       
682           Returning 0 (success) prevents CUPS from stopping the print queue.
683        """   
684        return 0
685       
686    def launchPreHook(self) :
687        """Allows plugging of an external hook before the job gets printed."""
688        prehook = self.config.getPreHook(self.PrinterName)
689        if prehook :
690            self.logdebug("Executing pre-hook [%s]..." % prehook)
691            retcode = os.system(prehook)
692            self.logdebug("pre-hook exited with status %s." % retcode)
693       
694    def launchPostHook(self) :
695        """Allows plugging of an external hook after the job gets printed and/or denied."""
696        posthook = self.config.getPostHook(self.PrinterName)
697        if posthook :
698            self.logdebug("Executing post-hook [%s]..." % posthook)
699            retcode = os.system(posthook)
700            self.logdebug("post-hook exited with status %s." % retcode)
701           
702    def improveMessage(self, message) :       
703        """Improves a message by adding more informations in it if possible."""
704        try :
705            return "%s@%s(%s) => %s" % (self.UserName, \
706                                        self.PrinterName, \
707                                        self.JobId, \
708                                        message)
709        except :                                               
710            return message
711       
712    def logdebug(self, message) :       
713        """Improves the debug message before outputting it."""
714        PyKotaTool.logdebug(self, self.improveMessage(message))
715       
716    def printInfo(self, message, level="info") :       
717        """Improves the informational message before outputting it."""
718        self.logger.log_message(self.improveMessage(message), level)
719   
720    def startingBanner(self, withaccounting) :
721        """Retrieves a starting banner for current printer and returns its content."""
722        self.logdebug("Retrieving starting banner...")
723        self.printBanner(self.config.getStartingBanner(self.PrinterName), withaccounting)
724        self.logdebug("Starting banner retrieved.")
725   
726    def endingBanner(self, withaccounting) :
727        """Retrieves an ending banner for current printer and returns its content."""
728        self.logdebug("Retrieving ending banner...")
729        self.printBanner(self.config.getEndingBanner(self.PrinterName), withaccounting)
730        self.logdebug("Ending banner retrieved.")
731       
732    def printBanner(self, bannerfileorcommand, withaccounting) :
733        """Reads a banner or generates one through an external command.
734       
735           Returns the banner's content in a format which MUST be accepted
736           by the printer.
737        """
738        self.logdebug("Printing banner...")
739        if bannerfileorcommand :
740            if os.access(bannerfileorcommand, os.X_OK) or \
741                  not os.path.isfile(bannerfileorcommand) :
742                self.logdebug("Launching %s to generate a banner." % bannerfileorcommand)
743                child = popen2.Popen3(bannerfileorcommand, capturestderr=1)
744                self.runOriginalBackend(child.fromchild, isBanner=1)
745                child.tochild.close()
746                child.childerr.close()
747                child.fromchild.close()
748                status = child.wait()
749                if os.WIFEXITED(status) :
750                    status = os.WEXITSTATUS(status)
751                self.printInfo(_("Banner generator %s exit code is %s") \
752                                         % (bannerfileorcommand, str(status)))
753                if withaccounting :
754                    if self.accounter.isSoftware :
755                        self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
756            else :
757                self.logdebug("Using %s as the banner." % bannerfileorcommand)
758                try :
759                    fh = open(bannerfileorcommand, 'rb')
760                except IOError, msg :   
761                    self.printInfo("Impossible to open %s : %s" \
762                                       % (bannerfileorcommand, msg), "error")
763                else :   
764                    self.runOriginalBackend(fh, isBanner=1)
765                    fh.close()
766                    if withaccounting :
767                        if self.accounter.isSoftware :
768                            self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
769        self.logdebug("Banner printed...")
770               
771    def handleBanner(self, bannertype, withaccounting) :
772        """Handles the banner with or without accounting."""
773        if withaccounting :
774            acc = "with"
775        else :   
776            acc = "without"
777        self.logdebug("Handling %s banner %s accounting..." % (bannertype, acc))
778        if (self.Action == 'DENY') and \
779           (self.UserPQuota.WarnCount >= \
780                            self.config.getMaxDenyBanners(self.PrinterName)) :
781            self.printInfo(_("Banner won't be printed : maximum number of deny banners reached."), \
782                             "warn")
783        else :
784            if self.Action == 'DENY' :
785                self.logdebug("Incrementing the number of deny banners for user %s on printer %s" \
786                                  % (self.UserName, self.PrinterName))
787                self.UserPQuota.incDenyBannerCounter() # increments the warning counter
788                self.exportUserInfo()
789            getattr(self, "%sBanner" % bannertype)(withaccounting)
790        self.logdebug("%s banner done." % bannertype.title())
791       
792    def sanitizeJobSize(self) :   
793        """Sanitizes the job's size if needed."""
794        # TODO : there's a difficult to see bug here when banner accounting is activated and hardware accounting is used.
795        self.logdebug("Sanitizing job's size...")
796        if self.softwareJobSize and (self.JobSize != self.softwareJobSize) :
797            self.printInfo(_("Beware : computed job size (%s) != precomputed job size (%s)") % \
798                                       (self.JobSize, self.softwareJobSize), \
799                           "error")
800            (limit, replacement) = self.config.getTrustJobSize(self.PrinterName)
801            if limit is None :
802                self.printInfo(_("The job size will be trusted anyway according to the 'trustjobsize' directive"), "warn")
803            else :
804                if self.JobSize <= limit :
805                    self.printInfo(_("The job size will be trusted because it is inferior to the 'trustjobsize' directive's limit %s") % limit, "warn")
806                else :
807                    self.printInfo(_("The job size will be modified according to the 'trustjobsize' directive : %s") % replacement, "warn")
808                    if replacement == "PRECOMPUTED" :
809                        self.JobSize = self.softwareJobSize
810                    else :   
811                        self.JobSize = replacement
812        self.logdebug("Job's size sanitized.")
813                       
814    def getPrinterUserAndUserPQuota(self) :       
815        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
816       
817           "OK" is returned in the policy if both printer, user and user print quota
818           exist in the Quota Storage.
819           Otherwise, the policy as defined for this printer in pykota.conf is returned.
820           
821           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
822           doesn't exist in the Quota Storage, then an external command is launched, as
823           defined in the external policy for this printer in pykota.conf
824           This external command can do anything, like automatically adding printers
825           or users, for example, and finally extracting printer, user and user print
826           quota from the Quota Storage is tried a second time.
827           
828           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
829           was returned by the external command.
830        """
831        self.logdebug("Retrieving printer, user, and user print quota entry from database...")
832        for passnumber in range(1, 3) :
833            printer = self.storage.getPrinter(self.PrinterName)
834            user = self.storage.getUser(self.UserName)
835            userpquota = self.storage.getUserPQuota(user, printer)
836            if printer.Exists and user.Exists and userpquota.Exists :
837                policy = "OK"
838                break
839            (policy, args) = self.config.getPrinterPolicy(self.PrinterName)
840            if policy == "EXTERNAL" :   
841                commandline = self.formatCommandLine(args, user, printer)
842                if not printer.Exists :
843                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.PrinterName, commandline, self.PrinterName))
844                if not user.Exists :
845                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.UserName, commandline, self.PrinterName))
846                if not userpquota.Exists :
847                    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))
848                if os.system(commandline) :
849                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.PrinterName), "error")
850                    policy = "EXTERNALERROR"
851                    break
852            else :       
853                if not printer.Exists :
854                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.PrinterName, policy))
855                if not user.Exists :
856                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.UserName, policy, self.PrinterName))
857                if not userpquota.Exists :
858                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.UserName, self.PrinterName, policy))
859                break
860               
861        if policy == "EXTERNAL" :   
862            if not printer.Exists :
863                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.PrinterName)
864            if not user.Exists :
865                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.UserName, self.PrinterName))
866            if not userpquota.Exists :
867                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.UserName, self.PrinterName))
868        self.Policy = policy         
869        self.Printer = printer
870        self.User = user
871        self.UserPQuota = userpquota
872        self.logdebug("Retrieval of printer, user and user print quota entry done.")
873       
874    def getBillingCode(self) :   
875        """Extracts the billing code from the database.
876         
877           An optional script is launched to notify the user when
878           the billing code is unknown and PyKota was configured to
879           deny printing in this case.
880        """
881        self.logdebug("Retrieving billing code information from the database...")
882        self.BillingCode = None
883        if self.JobBillingCode :
884            self.BillingCode = self.storage.getBillingCode(self.JobBillingCode)
885            if self.BillingCode.Exists :
886                self.logdebug("Billing code [%s] found in database." % self.JobBillingCode)
887            else :
888                msg = "Unknown billing code [%s] : " % self.JobBillingCode
889                (newaction, script) = self.config.getUnknownBillingCode(self.PrinterName)
890                if newaction == "CREATE" :
891                    self.logdebug(msg + "will be created.")
892                    self.storage.addBillingCode(self.BillingCode)
893                    self.BillingCode = self.storage.getBillingCode(self.JobBillingCode)
894                    if self.BillingCode.Exists :
895                        self.logdebug(msg + "has been created.")
896                    else :   
897                        self.printInfo(msg + "couldn't be created.", "error")
898                else :   
899                    self.logdebug(msg + "job will be denied.")
900                    self.Action = newaction
901                    if script is not None : 
902                        self.logdebug(msg + "launching subprocess [%s] to notify user." % script)
903                        os.system(script)
904        self.logdebug("Retrieval of billing code information done.")
905       
906    def checkIfDupe(self) :   
907        """Checks if the job is a duplicate, and handles the situation."""
908        self.logdebug("Checking if the job is a duplicate...")
909        denyduplicates = self.config.getDenyDuplicates(self.PrinterName)
910        if not denyduplicates :
911            self.logdebug("We don't care about duplicate jobs after all.")
912        else :
913            if self.Printer.LastJob.Exists \
914                    and (self.Printer.LastJob.UserName == self.UserName) \
915                    and (self.Printer.LastJob.JobMD5Sum == self.JobMD5Sum) :
916                now = DateTime.now()
917                try :
918                    previous = DateTime.ISO.ParseDateTime(str(self.Printer.LastJob.JobDate)).localtime()
919                except :
920                    previous = now
921                difference = (now - previous).seconds
922                duplicatesdelay = self.config.getDuplicatesDelay(self.PrinterName)
923                self.logdebug("Difference with previous job : %.2f seconds. Duplicates delay : %.2f seconds." % (difference, duplicatesdelay))
924                if difference > duplicatesdelay :
925                    self.logdebug("Duplicate job allowed because previous one is more than %.2f seconds old." % duplicatesdelay)
926                else :
927                    # TODO : use the current user's last job instead of 
928                    # TODO : the current printer's last job. This would be
929                    # TODO : better but requires an additional database query
930                    # TODO : with SQL, and is much more complex with the
931                    # TODO : actual LDAP schema. Maybe this is not very
932                    # TODO : important, because usually duplicate jobs are sucessive.
933                    msg = _("Job is a dupe")
934                    if denyduplicates == 1 :
935                        self.printInfo("%s : %s." % (msg, _("Printing is denied by configuration")), "warn")
936                        self.Action = "DENY"
937                        self.Reason = _("Duplicate print jobs are not allowed on printer %s.") % self.PrinterName
938                    else :   
939                        self.logdebug("Launching subprocess [%s] to see if duplicate jobs should be allowed or not." % denyduplicates)
940                        fanswer = os.popen(denyduplicates, "r")
941                        self.Action = fanswer.read().strip().upper()
942                        fanswer.close()
943                        if self.Action == "DENY" :     
944                            self.printInfo("%s : %s." % (msg, _("Subprocess denied printing of a dupe")), "warn")
945                            self.Reason = _("Duplicate print jobs are not allowed on printer %s at this time.") % self.PrinterName
946                        else :   
947                            self.printInfo("%s : %s." % (msg, _("Subprocess allowed printing of a dupe")), "warn")
948            else :           
949                self.logdebug("Job doesn't seem to be a duplicate.")
950        self.logdebug("Checking if the job is a duplicate done.")
951       
952    def tellUser(self) :
953        """Sends a message to an user."""
954        self.logdebug("Sending some feedback to user %s..." % self.UserName) 
955        if not self.Reason :
956            self.logdebug("No feedback to send to user %s." % self.UserName)
957        else :   
958            (mailto, arguments) = self.config.getMailTo(self.PrinterName)
959            if mailto == "EXTERNAL" :
960                # TODO : clean this again
961                self.regainPriv()
962                self.externalMailTo(arguments, self.Action, self.User, self.Printer, self.Reason)
963                self.dropPriv()
964            else :   
965                # TODO : clean this again
966                admin = self.config.getAdmin(self.PrinterName)
967                adminmail = self.config.getAdminMail(self.PrinterName)
968                usermail = self.User.Email or self.User.Name
969                if "@" not in usermail :
970                    usermail = "%s@%s" % (usermail, self.maildomain or self.smtpserver)
971                destination = []
972                if mailto in ("BOTH", "ADMIN") :
973                    destination.append(adminmail)
974                if mailto in ("BOTH", "USER") :   
975                    destination.append(usermail)
976                   
977                fullmessage = self.Reason + (_("\n\nYour system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail))
978                try :   
979                    server = smtplib.SMTP(self.smtpserver)
980                except socket.error, msg :   
981                    self.printInfo(_("Impossible to connect to SMTP server : %s") % msg, "error")
982                else :
983                    try :
984                        msg = MIMEText(fullmessage, _charset=self.charset)
985                        msg["Subject"] = str(Header(_("Print Quota"), charset=self.charset))
986                        msg["From"] = adminmail
987                        if mailto in ("BOTH", "USER") :
988                            msg["To"] = usermail
989                            if mailto == "BOTH" :
990                                msg["Cc"] = adminmail
991                        else :   
992                            msg["To"] = adminmail
993                        server.sendmail(adminmail, destination, msg.as_string())
994                    except smtplib.SMTPException, answer :   
995                        for (k, v) in answer.recipients.items() :
996                            self.printInfo(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
997                    server.quit()
998            self.logdebug("Feedback sent to user %s." % self.UserName)
999               
1000    def mainWork(self) :   
1001        """Main work is done here."""
1002        if not self.JobSizeBytes :
1003            # if no data to pass to real backend, probably a filter
1004            # higher in the chain failed because of a misconfiguration.
1005            # we deny the job in this case (nothing to print anyway)
1006            self.Reason = _("Job contains no data. Printing is denied.")
1007            self.printInfo(self.Reason, "error")
1008            self.tellUser()
1009            return self.removeJob()
1010           
1011        self.getPrinterUserAndUserPQuota()
1012        if self.Policy == "EXTERNALERROR" :
1013            # Policy was 'EXTERNAL' and the external command returned an error code
1014            self.Reason = _("Error in external policy script. Printing is denied.")
1015            self.printInfo(self.Reason, "error")
1016            self.tellUser()
1017            return self.removeJob()
1018        elif self.Policy == "EXTERNAL" :
1019            # Policy was 'EXTERNAL' and the external command wasn't able
1020            # to add either the printer, user or user print quota
1021            self.Reason = _("Still no print quota entry for user %s on printer %s after external policy. Printing is denied.") % (self.UserName, self.PrinterName)
1022            self.printInfo(self.Reason, "warn")
1023            self.tellUser()
1024            return self.removeJob()
1025        elif self.Policy == "DENY" :   
1026            # Either printer, user or user print quota doesn't exist,
1027            # and the job should be rejected.
1028            self.Reason = _("Printing is denied by printer policy.")
1029            self.printInfo(self.Reason, "warn")
1030            self.tellUser()
1031            return self.removeJob()
1032        elif self.Policy == "ALLOW" :
1033            # ALLOW means : Either printer, user or user print quota doesn't exist,
1034            #               but the job should be allowed anyway.
1035            self.Reason = _("Job allowed by printer policy. No accounting will be done.")
1036            self.printInfo(self.Reason, "warn")
1037            self.tellUser()
1038            return self.printJobDatas()
1039        elif self.Policy == "OK" :
1040            # OK means : Both printer, user and user print quota exist, job should
1041            #            be allowed if current user is allowed to print on this printer
1042            return self.doWork()
1043        else :   
1044            self.Reason = _("Invalid policy %s for printer %s") % (self.Policy, self.PrinterName)
1045            self.printInfo(self.Reason, "error")
1046            self.tellUser()
1047            return self.removeJob()
1048   
1049    def doWork(self) :   
1050        """The accounting work is done here."""
1051        self.precomputeJobPrice()
1052        self.exportUserInfo()
1053        self.exportPrinterInfo()
1054        self.exportPhaseInfo("BEFORE")
1055       
1056        if self.Action not in ("DENY", "CANCEL") : 
1057            if not self.didUserConfirm() :
1058                self.Action = "CANCEL"
1059                self.Reason = _("Print job cancelled.")
1060                os.environ["PYKOTASTATUS"] = "CANCELLED"
1061               
1062        if self.Action not in ("DENY", "CANCEL") : 
1063            if self.Printer.MaxJobSize and (self.softwareJobSize > self.Printer.MaxJobSize) :
1064                # This printer was set to refuse jobs this large.
1065                self.printInfo(_("Precomputed job size (%s pages) too large for printer %s.") % (self.softwareJobSize, self.PrinterName), "warn")
1066                self.Action = "DENY"
1067                # here we don't put the precomputed job size in the message
1068                # because in case of error the user could complain :-)
1069                self.Reason = _("You are not allowed to print so many pages on printer %s at this time.") % self.PrinterName
1070           
1071        if self.Action not in ("DENY", "CANCEL") :
1072            if self.User.LimitBy == "noprint" :
1073                self.printInfo(_("User %s is not allowed to print at this time.") % self.UserName, "warn")
1074                self.Action = "DENY"
1075                self.Reason = _("Your account settings forbid you to print at this time.")
1076               
1077        if self.Action not in ("DENY", "CANCEL") :
1078            # If printing is still allowed at this time, we
1079            # need to extract the billing code information from the database.
1080            # No need to do this if the job is denied, this way we
1081            # save some database queries.
1082            self.getBillingCode()
1083           
1084        if self.Action not in ("DENY", "CANCEL") :
1085            # If printing is still allowed at this time, we
1086            # need to check if the job is a dupe or not, and what to do then.
1087            # No need to do this if the job is denied, this way we
1088            # save some database queries.
1089            self.checkIfDupe()
1090                   
1091        if self.Action not in ("DENY", "CANCEL") :
1092            # If printing is still allowed at this time, we
1093            # need to check the user's print quota on the current printer.
1094            # No need to do this if the job is denied, this way we
1095            # save some database queries.
1096            if self.User.LimitBy in ('noquota', 'nochange') :
1097                self.logdebug("User %s is allowed to print with no limit, no need to check quota." % self.UserName)
1098            elif self.Printer.PassThrough :   
1099                self.logdebug("Printer %s is in PassThrough mode, no need to check quota." % self.PrinterName)
1100            else :
1101                self.logdebug("Checking user %s print quota entry on printer %s" \
1102                                    % (self.UserName, self.PrinterName))
1103                self.Action = self.checkUserPQuota(self.UserPQuota)
1104                if self.Action.startswith("POLICY_") :
1105                    self.Action = self.Action[7:]
1106                if self.Action == "DENY" :
1107                    self.printInfo(_("Print Quota exceeded for user %s on printer %s") % (self.UserName, self.PrinterName))
1108                    self.Reason = self.config.getHardWarn(self.PrinterName)
1109                elif self.Action == "WARN" :   
1110                    self.printInfo(_("Print Quota low for user %s on printer %s") % (self.UserName, self.PrinterName))
1111                    if self.User.LimitBy and (self.User.LimitBy.lower() == "balance") : 
1112                        self.Reason = self.config.getPoorWarn()
1113                    else :     
1114                        self.Reason = self.config.getSoftWarn(self.PrinterName)
1115           
1116        # exports some new environment variables
1117        self.exportReason()
1118       
1119        # now tell the user if he needs to know something
1120        self.tellUser()
1121       
1122        # launches the pre hook
1123        self.launchPreHook()
1124       
1125        # handle starting banner pages without accounting
1126        self.BannerSize = 0
1127        accountbanner = self.config.getAccountBanner(self.PrinterName)
1128        if accountbanner in ["ENDING", "NONE"] :
1129            self.handleBanner("starting", 0)
1130       
1131        if self.Action == "DENY" :
1132            self.printInfo(_("Job denied, no accounting will be done."))
1133        elif self.Action == "CANCEL" :   
1134            self.printInfo(_("Job cancelled, no accounting will be done."))
1135        else :
1136            self.printInfo(_("Job accounting begins."))
1137            self.deinstallSigTermHandler()
1138            self.accounter.beginJob(self.Printer)
1139            self.installSigTermHandler()
1140       
1141        # handle starting banner pages with accounting
1142        if accountbanner in ["STARTING", "BOTH"] :
1143            if not self.gotSigTerm :
1144                self.handleBanner("starting", 1)
1145       
1146        # pass the job's data to the real backend   
1147        if (not self.gotSigTerm) and (self.Action in ["ALLOW", "WARN"]) :
1148            retcode = self.printJobDatas()
1149        else :       
1150            retcode = self.removeJob()
1151       
1152        # indicate phase change
1153        self.exportPhaseInfo("AFTER")
1154       
1155        # handle ending banner pages with accounting
1156        if accountbanner in ["ENDING", "BOTH"] :
1157            if not self.gotSigTerm :
1158                self.handleBanner("ending", 1)
1159       
1160        # stops accounting
1161        if self.Action == "DENY" :
1162            self.printInfo(_("Job denied, no accounting has been done."))
1163        elif self.Action == "CANCEL" :   
1164            self.printInfo(_("Job cancelled, no accounting has been done."))
1165        else :
1166            self.deinstallSigTermHandler()
1167            self.accounter.endJob(self.Printer)
1168            self.installSigTermHandler()
1169            self.printInfo(_("Job accounting ends."))
1170       
1171        # Do all these database changes within a single transaction   
1172        # NB : we don't enclose ALL the changes within a single transaction
1173        # because while waiting for the printer to answer its internal page
1174        # counter, we would open the door to accounting problems for other
1175        # jobs launched by the same user at the same time on other printers.
1176        # All the code below doesn't take much time, so it's fine.
1177        self.storage.beginTransaction()
1178        try :
1179            onbackenderror = self.config.getPrinterOnBackendError(self.PrinterName)
1180            if retcode :
1181                # NB : We don't send any feedback to the end user. Only the admin
1182                # has to know that the real CUPS backend failed.
1183                self.Action = "PROBLEM"
1184                self.exportReason()
1185                if "NOCHARGE" in onbackenderror :
1186                    self.JobSize = 0
1187                    self.printInfo(_("Job size forced to 0 because the real CUPS backend failed. No accounting will be done."), "warn")
1188                else :   
1189                    self.printInfo(_("The real CUPS backend failed, but the job will be accounted for anyway."), "warn")
1190                   
1191            # retrieve the job size   
1192            if self.Action == "DENY" :
1193                self.JobSize = 0
1194                self.printInfo(_("Job size forced to 0 because printing is denied."))
1195            elif self.Action == "CANCEL" :     
1196                self.JobSize = 0
1197                self.printInfo(_("Job size forced to 0 because printing was cancelled."))
1198            else :   
1199                self.UserPQuota.resetDenyBannerCounter()
1200                if (self.Action != "PROBLEM") or ("CHARGE" in onbackenderror) : 
1201                    self.JobSize = self.accounter.getJobSize(self.Printer)
1202                    self.sanitizeJobSize()
1203                    self.JobSize += self.BannerSize
1204            self.printInfo(_("Job size : %i") % self.JobSize)
1205           
1206            if ((self.Action == "PROBLEM") and ("NOCHARGE" in onbackenderror)) or \
1207                (self.Action in ("DENY", "CANCEL")) :
1208                self.JobPrice = 0.0
1209            elif (self.User.LimitBy == "nochange") or self.Printer.PassThrough :
1210                # no need to update the quota for the current user on this printer
1211                self.printInfo(_("User %s's quota on printer %s won't be modified") % (self.UserName, self.PrinterName))
1212                self.JobPrice = 0.0
1213            else :
1214                # update the quota for the current user on this printer
1215                self.printInfo(_("Updating user %s's quota on printer %s") % (self.UserName, self.PrinterName))
1216                self.JobPrice = self.UserPQuota.increasePagesUsage(self.JobSize)
1217           
1218            # adds the current job to history   
1219            self.Printer.addJobToHistory(self.JobId, self.User, self.accounter.getLastPageCounter(), \
1220                                    self.Action, self.JobSize, self.JobPrice, self.InputFile, \
1221                                    self.Title, self.Copies, self.Options, self.ClientHost, \
1222                                    self.JobSizeBytes, self.JobMD5Sum, None, self.JobBillingCode, \
1223                                    self.softwareJobSize, self.softwareJobPrice)
1224            self.printInfo(_("Job added to history."))
1225           
1226            if hasattr(self, "BillingCode") and self.BillingCode and self.BillingCode.Exists :
1227                if (self.Action in ("ALLOW", "WARN")) or \
1228                   ((self.Action == "PROBLEM") and ("CHARGE" in onbackenderror)) :
1229                    self.BillingCode.consume(self.JobSize, self.JobPrice)
1230                    self.printInfo(_("Billing code %s was updated.") % self.BillingCode.BillingCode)
1231        except :   
1232            self.storage.rollbackTransaction()
1233            raise
1234        else :   
1235            self.storage.commitTransaction()
1236           
1237        # exports some new environment variables
1238        self.exportJobSizeAndPrice()
1239       
1240        # then re-export user information with new values
1241        self.exportUserInfo()
1242       
1243        # handle ending banner pages without accounting
1244        if accountbanner in ["STARTING", "NONE"] :
1245            self.handleBanner("ending", 0)
1246                   
1247        self.launchPostHook()
1248           
1249        return retcode   
1250               
1251    def printJobDatas(self) :           
1252        """Sends the job's datas to the real backend."""
1253        self.logdebug("Sending job's datas to real backend...")
1254       
1255        delay = 0
1256        number = 1
1257        for onb in self.config.getPrinterOnBackendError(self.PrinterName) :
1258            if onb.startswith("RETRY:") :
1259                try :
1260                    (number, delay) = [int(p) for p in onb[6:].split(":", 2)]
1261                    if (number < 0) or (delay < 0) :
1262                        raise ValueError
1263                except ValueError :   
1264                    self.printInfo(_("Incorrect value for the 'onbackenderror' directive in section [%s]") % self.PrinterName, "error")
1265                    delay = 0
1266                    number = 1
1267                else :   
1268                    break
1269        loopcnt = 1 
1270        while True :           
1271            if self.InputFile is None :
1272                infile = open(self.DataFile, "rb")
1273            else :   
1274                infile = None
1275            retcode = self.runOriginalBackend(infile)
1276            if self.InputFile is None :
1277                infile.close()
1278            if not retcode :
1279                break
1280            else :
1281                if (not number) or (loopcnt < number) :
1282                    self.logdebug(_("The real backend produced an error, we will try again in %s seconds.") % delay)
1283                    time.sleep(delay)
1284                    loopcnt += 1
1285                else :   
1286                    break
1287           
1288        self.logdebug("Job's datas sent to real backend.")
1289        return retcode
1290       
1291    def runOriginalBackend(self, filehandle=None, isBanner=0) :
1292        """Launches the original backend."""
1293        originalbackend = os.path.join(os.path.split(sys.argv[0])[0], self.RealBackend)
1294        if not isBanner :
1295            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:]
1296        else :   
1297            # For banners, we absolutely WANT
1298            # to remove any filename from the command line !
1299            self.logdebug("It looks like we try to print a banner.")
1300            arguments = [os.environ["DEVICE_URI"]] + sys.argv[1:6]
1301        arguments[2] = self.UserName # in case it was overwritten by external script
1302        # TODO : do something about job-billing option, in case it was overwritten as well...
1303        # TODO : do something about the job title : if we are printing a banner and the backend
1304        # TODO : uses the job's title to name an output file (cups-pdf:// for example), we're stuck !
1305       
1306        self.logdebug("Starting original backend %s with args %s" % (originalbackend, " ".join(['"%s"' % a for a in arguments])))
1307        self.regainPriv()   
1308        pid = os.fork()
1309        self.logdebug("Forked !")
1310        if pid == 0 :
1311            if filehandle is not None :
1312                self.logdebug("Redirecting file handle to real backend's stdin")
1313                os.dup2(filehandle.fileno(), 0)
1314            try :
1315                self.logdebug("Calling execve...")
1316                os.execve(originalbackend, arguments, os.environ)
1317            except OSError, msg :
1318                self.logdebug("execve() failed: %s" % msg)
1319            self.logdebug("We shouldn't be there !!!")   
1320            os._exit(-1)
1321        self.dropPriv()   
1322       
1323        self.logdebug("Waiting for original backend to exit...")   
1324        killed = 0
1325        status = -1
1326        while status == -1 :
1327            try :
1328                status = os.waitpid(pid, 0)[1]
1329            except OSError, (err, msg) :
1330                if (err == 4) and self.gotSigTerm :
1331                    os.kill(pid, signal.SIGTERM)
1332                    killed = 1
1333                   
1334        if os.WIFEXITED(status) :
1335            status = os.WEXITSTATUS(status)
1336            message = "CUPS backend %s returned %d." % \
1337                            (originalbackend, status)
1338            if status :
1339                level = "error"
1340                self.Reason = message
1341            else :   
1342                level = "info"
1343            self.printInfo(message, level)
1344            return status
1345        elif not killed :
1346            self.Reason = "CUPS backend %s died abnormally." % originalbackend
1347            self.printInfo(self.Reason, "error")
1348            return -1
1349        else :
1350            self.Reason = "CUPS backend %s was killed." % originalbackend
1351            self.printInfo(self.Reason, "warn")
1352            return 1
1353       
1354if __name__ == "__main__" :   
1355    # This is a CUPS backend, we should act and die like a CUPS backend
1356    wrapper = CUPSBackend()
1357    if len(sys.argv) == 1 :
1358        print "\n".join(wrapper.discoverOtherBackends())
1359        sys.exit(0)               
1360    elif len(sys.argv) not in (6, 7) :   
1361        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n"\
1362                              % sys.argv[0])
1363        sys.exit(1)
1364    else :   
1365        try :
1366            wrapper.deferredInit()
1367            wrapper.initBackendParameters()
1368            wrapper.waitForLock()
1369            wrapper.saveDatasAndCheckSum()
1370            wrapper.preaccounter = openAccounter(wrapper, ispreaccounter=1)
1371            wrapper.accounter = openAccounter(wrapper)
1372            wrapper.precomputeJobSize()
1373            wrapper.exportJobInfo() # exports a first time to give hints to external scripts
1374            wrapper.overwriteJobAttributes()
1375            wrapper.exportJobInfo() # re-exports in case it was overwritten
1376            retcode = wrapper.mainWork()
1377        except KeyboardInterrupt :   
1378            wrapper.printInfo(_("Job %s interrupted by the administrator !") % wrapper.JobId, "warn")
1379            retcode = 0
1380        except SystemExit, err :   
1381            retcode = err.code
1382        except :   
1383            try :
1384                wrapper.crashed("cupspykota backend failed")
1385            except :   
1386                crashed("cupspykota backend failed")
1387            retcode = 1
1388        wrapper.clean()
1389        sys.exit(retcode)
Note: See TracBrowser for help on using the browser.