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

Revision 1563, 49.9 kB (checked in by jalet, 20 years ago)

Catches accounter configuration errors earlier

  • 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-2004 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.107  2004/06/23 13:03:28  jalet
25# Catches accounter configuration errors earlier
26#
27# Revision 1.106  2004/06/22 09:31:18  jalet
28# Always send some debug info to CUPS' back channel stream (stderr) as
29# informationnal messages.
30#
31# Revision 1.105  2004/06/21 08:17:38  jalet
32# Added version number in subject message for directive crashrecipient.
33#
34# Revision 1.104  2004/06/18 13:34:49  jalet
35# Now all tracebacks include PyKota's version number
36#
37# Revision 1.103  2004/06/18 13:17:26  jalet
38# Now includes PyKota's version number in messages sent by the crashrecipient
39# directive.
40#
41# Revision 1.102  2004/06/17 13:26:51  jalet
42# Better exception handling code
43#
44# Revision 1.101  2004/06/16 20:56:34  jalet
45# Smarter initialisation code
46#
47# Revision 1.100  2004/06/11 08:16:03  jalet
48# More exceptions catched in case of very early failure.
49#
50# Revision 1.99  2004/06/11 07:07:38  jalet
51# Now detects and logs configuration syntax errors instead of failing without
52# any notice message.
53#
54# Revision 1.98  2004/06/08 19:27:12  jalet
55# Doesn't ignore SIGCHLD anymore
56#
57# Revision 1.97  2004/06/07 22:45:35  jalet
58# Now accepts a job when enforcement is STRICT and predicted account balance
59# is equal to 0.0 : since the job hasn't been printed yet, only its printing
60# will really render balance equal to 0.0, so we should be allowed to print.
61#
62# Revision 1.96  2004/06/05 22:18:04  jalet
63# Now catches some exceptions earlier.
64# storage.py and ldapstorage.py : removed old comments
65#
66# Revision 1.95  2004/06/03 21:50:34  jalet
67# Improved error logging.
68# crashrecipient directive added.
69# Now exports the job's size in bytes too.
70#
71# Revision 1.94  2004/06/03 08:51:03  jalet
72# logs job's size in bytes now
73#
74# Revision 1.93  2004/06/02 21:51:02  jalet
75# Moved the sigterm capturing elsewhere
76#
77# Revision 1.92  2004/06/02 13:21:38  jalet
78# Debug message added
79#
80# Revision 1.91  2004/05/25 05:17:52  jalet
81# Now precomputes the job's size only if current printer's enforcement
82# is "STRICT"
83#
84# Revision 1.90  2004/05/24 22:45:49  jalet
85# New 'enforcement' directive added
86# Polling loop improvements
87#
88# Revision 1.89  2004/05/21 22:02:52  jalet
89# Preliminary work on pre-accounting
90#
91# Revision 1.88  2004/05/18 14:49:20  jalet
92# Big code changes to completely remove the need for "requester" directives,
93# jsut use "hardware(... your previous requester directive's content ...)"
94#
95# Revision 1.87  2004/05/17 19:14:59  jalet
96# Now catches SIGPIPE and SIGCHLD
97#
98# Revision 1.86  2004/05/13 13:59:28  jalet
99# Code simplifications
100#
101# Revision 1.85  2004/05/11 08:26:27  jalet
102# Now catches connection problems to SMTP server
103#
104# Revision 1.84  2004/04/21 08:36:32  jalet
105# Exports the PYKOTASTATUS environment variable when SIGTERM is received.
106#
107# Revision 1.83  2004/04/16 17:03:49  jalet
108# The list of printers groups the current printer is a member of is
109# now exported in the PYKOTAPGROUPS environment variable
110#
111# Revision 1.82  2004/04/13 09:38:03  jalet
112# More work on correct child processes handling
113#
114# Revision 1.81  2004/04/09 22:24:47  jalet
115# Began work on correct handling of child processes when jobs are cancelled by
116# the user. Especially important when an external requester is running for a
117# long time.
118#
119# Revision 1.80  2004/04/06 12:00:21  jalet
120# uninitialized values caused problems
121#
122# Revision 1.79  2004/03/28 21:01:29  jalet
123# PYKOTALIMITBY environment variable is now exported too
124#
125# Revision 1.78  2004/03/08 20:13:25  jalet
126# Allow names to begin with a digit
127#
128# Revision 1.77  2004/03/03 13:10:35  jalet
129# Now catches all smtplib exceptions when there's a problem sending messages
130#
131# Revision 1.76  2004/03/01 14:34:15  jalet
132# PYKOTAPHASE wasn't set at the right time at the end of data transmission
133# to underlying layer (real backend)
134#
135# Revision 1.75  2004/03/01 11:23:25  jalet
136# Pre and Post hooks to external commands are available in the cupspykota
137# backend. Forthe pykota filter they will be implemented real soon now.
138#
139# Revision 1.74  2004/02/26 14:18:07  jalet
140# Should fix the remaining bugs wrt printers groups and users groups.
141#
142# Revision 1.73  2004/02/19 14:20:21  jalet
143# maildomain pykota.conf directive added.
144# Small improvements on mail headers quality.
145#
146# Revision 1.72  2004/01/14 15:51:19  jalet
147# Docstring added.
148#
149# Revision 1.71  2004/01/11 23:22:42  jalet
150# Major code refactoring, it's way cleaner, and now allows automated addition
151# of printers on first print.
152#
153# Revision 1.70  2004/01/08 14:10:32  jalet
154# Copyright year changed.
155#
156# Revision 1.69  2004/01/05 16:02:18  jalet
157# Dots in user, groups and printer names should be allowed.
158#
159# Revision 1.68  2004/01/02 17:38:40  jalet
160# This time it should be better...
161#
162# Revision 1.67  2004/01/02 17:37:09  jalet
163# I'm completely stupid !!! Better to not talk while coding !
164#
165# Revision 1.66  2004/01/02 17:31:26  jalet
166# Forgot to remove some code
167#
168# Revision 1.65  2003/12/06 08:14:38  jalet
169# Added support for CUPS device uris which contain authentication information.
170#
171# Revision 1.64  2003/11/29 22:03:17  jalet
172# Some code refactoring work. New code is not used at this time.
173#
174# Revision 1.63  2003/11/29 20:06:20  jalet
175# Added 'utolower' configuration option to convert all usernames to
176# lowercase when printing. All database accesses are still and will
177# remain case sensitive though.
178#
179# Revision 1.62  2003/11/25 22:37:22  jalet
180# Small code move
181#
182# Revision 1.61  2003/11/25 22:03:28  jalet
183# No more message on stderr when the translation is not available.
184#
185# Revision 1.60  2003/11/25 21:54:05  jalet
186# updated FAQ
187#
188# Revision 1.59  2003/11/25 13:33:43  jalet
189# Puts 'root' instead of '' when printing from CUPS web interface (which
190# gives an empty username)
191#
192# Revision 1.58  2003/11/21 14:28:45  jalet
193# More complete job history.
194#
195# Revision 1.57  2003/11/19 23:19:38  jalet
196# Code refactoring work.
197# Explicit redirection to /dev/null has to be set in external policy now, just
198# like in external mailto.
199#
200# Revision 1.56  2003/11/19 07:40:20  jalet
201# Missing import statement.
202# Better documentation for mailto: external(...)
203#
204# Revision 1.55  2003/11/18 23:43:12  jalet
205# Mailto can be any external command now, as usual.
206#
207# Revision 1.54  2003/10/24 21:52:46  jalet
208# Now can force language when coming from CGI script.
209#
210# Revision 1.53  2003/10/08 21:41:38  jalet
211# External policies for printers works !
212# We can now auto-add users on first print, and do other useful things if needed.
213#
214# Revision 1.52  2003/10/07 09:07:28  jalet
215# Character encoding added to please latest version of Python
216#
217# Revision 1.51  2003/10/06 14:21:41  jalet
218# Test reversed to not retrieve group members when no messages for them.
219#
220# Revision 1.50  2003/10/02 20:23:18  jalet
221# Storage caching mechanism added.
222#
223# Revision 1.49  2003/07/29 20:55:17  jalet
224# 1.14 is out !
225#
226# Revision 1.48  2003/07/21 23:01:56  jalet
227# Modified some messages aout soft limit
228#
229# Revision 1.47  2003/07/16 21:53:08  jalet
230# Really big modifications wrt new configuration file's location and content.
231#
232# Revision 1.46  2003/07/09 20:17:07  jalet
233# Email field added to PostgreSQL schema
234#
235# Revision 1.45  2003/07/08 19:43:51  jalet
236# Configurable warning messages.
237# Poor man's treshold value added.
238#
239# Revision 1.44  2003/07/07 11:49:24  jalet
240# Lots of small fixes with the help of PyChecker
241#
242# Revision 1.43  2003/07/04 09:06:32  jalet
243# Small bug fix wrt undefined "LimitBy" field.
244#
245# Revision 1.42  2003/06/30 12:46:15  jalet
246# Extracted reporting code.
247#
248# Revision 1.41  2003/06/25 14:10:01  jalet
249# Hey, it may work (edpykota --reset excepted) !
250#
251# Revision 1.40  2003/06/10 16:37:54  jalet
252# Deletion of the second user which is not needed anymore.
253# Added a debug configuration field in /etc/pykota.conf
254# All queries can now be sent to the logger in debug mode, this will
255# greatly help improve performance when time for this will come.
256#
257# Revision 1.39  2003/04/29 18:37:54  jalet
258# Pluggable accounting methods (actually doesn't support external scripts)
259#
260# Revision 1.38  2003/04/24 11:53:48  jalet
261# Default policy for unknown users/groups is to DENY printing instead
262# of the previous default to ALLOW printing. This is to solve an accuracy
263# problem. If you set the policy to ALLOW, jobs printed by in nexistant user
264# (from PyKota's POV) will be charged to the next user who prints on the
265# same printer.
266#
267# Revision 1.37  2003/04/24 08:08:27  jalet
268# Debug message forgotten
269#
270# Revision 1.36  2003/04/24 07:59:40  jalet
271# LPRng support now works !
272#
273# Revision 1.35  2003/04/23 22:13:57  jalet
274# Preliminary support for LPRng added BUT STILL UNTESTED.
275#
276# Revision 1.34  2003/04/17 09:26:21  jalet
277# repykota now reports account balances too.
278#
279# Revision 1.33  2003/04/16 12:35:49  jalet
280# Groups quota work now !
281#
282# Revision 1.32  2003/04/16 08:53:14  jalet
283# Printing can now be limited either by user's account balance or by
284# page quota (the default). Quota report doesn't include account balance
285# yet, though.
286#
287# Revision 1.31  2003/04/15 11:30:57  jalet
288# More work done on money print charging.
289# Minor bugs corrected.
290# All tools now access to the storage as priviledged users, repykota excepted.
291#
292# Revision 1.30  2003/04/10 21:47:20  jalet
293# Job history added. Upgrade script neutralized for now !
294#
295# Revision 1.29  2003/03/29 13:45:27  jalet
296# GPL paragraphs were incorrectly (from memory) copied into the sources.
297# Two README files were added.
298# Upgrade script for PostgreSQL pre 1.01 schema was added.
299#
300# Revision 1.28  2003/03/29 13:08:28  jalet
301# Configuration is now expected to be found in /etc/pykota.conf instead of
302# in /etc/cups/pykota.conf
303# Installation script can move old config files to the new location if needed.
304# Better error handling if configuration file is absent.
305#
306# Revision 1.27  2003/03/15 23:01:28  jalet
307# New mailto option in configuration file added.
308# No time to test this tonight (although it should work).
309#
310# Revision 1.26  2003/03/09 23:58:16  jalet
311# Comment
312#
313# Revision 1.25  2003/03/07 22:56:14  jalet
314# 0.99 is out with some bug fixes.
315#
316# Revision 1.24  2003/02/27 23:48:41  jalet
317# Correctly maps PyKota's log levels to syslog log levels
318#
319# Revision 1.23  2003/02/27 22:55:20  jalet
320# WARN log priority doesn't exist.
321#
322# Revision 1.22  2003/02/27 09:09:20  jalet
323# Added a method to match strings against wildcard patterns
324#
325# Revision 1.21  2003/02/17 23:01:56  jalet
326# Typos
327#
328# Revision 1.20  2003/02/17 22:55:01  jalet
329# More options can now be set per printer or globally :
330#
331#       admin
332#       adminmail
333#       gracedelay
334#       requester
335#
336# the printer option has priority when both are defined.
337#
338# Revision 1.19  2003/02/10 11:28:45  jalet
339# Localization
340#
341# Revision 1.18  2003/02/10 01:02:17  jalet
342# External requester is about to work, but I must sleep
343#
344# Revision 1.17  2003/02/09 13:05:43  jalet
345# Internationalization continues...
346#
347# Revision 1.16  2003/02/09 12:56:53  jalet
348# Internationalization begins...
349#
350# Revision 1.15  2003/02/08 22:09:52  jalet
351# Name check method moved here
352#
353# Revision 1.14  2003/02/07 10:42:45  jalet
354# Indentation problem
355#
356# Revision 1.13  2003/02/07 08:34:16  jalet
357# Test wrt date limit was wrong
358#
359# Revision 1.12  2003/02/06 23:20:02  jalet
360# warnpykota doesn't need any user/group name argument, mimicing the
361# warnquota disk quota tool.
362#
363# Revision 1.11  2003/02/06 22:54:33  jalet
364# warnpykota should be ok
365#
366# Revision 1.10  2003/02/06 15:03:11  jalet
367# added a method to set the limit date
368#
369# Revision 1.9  2003/02/06 10:39:23  jalet
370# Preliminary edpykota work.
371#
372# Revision 1.8  2003/02/06 09:19:02  jalet
373# More robust behavior (hopefully) when the user or printer is not managed
374# correctly by the Quota System : e.g. cupsFilter added in ppd file, but
375# printer and/or user not 'yet?' in storage.
376#
377# Revision 1.7  2003/02/06 00:00:45  jalet
378# Now includes the printer name in email messages
379#
380# Revision 1.6  2003/02/05 23:55:02  jalet
381# Cleaner email messages
382#
383# Revision 1.5  2003/02/05 23:45:09  jalet
384# Better DateTime manipulation wrt grace delay
385#
386# Revision 1.4  2003/02/05 23:26:22  jalet
387# Incorrect handling of grace delay
388#
389# Revision 1.3  2003/02/05 22:16:20  jalet
390# DEVICE_URI is undefined outside of CUPS, i.e. for normal command line tools
391#
392# Revision 1.2  2003/02/05 22:10:29  jalet
393# Typos
394#
395# Revision 1.1  2003/02/05 21:28:17  jalet
396# Initial import into CVS
397#
398#
399#
400
401import sys
402import os
403import fnmatch
404import getopt
405import smtplib
406import gettext
407import locale
408import signal
409import socket
410import tempfile
411import ConfigParser
412
413from mx import DateTime
414
415from pykota import version, config, storage, logger, accounter, pdlanalyzer
416
417class PyKotaToolError(Exception):
418    """An exception for PyKota config related stuff."""
419    def __init__(self, message = ""):
420        self.message = message
421        Exception.__init__(self, message)
422    def __repr__(self):
423        return self.message
424    __str__ = __repr__
425   
426def crashed(message) :   
427    """Minimal crash method."""
428    import traceback
429    lines = []
430    for line in traceback.format_exception(*sys.exc_info()) :
431        lines.extend([l for l in line.split("\n") if l])
432    msg = "ERROR: ".join(["%s\n" % l for l in (["ERROR: PyKota v%s" % version.__version__, message] + lines)])
433    sys.stderr.write(msg)
434    sys.stderr.flush()
435    return msg
436
437class PyKotaTool :   
438    """Base class for all PyKota command line tools."""
439    def __init__(self, lang=None, doc="PyKota %s (c) 2003-2004 %s" % (version.__version__, version.__author__)) :
440        """Initializes the command line tool."""
441        # locale stuff
442        try :
443            locale.setlocale(locale.LC_ALL, lang)
444            gettext.install("pykota")
445        except (locale.Error, IOError) :
446            gettext.NullTranslations().install()
447            # sys.stderr.write("PyKota : Error while loading translations\n")
448   
449        # pykota specific stuff
450        self.documentation = doc
451        try :
452            self.config = config.PyKotaConfig("/etc/pykota")
453        except ConfigParser.ParsingError, msg :   
454            sys.stderr.write("ERROR: Problem encountered while parsing configuration file : %s\n" % msg)
455            sys.stderr.flush()
456            sys.exit(-1)
457           
458        try :
459            self.debug = self.config.getDebug()
460            self.smtpserver = self.config.getSMTPServer()
461            self.maildomain = self.config.getMailDomain()
462            self.logger = logger.openLogger(self.config.getLoggingBackend())
463            self.storage = storage.openConnection(self)
464        except (config.PyKotaConfigError, logger.PyKotaLoggingError, storage.PyKotaStorageError), msg :
465            self.crashed(msg)
466            raise
467        self.softwareJobSize = 0
468        self.softwareJobPrice = 0.0
469       
470    def logdebug(self, message) :   
471        """Logs something to debug output if debug is enabled."""
472        if self.debug :
473            self.logger.log_message(message, "debug")
474       
475    def clean(self) :   
476        """Ensures that the database is closed."""
477        try :
478            self.storage.close()
479        except (TypeError, NameError, AttributeError) :   
480            pass
481           
482    def display_version_and_quit(self) :
483        """Displays version number, then exists successfully."""
484        self.clean()
485        print version.__version__
486        sys.exit(0)
487   
488    def display_usage_and_quit(self) :
489        """Displays command line usage, then exists successfully."""
490        self.clean()
491        print self.documentation
492        sys.exit(0)
493       
494    def crashed(self, message) :   
495        """Outputs a crash message, and optionally sends it to software author."""
496        msg = crashed(message)
497        try :
498            crashrecipient = self.config.getCrashRecipient()
499            if crashrecipient :
500                admin = self.config.getAdminMail("global") # Nice trick, isn't it ?
501                fullmessage = "========== Traceback :\n\n%s\n\n========== sys.argv :\n\n%s\n\n========== Environment :\n\n%s\n" % \
502                                (msg, \
503                                 "\n".join(["    %s" % repr(a) for a in sys.argv]), \
504                                 "\n".join(["    %s=%s" % (k, v) for (k, v) in os.environ.items()]))
505                server = smtplib.SMTP(self.smtpserver)
506                server.sendmail(admin, [admin, crashrecipient], \
507                                       "From: %s\nTo: %s\nCc: %s\nSubject: PyKota v%s crash traceback !\n\n%s" % \
508                                       (admin, crashrecipient, admin, version.__version__, fullmessage))
509                server.quit()
510        except :
511            pass
512       
513    def parseCommandline(self, argv, short, long, allownothing=0) :
514        """Parses the command line, controlling options."""
515        # split options in two lists: those which need an argument, those which don't need any
516        withoutarg = []
517        witharg = []
518        lgs = len(short)
519        i = 0
520        while i < lgs :
521            ii = i + 1
522            if (ii < lgs) and (short[ii] == ':') :
523                # needs an argument
524                witharg.append(short[i])
525                ii = ii + 1 # skip the ':'
526            else :
527                # doesn't need an argument
528                withoutarg.append(short[i])
529            i = ii
530               
531        for option in long :
532            if option[-1] == '=' :
533                # needs an argument
534                witharg.append(option[:-1])
535            else :
536                # doesn't need an argument
537                withoutarg.append(option)
538       
539        # we begin with all possible options unset
540        parsed = {}
541        for option in withoutarg + witharg :
542            parsed[option] = None
543       
544        # then we parse the command line
545        args = []       # to not break if something unexpected happened
546        try :
547            options, args = getopt.getopt(argv, short, long)
548            if options :
549                for (o, v) in options :
550                    # we skip the '-' chars
551                    lgo = len(o)
552                    i = 0
553                    while (i < lgo) and (o[i] == '-') :
554                        i = i + 1
555                    o = o[i:]
556                    if o in witharg :
557                        # needs an argument : set it
558                        parsed[o] = v
559                    elif o in withoutarg :
560                        # doesn't need an argument : boolean
561                        parsed[o] = 1
562                    else :
563                        # should never occur
564                        raise PyKotaToolError, "Unexpected problem when parsing command line"
565            elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
566                self.display_usage_and_quit()
567        except getopt.error, msg :
568            sys.stderr.write("%s\n" % msg)
569            sys.stderr.flush()
570            self.display_usage_and_quit()
571        return (parsed, args)
572   
573    def isValidName(self, name) :
574        """Checks if a user or printer name is valid."""
575        # unfortunately Python 2.1 string modules doesn't define ascii_letters...
576        asciiletters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
577        digits = '0123456789'
578        validchars = asciiletters + digits + "-_."
579        for c in name :
580            if c not in validchars :
581                return 0
582        return 1       
583       
584    def matchString(self, s, patterns) :
585        """Returns 1 if the string s matches one of the patterns, else 0."""
586        for pattern in patterns :
587            if fnmatch.fnmatchcase(s, pattern) :
588                return 1
589        return 0
590       
591    def sendMessage(self, adminmail, touser, fullmessage) :
592        """Sends an email message containing headers to some user."""
593        if "@" not in touser :
594            touser = "%s@%s" % (touser, self.maildomain or self.smtpserver)
595        try :   
596            server = smtplib.SMTP(self.smtpserver)
597        except socket.error, msg :   
598            self.logger.log_message(_("Impossible to connect to SMTP server : %s") % msg, "error")
599        else :
600            try :
601                server.sendmail(adminmail, [touser], "From: %s\nTo: %s\n%s" % (adminmail, touser, fullmessage))
602            except smtplib.SMTPException, answer :   
603                for (k, v) in answer.recipients.items() :
604                    self.logger.log_message(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
605            server.quit()
606       
607    def sendMessageToUser(self, admin, adminmail, user, subject, message) :
608        """Sends an email message to a user."""
609        message += _("\n\nPlease contact your system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail)
610        self.sendMessage(adminmail, user.Email or user.Name, "Subject: %s\n\n%s" % (subject, message))
611       
612    def sendMessageToAdmin(self, adminmail, subject, message) :
613        """Sends an email message to the Print Quota administrator."""
614        self.sendMessage(adminmail, adminmail, "Subject: %s\n\n%s" % (subject, message))
615       
616    def _checkUserPQuota(self, userpquota) :           
617        """Checks the user quota on a printer and deny or accept the job."""
618        # then we check the user's own quota
619        # if we get there we are sure that policy is not EXTERNAL
620        user = userpquota.User
621        printer = userpquota.Printer
622        enforcement = self.config.getPrinterEnforcement(printer.Name)
623        self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
624        (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
625        if not userpquota.Exists :
626            # Unknown userquota
627            if policy == "ALLOW" :
628                action = "POLICY_ALLOW"
629            else :   
630                action = "POLICY_DENY"
631            self.logger.log_message(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
632        else :   
633            pagecounter = int(userpquota.PageCounter or 0)
634            if enforcement == "STRICT" :
635                pagecounter += self.softwareJobSize
636            if userpquota.SoftLimit is not None :
637                softlimit = int(userpquota.SoftLimit)
638                if pagecounter < softlimit :
639                    action = "ALLOW"
640                else :   
641                    if userpquota.HardLimit is None :
642                        # only a soft limit, this is equivalent to having only a hard limit
643                        action = "DENY"
644                    else :   
645                        hardlimit = int(userpquota.HardLimit)
646                        if softlimit <= pagecounter < hardlimit :   
647                            now = DateTime.now()
648                            if userpquota.DateLimit is not None :
649                                datelimit = DateTime.ISO.ParseDateTime(userpquota.DateLimit)
650                            else :
651                                datelimit = now + self.config.getGraceDelay(printer.Name)
652                                userpquota.setDateLimit(datelimit)
653                            if now < datelimit :
654                                action = "WARN"
655                            else :   
656                                action = "DENY"
657                        else :         
658                            action = "DENY"
659            else :       
660                if userpquota.HardLimit is not None :
661                    # no soft limit, only a hard one.
662                    hardlimit = int(userpquota.HardLimit)
663                    if pagecounter < hardlimit :
664                        action = "ALLOW"
665                    else :     
666                        action = "DENY"
667                else :
668                    # Both are unset, no quota, i.e. accounting only
669                    action = "ALLOW"
670        return action
671   
672    def checkGroupPQuota(self, grouppquota) :   
673        """Checks the group quota on a printer and deny or accept the job."""
674        group = grouppquota.Group
675        printer = grouppquota.Printer
676        enforcement = self.config.getPrinterEnforcement(printer.Name)
677        self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
678        if group.LimitBy and (group.LimitBy.lower() == "balance") : 
679            val = group.AccountBalance
680            if enforcement == "STRICT" : 
681                val -= self.softwareJobPrice # use precomputed size.
682            if val <= 0.0 :
683                action = "DENY"
684            elif val <= self.config.getPoorMan() :   
685                action = "WARN"
686            else :   
687                action = "ALLOW"
688            if (enforcement == "STRICT") and (val == 0.0) :
689                action = "WARN" # we can still print until account is 0
690        else :
691            val = grouppquota.PageCounter
692            if enforcement == "STRICT" :
693                val += self.softwareJobSize
694            if grouppquota.SoftLimit is not None :
695                softlimit = int(grouppquota.SoftLimit)
696                if val < softlimit :
697                    action = "ALLOW"
698                else :   
699                    if grouppquota.HardLimit is None :
700                        # only a soft limit, this is equivalent to having only a hard limit
701                        action = "DENY"
702                    else :   
703                        hardlimit = int(grouppquota.HardLimit)
704                        if softlimit <= val < hardlimit :   
705                            now = DateTime.now()
706                            if grouppquota.DateLimit is not None :
707                                datelimit = DateTime.ISO.ParseDateTime(grouppquota.DateLimit)
708                            else :
709                                datelimit = now + self.config.getGraceDelay(printer.Name)
710                                grouppquota.setDateLimit(datelimit)
711                            if now < datelimit :
712                                action = "WARN"
713                            else :   
714                                action = "DENY"
715                        else :         
716                            action = "DENY"
717            else :       
718                if grouppquota.HardLimit is not None :
719                    # no soft limit, only a hard one.
720                    hardlimit = int(grouppquota.HardLimit)
721                    if val < hardlimit :
722                        action = "ALLOW"
723                    else :     
724                        action = "DENY"
725                else :
726                    # Both are unset, no quota, i.e. accounting only
727                    action = "ALLOW"
728        return action
729   
730    def checkUserPQuota(self, userpquota) :
731        """Checks the user quota on a printer and all its parents and deny or accept the job."""
732        user = userpquota.User
733        printer = userpquota.Printer
734       
735        # indicates that a warning needs to be sent
736        warned = 0               
737       
738        # first we check any group the user is a member of
739        for group in self.storage.getUserGroups(user) :
740            grouppquota = self.storage.getGroupPQuota(group, printer)
741            # for the printer and all its parents
742            for gpquota in [ grouppquota ] + grouppquota.ParentPrintersGroupPQuota :
743                if gpquota.Exists :
744                    action = self.checkGroupPQuota(gpquota)
745                    if action == "DENY" :
746                        return action
747                    elif action == "WARN" :   
748                        warned = 1
749                       
750        # Then we check the user's account balance
751        # if we get there we are sure that policy is not EXTERNAL
752        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
753        if user.LimitBy and (user.LimitBy.lower() == "balance") : 
754            self.logdebug("Checking account balance for user %s" % user.Name)
755            if user.AccountBalance is None :
756                if policy == "ALLOW" :
757                    action = "POLICY_ALLOW"
758                else :   
759                    action = "POLICY_DENY"
760                self.logger.log_message(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
761                return action       
762            else :   
763                val = float(user.AccountBalance or 0.0)
764                enforcement = self.config.getPrinterEnforcement(printer.Name)
765                if enforcement == "STRICT" : 
766                    val -= self.softwareJobPrice # use precomputed size.
767                if val <= 0.0 :
768                    action = "DENY"
769                elif val <= self.config.getPoorMan() :   
770                    action = "WARN"
771                else :
772                    action = "ALLOW"
773                if (enforcement == "STRICT") and (val == 0.0) :
774                    action = "WARN" # we can still print until account is 0
775                return action   
776        else :
777            # Then check the user quota on current printer and all its parents.               
778            policyallowed = 0
779            for upquota in [ userpquota ] + userpquota.ParentPrintersUserPQuota :               
780                action = self._checkUserPQuota(upquota)
781                if action in ("DENY", "POLICY_DENY") :
782                    return action
783                elif action == "WARN" :   
784                    warned = 1
785                elif action == "POLICY_ALLOW" :   
786                    policyallowed = 1
787            if warned :       
788                return "WARN"
789            elif policyallowed :   
790                return "POLICY_ALLOW" 
791            else :   
792                return "ALLOW"
793               
794    def externalMailTo(self, cmd, action, user, printer, message) :
795        """Warns the user with an external command."""
796        username = user.Name
797        printername = printer.Name
798        email = user.Email or user.Name
799        if "@" not in email :
800            email = "%s@%s" % (email, self.maildomain or self.smtpserver)
801        os.system(cmd % locals())
802   
803    def formatCommandLine(self, cmd, user, printer) :
804        """Executes an external command."""
805        username = user.Name
806        printername = printer.Name
807        return cmd % locals()
808       
809    def warnGroupPQuota(self, grouppquota) :
810        """Checks a group quota and send messages if quota is exceeded on current printer."""
811        group = grouppquota.Group
812        printer = grouppquota.Printer
813        admin = self.config.getAdmin(printer.Name)
814        adminmail = self.config.getAdminMail(printer.Name)
815        (mailto, arguments) = self.config.getMailTo(printer.Name)
816        action = self.checkGroupPQuota(grouppquota)
817        if action.startswith("POLICY_") :
818            action = action[7:]
819        if action == "DENY" :
820            adminmessage = _("Print Quota exceeded for group %s on printer %s") % (group.Name, printer.Name)
821            self.logger.log_message(adminmessage)
822            if mailto in [ "BOTH", "ADMIN" ] :
823                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
824            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
825                for user in self.storage.getGroupMembers(group) :
826                    if mailto != "EXTERNAL" :
827                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), self.config.getHardWarn(printer.Name))
828                    else :   
829                        self.externalMailTo(arguments, action, user, printer, message)
830        elif action == "WARN" :   
831            adminmessage = _("Print Quota low for group %s on printer %s") % (group.Name, printer.Name)
832            self.logger.log_message(adminmessage)
833            if mailto in [ "BOTH", "ADMIN" ] :
834                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
835            if group.LimitBy and (group.LimitBy.lower() == "balance") : 
836                message = self.config.getPoorWarn()
837            else :     
838                message = self.config.getSoftWarn(printer.Name)
839            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
840                for user in self.storage.getGroupMembers(group) :
841                    if mailto != "EXTERNAL" :
842                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
843                    else :   
844                        self.externalMailTo(arguments, action, user, printer, message)
845        return action       
846       
847    def warnUserPQuota(self, userpquota) :
848        """Checks a user quota and send him a message if quota is exceeded on current printer."""
849        user = userpquota.User
850        printer = userpquota.Printer
851        admin = self.config.getAdmin(printer.Name)
852        adminmail = self.config.getAdminMail(printer.Name)
853        (mailto, arguments) = self.config.getMailTo(printer.Name)
854        action = self.checkUserPQuota(userpquota)
855        if action.startswith("POLICY_") :
856            action = action[7:]
857        if action == "DENY" :
858            adminmessage = _("Print Quota exceeded for user %s on printer %s") % (user.Name, printer.Name)
859            self.logger.log_message(adminmessage)
860            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
861                message = self.config.getHardWarn(printer.Name)
862                if mailto != "EXTERNAL" :
863                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
864                else :   
865                    self.externalMailTo(arguments, action, user, printer, message)
866            if mailto in [ "BOTH", "ADMIN" ] :
867                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
868        elif action == "WARN" :   
869            adminmessage = _("Print Quota low for user %s on printer %s") % (user.Name, printer.Name)
870            self.logger.log_message(adminmessage)
871            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
872                if user.LimitBy and (user.LimitBy.lower() == "balance") : 
873                    message = self.config.getPoorWarn()
874                else :     
875                    message = self.config.getSoftWarn(printer.Name)
876                if mailto != "EXTERNAL" :   
877                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Low"), message)
878                else :   
879                    self.externalMailTo(arguments, action, user, printer, message)
880            if mailto in [ "BOTH", "ADMIN" ] :
881                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
882        return action       
883       
884class PyKotaFilterOrBackend(PyKotaTool) :   
885    """Class for the PyKota filter or backend."""
886    def __init__(self) :
887        """Initialize local datas from current environment."""
888        # We begin with ignoring signals, we may de-ignore them later on.
889        self.gotSigTerm = 0
890        signal.signal(signal.SIGTERM, signal.SIG_IGN)
891        # signal.signal(signal.SIGCHLD, signal.SIG_IGN)
892        signal.signal(signal.SIGPIPE, signal.SIG_IGN)
893       
894        PyKotaTool.__init__(self)
895        (self.printingsystem, \
896         self.printerhostname, \
897         self.printername, \
898         self.username, \
899         self.jobid, \
900         self.inputfile, \
901         self.copies, \
902         self.title, \
903         self.options, \
904         self.originalbackend) = self.extractInfoFromCupsOrLprng()
905        self.username = self.username or 'root' # when printing test page from CUPS web interface, username is empty
906        if self.config.getUserNameToLower() :
907            self.username = self.username.lower()
908        self.preserveinputfile = self.inputfile 
909        try :
910            self.accounter = accounter.openAccounter(self)
911        except (config.PyKotaConfigError, accounter.PyKotaAccounterError), msg :   
912            self.crashed(msg)
913            raise
914        self.exportJobInfo()
915        self.jobdatastream = self.openJobDataStream()
916        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.jobSizeBytes)
917        self.logdebug("Job size is %s bytes" % self.jobSizeBytes)
918        self.logdebug("Capturing SIGTERM events.")
919        signal.signal(signal.SIGTERM, self.sigterm_handler)
920       
921    def openJobDataStream(self) :   
922        """Opens the file which contains the job's datas."""
923        if self.preserveinputfile is None :
924            # Job comes from sys.stdin, but this is not
925            # seekable and complexifies our task, so create
926            # a temporary file and use it instead
927            self.sendBackChannelData("Duplicating data stream from stdin to temporary file")
928            dummy = 0
929            MEGABYTE = 1024*1024
930            self.jobSizeBytes = 0
931            infile = tempfile.TemporaryFile()
932            while 1 :
933                data = sys.stdin.read(MEGABYTE) 
934                if not data :
935                    break
936                self.jobSizeBytes += len(data)   
937                if not (dummy % 10) :
938                    self.sendBackChannelData("%s bytes read..." % self.jobSizeBytes)
939                dummy += 1   
940                infile.write(data)
941            infile.flush()   
942            infile.seek(0)
943            return infile
944        else :   
945            # real file, just open it
946            self.sendBackChannelData("Opening data stream %s" % self.preserveinputfile)
947            self.jobSizeBytes = os.stat(self.preserveinputfile)[6]
948            return open(self.preserveinputfile, "rb")
949       
950    def closeJobDataStream(self) :   
951        """Closes the file which contains the job's datas."""
952        self.sendBackChannelData("Closing data stream.")
953        try :
954            self.jobdatastream.close()
955        except :   
956            pass
957       
958    def precomputeJobSize(self) :   
959        """Computes the job size with a software method."""
960        self.logdebug("Precomputing job's size with generic PDL analyzer...")
961        self.jobdatastream.seek(0)
962        try :
963            parser = pdlanalyzer.PDLAnalyzer(self.jobdatastream)
964            jobsize = parser.getJobSize()
965        except pdlanalyzer.PDLAnalyzerError, msg :   
966            # Here we just log the failure, but
967            # we finally ignore it and return 0 since this
968            # computation is just an indication of what the
969            # job's size MAY be.
970            self.logger.log_message(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
971            return 0
972        else :   
973            if ((self.printingsystem == "CUPS") \
974                and (self.preserveinputfile is not None)) \
975                or (self.printingsystem != "CUPS") :
976                return jobsize * self.copies
977            else :       
978                return jobsize
979           
980    def sigterm_handler(self, signum, frame) :
981        """Sets an attribute whenever SIGTERM is received."""
982        self.gotSigTerm = 1
983        os.environ["PYKOTASTATUS"] = "CANCELLED"
984        self.logger.log_message(_("SIGTERM received, job %s cancelled.") % self.jobid, "info")
985       
986    def exportJobInfo(self) :   
987        """Exports job information to the environment."""
988        os.environ["PYKOTAUSERNAME"] = str(self.username)
989        os.environ["PYKOTAPRINTERNAME"] = str(self.printername)
990        os.environ["PYKOTAJOBID"] = str(self.jobid)
991        os.environ["PYKOTATITLE"] = self.title or ""
992        os.environ["PYKOTAFILENAME"] = self.preserveinputfile or ""
993        os.environ["PYKOTACOPIES"] = str(self.copies)
994        os.environ["PYKOTAOPTIONS"] = self.options or ""
995   
996    def exportUserInfo(self, userpquota) :
997        """Exports user information to the environment."""
998        os.environ["PYKOTALIMITBY"] = str(userpquota.User.LimitBy)
999        os.environ["PYKOTABALANCE"] = str(userpquota.User.AccountBalance or 0.0)
1000        os.environ["PYKOTALIFETIMEPAID"] = str(userpquota.User.LifeTimePaid or 0.0)
1001        os.environ["PYKOTAPAGECOUNTER"] = str(userpquota.PageCounter or 0)
1002        os.environ["PYKOTALIFEPAGECOUNTER"] = str(userpquota.LifePageCounter or 0)
1003        os.environ["PYKOTASOFTLIMIT"] = str(userpquota.SoftLimit)
1004        os.environ["PYKOTAHARDLIMIT"] = str(userpquota.HardLimit)
1005        os.environ["PYKOTADATELIMIT"] = str(userpquota.DateLimit)
1006       
1007        # not really an user information, but anyway
1008        # exports the list of printers groups the current
1009        # printer is a member of
1010        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(userpquota.Printer)])
1011       
1012    def prehook(self, userpquota) :
1013        """Allows plugging of an external hook before the job gets printed."""
1014        prehook = self.config.getPreHook(userpquota.Printer.Name)
1015        if prehook :
1016            self.logdebug("Executing pre-hook [%s]" % prehook)
1017            os.system(prehook)
1018       
1019    def posthook(self, userpquota) :
1020        """Allows plugging of an external hook after the job gets printed and/or denied."""
1021        posthook = self.config.getPostHook(userpquota.Printer.Name)
1022        if posthook :
1023            self.logdebug("Executing post-hook [%s]" % posthook)
1024            os.system(posthook)
1025       
1026    def extractInfoFromCupsOrLprng(self) :   
1027        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
1028       
1029           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
1030        """
1031        # Try to detect CUPS
1032        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
1033            if len(sys.argv) == 7 :
1034                inputfile = sys.argv[6]
1035            else :   
1036                inputfile = None
1037               
1038            # check that the DEVICE_URI environment variable's value is
1039            # prefixed with "cupspykota:" otherwise don't touch it.
1040            # If this is the case, we have to remove the prefix from
1041            # the environment before launching the real backend in cupspykota
1042            device_uri = os.environ.get("DEVICE_URI", "")
1043            if device_uri.startswith("cupspykota:") :
1044                fulldevice_uri = device_uri[:]
1045                device_uri = fulldevice_uri[len("cupspykota:"):]
1046                if device_uri.startswith("//") :    # lpd (at least)
1047                    device_uri = device_uri[2:]
1048                os.environ["DEVICE_URI"] = device_uri   # TODO : side effect !
1049            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
1050            try :
1051                (backend, destination) = device_uri.split(":", 1) 
1052            except ValueError :   
1053                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
1054            while destination.startswith("/") :
1055                destination = destination[1:]
1056            checkauth = destination.split("@", 1)   
1057            if len(checkauth) == 2 :
1058                destination = checkauth[1]
1059            printerhostname = destination.split("/")[0].split(":")[0]
1060            return ("CUPS", \
1061                    printerhostname, \
1062                    os.environ.get("PRINTER"), \
1063                    sys.argv[2].strip(), \
1064                    sys.argv[1].strip(), \
1065                    inputfile, \
1066                    int(sys.argv[4].strip()), \
1067                    sys.argv[3], \
1068                    sys.argv[5], \
1069                    backend)
1070        else :   
1071            # Try to detect LPRng
1072            # TODO : try to extract filename, job's title, and options if available
1073            jseen = Pseen = nseen = rseen = Kseen = None
1074            for arg in sys.argv :
1075                if arg.startswith("-j") :
1076                    jseen = arg[2:].strip()
1077                elif arg.startswith("-n") :     
1078                    nseen = arg[2:].strip()
1079                elif arg.startswith("-P") :   
1080                    Pseen = arg[2:].strip()
1081                elif arg.startswith("-r") :   
1082                    rseen = arg[2:].strip()
1083                elif arg.startswith("-K") or arg.startswith("-#") :   
1084                    Kseen = int(arg[2:].strip())
1085            if Kseen is None :       
1086                Kseen = 1       # we assume the user wants at least one copy...
1087            if (rseen is None) and jseen and Pseen and nseen :   
1088                self.logger.log_message(_("Printer hostname undefined, set to 'localhost'"), "warn")
1089                rseen = "localhost"
1090            if jseen and Pseen and nseen and rseen :       
1091                # job is always in stdin (None)
1092                return ("LPRNG", rseen, Pseen, nseen, jseen, None, Kseen, None, None, None)
1093        self.logger.log_message(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
1094        return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system
1095       
1096    def getPrinterUserAndUserPQuota(self) :       
1097        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
1098       
1099           "OK" is returned in the policy if both printer, user and user print quota
1100           exist in the Quota Storage.
1101           Otherwise, the policy as defined for this printer in pykota.conf is returned.
1102           
1103           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
1104           doesn't exist in the Quota Storage, then an external command is launched, as
1105           defined in the external policy for this printer in pykota.conf
1106           This external command can do anything, like automatically adding printers
1107           or users, for example, and finally extracting printer, user and user print
1108           quota from the Quota Storage is tried a second time.
1109           
1110           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
1111           was returned by the external command.
1112        """
1113        for passnumber in range(1, 3) :
1114            printer = self.storage.getPrinter(self.printername)
1115            user = self.storage.getUser(self.username)
1116            userpquota = self.storage.getUserPQuota(user, printer)
1117            if printer.Exists and user.Exists and userpquota.Exists :
1118                policy = "OK"
1119                break
1120            (policy, args) = self.config.getPrinterPolicy(self.printername)
1121            if policy == "EXTERNAL" :   
1122                commandline = self.formatCommandLine(args, user, printer)
1123                if not printer.Exists :
1124                    self.logger.log_message(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.printername, commandline, self.printername), "info")
1125                if not user.Exists :
1126                    self.logger.log_message(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.username, commandline, self.printername), "info")
1127                if not userpquota.Exists :
1128                    self.logger.log_message(_("User %s doesn't have quota on printer %s in the PyKota system, applying external policy (%s) for printer %s") % (self.username, self.printername, commandline, self.printername), "info")
1129                if os.system(commandline) :
1130                    self.logger.log_message(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.printername), "error")
1131                    policy = "EXTERNALERROR"
1132                    break
1133            else :       
1134                if not printer.Exists :
1135                    self.logger.log_message(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.printername, policy), "info")
1136                if not user.Exists :
1137                    self.logger.log_message(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.username, policy, self.printername), "info")
1138                if not userpquota.Exists :
1139                    self.logger.log_message(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.username, self.printername, policy), "info")
1140                break
1141        if policy == "EXTERNAL" :   
1142            if not printer.Exists :
1143                self.logger.log_message(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.printername, "info")
1144            if not user.Exists :
1145                self.logger.log_message(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.username, self.printername), "info")
1146            if not userpquota.Exists :
1147                self.logger.log_message(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.username, self.printername), "info")
1148        return (policy, printer, user, userpquota)
1149       
1150    def mainWork(self) :   
1151        """Main work is done here."""
1152        (policy, printer, user, userpquota) = self.getPrinterUserAndUserPQuota()
1153        # TODO : check for last user's quota in case pykota filter is used with querying
1154        if policy == "EXTERNALERROR" :
1155            # Policy was 'EXTERNAL' and the external command returned an error code
1156            return self.removeJob()
1157        elif policy == "EXTERNAL" :
1158            # Policy was 'EXTERNAL' and the external command wasn't able
1159            # to add either the printer, user or user print quota
1160            return self.removeJob()
1161        elif policy == "DENY" :   
1162            # Either printer, user or user print quota doesn't exist,
1163            # and the job should be rejected.
1164            return self.removeJob()
1165        else :
1166            if policy not in ("OK", "ALLOW") :
1167                self.logger.log_message(_("Invalid policy %s for printer %s") % (policy, self.printername))
1168                return self.removeJob()
1169            else :
1170                return self.doWork(policy, printer, user, userpquota)
Note: See TracBrowser for help on using the browser.