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

Revision 1140, 22.1 kB (checked in by jalet, 21 years ago)

Test reversed to not retrieve group members when no messages for them.

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