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

Revision 1538, 48.7 kB (checked in by jalet, 20 years ago)

More exceptions catched in case of very early failure.

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