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

Revision 1434, 41.5 kB (checked in by jalet, 20 years ago)

More work on correct child processes handling

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