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

Revision 1545, 49.0 kB (checked in by jalet, 20 years ago)

Now includes PyKota's version number in messages sent by the crashrecipient
directive.

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