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

Revision 2147, 46.9 kB (checked in by jerome, 19 years ago)

Removed all references to $Log$

  • 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#
24
25import sys
26import os
27import pwd
28import fnmatch
29import getopt
30import smtplib
31import gettext
32import locale
33import signal
34import socket
35import tempfile
36import md5
37import ConfigParser
38import popen2
39
40from mx import DateTime
41
42from pykota import version, config, storage, logger, accounter, pdlanalyzer
43
44def N_(message) :
45    """Fake translation marker for translatable strings extraction."""
46    return message
47
48class PyKotaToolError(Exception):
49    """An exception for PyKota config related stuff."""
50    def __init__(self, message = ""):
51        self.message = message
52        Exception.__init__(self, message)
53    def __repr__(self):
54        return self.message
55    __str__ = __repr__
56   
57def crashed(message) :   
58    """Minimal crash method."""
59    import traceback
60    lines = []
61    for line in traceback.format_exception(*sys.exc_info()) :
62        lines.extend([l for l in line.split("\n") if l])
63    msg = "ERROR: ".join(["%s\n" % l for l in (["ERROR: PyKota v%s" % version.__version__, message] + lines)])
64    sys.stderr.write(msg)
65    sys.stderr.flush()
66    return msg
67
68class Tool :
69    """Base class for tools with no database access."""
70    def __init__(self, lang="", charset=None, doc="PyKota %s (c) 2003-2004 %s" % (version.__version__, version.__author__)) :
71        """Initializes the command line tool."""
72        # did we drop priviledges ?
73        self.privdropped = 0
74       
75        # locale stuff
76        defaultToCLocale = 0
77        try :
78            locale.setlocale(locale.LC_ALL, lang)
79        except (locale.Error, IOError) :
80            locale.setlocale(locale.LC_ALL, "C")
81            defaultToCLocale = 1
82        try :
83            gettext.install("pykota")
84        except :
85            gettext.NullTranslations().install()
86           
87        # We can force the charset.   
88        # The CHARSET environment variable is set by CUPS when printing.
89        # Else we use the current locale's one.
90        # If nothing is set, we use ISO-8859-15 widely used in western Europe.
91        localecharset = None
92        try :
93            try :
94                # preferred method with Python 2.3 and up
95                localecharset = locale.getpreferredencoding()
96            except AttributeError :   
97                localecharset = locale.getlocale()[1]
98                try :
99                    localecharset = localecharset or locale.getdefaultlocale()[1]
100                except ValueError :   
101                    pass        # Unknown locale, strange...
102        except locale.Error :           
103            pass
104        self.charset = charset or os.environ.get("CHARSET") or localecharset or "ISO-8859-15"
105   
106        # pykota specific stuff
107        self.documentation = doc
108       
109        # try to find the configuration files in user's 'pykota' home directory.
110        try :
111            self.pykotauser = pwd.getpwnam("pykota")
112        except KeyError :   
113            self.pykotauser = None
114            confdir = "/etc/pykota"
115            missingUser = 1
116        else :   
117            confdir = self.pykotauser[5]
118            missingUser = 0
119           
120        try :
121            self.config = config.PyKotaConfig(confdir)
122        except ConfigParser.ParsingError, msg :   
123            sys.stderr.write("ERROR: Problem encountered while parsing configuration file : %s\n" % msg)
124            sys.stderr.flush()
125            sys.exit(-1)
126           
127        try :
128            self.debug = self.config.getDebug()
129            self.smtpserver = self.config.getSMTPServer()
130            self.maildomain = self.config.getMailDomain()
131            self.logger = logger.openLogger(self.config.getLoggingBackend())
132        except (config.PyKotaConfigError, logger.PyKotaLoggingError, storage.PyKotaStorageError), msg :
133            self.crashed(msg)
134            raise
135           
136        # now drop priviledge if possible
137        self.dropPriv()   
138       
139        # We NEED this here, even when not in an accounting filter/backend   
140        self.softwareJobSize = 0
141        self.softwareJobPrice = 0.0
142       
143        if defaultToCLocale :
144            self.printInfo("Incorrect locale settings. PyKota falls back to the 'C' locale.", "warn")
145        if missingUser :     
146            self.printInfo("The 'pykota' system account is missing. Configuration files were searched in /etc/pykota instead.", "warn")
147       
148        self.logdebug("Charset in use : %s" % self.charset)
149        arguments = " ".join(['"%s"' % arg for arg in sys.argv])
150        self.logdebug("Command line arguments : %s" % arguments)
151       
152    def dropPriv(self) :   
153        """Drops priviledges."""
154        uid = os.geteuid()
155        if uid :
156            try :
157                username = pwd.getpwuid(uid)[0]
158            except (KeyError, IndexError), msg :   
159                self.printInfo(_("Strange problem with uid(%s) : %s") % (uid, msg), "warn")
160            else :
161                self.logdebug(_("Running as user '%s'.") % username)
162        else :
163            if self.pykotauser is None :
164                self.logdebug(_("No user named 'pykota'. Not dropping priviledges."))
165            else :   
166                try :
167                    os.setegid(self.pykotauser[3])
168                    os.seteuid(self.pykotauser[2])
169                except OSError, msg :   
170                    self.printInfo(_("Impossible to drop priviledges : %s") % msg, "warn")
171                else :   
172                    self.logdebug(_("Priviledges dropped. Now running as user 'pykota'."))
173                    self.privdropped = 1
174           
175    def regainPriv(self) :   
176        """Drops priviledges."""
177        if self.privdropped :
178            try :
179                os.seteuid(0)
180                os.setegid(0)
181            except OSError, msg :   
182                self.printInfo(_("Impossible to regain priviledges : %s") % msg, "warn")
183            else :   
184                self.logdebug(_("Regained priviledges. Now running as root."))
185                self.privdropped = 0
186       
187    def getCharset(self) :   
188        """Returns the charset in use."""
189        return self.charset
190       
191    def logdebug(self, message) :   
192        """Logs something to debug output if debug is enabled."""
193        if self.debug :
194            self.logger.log_message(message, "debug")
195           
196    def printInfo(self, message, level="info") :       
197        """Sends a message to standard error."""
198        sys.stderr.write("%s: %s\n" % (level.upper(), message))
199        sys.stderr.flush()
200       
201    def display_version_and_quit(self) :
202        """Displays version number, then exists successfully."""
203        try :
204            self.clean()
205        except AttributeError :   
206            pass
207        print version.__version__
208        sys.exit(0)
209   
210    def display_usage_and_quit(self) :
211        """Displays command line usage, then exists successfully."""
212        try :
213            self.clean()
214        except AttributeError :   
215            pass
216        print _(self.documentation) % (version.__version__, version.__author__)
217        sys.exit(0)
218       
219    def crashed(self, message) :   
220        """Outputs a crash message, and optionally sends it to software author."""
221        msg = crashed(message)
222        try :
223            crashrecipient = self.config.getCrashRecipient()
224            if crashrecipient :
225                admin = self.config.getAdminMail("global") # Nice trick, isn't it ?
226                fullmessage = "========== Traceback :\n\n%s\n\n========== sys.argv :\n\n%s\n\n========== Environment :\n\n%s\n" % \
227                                (msg, \
228                                 "\n".join(["    %s" % repr(a) for a in sys.argv]), \
229                                 "\n".join(["    %s=%s" % (k, v) for (k, v) in os.environ.items()]))
230                server = smtplib.SMTP(self.smtpserver)
231                server.sendmail(admin, [admin, crashrecipient], \
232                                       "From: %s\nTo: %s\nCc: %s\nSubject: PyKota v%s crash traceback !\n\n%s" % \
233                                       (admin, crashrecipient, admin, version.__version__, fullmessage))
234                server.quit()
235        except :
236            pass
237       
238    def parseCommandline(self, argv, short, long, allownothing=0) :
239        """Parses the command line, controlling options."""
240        # split options in two lists: those which need an argument, those which don't need any
241        withoutarg = []
242        witharg = []
243        lgs = len(short)
244        i = 0
245        while i < lgs :
246            ii = i + 1
247            if (ii < lgs) and (short[ii] == ':') :
248                # needs an argument
249                witharg.append(short[i])
250                ii = ii + 1 # skip the ':'
251            else :
252                # doesn't need an argument
253                withoutarg.append(short[i])
254            i = ii
255               
256        for option in long :
257            if option[-1] == '=' :
258                # needs an argument
259                witharg.append(option[:-1])
260            else :
261                # doesn't need an argument
262                withoutarg.append(option)
263       
264        # we begin with all possible options unset
265        parsed = {}
266        for option in withoutarg + witharg :
267            parsed[option] = None
268       
269        # then we parse the command line
270        args = []       # to not break if something unexpected happened
271        try :
272            options, args = getopt.getopt(argv, short, long)
273            if options :
274                for (o, v) in options :
275                    # we skip the '-' chars
276                    lgo = len(o)
277                    i = 0
278                    while (i < lgo) and (o[i] == '-') :
279                        i = i + 1
280                    o = o[i:]
281                    if o in witharg :
282                        # needs an argument : set it
283                        parsed[o] = v
284                    elif o in withoutarg :
285                        # doesn't need an argument : boolean
286                        parsed[o] = 1
287                    else :
288                        # should never occur
289                        raise PyKotaToolError, "Unexpected problem when parsing command line"
290            elif (not args) and (not allownothing) and sys.stdin.isatty() : # no option and no argument, we display help if we are a tty
291                self.display_usage_and_quit()
292        except getopt.error, msg :
293            self.printInfo(msg)
294            self.display_usage_and_quit()
295        return (parsed, args)
296   
297class PyKotaTool(Tool) :   
298    """Base class for all PyKota command line tools."""
299    def __init__(self, lang="", charset=None, doc="PyKota %s (c) 2003-2004 %s" % (version.__version__, version.__author__)) :
300        """Initializes the command line tool and opens the database."""
301        Tool.__init__(self, lang, charset, doc)
302        try :
303            self.storage = storage.openConnection(self)
304        except storage.PyKotaStorageError, msg :
305            self.crashed(msg)
306            raise
307        else :   
308            if self.config.isAdmin : # TODO : We don't know this before, fix this !
309                self.logdebug("Beware : running as a PyKota administrator !")
310            else :   
311                self.logdebug("Don't Panic : running as a mere mortal !")
312       
313    def clean(self) :   
314        """Ensures that the database is closed."""
315        try :
316            self.storage.close()
317        except (TypeError, NameError, AttributeError) :   
318            pass
319           
320    def isValidName(self, name) :
321        """Checks if a user or printer name is valid."""
322        invalidchars = "/@?*,;&|"
323        for c in list(invalidchars) :
324            if c in name :
325                return 0
326        return 1       
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.maildomain or self.smtpserver)
339        try :   
340            server = smtplib.SMTP(self.smtpserver)
341        except socket.error, msg :   
342            self.printInfo(_("Impossible to connect to SMTP server : %s") % msg, "error")
343        else :
344            try :
345                server.sendmail(adminmail, [touser], "From: %s\nTo: %s\n%s" % (adminmail, touser, fullmessage))
346            except smtplib.SMTPException, answer :   
347                for (k, v) in answer.recipients.items() :
348                    self.printInfo(_("Impossible to send mail to %s, error %s : %s") % (k, v[0], v[1]), "error")
349            server.quit()
350       
351    def sendMessageToUser(self, admin, adminmail, user, subject, message) :
352        """Sends an email message to a user."""
353        message += _("\n\nPlease contact your system administrator :\n\n\t%s - <%s>\n") % (admin, adminmail)
354        self.sendMessage(adminmail, user.Email or user.Name, "Subject: %s\n\n%s" % (subject, message))
355       
356    def sendMessageToAdmin(self, adminmail, subject, message) :
357        """Sends an email message to the Print Quota administrator."""
358        self.sendMessage(adminmail, adminmail, "Subject: %s\n\n%s" % (subject, message))
359       
360    def _checkUserPQuota(self, userpquota) :           
361        """Checks the user quota on a printer and deny or accept the job."""
362        # then we check the user's own quota
363        # if we get there we are sure that policy is not EXTERNAL
364        user = userpquota.User
365        printer = userpquota.Printer
366        enforcement = self.config.getPrinterEnforcement(printer.Name)
367        self.logdebug("Checking user %s's quota on printer %s" % (user.Name, printer.Name))
368        (policy, dummy) = self.config.getPrinterPolicy(userpquota.Printer.Name)
369        if not userpquota.Exists :
370            # Unknown userquota
371            if policy == "ALLOW" :
372                action = "POLICY_ALLOW"
373            else :   
374                action = "POLICY_DENY"
375            self.printInfo(_("Unable to match user %s on printer %s, applying default policy (%s)") % (user.Name, printer.Name, action))
376        else :   
377            pagecounter = int(userpquota.PageCounter or 0)
378            if enforcement == "STRICT" :
379                pagecounter += self.softwareJobSize
380            if userpquota.SoftLimit is not None :
381                softlimit = int(userpquota.SoftLimit)
382                if pagecounter < softlimit :
383                    action = "ALLOW"
384                else :   
385                    if userpquota.HardLimit is None :
386                        # only a soft limit, this is equivalent to having only a hard limit
387                        action = "DENY"
388                    else :   
389                        hardlimit = int(userpquota.HardLimit)
390                        if softlimit <= pagecounter < hardlimit :   
391                            now = DateTime.now()
392                            if userpquota.DateLimit is not None :
393                                datelimit = DateTime.ISO.ParseDateTime(userpquota.DateLimit)
394                            else :
395                                datelimit = now + self.config.getGraceDelay(printer.Name)
396                                userpquota.setDateLimit(datelimit)
397                            if now < datelimit :
398                                action = "WARN"
399                            else :   
400                                action = "DENY"
401                        else :         
402                            action = "DENY"
403            else :       
404                if userpquota.HardLimit is not None :
405                    # no soft limit, only a hard one.
406                    hardlimit = int(userpquota.HardLimit)
407                    if pagecounter < hardlimit :
408                        action = "ALLOW"
409                    else :     
410                        action = "DENY"
411                else :
412                    # Both are unset, no quota, i.e. accounting only
413                    action = "ALLOW"
414        return action
415   
416    def checkGroupPQuota(self, grouppquota) :   
417        """Checks the group quota on a printer and deny or accept the job."""
418        group = grouppquota.Group
419        printer = grouppquota.Printer
420        enforcement = self.config.getPrinterEnforcement(printer.Name)
421        self.logdebug("Checking group %s's quota on printer %s" % (group.Name, printer.Name))
422        if group.LimitBy and (group.LimitBy.lower() == "balance") : 
423            val = group.AccountBalance or 0.0
424            if enforcement == "STRICT" : 
425                val -= self.softwareJobPrice # use precomputed size.
426            if val <= 0.0 :
427                action = "DENY"
428            elif val <= self.config.getPoorMan() :   
429                action = "WARN"
430            else :   
431                action = "ALLOW"
432            if (enforcement == "STRICT") and (val == 0.0) :
433                action = "WARN" # we can still print until account is 0
434        else :
435            val = grouppquota.PageCounter or 0
436            if enforcement == "STRICT" :
437                val += self.softwareJobSize
438            if grouppquota.SoftLimit is not None :
439                softlimit = int(grouppquota.SoftLimit)
440                if val < softlimit :
441                    action = "ALLOW"
442                else :   
443                    if grouppquota.HardLimit is None :
444                        # only a soft limit, this is equivalent to having only a hard limit
445                        action = "DENY"
446                    else :   
447                        hardlimit = int(grouppquota.HardLimit)
448                        if softlimit <= val < hardlimit :   
449                            now = DateTime.now()
450                            if grouppquota.DateLimit is not None :
451                                datelimit = DateTime.ISO.ParseDateTime(grouppquota.DateLimit)
452                            else :
453                                datelimit = now + self.config.getGraceDelay(printer.Name)
454                                grouppquota.setDateLimit(datelimit)
455                            if now < datelimit :
456                                action = "WARN"
457                            else :   
458                                action = "DENY"
459                        else :         
460                            action = "DENY"
461            else :       
462                if grouppquota.HardLimit is not None :
463                    # no soft limit, only a hard one.
464                    hardlimit = int(grouppquota.HardLimit)
465                    if val < hardlimit :
466                        action = "ALLOW"
467                    else :     
468                        action = "DENY"
469                else :
470                    # Both are unset, no quota, i.e. accounting only
471                    action = "ALLOW"
472        return action
473   
474    def checkUserPQuota(self, userpquota) :
475        """Checks the user quota on a printer and all its parents and deny or accept the job."""
476        user = userpquota.User
477        printer = userpquota.Printer
478       
479        # indicates that a warning needs to be sent
480        warned = 0               
481       
482        # first we check any group the user is a member of
483        for group in self.storage.getUserGroups(user) :
484            grouppquota = self.storage.getGroupPQuota(group, printer)
485            # for the printer and all its parents
486            for gpquota in [ grouppquota ] + grouppquota.ParentPrintersGroupPQuota :
487                if gpquota.Exists :
488                    action = self.checkGroupPQuota(gpquota)
489                    if action == "DENY" :
490                        return action
491                    elif action == "WARN" :   
492                        warned = 1
493                       
494        # Then we check the user's account balance
495        # if we get there we are sure that policy is not EXTERNAL
496        (policy, dummy) = self.config.getPrinterPolicy(printer.Name)
497        if user.LimitBy and (user.LimitBy.lower() == "balance") : 
498            self.logdebug("Checking account balance for user %s" % user.Name)
499            if user.AccountBalance is None :
500                if policy == "ALLOW" :
501                    action = "POLICY_ALLOW"
502                else :   
503                    action = "POLICY_DENY"
504                self.printInfo(_("Unable to find user %s's account balance, applying default policy (%s) for printer %s") % (user.Name, action, printer.Name))
505                return action       
506            else :   
507                if user.OverCharge == 0.0 :
508                    self.printInfo(_("User %s will not be charged for printing.") % user.Name)
509                    action = "ALLOW"
510                else :
511                    val = float(user.AccountBalance or 0.0)
512                    enforcement = self.config.getPrinterEnforcement(printer.Name)
513                    if enforcement == "STRICT" : 
514                        val -= self.softwareJobPrice # use precomputed size.
515                    if val <= 0.0 :
516                        action = "DENY"
517                    elif val <= self.config.getPoorMan() :   
518                        action = "WARN"
519                    else :
520                        action = "ALLOW"
521                    if (enforcement == "STRICT") and (val == 0.0) :
522                        action = "WARN" # we can still print until account is 0
523                return action   
524        else :
525            # Then check the user quota on current printer and all its parents.               
526            policyallowed = 0
527            for upquota in [ userpquota ] + userpquota.ParentPrintersUserPQuota :               
528                action = self._checkUserPQuota(upquota)
529                if action in ("DENY", "POLICY_DENY") :
530                    return action
531                elif action == "WARN" :   
532                    warned = 1
533                elif action == "POLICY_ALLOW" :   
534                    policyallowed = 1
535            if warned :       
536                return "WARN"
537            elif policyallowed :   
538                return "POLICY_ALLOW" 
539            else :   
540                return "ALLOW"
541               
542    def externalMailTo(self, cmd, action, user, printer, message) :
543        """Warns the user with an external command."""
544        username = user.Name
545        printername = printer.Name
546        email = user.Email or user.Name
547        if "@" not in email :
548            email = "%s@%s" % (email, self.maildomain or self.smtpserver)
549        os.system(cmd % locals())
550   
551    def formatCommandLine(self, cmd, user, printer) :
552        """Executes an external command."""
553        username = user.Name
554        printername = printer.Name
555        return cmd % locals()
556       
557    def warnGroupPQuota(self, grouppquota) :
558        """Checks a group quota and send messages if quota is exceeded on current printer."""
559        group = grouppquota.Group
560        printer = grouppquota.Printer
561        admin = self.config.getAdmin(printer.Name)
562        adminmail = self.config.getAdminMail(printer.Name)
563        (mailto, arguments) = self.config.getMailTo(printer.Name)
564        action = self.checkGroupPQuota(grouppquota)
565        if action.startswith("POLICY_") :
566            action = action[7:]
567        if action == "DENY" :
568            adminmessage = _("Print Quota exceeded for group %s on printer %s") % (group.Name, printer.Name)
569            self.printInfo(adminmessage)
570            if mailto in [ "BOTH", "ADMIN" ] :
571                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
572            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
573                for user in self.storage.getGroupMembers(group) :
574                    if mailto != "EXTERNAL" :
575                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), self.config.getHardWarn(printer.Name))
576                    else :   
577                        self.externalMailTo(arguments, action, user, printer, self.config.getHardWarn(printer.Name))
578        elif action == "WARN" :   
579            adminmessage = _("Print Quota low for group %s on printer %s") % (group.Name, printer.Name)
580            self.printInfo(adminmessage)
581            if mailto in [ "BOTH", "ADMIN" ] :
582                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
583            if group.LimitBy and (group.LimitBy.lower() == "balance") : 
584                message = self.config.getPoorWarn()
585            else :     
586                message = self.config.getSoftWarn(printer.Name)
587            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
588                for user in self.storage.getGroupMembers(group) :
589                    if mailto != "EXTERNAL" :
590                        self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
591                    else :   
592                        self.externalMailTo(arguments, action, user, printer, message)
593        return action       
594       
595    def warnUserPQuota(self, userpquota) :
596        """Checks a user quota and send him a message if quota is exceeded on current printer."""
597        user = userpquota.User
598        printer = userpquota.Printer
599        admin = self.config.getAdmin(printer.Name)
600        adminmail = self.config.getAdminMail(printer.Name)
601        (mailto, arguments) = self.config.getMailTo(printer.Name)
602        action = self.checkUserPQuota(userpquota)
603        if action.startswith("POLICY_") :
604            action = action[7:]
605           
606        if action == "DENY" :
607            adminmessage = _("Print Quota exceeded for user %s on printer %s") % (user.Name, printer.Name)
608            self.printInfo(adminmessage)
609            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
610                message = self.config.getHardWarn(printer.Name)
611                if mailto != "EXTERNAL" :
612                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Exceeded"), message)
613                else :   
614                    self.externalMailTo(arguments, action, user, printer, message)
615            if mailto in [ "BOTH", "ADMIN" ] :
616                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
617        elif action == "WARN" :   
618            adminmessage = _("Print Quota low for user %s on printer %s") % (user.Name, printer.Name)
619            self.printInfo(adminmessage)
620            if mailto in [ "BOTH", "USER", "EXTERNAL" ] :
621                if user.LimitBy and (user.LimitBy.lower() == "balance") : 
622                    message = self.config.getPoorWarn()
623                else :     
624                    message = self.config.getSoftWarn(printer.Name)
625                if mailto != "EXTERNAL" :   
626                    self.sendMessageToUser(admin, adminmail, user, _("Print Quota Low"), message)
627                else :   
628                    self.externalMailTo(arguments, action, user, printer, message)
629            if mailto in [ "BOTH", "ADMIN" ] :
630                self.sendMessageToAdmin(adminmail, _("Print Quota"), adminmessage)
631        return action       
632       
633class PyKotaFilterOrBackend(PyKotaTool) :   
634    """Class for the PyKota filter or backend."""
635    def __init__(self) :
636        """Initialize local datas from current environment."""
637        # We begin with ignoring signals, we may de-ignore them later on.
638        self.gotSigTerm = 0
639        signal.signal(signal.SIGTERM, signal.SIG_IGN)
640        # signal.signal(signal.SIGCHLD, signal.SIG_IGN)
641        signal.signal(signal.SIGPIPE, signal.SIG_IGN)
642       
643        PyKotaTool.__init__(self)
644        (self.printingsystem, \
645         self.printerhostname, \
646         self.printername, \
647         self.username, \
648         self.jobid, \
649         self.inputfile, \
650         self.copies, \
651         self.title, \
652         self.options, \
653         self.originalbackend) = self.extractInfoFromCupsOrLprng()
654         
655        arguments = " ".join(['"%s"' % arg for arg in sys.argv])
656        self.logdebug(_("Printing system %s, args=%s") % (str(self.printingsystem), arguments))
657       
658        self.username = self.username or 'root' # when printing test page from CUPS web interface, username is empty
659       
660        # do we want to strip out the Samba/Winbind domain name ?
661        separator = self.config.getWinbindSeparator()
662        if separator is not None :
663            self.username = self.username.split(separator)[-1]
664           
665        # do we want to lowercase usernames ?   
666        if self.config.getUserNameToLower() :
667            self.username = self.username.lower()
668           
669        self.preserveinputfile = self.inputfile 
670        try :
671            self.accounter = accounter.openAccounter(self)
672        except (config.PyKotaConfigError, accounter.PyKotaAccounterError), msg :   
673            self.crashed(msg)
674            raise
675        self.exportJobInfo()
676        self.jobdatastream = self.openJobDataStream()
677        self.checksum = self.computeChecksum()
678        self.softwareJobSize = self.precomputeJobSize()
679        os.environ["PYKOTAPRECOMPUTEDJOBSIZE"] = str(self.softwareJobSize)
680        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.jobSizeBytes)
681        self.logdebug("Job size is %s bytes on %s pages." % (self.jobSizeBytes, self.softwareJobSize))
682        self.logdebug("Capturing SIGTERM events.")
683        signal.signal(signal.SIGTERM, self.sigterm_handler)
684       
685    def sendBackChannelData(self, message, level="info") :   
686        """Sends an informational message to CUPS via back channel stream (stderr)."""
687        sys.stderr.write("%s: PyKota (PID %s) : %s\n" % (level.upper(), os.getpid(), message.strip()))
688        sys.stderr.flush()
689       
690    def computeChecksum(self) :   
691        """Computes the MD5 checksum of the job's datas, to be able to detect and forbid duplicate jobs."""
692        self.logdebug("Computing MD5 checksum for job %s" % self.jobid)
693        MEGABYTE = 1024*1024
694        checksum = md5.new()
695        while 1 :
696            data = self.jobdatastream.read(MEGABYTE) 
697            if not data :
698                break
699            checksum.update(data)   
700        self.jobdatastream.seek(0)
701        digest = checksum.hexdigest()
702        self.logdebug("MD5 checksum for job %s is %s" % (self.jobid, digest))
703        os.environ["PYKOTAMD5SUM"] = digest
704        return digest
705       
706    def openJobDataStream(self) :   
707        """Opens the file which contains the job's datas."""
708        if self.preserveinputfile is None :
709            # Job comes from sys.stdin, but this is not
710            # seekable and complexifies our task, so create
711            # a temporary file and use it instead
712            self.logdebug("Duplicating data stream from stdin to temporary file")
713            dummy = 0
714            MEGABYTE = 1024*1024
715            self.jobSizeBytes = 0
716            infile = tempfile.TemporaryFile()
717            while 1 :
718                data = sys.stdin.read(MEGABYTE) 
719                if not data :
720                    break
721                self.jobSizeBytes += len(data)   
722                if not (dummy % 10) :
723                    self.logdebug("%s bytes read..." % self.jobSizeBytes)
724                dummy += 1   
725                infile.write(data)
726            self.logdebug("%s bytes read total." % self.jobSizeBytes)
727            infile.flush()   
728            infile.seek(0)
729            return infile
730        else :   
731            # real file, just open it
732            self.regainPriv()
733            self.logdebug("Opening data stream %s" % self.preserveinputfile)
734            self.jobSizeBytes = os.stat(self.preserveinputfile)[6]
735            infile = open(self.preserveinputfile, "rb")
736            self.dropPriv()
737            return infile
738       
739    def closeJobDataStream(self) :   
740        """Closes the file which contains the job's datas."""
741        self.logdebug("Closing data stream.")
742        try :
743            self.jobdatastream.close()
744        except :   
745            pass
746       
747    def precomputeJobSize(self) :   
748        """Computes the job size with a software method."""
749        self.logdebug("Precomputing job's size with generic PDL analyzer...")
750        self.jobdatastream.seek(0)
751        try :
752            parser = pdlanalyzer.PDLAnalyzer(self.jobdatastream)
753            jobsize = parser.getJobSize()
754        except pdlanalyzer.PDLAnalyzerError, msg :   
755            # Here we just log the failure, but
756            # we finally ignore it and return 0 since this
757            # computation is just an indication of what the
758            # job's size MAY be.
759            self.printInfo(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
760            return 0
761        else :   
762            if ((self.printingsystem == "CUPS") \
763                and (self.preserveinputfile is not None)) \
764                or (self.printingsystem != "CUPS") :
765                return jobsize * self.copies
766            else :       
767                return jobsize
768           
769    def sigterm_handler(self, signum, frame) :
770        """Sets an attribute whenever SIGTERM is received."""
771        self.gotSigTerm = 1
772        os.environ["PYKOTASTATUS"] = "CANCELLED"
773        self.printInfo(_("SIGTERM received, job %s cancelled.") % self.jobid)
774       
775    def exportJobInfo(self) :   
776        """Exports job information to the environment."""
777        os.environ["PYKOTAUSERNAME"] = str(self.username)
778        os.environ["PYKOTAPRINTERNAME"] = str(self.printername)
779        os.environ["PYKOTAJOBID"] = str(self.jobid)
780        os.environ["PYKOTATITLE"] = self.title or ""
781        os.environ["PYKOTAFILENAME"] = self.preserveinputfile or ""
782        os.environ["PYKOTACOPIES"] = str(self.copies)
783        os.environ["PYKOTAOPTIONS"] = self.options or ""
784        os.environ["PYKOTAPRINTERHOSTNAME"] = self.printerhostname or "localhost"
785   
786    def exportUserInfo(self, userpquota) :
787        """Exports user information to the environment."""
788        os.environ["PYKOTAOVERCHARGE"] = str(userpquota.User.OverCharge)
789        os.environ["PYKOTALIMITBY"] = str(userpquota.User.LimitBy)
790        os.environ["PYKOTABALANCE"] = str(userpquota.User.AccountBalance or 0.0)
791        os.environ["PYKOTALIFETIMEPAID"] = str(userpquota.User.LifeTimePaid or 0.0)
792        os.environ["PYKOTAPAGECOUNTER"] = str(userpquota.PageCounter or 0)
793        os.environ["PYKOTALIFEPAGECOUNTER"] = str(userpquota.LifePageCounter or 0)
794        os.environ["PYKOTASOFTLIMIT"] = str(userpquota.SoftLimit)
795        os.environ["PYKOTAHARDLIMIT"] = str(userpquota.HardLimit)
796        os.environ["PYKOTADATELIMIT"] = str(userpquota.DateLimit)
797        os.environ["PYKOTAWARNCOUNT"] = str(userpquota.WarnCount)
798       
799        # not really an user information, but anyway
800        # exports the list of printers groups the current
801        # printer is a member of
802        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(userpquota.Printer)])
803       
804    def prehook(self, userpquota) :
805        """Allows plugging of an external hook before the job gets printed."""
806        prehook = self.config.getPreHook(userpquota.Printer.Name)
807        if prehook :
808            self.logdebug("Executing pre-hook [%s]" % prehook)
809            os.system(prehook)
810       
811    def posthook(self, userpquota) :
812        """Allows plugging of an external hook after the job gets printed and/or denied."""
813        posthook = self.config.getPostHook(userpquota.Printer.Name)
814        if posthook :
815            self.logdebug("Executing post-hook [%s]" % posthook)
816            os.system(posthook)
817           
818    def printInfo(self, message, level="info") :       
819        """Sends a message to standard error."""
820        self.logger.log_message("%s" % message, level)
821       
822    def printMoreInfo(self, user, printer, message, level="info") :           
823        """Prefixes the information printed with 'user@printer(jobid) =>'."""
824        self.printInfo("%s@%s(%s) => %s" % (getattr(user, "Name", None), getattr(printer, "Name", None), self.jobid, message), level)
825       
826    def extractInfoFromCupsOrLprng(self) :   
827        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
828       
829           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
830        """
831        # Try to detect CUPS
832        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
833            if len(sys.argv) == 7 :
834                inputfile = sys.argv[6]
835            else :   
836                inputfile = None
837               
838            # check that the DEVICE_URI environment variable's value is
839            # prefixed with "cupspykota:" otherwise don't touch it.
840            # If this is the case, we have to remove the prefix from
841            # the environment before launching the real backend in cupspykota
842            device_uri = os.environ.get("DEVICE_URI", "")
843            if device_uri.startswith("cupspykota:") :
844                fulldevice_uri = device_uri[:]
845                device_uri = fulldevice_uri[len("cupspykota:"):]
846                if device_uri.startswith("//") :    # lpd (at least)
847                    device_uri = device_uri[2:]
848                os.environ["DEVICE_URI"] = device_uri   # TODO : side effect !
849            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
850            try :
851                (backend, destination) = device_uri.split(":", 1) 
852            except ValueError :   
853                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
854            while destination.startswith("/") :
855                destination = destination[1:]
856            checkauth = destination.split("@", 1)   
857            if len(checkauth) == 2 :
858                destination = checkauth[1]
859            printerhostname = destination.split("/")[0].split(":")[0]
860            return ("CUPS", \
861                    printerhostname, \
862                    os.environ.get("PRINTER"), \
863                    sys.argv[2].strip(), \
864                    sys.argv[1].strip(), \
865                    inputfile, \
866                    int(sys.argv[4].strip()), \
867                    sys.argv[3], \
868                    sys.argv[5], \
869                    backend)
870        else :   
871            # Try to detect LPRng
872            # TODO : try to extract filename, options if available
873            jseen = Jseen = Pseen = nseen = rseen = Kseen = None
874            for arg in sys.argv :
875                if arg.startswith("-j") :
876                    jseen = arg[2:].strip()
877                elif arg.startswith("-n") :     
878                    nseen = arg[2:].strip()
879                elif arg.startswith("-P") :   
880                    Pseen = arg[2:].strip()
881                elif arg.startswith("-r") :   
882                    rseen = arg[2:].strip()
883                elif arg.startswith("-J") :   
884                    Jseen = arg[2:].strip()
885                elif arg.startswith("-K") or arg.startswith("-#") :   
886                    Kseen = int(arg[2:].strip())
887            if Kseen is None :       
888                Kseen = 1       # we assume the user wants at least one copy...
889            if (rseen is None) and jseen and Pseen and nseen :   
890                lparg = [arg for arg in "".join(os.environ.get("PRINTCAP_ENTRY", "").split()).split(":") if arg.startswith("rm=") or arg.startswith("lp=")]
891                try :
892                    rseen = lparg[0].split("=")[-1].split("@")[-1].split("%")[0]
893                except :   
894                    # Not found
895                    self.printInfo(_("Printer hostname undefined, set to 'localhost'"), "warn")
896                    rseen = "localhost"
897               
898            spooldir = os.environ.get("SPOOL_DIR", ".")   
899            df_name = os.environ.get("DATAFILES")
900            if not df_name :
901                try : 
902                    df_name = [line[10:] for line in os.environ.get("HF", "").split() if line.startswith("datafiles=")][0]
903                except IndexError :   
904                    try :   
905                        df_name = [line[8:] for line in os.environ.get("HF", "").split() if line.startswith("df_name=")][0]
906                    except IndexError :
907                        try :
908                            cftransfername = [line[15:] for line in os.environ.get("HF", "").split() if line.startswith("cftransfername=")][0]
909                        except IndexError :   
910                            try :
911                                df_name = [line[1:] for line in os.environ.get("CONTROL", "").split() if line.startswith("fdf") or line.startswith("Udf")][0]
912                            except IndexError :   
913                                raise PyKotaToolError, "Unable to find the file which holds the job's datas. Please file a bug report for PyKota."
914                            else :   
915                                inputfile = os.path.join(spooldir, df_name) # no need to strip()
916                        else :   
917                            inputfile = os.path.join(spooldir, "d" + cftransfername[1:]) # no need to strip()
918                    else :   
919                        inputfile = os.path.join(spooldir, df_name) # no need to strip()
920                else :   
921                    inputfile = os.path.join(spooldir, df_name) # no need to strip()
922            else :   
923                inputfile = os.path.join(spooldir, df_name.strip())
924               
925            if jseen and Pseen and nseen and rseen :       
926                options = os.environ.get("HF", "") or os.environ.get("CONTROL", "")
927                return ("LPRNG", rseen, Pseen, nseen, jseen, inputfile, Kseen, Jseen, options, None)
928        self.printInfo(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
929        return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system
930       
931    def getPrinterUserAndUserPQuota(self) :       
932        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
933       
934           "OK" is returned in the policy if both printer, user and user print quota
935           exist in the Quota Storage.
936           Otherwise, the policy as defined for this printer in pykota.conf is returned.
937           
938           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
939           doesn't exist in the Quota Storage, then an external command is launched, as
940           defined in the external policy for this printer in pykota.conf
941           This external command can do anything, like automatically adding printers
942           or users, for example, and finally extracting printer, user and user print
943           quota from the Quota Storage is tried a second time.
944           
945           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
946           was returned by the external command.
947        """
948        for passnumber in range(1, 3) :
949            printer = self.storage.getPrinter(self.printername)
950            user = self.storage.getUser(self.username)
951            userpquota = self.storage.getUserPQuota(user, printer)
952            if printer.Exists and user.Exists and userpquota.Exists :
953                policy = "OK"
954                break
955            (policy, args) = self.config.getPrinterPolicy(self.printername)
956            if policy == "EXTERNAL" :   
957                commandline = self.formatCommandLine(args, user, printer)
958                if not printer.Exists :
959                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.printername, commandline, self.printername))
960                if not user.Exists :
961                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.username, commandline, self.printername))
962                if not userpquota.Exists :
963                    self.printInfo(_("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))
964                if os.system(commandline) :
965                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.printername), "error")
966                    policy = "EXTERNALERROR"
967                    break
968            else :       
969                if not printer.Exists :
970                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.printername, policy))
971                if not user.Exists :
972                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.username, policy, self.printername))
973                if not userpquota.Exists :
974                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.username, self.printername, policy))
975                break
976        if policy == "EXTERNAL" :   
977            if not printer.Exists :
978                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.printername)
979            if not user.Exists :
980                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.username, self.printername))
981            if not userpquota.Exists :
982                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.username, self.printername))
983        return (policy, printer, user, userpquota)
984       
985    def mainWork(self) :   
986        """Main work is done here."""
987        (policy, printer, user, userpquota) = self.getPrinterUserAndUserPQuota()
988        # TODO : check for last user's quota in case pykota filter is used with querying
989        if policy == "EXTERNALERROR" :
990            # Policy was 'EXTERNAL' and the external command returned an error code
991            return self.removeJob()
992        elif policy == "EXTERNAL" :
993            # Policy was 'EXTERNAL' and the external command wasn't able
994            # to add either the printer, user or user print quota
995            return self.removeJob()
996        elif policy == "DENY" :   
997            # Either printer, user or user print quota doesn't exist,
998            # and the job should be rejected.
999            return self.removeJob()
1000        else :
1001            if policy not in ("OK", "ALLOW") :
1002                self.printInfo(_("Invalid policy %s for printer %s") % (policy, self.printername))
1003                return self.removeJob()
1004            else :
1005                return self.doWork(policy, printer, user, userpquota)
Note: See TracBrowser for help on using the browser.