# -*- Mode: perl; indent-tabs-mode: nil -*-
#
# The contents of this file are subject to the Mozilla Public
# License Version 1.1 (the "License"); you may not use this file
# except in compliance with the License. You may obtain a copy of
# the License at http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS
# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
# implied. See the License for the specific language governing
# rights and limitations under the License.
#
# The Original Code is the Bugzilla Test Runner System.
#
# The Initial Developer of the Original Code is Maciej Maczynski.
# Portions created by Maciej Maczynski are Copyright (C) 2001
# Maciej Maczynski. All Rights Reserved.
#
# Large portions lifted uncerimoniously from Bugzilla::Attachment.pm
# and bugzilla's attachment.cgi
#
# Contributor(s): Greg Hendricks <ghendricks@novell.com>

package Bugzilla::Extension::Testopia::Attachment;

use strict;

use Bugzilla::Util;
use Bugzilla::Config;
use Bugzilla::Error;
use Bugzilla::Constants;
use Bugzilla::User;

use Bugzilla::Extension::Testopia::Constants;
use Bugzilla::Extension::Testopia::Util;

use base qw(Exporter Bugzilla::Object);

###############################
####    Initialization     ####
###############################
use constant DB_TABLE   => "test_attachments";
use constant NAME_FIELD => "description";
use constant ID_FIELD   => "attachment_id";
use constant DB_COLUMNS => qw(
    attachment_id
    submitter_id
    description
    filename
    creation_ts
    mime_type
);

use constant REQUIRED_CREATE_FIELDS => qw(submitter_id description filename mime_type);
use constant UPDATE_COLUMNS         => qw(description filename mime_type);

use constant VALIDATORS => {
    plan_id  => \&_check_plan,
    case_id  => \&_check_case,
    filename => \&_check_filename,
    submitter_id => \&_check_submitter,
};

###############################
####       Validators      ####
###############################
sub _validate_data {
    my $data = shift;
    my $maxsize = Bugzilla->params->{"maxattachmentsize"};
    $maxsize *= 1024; # Convert from K

    # Make sure the attachment does not exceed the maximum permitted size
    my $len = $data ? length($data) : 0;
    if ($maxsize && $len > $maxsize) {
        my $vars = { filesize => sprintf("%.0f", $len/1024) };
        ThrowUserError("file_too_large", $vars);
    }
    trick_taint($data);
    return $data;
}

sub _check_plan {
    my ($invocant, $plan_id) = @_;
    Bugzilla::Extension::Testopia::Util::validate_test_id($plan_id, 'plan');
    trick_taint($plan_id);
    return $plan_id;
}

sub _check_case {
    my ($invocant, $case_id) = @_;
    Bugzilla::Extension::Testopia::Util::validate_test_id($case_id, 'case');
    trick_taint($case_id);
    return $case_id;
}

sub _check_filename {
    my ($invocant, $filename) = @_;
    # Remove path info (if any) from the file name.  The browser should do this
    # for us, but some are buggy.  This may not work on Mac file names and could
    # mess up file names with slashes in them, but them's the breaks.  We only
    # use this as a hint to users downloading attachments anyway, so it's not 
    # a big deal if it munges incorrectly occasionally.
    $filename =~ s/^.*[\/\\]//;

    # Truncate the filename to 100 characters, counting from the end of the string
    # to make sure we keep the filename extension.
    $filename = substr($filename, -100, 100);

    trick_taint($filename);
    return $filename;    

}

sub _check_submitter{
    my ($invocant, $tester) = @_;
    $tester = trim($tester);
    return unless $tester;
    if ($tester =~ /^\d+$/){
        $tester = Bugzilla::User->new($tester);
        return $tester->id;
    }
    else {
        my $id = login_to_id($tester, THROW_ERROR);
        return $id;
    }
}

###############################
####       Mutators        ####
###############################
sub set_description { $_[0]->set('description', $_[1]); }
sub set_mime_type   { $_[0]->set('mime_type', $_[1]); }
sub set_filename    { $_[0]->set('filename', $_[1]); }

sub new {
    my $invocant = shift;
    my $class = ref($invocant) || $invocant;
    my $param = shift;

    # We want to be able to supply an empty object to the templates for numerous
    # lists etc. This is much cleaner than exporting a bunch of subroutines and
    # adding them to $vars one by one. Probably just Laziness shining through.
    if (ref $param eq 'HASH' && !keys %$param){
        bless($param, $class);
        return $param;
    }

    unshift @_, $param;
    my $self = $class->SUPER::new(@_);

    return $self; 
}

