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

Revision 1144, 22.2 kB (checked in by jalet, 21 years ago)

Character encoding added to please latest version of Python

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