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

Revision 2214, 46.7 kB (checked in by jerome, 19 years ago)

An unknown locale could cause a traceback, this
is now fixed.

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