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

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

Smarter initialisation code

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