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

Revision 1523, 47.3 kB (checked in by jalet, 20 years ago)

Now catches some exceptions earlier.
storage.py and ldapstorage.py : removed old comments

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