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

Revision 1061, 20.8 kB (checked in by jalet, 21 years ago)

Small bug fix wrt undefined "LimitBy?" field.

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