class TaskJuggler::SheetReceiver

Public Class Methods

new(appName, type) click to toggle source
Calls superclass method TaskJuggler::SheetHandlerBase::new
# File lib/taskjuggler/SheetReceiver.rb, line 28
def initialize(appName, type)
  super(appName)

  @sheetType = type
  # The following settings must be set by the deriving class.
  # Sheet type specific option for tj3client
  @tj3clientOption = nil
  # Base directory to store received sheets
  @sheetDir = nil
  # Base directory where to find the resource file.
  @templateDir = nil
  # Directory to store the failed emails.
  @failedMailsDir = nil
  # Directory to store the failed sheets
  @failedSheetsDir = nil
  # File that holds the acceptable signatures.
  @signatureFile = nil
  # The log file
  @logFile = nil
  # The subject of the confirmation email
  @emailSubject = nil

  # Regular expressions to identify a sheet.
  @sheetHeader = nil
  # Regular expression to extract the sheet signature (date).
  @signatureFilter = nil
  # The email address of the submitter of the sheet.
  @submitter = nil
  # The resource ID of the submitter.
  @resourceId = nil
  # The stdout content from tj3client
  @report = nil
  # The stderr content from tj3client
  @warnings = nil

  # The extracted sheet text.
  @sheet = nil
  # Will indicate whether the sheet was attached or in mail body
  @sheetWasAttached = true
  # The end date of the reporting period.
  @date = nil
  # The id of the incomming message.
  @messageId = nil
end

Public Instance Methods

processEmail() click to toggle source

Read the sheet from $stdin in email format. Extract the sheet from the attachments or body and check it. If ok, send back a summary, otherwise the error message. The actual check is done by a tj3 server process that is accessed via tj3client.

# File lib/taskjuggler/SheetReceiver.rb, line 78
    def processEmail
      setWorkingDir

      createDirectories

      begin
        # Read the RFC 822 compliant mail from STDIN.
        rawMail = $stdin.read
        rawMail = rawMail.forceUTF8Encoding

        mail = Mail.new(rawMail)
      rescue
        # In certain cases, Mail will fail to create the Mail object. Since we
        # don't have the email sender yet, we have to try to extract it
        # ourself.
        fromLine = nil
        rawMail.each_line do |line|
          unless fromLine
            matches = line.match('^From: .*')
            if matches
              fromLine = matches[0]
              break
            end
          end
        end

        # Try to extract the mail sender the dirty way so we can at least send
        # a response to the submitter.
        @submitter = fromLine[6..-1] if fromLine && fromLine.is_a?(String)
        error("Incoming mail could not be processed: #{$!}")
      end

      # Who sent this email?
      @submitter = mail.from.respond_to?('[]') ? mail.from[0] : mail.from
      # Getting the message ID.
      @messageId = mail.message_id || 'unknown'
      @idDigest = Digest::MD5.hexdigest(@messageId)
      info("Processing #{@sheetType} mail from #{@submitter} " +
           "with ID #{@messageId} (#{@idDigest})")

      # Store the mail in the failedMailsDir in case something goes wrong.
      File.open("#{@failedMailsDir}/#{@idDigest}", 'w') do |f|
        f.write(mail)
      end

      # First we search the attachments and then the body.
      mail.attachments.each do |attachment|
        # We are looking for an attached file with a .tji extension.
        fileName = attachment.filename
        next unless fileName && fileName[-4..-1] == '.tji'

        # Further inspect the attachment. If we could process it, we are done.
        return true if processSheet(attachment.body.decoded)
      end
      # None of the attachements worked, so let's try the mail body.
      @sheetWasAttached = false
      return true if processSheet(mail.body.decoded)

      error(<<"EOT"
No #{@sheetType} sheet found in email. Please make sure the header syntax is
correct and contained in a single line that starts at the begining of the
line. If you had the #{@sheetType} sheet attached, the file name must have a
'.tji' extension to be found.
EOT
           )
    end

Private Instance Methods

