Add cust_attachment stuff
authormark <mark>
Sun, 9 Aug 2009 09:05:38 +0000 (09:05 +0000)
committermark <mark>
Sun, 9 Aug 2009 09:05:38 +0000 (09:05 +0000)
FS/FS/AccessRight.pm
FS/FS/Conf.pm
FS/FS/Mason.pm
FS/FS/Record.pm
FS/FS/Schema.pm
FS/FS/cust_attachment.pm [new file with mode: 0644]
httemplate/edit/cust_main_attach.cgi [new file with mode: 0755]
httemplate/edit/process/cust_main_attach.cgi [new file with mode: 0644]
httemplate/view/attachment.html [new file with mode: 0644]
httemplate/view/cust_main.cgi
httemplate/view/cust_main/attachments.html [new file with mode: 0755]

index 29cecd5..d192125 100644 (file)
@@ -100,6 +100,9 @@ tie my %rights, 'Tie::IxHash',
     { rightname=>'Delete customer', desc=>"Enable customer deletions. Be very careful! Deleting a customer will remove all traces that this customer ever existed! It should probably only be used when auditing a legacy database. Normally, you cancel all of a customer's packages if they cancel service." }, #aka. deletecustomers
     'Add customer note', #NEW
     'Edit customer note', #NEW
+    'Download attachment', #NEW
+    'Add attachment', #NEW
+    'Edit attachment', #NEW
     'Bill customer now', #NEW
     'Bulk send customer notices', #NEW
   ],
