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

Revision 1537, 48.4 kB (checked in by jalet, 20 years ago)

Now detects and logs configuration syntax errors instead of failing without
any notice message.

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