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

Revision 1532, 48.0 kB (checked in by jalet, 20 years ago)

Doesn't ignore SIGCHLD anymore

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