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

Revision 973, 21.1 kB (checked in by jalet, 21 years ago)

Pluggable accounting methods (actually doesn't support external scripts)

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