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

Revision 1171, 22.6 kB (checked in by jalet, 21 years ago)

Now can force language when coming from CGI script.

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