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

Revision 2241, 46.8 kB (checked in by jerome, 19 years ago)

Fixed username when printing test pages from CUPS' web interface : use
the user CUPS is running as, instead of "root"

  • 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="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 %s (c) 2003-2004 %s" % (version.__version__, version.__author__)) :
71        """Initializes the command line tool."""
72        # did we drop priviledges ?
73        self.privdropped = 0
74       
75        # locale stuff
76        self.defaultToCLocale = 0
77        try :
78            locale.setlocale(locale.LC_ALL, lang)
79        except (locale.Error, IOError) :
80            locale.setlocale(locale.LC_ALL, "C")
81            self.defaultToCLocale = 1
82        try :
83            gettext.install("pykota")
84        except :
85            gettext.NullTranslations().install()
86           
87        # We can force the charset.   
88        # The CHARSET environment variable is set by CUPS when printing.
89        # Else we use the current locale's one.
90        # If nothing is set, we use ISO-8859-15 widely used in western Europe.
91        localecharset = None
92        try :
93            try :
94                localecharset = locale.nl_langinfo(locale.CODESET)
95            except AttributeError :   
96                try :
97                    localecharset = locale.getpreferredencoding()
98                except AttributeError :   
99                    try :
100                        localecharset = locale.getlocale()[1]
101                        localecharset = localecharset or locale.getdefaultlocale()[1]
102                    except ValueError :   
103                        pass        # Unknown locale, strange...
104        except locale.Error :           
105            pass
106        self.charset = charset or os.environ.get("CHARSET") or localecharset or "ISO-8859-15"
107   
108        # pykota specific stuff
109        self.documentation = doc
110       
111    def deferredInit(self) :       
112        """Deferred initialization."""
113        # try to find the configuration files in user's 'pykota' home directory.
114        try :
115            self.pykotauser = pwd.getpwnam("pykota")
116        except KeyError :   
117            self.pykotauser = None
118            confdir = "/etc/pykota"
119            missingUser = 1
120        else :   
121            confdir = self.pykotauser[5]
122            missingUser = 0
123           
124        self.config = config.PyKotaConfig(confdir)
125        self.debug = self.config.getDebug()
126        self.smtpserver = self.config.getSMTPServer()
127        self.maildomain = self.config.getMailDomain()
128        self.logger = logger.openLogger(self.config.getLoggingBackend())
129           
130        # now drop priviledge if possible
131        self.dropPriv()   
132       
133        # We NEED this here, even when not in an accounting filter/backend   
134        self.softwareJobSize = 0
135        self.softwareJobPrice = 0.0
136       
137        if self.defaultToCLocale :
138            self.printInfo("Incorrect locale settings. PyKota falls back to the 'C' locale.", "warn")
139        if missingUser :     
140            self.printInfo("The 'pykota' system account is missing. Configuration files were searched in /etc/pykota instead.", "warn")
141       
142        self.logdebug("Charset in use : %s" % self.charset)
143        arguments = " ".join(['"%s"' % arg for arg in sys.argv])
144        self.logdebug("Command line arguments : %s" % arguments)
145       
146    def dropPriv(self) :   
147        """Drops priviledges."""
148        uid = os.geteuid()
149        if uid :
150            try :
151                username = pwd.getpwuid(uid)[0]
152            except (KeyError, IndexError), msg :   
153                self.printInfo(_("Strange problem with uid(%s) : %s") % (uid, msg), "warn")
154            else :
155                self.logdebug(_("Running as user '%s'.") % username)
156        else :
157            if self.pykotauser is None :
158                self.logdebug(_("No user named 'pykota'. Not dropping priviledges."))
159            else :   
160                try :
161                    os.setegid(self.pykotauser[3])
162                    os.seteuid(self.pykotauser[2])
163                except OSError, msg :   
164                    self.printInfo(_("Impossible to drop priviledges : %s") % msg, "warn")
165                else :   
166                    self.logdebug(_("Priviledges dropped. Now running as user 'pykota'."))
167                    self.privdropped = 1
168           
169    def regainPriv(self) :   
170        """Drops priviledges."""
171        if self.privdropped :
172            try :
173                os.seteuid(0)
174                os.setegid(0)
175            except OSError, msg :   
176                self.printInfo(_("Impossible to regain priviledges : %s") % msg, "warn")
177            else :   
178                self.logdebug(_("Regained priviledges. Now running as root."))
179                self.privdropped = 0
180       
181    def getCharset(self) :   
182        """Returns the charset in use."""
183        return self.charset
184       
185    def logdebug(self, message) :   
186        """Logs something to debug output if debug is enabled."""
187        if self.debug :
188            self.logger.log_message(message, "debug")
189           
190    def printInfo(self, message, level="info") :       
191        """Sends a message to standard error."""
192        sys.stderr.write("%s: %s\n" % (level.upper(), message))
193        sys.stderr.flush()
194       
195    def matchString(self, s, patterns) :
196        """Returns 1 if the string s matches one of the patterns, else 0."""
197        for pattern in patterns :
198            if fnmatch.fnmatchcase(s, pattern) :
199                return 1
200        return 0
201       
202    def display_version_and_quit(self) :
203        """Displays version number, then exists successfully."""
204        try :
205            self.clean()
206        except AttributeError :   
207            pass
208        print version.__version__
209        sys.exit(0)
210   
211    def display_usage_and_quit(self) :
212        """Displays command line usage, then exists successfully."""
213        try :
214            self.clean()
215        except AttributeError :   
216            pass
217        print _(self.documentation) % (version.__version__, version.__author__)
218        sys.exit(0)
219       
220    def crashed(self, message="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        self.preserveinputfile = self.inputfile 
668        try :
669            self.accounter = accounter.openAccounter(self)
670        except (config.PyKotaConfigError, accounter.PyKotaAccounterError), msg :   
671            self.crashed(msg)
672            raise
673        self.exportJobInfo()
674        self.jobdatastream = self.openJobDataStream()
675        self.checksum = self.computeChecksum()
676        self.softwareJobSize = self.precomputeJobSize()
677        os.environ["PYKOTAPRECOMPUTEDJOBSIZE"] = str(self.softwareJobSize)
678        os.environ["PYKOTAJOBSIZEBYTES"] = str(self.jobSizeBytes)
679        self.logdebug("Job size is %s bytes on %s pages." % (self.jobSizeBytes, self.softwareJobSize))
680        self.logdebug("Capturing SIGTERM events.")
681        signal.signal(signal.SIGTERM, self.sigterm_handler)
682       
683    def sendBackChannelData(self, message, level="info") :   
684        """Sends an informational message to CUPS via back channel stream (stderr)."""
685        sys.stderr.write("%s: PyKota (PID %s) : %s\n" % (level.upper(), os.getpid(), message.strip()))
686        sys.stderr.flush()
687       
688    def computeChecksum(self) :   
689        """Computes the MD5 checksum of the job's datas, to be able to detect and forbid duplicate jobs."""
690        self.logdebug("Computing MD5 checksum for job %s" % self.jobid)
691        MEGABYTE = 1024*1024
692        checksum = md5.new()
693        while 1 :
694            data = self.jobdatastream.read(MEGABYTE) 
695            if not data :
696                break
697            checksum.update(data)   
698        self.jobdatastream.seek(0)
699        digest = checksum.hexdigest()
700        self.logdebug("MD5 checksum for job %s is %s" % (self.jobid, digest))
701        os.environ["PYKOTAMD5SUM"] = digest
702        return digest
703       
704    def openJobDataStream(self) :   
705        """Opens the file which contains the job's datas."""
706        if self.preserveinputfile is None :
707            # Job comes from sys.stdin, but this is not
708            # seekable and complexifies our task, so create
709            # a temporary file and use it instead
710            self.logdebug("Duplicating data stream from stdin to temporary file")
711            dummy = 0
712            MEGABYTE = 1024*1024
713            self.jobSizeBytes = 0
714            infile = tempfile.TemporaryFile()
715            while 1 :
716                data = sys.stdin.read(MEGABYTE) 
717                if not data :
718                    break
719                self.jobSizeBytes += len(data)   
720                if not (dummy % 10) :
721                    self.logdebug("%s bytes read..." % self.jobSizeBytes)
722                dummy += 1   
723                infile.write(data)
724            self.logdebug("%s bytes read total." % self.jobSizeBytes)
725            infile.flush()   
726            infile.seek(0)
727            return infile
728        else :   
729            # real file, just open it
730            self.regainPriv()
731            self.logdebug("Opening data stream %s" % self.preserveinputfile)
732            self.jobSizeBytes = os.stat(self.preserveinputfile)[6]
733            infile = open(self.preserveinputfile, "rb")
734            self.dropPriv()
735            return infile
736       
737    def closeJobDataStream(self) :   
738        """Closes the file which contains the job's datas."""
739        self.logdebug("Closing data stream.")
740        try :
741            self.jobdatastream.close()
742        except :   
743            pass
744       
745    def precomputeJobSize(self) :   
746        """Computes the job size with a software method."""
747        self.logdebug("Precomputing job's size with generic PDL analyzer...")
748        self.jobdatastream.seek(0)
749        try :
750            parser = pdlanalyzer.PDLAnalyzer(self.jobdatastream)
751            jobsize = parser.getJobSize()
752        except pdlanalyzer.PDLAnalyzerError, msg :   
753            # Here we just log the failure, but
754            # we finally ignore it and return 0 since this
755            # computation is just an indication of what the
756            # job's size MAY be.
757            self.printInfo(_("Unable to precompute the job's size with the generic PDL analyzer : %s") % msg, "warn")
758            return 0
759        else :   
760            if ((self.printingsystem == "CUPS") \
761                and (self.preserveinputfile is not None)) \
762                or (self.printingsystem != "CUPS") :
763                return jobsize * self.copies
764            else :       
765                return jobsize
766           
767    def sigterm_handler(self, signum, frame) :
768        """Sets an attribute whenever SIGTERM is received."""
769        self.gotSigTerm = 1
770        os.environ["PYKOTASTATUS"] = "CANCELLED"
771        self.printInfo(_("SIGTERM received, job %s cancelled.") % self.jobid)
772       
773    def exportJobInfo(self) :   
774        """Exports job information to the environment."""
775        os.environ["PYKOTAUSERNAME"] = str(self.username)
776        os.environ["PYKOTAPRINTERNAME"] = str(self.printername)
777        os.environ["PYKOTAJOBID"] = str(self.jobid)
778        os.environ["PYKOTATITLE"] = self.title or ""
779        os.environ["PYKOTAFILENAME"] = self.preserveinputfile or ""
780        os.environ["PYKOTACOPIES"] = str(self.copies)
781        os.environ["PYKOTAOPTIONS"] = self.options or ""
782        os.environ["PYKOTAPRINTERHOSTNAME"] = self.printerhostname or "localhost"
783   
784    def exportUserInfo(self, userpquota) :
785        """Exports user information to the environment."""
786        os.environ["PYKOTAOVERCHARGE"] = str(userpquota.User.OverCharge)
787        os.environ["PYKOTALIMITBY"] = str(userpquota.User.LimitBy)
788        os.environ["PYKOTABALANCE"] = str(userpquota.User.AccountBalance or 0.0)
789        os.environ["PYKOTALIFETIMEPAID"] = str(userpquota.User.LifeTimePaid or 0.0)
790        os.environ["PYKOTAPAGECOUNTER"] = str(userpquota.PageCounter or 0)
791        os.environ["PYKOTALIFEPAGECOUNTER"] = str(userpquota.LifePageCounter or 0)
792        os.environ["PYKOTASOFTLIMIT"] = str(userpquota.SoftLimit)
793        os.environ["PYKOTAHARDLIMIT"] = str(userpquota.HardLimit)
794        os.environ["PYKOTADATELIMIT"] = str(userpquota.DateLimit)
795        os.environ["PYKOTAWARNCOUNT"] = str(userpquota.WarnCount)
796       
797        # not really an user information, but anyway
798        # exports the list of printers groups the current
799        # printer is a member of
800        os.environ["PYKOTAPGROUPS"] = ",".join([p.Name for p in self.storage.getParentPrinters(userpquota.Printer)])
801       
802    def prehook(self, userpquota) :
803        """Allows plugging of an external hook before the job gets printed."""
804        prehook = self.config.getPreHook(userpquota.Printer.Name)
805        if prehook :
806            self.logdebug("Executing pre-hook [%s]" % prehook)
807            os.system(prehook)
808       
809    def posthook(self, userpquota) :
810        """Allows plugging of an external hook after the job gets printed and/or denied."""
811        posthook = self.config.getPostHook(userpquota.Printer.Name)
812        if posthook :
813            self.logdebug("Executing post-hook [%s]" % posthook)
814            os.system(posthook)
815           
816    def printInfo(self, message, level="info") :       
817        """Sends a message to standard error."""
818        self.logger.log_message("%s" % message, level)
819       
820    def printMoreInfo(self, user, printer, message, level="info") :           
821        """Prefixes the information printed with 'user@printer(jobid) =>'."""
822        self.printInfo("%s@%s(%s) => %s" % (getattr(user, "Name", None), getattr(printer, "Name", None), self.jobid, message), level)
823       
824    def extractInfoFromCupsOrLprng(self) :   
825        """Returns a tuple (printingsystem, printerhostname, printername, username, jobid, filename, title, options, backend).
826       
827           Returns (None, None, None, None, None, None, None, None, None, None) if no printing system is recognized.
828        """
829        # Try to detect CUPS
830        if os.environ.has_key("CUPS_SERVERROOT") and os.path.isdir(os.environ.get("CUPS_SERVERROOT", "")) :
831            if len(sys.argv) == 7 :
832                inputfile = sys.argv[6]
833            else :   
834                inputfile = None
835               
836            # check that the DEVICE_URI environment variable's value is
837            # prefixed with "cupspykota:" otherwise don't touch it.
838            # If this is the case, we have to remove the prefix from
839            # the environment before launching the real backend in cupspykota
840            device_uri = os.environ.get("DEVICE_URI", "")
841            if device_uri.startswith("cupspykota:") :
842                fulldevice_uri = device_uri[:]
843                device_uri = fulldevice_uri[len("cupspykota:"):]
844                if device_uri.startswith("//") :    # lpd (at least)
845                    device_uri = device_uri[2:]
846                os.environ["DEVICE_URI"] = device_uri   # TODO : side effect !
847            # TODO : check this for more complex urls than ipp://myprinter.dot.com:631/printers/lp
848            try :
849                (backend, destination) = device_uri.split(":", 1) 
850            except ValueError :   
851                raise PyKotaToolError, "Invalid DEVICE_URI : %s\n" % device_uri
852            while destination.startswith("/") :
853                destination = destination[1:]
854            checkauth = destination.split("@", 1)   
855            if len(checkauth) == 2 :
856                destination = checkauth[1]
857            printerhostname = destination.split("/")[0].split(":")[0]
858            return ("CUPS", \
859                    printerhostname, \
860                    os.environ.get("PRINTER"), \
861                    sys.argv[2].strip(), \
862                    sys.argv[1].strip(), \
863                    inputfile, \
864                    int(sys.argv[4].strip()), \
865                    sys.argv[3], \
866                    sys.argv[5], \
867                    backend)
868        else :   
869            # Try to detect LPRng
870            # TODO : try to extract filename, options if available
871            jseen = Jseen = Pseen = nseen = rseen = Kseen = None
872            for arg in sys.argv :
873                if arg.startswith("-j") :
874                    jseen = arg[2:].strip()
875                elif arg.startswith("-n") :     
876                    nseen = arg[2:].strip()
877                elif arg.startswith("-P") :   
878                    Pseen = arg[2:].strip()
879                elif arg.startswith("-r") :   
880                    rseen = arg[2:].strip()
881                elif arg.startswith("-J") :   
882                    Jseen = arg[2:].strip()
883                elif arg.startswith("-K") or arg.startswith("-#") :   
884                    Kseen = int(arg[2:].strip())
885            if Kseen is None :       
886                Kseen = 1       # we assume the user wants at least one copy...
887            if (rseen is None) and jseen and Pseen and nseen :   
888                lparg = [arg for arg in "".join(os.environ.get("PRINTCAP_ENTRY", "").split()).split(":") if arg.startswith("rm=") or arg.startswith("lp=")]
889                try :
890                    rseen = lparg[0].split("=")[-1].split("@")[-1].split("%")[0]
891                except :   
892                    # Not found
893                    self.printInfo(_("Printer hostname undefined, set to 'localhost'"), "warn")
894                    rseen = "localhost"
895               
896            spooldir = os.environ.get("SPOOL_DIR", ".")   
897            df_name = os.environ.get("DATAFILES")
898            if not df_name :
899                try : 
900                    df_name = [line[10:] for line in os.environ.get("HF", "").split() if line.startswith("datafiles=")][0]
901                except IndexError :   
902                    try :   
903                        df_name = [line[8:] for line in os.environ.get("HF", "").split() if line.startswith("df_name=")][0]
904                    except IndexError :
905                        try :
906                            cftransfername = [line[15:] for line in os.environ.get("HF", "").split() if line.startswith("cftransfername=")][0]
907                        except IndexError :   
908                            try :
909                                df_name = [line[1:] for line in os.environ.get("CONTROL", "").split() if line.startswith("fdf") or line.startswith("Udf")][0]
910                            except IndexError :   
911                                raise PyKotaToolError, "Unable to find the file which holds the job's datas. Please file a bug report for PyKota."
912                            else :   
913                                inputfile = os.path.join(spooldir, df_name) # no need to strip()
914                        else :   
915                            inputfile = os.path.join(spooldir, "d" + cftransfername[1:]) # no need to strip()
916                    else :   
917                        inputfile = os.path.join(spooldir, df_name) # no need to strip()
918                else :   
919                    inputfile = os.path.join(spooldir, df_name) # no need to strip()
920            else :   
921                inputfile = os.path.join(spooldir, df_name.strip())
922               
923            if jseen and Pseen and nseen and rseen :       
924                options = os.environ.get("HF", "") or os.environ.get("CONTROL", "")
925                return ("LPRNG", rseen, Pseen, nseen, jseen, inputfile, Kseen, Jseen, options, None)
926        self.printInfo(_("Printing system unknown, args=%s") % " ".join(sys.argv), "warn")
927        return (None, None, None, None, None, None, None, None, None, None)   # Unknown printing system
928       
929    def getPrinterUserAndUserPQuota(self) :       
930        """Returns a tuple (policy, printer, user, and user print quota) on this printer.
931       
932           "OK" is returned in the policy if both printer, user and user print quota
933           exist in the Quota Storage.
934           Otherwise, the policy as defined for this printer in pykota.conf is returned.
935           
936           If policy was set to "EXTERNAL" and one of printer, user, or user print quota
937           doesn't exist in the Quota Storage, then an external command is launched, as
938           defined in the external policy for this printer in pykota.conf
939           This external command can do anything, like automatically adding printers
940           or users, for example, and finally extracting printer, user and user print
941           quota from the Quota Storage is tried a second time.
942           
943           "EXTERNALERROR" is returned in case policy was "EXTERNAL" and an error status
944           was returned by the external command.
945        """
946        for passnumber in range(1, 3) :
947            printer = self.storage.getPrinter(self.printername)
948            user = self.storage.getUser(self.username)
949            userpquota = self.storage.getUserPQuota(user, printer)
950            if printer.Exists and user.Exists and userpquota.Exists :
951                policy = "OK"
952                break
953            (policy, args) = self.config.getPrinterPolicy(self.printername)
954            if policy == "EXTERNAL" :   
955                commandline = self.formatCommandLine(args, user, printer)
956                if not printer.Exists :
957                    self.printInfo(_("Printer %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.printername, commandline, self.printername))
958                if not user.Exists :
959                    self.printInfo(_("User %s not registered in the PyKota system, applying external policy (%s) for printer %s") % (self.username, commandline, self.printername))
960                if not userpquota.Exists :
961                    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))
962                if os.system(commandline) :
963                    self.printInfo(_("External policy %s for printer %s produced an error. Job rejected. Please check PyKota's configuration files.") % (commandline, self.printername), "error")
964                    policy = "EXTERNALERROR"
965                    break
966            else :       
967                if not printer.Exists :
968                    self.printInfo(_("Printer %s not registered in the PyKota system, applying default policy (%s)") % (self.printername, policy))
969                if not user.Exists :
970                    self.printInfo(_("User %s not registered in the PyKota system, applying default policy (%s) for printer %s") % (self.username, policy, self.printername))
971                if not userpquota.Exists :
972                    self.printInfo(_("User %s doesn't have quota on printer %s in the PyKota system, applying default policy (%s)") % (self.username, self.printername, policy))
973                break
974        if policy == "EXTERNAL" :   
975            if not printer.Exists :
976                self.printInfo(_("Printer %s still not registered in the PyKota system, job will be rejected") % self.printername)
977            if not user.Exists :
978                self.printInfo(_("User %s still not registered in the PyKota system, job will be rejected on printer %s") % (self.username, self.printername))
979            if not userpquota.Exists :
980                self.printInfo(_("User %s still doesn't have quota on printer %s in the PyKota system, job will be rejected") % (self.username, self.printername))
981        return (policy, printer, user, userpquota)
982       
983    def mainWork(self) :   
984        """Main work is done here."""
985        (policy, printer, user, userpquota) = self.getPrinterUserAndUserPQuota()
986        # TODO : check for last user's quota in case pykota filter is used with querying
987        if policy == "EXTERNALERROR" :
988            # Policy was 'EXTERNAL' and the external command returned an error code
989            return self.removeJob()
990        elif policy == "EXTERNAL" :
991            # Policy was 'EXTERNAL' and the external command wasn't able
992            # to add either the printer, user or user print quota
993            return self.removeJob()
994        elif policy == "DENY" :   
995            # Either printer, user or user print quota doesn't exist,
996            # and the job should be rejected.
997            return self.removeJob()
998        else :
999            if policy not in ("OK", "ALLOW") :
1000                self.printInfo(_("Invalid policy %s for printer %s") % (policy, self.printername))
1001                return self.removeJob()
1002            else :
1003                return self.doWork(policy, printer, user, userpquota)
Note: See TracBrowser for help on using the browser.