root / pykota / trunk / bin / cupspykota @ 3547

Revision 3545, 66.3 kB (checked in by jerome, 15 years ago)

Ensures that all command lines are properly encoded before calling
os.system() on them from the cupspykota backend wrapper.
TODO : check command line tools for the same potential problem.

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