index 66f7457..1da5583 100644 (file)
@@ -736,6 +736,20 @@ worry that config_items is freeside-specific and icky.
   },
 
   {
+    'key'         => 'disable_cust_attachment',
+    'section'     => '',
+    'description' => 'Disable customer file attachments',
+    'type'        => 'checkbox',
+  },
+
+  {
+    'key'         => 'max_attachment_size',
+    'section'     => '',
+    'description' => 'Maximum size for customer file attachments (leave blank for unlimited)',
+    'type'        => 'text',
+  },
+
+  {
     'key'         => 'disable_customer_referrals',
     'section'     => 'UI',
     'description' => 'Disable new customer-to-customer referrals in the web interface',
index ed99bf6..d73d381 100644 (file)
@@ -186,6 +186,7 @@ Initializes the Mason environment, loads all Freeside and RT libraries, etc.
   use FS::part_pkg_taxrate;
   use FS::tax_rate;
   use FS::part_pkg_report_option;
+  use FS::cust_attachment;
   use FS::h_cust_pkg;
   use FS::h_svc_acct;
   use FS::h_svc_broadband;
index 9e1c0e8..11afd9f 100644 (file)
@@ -55,14 +55,13 @@ FS::UID->install_callback( sub {
   $conf_encryption = $conf->exists('encryption');
   $File::CounterFile::DEFAULT_DIR = $conf->base_dir . "/counters.". datasrc;
   if ( driver_name eq 'Pg' ) {
-    eval "use DBD::Pg qw(:pg_types);";
+    eval "use DBD::Pg ':pg_types'";
     die $@ if $@;
   } else {
     eval "sub PG_BYTEA { die 'guru meditation #9: calling PG_BYTEA when not running Pg?'; }";
   }
 } );
 
-
 =head1 NAME
 
 FS::Record - Database record objects
@@ -2718,7 +2717,10 @@ sub _quote {
           )
   {
     no strict 'subs';
-    dbh->quote($value, PG_BYTEA);
+#    dbh->quote($value, { pg_type => PG_BYTEA() }); # doesn't work right
+    # Pg binary string quoting: convert each character to 3-digit octal prefixed with \\, 
+    # single-quote the whole mess, and put an "E" in front.
+    return ("E'" . join('', map { sprintf('\\\\%03o', ord($_)) } split(//, $value) ) . "'");
   } else {
     dbh->quote($value);
   }
index 80aed82..0ede000 100644 (file)
@@ -372,6 +372,22 @@ sub tables_hashref {
       'index' => [ ['typenum'] ],
     },
 
+    'cust_attachment' => {
+      'columns' => [
+        'attachnum', 'serial', '', '', '', '',
+        'custnum',   'int', '', '', '', '',
+        '_date',     @date_type, '', '',
+        'otaker',    'varchar', '', 32, '', '',
+        'filename',  'varchar', '', 32, '', '',
+        'mime_type', 'varchar', '', 32, '', '',
+        'body',      'blob', 'NULL', '', '', '',
+        'disabled',  @date_type, '', '',
+      ],
+      'primary_key' => 'attachnum',
+      'unique'      => [],
+      'index'       => [ ['custnum'] ],
+    },
+
     'cust_bill' => {
       'columns' => [
         'invnum',    'serial',  '', '', '', '', 
diff --git a/FS/FS/cust_attachment.pm b/FS/FS/cust_attachment.pm
new file mode 100644 (file)
index 0000000..9527381
--- /dev/null
@@ -0,0 +1,170 @@
+package FS::cust_attachment;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
+
+=head1 NAME
+
+FS::cust_attachment - Object methods for cust_attachment records
+
+=head1 SYNOPSIS
+
+  use FS::cust_attachment;
+
+  $record = new FS::cust_attachment \%hash;
+  $record = new FS::cust_attachment { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_attachment object represents a file attached to a L<FS::cust_main>
+object.  FS::cust_attachment inherits from FS::Record.  The following fields 
+are currently supported:
+
+=over 4
+
+=item attachnum
+
+Primary key (assigned automatically).
+
+=item custnum
+
+Customer number (see L<FS::cust_main>).
+
+=item _date
+
+The date the record was last updated.
+
+=item otaker
+
+Order taker (assigned automatically; see L<FS::UID>).
+
+=item filename
+
+The file's name.
+
+=item mime_type
+
+The Content-Type of the file.
+
+=item body
+
+The contents of the file.
+
+=item disabled
+
+If the attachment was disabled, this contains the date it was disabled.
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new attachment object.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'cust_attachment'; }
+
+sub nohistory_fields { 'body'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $conf = new FS::Conf;
+  my $error;
+  if($conf->config('disable_cust_attachment') ) {
+    $error = 'Attachments disabled (see configuration)';
+  }
+
+  $error = 
+    $self->ut_numbern('attachnum')
+    || $self->ut_number('custnum')
+    || $self->ut_numbern('_date')
+    || $self->ut_text('otaker')
+    || $self->ut_text('filename')
+    || $self->ut_text('mime_type')
+    || $self->ut_numbern('disabled')
+    || $self->ut_anything('body')
+  ;
+  if($conf->config('max_attachment_size') 
+    and $self->size > $conf->config('max_attachment_size') ) {
+    $error = 'Attachment too large'
+  }
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item size
+
+Returns the size of the attachment in bytes.
+
+=cut
+
+sub size {
+  my $self = shift;
+  return length($self->body);
+}
+
+=back
+
+=head1 BUGS
+
+Doesn't work on non-Postgres systems.
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/httemplate/edit/cust_main_attach.cgi b/httemplate/edit/cust_main_attach.cgi
new file mode 100755 (executable)
index 0000000..7c9e407
--- /dev/null
@@ -0,0 +1,58 @@
+<% include('/elements/header-popup.html', "$action File Attachment") %>
+
+<% include('/elements/error.html') %>
+
+<FORM ACTION="<% popurl(1) %>process/cust_main_attach.cgi" METHOD=POST ENCTYPE="multipart/form-data">
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
+<INPUT TYPE="hidden" NAME="attachnum" VALUE="<% $attachnum %>">
+
+<BR><BR>
+
+% if(defined $attach) {
+Filename <INPUT TYPE="text" NAME="filename" VALUE="<% $attach->filename %>"><BR>
+MIME type <INPUT TYPE="text" NAME="mime_type" VALUE="<% $attach->mime_type %>"<BR>
+Size: <% $attach->size %><BR>
+
+% }
+% else { # !defined $attach
+
+Filename <INPUT TYPE="file" NAME="file"><BR>
+
+% }
+
+<BR>
+<INPUT TYPE="submit" NAME="submit" 
+    VALUE="<% $attachnum ? "Apply Changes" : "Upload File" %>">
+
+% if(defined $attach) {
+<BR>
+<INPUT TYPE="submit" NAME="delete" value="Delete File">
+% }
+
+</FORM>
+</BODY>
+</HTML>
+
+<%init>
+
+my $attachnum = '';
+my $attach;
+if ( $cgi->param('error') ) {
+  #$comment     = $cgi->param('comment');
+} elsif ( $cgi->param('attachnum') =~ /^(\d+)$/ ) {
+  $attachnum = $1;
+  die "illegal query ". $cgi->keywords unless $attachnum;
+  $attach = qsearchs('cust_attachment', { 'attachnum' => $attachnum });
+  die "no such attachment: ". $attachnum unless $attach;
+}
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die "illegal custnum";
+my $custnum = $1;
+
+my $action = $attachnum ? 'Edit' : 'Add';
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right("$action customer note");
+
+</%init>
+
diff --git a/httemplate/edit/process/cust_main_attach.cgi b/httemplate/edit/process/cust_main_attach.cgi
new file mode 100644 (file)
index 0000000..51eead0
--- /dev/null
@@ -0,0 +1,88 @@
+%if ($error) {
+%  $cgi->param('error', $error);
+<% $cgi->redirect(popurl(2). 'cust_main_attach.cgi?'. $cgi->query_string ) %>
+%} else {
+% my $act = 'added';
+% $act = 'updated' if ($attachnum);
+% $act = 'undeleted' if($attachnum and $undelete);
+% $act = 'deleted' if($attachnum and $delete);
+<% header('Attachment ' . $act ) %>
+    <SCRIPT TYPE="text/javascript">
+      window.top.location.reload();
+    </SCRIPT>
+    </BODY></HTML>
+% }
+<%init>
+
+my $error;
+$cgi->param('custnum') =~ /^(\d+)$/
+  or die "Illegal custnum: ". $cgi->param('custnum');
+my $custnum = $1;
+
+$cgi->param('attachnum') =~ /^(\d*)$/
+  or die "Illegal attachnum: ". $cgi->param('attachnum');
+my $attachnum = $1;
+
+my $otaker = $FS::CurrentUser::CurrentUser->name;
+$otaker = $FS::CurrentUser::CurrentUser->username
+  if ($otaker eq "User, Legacy");
+
+my $delete = $cgi->param('delete');
+my $undelete = $cgi->param('undelete');
+
+my $new = new FS::cust_attachment ( {
+  attachnum => $attachnum,
+  custnum   => $custnum,
+  _date     => time,
+  otaker    => $otaker,
+  disabled  => '',
+});
+my $old;
+
+if($attachnum) {
+  $old = qsearchs('cust_attachment', { attachnum => $attachnum });
+  if(!$old) {
+    $error = "Attachnum '$attachnum' not found";
+  }
+  else {
+    map { $new->$_($old->$_) } 
+      ('_date', 'otaker', 'body', 'disabled');
+    $new->filename($cgi->param('filename') || $old->filename);
+    $new->mime_type($cgi->param('mime_type') || $old->mime_type);
+    if($delete and not $old->disabled) {
+      $new->disabled(time);
+    }
+    if($undelete and $old->disabled) {
+      $new->disabled('');
+    }
+  }
+}
+else { # This is a new attachment, so require a file.
+
+  my $filename = $cgi->param('file');
+  if($filename) {
+    $new->filename($filename);
+    $new->mime_type($cgi->uploadInfo($filename)->{'Content-Type'});
+    
+    local $/;
+    my $fh = $cgi->upload('file');
+    $new->body(<$fh>);
+  }
+  else {
+    $error = 'No file uploaded';
+  }
+}
+my $user = $FS::CurrentUser::CurrentUser;
+
+$error = 'access denied' unless $user->access_right(($old ? 'Edit' : 'Add') . ' attachment');
+
+if(!$error) {
+  if($old) {
+    $error = $new->replace($old);
+  }
+  else {
+    $error = $new->insert;
+  }
+}
+
+</%init>
diff --git a/httemplate/view/attachment.html b/httemplate/view/attachment.html
new file mode 100644 (file)
index 0000000..c85b137
--- /dev/null
@@ -0,0 +1,16 @@
+<%init>
+my ($query) = $cgi->keywords;
+$query =~ /^(\d+)$/;
+my $attachnum = $1 or die 'Invalid attachment number';
+$FS::CurrentUser::CurrentUser->access_right('Download attachment') or die 'access denied';
+
+my $attach = qsearchs('cust_attachment', { attachnum => $attachnum }) or die 'Attachment not found: $attachnum';
+
+$m->clear_buffer;
+$r->content_type($attach->mime_type || 'text/plain');
+$r->headers_out->add('Content-Disposition' => 'attachment;filename=' . $attach->filename);
+
+binmode STDOUT;
+print STDOUT $attach->body;
+
+</%init>
index 78bcb1f..da1a56a 100755 (executable)
@@ -113,7 +113,6 @@ Comments
 % if ( ! $conf->exists('cust_main-disable_notes') || $notecount) {
 
 %   unless ( $view eq 'notes' && $cust_main->comments !~ /[^\s\n\r]/ ) {
-      <BR>
       <A NAME="cust_main_note"><FONT SIZE="+2">Notes</FONT></A><BR>
 %   }
 
@@ -138,6 +137,22 @@ Comments
 <% include('cust_main/notes.html', 'custnum' => $cust_main->custnum ) %>
 
 % }
+<BR>
+
+% if(! $conf->config('disable_cust_attachment') 
+%  and $curuser->access_right('Add attachment')) {
+<% include( '/elements/popup_link-cust_main.html',
+              'label'       => 'Attach file',
+              'action'      => $p.'edit/cust_main_attach.cgi',
+              'actionlabel' => 'Upload file',
+              'cust_main'   => $cust_main,
+              'width'       => 616,
+              'height'      => 408,
+          )
+%>
+% }
+<% include('cust_main/attachments.html', 'custnum' => $cust_main->custnum ) %>
+<BR>
 
 % }
 
@@ -181,10 +196,6 @@ Comments
 
 % }
 
-% if ( $view eq 'change_history' ) { #  || $view eq 'jumbo'
-  <% include('cust_main/change_history.html', $cust_main ) %>
-% }
-
 <% include('/elements/footer.html') %>
 <%init>
 
@@ -218,12 +229,11 @@ tie my %views, 'Tie::IxHash',
        'Notes'            => 'notes', #notes and files?
 ;
 $views{'Tickets'}         =  'tickets'
-  if $conf->config('ticket_system');
+                               if $conf->config('ticket_system');
 $views{'Packages'}        =  'packages';
 $views{'Payment History'} =  'payment_history'
-  unless $conf->config('payby-default' eq 'HIDE');
-$views{'Change History'}  =  'change_history'
-  if $curuser->access_right('View customer history');
+                               unless $conf->config('payby-default' eq 'HIDE');
+#$views{'Change History'}  =  '';
 $views{'Jumbo'}           =  'jumbo';
 
 my %viewname = reverse %views;
diff --git a/httemplate/view/cust_main/attachments.html b/httemplate/view/cust_main/attachments.html
new file mode 100755 (executable)
index 0000000..e25814f
--- /dev/null
@@ -0,0 +1,133 @@
+% if ( scalar(@attachments) ) {
+
+  <% include('/elements/init_overlib.html') %>
+
+  <% include("/elements/table-grid.html") %>
+
+  <TR>
+    <TH CLASS="grid" BGCOLOR="#cccccc">Date</TH>
+%   if ( $conf->exists('cust_main_note-display_times') ) {
+      <TH CLASS="grid" BGCOLOR="#cccccc">Time</TH>
+%   }
+    <TH CLASS="grid" BGCOLOR="#cccccc">Person</TH>
+    <TH CLASS="grid" BGCOLOR="#cccccc">Filename</TH>
+    <TH CLASS="grid" BGCOLOR="#cccccc">Type</TH>
+    <TH CLASS="grid" BGCOLOR="#cccccc">Size</TH>
+    <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
+  </TR>
+
+% my $bgcolor1 = '#eeeeee';
+% my $bgcolor2 = '#ffffff';
+% my $bgcolor = '';
+%
+% foreach my $attach ((grep { $_->disabled } @attachments),
+%                     (grep { ! $_->disabled } @attachments)) {
+%
+%   if ( $bgcolor eq $bgcolor1 ) {
+%     $bgcolor = $bgcolor2;
+%   } else {
+%     $bgcolor = $bgcolor1;
+%   }
+%
+%   my $pop = popurl(3);
+%   my $attachnum = $attach->attachnum;
+%   my $edit = '';
+%   my $download = '';
+%   if($attach->disabled) {
+%     my $onclick = include('/elements/popup_link_onclick.html',
+%                            'action'   => popurl(2).
+%                                         'edit/process/cust_main_attach.cgi'.
+%                                         "?custnum=$custnum;".
+%                                         "attachnum=$attachnum;".
+%                                         "undelete=1",
+%                            'actionlabel' => 'Undelete attachment',
+%                            'width'       => 616,
+%                            'height'      => 408,
+%                            'frame'       => 'top',
+%                         );
+%     my $clickjs = qq!onclick="$onclick"!;
+%     if($curuser->access_right('Edit attachment')) {
+%       $edit = qq! <A HREF="javascript:void(0);" $clickjs>(undelete)</A>!;
+%     }
+%   }
+%   else {
+%     my $onclick = include( '/elements/popup_link_onclick.html',
+%                              'action'      => popurl(2).
+%                                               'edit/cust_main_attach.cgi'.
+%                                               "?custnum=$custnum".
+%                                               ";attachnum=$attachnum",
+%                              'actionlabel' => 'Edit customer note',
+%                              'width'       => 616,
+%                              'height'      => 408,
+%                              'frame'       => 'top',
+%                          );
+%     my $clickjs = qq!onclick="$onclick"!;
+%
+%     if ($curuser->access_right('Edit attachment') ) {
+%       $edit = qq! <A HREF="javascript:void(0);" $clickjs>(edit)</A>!;
+%     }
+%     if ($curuser->access_right('Download attachment') ) {
+%       $download = qq! <A HREF="!.popurl(1).'attachment.html?'.$attachnum.qq!">(download)</A>!;
+%     }
+%   }
+
+    <TR>
+      <% note_datestr($attach,$conf,$bgcolor) %>
+      <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+        &nbsp;<% $attach->otaker%>
+      </TD>
+      <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+       &nbsp;<% $attach->filename %>
+      </TD>
+      <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+       &nbsp;<% $attach->mime_type %>
+      </TD>
+      <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+       &nbsp;<% size_units( $attach->size ) %>
+      </TD>
+      <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+        &nbsp;<% $edit %>
+        &nbsp;<% $download %>
+      </TD>
+      <% $attach->disabled ? '</I>' : '' %>
+    </TR>
+
+% } #end display notes
+
+</TABLE>
+
+% }
+<%init>
+
+my $conf = new FS::Conf;
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+my(%opt) = @_;
+
+my $custnum = $opt{'custnum'};
+
+my $cust_main = qsearchs('cust_main', {'custnum' => $custnum} );
+die "Customer not found!" unless $cust_main;
+
+my (@attachments) = qsearch('cust_attachment', {'custnum' => $custnum});
+
+#subroutines
+
+sub note_datestr {
+  my($note, $conf, $bgcolor) = @_ or return '';
+  my $td = qq{<TD CLASS="grid" BGCOLOR="$bgcolor" ALIGN="right">};
+  my $format = "$td%b&nbsp;%o,&nbsp;%Y</TD>";
+  $format .= "$td%l:%M%P</TD>"
+    if $conf->exists('cust_main_note-display_times');
+  ( my $strip = time2str($format, $note->_date) ) =~ s/ (\d)/$1/g;
+  $strip;
+}
+
+sub size_units {
+  my $bytes = shift;
+  return $bytes if $bytes < 1024;
+  return int($bytes / 1024)."K" if $bytes < 1048576;
+  return int($bytes / 1048576)."M";
+}
+
+</%init>