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

Revision 952, 22.7 kB (checked in by jalet, 21 years ago)

Preliminary support for LPRng added BUT STILL UNTESTED.

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