root / pykota / trunk / bin / cupspykota @ 3552

Revision 3549, 67.2 kB (checked in by jerome, 14 years ago)

Removed support for the MaxJobSize? attribute for users group print quota
entries : I couldn't see a real use for this at the moment, and it would
complexify the code. This support might reappear later however. Added full
support for the MaxJobSize? attribute for user print quota entries,
editable with edpykota's new --maxjobsize command line switch. Changed
the internal handling of the MaxJobSize? attribute for printers :
internally 0 used to mean unlimited, it now allows one to forbid
printing onto a particular printer. The database upgrade script (only
for PostgreSQL) takes care of this.
IMPORTANT : the database schema changes. A database upgrade script is
provided for PostgreSQL only. The LDAP schema doesn't change to not
break any existing LDAP directory, so the pykotaMaxJobSize attribute is
still allowed on group print quota entries, but never used.
Seems to work as expected, for a change :-)
Fixes #15.

  • 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        setenv("PYKOTAMAXJOBSIZE", ((self.UserPQuota.MaxJobSize is None) and _("Unlimited")) or str(self.UserPQuota.MaxJobSize), self.charset)
560
561        setenv("PYKOTAPAGECOUNTER", str(self.UserPQuota.PageCounter or 0), self.charset)
562        setenv("PYKOTALIFEPAGECOUNTER", str(self.UserPQuota.LifePageCounter or 0), self.charset)
563        setenv("PYKOTASOFTLIMIT", str(self.UserPQuota.SoftLimit), self.charset)
564        setenv("PYKOTAHARDLIMIT", str(self.UserPQuota.HardLimit), self.charset)
565        setenv("PYKOTADATELIMIT", str(self.UserPQuota.DateLimit), self.charset)
566        setenv("PYKOTAWARNCOUNT", str(self.UserPQuota.WarnCount), self.charset)
567
568        # TODO : move this elsewhere once software accounting is done only once.
569        setenv("PYKOTAPRECOMPUTEDJOBPRICE", str(self.softwareJobPrice), self.charset)
570
571        self.logdebug("Environment updated.")
572
573    def exportPrinterInfo(self) :
574        """Exports printer information to the environment."""
575        self.logdebug("Exporting printer information to the environment...")
576        # exports the list of printers groups the current
577        # printer is a member of
578        setenv("PYKOTAPGROUPS", ",".join([p.Name for p in self.storage.getParentPrinters(self.Printer)]), self.charset)
579        setenv("PYKOTAPRINTERDESCRIPTION", self.Printer.Description or "", self.charset)
580        setenv("PYKOTAPRINTERMAXJOBSIZE", ((self.Printer.MaxJobSize is None) and _("Unlimited")) or str(self.Printer.MaxJobSize), self.charset)
581        setenv("PYKOTAPRINTERPASSTHROUGHMODE", (self.Printer.PassThrough and _("ON")) or _("OFF"), self.charset)
582        setenv("PYKOTAPRICEPERPAGE", str(self.Printer.PricePerPage or 0), self.charset)
583        setenv("PYKOTAPRICEPERJOB", str(self.Printer.PricePerJob or 0), self.charset)
584        self.logdebug("Environment updated.")
585
586    def exportPhaseInfo(self, phase) :
587        """Exports phase information to the environment."""
588        self.logdebug("Exporting phase information [%s] to the environment..." % phase)
589        setenv("PYKOTAPHASE", phase, self.charset)
590        self.logdebug("Environment updated.")
591
592    def exportJobSizeAndPrice(self) :
593        """Exports job's size and price information to the environment."""
594        self.logdebug("Exporting job's size and price information to the environment...")
595        setenv("PYKOTAJOBSIZE", str(self.JobSize), self.charset)
596        setenv("PYKOTAJOBPRICE", str(self.JobPrice), self.charset)
597        self.logdebug("Environment updated.")
598
599    def exportReason(self) :
600        """Exports the job's action status and optional reason."""
601        self.logdebug("Exporting job's action status...")
602        setenv("PYKOTAACTION", self.Action or "", self.charset)
603        if self.Reason :
604            setenv("PYKOTAREASON", self.Reason or "", self.charset)
605        self.logdebug("Environment updated.")
606
607    def acceptJob(self) :
608        """Returns the appropriate exit code to tell CUPS all is OK."""
609        return 0
610
611    def removeJob(self) :
612        """Returns the appropriate exit code to let CUPS think all is OK.
613
614           Returning 0 (success) prevents CUPS from stopping the print queue.
615        """
616        return 0
617
618    def launchPreHook(self) :
619        """Allows plugging of an external hook before the job gets printed."""
620        prehook = self.config.getPreHook(self.PrinterName)
621        if prehook :
622            self.logdebug("Executing pre-hook [%s]..." % prehook)
623            retcode = os.system(prehook.encode(self.charset, "replace"))
624            self.logdebug("pre-hook exited with status %s." % retcode)
625
626    def launchPostHook(self) :
627        """Allows plugging of an external hook after the job gets printed and/or denied."""
628        posthook = self.config.getPostHook(self.PrinterName)
629        if posthook :
630            self.logdebug("Executing post-hook [%s]..." % posthook)
631            retcode = os.system(posthook.encode(self.charset, "replace"))
632            self.logdebug("post-hook exited with status %s." % retcode)
633
634    def improveMessage(self, message) :
635        """Improves a message by adding more informations in it if possible."""
636        try :
637            return "%s@%s(%s) => %s" % (self.UserName, \
638                                        self.PrinterName, \
639                                        self.Ticket.JobId, \
640                                        message)
641        except :
642            return message
643
644    def logdebug(self, message) :
645        """Improves the debug message before outputting it."""
646        PyKotaTool.logdebug(self, self.improveMessage(message))
647
648    def printInfo(self, message, level="info") :
649        """Improves the informational message before outputting it."""
650        self.logger.log_message(self.improveMessage(message), level)
651
652    def startingBanner(self, withaccounting) :
653        """Retrieves a starting banner for current printer and returns its content."""
654        self.logdebug("Retrieving starting banner...")
655        self.printBanner(self.config.getStartingBanner(self.PrinterName), withaccounting)
656        self.logdebug("Starting banner retrieved.")
657
658    def endingBanner(self, withaccounting) :
659        """Retrieves an ending banner for current printer and returns its content."""
660        self.logdebug("Retrieving ending banner...")
661        self.printBanner(self.config.getEndingBanner(self.PrinterName), withaccounting)
662        self.logdebug("Ending banner retrieved.")
663
664    def printBanner(self, bannerfileorcommand, withaccounting) :
665        """Reads a banner or generates one through an external command.
666
667           Returns the banner's content in a format which MUST be accepted
668           by the printer.
669        """
670        self.logdebug("Printing banner...")
671        if bannerfileorcommand :
672            if os.access(bannerfileorcommand, os.X_OK) or \
673                  not os.path.isfile(bannerfileorcommand) :
674                self.logdebug("Launching %s to generate a banner." % bannerfileorcommand)
675                child = popen2.Popen3(bannerfileorcommand.encode(self.charset, "replace"), capturestderr=1)
676                self.runOriginalBackend(child.fromchild, isBanner=1)
677                child.tochild.close()
678                child.childerr.close()
679                child.fromchild.close()
680                status = child.wait()
681                if os.WIFEXITED(status) :
682                    status = os.WEXITSTATUS(status)
683                self.printInfo(_("Banner generator %s exit code is %s") \
684                                         % (bannerfileorcommand, str(status)))
685                if withaccounting :
686                    if self.accounter.isSoftware :
687                        self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
688            else :
689                self.logdebug("Using %s as the banner." % bannerfileorcommand)
690                try :
691                    fh = open(bannerfileorcommand, 'rb')
692                except IOError, msg :
693                    self.printInfo("Impossible to open %s : %s" \
694                                       % (bannerfileorcommand, msg), "error")
695                else :
696                    self.runOriginalBackend(fh, isBanner=1)
697                    fh.close()
698                    if withaccounting :
699                        if self.accounter.isSoftware :
700                            self.BannerSize += 1 # TODO : fix this by passing the banner's content through software accounting
701        self.logdebug("Banner printed...")
702
703    def handleBanner(self, bannertype, withaccounting) :
704        """Handles the banner with or without accounting."""
705        if withaccounting :
706            acc = "with"
707        else :
708            acc = "without"
709        self.logdebug("Handling %s banner %s accounting..." % (bannertype, acc))
710        if (self.Action == 'DENY') and \
711           (self.UserPQuota.WarnCount >= \
712                            self.config.getMaxDenyBanners(self.PrinterName)) :
713            self.printInfo(_("Banner won't be printed : maximum number of deny banners reached."), \
714                             "warn")
715        else :
716            if self.Action == 'DENY' :
717                self.logdebug("Incrementing the number of deny banners for user %s on printer %s" \
718                                  % (self.UserName, self.PrinterName))
719                self.UserPQuota.incDenyBannerCounter() # increments the warning counter
720                self.exportUserInfo()
721            if ((self.Action == 'CANCEL') and not self.config.getPrintCancelledBanners()) :
722                self.logdebug("Print job cancelled, not printing a banner.", "warn")
723            else :
724                self.logdebug("Checking if job owner printed the last job and if another banner is needed...")
725                # Print the banner by default
726                printbanner = True
727                avoidduplicatebanners = self.config.getAvoidDuplicateBanners(self.PrinterName)
728                if ((avoidduplicatebanners == "NO") or (avoidduplicatebanners == 0)):
729                    self.logdebug("We want all banners to be printed.")
730                else :
731                    # Check if we should deny the banner or not
732                    if self.Printer.LastJob.Exists \
733                            and (self.Printer.LastJob.UserName == self.UserName) :
734                        if (avoidduplicatebanners == "YES") :
735                            printbanner = False
736                        else :
737                            # avoidduplicatebanners is an integer, since NO,
738                            # YES and 0 are already handled
739                            now = DateTime.now()
740                            try :
741                                previous = DateTime.ISO.ParseDateTime(str(self.Printer.LastJob.JobDate)[:19])
742                            except :
743                                previous = now
744                            difference = (now - previous).seconds
745                            self.logdebug("Difference with previous job : %.2f seconds. Try to avoid banners for : %.2f seconds." % (difference, avoidduplicatebanners))
746                            if difference < avoidduplicatebanners :
747                                self.logdebug("Duplicate banner avoided because previous banner is less than %.2f seconds old." % avoidduplicatebanners)
748                                printbanner = False
749                            else :
750                                printbanner = True
751                if printbanner :
752                    getattr(self, "%sBanner" % bannertype)(withaccounting)
753        self.logdebug("%s banner done." % bannertype.title())
754
755    def sanitizeJobSize(self) :
756        """Sanitizes the job's size if needed."""
757        # TODO : there's a difficult to see bug here when banner accounting is activated and hardware accounting is used.
758        self.logdebug("Sanitizing job's size...")
759        if self.softwareJobSize and (self.JobSize != self.softwareJobSize) :
760            self.printInfo(_("Beware : computed job size (%s) != precomputed job size (%s)") % \
761                                       (self.JobSize, self.softwareJobSize), \
762                           "error")
763            (limit, replacement) = self.config.getTrustJobSize(self.PrinterName)
764            if limit is None :
765                self.printInfo(_("The job size will be trusted anyway according to the 'trustjobsize' directive"), "warn")
766            else :
767                if self.JobSize <= limit :
768                    self.printInfo(_("The job size will be trusted because it is inferior to the 'trustjobsize' directive's limit %s") % limit, "warn")
769                else :
770                    self.printInfo(_("The job size will be modified according to the 'trustjobsize' directive : %s") % replacement, "warn")
771                    if replacement == "PRECOMPUTED" :
772                        self.JobSize = self.softwareJobSize
773                    else :
774                        self.JobSize = replacement
775        self.logdebug("Job's size sanitized.")
776
777    def getPrinterUserAndUserPQuota(self) :
778        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
779
780           "OK" is returned in the policy if both printer, user and user print quota
781           exist in the Quota Storage.
782           Otherwise, the policy as defined for this printer in pykota.conf is returned.
783
784           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
785           doesn't exist in the Quota Storage, then an external command is launched, as
786           defined in the external policy for this printer in pykota.conf
787           This external command can do anything, like automatically adding printers
788           or users, for example, and finally extracting printer, user and user print
789           quota from the Quota Storage is tried a second time.
790
791           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
792           was returned by the external command.
793        """
794        self.logdebug("Retrieving printer, user, and user print quota entry from database...")
795        for passnumber in range(1, 3) :
796            printer = self.storage.getPrinter(self.PrinterName)
797            user = self.storage.getUser(self.UserName)
798            userpquota = self.storage.getUserPQuota(user, printer)
799            if printer.Exists and user.Exists and userpquota.Exists :
800                policy = "OK"
801                break
802            (policy, args) = self.config.getPrinterPolicy(self.PrinterName)
803            if policy == "EXTERNAL" :
804                commandline = self.formatCommandLine(args, user, printer)
805                if not printer.Exists :
806                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.PrinterName, commandline, self.PrinterName))
807                if not user.Exists :
808                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.UserName, commandline, self.PrinterName))
809                if not userpquota.Exists :
810                    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))
811                if os.system(commandline.encode(self.charset, "replace")) :
812                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.PrinterName), "error")
813                    policy = "EXTERNALERROR"
814                    break
815            else :
816                if not printer.Exists :
817                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.PrinterName, policy))
818                if not user.Exists :
819                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.UserName, policy, self.PrinterName))
820                if not userpquota.Exists :
821                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.UserName, self.PrinterName, policy))
822                break
823
824        if policy == "EXTERNAL" :
825            if not printer.Exists :
826                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.PrinterName)
827            if not user.Exists :
828                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.UserName, self.PrinterName))
829            if not userpquota.Exists :
830                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.UserName, self.PrinterName))
831        self.Policy = policy
832        self.Printer = printer
833        self.User = user
834        self.UserPQuota = userpquota
835        self.logdebug("Retrieval of printer, user and user print quota entry done.")
836
837    def getBillingCode(self) :
838        """Extracts the billing code from the database.
839
840           An optional script is launched to notify the user when
841           the billing code is unknown and PyKota was configured to
842           deny printing in this case.
843        """
844        self.logdebug("Retrieving billing code information from the database...")
845        self.BillingCode = None
846        if self.Ticket.BillingCode :
847            self.BillingCode = self.storage.getBillingCode(self.Ticket.BillingCode)
848            if self.BillingCode.Exists :
849                self.logdebug("Billing code [%s] found in database." % self.Ticket.BillingCode)
850            else :
851                msg = "Unknown billing code [%s] : " % self.Ticket.BillingCode
852                (newaction, script) = self.config.getUnknownBillingCode(self.PrinterName)
853                if newaction == "CREATE" :
854                    self.logdebug(msg + "will be created.")
855                    self.storage.addBillingCode(self.BillingCode)
856                    self.BillingCode = self.storage.getBillingCode(self.Ticket.BillingCode)
857                    if self.BillingCode.Exists :
858                        self.logdebug(msg + "has been created.")
859                    else :
860                        self.printInfo(msg + "couldn't be created.", "error")
861                else :
862                    self.logdebug(msg + "job will be denied.")
863                    self.Action = newaction
864                    if script is not None :
865                        self.logdebug(msg + "launching subprocess [%s] to notify user." % script)
866                        os.system(script.encode(self.charset, "replace"))
867        self.logdebug("Retrieval of billing code information done.")
868
869    def checkIfDupe(self) :
870        """Checks if the job is a duplicate, and handles the situation."""
871        self.logdebug("Checking if the job is a duplicate...")
872        denyduplicates = self.config.getDenyDuplicates(self.PrinterName)
873        if not denyduplicates :
874            self.logdebug("We don't care about duplicate jobs after all.")
875        else :
876            if self.Printer.LastJob.Exists \
877                    and (self.Printer.LastJob.UserName == self.UserName) \
878                    and (self.Printer.LastJob.JobMD5Sum == self.JobMD5Sum) :
879                now = DateTime.now()
880                try :
881                    previous = DateTime.ISO.ParseDateTime(str(self.Printer.LastJob.JobDate)[:19])
882                except :
883                    previous = now
884                difference = (now - previous).seconds
885                duplicatesdelay = self.config.getDuplicatesDelay(self.PrinterName)
886                self.logdebug("Difference with previous job : %.2f seconds. Duplicates delay : %.2f seconds." % (difference, duplicatesdelay))
887                if difference > duplicatesdelay :
888                    self.logdebug("Duplicate job allowed because previous one is more than %.2f seconds old." % duplicatesdelay)
889                else :
890                    # TODO : use the current user's last job instead of
891                    # TODO : the current printer's last job. This would be
892                    # TODO : better but requires an additional database query
893                    # TODO : with SQL, and is much more complex with the
894                    # TODO : actual LDAP schema. Maybe this is not very
895                    # TODO : important, because usually duplicate jobs are sucessive.
896                    msg = _("Job is a dupe")
897                    if denyduplicates is True :
898                        self.printInfo("%s : %s." % (msg, _("Printing is denied by configuration")), "warn")
899                        self.Action = "DENY"
900                        self.Reason = _("Duplicate print jobs are not allowed on printer %s.") % self.PrinterName
901                    else :
902                        self.logdebug("Launching subprocess [%s] to see if duplicate jobs should be allowed or not." % denyduplicates)
903                        fanswer = os.popen(denyduplicates.encode(self.charset, "replace"), "r")
904                        self.Action = fanswer.read().strip().upper()
905                        fanswer.close()
906                        if self.Action == "DENY" :
907                            self.printInfo("%s : %s." % (msg, _("Subprocess denied printing of a dupe")), "warn")
908                            self.Reason = _("Duplicate print jobs are not allowed on printer %s at this time.") % self.PrinterName
909                        else :
910                            self.printInfo("%s : %s." % (msg, _("Subprocess allowed printing of a dupe")), "warn")
911            else :
912                self.logdebug("Job doesn't seem to be a duplicate.")
913        self.logdebug("Checking if the job is a duplicate done.")
914
915    def tellUser(self) :
916        """Sends a message to an user."""
917        self.logdebug("Sending some feedback to user %s..." % self.UserName)
918        if not self.Reason :
919            self.logdebug("No feedback to send to user %s." % self.UserName)
920        else :
921            (mailto, arguments) = self.config.getMailTo(self.PrinterName)
922            if mailto == "EXTERNAL" :
923                # TODO : clean this again
924                self.externalMailTo(arguments, self.Action, self.User, self.Printer, self.Reason)
925            else :
926                # TODO : clean this again
927                admin = self.config.getAdmin(self.PrinterName)
928                adminmail = self.config.getAdminMail(self.PrinterName)
929                usermail = self.User.Email or self.User.Name
930                if "@" not in usermail :
931                    usermail = "%s@%s" % (usermail, self.maildomain or self.smtpserver)
932                destination = []
933                if mailto in ("BOTH", "ADMIN") :
934                    destination.append(adminmail)
935                if mailto in ("BOTH", "USER") :
936                    destination.append(usermail)
937
938                fullmessage = self.Reason + (_("\n\nYour system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail))
939                try :
940                    server = smtplib.SMTP(self.smtpserver)
941                except socket.error, msg :
942                    self.printInfo(_("Impossible to connect to SMTP server : %s") % msg, "error")
943                else :
944                    try :
945                        msg = MIMEText(fullmessage.encode(self.charset,
946                                                          "replace"),
947                                       _charset=self.charset)
948                        subject = _("Print Quota")
949                        msg["Subject"] = Header(subject.encode(self.charset, "replace"),
950                                                charset=self.charset,
951                                                errors="replace")
952                        msg["From"] = adminmail
953                        if mailto in ("BOTH", "USER") :
954                            msg["To"] = usermail
955                            if mailto == "BOTH" :
956                                msg["Cc"] = adminmail
957                        else :
958                            msg["To"] = adminmail
959                        msg["Date"] = email.Utils.formatdate(localtime=True)
960                        server.sendmail(adminmail, destination, msg.as_string())
961                    except smtplib.SMTPException, answer :
962                        try :
963                            for (k, v) in answer.recipients.items() :
964                                self.printInfo(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
965                        except AttributeError :
966                            self.printInfo(_("Problem when sending mail : %s") % str(answer), "error")
967                    server.quit()
968            self.logdebug("Feedback sent to user %s." % self.UserName)
969
970    def mainWork(self) :
971        """Main work is done here."""
972        if not self.JobSizeBytes :
973            # if no data to pass to real backend, probably a filter
974            # higher in the chain failed because of a misconfiguration.
975            # we deny the job in this case (nothing to print anyway)
976            self.Reason = _("Job contains no data. Printing is denied.")
977            self.printInfo(self.Reason, "error")
978            self.tellUser()
979            return self.removeJob()
980
981        self.getPrinterUserAndUserPQuota()
982        if self.Policy == "EXTERNALERROR" :
983            # Policy was 'EXTERNAL' and the external command returned an error code
984            self.Reason = _("Error in external policy script. Printing is denied.")
985            self.printInfo(self.Reason, "error")
986            self.tellUser()
987            return self.removeJob()
988        elif self.Policy == "EXTERNAL" :
989            # Policy was 'EXTERNAL' and the external command wasn't able
990            # to add either the printer, user or user print quota
991            self.Reason = _("Still no print quota entry for user %s on printer %s after external policy. Printing is denied.") % (self.UserName, self.PrinterName)
992            self.printInfo(self.Reason, "warn")
993            self.tellUser()
994            return self.removeJob()
995        elif self.Policy == "DENY" :
996            # Either printer, user or user print quota doesn't exist,
997            # and the job should be rejected.
998            self.Reason = _("Printing is denied by printer policy.")
999            self.printInfo(self.Reason, "warn")
1000            self.tellUser()
1001            return self.removeJob()
1002        elif self.Policy == "ALLOW" :
1003            # ALLOW means : Either printer, user or user print quota doesn't exist,
1004            #               but the job should be allowed anyway.
1005            self.Reason = _("Job allowed by printer policy. No accounting will be done.")
1006            self.printInfo(self.Reason, "warn")
1007            self.tellUser()
1008            return self.printJobDatas()
1009        elif self.Policy == "OK" :
1010            # OK means : Both printer, user and user print quota exist, job should
1011            #            be allowed if current user is allowed to print on this printer
1012            return self.doWork()
1013        else :
1014            self.Reason = _("Invalid policy %s for printer %s") % (self.Policy, self.PrinterName)
1015            self.printInfo(self.Reason, "error")
1016            self.tellUser()
1017            return self.removeJob()
1018
1019    def doWork(self) :
1020        """The accounting work is done here."""
1021        self.precomputeJobPrice()
1022        self.exportUserInfo()
1023        self.exportPrinterInfo()
1024        self.exportPhaseInfo("BEFORE")
1025
1026        if self.Action not in ("DENY", "CANCEL") :
1027            if self.Printer.MaxJobSize and (self.softwareJobSize > self.Printer.MaxJobSize) :
1028                # This printer was set to refuse jobs this large.
1029                self.printInfo(_("Precomputed job size (%s pages) too large for printer %s.") % (self.softwareJobSize, self.PrinterName), "warn")
1030                self.Action = "DENY"
1031                # here we don't put the precomputed job size in the message
1032                # because in case of error the user could complain :-)
1033                self.Reason = _("You are not allowed to print so many pages on printer %s at this time.") % self.PrinterName
1034
1035        if self.Action not in ("DENY", "CANCEL") :
1036            if (self.UserPQuota.MaxJobSize is not None) and (self.softwareJobSize > self.UserPQuota.MaxJobSize) :
1037                # This user print quota entry was set to refuse jobs this large.
1038                self.printInfo(_("Precomputed job size (%s pages) too large for user %s on printer %s.") % (self.softwareJobSize, self.UserName, self.PrinterName), "warn")
1039                self.Action = "DENY"
1040                # here we don't put the precomputed job size in the message
1041                # because in case of error the user could complain :-)
1042                self.Reason = _("You are not allowed to print so many pages on printer %s at this time.") % self.PrinterName
1043
1044        if self.Action not in ("DENY", "CANCEL") :
1045            if self.User.LimitBy == "noprint" :
1046                self.printInfo(_("User %s is not allowed to print at this time.") % self.UserName, "warn")
1047                self.Action = "DENY"
1048                self.Reason = _("Your account settings forbid you to print at this time.")
1049
1050        if self.Action not in ("DENY", "CANCEL") :
1051            # If printing is still allowed at this time, we
1052            # need to extract the billing code information from the database.
1053            # No need to do this if the job is denied, this way we
1054            # save some database queries.
1055            self.getBillingCode()
1056
1057        if self.Action not in ("DENY", "CANCEL") :
1058            # If printing is still allowed at this time, we
1059            # need to check if the job is a dupe or not, and what to do then.
1060            # No need to do this if the job is denied, this way we
1061            # save some database queries.
1062            self.checkIfDupe()
1063
1064        if self.Action not in ("DENY", "CANCEL") :
1065            # If printing is still allowed at this time, we
1066            # need to check the user's print quota on the current printer.
1067            # No need to do this if the job is denied, this way we
1068            # save some database queries.
1069            if self.User.LimitBy in ('noquota', 'nochange') :
1070                self.logdebug("User %s is allowed to print with no limit, no need to check quota." % self.UserName)
1071            elif self.Printer.PassThrough :
1072                self.logdebug("Printer %s is in PassThrough mode, no need to check quota." % self.PrinterName)
1073            else :
1074                self.logdebug("Checking user %s print quota entry on printer %s" \
1075                                    % (self.UserName, self.PrinterName))
1076                self.Action = self.checkUserPQuota(self.UserPQuota)
1077                if self.Action.startswith("POLICY_") :
1078                    self.Action = self.Action[7:]
1079                if self.Action == "DENY" :
1080                    self.printInfo(_("Print Quota exceeded for user %s on printer %s") % (self.UserName, self.PrinterName))
1081                    self.Reason = self.config.getHardWarn(self.PrinterName)
1082                elif self.Action == "WARN" :
1083                    self.printInfo(_("Print Quota low for user %s on printer %s") % (self.UserName, self.PrinterName))
1084                    if self.User.LimitBy and (self.User.LimitBy.lower() == "balance") :
1085                        self.Reason = self.config.getPoorWarn()
1086                    else :
1087                        self.Reason = self.config.getSoftWarn(self.PrinterName)
1088
1089        # If job still allowed to print, should we ask for confirmation ?
1090        if self.Action not in ("DENY", "CANCEL") :
1091            if not self.didUserConfirm() :
1092                self.Action = "CANCEL"
1093                self.Reason = _("Print job cancelled.")
1094                setenv("PYKOTASTATUS", "CANCELLED", self.charset)
1095
1096        # exports some new environment variables
1097        self.exportReason()
1098
1099        # now tell the user if he needs to know something
1100        self.tellUser()
1101
1102        # launches the pre hook
1103        self.launchPreHook()
1104
1105        # handle starting banner pages without accounting
1106        self.BannerSize = 0
1107        accountbanner = self.config.getAccountBanner(self.PrinterName)
1108        if (self.Action != "CANCEL") and accountbanner in ["ENDING", "NONE"] :
1109            self.handleBanner("starting", 0)
1110
1111        if self.Action == "DENY" :
1112            self.printInfo(_("Job denied, no accounting will be done."))
1113        elif self.Action == "CANCEL" :
1114            self.printInfo(_("Job cancelled, no accounting will be done."))
1115        else :
1116            self.printInfo(_("Job accounting begins."))
1117            self.accounter.beginJob(self.Printer)
1118
1119        # handle starting banner pages with accounting
1120        if (self.Action != "CANCEL") and accountbanner in ["STARTING", "BOTH"] :
1121            self.handleBanner("starting", 1)
1122
1123        # pass the job's data to the real backend if needed
1124        if self.Action in ("ALLOW", "WARN") :
1125            retcode = self.printJobDatas()
1126        else :
1127            retcode = self.removeJob()
1128
1129        # indicate phase change
1130        self.exportPhaseInfo("AFTER")
1131
1132        # handle ending banner pages with accounting
1133        if (self.Action != "CANCEL") and accountbanner in ["ENDING", "BOTH"] :
1134            self.handleBanner("ending", 1)
1135
1136        # stops accounting
1137        if self.Action == "DENY" :
1138            self.printInfo(_("Job denied, no accounting has been done."))
1139        elif self.Action == "CANCEL" :
1140            self.printInfo(_("Job cancelled, no accounting has been done."))
1141        else :
1142            self.accounter.endJob(self.Printer)
1143            self.printInfo(_("Job accounting ends."))
1144
1145        # Do all these database changes within a single transaction
1146        # NB : we don't enclose ALL the changes within a single transaction
1147        # because while waiting for the printer to answer its internal page
1148        # counter, we would open the door to accounting problems for other
1149        # jobs launched by the same user at the same time on other printers.
1150        # All the code below doesn't take much time, so it's fine.
1151        self.storage.beginTransaction()
1152        try :
1153            onbackenderror = self.config.getPrinterOnBackendError(self.PrinterName)
1154            if retcode :
1155                # NB : We don't send any feedback to the end user. Only the admin
1156                # has to know that the real CUPS backend failed.
1157                self.Action = "PROBLEM"
1158                self.exportReason()
1159                if "NOCHARGE" in onbackenderror :
1160                    self.JobSize = 0
1161                    self.printInfo(_("Job size forced to 0 because the real CUPS backend failed. No accounting will be done."), "warn")
1162                else :
1163                    self.printInfo(_("The real CUPS backend failed, but the job will be accounted for anyway."), "warn")
1164
1165            # retrieve the job size
1166            if self.Action == "DENY" :
1167                self.JobSize = 0
1168                self.printInfo(_("Job size forced to 0 because printing is denied."))
1169            elif self.Action == "CANCEL" :
1170                self.JobSize = 0
1171                self.printInfo(_("Job size forced to 0 because printing was cancelled."))
1172            else :
1173                self.UserPQuota.resetDenyBannerCounter()
1174                if (self.Action != "PROBLEM") or ("CHARGE" in onbackenderror) :
1175                    self.JobSize = self.accounter.getJobSize(self.Printer)
1176                    self.sanitizeJobSize()
1177                    self.JobSize += self.BannerSize
1178                else :
1179                    self.JobSize = 0
1180            self.printInfo(_("Job size : %i") % self.JobSize)
1181
1182            if ((self.Action == "PROBLEM") and ("NOCHARGE" in onbackenderror)) or \
1183                (self.Action in ("DENY", "CANCEL")) :
1184                self.JobPrice = 0.0
1185            elif (self.User.LimitBy == "nochange") or self.Printer.PassThrough :
1186                # no need to update the quota for the current user on this printer
1187                self.printInfo(_("User %s's quota on printer %s won't be modified") % (self.UserName, self.PrinterName))
1188                self.JobPrice = 0.0
1189            else :
1190                # update the quota for the current user on this printer
1191                self.printInfo(_("Updating user %s's quota on printer %s") % (self.UserName, self.PrinterName))
1192                self.JobPrice = self.UserPQuota.increasePagesUsage(self.JobSize, self.accounter.inkUsage)
1193
1194            # adds the current job to history
1195            self.Printer.addJobToHistory(self.Ticket.JobId, self.User, self.accounter.getLastPageCounter(), \
1196                                    self.Action, self.JobSize, self.JobPrice, self.Ticket.FileName, \
1197                                    self.Ticket.Title, self.Ticket.Copies, self.Ticket.Options, self.Ticket.OriginatingHostName, \
1198                                    self.JobSizeBytes, self.JobMD5Sum, None, self.Ticket.BillingCode, \
1199                                    self.softwareJobSize, self.softwareJobPrice)
1200            self.printInfo(_("Job added to history."))
1201
1202            if hasattr(self, "BillingCode") and self.BillingCode and self.BillingCode.Exists :
1203                if (self.Action in ("ALLOW", "WARN")) or \
1204                   ((self.Action == "PROBLEM") and ("CHARGE" in onbackenderror)) :
1205                    self.BillingCode.consume(self.JobSize, self.JobPrice)
1206                    self.printInfo(_("Billing code %s was updated.") % self.BillingCode.BillingCode)
1207        except :
1208            self.storage.rollbackTransaction()
1209            raise
1210        else :
1211            self.storage.commitTransaction()
1212
1213        # exports some new environment variables
1214        self.exportJobSizeAndPrice()
1215
1216        # then re-export user information with new values
1217        self.exportUserInfo()
1218
1219        # handle ending banner pages without accounting
1220        if (self.Action != "CANCEL") and accountbanner in ["STARTING", "NONE"] :
1221            self.handleBanner("ending", 0)
1222
1223        self.launchPostHook()
1224
1225        return retcode
1226
1227    def printJobDatas(self) :
1228        """Sends the job's datas to the real backend."""
1229        self.logdebug("Sending job's datas to real backend...")
1230
1231        delay = 0
1232        number = 1
1233        for onb in self.config.getPrinterOnBackendError(self.PrinterName) :
1234            if onb.startswith("RETRY:") :
1235                try :
1236                    (number, delay) = [int(p) for p in onb[6:].split(":", 2)]
1237                    if (number < 0) or (delay < 0) :
1238                        raise ValueError
1239                except ValueError :
1240                    self.printInfo(_("Incorrect value for the 'onbackenderror' directive in section [%s]") % self.PrinterName, "error")
1241                    delay = 0
1242                    number = 1
1243                else :
1244                    break
1245        loopcnt = 1
1246        while True :
1247            if self.Ticket.FileName is None :
1248                infile = open(self.DataFile, "rb")
1249            else :
1250                infile = None
1251            retcode = self.runOriginalBackend(infile)
1252            if self.Ticket.FileName is None :
1253                infile.close()
1254            if not retcode :
1255                break
1256            else :
1257                if (not number) or (loopcnt < number) :
1258                    self.logdebug(_("The real backend produced an error, we will try again in %s seconds.") % delay)
1259                    time.sleep(delay)
1260                    loopcnt += 1
1261                else :
1262                    break
1263
1264        self.logdebug("Job's datas sent to real backend.")
1265        return retcode
1266
1267    def runOriginalBackend(self, filehandle=None, isBanner=0) :
1268        """Launches the original backend."""
1269        originalbackend = os.path.join(os.path.split(sys.argv[0])[0], self.RealBackend)
1270        if not isBanner :
1271            arguments = [os.environ["DEVICE_URI"]] + [a.encode("UTF-8") for a in sys.argv[1:]]
1272        else :
1273            # For banners, we absolutely WANT
1274            # to remove any filename from the command line !
1275            self.logdebug("It looks like we try to print a banner.")
1276            arguments = [os.environ["DEVICE_URI"]] + [a.encode("UTF-8") for a in sys.argv[1:6]]
1277        arguments[2] = self.UserName # in case it was overwritten by external script
1278        # TODO : do something about job-billing option, in case it was overwritten as well...
1279        # TODO : do something about the job title : if we are printing a banner and the backend
1280        # TODO : uses the job's title to name an output file (cups-pdf:// for example), we're stuck !
1281
1282        self.logdebug("Starting original backend %s with args %s" % (originalbackend, " ".join(['"%s"' % a.decode("UTF-8") for a in arguments])))
1283        pid = os.fork()
1284        self.logdebug("Forked !")
1285        if pid == 0 :
1286            if filehandle is not None :
1287                self.logdebug("Redirecting file handle to real backend's stdin")
1288                os.dup2(filehandle.fileno(), 0)
1289            try :
1290                self.logdebug("Calling execve...")
1291                os.execve(originalbackend, arguments, os.environ)
1292            except OSError, msg :
1293                self.logdebug("execve() failed: %s" % msg)
1294            self.logdebug("We shouldn't be there !!!")
1295            os._exit(-1)
1296
1297        self.logdebug("Waiting for original backend to exit...")
1298        killed = False
1299        status = -1
1300        while status == -1 :
1301            try :
1302                status = os.waitpid(pid, 0)[1]
1303            except OSError, (err, msg) :
1304                if err == 4 :
1305                    killed = True
1306
1307        if os.WIFEXITED(status) :
1308            status = os.WEXITSTATUS(status)
1309            message = "CUPS backend %s returned %d." % \
1310                            (originalbackend, status)
1311            if status :
1312                level = "error"
1313                self.Reason = message
1314            else :
1315                level = "info"
1316            self.printInfo(message, level)
1317            return status
1318        elif not killed :
1319            self.Reason = "CUPS backend %s died abnormally." % originalbackend
1320            self.printInfo(self.Reason, "error")
1321            return 1
1322        else :
1323            self.Reason = "CUPS backend %s was killed." % originalbackend
1324            self.printInfo(self.Reason, "warn")
1325            return 1
1326
1327if __name__ == "__main__" :
1328    # This is a CUPS backend, we should act and die like a CUPS backend
1329    wrapper = CUPSBackend()
1330    if len(sys.argv) == 1 :
1331        print "\n".join(wrapper.discoverOtherBackends())
1332        sys.exit(0)
1333    elif len(sys.argv) not in (6, 7) :
1334        logerr("ERROR: %s job-id user title copies options [file]\n"\
1335                              % sys.argv[0])
1336        sys.exit(1)
1337    else :
1338        os.environ["PATH"] = "%s:/bin:/usr/bin:/usr/local/bin:/opt/bin:/sbin:/usr/sbin" % os.environ.get("PATH", "")
1339        try :
1340            try :
1341                wrapper.deferredInit()
1342                wrapper.initBackendParameters()
1343                wrapper.waitForLock()
1344                if os.environ.get("PYKOTASTATUS") == "CANCELLED" :
1345                    raise KeyboardInterrupt
1346                wrapper.saveDatasAndCheckSum()
1347                wrapper.exportJobInfo() # exports a first time to give hints to external scripts
1348                wrapper.preaccounter = openAccounter(wrapper, ispreaccounter=1)
1349                wrapper.accounter = openAccounter(wrapper)
1350                wrapper.precomputeJobSize()
1351                wrapper.exportJobInfo() # exports a second time, now that we know the job's size. TODO : don't reexport all
1352                wrapper.overwriteJobAttributes()
1353                wrapper.exportJobInfo() # re-exports in case it was overwritten. TODO : don't reexport all.
1354                retcode = wrapper.mainWork()
1355            except KeyboardInterrupt :
1356                wrapper.printInfo(_("Job %s interrupted by the administrator !") % wrapper.Ticket.JobId, "warn")
1357                retcode = 0
1358            except SystemExit, err :
1359                retcode = err.code
1360            except :
1361                try :
1362                    wrapper.crashed("cupspykota backend failed")
1363                except :
1364                    crashed("cupspykota backend failed")
1365                retcode = 1
1366        finally :
1367            wrapper.clean()
1368        sys.exit(retcode)
Note: See TracBrowser for help on using the browser.