sub create {
    my ($class, $params) = @_;
    my $dbh = Bugzilla->dbh;

    $class->SUPER::check_required_create_fields($params);

    # This is an either/or operation. We need either a plan or a case
    # to attach this to.
    if (!$params->{'case_id'} && !$params->{'plan_id'}){
        ThrowCodeError("testopia-missing-attachment-key");
    }

    my $field_values = $class->run_create_validators($params);

    # Windows screenshots are usually uncompressed BMP files which
    # makes for a quick way to eat up disk space. Let's compress them. 
    # We do this before we check the size since the uncompressed version
    # could easily be greater than maxattachmentsize.
    if (Bugzilla->params->{'convert_uncompressed_images'} 
          && $field_values->{'mime_type'} eq 'image/bmp'){
      require Image::Magick; 
      my $img = Image::Magick->new(magick=>'bmp');
      $img->BlobToImage($field_values->{'contents'});
      $img->set(magick=>'png');
      my $imgdata = $img->ImageToBlob();
      $field_values->{'contents'} = $imgdata;
      $field_values->{'mime_type'} = 'image/png';
    }

    $field_values->{contents} = _validate_data($field_values->{contents});
    $field_values->{creation_ts} = Bugzilla::Extension::Testopia::Util::get_time_stamp();
    $field_values->{mime_type} ||= 'application/octet-stream';

    my $contents   = $field_values->{contents};
    my $case_id    = $field_values->{case_id};
    my $plan_id    = $field_values->{plan_id};
    my $caserun_id = $field_values->{caserun_id};

    delete $field_values->{contents};
    delete $field_values->{case_id};
    delete $field_values->{plan_id};
    delete $field_values->{caserun_id};

    my $self = $class->SUPER::insert_create_data($field_values);

    # Store the data
    $dbh->do("INSERT INTO test_attachment_data (attachment_id, contents) VALUES(?,?)",
              undef, $self->id, $contents);

    # Link it to the case or plan
    if ($case_id){
        $dbh->do("INSERT INTO test_case_attachments (attachment_id, case_id, case_run_id)
                  VALUES (?,?,?)",
                  undef, ($self->id, $case_id, $caserun_id));
    }
    elsif ($plan_id){
        $dbh->do("INSERT INTO test_plan_attachments (attachment_id, plan_id)
                  VALUES (?,?)",
                  undef, ($self->id, $plan_id));
    }

    return $self;
}

###############################
####       Methods         ####
###############################

sub TO_JSON {
    my $self = shift;
    my $cgi  = shift;
    my $obj;
    my $json = new JSON;

    foreach my $field ($self->DB_COLUMNS){
        $obj->{$field} = $self->{$field};
    }

    # Add the calculated fields
    $obj->{'isviewable'} = $self->is_browser_safe($cgi);
    $obj->{'datasize'}   = $self->datasize;
    $obj->{'submitter'}  = $self->submitter->name if $self->submitter;
    $obj->{'canedit'}    = $self->canedit;
    $obj->{'candelete'}  = $self->candelete;
    $obj->{'caserun_id'}   = $self->{'caserun_id'} if $self->{'caserun_id'}; 
    $obj->{'creation_ts'} = format_time($self->{'creation_ts'}, TIME_FORMAT);

    return $json->encode($obj); 
}

# Returns 1 if the parameter is a content-type viewable in this browser
# Note that we don't use $cgi->Accept()'s ability to check if a content-type
# matches, because this will return a value even if it's matched by the generic
# */* which most browsers add to the end of their Accept: headers.

