root / pykota / trunk / pykota / tool.py @ 1562

Revision 1562, 49.8 kB (checked in by jalet, 20 years ago)

Always send some debug info to CUPS' back channel stream (stderr) as
informationnal messages.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
Line 
1# PyKota
2# -*- coding: ISO-8859-15 -*-
3
4# PyKota - Print Quotas for CUPS and LPRng
5#
6# (c) 2003-2004 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 2 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, write to the Free Software
19# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
20#
21# $Id$
22#
23# $Log$
24# Revision 1.106  2004/06/22 09:31:18  jalet
25# Always send some debug info to CUPS' back channel stream (stderr) as
26# informationnal messages.
27#
28# Revision 1.105  2004/06/21 08:17:38  jalet
29# Added version number in subject message for directive crashrecipient.
30#
31# Revision 1.104  2004/06/18 13:34:49  jalet
32# Now all tracebacks include PyKota's version number
33#
34# Revision 1.103  2004/06/18 13:17:26  jalet
35# Now includes PyKota's version number in messages sent by the crashrecipient
36# directive.
37#
38# Revision 1.102  2004/06/17 13:26:51  jalet
39# Better exception handling code
40#
41# Revision 1.101  2004/06/16 20:56:34  jalet
42# Smarter initialisation code
43#
44# Revision 1.100  2004/06/11 08:16:03  jalet
45# More exceptions catched in case of very early failure.
46#
47# Revision 1.99  2004/06/11 07:07:38  jalet
48# Now detects and logs configuration syntax errors instead of failing without
49# any notice message.
50#
51# Revision 1.98  2004/06/08 19:27:12  jalet
52# Doesn't ignore SIGCHLD anymore
53#
54# Revision 1.97  2004/06/07 22:45:35  jalet
55# Now accepts a job when enforcement is STRICT and predicted account balance
56# is equal to 0.0 : since the job hasn't been printed yet, only its printing
57# will really render balance equal to 0.0, so we should be allowed to print.
58#
59# Revision 1.96  2004/06/05 22:18:04  jalet
60# Now catches some exceptions earlier.
61# storage.py and ldapstorage.py : removed old comments
62#
63# Revision 1.95  2004/06/03 21:50:34  jalet
64# Improved error logging.
65# crashrecipient directive added.
66# Now exports the job's size in bytes too.
67#
68# Revision 1.94  2004/06/03 08:51:03  jalet
69# logs job's size in bytes now
70#
71# Revision 1.93  2004/06/02 21:51:02  jalet
72# Moved the sigterm capturing elsewhere
73#
74# Revision 1.92  2004/06/02 13:21:38  jalet
75# Debug message added
76#
77# Revision 1.91  2004/05/25 05:17:52  jalet
78# Now precomputes the job's size only if current printer's enforcement
79# is "STRICT"
80#
81# Revision 1.90  2004/05/24 22:45:49  jalet
82# New 'enforcement' directive added
83# Polling loop improvements
84#
85# Revision 1.89  2004/05/21 22:02:52  jalet
86# Preliminary work on pre-accounting
87#
88# Revision 1.88  2004/05/18 14:49:20  jalet
89# Big code changes to completely remove the need for "requester" directives,
90# jsut use "hardware(... your previous requester directive's content ...)"
91#
92# Revision 1.87  2004/05/17 19:14:59  jalet
93# Now catches SIGPIPE and SIGCHLD
94#
95# Revision 1.86  2004/05/13 13:59:28  jalet
96# Code simplifications
97#
98# Revision 1.85  2004/05/11 08:26:27  jalet
99# Now catches connection problems to SMTP server
100#
101# Revision 1.84  2004/04/21 08:36:32  jalet
102# Exports the PYKOTASTATUS environment variable when SIGTERM is received.
103#
104# Revision 1.83  2004/04/16 17:03:49  jalet
105# The list of printers groups the current printer is a member of is
106# now exported in the PYKOTAPGROUPS environment variable
107#
108# Revision 1.82  2004/04/13 09:38:03  jalet
109# More work on correct child processes handling
110#
111# Revision 1.81  2004/04/09 22:24:47  jalet
112# Began work on correct handling of child processes when jobs are cancelled by
113# the user. Especially important when an external requester is running for a
114# long time.
115#
116# Revision 1.80  2004/04/06 12:00:21  jalet
117# uninitialized values caused problems
118#
119# Revision 1.79  2004/03/28 21:01:29  jalet
120# PYKOTALIMITBY environment variable is now exported too
121#
122# Revision 1.78  2004/03/08 20:13:25  jalet
123# Allow names to begin with a digit
124#
125# Revision 1.77  2004/03/03 13:10:35  jalet
126# Now catches all smtplib exceptions when there's a problem sending messages
127#
128# Revision 1.76  2004/03/01 14:34:15  jalet
129# PYKOTAPHASE wasn't set at the right time at the end of data transmission
130# to underlying layer (real backend)
131#
132# Revision 1.75  2004/03/01 11:23:25  jalet
133# Pre and Post hooks to external commands are available in the cupspykota
134# backend. Forthe pykota filter they will be implemented real soon now.
135#
136# Revision 1.74  2004/02/26 14:18:07  jalet
137# Should fix the remaining bugs wrt printers groups and users groups.
138#
139# Revision 1.73  2004/02/19 14:20:21  jalet
140# maildomain pykota.conf directive added.
141# Small improvements on mail headers quality.
142#
143# Revision 1.72  2004/01/14 15:51:19  jalet
144# Docstring added.
145#
146# Revision 1.71  2004/01/11 23:22:42  jalet
147# Major code refactoring, it's way cleaner, and now allows automated addition
148# of printers on first print.
149#
150# Revision 1.70  2004/01/08 14:10:32  jalet
151# Copyright year changed.
152#
153# Revision 1.69  2004/01/05 16:02:18  jalet
154# Dots in user, groups and printer names should be allowed.
155#
156# Revision 1.68  2004/01/02 17:38:40  jalet
157# This time it should be better...
158#
159# Revision 1.67  2004/01/02 17:37:09  jalet
160# I'm completely stupid !!! Better to not talk while coding !
161#
162# Revision 1.66  2004/01/02 17:31:26  jalet
163# Forgot to remove some code
164#
165# Revision 1.65  2003/12/06 08:14:38  jalet
166# Added support for CUPS device uris which contain authentication information.
167#
168# Revision 1.64  2003/11/29 22:03:17  jalet
169# Some code refactoring work. New code is not used at this time.
170#
171# Revision 1.63  2003/11/29 20:06:20  jalet
172# Added 'utolower' configuration option to convert all usernames to
173# lowercase when printing. All database accesses are still and will
174# remain case sensitive though.
175#
176# Revision 1.62  2003/11/25 22:37:22  jalet
177# Small code move
178#
179# Revision 1.61  2003/11/25 22:03:28  jalet
180# No more message on stderr when the translation is not available.
181#
182# Revision 1.60  2003/11/25 21:54:05  jalet
183# updated FAQ
184#
185# Revision 1.59  2003/11/25 13:33:43  jalet
186# Puts 'root' instead of '' when printing from CUPS web interface (which
187# gives an empty username)
188#
189# Revision 1.58  2003/11/21 14:28:45  jalet
190# More complete job history.
191#
192# Revision 1.57  2003/11/19 23:19:38  jalet
193# Code refactoring work.
194# Explicit redirection to /dev/null has to be set in external policy now, just
195# like in external mailto.
196#
197# Revision 1.56  2003/11/19 07:40:20  jalet
198# Missing import statement.
199# Better documentation for mailto: external(...)
200#
201# Revision 1.55  2003/11/18 23:43:12  jalet
202# Mailto can be any external command now, as usual.
203#
204# Revision 1.54  2003/10/24 21:52:46  jalet
205# Now can force language when coming from CGI script.
206#
207# Revision 1.53  2003/10/08 21:41:38  jalet
208# External policies for printers works !
209# We can now auto-add users on first print, and do other useful things if needed.
210#
211# Revision 1.52  2003/10/07 09:07:28  jalet
212# Character encoding added to please latest version of Python
213#
214# Revision 1.51  2003/10/06 14:21:41  jalet
215# Test reversed to not retrieve group members when no messages for them.
216#
217# Revision 1.50  2003/10/02 20:23:18  jalet
218# Storage caching mechanism added.
219#
220# Revision 1.49  2003/07/29 20:55:17  jalet
221# 1.14 is out !
222#
223# Revision 1.48  2003/07/21 23:01:56  jalet
224# Modified some messages aout soft limit
225#
226# Revision 1.47  2003/07/16 21:53:08  jalet
227# Really big modifications wrt new configuration file's location and content.
228#
229# Revision 1.46  2003/07/09 20:17:07  jalet
230# Email field added to PostgreSQL schema
231#
232# Revision 1.45  2003/07/08 19:43:51  jalet
233# Configurable warning messages.
234# Poor man's treshold value added.
235#
236# Revision 1.44  2003/07/07 11:49:24  jalet
237# Lots of small fixes with the help of PyChecker
238#
239# Revision 1.43  2003/07/04 09:06:32  jalet
240# Small bug fix wrt undefined "LimitBy" field.
241#
242# Revision 1.42  2003/06/30 12:46:15  jalet
243# Extracted reporting code.
244#
245# Revision 1.41  2003/06/25 14:10:01  jalet
246# Hey, it may work (edpykota --reset excepted) !
247#
248# Revision 1.40  2003/06/10 16:37:54  jalet
249# Deletion of the second user which is not needed anymore.
250# Added a debug configuration field in /etc/pykota.conf
251# All queries can now be sent to the logger in debug mode, this will
252# greatly help improve performance when time for this will come.
253#
254# Revision 1.39  2003/04/29 18:37:54  jalet
255# Pluggable accounting methods (actually doesn't support external scripts)
256#
257# Revision 1.38  2003/04/24 11:53:48  jalet
258# Default policy for unknown users/groups is to DENY printing instead
259# of the previous default to ALLOW printing. This is to solve an accuracy
260# problem. If you set the policy to ALLOW, jobs printed by in nexistant user
261# (from PyKota's POV) will be charged to the next user who prints on the
262# same printer.
263#
264# Revision 1.37  2003/04/24 08:08:27  jalet
265# Debug message forgotten
266#
267# Revision 1.36  2003/04/24 07:59:40  jalet
268# LPRng support now works !
269#
270# Revision 1.35  2003/04/23 22:13:57  jalet
271# Preliminary support for LPRng added BUT STILL UNTESTED.
272#
273# Revision 1.34  2003/04/17 09:26:21  jalet
274# repykota now reports account balances too.
275#
276# Revision 1.33  2003/04/16 12:35:49  jalet
277# Groups quota work now !
278#
279# Revision 1.32  2003/04/16 08:53:14  jalet
280# Printing can now be limited either by user's account balance or by
281# page quota (the default). Quota report doesn't include account balance
282# yet, though.
283#
284# Revision 1.31  2003/04/15 11:30:57  jalet
285# More work done on money print charging.
286# Minor bugs corrected.
287# All tools now access to the storage as priviledged users, repykota excepted.
288#
289# Revision 1.30  2003/04/10 21:47:20  jalet
290# Job history added. Upgrade script neutralized for now !
291#
292# Revision 1.29  2003/03/29 13:45:27  jalet
293# GPL paragraphs were incorrectly (from memory) copied into the sources.
294# Two README files were added.
295# Upgrade script for PostgreSQL pre 1.01 schema was added.
296#
297# Revision 1.28  2003/03/29 13:08:28  jalet
298# Configuration is now expected to be found in /etc/pykota.conf instead of
299# in /etc/cups/pykota.conf
300# Installation script can move old config files to the new location if needed.
301# Better error handling if configuration file is absent.
302#
303# Revision 1.27  2003/03/15 23:01:28  jalet
304# New mailto option in configuration file added.
305# No time to test this tonight (although it should work).
306#
307# Revision 1.26  2003/03/09 23:58:16  jalet
308# Comment
309#
310# Revision 1.25  2003/03/07 22:56:14  jalet
311# 0.99 is out with some bug fixes.
312#
313# Revision 1.24  2003/02/27 23:48:41  jalet
314# Correctly maps PyKota's log levels to syslog log levels
315#
316# Revision 1.23  2003/02/27 22:55:20  jalet
317# WARN log priority doesn't exist.
318#
319# Revision 1.22  2003/02/27 09:09:20  jalet
320# Added a method to match strings against wildcard patterns
321#
322# Revision 1.21  2003/02/17 23:01:56  jalet
323# Typos
324#
325# Revision 1.20  2003/02/17 22:55:01  jalet
326# More options can now be set per printer or globally :
327#
328#       admin
329#       adminmail
330#       gracedelay
331#       requester
332#
333# the printer option has priority when both are defined.
334#
335# Revision 1.19  2003/02/10 11:28:45  jalet
336# Localization
337#
338# Revision 1.18  2003/02/10 01:02:17  jalet
339# External requester is about to work, but I must sleep
340#
341# Revision 1.17  2003/02/09 13:05:43  jalet
342# Internationalization continues...
343#
344# Revision 1.16  2003/02/09 12:56:53  jalet
345# Internationalization begins...
346#
347# Revision 1.15  2003/02/08 22:09:52  jalet
348# Name check method moved here
349#
350# Revision 1.14  2003/02/07 10:42:45  jalet
351# Indentation problem
352#
353# Revision 1.13  2003/02/07 08:34:16  jalet
354# Test wrt date limit was wrong
355#
356# Revision 1.12  2003/02/06 23:20:02  jalet
357# warnpykota doesn't need any user/group name argument, mimicing the
358# warnquota disk quota tool.
359#
360# Revision 1.11  2003/02/06 22:54:33  jalet
361# warnpykota should be ok
362#
363# Revision 1.10  2003/02/06 15:03:11  jalet
364# added a method to set the limit date
365#
366# Revision 1.9  2003/02/06 10:39:23  jalet
367# Preliminary edpykota work.
368#
369# Revision 1.8  2003/02/06 09:19:02  jalet
370# More robust behavior (hopefully) when the user or printer is not managed
371# correctly by the Quota System : e.g. cupsFilter added in ppd file, but
372# printer and/or user not 'yet?' in storage.
373#
374# Revision 1.7  2003/02/06 00:00:45  jalet
375# Now includes the printer name in email messages
376#
377# Revision 1.6  2003/02/05 23:55:02  jalet
378# Cleaner email messages
379#
380# Revision 1.5  2003/02/05 23:45:09  jalet
381# Better DateTime manipulation wrt grace delay
382#
383# Revision 1.4  2003/02/05 23:26:22  jalet
384# Incorrect handling of grace delay
385#
386# Revision 1.3  2003/02/05 22:16:20  jalet
387# DEVICE_URI is undefined outside of CUPS, i.e. for normal command line tools
388#
389# Revision 1.2  2003/02/05 22:10:29  jalet
390# Typos
391#
392# Revision 1.1  2003/02/05 21:28:17  jalet
393# Initial import into CVS
394#
395#
396#
397
398import sys
399import os
400import fnmatch
401import getopt
402import smtplib
403import gettext
404import locale
405import signal
406import socket
407import tempfile
408import ConfigParser
409
410from mx import DateTime
411
412from pykota import version, config, storage, logger, accounter, pdlanalyzer
413
414class PyKotaToolError(Exception):
415    """An exception for PyKota config related stuff."""
416    def __init__(self, message = ""):
417        self.message = message
418        Exception.__init__(self, message)
419    def __repr__(self):
420        return self.message
421    __str__ = __repr__
422   
423def crashed(message) :   
424    """Minimal crash method."""
425    import traceback
426    lines = []
427    for line in traceback.format_exception(*sys.exc_info()) :
428        lines.extend([l for l in line.split("\n") if l])
429    msg = "ERROR: ".join(["%s\n" % l for l in (["ERROR: PyKota v%s" % version.__version__, message] + lines)])
430    sys.stderr.write(msg)
431    sys.stderr.flush()
432    return msg
433
434class PyKotaTool :   
435    """Base class for all PyKota command line tools."""
436    def __init__(self, lang=None, doc="PyKota %s (c) 2003-2004 %s" % (version.__version__, version.__author__)) :
437        """Initializes the command line tool."""
438        # locale stuff
439        try :
440            locale.setlocale(locale.LC_ALL, lang)
441            gettext.install("pykota")
442        except (locale.Error, IOError) :
443            gettext.NullTranslations().install()
444            # sys.stderr.write("PyKota : Error while loading translations\n")
445   
446        # pykota specific stuff
447        self.documentation = doc
448        try :
449            self.config = config.PyKotaConfig("/etc/pykota")
450        except ConfigParser.ParsingError, msg :   
451            sys.stderr.write("ERROR: Problem encountered while parsing configuration file : %s\n" % msg)
452            sys.stderr.flush()
453            sys.exit(-1)
454           
455        try :
456            self.debug = self.config.getDebug()
457            self.smtpserver = self.config.getSMTPServer()
458            self.maildomain = self.config.getMailDomain()
459            self.logger = logger.openLogger(self.config.getLoggingBackend())
460            self.storage = storage.openConnection(self)
461        except (config.PyKotaConfigError, logger.PyKotaLoggingError, storage.PyKotaStorageError), msg :
462            self.crashed(msg)
463            raise
464        self.softwareJobSize = 0
465        self.softwareJobPrice = 0.0
466       
467    def logdebug(self, message) :   
468        """Logs something to debug output if debug is enabled."""
469        if self.debug :
470            self.logger.log_message(message, "debug")
471       
472    def clean(self) :   
473        """Ensures that the database is closed."""
474        try :
475            self.storage.close()
476        except (TypeError, NameError, AttributeError) :   
477            pass
478           
479    def display_version_and_quit(self) :
480        """Displays version number, then exists successfully."""
481        self.clean()
482        print version.__version__
483        sys.exit(0)
484   
485    def display_usage_and_quit(self) :
486        """Displays command line usage, then exists successfully."""
487        self.clean()
488        print self.documentation
489        sys.exit(0)
490       
491    def crashed(self, message) :   
492        """Outputs a crash message, and optionally sends it to software author."""
493        msg = crashed(message)
494        try :
495            crashrecipient = self.config.getCrashRecipient()
496            if crashrecipient :
497                admin = self.config.getAdminMail("global") # Nice trick, isn't it ?
498                fullmessage = "========== Traceback :\n\n%s\n\n========== sys.argv :\n\n%s\n\n========== Environment :\n\n%s\n" % \
499                                (msg, \
500                                 "\n".join(["    %s" % repr(a) for a in sys.argv]), \
501                                 "\n".join(["    %s=%s" % (k, v) for (k, v) in os.environ.items()]))
502                server = smtplib.SMTP(self.smtpserver)
503                server.sendmail(admin, [admin, crashrecipient], \
504                                       "From: %s\nTo: %s\nCc: %s\nSubject: PyKota v%s crash traceback !\n\n%s" % \
505                                       (admin, crashrecipient, admin, version.__version__, fullmessage))
506                server.quit()
507        except :
508            pass
509       
510    def parseCommandline(self, argv, short, long, allownothing=0) :
511        """Parses the command line, controlling options."""
512        # split options in two lists: those which need an argument, those which don't need any
513        withoutarg = []
514        witharg = []
515        lgs = len(short)
516        i = 0
517        while i < lgs :
518            ii = i + 1
519            if (ii < lgs) and (short[ii] == ':') :
520                # needs an argument
521                witharg.append(short[i])
522                ii = ii + 1 # skip the ':'
523            else :
524                # doesn't need an argument
525                withoutarg.append(short[i])
526            i = ii
527               
528        for option in long :
529            if option[-1] == '=' :
530                # needs an argument
531                witharg.append(option[:-1])
532            else :
533                # doesn't need an argument
534                withoutarg.append(option)
535       
536        # we begin with all possible options unset
537        parsed = {}
538        for option in withoutarg + witharg :
539            parsed[option] = None
540       
541        # then we parse the command line
542        args = []       # to not break if something unexpected happened
543        try :
544            options, args = getopt.getopt(argv, short, long)
545            if options :
546                for (o, v) in options :
547                    # we skip the '-' chars
548                    lgo = len(o)
549                    i = 0
550                    while (i < lgo) and (o[i] == '-') :
551                        i = i + 1
552                    o = o[i:]
553                    if o in witharg :
554                        # needs an argument : set it
555                        parsed[o] = v
556                    elif o in withoutarg :
557                        # doesn't need an argument : boolean
558                        parsed[o] = 1
559                    else :
560                        # should never occur
561                        raise PyKotaToolError, "Unexpected problem when parsing command line"
562            elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
563                self.display_usage_and_quit()
564        except getopt.error, msg :
565            sys.stderr.write("%s\n" % msg)
566            sys.stderr.flush()
567            self.display_usage_and_quit()
568        return (parsed, args)
569   
570    def isValidName(self, name) :
571        """Checks if a user or printer name is valid."""
572        # unfortunately Python 2.1 string modules doesn't define ascii_letters...
573        asciiletters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
574        digits = '0123456789'
575        validchars = asciiletters + digits + "-_."
576        for c in name :
577            if c not in validchars :
578                return 0
579        return 1       
580       
581    def matchString(self, s, patterns) :
582        """Returns 1 if the string s matches one of the patterns, else 0."""
583        for pattern in patterns :
584            if fnmatch.fnmatchcase(s, pattern) :
585                return 1
586        return 0
587       
588    def sendMessage(self, adminmail, touser, fullmessage) :
589        """Sends an email message containing headers to some user."""
590        if "@" not in touser :
591            touser = "%s@%s" % (touser, self.maildomain or self.smtpserver)
592        try :   
593            server = smtplib.SMTP(self.smtpserver)
594        except socket.error, msg :   
595            self.logger.log_message(_("Impossible to connect to SMTP server : %s") % msg, "error")
596        else :
597            try :
598                server.sendmail(adminmail, [touser], "From: %s\nTo: %s\n%s" % (adminmail, touser, fullmessage))
599            except smtplib.SMTPException, answer :   
600                for (k, v) in answer.recipients.items() :
601                    self.logger.log_message(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
602            server.quit()
603       
604    def sendMessageToUser(self, admin, adminmail, user, subject, message) :
605        """Sends an email message to a user."""
606        message += _("\n\nPlease contact your system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail)
607        self.sendMessage(adminmail, user.Email or user.Name, "Subject: %s\n\n%s" % (subject, message))
608       
609    def sendMessageToAdmin(self, adminmail, subject, message) :
610        """Sends an email message to the Print Quota administrator."""
611        self.sendMessage(adminmail, adminmail, "Subject: %s\n\n%s" % (subject, message))
612       
613    def _checkUserPQuota(self, userpquota) :           
614        """Checks the user quota on a printer and deny or accept the job."""
615        # then we check the user's own quota
616        # if we get there we are sure that policy is not EXTERNAL
617        user = userpquota.User
618        printer = userpquota.Printer
619        enforcement = self.config.getPrinterEnforcement(printer.Name)
620        self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
621        (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
622        if not userpquota.Exists :
623            # Unknown userquota
624            if policy == "ALLOW" :
625                action = "POLICY_ALLOW"
626            else :   
627                action = "POLICY_DENY"
628            self.logger.log_message(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
629        else :   
630            pagecounter = int(userpquota.PageCounter or 0)
631            if enforcement == "STRICT" :
632                pagecounter += self.softwareJobSize
633            if userpquota.SoftLimit is not None :
634                softlimit = int(userpquota.SoftLimit)
635                if pagecounter < softlimit :
636                    action = "ALLOW"
637                else :   
638                    if userpquota.HardLimit is None :
639                        # only a soft limit, this is equivalent to having only a hard limit
640                        action = "DENY"
641                    else :   
642                        hardlimit = int(userpquota.HardLimit)
643                        if softlimit <= pagecounter < hardlimit :   
644                            now = DateTime.now()
645                            if userpquota.DateLimit is not None :
646                                datelimit = DateTime.ISO.ParseDateTime(userpquota.DateLimit)
647                            else :
648                                datelimit = now + self.config.getGraceDelay(printer.Name)
649                                userpquota.setDateLimit(datelimit)
650                            if now < datelimit :
651                                action = "WARN"
652                            else :   
653                                action = "DENY"
654                        else :         
655                            action = "DENY"
656            else :       
657                if userpquota.HardLimit is not None :
658                    # no soft limit, only a hard one.
659                    hardlimit = int(userpquota.HardLimit)
660                    if pagecounter < hardlimit :
661                        action = "ALLOW"
662                    else :     
663                        action = "DENY"
664                else :
665                    # Both are unset, no quota, i.e. accounting only
666                    action = "ALLOW"
667        return action
668   
669    def checkGroupPQuota(self, grouppquota) :   
670        """Checks the group quota on a printer and deny or accept the job."""
671        group = grouppquota.Group
672        printer = grouppquota.Printer
673        enforcement = self.config.getPrinterEnforcement(printer.Name)
674        self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
675        if group.LimitBy and (group.LimitBy.lower() == "balance") : 
676            val = group.AccountBalance
677            if enforcement == "STRICT" : 
678                val -= self.softwareJobPrice # use precomputed size.
679            if val <= 0.0 :
680                action = "DENY"
681            elif val <= self.config.getPoorMan() :   
682                action = "WARN"
683            else :   
684                action = "ALLOW"
685            if (enforcement == "STRICT") and (val == 0.0) :
686                action = "WARN" # we can still print until account is 0
687        else :
688            val = grouppquota.PageCounter
689            if enforcement == "STRICT" :
690                val += self.softwareJobSize
691            if grouppquota.SoftLimit is not None :
692                softlimit = int(grouppquota.SoftLimit)
693                if val < softlimit :
694                    action = "ALLOW"
695                else :   
696                    if grouppquota.HardLimit is None :
697                        # only a soft limit, this is equivalent to having only a hard limit
698                        action = "DENY"
699                    else :   
700                        hardlimit = int(grouppquota.HardLimit)
701                        if softlimit <= val < hardlimit :   
702                            now = DateTime.now()
703                            if grouppquota.DateLimit is not None :
704                                datelimit = DateTime.ISO.ParseDateTime(grouppquota.DateLimit)
705                            else :
706                                datelimit = now + self.config.getGraceDelay(printer.Name)
707                                grouppquota.setDateLimit(datelimit)
708                            if now < datelimit :
709                                action = "WARN"
710                            else :   
711                                action = "DENY"
712                        else :         
713                            action = "DENY"
714            else :       
715                if grouppquota.HardLimit is not None :
716                    # no soft limit, only a hard one.
717                    hardlimit = int(grouppquota.HardLimit)
718                    if val < hardlimit :
719                        action = "ALLOW"
720                    else :     
721                        action = "DENY"
722                else :
723                    # Both are unset, no quota, i.e. accounting only
724                    action = "ALLOW"
725        return action
726   
727    def checkUserPQuota(self, userpquota) :
728        """Checks the user quota on a printer and all its parents and deny or accept the job."""
729        user = userpquota.User
730        printer = userpquota.Printer
731       
732        # indicates that a warning needs to be sent
733        warned = 0               
734       
735        # first we check any group the user is a member of
736        for group in self.storage.getUserGroups(user) :
737            grouppquota = self.storage.getGroupPQuota(group, printer)
738            # for the printer and all its parents
739            for gpquota in [ grouppquota ] + grouppquota.ParentPrintersGroupPQuota :
740                if gpquota.Exists :
741                    action = self.checkGroupPQuota(gpquota)
742                    if action == "DENY" :
743                        return action
744                    elif action == "WARN" :   
745                        warned = 1
746                       
747        # Then we check the user's account balance
748        # if we get there we are sure that policy is not EXTERNAL
749        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
750        if user.LimitBy and (user.LimitBy.lower() == "balance") : 
751            self.logdebug("Checking account balance for user %s" % user.Name)
752            if user.AccountBalance is None :
753                if policy == "ALLOW" :
754                    action = "POLICY_ALLOW"
755                else :   
756                    action = "POLICY_DENY"
757                self.logger.log_message(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
758                return action       
759            else :   
760                val = float(user.AccountBalance or 0.0)
761                enforcement = self.config.getPrinterEnforcement(printer.Name)
762                if enforcement == "STRICT" : 
763                    val -= self.softwareJobPrice # use precomputed size.
764                if val <= 0.0 :
765                    action = "DENY"
766                elif val <= self.config.getPoorMan() :   
767                    action = "WARN"
768                else :
769                    action = "ALLOW"
770                if (enforcement == "STRICT") and (val == 0.0) :
771                    action = "WARN" # we can still print until account is 0
772                return action   
773        else :
774            # Then check the user quota on current printer and all its parents.               
775            policyallowed = 0
776            for upquota in [ userpquota ] + userpquota.ParentPrintersUserPQuota :               
777                action = self._checkUserPQuota(upquota)
778                if action in ("DENY", "POLICY_DENY") :
779                    return action
780                elif action == "WARN" :   
781                    warned = 1
782                elif action == "POLICY_ALLOW" :   
783                    policyallowed = 1
784            if warned :       
785                return "WARN"
786            elif policyallowed :   
787                return "POLICY_ALLOW" 
788            else :   
789                return "ALLOW"
790               
791    def externalMailTo(self, cmd, action, user, printer, message) :
792        """Warns the user with an external command."""
793        username = user.Name
794        printername = printer.Name
795        email = user.Email or user.Name
796        if "@" not in email :
797            email = "%s@%s" % (email, self.maildomain or self.smtpserver)
798        os.system(cmd % locals())
799   
800    def formatCommandLine(self, cmd, user, printer) :
801        """Executes an external command."""
802        username = user.Name
803        printername = printer.Name
804        return cmd % locals()
805       
806    def warnGroupPQuota(self, grouppquota) :
807        """Checks a group quota and send messages if quota is exceeded on current printer."""
808        group = grouppquota.Group
809        printer = grouppquota.Printer
810        admin = self.config.getAdmin(printer.Name)
811        adminmail = self.config.getAdminMail(printer.Name)
812        (mailto, arguments) = self.config.getMailTo(printer.Name)
813        action = self.checkGroupPQuota(grouppquota)
814        if action.startswith("POLICY_") :
815            action = action[7:]
816        if action == "DENY" :
817            adminmessage = _("Print Quota exceeded for group %s on printer %s") % (group.Name, printer.Name)
818            self.logger.log_message(adminmessage)
819            if mailto in [ "BOTH", "ADMIN" ] :
820                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
821            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
822                for user in self.storage.getGroupMembers(group) :
823                    if mailto != "EXTERNAL" :
824                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), self.config.getHardWarn(printer.Name))
825                    else :   
826                        self.externalMailTo(arguments, action, user, printer, message)
827        elif action == "WARN" :   
828            adminmessage = _("Print Quota low for group %s on printer %s") % (group.Name, printer.Name)
829            self.logger.log_message(adminmessage)
830            if mailto in [ "BOTH", "ADMIN" ] :
831                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
832            if group.LimitBy and (group.LimitBy.lower() == "balance") : 
833                message = self.config.getPoorWarn()
834            else :     
835                message = self.config.getSoftWarn(printer.Name)
836            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
837                for user in self.storage.getGroupMembers(group) :
838                    if mailto != "EXTERNAL" :
839                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
840                    else :   
841                        self.externalMailTo(arguments, action, user, printer, message)
842        return action       
843       
844    def warnUserPQuota(self, userpquota) :
845        """Checks a user quota and send him a message if quota is exceeded on current printer."""
846        user = userpquota.User
847        printer = userpquota.Printer
848        admin = self.config.getAdmin(printer.Name)
849        adminmail = self.config.getAdminMail(printer.Name)
850        (mailto, arguments) = self.config.getMailTo(printer.Name)
851        action = self.checkUserPQuota(userpquota)
852        if action.startswith("POLICY_") :
853            action = action[7:]
854        if action == "DENY" :
855            adminmessage = _("Print Quota exceeded for user %s on printer %s") % (user.Name, printer.Name)
856            self.logger.log_message(adminmessage)
857            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
858                message = self.config.getHardWarn(printer.Name)
859                if mailto != "EXTERNAL" :
860                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
861                else :   
862                    self.externalMailTo(arguments, action, user, printer, message)
863            if mailto in [ "BOTH", "ADMIN" ] :
864                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
865        elif action == "WARN" :   
866            adminmessage = _("Print Quota low for user %s on printer %s") % (user.Name, printer.Name)
867            self.logger.log_message(adminmessage)
868            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
869                if user.LimitBy and (user.LimitBy.lower() == "balance") : 
870                    message = self.config.getPoorWarn()
871                else :     
872                    message = self.config.getSoftWarn(printer.Name)
873                if mailto != "EXTERNAL" :   
874                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Low"), message)
875                else :   
876                    self.externalMailTo(arguments, action, user, printer, message)
877            if mailto in [ "BOTH", "ADMIN" ] :
878                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
879        return action       
880       
881class PyKotaFilterOrBackend(PyKotaTool) :   
882    """Class for the PyKota filter or backend."""
883    def __init__(self) :
884        """Initialize local datas from current environment."""
885        # We begin with ignoring signals, we may de-ignore them later on.
886        self.gotSigTerm = 0
887        signal.signal(signal.SIGTERM, signal.SIG_IGN)
888        # signal.signal(signal.SIGCHLD, signal.SIG_IGN)
889        signal.signal(signal.SIGPIPE, signal.SIG_IGN)
890       
891        PyKotaTool.__init__(self)
892        (self.printingsystem, \
893         self.printerhostname, \
894         self.printername, \
895         self.username, \
896         self.jobid, \
897         self.inputfile, \
898         self.copies, \
899         self.title, \
900         self.options, \
901         self.originalbackend) = self.extractInfoFromCupsOrLprng()
902        self.username = self.username or 'root' # when printing test page from CUPS web interface, username is empty
903        if self.config.getUserNameToLower() :
904            self.username = self.username.lower()
905        self.preserveinputfile = self.inputfile 
906        try :
907            self.accounter = accounter.openAccounter(self)
908        except accounter.PyKotaAccounterError, msg :   
909            self.crashed(msg)
910            raise
911        self.exportJobInfo()
912        self.jobdatastream = self.openJobDataStream()
913        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.jobSizeBytes)
914        self.logdebug("Job size is %s bytes" % self.jobSizeBytes)
915        self.logdebug("Capturing SIGTERM events.")
916        signal.signal(signal.SIGTERM, self.sigterm_handler)
917       
918    def openJobDataStream(self) :   
919        """Opens the file which contains the job's datas."""
920        if self.preserveinputfile is None :
921            # Job comes from sys.stdin, but this is not
922            # seekable and complexifies our task, so create
923            # a temporary file and use it instead
924            self.sendBackChannelData("Duplicating data stream from stdin to temporary file")
925            dummy = 0
926            MEGABYTE = 1024*1024
927            self.jobSizeBytes = 0
928            infile = tempfile.TemporaryFile()
929            while 1 :
930                data = sys.stdin.read(MEGABYTE) 
931                if not data :
932                    break
933                self.jobSizeBytes += len(data)   
934                if not (dummy % 10) :
935                    self.sendBackChannelData("%s bytes read..." % self.jobSizeBytes)
936                dummy += 1   
937                infile.write(data)
938            infile.flush()   
939            infile.seek(0)
940            return infile
941        else :   
942            # real file, just open it
943            self.sendBackChannelData("Opening data stream %s" % self.preserveinputfile)
944            self.jobSizeBytes = os.stat(self.preserveinputfile)[6]
945            return open(self.preserveinputfile, "rb")
946       
947    def closeJobDataStream(self) :   
948        """Closes the file which contains the job's datas."""
949        self.sendBackChannelData("Closing data stream.")
950        try :
951            self.jobdatastream.close()
952        except :   
953            pass
954       
955    def precomputeJobSize(self) :   
956        """Computes the job size with a software method."""
957        self.logdebug("Precomputing job's size with generic PDL analyzer...")
958        self.jobdatastream.seek(0)
959        try :
960            parser = pdlanalyzer.PDLAnalyzer(self.jobdatastream)
961            jobsize = parser.getJobSize()
962        except pdlanalyzer.PDLAnalyzerError, msg :   
963            # Here we just log the failure, but
964            # we finally ignore it and return 0 since this
965            # computation is just an indication of what the
966            # job's size MAY be.
967            self.logger.log_message(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
968            return 0
969        else :   
970            if ((self.printingsystem == "CUPS") \
971                and (self.preserveinputfile is not None)) \
972                or (self.printingsystem != "CUPS") :
973                return jobsize * self.copies
974            else :       
975                return jobsize
976           
977    def sigterm_handler(self, signum, frame) :
978        """Sets an attribute whenever SIGTERM is received."""
979        self.gotSigTerm = 1
980        os.environ["PYKOTASTATUS"] = "CANCELLED"
981        self.logger.log_message(_("SIGTERM received, job %s cancelled.") % self.jobid, "info")
982       
983    def exportJobInfo(self) :   
984        """Exports job information to the environment."""
985        os.environ["PYKOTAUSERNAME"] = str(self.username)
986        os.environ["PYKOTAPRINTERNAME"] = str(self.printername)
987        os.environ["PYKOTAJOBID"] = str(self.jobid)
988        os.environ["PYKOTATITLE"] = self.title or ""
989        os.environ["PYKOTAFILENAME"] = self.preserveinputfile or ""
990        os.environ["PYKOTACOPIES"] = str(self.copies)
991        os.environ["PYKOTAOPTIONS"] = self.options or ""
992   
993    def exportUserInfo(self, userpquota) :
994        """Exports user information to the environment."""
995        os.environ["PYKOTALIMITBY"] = str(userpquota.User.LimitBy)
996        os.environ["PYKOTABALANCE"] = str(userpquota.User.AccountBalance or 0.0)
997        os.environ["PYKOTALIFETIMEPAID"] = str(userpquota.User.LifeTimePaid or 0.0)
998        os.environ["PYKOTAPAGECOUNTER"] = str(userpquota.PageCounter or 0)
999        os.environ["PYKOTALIFEPAGECOUNTER"] = str(userpquota.LifePageCounter or 0)
1000        os.environ["PYKOTASOFTLIMIT"] = str(userpquota.SoftLimit)
1001        os.environ["PYKOTAHARDLIMIT"] = str(userpquota.HardLimit)
1002        os.environ["PYKOTADATELIMIT"] = str(userpquota.DateLimit)
1003       
1004        # not really an user information, but anyway
1005        # exports the list of printers groups the current
1006        # printer is a member of
1007        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(userpquota.Printer)])
1008       
1009    def prehook(self, userpquota) :
1010        """Allows plugging of an external hook before the job gets printed."""
1011        prehook = self.config.getPreHook(userpquota.Printer.Name)
1012        if prehook :
1013            self.logdebug("Executing pre-hook [%s]" % prehook)
1014            os.system(prehook)
1015       
1016    def posthook(self, userpquota) :
1017        """Allows plugging of an external hook after the job gets printed and/or denied."""
1018        posthook = self.config.getPostHook(userpquota.Printer.Name)
1019        if posthook :
1020            self.logdebug("Executing post-hook [%s]" % posthook)
1021            os.system(posthook)
1022       
1023    def extractInfoFromCupsOrLprng(self) :   
1024        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
1025       
1026           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
1027        """
1028        # Try to detect CUPS
1029        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
1030            if len(sys.argv) == 7 :
1031                inputfile = sys.argv[6]
1032            else :   
1033                inputfile = None
1034               
1035            # check that the DEVICE_URI environment variable's value is
1036            # prefixed with "cupspykota:" otherwise don't touch it.
1037            # If this is the case, we have to remove the prefix from
1038            # the environment before launching the real backend in cupspykota
1039            device_uri = os.environ.get("DEVICE_URI", "")
1040            if device_uri.startswith("cupspykota:") :
1041                fulldevice_uri = device_uri[:]
1042                device_uri = fulldevice_uri[len("cupspykota:"):]
1043                if device_uri.startswith("//") :    # lpd (at least)
1044                    device_uri = device_uri[2:]
1045                os.environ["DEVICE_URI"] = device_uri   # TODO : side effect !
1046            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
1047            try :
1048                (backend, destination) = device_uri.split(":", 1) 
1049            except ValueError :   
1050                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
1051            while destination.startswith("/") :
1052                destination = destination[1:]
1053            checkauth = destination.split("@", 1)   
1054            if len(checkauth) == 2 :
1055                destination = checkauth[1]
1056            printerhostname = destination.split("/")[0].split(":")[0]
1057            return ("CUPS", \
1058                    printerhostname, \
1059                    os.environ.get("PRINTER"), \
1060                    sys.argv[2].strip(), \
1061                    sys.argv[1].strip(), \
1062                    inputfile, \
1063                    int(sys.argv[4].strip()), \
1064                    sys.argv[3], \
1065                    sys.argv[5], \
1066                    backend)
1067        else :   
1068            # Try to detect LPRng
1069            # TODO : try to extract filename, job's title, and options if available
1070            jseen = Pseen = nseen = rseen = Kseen = None
1071            for arg in sys.argv :
1072                if arg.startswith("-j") :
1073                    jseen = arg[2:].strip()
1074                elif arg.startswith("-n") :     
1075                    nseen = arg[2:].strip()
1076                elif arg.startswith("-P") :   
1077                    Pseen = arg[2:].strip()
1078                elif arg.startswith("-r") :   
1079                    rseen = arg[2:].strip()
1080                elif arg.startswith("-K") or arg.startswith("-#") :   
1081                    Kseen = int(arg[2:].strip())
1082            if Kseen is None :       
1083                Kseen = 1       # we assume the user wants at least one copy...
1084            if (rseen is None) and jseen and Pseen and nseen :   
1085                self.logger.log_message(_("Printer hostname undefined, set to 'localhost'"), "warn")
1086                rseen = "localhost"
1087            if jseen and Pseen and nseen and rseen :       
1088                # job is always in stdin (None)
1089                return ("LPRNG", rseen, Pseen, nseen, jseen, None, Kseen, None, None, None)
1090        self.logger.log_message(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
1091        return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system
1092       
1093    def getPrinterUserAndUserPQuota(self) :       
1094        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
1095       
1096           "OK" is returned in the policy if both printer, user and user print quota
1097           exist in the Quota Storage.
1098           Otherwise, the policy as defined for this printer in pykota.conf is returned.
1099           
1100           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
1101           doesn't exist in the Quota Storage, then an external command is launched, as
1102           defined in the external policy for this printer in pykota.conf
1103           This external command can do anything, like automatically adding printers
1104           or users, for example, and finally extracting printer, user and user print
1105           quota from the Quota Storage is tried a second time.
1106           
1107           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
1108           was returned by the external command.
1109        """
1110        for passnumber in range(1, 3) :
1111            printer = self.storage.getPrinter(self.printername)
1112            user = self.storage.getUser(self.username)
1113            userpquota = self.storage.getUserPQuota(user, printer)
1114            if printer.Exists and user.Exists and userpquota.Exists :
1115                policy = "OK"
1116                break
1117            (policy, args) = self.config.getPrinterPolicy(self.printername)
1118            if policy == "EXTERNAL" :   
1119                commandline = self.formatCommandLine(args, user, printer)
1120                if not printer.Exists :
1121                    self.logger.log_message(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.printername, commandline, self.printername), "info")
1122                if not user.Exists :
1123                    self.logger.log_message(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.username, commandline, self.printername), "info")
1124                if not userpquota.Exists :
1125                    self.logger.log_message(_("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), "info")
1126                if os.system(commandline) :
1127                    self.logger.log_message(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.printername), "error")
1128                    policy = "EXTERNALERROR"
1129                    break
1130            else :       
1131                if not printer.Exists :
1132                    self.logger.log_message(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.printername, policy), "info")
1133                if not user.Exists :
1134                    self.logger.log_message(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.username, policy, self.printername), "info")
1135                if not userpquota.Exists :
1136                    self.logger.log_message(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.username, self.printername, policy), "info")
1137                break
1138        if policy == "EXTERNAL" :   
1139            if not printer.Exists :
1140                self.logger.log_message(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.printername, "info")
1141            if not user.Exists :
1142                self.logger.log_message(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.username, self.printername), "info")
1143            if not userpquota.Exists :
1144                self.logger.log_message(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.username, self.printername), "info")
1145        return (policy, printer, user, userpquota)
1146       
1147    def mainWork(self) :   
1148        """Main work is done here."""
1149        (policy, printer, user, userpquota) = self.getPrinterUserAndUserPQuota()
1150        # TODO : check for last user's quota in case pykota filter is used with querying
1151        if policy == "EXTERNALERROR" :
1152            # Policy was 'EXTERNAL' and the external command returned an error code
1153            return self.removeJob()
1154        elif policy == "EXTERNAL" :
1155            # Policy was 'EXTERNAL' and the external command wasn't able
1156            # to add either the printer, user or user print quota
1157            return self.removeJob()
1158        elif policy == "DENY" :   
1159            # Either printer, user or user print quota doesn't exist,
1160            # and the job should be rejected.
1161            return self.removeJob()
1162        else :
1163            if policy not in ("OK", "ALLOW") :
1164                self.logger.log_message(_("Invalid policy %s for printer %s") % (policy, self.printername))
1165                return self.removeJob()
1166            else :
1167                return self.doWork(policy, printer, user, userpquota)
Note: See TracBrowser for help on using the browser.