checkSheet(sheet) click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 181
def checkSheet(sheet)
  res = nil
  begin
    # Save a copy of the sheet for debugging purposes.
    File.open("#{@failedSheetsDir}/#{@resourceId}-#{@date}.tji", 'w') do |f|
      f.write(sheet)
    end
    command = [ '--unsafe', '--silent', *@tj3clientOption.split(' '),
                @projectId, '.' ]
    # Send the report to the tj3client process via stdin.
    res = stdIoWrapper(sheet) do
      Tj3Client.new.main(command)
    end
    # Without errors, the incoming report is pretty printed and returned
    # in RichText format.
    @report = res.stdOut
    @warnings = res.stdErr
  rescue
    fatal("Cannot check #{@sheetType} sheet: #{$!}")
  end
  if res.returnValue == 0
    File.delete("#{@failedSheetsDir}/#{@resourceId}-#{@date}.tji")
    return true
  end

  # The exit status was not 0. The stderr output should not be empty and
  # will contain error and warning messages.
  error(@warnings)
end
checkSignature(sheet) click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 289
    def checkSignature(sheet)
      if matches = @signatureFilter.match(sheet)
        interval = matches[1]
      else
        fatal("No #{@sheetType}sheet header found")
      end

      acceptedSignatures = []
      if File.exist?(@signatureFile)
        File.open(@signatureFile, 'r') do |file|
          acceptedSignatures = file.readlines
        end
        acceptedSignatures.map! { |s| s.chomp }
        acceptedSignatures.delete_if { |s| s.chomp.empty? }
      else
        error("#{@signatureFile} does not exist yet.")
      end

      unless acceptedSignatures.include?(interval)
        error(<<"EOT"
The reporting period #{interval}
was not accepted!  Either you have modified the sheet header,
you are submitting the sheet too late or too early.
EOT
             )
      end
    end
createDirectories() click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 317
def createDirectories
  [ @sheetDir, @failedMailsDir, @failedSheetsDir ].each do |dir|
    unless File.directory?(dir)
      info("Creating directory #{dir}")
      Dir.mkdir(dir)
    end
  end
end
error(message) click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 326
def error(message)
  $stderr.puts message if @outputLevel >= 1

  log('ERROR', "#{message}") if @logLevel >= 1

  # Append the submitted sheet for further tries. We may run into encoding
  # errors here. In this case we send the answer without the incoming time
  # sheet.
  begin
    message += "\n" + @sheet if @sheet && !@sheetWasAttached
  rescue
  end

  sendEmail(@submitter, "Your #{@sheetType} sheet submission failed!",
            message)

  raise TjRuntimeError
end
fatal(message) click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 345
    def fatal(message)
      log('FATAL', "#{message}")

      # Append the submitted sheet for further tries.
      message += "\n" + @sheet if @sheet

      sendEmail(@submitter, 'Temporary server error', <<"EOT"
We are sorry! The #{@sheetType} sheet server detected a configuration
problem and is temporarily out of service. The administrator
has been notified and will try to rectify the situation as
soon as possible. Please re-submit your #{@sheetType} sheet later!
EOT
               )
      raise TjRuntimeError
    end
fileSheet(sheet) click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 211
    def fileSheet(sheet)
      # Create the appropriate directory structure if it doesn't exist.
      dir = "#{@sheetDir}/#{@date}"
      fileName = "#{dir}/#{@resourceId}_#{@date}.tji"
      newDir = false
      begin
        unless File.directory?(dir)
          Dir.mkdir(dir)
          addToScm('Adding new directory', dir)
          newDir = true
        end
        File.open(fileName, 'w') { |f| f.write(sheet) }
        addToScm("Adding/updating #{fileName}", fileName)
      rescue
        fatal("Cannot store #{@sheetType} sheet #{fileName}: #{$!}")
        return
      end

      # Create or update the file that includes all *.tji in the directory.
      generateInclusionFile(dir)

      if newDir
        # Add the new directory to the parent all.tji file.
        allFile = "#{@sheetDir}/all.tji"
        File.open(allFile, 'a') do |f|
          f.write("\ninclude '#{@date}/all.tji' { }")
        end
        addToScm('Adding new directory to all.tji', allFile)
      end

      text = <<"EOT"
