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

Revision 1495, 45.0 kB (checked in by jalet, 20 years ago)

New 'enforcement' directive added
Polling loop improvements

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