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

Revision 1130, 22.0 kB (checked in by jalet, 21 years ago)

Storage caching mechanism added.

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