== Report from #{getResourceName} for the period ending #{@date} ==

EOT

      # Add warnings if we had any.
      unless @warnings.empty?
        text += <<"EOT"
----
Your report does contain some issues that you may want to fix or address with
your manager or project manager:

<nowiki>#{@warnings}</nowiki>

----
EOT
      end

      # Append the pretty printed version of the submitted sheet.
      text += @report

      # Send out the email.
      sendRichTextEmail(@submitter, sprintf(@emailSubject, getResourceName,
                                            @date), text, nil, nil, @messageId)
      true
    end
generateInclusionFile(dir) click to toggle source

Generate or update a file the contains 'include' statements for all the .tji files in the provided directory. The generated file will be in this directory as well.

# File lib/taskjuggler/SheetReceiver.rb, line 271
def generateInclusionFile(dir)
  pwd = Dir.pwd
  begin
    Dir.chdir(dir)
    File.open('all.tji', 'w') do |file|
      Dir.glob('*.tji').each do |tji|
        file.puts("include '#{tji}' { }") unless tji == 'all.tji'
      end
    end
  rescue
    error("Can't create inclusion file: #{$!}")
  ensure
    Dir.chdir(pwd)
  end
  # Report the change to the SCM handler.
  addToScm('Adding/updating summary include file.', "#{dir}/all.tji")
end
getResourceEmail(id = @resourceId) click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 378
def getResourceEmail(id = @resourceId)
  getResourceList unless @resourceList

  @resourceList.each do |resource|
    return resource[2] if resource[0] == id
  end
  error("Resource ID '#{id}' not found in list")
end
getResourceList() click to toggle source

Load tye resources.yml YAML file into the @resourceList variable. The format is Array with one entry per resource. The entry is an Array with 3 fields: ID, name and email. All fields are String objects.

# File lib/taskjuggler/SheetReceiver.rb, line 365
def getResourceList
  fatal('@date not set') unless @date

  fileName = "#{@templateDir}/#{@date}/resources.yml"
  begin
    @resourceList = YAML.load(File.read(fileName))
    info("#{@resourceList.length} resources loaded")
  rescue
    error("Cannot read resource file #{fileName}: #{$!}")
  end
  @resourceList
end
getResourceName(id = @resourceId) click to toggle source
# File lib/taskjuggler/SheetReceiver.rb, line 387
def getResourceName(id = @resourceId)
  getResourceList unless @resourceList

  @resourceList.each do |resource|
    return resource[1] if resource[0] == id
  end
  error("Resource ID '#{id}' not found in list")
end
processSheet(sheet) click to toggle source

Isolate the actual syntax from sheet and process it.

# File lib/taskjuggler/SheetReceiver.rb, line 148
def processSheet(sheet)
  begin
    @sheet = sheet.forceUTF8Encoding
  rescue
    error($!.message)
  end

  # If the sheet contains special cut markers, we extract only the content
  # within those markers.
  @sheet = cutOut(@sheet)

  # A valid sheet must have the poper header line.
  if @sheetHeader.match(@sheet)
    checkSignature(@sheet)
    # Extract the resource ID and the end date from the sheet.
    matches = @sheetHeader.match(@sheet)
    @resourceId, @date = matches[1..2]
    # Email answers will only go the email address on file!
    @submitter = getResourceEmail(@resourceId)
    info("Found #{@sheetWasAttached ? 'attached ' : ''}sheet for " +
         "#{@resourceId} dated #{@date}")
    # Ok, found. Now check the full sheet.
    if checkSheet(@sheet)
      # Everything is fine. Store it away.
      fileSheet(@sheet)
      # Remove the mail from the failedMailsDir
      File.delete("#{@failedMailsDir}/#{@idDigest}")
      info("Accepted sheet for #{@resourceId} dated #{@date}")
      return true
    end
  end
end