root / pykota / trunk / bin / cupspykota @ 3252

Revision 3252, 68.1 kB (checked in by jerome, 16 years ago)

Can now print again under limited circumstances.

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