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

Revision 2344, 47.3 kB (checked in by jerome, 19 years ago)

Moved the GPL blurb into a single location.
Now uses named parameters in commands' help.

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