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

Revision 1529, 48.0 kB (checked in by jalet, 20 years ago)

Now accepts a job when enforcement is STRICT and predicted account balance
is equal to 0.0 : since the job hasn't been printed yet, only its printing
will really render balance equal to 0.0, so we should be allowed to print.

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