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

Revision 1516, 45.6 kB (checked in by jalet, 20 years ago)

logs job's size in bytes now

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