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

Revision 1514, 45.3 kB (checked in by jalet, 20 years ago)

Moved the sigterm capturing elsewhere

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