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

Revision 1517, 47.1 kB (checked in by jalet, 20 years ago)

Improved error logging.
crashrecipient directive added.
Now exports the job's size in bytes too.

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