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

Revision 956, 23.1 kB (checked in by jalet, 21 years ago)

Default policy for unknown users/groups is to DENY printing instead
of the previous default to ALLOW printing. This is to solve an accuracy
problem. If you set the policy to ALLOW, jobs printed by in nexistant user
(from PyKota's POV) will be charged to the next user who prints on the
same printer.

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