sub is_browser_safe {
  my $self = shift;
  my $cgi = shift;
  my $contenttype = $self->mime_type;

  # We assume we can view all text and image types  
  if ($contenttype =~ /^(text|image)\//) {
    return 1;
  }

  # Mozilla can view XUL. Note the trailing slash on the Gecko detection to
  # avoid sending XUL to Safari.
  if (($contenttype =~ /^application\/vnd\.mozilla\./) &&
      ($cgi->user_agent() =~ /Gecko\//))
  {
    return 1;
  }

  # If it's not one of the above types, we check the Accept: header for any 
  # types mentioned explicitly.
  my $accept = join(",", $cgi->Accept());

  if ($accept =~ /^(.*,)?\Q$contenttype\E(,.*)?$/) {
    return 1;
  }

  return 0;
}

sub obliterate {
    my $self = shift;
    return 0 unless $self->candelete;
    my $dbh = Bugzilla->dbh;

    $dbh->do("DELETE FROM test_attachment_data 
              WHERE attachment_id = ?", undef, $self->{'attachment_id'});
    $dbh->do("DELETE FROM test_case_attachments 
              WHERE attachment_id = ?", undef, $self->{'attachment_id'});
    $dbh->do("DELETE FROM test_plan_attachments 
              WHERE attachment_id = ?", undef, $self->{'attachment_id'});
    $dbh->do("DELETE FROM test_attachments 
              WHERE attachment_id = ?", undef, $self->{'attachment_id'});
    return 1;
}

sub link_plan {
    my $self = shift;
    my ($plan_id) = @_;
    my $dbh = Bugzilla->dbh;

    $dbh->bz_start_transaction();
    my ($is) = $dbh->selectrow_array(
            "SELECT 1 
               FROM test_plan_attachments
              WHERE attachment_id = ?
                AND plan_id = ?",
               undef, ($self->id, $plan_id));
    if ($is) {
        $dbh->bz_commit_transaction();
        return;
    }

    $dbh->do("INSERT INTO test_plan_attachments (attachment_id, plan_id)
              VALUES (?,?)",
              undef, ($self->id, $plan_id));
    $dbh->bz_commit_transaction(); 
}

sub link_case {
    my $self = shift;
    my ($case_id) = @_;
    my $dbh = Bugzilla->dbh;

    $dbh->bz_start_transaction();
    my ($is) = $dbh->selectrow_array(
            "SELECT 1 
               FROM test_case_attachments
              WHERE attachment_id = ?
                AND case_id = ?",
               undef, ($self->id, $case_id));
    if ($is) {
        $dbh->bz_commit_transaction();
        return;
    }

    $dbh->do("INSERT INTO test_case_attachments (attachment_id, case_id)
              VALUES (?,?)",
              undef, ($self->id, $case_id));
    $dbh->bz_commit_transaction();
}

sub unlink_plan {
    my $self = shift;
    my ($plan_id) = @_;
    my $dbh = Bugzilla->dbh;
    my ($refcount) = $dbh->selectrow_array(
        "SELECT COUNT(*) 
           FROM test_plan_attachments 
          WHERE attachment_id = ?", undef, $self->id);
    if ($refcount > 1){
        $dbh->do("DELETE FROM test_plan_attachments 
                  WHERE plan_id = ? AND attachment_id = ?",
                  undef, ($plan_id, $self->id));
    }
    else {
        $self->obliterate;
    }
}

sub unlink_case {
    my $self = shift;
    my ($case_id) = @_;
    my $dbh = Bugzilla->dbh;

    my ($refcount) = $dbh->selectrow_array(
        "SELECT COUNT(*) 
           FROM test_case_attachments 
          WHERE attachment_id = ?", undef, $self->id);
    if ($refcount > 1){
        $dbh->do("DELETE FROM test_case_attachments 
                  WHERE case_id = ? AND attachment_id = ?",
                  undef, ($case_id, $self->id));
    }
    else {
        $self->obliterate;
    }
}

sub canview {
    my $self = shift;
    return 1 if Bugzilla->user->in_group('Testers');
    my $retval = 0;
    foreach my $i (@{$self->cases}){
        $retval = 1 if $i->canview;
    }
    foreach my $i (@{$self->plans}){
        $retval = 1 if $i->canview;
    }
    return $retval;
}

sub canedit {
    my $self = shift;
    return 1 if Bugzilla->user->in_group('Testers');
    my $retval = 0;
    foreach my $i (@{$self->cases}){
        $retval = 1 if $i->canview;
    }
    foreach my $i (@{$self->plans}){
        $retval = 1 if $i->canview;
    }
    return $retval;
}

sub candelete {
    my $self = shift;
    return 1 if Bugzilla->user->in_group("admin");
    return 0 unless $self->canedit && Bugzilla->params->{"allow-test-deletion"};
    return 1 if Bugzilla->user->id == $self->submitter->id;
    foreach my $i (@{$self->cases}){
        return 0 unless $i->canedit;
    }
    foreach my $i (@{$self->plans}){
        return 0 unless $i->canedit;
    }
    return 1;
}

###############################
####      Accessors        ####
###############################

sub id             { return $_[0]->{'attachment_id'};    }
sub submitter      { return Bugzilla::User->new($_[0]->{'submitter_id'});      }
sub description    { return $_[0]->{'description'};      }
sub filename       { return $_[0]->{'filename'};         }
sub creation_ts    { return $_[0]->{'creation_ts'};      }
sub mime_type      { return $_[0]->{'mime_type'};        }

sub contents {
    my ($self) = @_;
    my $dbh = Bugzilla->dbh;
    return $self->{'contents'} if exists $self->{'contents'};
    my ($contents) = $dbh->selectrow_array("SELECT contents 
                                           FROM test_attachment_data
                                           WHERE attachment_id = ?",
                                           undef, $self->{'attachment_id'});

    $self->{'contents'} = $contents;
    return $self->{'contents'};
}

sub datasize {
    my ($self) = @_;
    my $dbh = Bugzilla->dbh;
    return $self->{'datasize'} if exists $self->{'datasize'};

    my ($datasize) = $dbh->selectrow_array("SELECT LENGTH(contents) 
                                           FROM test_attachment_data
                                           WHERE attachment_id = ?",
                                           undef, $self->{'attachment_id'});
    $self->{'datasize'} = $datasize;
    return $self->{'datasize'};
}

sub cases {
    my ($self) = @_;
    my $dbh = Bugzilla->dbh;
    
    require Bugzilla::Extension::Testopia::TestCase;
    
    return $self->{'cases'} if exists $self->{'cases'};
    my $caseids = $dbh->selectcol_arrayref(
            "SELECT case_id FROM test_case_attachments
              WHERE attachment_id = ?", 
             undef, $self->id);
    my @cases;
    foreach my $id (@{$caseids}){
        push @cases, Bugzilla::Extension::Testopia::TestCase->new($id);
    }

    $self->{'cases'} = \@cases;
    return $self->{'cases'};
}

sub plans {
    my ($self) = @_;
    my $dbh = Bugzilla->dbh;
    
    require Bugzilla::Extension::Testopia::TestPlan;
    
    return $self->{'plans'} if exists $self->{'plans'};
    my $planids = $dbh->selectcol_arrayref(
            "SELECT plan_id FROM test_plan_attachments
              WHERE attachment_id = ?", 
             undef, $self->id);
    my @plans;
    foreach my $id (@{$planids}){
        push @plans, Bugzilla::Extension::Testopia::TestPlan->new($id);
    }

    $self->{'plans'} = \@plans;
    return $self->{'plans'};
}

sub type {
    my $self = shift;
    $self->{'type'} = 'attachment';
    return $self->{'type'};
}

1;

__END__

=head1 NAME

Bugzilla::Extension::Testopia::Attachment - Attachment object for Testopia

=head1 EXTENDS

Bugzilla::Object

=head1 DESCRIPTION

This module provides support for attachments to Test Cases, Test
Plans and Test Case Runs in Testopia. Attachments can be linked
to multiple cases or plans. If linked to a test case, there is
an optional id for the case_run in which it was linked.

=head1 SYNOPSIS

=head2 Creating

 $attachment = Bugzilla::Extension::Testopia::Attachment->new($attachment_id);
 $attachment = Bugzilla::Extension::Testopia::Attachment->new({name => $name});

 $new_attachment = Bugzilla::Extension::Testopia::Attachment->create({name => $name, 
                                                 description => $desc
                                                 ... });

=head2 Updating

 $attachment->set_filename($name);
 $attachment->set_description($desc);
 $attachment->set_mime_type($mime_type);

 $attachment->update();

=head2 Accessors

 my $id            = $attachment->id;
 my $fname         = $attachment->filename;
 my $desc          = $attachment->description;
 my $size          = $attachment->datasize;
 my $contents      = $attachment->contents;
 my $created       = $attachment->creation_ts;
 my $submitter     = $attachment->submitter;

=head1 FIELDS
    
    Table test_attachments
    +---------------+------------------+------+-----+---------------------+----------------+
    | Field         | Type             | Null | Key | Default             | Extra          |
    +---------------+------------------+------+-----+---------------------+----------------+
    | attachment_id | int(10) unsigned | NO   | PRI | NULL                | auto_increment |
    | submitter_id  | mediumint(9)     | NO   | MUL | 0                   |                |
    | description   | mediumtext       | YES  |     | NULL                |                |
    | filename      | mediumtext       | YES  |     | NULL                |                |
    | creation_ts   | datetime         | NO   |     | 0000-00-00 00:00:00 |                |
    | mime_type     | varchar(100)     | NO   |     |                     |                |
    +---------------+------------------+------+-----+---------------------+----------------+
    
    Table test_attachment_data
    +---------------+----------+------+-----+---------+-------+
    | Field         | Type     | Null | Key | Default | Extra |
    +---------------+----------+------+-----+---------+-------+
    | attachment_id | int(11)  | NO   | MUL |         |       |
    | contents      | longblob | YES  |     | NULL    |       |
    +---------------+----------+------+-----+---------+-------+

=over

=item C<attachment_id> 

The unique id of this attachment in the database. 

=item C<creation_ts>

Timestamp - when this attachment was created

=item C<description>

A description of this attachment. Becomes the link on the plan and case pages. 

=item C<filename>

The file name from the users harddrive before uploading with its path removed.

=item C<mime_type>

The MIME type associated with this attachment. This is used to determine if the 
attachment if viewable in a browser.

=item C<test_attachment_data.contents>

The actual attachment data. This is stored in a separate table to reduce lookup times.

=item C<submitter_id>

The ID of the person that uploaded the attachment.

=back

=head1 METHODS

=over

=item C<new($param)>

 Description: Used to load an existing attachment from the database.

 Params:      $param - An integer representing the ID in the database
                       or a hash with the "name" key representing the named
                       attachment in the database.

 Returns:     A blessed Bugzilla::Extension::Testopia::Attachment object

=item C<candelete()>

 Description: Check that the current attachment can be safely deleted and that 
              the current user has rights to do so.

 Params:      none.

 Returns:     0 if the user does not have rights to delete this attachment.
              1 if the user does have rights.

=item C<canedit()>

 Description: Check that the current user has rights to edit this attachment.

 Params:      none.

 Returns:     0 if the user does not have rights.
              1 if the user does have rights.

=item C<canview()>

 Description: Chec that the current user has rights to view this attachment.

 Params:      none.

 Returns:     0 if the user does not have rights.
              1 if the user does have rights.

=item C<create()>

 Description: Creates a new attachment object and stores it in the database.
              Also links the associated plans or cases to the object. 

 Params:      A hash with keys and values matching the fields of the attachment to 
              be created.

 Returns:     The newly created object.

=item C<is_browser_safe()>

 Description: Checks that the attachment is viewable in the browser based on
              its mime_type.

 Params:      CGI - a Bugzilla::CGI object.

 Returns:     1 if this attachment can be viewed inline in the browser.
              0 if the attachment must be downloaded for viewing in an external
                application.

=item C<link_case()>

 Description: Links this attachment to the specified case.

 Params:      case_id - id of the case to link to.

 Returns:     nothing.

=item C<link_plan()>

 Description: Links this attachment to the specified plan.

 Params:      plan_id - id of the plan to link to.

 Returns:     nothing.

=item C<obliterate()>

 Description: Completely removes this attachment from the database and clears
              references to it.

 Params:      none.

 Returns:     nothing.

=item C<set_description()>

 Description: Replaces the current attachment's description. Must call update to 
              store the change in the database.

 Params:      text - the new description.

 Returns:     nothing.

=item C<set_filename()>

 Description: Sets the isactive field. 

 Params:      string - the new filename

 Returns:     nothing.

=item C<set_mime_type()>

 Description: Changes the assigned mime_type

 Params:      string - the new mime_type

 Returns:     nothing.

=item C<store()> DEPRECATED

 Description: Similar to create except validation is not performed during store. 

 Params:      none.

 Returns:     The id of the newly stored attachment.

=item C<to_json()>

 Description: Outputs a JSON representation of the object.

 Params:      none

 Returns:     A JSON string.

=item C<unlink_case()>

 Description: Unlinks the this attachment from the specified test case. If only
              attached to a single case, delete the attachment instead.

 Params:      case_id - id of the case to unlink.

 Returns:     nothing.

=item C<unlink_plan()>

 Description: Unlinks the this attachment from the specified test plan. If only
              attached to a single plan, delete the attachment instead.

 Params:      plan_id - id of the plan to unlink.

 Returns:     nothing.

=back

=head1 ACCESSORS

=over

=item C<cases()>

 Returns a list of TestCase objects that this attachment is associated with.

=item C<contents()>

 Returns the content data of this attachment.

=item C<creation_ts()>

 Returns the timestamp when this attachment was created.

=item C<datasize()>

 Returns the size in byts of the contents of this attachment.

=item C<description()>

 Returns the description of this attachment.

=item C<filename()>

 Returns the filename from the users harddrive that was uploaded when the 
 attachemnt was created with any path information stripped off.

=item C<id()>

 Returns the id of the attachment.

=item C<mime_type()>

 Returns the MIME type of this attachment.

=item C<plans()>

 Returns a list of TestPlan objects associated with this attachment.

=item C<submitter()>

 Returns the login name of the person who submitted this attachment.

=item C<type()>

 Returns "attachment". For use in internal code.

=back

=head1 SEE ALSO

=over

L<Bugzilla::Extension::Testopia::TestCase>
L<Bugzilla::Extension::Testopia::TestPlan> 
L<Bugzilla::Object> 

=back

=head1 AUTHOR

Greg Hendricks <ghendricks@novell.com>
