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

Revision 1481, 42.2 kB (checked in by jalet, 20 years ago)

Now catches SIGPIPE and SIGCHLD

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