root / pykota / trunk / bin / cupspykota @ 1203

Revision 1203, 18.2 kB (checked in by jalet, 20 years ago)

Job price added to history

  • Property svn:eol-style set to native
  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Revision
Line 
1#! /usr/bin/env python
2# -*- coding: ISO-8859-15 -*-
3
4# CUPSPyKota accounting backend
5#
6# PyKota - Print Quotas for CUPS and LPRng
7#
8# (c) 2003 Jerome Alet <alet@librelogiciel.com>
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation; either version 2 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program; if not, write to the Free Software
21# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
22#
23# $Id$
24#
25# $Log$
26# Revision 1.13  2003/11/23 19:01:35  jalet
27# Job price added to history
28#
29# Revision 1.12  2003/11/21 14:28:43  jalet
30# More complete job history.
31#
32# Revision 1.11  2003/11/19 23:19:35  jalet
33# Code refactoring work.
34# Explicit redirection to /dev/null has to be set in external policy now, just
35# like in external mailto.
36#
37# Revision 1.10  2003/11/18 17:54:24  jalet
38# SIGTERMs are now transmitted to original backends.
39#
40# Revision 1.9  2003/11/18 14:11:07  jalet
41# Small fix for bizarre urls
42#
43# Revision 1.8  2003/11/15 14:26:44  jalet
44# General improvements to the documentation.
45# Email address changed in sample configuration file, because
46# I receive low quota messages almost every day...
47#
48# Revision 1.7  2003/11/14 22:05:12  jalet
49# New CUPS backend fully functionnal.
50# Old CUPS configuration method is now officially deprecated.
51#
52# Revision 1.6  2003/11/14 20:13:11  jalet
53# We exit the loop too soon.
54#
55# Revision 1.5  2003/11/14 18:31:27  jalet
56# Not perfect, but seems to work with the poll() loop.
57#
58# Revision 1.4  2003/11/14 17:04:15  jalet
59# More (untested) work on the CUPS backend.
60#
61# Revision 1.3  2003/11/12 23:27:44  jalet
62# More work on new backend. This commit may be unstable.
63#
64# Revision 1.2  2003/11/12 09:33:34  jalet
65# New CUPS backend supports device enumeration
66#
67# Revision 1.1  2003/11/08 16:05:31  jalet
68# CUPS backend added for people to experiment.
69#
70#
71#
72
73import sys
74import os
75import popen2
76import time
77import cStringIO
78import shlex
79import select
80import signal
81
82from pykota.tool import PyKotaFilterOrBackend, PyKotaToolError
83from pykota.config import PyKotaConfigError
84from pykota.storage import PyKotaStorageError
85from pykota.accounter import PyKotaAccounterError
86from pykota.requester import PyKotaRequesterError
87
88class PyKotaPopen3(popen2.Popen3) :
89    """Our own class to execute real backends.
90   
91       Their first argument is different from their path so using
92       native popen2.Popen3 would not be feasible.
93    """
94    def __init__(self, cmd, capturestderr=False, bufsize=-1, arg0=None) :
95        self.arg0 = arg0
96        popen2.Popen3.__init__(self, cmd, capturestderr, bufsize)
97       
98    def _run_child(self, cmd):
99        for i in range(3, 256): # TODO : MAXFD in original popen2 module
100            try:
101                os.close(i)
102            except OSError:
103                pass
104        try:
105            os.execvpe(cmd[0], [self.arg0 or cmd[0]] + cmd[1:], os.environ)
106        finally:
107            os._exit(1)
108   
109gotSigTerm = 0
110def sigterm_handler(signum, frame) :
111    """Sets a global variable whenever SIGTERM is received."""
112    # SIGTERM will be ignore most of the time, but during
113    # the call to the real backend, we have to pass it through.
114    global gotSigTerm
115    gotSigTerm = 1
116   
117def main(thebackend) :   
118    """Do it, and do it right !"""
119    # first deal with signals
120    # CUPS backends ignore SIGPIPE and exit(1) on SIGTERM
121    # Fortunately SIGPIPE is already ignored by Python
122    # It's there just in case this changes in the future.
123    # Here we have to handle SIGTERM correctly, and pass
124    # it to the original backend if needed.
125    global gotSigTerm
126    gotSigTerm = 0
127    signal.signal(signal.SIGPIPE, signal.SIG_IGN)
128    signal.signal(signal.SIGTERM, sigterm_handler)
129   
130    #
131    # retrieve some informations on the current printer and user
132    # from the Quota Storage.
133    printer = thebackend.storage.getPrinter(thebackend.printername)
134    if not printer.Exists :
135        # The printer is unknown from the Quota Storage perspective
136        # we let the job pass through, but log a warning message
137        thebackend.logger.log_message(_("Printer %s not registered in the PyKota system") % thebackend.printername, "warn")
138        action = "ALLOW"
139    else :   
140        for dummy in range(2) :
141            user = thebackend.storage.getUser(thebackend.username)
142            if user.Exists :
143                break
144            else :   
145                # The user is unknown from the Quota Storage perspective
146                # Depending on the default policy for this printer, we
147                # either let the job pass through or reject it, but we
148                # log a message in any case.
149                (policy, args) = thebackend.config.getPrinterPolicy(thebackend.printername)
150                if policy == "ALLOW" :
151                    action = "POLICY_ALLOW"
152                elif policy == "EXTERNAL" :   
153                    commandline = thebackend.formatCommandLine(args, user, printer)
154                    thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (thebackend.username, commandline, thebackend.printername), "info")
155                    if os.system(commandline) :
156                        # if an error occured, we die without error,
157                        # so that the job doesn't stop the print queue.
158                        thebackend.logger.log_message(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, thebackend.printername), "error")
159                        return 0
160                    else :   
161                        # here we try a second time, because the goal
162                        # of the external action was to add the user
163                        # in the database.
164                        continue 
165                else :   
166                    action = "POLICY_DENY"
167                thebackend.logger.log_message(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (thebackend.username, action, thebackend.printername), "warn")
168                if action == "POLICY_DENY" :
169                    # if not allowed to print then die, else proceed.
170                    # we die without error, so that the job doesn't
171                    # stop the print queue.
172                    return 0
173                # when we get there, the printer policy allows the job to pass
174                break   
175                   
176        if user.Exists :
177            # Is the current user allowed to print at all ?
178            action = thebackend.warnUserPQuota(thebackend.storage.getUserPQuota(user, printer))
179        elif policy == "EXTERNAL" :               
180            # if the extenal policy produced no error, but the
181            # user still doesn't exist, we die without error,
182            # so that the job doesn't stop the print queue.
183            thebackend.logger.log_message(_("External policy %s for printer %s couldn't add user %s. Job rejected.") % (commandline, thebackend.printername, thebackend.username), "error")
184            return 0
185       
186    if action not in ["ALLOW", "WARN"] :   
187        # if not allowed to print then die, else proceed.
188        # we die without error, so that the job doesn't
189        # stop the print queue.
190        retcode = 0
191    else :
192        # pass the job untouched to the underlying layer
193        # and starts accounting at the same time
194        thebackend.accounter.beginJob(printer, user)
195       
196        # Now it becomes tricky...
197       
198        # First ensure that we have a file object as input
199        mustclose = 0   
200        if thebackend.inputfile is not None :   
201            if hasattr(thebackend.inputfile, "read") :
202                infile = thebackend.inputfile
203            else :   
204                infile = open(thebackend.inputfile, "rb")
205            mustclose = 1
206        else :   
207            infile = sys.stdin
208           
209        # Find the real backend pathname   
210        realbackend = os.path.join(os.path.split(sys.argv[0])[0], thebackend.originalbackend)
211       
212        # And launch it
213        subprocess = PyKotaPopen3([realbackend] + sys.argv[1:], capturestderr=1, bufsize=0, arg0=os.environ["DEVICE_URI"])
214       
215        # Save file descriptors, we will need them later.
216        infno = infile.fileno()
217        stdoutfno = sys.stdout.fileno()
218        stderrfno = sys.stderr.fileno()
219        fromcfno = subprocess.fromchild.fileno()
220        tocfno = subprocess.tochild.fileno()
221        cerrfno = subprocess.childerr.fileno()
222       
223        # We will have to be careful when dealing with I/O
224        # So we use a poll object to know when to read or write
225        pollster = select.poll()
226        pollster.register(infno, select.POLLIN | select.POLLPRI)
227        pollster.register(fromcfno, select.POLLIN | select.POLLPRI)
228        pollster.register(cerrfno, select.POLLIN | select.POLLPRI)
229        pollster.register(stdoutfno, select.POLLOUT)
230        pollster.register(stderrfno, select.POLLOUT)
231        pollster.register(tocfno, select.POLLOUT)
232       
233        # Initialize our buffers
234        indata = ""
235        outdata = ""
236        errdata = ""
237        endinput = 0
238        status = -1
239        while status == -1 :
240            # First check if original backend is still alive
241            status = subprocess.poll()
242           
243            # Now if we got SIGTERM, we have
244            # to kill -TERM the original backend
245            if gotSigTerm :
246                try :
247                    os.kill(subprocess.pid, signal.SIGTERM)
248                except : # ignore if process was already killed.
249                    pass
250           
251            # In any case, deal with any remaining I/O
252            availablefds = pollster.poll()
253            for (fd, mask) in availablefds :
254                if mask & select.POLLOUT :
255                    # We can write
256                    if fd == tocfno :
257                        if indata :
258                            os.write(fd, indata)   
259                            indata = ""
260                    elif fd == stdoutfno :
261                        if outdata :
262                            os.write(fd, outdata)
263                            outdata = ""
264                    elif fd == stderrfno :
265                        if errdata :
266                            os.write(fd, errdata)
267                            errdata = ""
268                if (mask & select.POLLIN) or (mask & select.POLLPRI) :     
269                    # We have something to read
270                    data = os.read(fd, 256 * 1024)
271                    if fd == infno :
272                        indata += data
273                        if not data :    # If yes, then no more input data
274                            endinput = 1 # this happens with real files.
275                    elif fd == fromcfno :
276                        outdata += data
277                    elif fd == cerrfno :   
278                        errdata += data
279                if (mask & select.POLLHUP) or (mask & select.POLLERR) :
280                    # I've never seen POLLERR myself, but this probably
281                    # can't hurt to treat an error condition just like
282                    # an EOF.
283                    #
284                    # Some standard I/O stream has no more datas
285                    if fd == infno :
286                        # Here we are in the case where the input file is stdin.
287                        # which has no more data to be read.
288                        endinput = 1
289                    elif fd == fromcfno :   
290                        # This should never happen, since
291                        # CUPS backends don't send anything on their
292                        # standard output
293                        if outdata :               
294                            try :
295                                os.write(stdoutfno, outdata)
296                                outdata = ""
297                            except :   
298                                pass
299                        try :       
300                            pollster.unregister(fromcfno)       
301                        except KeyError :   
302                            pass
303                        else :   
304                            os.close(fromcfno)
305                    elif fd == cerrfno :   
306                        # Original CUPS backend has finished
307                        # to write informations on its standard error
308                        if errdata :               
309                            # Try to write remaining info (normally "Ready to print.")
310                            try :
311                                os.write(stderrfno, errdata)
312                                errdata = ""
313                            except :   
314                                pass
315                        # We are no more interested in this file descriptor       
316                        try :       
317                            pollster.unregister(cerrfno)       
318                        except KeyError :   
319                            pass
320                        else :   
321                            os.close(cerrfno)
322                       
323            if endinput :           
324                # We deal with remaining input datas here
325                # because EOF can happen in two different
326                # situations and I don't want to duplicate
327                # code, nor making functions.
328                if indata :               
329                    try :
330                        os.write(tocfno, indata)
331                        indata = ""
332                    except :   
333                        pass
334                # Again, we're not interested in this file descriptor       
335                # anymore.
336                try :       
337                    pollster.unregister(tocfno)       
338                except KeyError :   
339                    pass
340                else :   
341                    os.close(tocfno)
342               
343        # Input file was a real file, we have to close it.   
344        if mustclose :
345            infile.close()
346           
347        # Check exit code of original CUPS backend.   
348        if os.WIFEXITED(status) :
349            retcode = os.WEXITSTATUS(status)
350        else :   
351            thebackend.logger.log_message(_("CUPS backend %s died abnormally.") % realbackend, "error")
352            retcode = -1
353   
354    # stops accounting.
355    thebackend.accounter.endJob(printer, user)
356       
357    # retrieve the job size   
358    jobsize = thebackend.accounter.getJobSize()
359   
360    # update the quota for the current user on this printer
361    if printer.Exists :
362        jobprice = (float(printer.PricePerPage or 0.0) * jobsize) + float(printer.PricePerJob or 0.0)
363        if jobsize :
364            userquota = thebackend.storage.getUserPQuota(user, printer)
365            if userquota.Exists :
366                userquota.increasePagesUsage(jobsize)
367       
368        # adds the current job to history   
369        printer.addJobToHistory(thebackend.jobid, user, thebackend.accounter.getLastPageCounter(), action, jobsize, jobprice, thebackend.preserveinputfile, thebackend.title, thebackend.copies, thebackend.options)
370   
371    return retcode # return (retcode or gotSigTerm) shouldn't be needed
372
373if __name__ == "__main__" :   
374    # This is a CUPS backend, we should act and die like a CUPS backend
375    if len(sys.argv) == 1 :
376        # we will execute each existing backend in device enumeration mode
377        # and generate their PyKota accounting counterpart
378        (directory, myname) = os.path.split(sys.argv[0])
379        for backend in [os.path.join(directory, b) for b in os.listdir(directory) if os.path.isfile(os.path.join(directory, b)) and (b != myname)] :
380            answer = os.popen(backend, "r")
381            try :
382                devices = [line.strip() for line in answer.readlines()]
383            except :   
384                devices = []
385            status = answer.close()
386            if status is None :
387                for d in devices :
388                    # each line is of the form : 'xxxx xxxx "xxxx xxx" "xxxx xxx"'
389                    # so we have to decompose it carefully
390                    fdevice = cStringIO.StringIO("%s" % d)
391                    tokenizer = shlex.shlex(fdevice)
392                    tokenizer.wordchars = tokenizer.wordchars + r".:,?!~/\_$*-+={}[]()#"
393                    arguments = []
394                    while 1 :
395                        token = tokenizer.get_token()
396                        if token :
397                            arguments.append(token)
398                        else :
399                            break
400                    fdevice.close()
401                    try :
402                        (devicetype, device, name, fullname) = arguments
403                    except ValueError :   
404                        pass    # ignore this 'bizarre' device
405                    else :   
406                        if name.startswith('"') and name.endswith('"') :
407                            name = name[1:-1]
408                        if fullname.startswith('"') and fullname.endswith('"') :
409                            fullname = fullname[1:-1]
410                        print '%s cupspykota:%s "PyKota+%s" "PyKota managed %s"' % (devicetype, device, name, fullname)
411        retcode = 0
412    elif len(sys.argv) not in (6, 7) :   
413        sys.stderr.write("ERROR: %s job-id user title copies options [file]\n" % sys.argv[0])
414        retcode = 1
415    else :   
416        try :
417            # Initializes the backend
418            kotabackend = PyKotaFilterOrBackend()   
419            retcode = main(kotabackend)
420        except (PyKotaToolError, PyKotaConfigError, PyKotaStorageError, PyKotaAccounterError, PyKotaRequesterError, AttributeError, KeyError, IndexError, ValueError, TypeError, IOError), msg :
421            sys.stderr.write("ERROR : cupspykota backend failed (%s)\n" % msg)
422            sys.stderr.flush()
423            retcode = 1
424       
425        try :
426            kotabackend.storage.close()
427        except (TypeError, NameError, AttributeError) :   
428            pass
429       
430    sys.exit(retcode)   
Note: See TracBrowser for help on using the browser.