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

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

Now precomputes the job's size only if current printer's enforcement
is "STRICT"

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