root / pykota / trunk / bin / cupspykota @ 3300

Revision 3300, 67.6 kB (checked in by jerome, 16 years ago)

Ensures email headers will be ok.

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