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

Revision 1546, 49.3 kB (checked in by jalet, 20 years ago)

Now all tracebacks include PyKota's version number

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