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

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

Added the striptitle directive.

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