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

Revision 2195, 47.0 kB (checked in by jerome, 19 years ago)

Fixed charset problem under MacOSX

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