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

Revision 1061, 20.8 kB (checked in by jalet, 21 years ago)

Small bug fix wrt undefined "LimitBy?" field.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
RevLine 
[695]1#! /usr/bin/env python
2
[952]3# PyKota - Print Quotas for CUPS and LPRng
[695]4#
5# (c) 2003 Jerome Alet <alet@librelogiciel.com>
[873]6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
[695]10#
[873]11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
[695]19#
20# $Id$
21#
22# $Log$
[1061]23# Revision 1.43  2003/07/04 09:06:32  jalet
24# Small bug fix wrt undefined "LimitBy" field.
25#
[1048]26# Revision 1.42  2003/06/30 12:46:15  jalet
27# Extracted reporting code.
28#
[1041]29# Revision 1.41  2003/06/25 14:10:01  jalet
30# Hey, it may work (edpykota --reset excepted) !
31#
[1021]32# Revision 1.40  2003/06/10 16:37:54  jalet
33# Deletion of the second user which is not needed anymore.
34# Added a debug configuration field in /etc/pykota.conf
35# All queries can now be sent to the logger in debug mode, this will
36# greatly help improve performance when time for this will come.
37#
[973]38# Revision 1.39  2003/04/29 18:37:54  jalet
39# Pluggable accounting methods (actually doesn't support external scripts)
40#
[956]41# Revision 1.38  2003/04/24 11:53:48  jalet
42# Default policy for unknown users/groups is to DENY printing instead
43# of the previous default to ALLOW printing. This is to solve an accuracy
44# problem. If you set the policy to ALLOW, jobs printed by in nexistant user
45# (from PyKota's POV) will be charged to the next user who prints on the
46# same printer.
47#
[955]48# Revision 1.37  2003/04/24 08:08:27  jalet
49# Debug message forgotten
50#
[954]51# Revision 1.36  2003/04/24 07:59:40  jalet
52# LPRng support now works !
53#
[952]54# Revision 1.35  2003/04/23 22:13:57  jalet
55# Preliminary support for LPRng added BUT STILL UNTESTED.
56#
[929]57# Revision 1.34  2003/04/17 09:26:21  jalet
58# repykota now reports account balances too.
59#
[927]60# Revision 1.33  2003/04/16 12:35:49  jalet
61# Groups quota work now !
62#
[925]63# Revision 1.32  2003/04/16 08:53:14  jalet
64# Printing can now be limited either by user's account balance or by
65# page quota (the default). Quota report doesn't include account balance
66# yet, though.
67#
[915]68# Revision 1.31  2003/04/15 11:30:57  jalet
69# More work done on money print charging.
70# Minor bugs corrected.
71# All tools now access to the storage as priviledged users, repykota excepted.
72#
[900]73# Revision 1.30  2003/04/10 21:47:20  jalet
74# Job history added. Upgrade script neutralized for now !
75#
[873]76# Revision 1.29  2003/03/29 13:45:27  jalet
77# GPL paragraphs were incorrectly (from memory) copied into the sources.
78# Two README files were added.
79# Upgrade script for PostgreSQL pre 1.01 schema was added.
80#
[872]81# Revision 1.28  2003/03/29 13:08:28  jalet
82# Configuration is now expected to be found in /etc/pykota.conf instead of
83# in /etc/cups/pykota.conf
84# Installation script can move old config files to the new location if needed.
85# Better error handling if configuration file is absent.
86#
[852]87# Revision 1.27  2003/03/15 23:01:28  jalet
88# New mailto option in configuration file added.
89# No time to test this tonight (although it should work).
90#
[844]91# Revision 1.26  2003/03/09 23:58:16  jalet
92# Comment
93#
[834]94# Revision 1.25  2003/03/07 22:56:14  jalet
95# 0.99 is out with some bug fixes.
96#
[825]97# Revision 1.24  2003/02/27 23:48:41  jalet
98# Correctly maps PyKota's log levels to syslog log levels
99#
[824]100# Revision 1.23  2003/02/27 22:55:20  jalet
101# WARN log priority doesn't exist.
102#
[817]103# Revision 1.22  2003/02/27 09:09:20  jalet
104# Added a method to match strings against wildcard patterns
105#
[804]106# Revision 1.21  2003/02/17 23:01:56  jalet
107# Typos
108#
[802]109# Revision 1.20  2003/02/17 22:55:01  jalet
110# More options can now be set per printer or globally :
111#
[804]112#       admin
113#       adminmail
114#       gracedelay
115#       requester
[802]116#
117# the printer option has priority when both are defined.
118#
[788]119# Revision 1.19  2003/02/10 11:28:45  jalet
120# Localization
121#
[782]122# Revision 1.18  2003/02/10 01:02:17  jalet
123# External requester is about to work, but I must sleep
124#
[773]125# Revision 1.17  2003/02/09 13:05:43  jalet
126# Internationalization continues...
127#
[772]128# Revision 1.16  2003/02/09 12:56:53  jalet
129# Internationalization begins...
130#
[764]131# Revision 1.15  2003/02/08 22:09:52  jalet
132# Name check method moved here
133#
[742]134# Revision 1.14  2003/02/07 10:42:45  jalet
135# Indentation problem
136#
[733]137# Revision 1.13  2003/02/07 08:34:16  jalet
138# Test wrt date limit was wrong
139#
[729]140# Revision 1.12  2003/02/06 23:20:02  jalet
141# warnpykota doesn't need any user/group name argument, mimicing the
142# warnquota disk quota tool.
143#
[728]144# Revision 1.11  2003/02/06 22:54:33  jalet
145# warnpykota should be ok
146#
[722]147# Revision 1.10  2003/02/06 15:03:11  jalet
148# added a method to set the limit date
149#
[715]150# Revision 1.9  2003/02/06 10:39:23  jalet
151# Preliminary edpykota work.
152#
[713]153# Revision 1.8  2003/02/06 09:19:02  jalet
154# More robust behavior (hopefully) when the user or printer is not managed
155# correctly by the Quota System : e.g. cupsFilter added in ppd file, but
156# printer and/or user not 'yet?' in storage.
157#
[712]158# Revision 1.7  2003/02/06 00:00:45  jalet
159# Now includes the printer name in email messages
160#
[711]161# Revision 1.6  2003/02/05 23:55:02  jalet
162# Cleaner email messages
163#
[709]164# Revision 1.5  2003/02/05 23:45:09  jalet
165# Better DateTime manipulation wrt grace delay
166#
[708]167# Revision 1.4  2003/02/05 23:26:22  jalet
168# Incorrect handling of grace delay
169#
[699]170# Revision 1.3  2003/02/05 22:16:20  jalet
171# DEVICE_URI is undefined outside of CUPS, i.e. for normal command line tools
172#
[698]173# Revision 1.2  2003/02/05 22:10:29  jalet
174# Typos
175#
[695]176# Revision 1.1  2003/02/05 21:28:17  jalet
177# Initial import into CVS
178#
179#
180#
181
182import sys
183import os
[817]184import fnmatch
[715]185import getopt
[695]186import smtplib
[772]187import gettext
[782]188import locale
[695]189
[708]190from mx import DateTime
191
[715]192from pykota import version, config, storage, logger
[695]193
194class PyKotaToolError(Exception):
195    """An exception for PyKota config related stuff."""
196    def __init__(self, message = ""):
197        self.message = message
198        Exception.__init__(self, message)
199    def __repr__(self):
200        return self.message
201    __str__ = __repr__
202   
203class PyKotaTool :   
204    """Base class for all PyKota command line tools."""
[1021]205    def __init__(self, doc="PyKota %s (c) 2003 %s" % (version.__version__, version.__author__)) :
[695]206        """Initializes the command line tool."""
[772]207        # locale stuff
[788]208        try :
[782]209            locale.setlocale(locale.LC_ALL, "")
[788]210            gettext.install("pykota")
211        except (locale.Error, IOError) :
212            gettext.NullTranslations().install()
[772]213   
214        # pykota specific stuff
[715]215        self.documentation = doc
[872]216        self.config = config.PyKotaConfig("/etc")
[1021]217        self.logger = logger.openLogger(self)
218        self.storage = storage.openConnection(self)
[695]219        self.smtpserver = self.config.getSMTPServer()
220       
[715]221    def display_version_and_quit(self) :
222        """Displays version number, then exists successfully."""
223        print version.__version__
224        sys.exit(0)
225   
226    def display_usage_and_quit(self) :
227        """Displays command line usage, then exists successfully."""
228        print self.documentation
229        sys.exit(0)
230       
[729]231    def parseCommandline(self, argv, short, long, allownothing=0) :
[715]232        """Parses the command line, controlling options."""
233        # split options in two lists: those which need an argument, those which don't need any
234        withoutarg = []
235        witharg = []
236        lgs = len(short)
237        i = 0
238        while i < lgs :
239            ii = i + 1
240            if (ii < lgs) and (short[ii] == ':') :
241                # needs an argument
242                witharg.append(short[i])
243                ii = ii + 1 # skip the ':'
244            else :
245                # doesn't need an argument
246                withoutarg.append(short[i])
247            i = ii
248               
249        for option in long :
250            if option[-1] == '=' :
251                # needs an argument
252                witharg.append(option[:-1])
253            else :
254                # doesn't need an argument
255                withoutarg.append(option)
256       
257        # we begin with all possible options unset
258        parsed = {}
259        for option in withoutarg + witharg :
260            parsed[option] = None
261       
262        # then we parse the command line
263        args = []       # to not break if something unexpected happened
264        try :
265            options, args = getopt.getopt(argv, short, long)
266            if options :
267                for (o, v) in options :
268                    # we skip the '-' chars
269                    lgo = len(o)
270                    i = 0
271                    while (i < lgo) and (o[i] == '-') :
272                        i = i + 1
273                    o = o[i:]
274                    if o in witharg :
275                        # needs an argument : set it
276                        parsed[o] = v
277                    elif o in withoutarg :
278                        # doesn't need an argument : boolean
279                        parsed[o] = 1
280                    else :
281                        # should never occur
282                        raise PyKotaToolError, "Unexpected problem when parsing command line"
[729]283            elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
[715]284                self.display_usage_and_quit()
285        except getopt.error, msg :
286            sys.stderr.write("%s\n" % msg)
287            sys.stderr.flush()
288            self.display_usage_and_quit()
289        return (parsed, args)
290   
[764]291    def isValidName(self, name) :
292        """Checks if a user or printer name is valid."""
293        # unfortunately Python 2.1 string modules doesn't define ascii_letters...
294        asciiletters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
295        digits = '0123456789'
296        if name[0] in asciiletters :
297            validchars = asciiletters + digits + "-_"
298            for c in name[1:] :
299                if c not in validchars :
300                    return 0
301            return 1       
302        return 0
303       
[817]304    def matchString(self, s, patterns) :
305        """Returns 1 if the string s matches one of the patterns, else 0."""
306        for pattern in patterns :
307            if fnmatch.fnmatchcase(s, pattern) :
308                return 1
309        return 0
310       
[802]311    def sendMessage(self, adminmail, touser, fullmessage) :
[695]312        """Sends an email message containing headers to some user."""
313        if "@" not in touser :
314            touser = "%s@%s" % (touser, self.smtpserver)
315        server = smtplib.SMTP(self.smtpserver)
[1041]316        try :
317            server.sendmail(adminmail, [touser], fullmessage)
318        except smtplib.SMTPRecipientsRefused, answer :   
319            for (k, v) in answer.recipients.items() :
320                self.logger.log_message(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
[695]321        server.quit()
322       
[802]323    def sendMessageToUser(self, admin, adminmail, username, subject, message) :
[695]324        """Sends an email message to a user."""
[802]325        message += _("\n\nPlease contact your system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail)
326        self.sendMessage(adminmail, username, "Subject: %s\n\n%s" % (subject, message))
[695]327       
[802]328    def sendMessageToAdmin(self, adminmail, subject, message) :
[695]329        """Sends an email message to the Print Quota administrator."""
[802]330        self.sendMessage(adminmail, adminmail, "Subject: %s\n\n%s" % (subject, message))
[695]331       
[1041]332    def checkGroupPQuota(self, grouppquota) :   
[927]333        """Checks the group quota on a printer and deny or accept the job."""
[1041]334        group = grouppquota.Group
335        printer = grouppquota.Printer
[1061]336        if group.LimitBy and (group.LimitBy.lower() == "balance") : 
[1041]337            # TODO : there's no warning (no account balance soft limit)
338            if float(group.AccountBalance) <= 0.0 :
339                action = "DENY"
[927]340            else :   
[1041]341                action = "ALLOW"
[927]342        else :
[1041]343            if grouppquota.SoftLimit is not None :
344                softlimit = int(grouppquota.SoftLimit)
345                if grouppquota.PageCounter < softlimit :
346                    action = "ALLOW"
[927]347                else :   
[1041]348                    if grouppquota.HardLimit is None :
349                        # only a soft limit, this is equivalent to having only a hard limit
350                        action = "DENY"
[927]351                    else :   
[1041]352                        hardlimit = int(grouppquota.HardLimit)
353                        if softlimit <= grouppquota.PageCounter < hardlimit :   
354                            now = DateTime.now()
355                            if grouppquota.DateLimit is not None :
356                                datelimit = DateTime.ISO.ParseDateTime(grouppquota.DateLimit)
357                            else :
358                                datelimit = now + self.config.getGraceDelay(printer.Name)
359                                grouppquota.setDateLimit(datelimit)
360                            if now < datelimit :
361                                action = "WARN"
362                            else :   
[927]363                                action = "DENY"
[1041]364                        else :         
[927]365                            action = "DENY"
[1041]366            else :       
367                if grouppquota.HardLimit is not None :
368                    # no soft limit, only a hard one.
369                    hardlimit = int(grouppquota.HardLimit)
370                    if grouppquota.PageCounter < hardlimit :
[927]371                        action = "ALLOW"
[1041]372                    else :     
373                        action = "DENY"
374                else :
375                    # Both are unset, no quota, i.e. accounting only
376                    action = "ALLOW"
[927]377        return action
378   
[1041]379    def checkUserPQuota(self, userpquota) :
[708]380        """Checks the user quota on a printer and deny or accept the job."""
[1041]381        user = userpquota.User
382        printer = userpquota.Printer
383       
[927]384        # first we check any group the user is a member of
[1041]385        for group in self.storage.getUserGroups(user) :
386            grouppquota = self.storage.getGroupPQuota(group, printer)
387            if grouppquota.Exists :
388                action = self.checkGroupPQuota(grouppquota)
389                if action == "DENY" :
390                    return action
[927]391               
392        # then we check the user's own quota
[1041]393        policy = self.config.getPrinterPolicy(printer.Name)
[1061]394        if user.LimitBy and (user.LimitBy.lower() == "balance") : 
[1041]395            if user.AccountBalance is None :
[956]396                if policy == "ALLOW" :
[925]397                    action = "POLICY_ALLOW"
398                else :   
399                    action = "POLICY_DENY"
[1041]400                self.logger.log_message(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
[713]401            else :   
[925]402                # TODO : there's no warning (no account balance soft limit)
[1041]403                if float(user.AccountBalance or 0.0) <= 0.0 :
[925]404                    action = "DENY"
405                else :   
[713]406                    action = "ALLOW"
[925]407        else :
[1041]408            if not userpquota.Exists :
409                # Unknown userquota
[956]410                if policy == "ALLOW" :
[925]411                    action = "POLICY_ALLOW"
[834]412                else :   
[925]413                    action = "POLICY_DENY"
[1041]414                self.logger.log_message(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
[925]415            else :   
[1041]416                pagecounter = int(userpquota.PageCounter or 0)
417                if userpquota.SoftLimit is not None :
418                    softlimit = int(userpquota.SoftLimit)
[925]419                    if pagecounter < softlimit :
420                        action = "ALLOW"
[834]421                    else :   
[1041]422                        if userpquota.HardLimit is None :
[925]423                            # only a soft limit, this is equivalent to having only a hard limit
424                            action = "DENY"
425                        else :   
[1041]426                            hardlimit = int(userpquota.HardLimit)
[925]427                            if softlimit <= pagecounter < hardlimit :   
428                                now = DateTime.now()
[1041]429                                if userpquota.DateLimit is not None :
430                                    datelimit = DateTime.ISO.ParseDateTime(userpquota.DateLimit)
[925]431                                else :
[1041]432                                    datelimit = now + self.config.getGraceDelay(printer.Name)
433                                    userpquota.setDateLimit(datelimit)
[925]434                                if now < datelimit :
435                                    action = "WARN"
436                                else :   
437                                    action = "DENY"
438                            else :         
[834]439                                action = "DENY"
[925]440                else :       
[1041]441                    if userpquota.HardLimit is not None :
[925]442                        # no soft limit, only a hard one.
[1041]443                        hardlimit = int(userpquota.HardLimit)
[925]444                        if pagecounter < hardlimit :
445                            action = "ALLOW"
446                        else :     
[742]447                            action = "DENY"
[925]448                    else :
449                        # Both are unset, no quota, i.e. accounting only
[834]450                        action = "ALLOW"
451        return action
[708]452   
[1041]453    def warnGroupPQuota(self, grouppquota) :
[927]454        """Checks a group quota and send messages if quota is exceeded on current printer."""
[1041]455        group = grouppquota.Group
456        printer = grouppquota.Printer
457        admin = self.config.getAdmin(printer.Name)
458        adminmail = self.config.getAdminMail(printer.Name)
459        mailto = self.config.getMailTo(printer.Name)
460        action = self.checkGroupPQuota(grouppquota, printer)
[927]461        if action.startswith("POLICY_") :
462            action = action[7:]
463        if action == "DENY" :
[1041]464            adminmessage = _("Print Quota exceeded for group %s on printer %s") % (group.Name, printer.Name)
[927]465            self.logger.log_message(adminmessage)
466            if mailto in [ "BOTH", "ADMIN" ] :
467                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
[1041]468            for user in self.storage.getGroupMembers(group) :
[927]469                if mailto in [ "BOTH", "USER" ] :
[1041]470                    self.sendMessageToUser(admin, adminmail, user.Name, _("Print Quota Exceeded"), _("You are not allowed to print anymore because\nyour group Print Quota is exceeded on printer %s.") % printer.Name)
[927]471        elif action == "WARN" :   
[1041]472            adminmessage = _("Print Quota soft limit exceeded for group %s on printer %s") % (group.Name, printer.Name)
[927]473            self.logger.log_message(adminmessage)
474            if mailto in [ "BOTH", "ADMIN" ] :
475                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
[1041]476            for user in self.storage.getGroupMembers(group) :
[927]477                if mailto in [ "BOTH", "USER" ] :
[1041]478                    self.sendMessageToUser(admin, adminmail, user.Name, _("Print Quota Exceeded"), _("You will soon be forbidden to print anymore because\nyour group Print Quota is almost reached on printer %s.") % printer.Name)
[927]479        return action       
[728]480       
[1041]481    def warnUserPQuota(self, userpquota) :
[728]482        """Checks a user quota and send him a message if quota is exceeded on current printer."""
[1041]483        user = userpquota.User
484        printer = userpquota.Printer
485        admin = self.config.getAdmin(printer.Name)
486        adminmail = self.config.getAdminMail(printer.Name)
487        mailto = self.config.getMailTo(printer.Name)
488        action = self.checkUserPQuota(userpquota)
[834]489        if action.startswith("POLICY_") :
490            action = action[7:]
[695]491        if action == "DENY" :
[1041]492            adminmessage = _("Print Quota exceeded for user %s on printer %s") % (user.Name, printer.Name)
[834]493            self.logger.log_message(adminmessage)
[852]494            if mailto in [ "BOTH", "USER" ] :
[1041]495                self.sendMessageToUser(admin, adminmail, user.Name, _("Print Quota Exceeded"), _("You are not allowed to print anymore because\nyour Print Quota is exceeded on printer %s.") % printer.Name)
[852]496            if mailto in [ "BOTH", "ADMIN" ] :
497                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
[695]498        elif action == "WARN" :   
[1041]499            adminmessage = _("Print Quota soft limit exceeded for user %s on printer %s") % (user.Name, printer.Name)
[695]500            self.logger.log_message(adminmessage)
[852]501            if mailto in [ "BOTH", "USER" ] :
[1041]502                self.sendMessageToUser(admin, adminmail, user.Name, _("Print Quota Exceeded"), _("You will soon be forbidden to print anymore because\nyour Print Quota is almost reached on printer %s.") % printer.Name)
[852]503            if mailto in [ "BOTH", "ADMIN" ] :
504                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
[695]505        return action       
Note: See TracBrowser for help on using the browser.