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

Revision 1193, 23.9 kB (checked in by jalet, 20 years ago)

Missing import statement.
Better documentation for mailto: external(...)

  • 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 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.56  2003/11/19 07:40:20  jalet
25# Missing import statement.
26# Better documentation for mailto: external(...)
27#
28# Revision 1.55  2003/11/18 23:43:12  jalet
29# Mailto can be any external command now, as usual.
30#
31# Revision 1.54  2003/10/24 21:52:46  jalet
32# Now can force language when coming from CGI script.
33#
34# Revision 1.53  2003/10/08 21:41:38  jalet
35# External policies for printers works !
36# We can now auto-add users on first print, and do other useful things if needed.
37#
38# Revision 1.52  2003/10/07 09:07:28  jalet
39# Character encoding added to please latest version of Python
40#
41# Revision 1.51  2003/10/06 14:21:41  jalet
42# Test reversed to not retrieve group members when no messages for them.
43#
44# Revision 1.50  2003/10/02 20:23:18  jalet
45# Storage caching mechanism added.
46#
47# Revision 1.49  2003/07/29 20:55:17  jalet
48# 1.14 is out !
49#
50# Revision 1.48  2003/07/21 23:01:56  jalet
51# Modified some messages aout soft limit
52#
53# Revision 1.47  2003/07/16 21:53:08  jalet
54# Really big modifications wrt new configuration file's location and content.
55#
56# Revision 1.46  2003/07/09 20:17:07  jalet
57# Email field added to PostgreSQL schema
58#
59# Revision 1.45  2003/07/08 19:43:51  jalet
60# Configurable warning messages.
61# Poor man's treshold value added.
62#
63# Revision 1.44  2003/07/07 11:49:24  jalet
64# Lots of small fixes with the help of PyChecker
65#
66# Revision 1.43  2003/07/04 09:06:32  jalet
67# Small bug fix wrt undefined "LimitBy" field.
68#
69# Revision 1.42  2003/06/30 12:46:15  jalet
70# Extracted reporting code.
71#
72# Revision 1.41  2003/06/25 14:10:01  jalet
73# Hey, it may work (edpykota --reset excepted) !
74#
75# Revision 1.40  2003/06/10 16:37:54  jalet
76# Deletion of the second user which is not needed anymore.
77# Added a debug configuration field in /etc/pykota.conf
78# All queries can now be sent to the logger in debug mode, this will
79# greatly help improve performance when time for this will come.
80#
81# Revision 1.39  2003/04/29 18:37:54  jalet
82# Pluggable accounting methods (actually doesn't support external scripts)
83#
84# Revision 1.38  2003/04/24 11:53:48  jalet
85# Default policy for unknown users/groups is to DENY printing instead
86# of the previous default to ALLOW printing. This is to solve an accuracy
87# problem. If you set the policy to ALLOW, jobs printed by in nexistant user
88# (from PyKota's POV) will be charged to the next user who prints on the
89# same printer.
90#
91# Revision 1.37  2003/04/24 08:08:27  jalet
92# Debug message forgotten
93#
94# Revision 1.36  2003/04/24 07:59:40  jalet
95# LPRng support now works !
96#
97# Revision 1.35  2003/04/23 22:13:57  jalet
98# Preliminary support for LPRng added BUT STILL UNTESTED.
99#
100# Revision 1.34  2003/04/17 09:26:21  jalet
101# repykota now reports account balances too.
102#
103# Revision 1.33  2003/04/16 12:35:49  jalet
104# Groups quota work now !
105#
106# Revision 1.32  2003/04/16 08:53:14  jalet
107# Printing can now be limited either by user's account balance or by
108# page quota (the default). Quota report doesn't include account balance
109# yet, though.
110#
111# Revision 1.31  2003/04/15 11:30:57  jalet
112# More work done on money print charging.
113# Minor bugs corrected.
114# All tools now access to the storage as priviledged users, repykota excepted.
115#
116# Revision 1.30  2003/04/10 21:47:20  jalet
117# Job history added. Upgrade script neutralized for now !
118#
119# Revision 1.29  2003/03/29 13:45:27  jalet
120# GPL paragraphs were incorrectly (from memory) copied into the sources.
121# Two README files were added.
122# Upgrade script for PostgreSQL pre 1.01 schema was added.
123#
124# Revision 1.28  2003/03/29 13:08:28  jalet
125# Configuration is now expected to be found in /etc/pykota.conf instead of
126# in /etc/cups/pykota.conf
127# Installation script can move old config files to the new location if needed.
128# Better error handling if configuration file is absent.
129#
130# Revision 1.27  2003/03/15 23:01:28  jalet
131# New mailto option in configuration file added.
132# No time to test this tonight (although it should work).
133#
134# Revision 1.26  2003/03/09 23:58:16  jalet
135# Comment
136#
137# Revision 1.25  2003/03/07 22:56:14  jalet
138# 0.99 is out with some bug fixes.
139#
140# Revision 1.24  2003/02/27 23:48:41  jalet
141# Correctly maps PyKota's log levels to syslog log levels
142#
143# Revision 1.23  2003/02/27 22:55:20  jalet
144# WARN log priority doesn't exist.
145#
146# Revision 1.22  2003/02/27 09:09:20  jalet
147# Added a method to match strings against wildcard patterns
148#
149# Revision 1.21  2003/02/17 23:01:56  jalet
150# Typos
151#
152# Revision 1.20  2003/02/17 22:55:01  jalet
153# More options can now be set per printer or globally :
154#
155#       admin
156#       adminmail
157#       gracedelay
158#       requester
159#
160# the printer option has priority when both are defined.
161#
162# Revision 1.19  2003/02/10 11:28:45  jalet
163# Localization
164#
165# Revision 1.18  2003/02/10 01:02:17  jalet
166# External requester is about to work, but I must sleep
167#
168# Revision 1.17  2003/02/09 13:05:43  jalet
169# Internationalization continues...
170#
171# Revision 1.16  2003/02/09 12:56:53  jalet
172# Internationalization begins...
173#
174# Revision 1.15  2003/02/08 22:09:52  jalet
175# Name check method moved here
176#
177# Revision 1.14  2003/02/07 10:42:45  jalet
178# Indentation problem
179#
180# Revision 1.13  2003/02/07 08:34:16  jalet
181# Test wrt date limit was wrong
182#
183# Revision 1.12  2003/02/06 23:20:02  jalet
184# warnpykota doesn't need any user/group name argument, mimicing the
185# warnquota disk quota tool.
186#
187# Revision 1.11  2003/02/06 22:54:33  jalet
188# warnpykota should be ok
189#
190# Revision 1.10  2003/02/06 15:03:11  jalet
191# added a method to set the limit date
192#
193# Revision 1.9  2003/02/06 10:39:23  jalet
194# Preliminary edpykota work.
195#
196# Revision 1.8  2003/02/06 09:19:02  jalet
197# More robust behavior (hopefully) when the user or printer is not managed
198# correctly by the Quota System : e.g. cupsFilter added in ppd file, but
199# printer and/or user not 'yet?' in storage.
200#
201# Revision 1.7  2003/02/06 00:00:45  jalet
202# Now includes the printer name in email messages
203#
204# Revision 1.6  2003/02/05 23:55:02  jalet
205# Cleaner email messages
206#
207# Revision 1.5  2003/02/05 23:45:09  jalet
208# Better DateTime manipulation wrt grace delay
209#
210# Revision 1.4  2003/02/05 23:26:22  jalet
211# Incorrect handling of grace delay
212#
213# Revision 1.3  2003/02/05 22:16:20  jalet
214# DEVICE_URI is undefined outside of CUPS, i.e. for normal command line tools
215#
216# Revision 1.2  2003/02/05 22:10:29  jalet
217# Typos
218#
219# Revision 1.1  2003/02/05 21:28:17  jalet
220# Initial import into CVS
221#
222#
223#
224
225import sys
226import os
227import fnmatch
228import getopt
229import smtplib
230import gettext
231import locale
232
233from mx import DateTime
234
235from pykota import version, config, storage, logger
236
237class PyKotaToolError(Exception):
238    """An exception for PyKota config related stuff."""
239    def __init__(self, message = ""):
240        self.message = message
241        Exception.__init__(self, message)
242    def __repr__(self):
243        return self.message
244    __str__ = __repr__
245   
246class PyKotaTool :   
247    """Base class for all PyKota command line tools."""
248    def __init__(self, lang=None, doc="PyKota %s (c) 2003 %s" % (version.__version__, version.__author__)) :
249        """Initializes the command line tool."""
250        # locale stuff
251        try :
252            locale.setlocale(locale.LC_ALL, lang)
253            gettext.install("pykota")
254        except (locale.Error, IOError) :
255            gettext.NullTranslations().install()
256            sys.stderr.write("PyKota : Error while loading translations\n")
257   
258        # pykota specific stuff
259        self.documentation = doc
260        self.config = config.PyKotaConfig("/etc/pykota")
261        self.logger = logger.openLogger(self)
262        self.debug = self.config.getDebug()
263        self.storage = storage.openConnection(self)
264        self.smtpserver = self.config.getSMTPServer()
265       
266    def logdebug(self, message) :   
267        """Logs something to debug output if debug is enabled."""
268        if self.debug :
269            self.logger.log_message(message, "debug")
270       
271    def clean(self) :   
272        """Ensures that the database is closed."""
273        try :
274            self.storage.close()
275        except (TypeError, NameError, AttributeError) :   
276            pass
277           
278    def display_version_and_quit(self) :
279        """Displays version number, then exists successfully."""
280        self.clean()
281        print version.__version__
282        sys.exit(0)
283   
284    def display_usage_and_quit(self) :
285        """Displays command line usage, then exists successfully."""
286        self.clean()
287        print self.documentation
288        sys.exit(0)
289       
290    def parseCommandline(self, argv, short, long, allownothing=0) :
291        """Parses the command line, controlling options."""
292        # split options in two lists: those which need an argument, those which don't need any
293        withoutarg = []
294        witharg = []
295        lgs = len(short)
296        i = 0
297        while i < lgs :
298            ii = i + 1
299            if (ii < lgs) and (short[ii] == ':') :
300                # needs an argument
301                witharg.append(short[i])
302                ii = ii + 1 # skip the ':'
303            else :
304                # doesn't need an argument
305                withoutarg.append(short[i])
306            i = ii
307               
308        for option in long :
309            if option[-1] == '=' :
310                # needs an argument
311                witharg.append(option[:-1])
312            else :
313                # doesn't need an argument
314                withoutarg.append(option)
315       
316        # we begin with all possible options unset
317        parsed = {}
318        for option in withoutarg + witharg :
319            parsed[option] = None
320       
321        # then we parse the command line
322        args = []       # to not break if something unexpected happened
323        try :
324            options, args = getopt.getopt(argv, short, long)
325            if options :
326                for (o, v) in options :
327                    # we skip the '-' chars
328                    lgo = len(o)
329                    i = 0
330                    while (i < lgo) and (o[i] == '-') :
331                        i = i + 1
332                    o = o[i:]
333                    if o in witharg :
334                        # needs an argument : set it
335                        parsed[o] = v
336                    elif o in withoutarg :
337                        # doesn't need an argument : boolean
338                        parsed[o] = 1
339                    else :
340                        # should never occur
341                        raise PyKotaToolError, "Unexpected problem when parsing command line"
342            elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
343                self.display_usage_and_quit()
344        except getopt.error, msg :
345            sys.stderr.write("%s\n" % msg)
346            sys.stderr.flush()
347            self.display_usage_and_quit()
348        return (parsed, args)
349   
350    def isValidName(self, name) :
351        """Checks if a user or printer name is valid."""
352        # unfortunately Python 2.1 string modules doesn't define ascii_letters...
353        asciiletters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
354        digits = '0123456789'
355        if name[0] in asciiletters :
356            validchars = asciiletters + digits + "-_"
357            for c in name[1:] :
358                if c not in validchars :
359                    return 0
360            return 1       
361        return 0
362       
363    def matchString(self, s, patterns) :
364        """Returns 1 if the string s matches one of the patterns, else 0."""
365        for pattern in patterns :
366            if fnmatch.fnmatchcase(s, pattern) :
367                return 1
368        return 0
369       
370    def sendMessage(self, adminmail, touser, fullmessage) :
371        """Sends an email message containing headers to some user."""
372        if "@" not in touser :
373            touser = "%s@%s" % (touser, self.smtpserver)
374        server = smtplib.SMTP(self.smtpserver)
375        try :
376            server.sendmail(adminmail, [touser], fullmessage)
377        except smtplib.SMTPRecipientsRefused, answer :   
378            for (k, v) in answer.recipients.items() :
379                self.logger.log_message(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
380        server.quit()
381       
382    def sendMessageToUser(self, admin, adminmail, user, subject, message) :
383        """Sends an email message to a user."""
384        message += _("\n\nPlease contact your system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail)
385        self.sendMessage(adminmail, user.Email or user.Name, "Subject: %s\n\n%s" % (subject, message))
386       
387    def sendMessageToAdmin(self, adminmail, subject, message) :
388        """Sends an email message to the Print Quota administrator."""
389        self.sendMessage(adminmail, adminmail, "Subject: %s\n\n%s" % (subject, message))
390       
391    def checkGroupPQuota(self, grouppquota) :   
392        """Checks the group quota on a printer and deny or accept the job."""
393        group = grouppquota.Group
394        printer = grouppquota.Printer
395        if group.LimitBy and (group.LimitBy.lower() == "balance") : 
396            if group.AccountBalance <= 0.0 :
397                action = "DENY"
398            elif group.AccountBalance <= self.config.getPoorMan() :   
399                action = "WARN"
400            else :   
401                action = "ALLOW"
402        else :
403            if grouppquota.SoftLimit is not None :
404                softlimit = int(grouppquota.SoftLimit)
405                if grouppquota.PageCounter < softlimit :
406                    action = "ALLOW"
407                else :   
408                    if grouppquota.HardLimit is None :
409                        # only a soft limit, this is equivalent to having only a hard limit
410                        action = "DENY"
411                    else :   
412                        hardlimit = int(grouppquota.HardLimit)
413                        if softlimit <= grouppquota.PageCounter < hardlimit :   
414                            now = DateTime.now()
415                            if grouppquota.DateLimit is not None :
416                                datelimit = DateTime.ISO.ParseDateTime(grouppquota.DateLimit)
417                            else :
418                                datelimit = now + self.config.getGraceDelay(printer.Name)
419                                grouppquota.setDateLimit(datelimit)
420                            if now < datelimit :
421                                action = "WARN"
422                            else :   
423                                action = "DENY"
424                        else :         
425                            action = "DENY"
426            else :       
427                if grouppquota.HardLimit is not None :
428                    # no soft limit, only a hard one.
429                    hardlimit = int(grouppquota.HardLimit)
430                    if grouppquota.PageCounter < hardlimit :
431                        action = "ALLOW"
432                    else :     
433                        action = "DENY"
434                else :
435                    # Both are unset, no quota, i.e. accounting only
436                    action = "ALLOW"
437        return action
438   
439    def checkUserPQuota(self, userpquota) :
440        """Checks the user quota on a printer and deny or accept the job."""
441        user = userpquota.User
442        printer = userpquota.Printer
443       
444        # first we check any group the user is a member of
445        for group in self.storage.getUserGroups(user) :
446            grouppquota = self.storage.getGroupPQuota(group, printer)
447            if grouppquota.Exists :
448                action = self.checkGroupPQuota(grouppquota)
449                if action == "DENY" :
450                    return action
451               
452        # then we check the user's own quota
453        # if we get there we are sure that policy is not EXTERNAL
454        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
455        if user.LimitBy and (user.LimitBy.lower() == "balance") : 
456            if user.AccountBalance is None :
457                if policy == "ALLOW" :
458                    action = "POLICY_ALLOW"
459                else :   
460                    action = "POLICY_DENY"
461                self.logger.log_message(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
462            else :   
463                val = float(user.AccountBalance or 0.0)
464                if val <= 0.0 :
465                    action = "DENY"
466                elif val <= self.config.getPoorMan() :   
467                    action = "WARN"
468                else :   
469                    action = "ALLOW"
470        else :
471            if not userpquota.Exists :
472                # Unknown userquota
473                if policy == "ALLOW" :
474                    action = "POLICY_ALLOW"
475                else :   
476                    action = "POLICY_DENY"
477                self.logger.log_message(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
478            else :   
479                pagecounter = int(userpquota.PageCounter or 0)
480                if userpquota.SoftLimit is not None :
481                    softlimit = int(userpquota.SoftLimit)
482                    if pagecounter < softlimit :
483                        action = "ALLOW"
484                    else :   
485                        if userpquota.HardLimit is None :
486                            # only a soft limit, this is equivalent to having only a hard limit
487                            action = "DENY"
488                        else :   
489                            hardlimit = int(userpquota.HardLimit)
490                            if softlimit <= pagecounter < hardlimit :   
491                                now = DateTime.now()
492                                if userpquota.DateLimit is not None :
493                                    datelimit = DateTime.ISO.ParseDateTime(userpquota.DateLimit)
494                                else :
495                                    datelimit = now + self.config.getGraceDelay(printer.Name)
496                                    userpquota.setDateLimit(datelimit)
497                                if now < datelimit :
498                                    action = "WARN"
499                                else :   
500                                    action = "DENY"
501                            else :         
502                                action = "DENY"
503                else :       
504                    if userpquota.HardLimit is not None :
505                        # no soft limit, only a hard one.
506                        hardlimit = int(userpquota.HardLimit)
507                        if pagecounter < hardlimit :
508                            action = "ALLOW"
509                        else :     
510                            action = "DENY"
511                    else :
512                        # Both are unset, no quota, i.e. accounting only
513                        action = "ALLOW"
514        return action
515   
516    def externalMailTo(self, cmd, action, user, printername, message) :
517        """Warns the user with an external command."""
518        username = user.Name
519        email = user.Email or user.Name
520        if "@" not in email :
521            email = "%s@%s" % (email, self.smtpserver)
522        os.system(cmd % locals())
523   
524    def warnGroupPQuota(self, grouppquota) :
525        """Checks a group quota and send messages if quota is exceeded on current printer."""
526        group = grouppquota.Group
527        printer = grouppquota.Printer
528        admin = self.config.getAdmin(printer.Name)
529        adminmail = self.config.getAdminMail(printer.Name)
530        (mailto, arguments) = self.config.getMailTo(printer.Name)
531        action = self.checkGroupPQuota(grouppquota)
532        if action.startswith("POLICY_") :
533            action = action[7:]
534        if action == "DENY" :
535            adminmessage = _("Print Quota exceeded for group %s on printer %s") % (group.Name, printer.Name)
536            self.logger.log_message(adminmessage)
537            if mailto in [ "BOTH", "ADMIN" ] :
538                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
539            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
540                for user in self.storage.getGroupMembers(group) :
541                    if mailto != "EXTERNAL" :
542                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), self.config.getHardWarn(printer.Name))
543                    else :   
544                        self.externalMailTo(arguments, action, user, printer.Name, message)
545        elif action == "WARN" :   
546            adminmessage = _("Print Quota low for group %s on printer %s") % (group.Name, printer.Name)
547            self.logger.log_message(adminmessage)
548            if mailto in [ "BOTH", "ADMIN" ] :
549                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
550            if group.LimitBy and (group.LimitBy.lower() == "balance") : 
551                message = self.config.getPoorWarn()
552            else :     
553                message = self.config.getSoftWarn(printer.Name)
554            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
555                for user in self.storage.getGroupMembers(group) :
556                    if mailto != "EXTERNAL" :
557                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
558                    else :   
559                        self.externalMailTo(arguments, action, user, printer.Name, message)
560        return action       
561       
562    def warnUserPQuota(self, userpquota) :
563        """Checks a user quota and send him a message if quota is exceeded on current printer."""
564        user = userpquota.User
565        printer = userpquota.Printer
566        admin = self.config.getAdmin(printer.Name)
567        adminmail = self.config.getAdminMail(printer.Name)
568        (mailto, arguments) = self.config.getMailTo(printer.Name)
569        action = self.checkUserPQuota(userpquota)
570        if action.startswith("POLICY_") :
571            action = action[7:]
572        if action == "DENY" :
573            adminmessage = _("Print Quota exceeded for user %s on printer %s") % (user.Name, printer.Name)
574            self.logger.log_message(adminmessage)
575            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
576                message = self.config.getHardWarn(printer.Name)
577                if mailto != "EXTERNAL" :
578                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
579                else :   
580                    self.externalMailTo(arguments, action, user, printer.Name, message)
581            if mailto in [ "BOTH", "ADMIN" ] :
582                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
583        elif action == "WARN" :   
584            adminmessage = _("Print Quota low for user %s on printer %s") % (user.Name, printer.Name)
585            self.logger.log_message(adminmessage)
586            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
587                if user.LimitBy and (user.LimitBy.lower() == "balance") : 
588                    message = self.config.getPoorWarn()
589                else :     
590                    message = self.config.getSoftWarn(printer.Name)
591                if mailto != "EXTERNAL" :   
592                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Low"), message)
593                else :   
594                    self.externalMailTo(arguments, action, user, printer.Name, message)
595            if mailto in [ "BOTH", "ADMIN" ] :
596                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
597        return action       
Note: See TracBrowser for help on using the browser.