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

Revision 1542, 48.8 kB (checked in by jalet, 20 years ago)

Better exception handling code

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