root / pykota / trunk / bin / cupspykota @ 3413

Revision 3413, 65.6 kB (checked in by jerome, 16 years ago)

Removed unnecessary spaces at EOL.

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