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

Revision 1560, 49.4 kB (checked in by jalet, 20 years ago)

Added version number in subject message for directive crashrecipient.

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