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

Revision 1492, 43.9 kB (checked in by jalet, 20 years ago)

Preliminary work on pre-accounting

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