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

Revision 1087, 21.3 kB (checked in by jalet, 21 years ago)

Really big modifications wrt new configuration file's location and content.

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