[Rt-commit] rt branch, 4.4/remove-user-info, created. rt-4.4.3-96-gb0b94f3b19

Craig Kaiser craig at bestpractical.com
Fri Dec 21 18:21:54 EST 2018


The branch, 4.4/remove-user-info has been created
        at  b0b94f3b19d599a148e0b331e11033cd75aea81c (commit)

- Log -----------------------------------------------------------------
commit d0449e83dde2cb3a2f694ffd303c8fe784769ea1
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 7 08:44:33 2018 -0400

    Create ReplaceAttachments, ReplaceHeader and ReplaceContent Methods
    
    Quickly search on the attachments table and remove or replace matching
    data found.

diff --git a/lib/RT/Attachment.pm b/lib/RT/Attachment.pm
index 4c544301c7..5708e31428 100644
--- a/lib/RT/Attachment.pm
+++ b/lib/RT/Attachment.pm
@@ -743,6 +743,62 @@ sub SetHeader {
     $self->__Set( Field => 'Headers', Value => $newheader);
 }
 
+=head2 ReplaceHeaders ( Search => 'SEARCH', Replacement => 'Replacement' )
+
+Search the attachments table's Header column for the search string provided.
+When a match is found call the SetHeader() method on the header with the match,
+either set the header to empty or a replacement value.
+
+=cut
+
+sub ReplaceHeaders {
+    my $self = shift;
+    my %args = (
+        Search      => undef,
+        Replacement => '',
+        @_,
+    );
+
+    return ( 0, $self->loc('No Search string provided') ) unless $args{Search};
+
+    foreach my $header ( $self->SplitHeaders ) {
+        my ( $tag, $value ) = split /:/, $header, 2;
+        if ( $value =~ s/\Q$args{Search}\E/$args{Replacement}/ig ) {
+            my $ret = $self->SetHeader( $tag, $value );
+            RT::Logger->error("Could not set header: $tag to $value") unless $ret;
+        }
+    }
+    return ( 1, $self->loc('Headers cleared') );
+}
+
+=head2 ReplaceContent ( Search => 'SEARCH', Replacement => 'Replacement' )
+
+Search the attachments table's Content column for the search string provided.
+When a match is found either replace it with the provided replacement string or an
+empty string.
+
+=cut
+
+sub ReplaceContent {
+    my $self = shift;
+    my %args = (
+        Search      => undef,
+        Replacement => '',
+        @_,
+    );
+
+    return ( 0, $self->loc('No search string provided') ) unless $args{Search};
+
+    my $content = $self->Content;
+
+    if ( $content && $content =~ s/\Q$args{Search}\E/$args{Replacement}/ig ) {
+        my ( $ret, $msg ) = $self->_Set( Field => 'Content', Value => $content );
+        return ( $ret, 'Content replaced' );
+    }
+    return ( 1, $self->loc('No content matches found') );
+}
+
+
 sub _CanonicalizeHeaderValue {
     my $self  = shift;
     my $value = shift;
diff --git a/lib/RT/Attachments.pm b/lib/RT/Attachments.pm
index 8b06088678..a4b070e2cd 100644
--- a/lib/RT/Attachments.pm
+++ b/lib/RT/Attachments.pm
@@ -252,6 +252,81 @@ sub AddRecord {
     return $self->SUPER::AddRecord( $record );
 }
 
+=head2 ReplaceAttachments ( Search => 'SEARCH', Replacement => 'Replacement', Header => 1, Content => 1 )
+
+Provide a search string to search the attachments table for, by default the Headers and Content
+columns will both be searched for matches.
+
+=cut
+
+sub ReplaceAttachments {
+    my $self = shift;
+    my %args = (
+        Search      => undef,
+        Replacement => '',
+        Headers     => 1,
+        Content     => 1,
+        @_,
+    );
+
+    return ( 0, $self->loc('Provide a search string to search on') ) unless $args{Search};
+
+    $self->Limit(
+        ENTRYAGGREGATOR => 'OR',
+        FIELD           => 'Headers',
+        OPERATOR        => 'LIKE',
+        VALUE           => $args{Search},
+        SUBCLAUSE       => 'Attachments',
+    ) if $args{Headers};
+    $self->Limit(
+        ENTRYAGGREGATOR => 'OR',
+        FIELD           => 'Content',
+        OPERATOR        => 'LIKE',
+        VALUE           => $args{Search},
+        SUBCLAUSE       => 'Attachments',
+    ) if $args{Content};
+    $self->Limit(
+        FIELD           => 'ContentType',
+        OPERATOR        => 'IN',
+        VALUE           => ['text/plain', 'text/html'],
+    );
+    $self->Limit(
+        FIELD           => 'ContentEncoding',
+        VALUE           => 'none',
+    );
+
+    my %tickets;
+    my ($ret, $msg);
+    while (my $attachment = $self->Next) {
+        my $content_replaced;
+        if ( $args{Headers} ) {
+            ($ret, $msg) = $attachment->ReplaceHeaders(Search => $args{Search}, Replacement => $args{Replacement});
+            $content_replaced ||= $ret;
+
+            RT::Logger->error($msg) unless $ret;
+        }
+
+        if ( $args{Content} ) {
+            ($ret, $msg) = $attachment->ReplaceContent(Search => $args{Search}, Replacement => $args{Replacement});
+            $content_replaced ||= $ret;
+
+            RT::Logger->error($msg) unless $ret;
+        }
+
+        my $ticket = $attachment->TransactionObj->TicketObj;
+        $tickets{$ticket->Id} ||= $ticket if $content_replaced;
+    }
+
+    foreach my $id ( sort { $a <=> $b } keys %tickets ) {
+        (my $transaction, $msg, my $trans) = $tickets{$id}->_NewTransaction (
+            Type     => "Munge",
+        );
+        RT::Logger->error($msg) unless $transaction;
+    }
+    my $count = scalar keys %tickets;
+    return ( 1, $self->loc( "Updated [_1] ticket's attachment content", $count ) );
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Transaction.pm b/lib/RT/Transaction.pm
index 9f897c307d..c88f43f9d6 100644
--- a/lib/RT/Transaction.pm
+++ b/lib/RT/Transaction.pm
@@ -1373,6 +1373,10 @@ sub _CanonicalizeRoleName {
         $principal->Load($self->Field);
         return ("Removed from group '[_1]'", $principal->Object->Name); #loc()
     },
+    Munge => sub {
+        my $self = shift;
+        return "Attachment content modified";
+    },
 );
 
 

commit 74736c6cbdfc0ccae33386f40bc7ba122ae446ee
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 7 08:45:39 2018 -0400

    Create rt-munge-attachments executable

diff --git a/.gitignore b/.gitignore
index 1159ee89cc..ff1a7110d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@
 /sbin/rt-ldapimport
 /sbin/rt-passwd
 /sbin/standalone_httpd
+/sbin/rt-munge-attachments
 /var/mason_data/
 /autom4te.cache/
 /configure
diff --git a/Makefile.in b/Makefile.in
index cc418241f0..e704069278 100644
--- a/Makefile.in
+++ b/Makefile.in
@@ -139,6 +139,7 @@ BINARIES		=	$(RT_MAILGATE_BIN) \
 				$(RT_CRON_BIN)
 
 SYSTEM_BINARIES		=	rt-attributes-viewer \
+				rt-munge-attachments \
 				rt-clean-sessions \
 				rt-dump-metadata \
 				rt-email-dashboards \
diff --git a/configure.ac b/configure.ac
index d7685d80d1..4c6e34b6aa 100755
--- a/configure.ac
+++ b/configure.ac
@@ -486,6 +486,7 @@ AC_CONFIG_FILES([
                  sbin/rt-serializer
                  sbin/rt-importer
                  sbin/rt-passwd
+                 sbin/rt-munge-attachments
                  bin/rt-crontool
                  bin/rt-mailgate
                  bin/rt],
diff --git a/sbin/rt-munge-attachments.in b/sbin/rt-munge-attachments.in
new file mode 100644
index 0000000000..2a3eec9692
--- /dev/null
+++ b/sbin/rt-munge-attachments.in
@@ -0,0 +1,123 @@
+#!@PERL@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+# 02110-1301 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+use strict;
+use warnings;
+
+# fix lib paths, some may be relative
+BEGIN {    # BEGIN RT CMD BOILERPLATE
+    require File::Spec;
+    require Cwd;
+    my @libs = ( "@RT_LIB_PATH@", "@LOCAL_LIB_PATH@" );
+    my $bin_path;
+
+    for my $lib (@libs) {
+        unless ( File::Spec->file_name_is_absolute($lib) ) {
+            $bin_path
+                ||= ( File::Spec->splitpath( Cwd::abs_path(__FILE__) ) )[1];
+            $lib = File::Spec->catfile( $bin_path, File::Spec->updir, $lib );
+        }
+        unshift @INC, $lib;
+    }
+
+}
+
+# Read in the options
+my %opts;
+use Getopt::Long;
+GetOptions( \%opts, "help|h", "search=s", "replacement=s", );
+
+if ( $opts{'help'} || !$opts{'search'} ) {
+    require Pod::Usage;
+    print Pod::Usage::pod2usage( -verbose => 2 );
+    exit;
+}
+
+use RT -init;
+
+my $replacement = $opts{'replacement'} || '';
+
+my $search = $opts{'search'};
+
+my $attachments = RT::Attachments->new( RT->SystemUser );
+my ($ret, $msg) = $attachments->ReplaceAttachments(Search => $search, Replacement => $replacement);
+
+print STDERR $msg . "\n";
+
+
+=head1 rt-munge-attachments
+
+rt-munge-attachments - Remove or replace string from attachments table.
+
+=head1 SYNOPSIS
+
+    rt-munge-attachments --search="user1 at example.com" --replace="Ex-Employee"
+
+=cut
+
+=head1 DESCRIPTION
+
+When a match is found in the Headers column, the header is deleted unless a replacement
+value was provided. If a match is found in the Content column then the matching substring
+will be replaced with a blank or provided value.
+
+=head1 OPTIONS
+
+=over 2
+
+=item --search=SEARCH
+
+Provide a string to search the header and content columns in the attachments table, if a match
+is found the content will be removed.
+
+=item --replace=REPLACEMENT
+
+Provide a string to replace strings matching the search string for the
+attachments table.
+
+=back

commit 1461de1fec3ef45287879b361560a11656c569fb
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Sep 7 08:45:59 2018 -0400

    Add tests for methods that munge the attachments table

diff --git a/t/api/attachment.t b/t/api/attachment.t
index 52e3c3f162..25d736b8bf 100644
--- a/t/api/attachment.t
+++ b/t/api/attachment.t
@@ -2,7 +2,7 @@
 use strict;
 use warnings;
 use RT;
-use RT::Test tests => 7;
+use RT::Test tests => undef;
 
 
 {
@@ -64,3 +64,95 @@ is ($#headers, 2, "testing a bunch of singline multiple headers" );
         'body of ContentAsMIME is original'
     );
 }
+
+diag 'Test clearing and replacing header and content in attachments table';
+{
+    my $queue = RT::Test->load_or_create_queue( Name => 'General' );
+    ok $queue && $queue->id, 'loaded or created queue';
+
+    my $t = RT::Test->create_ticket( Queue => 'General', Subject => 'test' );
+    ok $t && $t->id, 'created a ticket';
+
+    $t->Comment( Content => 'test' );
+
+    my $attachments = RT::Attachments->new(RT->SystemUser);
+    $attachments->Limit(
+        FIELD           => 'Content',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'test',
+    );
+    is $attachments->Count, 1, 'Found content with "test"';
+
+    # Replace attachment value for 'test' in Conetent col
+    my ($ret, $msg) = $attachments->ReplaceAttachments(Search => 'test', Replacement => 'new_value', Headers => 0);
+    ok $ret, $msg;
+
+    $attachments->CleanSlate;
+
+    $attachments->Limit(
+        FIELD           => 'Content',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'test',
+    );
+    is $attachments->Count, 0, 'Found no content with "test"';
+
+    $attachments->Limit(
+        FIELD           => 'Content',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'new_value',
+    );
+    is $attachments->Count, 1, 'Found content with "new_value"';
+
+    $attachments->CleanSlate;
+
+    $attachments->Limit(
+        FIELD           => 'Headers',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'API',
+    );
+    is $attachments->Count, 1, 'Found header with content "API"';
+
+    # Replace attachment value for 'API' in Header col
+    ($ret, $msg) = $attachments->ReplaceAttachments(Search => 'API', Replacement => 'replacement', Content => 0);
+    ok $ret, $msg;
+    $attachments->CleanSlate;
+
+    $attachments->Limit(
+        FIELD           => 'Headers',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'API',
+    );
+    is $attachments->Count, 0, 'Found no header with content "API"';
+    $attachments->CleanSlate;
+
+    $attachments->Limit(
+        FIELD           => 'Headers',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'replacement',
+    );
+    is $attachments->Count, 1, 'Found header with content "replacement"';
+
+    ($ret, $msg) = $attachments->ReplaceAttachments(Search => 'new_value', Replacement => 'replacement', Content => 0);
+    ok $ret, $msg;
+
+    $attachments->CleanSlate;
+    $attachments->Limit(
+        FIELD           => 'Content',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'new_value',
+    );
+    is $attachments->Count, 1, 'Content is not changed when flagged as false';
+
+    ($ret, $msg) = $attachments->ReplaceAttachments(Search => 'replacement', Replacement => 'new_value', Headers => 0);
+    ok $ret, $msg;
+
+    $attachments->CleanSlate;
+    $attachments->Limit(
+        FIELD           => 'Headers',
+        OPERATOR        => 'LIKE',
+        VALUE           => 'replacement',
+    );
+    is $attachments->Count, 1, 'Headers are not replaced when flagged as false';
+}
+
+done_testing();

commit 32518488beccba90a4f35e9656dc860bd97929f9
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Dec 12 00:05:30 2018 +0800

    Fix rt-dump-metadata for the AppliedTo => AddedTo change

diff --git a/sbin/rt-dump-metadata.in b/sbin/rt-dump-metadata.in
index 3e19143923..7d4f07b091 100644
--- a/sbin/rt-dump-metadata.in
+++ b/sbin/rt-dump-metadata.in
@@ -184,7 +184,7 @@ OBJECT:
                     # as the sub InsertData in RT::Handle creates a global CF
                     # when no queue is specified.
                     $rv->{Queue} = [];
-                    my $applies = $obj->AppliedTo;
+                    my $applies = $obj->AddedTo;
                     while ( my $queue = $applies->Next ) {
                         push @{ $rv->{Queue} }, $queue->Name;
                     }

commit 55f253644bdd82b03e9d20eb28e4ae95390622d6
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Dec 21 16:24:56 2018 -0500

    Rename RelatedData to BasicUserData

diff --git a/share/html/User/BasicUserData.tsv b/share/html/User/BasicUserData.tsv
new file mode 100644
index 0000000000..25804686dd
--- /dev/null
+++ b/share/html/User/BasicUserData.tsv
@@ -0,0 +1,85 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$PreserveNewLines => 0
+$Type             => 'User'
+$Format           => undef
+$id
+</%ARGS>
+
+<%INIT>
+# Abort unless supported type for export found
+Abort('Incorrect value passed for Type') unless
+    $Type && ( $Type eq 'User' || $Type eq 'Transaction');
+
+if ( $session{'CurrentUser'}->id ne $id ) {
+    Abort('User does not have the right to view other users') unless
+        $session{'CurrentUser'}->UserObj->HasRight( Object => $RT::System, Right =>'AdminUsers');
+}
+
+my $Collection;
+
+if ( $Type eq 'User' ) {
+    $Format = RT->Config->Get('UserDataResultFormat') unless $Format;
+
+    $Collection = RT::Users->new( $session{'CurrentUser'} );
+    $Collection->Limit( FIELD => 'id', VALUE => $id );
+
+} elsif ( $Type eq 'Transaction' ) {
+    $Format = RT->Config->Get('UserTransactionDataResultFormat') unless $Format;
+
+    $Collection = RT::Transactions->new( $session{'CurrentUser'} );
+    $Collection->Limit( FIELD => 'ObjectType', VALUE => 'RT::Ticket' );
+    $Collection->Limit( FIELD => 'Creator',    VALUE => $id );
+    $Collection->Limit( FIELD => 'Type',       VALUE => 'Create' );
+    $Collection->Limit( FIELD => 'Type',       VALUE => 'Correspond' );
+    $Collection->Limit( FIELD => 'Type',       VALUE => 'Comment' );
+}
+
+$m->comp( "/Elements/TSVExport", Collection => $Collection, Format => $Format, PreserveNewLines => $PreserveNewLines );
+</%INIT>
diff --git a/share/html/User/Elements/BasicUserData b/share/html/User/Elements/BasicUserData
new file mode 100644
index 0000000000..bc824f5307
--- /dev/null
+++ b/share/html/User/Elements/BasicUserData
@@ -0,0 +1,75 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<&|/Widgets/TitleBox,
+    class => 'user-related-info',
+    title => loc("User related info"),
+&>
+
+<table>
+    <tr><td>
+        <a href="/User/BasicUserData.tsv?Type=User&id=<% $UserObj->id %>" class="button"><% $UserDataButton %></a>
+    </td></tr>
+    <tr><td>
+    <a href="/Search/Results.tsv?Query=Requestor.id=<% $UserObj->id %>&Format=<% $Format | un %>" class="button"><% $UserTicketsButton %></a>
+    </td></tr>
+    <tr><td>
+    <a href="/User/BasicUserData.tsv?Type=Transaction&id=<% $UserObj->id %>" class="button"><% $UserTxnButton %></a>
+    </td></tr>
+</table>
+</&>
+
+<%INIT>
+my $Format = RT->Config->Get('UserTicketDataResultFormat') || RT->Config->Get('DefaultSearchResultFormat');
+</%INIT>
+
+<%ARGS>
+$UserObj
+$UserDataButton    => loc( 'Download User Data' )
+$UserTicketsButton => loc( 'Download User Tickets' )
+$UserTxnButton     => loc( 'Download User Transaction Data' )
+</%ARGS>

commit 7c348191a61d5ba06be763f1689952973c7ea4ec
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Fri Dec 21 16:24:09 2018 -0500

    Move User related info portlet into side column

diff --git a/share/html/Admin/Users/Modify.html b/share/html/Admin/Users/Modify.html
index d5e331c294..f51bfa4b83 100644
--- a/share/html/Admin/Users/Modify.html
+++ b/share/html/Admin/Users/Modify.html
@@ -125,6 +125,13 @@
 
 <& /Elements/EditCustomFields, Object => $UserObj, Grouping => 'Access control' &>
 
+</&>
+<&| /Widgets/TitleBox, title => loc('Comments about this user'), class => 'user-info-comments' &>
+<table width="100%" border="0">
+    <tr><td valign="top" class="boxcontainer">
+        <textarea class="comments" name="Comments" cols="80" rows="5" wrap="virtual"><%$UserObj->Comments//$ARGS{Comments}//''%></textarea>
+    </td></tr>
+</table>
 </&>
 % $m->callback( %ARGS, CallbackName => 'LeftColumnBottom', UserObj => $UserObj );
 </td>
@@ -203,6 +210,7 @@
 
 </table>
 </&>
+<& /User/Elements/BasicUserData, UserObj => $UserObj &>
 <br />
 
 <& /Elements/EditCustomFieldCustomGroupings, Object => $UserObj &>
@@ -211,9 +219,6 @@
 </td></tr>
 <tr>
 <td colspan="2">
-<&| /Widgets/TitleBox, title => loc('Comments about this user'), class => 'user-info-comments' &>
-<textarea class="comments" name="Comments" cols="80" rows="5" wrap="virtual"><%$UserObj->Comments//$ARGS{Comments}//''%></textarea>
-</&>
 %if (!$Create && $UserObj->Privileged) {
 <br />
 <&| /Widgets/TitleBox, title => loc('Signature'), class => 'user-info-signature' &>
@@ -232,7 +237,6 @@
 % }
 </form>
 
-<& /User/Elements/RelatedData, UserObj => $UserObj &>
 <%INIT>
 
 my $UserObj = RT::User->new($session{'CurrentUser'});
diff --git a/share/html/Prefs/AboutMe.html b/share/html/Prefs/AboutMe.html
index e2e046711f..df90996e8b 100644
--- a/share/html/Prefs/AboutMe.html
+++ b/share/html/Prefs/AboutMe.html
@@ -56,13 +56,6 @@
 
 </form>
 
-<& /User/Elements/RelatedData,
-    UserObj           => $UserObj,
-    UserDataButton    => loc( 'Download My Data' ),
-    UserTicketsButton => loc( 'Download My Tickets' ),
-    UserTxnButton     => loc( 'Download My Transaction Data' ),
-&>
-
 <%INIT>
 
 my $UserObj = RT::User->new( $session{'CurrentUser'} );
diff --git a/share/html/SelfService/Prefs.html b/share/html/SelfService/Prefs.html
index 19644c9818..3105f5e32f 100644
--- a/share/html/SelfService/Prefs.html
+++ b/share/html/SelfService/Prefs.html
@@ -94,7 +94,7 @@
 % }
 
 % if( RT->Config->Get('SelfServiceDownloadUserData') ) {
-<& /SelfService/User/Elements/RelatedData, UserObj => $user &>
+<& /SelfService/User/Elements/BasicUserData, UserObj => $user &>
 % }
 
 <%INIT>
diff --git a/share/html/SelfService/User/Elements/RelatedData b/share/html/SelfService/User/Elements/RelatedData
index 4ad2469cda..4e9087176d 100644
--- a/share/html/SelfService/User/Elements/RelatedData
+++ b/share/html/SelfService/User/Elements/RelatedData
@@ -51,9 +51,9 @@
 &>
 
 <div>
-    <a href="/SelfService/User/RelatedData.tsv?Type=User&id=<% $UserObj->id %>" class="button"><% $UserDataButton %></a>
+    <a href="/SelfService/User/BasicUserData.tsv?Type=User&id=<% $UserObj->id %>" class="button"><% $UserDataButton %></a>
     <a href="/SelfService/Search/Results.tsv?Query=Requestor.id=<% $UserObj->id %>&Format=<% $Format | un %>" class="button"><% $UserTicketsButton %></a>
-    <a href="/SelfService/User/RelatedData.tsv?Type=Transaction&id=<% $UserObj->id %>" class="button"><% $UserTxnButton %></a>
+    <a href="/SelfService/User/BasicUserData.tsv?Type=Transaction&id=<% $UserObj->id %>" class="button"><% $UserTxnButton %></a>
 </div>
 </&>
 
diff --git a/share/html/SelfService/User/RelatedData.tsv b/share/html/SelfService/User/RelatedData.tsv
index e0222e11c4..bb61d6ff5b 100644
--- a/share/html/SelfService/User/RelatedData.tsv
+++ b/share/html/SelfService/User/RelatedData.tsv
@@ -46,5 +46,5 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <%INIT>
-$m->comp('/User/RelatedData.tsv', %ARGS);
+$m->comp('/User/BasicUserData.tsv', %ARGS);
 </%INIT>
diff --git a/share/html/User/Elements/RelatedData b/share/html/User/Elements/RelatedData
deleted file mode 100644
index 051112d969..0000000000
--- a/share/html/User/Elements/RelatedData
+++ /dev/null
@@ -1,69 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<&|/Widgets/TitleBox,
-    class => 'user-related-info',
-    title => loc("User related info"),
-&>
-
-<div>
-    <a href="/User/RelatedData.tsv?Type=User&id=<% $UserObj->id %>" class="button"><% $UserDataButton %></a>
-    <a href="/Search/Results.tsv?Query=Requestor.id=<% $UserObj->id %>&Format=<% $Format | un %>" class="button"><% $UserTicketsButton %></a>
-    <a href="/User/RelatedData.tsv?Type=Transaction&id=<% $UserObj->id %>" class="button"><% $UserTxnButton %></a>
-</div>
-</&>
-
-<%INIT>
-my $Format = RT->Config->Get('UserTicketDataResultFormat') || RT->Config->Get('DefaultSearchResultFormat');
-</%INIT>
-
-<%ARGS>
-$UserObj
-$UserDataButton    => loc( 'Download User Data' )
-$UserTicketsButton => loc( 'Download User Tickets' )
-$UserTxnButton     => loc( 'Download User Transaction Data' )
-</%ARGS>
diff --git a/share/html/User/RelatedData.tsv b/share/html/User/RelatedData.tsv
deleted file mode 100644
index 25804686dd..0000000000
--- a/share/html/User/RelatedData.tsv
+++ /dev/null
@@ -1,85 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
-%#                                          <sales at bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-<%ARGS>
-$PreserveNewLines => 0
-$Type             => 'User'
-$Format           => undef
-$id
-</%ARGS>
-
-<%INIT>
-# Abort unless supported type for export found
-Abort('Incorrect value passed for Type') unless
-    $Type && ( $Type eq 'User' || $Type eq 'Transaction');
-
-if ( $session{'CurrentUser'}->id ne $id ) {
-    Abort('User does not have the right to view other users') unless
-        $session{'CurrentUser'}->UserObj->HasRight( Object => $RT::System, Right =>'AdminUsers');
-}
-
-my $Collection;
-
-if ( $Type eq 'User' ) {
-    $Format = RT->Config->Get('UserDataResultFormat') unless $Format;
-
-    $Collection = RT::Users->new( $session{'CurrentUser'} );
-    $Collection->Limit( FIELD => 'id', VALUE => $id );
-
-} elsif ( $Type eq 'Transaction' ) {
-    $Format = RT->Config->Get('UserTransactionDataResultFormat') unless $Format;
-
-    $Collection = RT::Transactions->new( $session{'CurrentUser'} );
-    $Collection->Limit( FIELD => 'ObjectType', VALUE => 'RT::Ticket' );
-    $Collection->Limit( FIELD => 'Creator',    VALUE => $id );
-    $Collection->Limit( FIELD => 'Type',       VALUE => 'Create' );
-    $Collection->Limit( FIELD => 'Type',       VALUE => 'Correspond' );
-    $Collection->Limit( FIELD => 'Type',       VALUE => 'Comment' );
-}
-
-$m->comp( "/Elements/TSVExport", Collection => $Collection, Format => $Format, PreserveNewLines => $PreserveNewLines );
-</%INIT>

commit b7037169399317ce7c68f1cfa84181142f0434bd
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Thu Dec 13 09:47:30 2018 -0500

    Create method AnonymizeUser in User.pm
    
    Create method 'AnonymizeUser' that will remove the personal
    identifying information from a user record, but keep the record alive.

diff --git a/lib/RT/User.pm b/lib/RT/User.pm
index daae994e75..f0f59b6d33 100644
--- a/lib/RT/User.pm
+++ b/lib/RT/User.pm
@@ -281,6 +281,95 @@ sub ValidateName {
     }
 }
 
+=head2 GenerateAnonymousName INT
+
+Generate a random username proceeded by 'anon_' and then a
+random string, Returns the AnonymousName string. The length of the
+random string can be set by providing an integer for character length.
+
+=cut
+
+sub GenerateAnonymousName {
+    my $self = shift;
+    my $length = shift;
+
+    my $valid = 0;
+    my $name  = '';
+
+    while ( not $valid ) {
+        my @Chars = ('a'..'z', 'A'..'Z', '0'..'9');
+        for (1..$length || 9) {
+            $name .= $Chars[int rand @Chars];
+        }
+        $valid = $self->ValidateName('anon_' . $name);
+    }
+    return 'anon_' . $name;
+}
+
+=head2 AnonymizeUser { clear_customfields }
+
+Remove all personal identifying information on the user record, but keep
+the user record alive. Additionally replace the username with an anonymous name.
+Submit clear_customfields in a paramhash, if true all customfield values
+applied to the user record will be cleared.
+
+=cut
+
+sub AnonymizeUser {
+    my $self = shift;
+    my %args = (
+        ClearCustomFields  => undef,
+        @_,
+    );
+
+    my @core_accessable = $self->_CoreAccessible();
+    my @tmp;
+    map{ push @tmp, $_ if $self->_Accessible($_, 'write') } keys %{$core_accessable[0] };
+
+    my %skip_clear = ( Name => '1', Password => '1', AuthToken => '1' );
+    my @user_identifying_info = grep { $skip_clear{$_} ? 0 : 1 } @tmp;
+
+    $RT::Handle->BeginTransaction();
+    # Remove identifying user information from record
+    foreach my $attr (@user_identifying_info) {
+        if ( defined $self->$attr && length $self->$attr) {
+                my $method = 'Set' . $attr;
+                my ($ret, $msg) = $self->$method('');
+                RT::Logger->error("Failed to clear user value for: $attr ".$msg) unless $ret;
+                $RT::Handle->Rollback() unless $ret;
+                return ($ret, "Failed to clear user value for: $attr " . $msg) unless $ret;
+        }
+    }
+
+    # Do not do anything if password is already unset
+    if ( $self->HasPassword ) {
+        my ($ret, $msg) = $self->_Set(Field => 'Password', Value => '*NO-PASSWORD*' );
+        RT::Logger->error("Failed to set password to '*NO-PASSWORD*' ".$msg) unless $ret;
+    }
+
+    # Generate the random anon username
+    my ($ret, $msg) = $self->SetName($self->GenerateAnonymousName);
+    RT::Logger->error("Failed to set name to generaterated anonymous name".$msg) unless $ret;
+
+    # Remove user customfield values
+    if ( $args{'ClearCustomFields'} ) {
+        my $customfields = RT::CustomFields->new(RT->SystemUser);
+        $customfields->LimitToLookupType('RT::User');
+
+        while (my $customfield = $customfields->Next) {
+            while ( $self->FirstCustomFieldValue( $customfield->Name ) ) {
+                ($ret, $msg) = $self->DeleteCustomFieldValue( Field => $customfield->Id, Value =>  $self->FirstCustomFieldValue( $customfield->Name ) );
+                RT::Logger->error($msg) unless $ret;
+                $RT::Handle->Rollback() unless $ret;
+                return ($ret, "Failed to delete custom field value " . $msg) unless $ret;
+            }
+        }
+    }
+    $RT::Handle->Commit();
+
+    return(1, $self->loc('User successfully anonymized'));
+}
+
 =head2 ValidatePassword STRING
 
 Returns either (0, "failure reason") or 1 depending on whether the given

commit 45571ce765ffdad55816f8ae8717dfeedcbfd932
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Mon Dec 17 12:10:20 2018 -0500

    Add Timezone to user column map

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 17d912e698..6ab8463bbb 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -1403,7 +1403,7 @@ Set($UserDataResultFormat, "'__id__', '__Name__', '__EmailAddress__', '__RealNam
                             '__NickName__', '__Organization__', '__HomePhone__', '__WorkPhone__',\
                             '__MobilePhone__', '__PagerPhone__', '__Address1__', '__Address2__',\
                             '__City__', '__State__','__Zip__', '__Country__', '__Gecos__', '__Lang__',\
-                            '__FreeFormContactInfo__'");
+                            '__Timezone__', '__FreeFormContactInfo__'");
 
 =item C<$UserTransactionDataResultFormat>
 
diff --git a/share/html/Elements/RT__User/ColumnMap b/share/html/Elements/RT__User/ColumnMap
index b4746e39b4..1e6d268c1b 100644
--- a/share/html/Elements/RT__User/ColumnMap
+++ b/share/html/Elements/RT__User/ColumnMap
@@ -146,6 +146,11 @@ my $COLUMN_MAP = {
         title     => 'Status', # loc
         value     => sub { return $_[0]->Disabled? $_[0]->loc('Disabled'): $_[0]->loc('Enabled') },
     },
+    Timezone => {
+        title     => 'Timezone', # loc
+        attribute => 'Timezone',
+        value     => sub { return $_[0]->Timezone },
+    },
 };
 
 </%ONCE>

commit d5c949bd9e9845c00beb2ceb050c7104ee4ead6a
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Tue Dec 18 08:28:49 2018 -0500

    Update download user data default format

diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
index 6ab8463bbb..4acb183916 100644
--- a/etc/RT_Config.pm.in
+++ b/etc/RT_Config.pm.in
@@ -1411,10 +1411,9 @@ This is the format of the user transaction search result for "Download User Tran
 
 =cut
 
-Set($UserTransactionDataResultFormat, "'__ObjectId__', '__id__', '__Created__', '__Description__',\
+Set($UserTransactionDataResultFormat, "'__ObjectId__/TITLE:Ticket Id', '__id__', '__Created__', '__Description__',\
                                         '__OldValue__', '__NewValue__', '__Content__'");
 
-
 =item C<$DefaultSearchResultOrderBy>
 
 What Tickets column should we order by for RT Ticket search results.

commit feb6fc4c5aa1294f9f53b812be6dd37f88060ae6
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Thu Dec 13 09:49:00 2018 -0500

    Create modal mason component

diff --git a/share/html/Elements/Modal b/share/html/Elements/Modal
new file mode 100644
index 0000000000..ed81cb7efd
--- /dev/null
+++ b/share/html/Elements/Modal
@@ -0,0 +1,71 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2018 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work is distributed in the hope that it will be useful, but
+%# WITHOUT ANY WARRANTY; without even the implied warranty of
+%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+%# General Public License for more details.
+%#
+%# You should have received a copy of the GNU General Public License
+%# along with this program; if not, write to the Free Software
+%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+%# 02110-1301 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<div id="<% $ModalId %>" class="<% $Class %>" align="center">
+    <form action="<% $Action %>" method="<% $Method %>" id="<% $ModalId %>" name="<% $Name %>" >
+% foreach my $field (@{$Fields}) {
+        <p><% $field->{'Label'} %>
+%   if ( $field->{'Input'} ) {
+        <input type="<% $field->{'Input'} %>" class="<% $field->{'Class'} %>" name="<% $field->{'Name'} %>" value="<% $field->{'Value'} %>">
+%   }
+        </p>
+% }
+        <a href="#" rel="modal:close" class="button"><&|/l, $Cancel &>[_1]</&></a>
+        <button type="Submit" class="button"><&|/l, $Accept &>[_1]</&></button>
+    </form>
+</div>
+
+<%ARGS>
+$Fields   => undef
+$Name     => undef
+$ModalId  => undef
+$Class    => 'modal'
+$Action   => undef
+$Method   => 'GET'
+$Accept   => 'Ok'
+$Cancel   => 'Cancel'
+</%ARGS>

commit c21aa2379a3909e673a44f938e800f69357dac53
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Thu Dec 13 09:50:15 2018 -0500

    Create portlet for removing user information
    
    Mason template that has three buttons for removing user information. The
    buttons are 'Anonymize user', 'Replace User' and 'Remove User'.
    Anonymize user will call the 'AnonymizeUser' method to clear identifying
    information from the user record. 'Replace User' and 'Remove User' will
    link to the shredder page with a pre formatted search.

diff --git a/share/html/User/BasicUserData.tsv b/share/html/User/BasicUserData.tsv
index 25804686dd..d10ff95a8c 100644
--- a/share/html/User/BasicUserData.tsv
+++ b/share/html/User/BasicUserData.tsv
@@ -81,5 +81,7 @@ if ( $Type eq 'User' ) {
     $Collection->Limit( FIELD => 'Type',       VALUE => 'Comment' );
 }
 
-$m->comp( "/Elements/TSVExport", Collection => $Collection, Format => $Format, PreserveNewLines => $PreserveNewLines );
+$m->comp( "/Elements/TSVExport", Collection => $Collection, Format => $Format,
+    PreserveNewLines => $PreserveNewLines
+);
 </%INIT>
diff --git a/share/html/User/Elements/BasicUserData b/share/html/User/Elements/BasicUserData
index bc824f5307..b3a2398301 100644
--- a/share/html/User/Elements/BasicUserData
+++ b/share/html/User/Elements/BasicUserData
@@ -47,20 +47,32 @@
 %# END BPS TAGGED BLOCK }}}
 <&|/Widgets/TitleBox,
     class => 'user-related-info',
-    title => loc("User related info"),
+    title => loc("Manage User Data"),
 &>
 
-<table>
-    <tr><td>
-        <a href="/User/BasicUserData.tsv?Type=User&id=<% $UserObj->id %>" class="button"><% $UserDataButton %></a>
-    </td></tr>
-    <tr><td>
-    <a href="/Search/Results.tsv?Query=Requestor.id=<% $UserObj->id %>&Format=<% $Format | un %>" class="button"><% $UserTicketsButton %></a>
-    </td></tr>
-    <tr><td>
-    <a href="/User/BasicUserData.tsv?Type=Transaction&id=<% $UserObj->id %>" class="button"><% $UserTxnButton %></a>
-    </td></tr>
-</table>
+<div id="manage-user-data">
+    <div name="download-user-data-title"><b>Download User Information:</b></div>
+    <div name="download-user-data-buttons" class="inline-row">
+        <div class="inline-cell"><a class="button" href="/User/BasicUserData.tsv?Type=User&id=<% $UserObj->id %>"><% $UserDataButton %></a></div>
+        <div class="inline-cell"><a class="button" href="/Search/Results.tsv?Query=Requestor.id=<% $UserObj->id %>&Format=<% $Format | un %>"><% $UserTicketsButton %></a></div>
+        <div class="inline-cell"><a class="button" href="/User/BasicUserData.tsv?Type=Transaction&id=<% $UserObj->id %>"><% $UserTxnButton %></a></div>
+    </div>
+    <div name="remove-user-data-title"><b>Remove User Information:</b></div>
+    <div name="remove-user-data-buttons" class="inline-row">
+        <div class="inline-cell">
+            <a class="button" href="#user-info-modal" rel="modal:open" name="anonymize_user"><&|/l&>Anonymize User</&></a>
+            <i class="label"><&|/l&>Remove user information with anonymous username</&></i>
+        </div>
+        <div class="inline-cell">
+            <a class="button" href="<%RT->Config->Get('WebPath')%>/Admin/Tools/Shredder/index.html?Plugin=Users&Users%3Astatus=enabled&Users%3Aname=<% $UserObj->Name %>&Users%3Areplace_relations=Nobody&Search=Search" name="replace-user"><&|/l&>Replace User</&></a>
+            <i class="label"><&|/l&>Replace user links in the database with "Nobody" user</&></i>
+        </div>
+        <div class="inline-cell">
+            <a class="button" href="<%RT->Config->Get('WebPath')%>/Admin/Tools/Shredder/index.html?Plugin=Users&Users%3Astatus=enabled&Users%3Aname=<% $UserObj->Name %>&Search=Search&remove_user" name="remove-user"><&|/l&>Remove User</&></a>
+            <i class="label"><&|/l&>Remove all references to user and links to user</&></i>
+        </div>
+    </div>
+</div>
 </&>
 
 <%INIT>
@@ -69,7 +81,7 @@ my $Format = RT->Config->Get('UserTicketDataResultFormat') || RT->Config->Get('D
 
 <%ARGS>
 $UserObj
-$UserDataButton    => loc( 'Download User Data' )
-$UserTicketsButton => loc( 'Download User Tickets' )
-$UserTxnButton     => loc( 'Download User Transaction Data' )
+$UserDataButton    => loc( 'User Data' )
+$UserTicketsButton => loc( 'User Tickets' )
+$UserTxnButton     => loc( 'User Transactions' )
 </%ARGS>
diff --git a/share/static/css/base/misc.css b/share/static/css/base/misc.css
index 25ec20c699..51ffe431ec 100644
--- a/share/static/css/base/misc.css
+++ b/share/static/css/base/misc.css
@@ -127,3 +127,24 @@ td.current-recipients {
     vertical-align: top;
     padding-left: 50px;
 }
+
+.inline-row {
+    margin-bottom: 10px;
+    display: inline-flex;
+    width: 100%;
+}
+
+div.inline-row div {
+    max-width: 175px;
+    display: block;
+}
+
+div.inline-row a {
+    text-align: center;
+    width: 85%;
+}
+
+div.inline-row i {
+    text-align: left;
+    width: 85%;
+}

commit 2fa200701bae519d92bab3f9e109c6cf9ce84f48
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Thu Dec 13 11:33:06 2018 -0500

    Allow TSVExport filename to be set through Filename arg

diff --git a/share/html/Elements/TSVExport b/share/html/Elements/TSVExport
index df7105f794..3f6d5d8136 100644
--- a/share/html/Elements/TSVExport
+++ b/share/html/Elements/TSVExport
@@ -50,6 +50,7 @@ $Class => undef
 $Collection
 $Format
 $PreserveNewLines => 0
+$Filename  => undef
 </%ARGS>
 <%ONCE>
 my $no_html = HTML::Scrubber->new( deny => '*' );
@@ -59,6 +60,7 @@ require HTML::Entities;
 $Class ||= $Collection->ColumnMapClassName;
 
 $r->content_type('application/vnd.ms-excel');
+$r->header_out( 'Content-disposition' => "attachment; filename=$Filename" ) if $Filename;
 
 my $DisplayFormat = $m->comp('/Elements/ScrubHTML', Content => $Format);
 
diff --git a/share/html/User/BasicUserData.tsv b/share/html/User/BasicUserData.tsv
index d10ff95a8c..016912a5e7 100644
--- a/share/html/User/BasicUserData.tsv
+++ b/share/html/User/BasicUserData.tsv
@@ -50,6 +50,7 @@ $PreserveNewLines => 0
 $Type             => 'User'
 $Format           => undef
 $id
+$Filename         => undef
 </%ARGS>
 
 <%INIT>
@@ -82,6 +83,6 @@ if ( $Type eq 'User' ) {
 }
 
 $m->comp( "/Elements/TSVExport", Collection => $Collection, Format => $Format,
-    PreserveNewLines => $PreserveNewLines
+    PreserveNewLines => $PreserveNewLines, Filename => $Filename,
 );
 </%INIT>
diff --git a/share/html/User/Elements/BasicUserData b/share/html/User/Elements/BasicUserData
index b3a2398301..f533f4064e 100644
--- a/share/html/User/Elements/BasicUserData
+++ b/share/html/User/Elements/BasicUserData
@@ -53,9 +53,9 @@
 <div id="manage-user-data">
     <div name="download-user-data-title"><b>Download User Information:</b></div>
     <div name="download-user-data-buttons" class="inline-row">
-        <div class="inline-cell"><a class="button" href="/User/BasicUserData.tsv?Type=User&id=<% $UserObj->id %>"><% $UserDataButton %></a></div>
-        <div class="inline-cell"><a class="button" href="/Search/Results.tsv?Query=Requestor.id=<% $UserObj->id %>&Format=<% $Format | un %>"><% $UserTicketsButton %></a></div>
-        <div class="inline-cell"><a class="button" href="/User/BasicUserData.tsv?Type=Transaction&id=<% $UserObj->id %>"><% $UserTxnButton %></a></div>
+        <div class="inline-cell"><a class="button" href="/User/BasicUserData.tsv?Type=User&id=<% $UserObj->id %>&Filename=UserData.tsv"><% $UserDataButton %></a></div>
+        <div class="inline-cell"><a class="button" href="/Search/Results.tsv?Query=Requestor.id=<% $UserObj->id %>&Format=<% $Format | un %>&Filename=UserTicketData.tsv"><% $UserTicketsButton %></a></div>
+        <div class="inline-cell"><a class="button" href="/User/BasicUserData.tsv?Type=Transaction&id=<% $UserObj->id %>&Filename=UserTransactionData.tsv"><% $UserTxnButton %></a></div>
     </div>
     <div name="remove-user-data-title"><b>Remove User Information:</b></div>
     <div name="remove-user-data-buttons" class="inline-row">

commit 932a9c87cafc47a44369e53b014145cd0751d465
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Thu Dec 13 09:51:34 2018 -0500

    Add remove user info portlet to user modify page

diff --git a/share/html/Admin/Users/Modify.html b/share/html/Admin/Users/Modify.html
index f51bfa4b83..7f51e89dca 100644
--- a/share/html/Admin/Users/Modify.html
+++ b/share/html/Admin/Users/Modify.html
@@ -210,7 +210,9 @@
 
 </table>
 </&>
+% if ( !$Create ) {
 <& /User/Elements/BasicUserData, UserObj => $UserObj &>
+% }
 <br />
 
 <& /Elements/EditCustomFieldCustomGroupings, Object => $UserObj &>
@@ -237,6 +239,21 @@
 % }
 </form>
 
+% if ( $UserObj->Id ) {
+    <& /Elements/Modal, ModalId => "user-info-modal", Method => 'POST', Action => RT->Config->Get('WebPath') . '/Admin/Users/Modify.html', Fields => [
+    { Label   => loc("Are you sure you want to anonymize user") . ": " . $UserObj->Name . "?" },
+    { Input => 'Hidden', Value => $UserObj->Id, Name => 'id' },
+    { Input => 'Hidden', Value => 1, Name => 'Anonymize' },
+    {
+        Label    => loc("Check to clear user custom fields") . ":",
+        Input    => 'checkbox',
+        Class    => 'checkbox',
+        Name     => 'clear_customfields',
+        Value    => 'On',
+    },
+]
+&>
+% }
 <%INIT>
 
 my $UserObj = RT::User->new($session{'CurrentUser'});
@@ -300,6 +317,11 @@ if ($Create) {
     }
 }
 
+if ( $ARGS{'Anonymize'} and $UserObj->Id ) {
+    my ($ret, $msg) = $UserObj->AnonymizeUser(ClearCustomFields => $ARGS{'clear_customfields'});
+    push @results, $msg;
+}
+
 if ( $UserObj->Id ) {
     # Deal with Password field
     my ($status, $msg) = $UserObj->SafeSetPassword(

commit ba602445524c04a831eabf28e44ab37dd705a8cb
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Thu Dec 13 09:52:42 2018 -0500

    Create test for remove user information

diff --git a/t/web/remove_user_info.t b/t/web/remove_user_info.t
new file mode 100644
index 0000000000..3b5196284d
--- /dev/null
+++ b/t/web/remove_user_info.t
@@ -0,0 +1,163 @@
+use strict;
+use warnings;
+
+use RT::Test tests => undef;
+
+RT::Config->Set( 'ShredderStoragePath', RT::Test->temp_directory . '' );
+
+my ( $baseurl, $agent ) = RT::Test->started_ok;
+
+diag("Test server running at $baseurl");
+my $url = $agent->rt_base_url;
+
+# Login
+$agent->login( 'root' => 'password' );
+
+# Anonymize User
+{
+    my $user = RT::Test->load_or_create_user(
+        Name         => 'Test User',
+        EmailAddress => 'test at example.com',
+    );
+    ok( $user && $user->id );
+
+    my $user_id = $user->id;
+
+    $agent->get_ok( $url . "Admin/Users/Modify.html?id=" . $user_id );
+    $agent->follow_link_ok( { text => 'Anonymize User' } );
+
+    $agent->submit_form_ok( { form_id => 'user-info-modal', },
+        "Anonymize user" );
+
+    $user->Load($user_id);
+    is( $user->EmailAddress, '', 'User Email removed' );
+
+    # UserId is still the same, but all other records should be anonimyzed for TestUser
+    my ( $ret, $msg ) = $user->Load($user_id);
+    ok($ret);
+
+    is( $user->Name =~ /anon_/, 1, 'Username replaced with anon name' );
+
+    my @core_accessable = $user->_CoreAccessible();
+    my @tmp;
+    map{ push @tmp, $_ if $user->_Accessible($_, 'write') } keys %{$core_accessable[0] };
+
+    my %skip_clear = ( Name => '1', Password => '1', AuthToken => '1' );
+    my @user_identifying_info = grep { $skip_clear{$_} ? 0 : 1 } @tmp;
+
+    $user->Load($user_id);
+
+    # Ensure that all other user fields are blank
+    foreach my $attr (@user_identifying_info) {
+        my $check = grep { not defined $_ or $_ eq '' or $_ eq 0 } $user->$attr;
+        is $check, 1, 'Attribute ' . $attr . ' is blank';
+    }
+
+    # Test that customfield values are removed with anonymize user action
+    my $customfield = RT::CustomField->new( RT->SystemUser );
+    ( $ret, $msg ) = $customfield->Create(
+        Name       => 'TestCustomfield',
+        LookupType => 'RT::User',
+        Type       => 'FreeformSingle',
+    );
+    ok( $ret, $msg );
+
+    ( $ret, $msg ) = $customfield->AddToObject($user);
+    ok( $ret, "Added CF to user object - " . $msg );
+
+    ( $ret, $msg ) = $user->AddCustomFieldValue(
+        Field => 'TestCustomfield',
+        Value => 'Testing'
+    );
+    ok( $ret, $msg );
+
+    is( $user->FirstCustomFieldValue('TestCustomfield'), 'Testing',
+        'Customfield exists and has value for user.' );
+
+    $agent->get_ok( $url . "Admin/Users/Modify.html?id=" . $user->id );
+    $agent->follow_link_ok( { text => 'Anonymize User' } );
+
+    $agent->submit_form_ok(
+        {   form_id => 'user-info-modal',
+            fields  => { clear_customfields => 'On' },
+        },
+        "Anonymize user and customfields"
+    );
+
+    is( $user->FirstCustomFieldValue('TestCustomfield'), undef,
+        'Customfield value cleared' );
+}
+
+# Test replace user
+{
+    my $user = RT::Test->load_or_create_user(
+        Name       => 'user',
+        Password   => 'password',
+        Privileged => 1
+    );
+    ok( $user && $user->id );
+    my $id = $user->id;
+
+    ok( RT::Test->set_rights(
+            { Principal => $user, Right => [qw(SuperUser)] },
+        ),
+        'set rights'
+      );
+
+    ok( $agent->logout );
+    ok( $agent->login( 'root' => 'password' ) );
+
+    $agent->get_ok( $url . "Admin/Users/Modify.html?id=" . $user->id );
+    $agent->follow_link_ok( { text => 'Replace User' } );
+
+    $agent->submit_form_ok(
+        {   form_id => 'shredder-search-form',
+            fields  => { WipeoutObject => 'RT::User-' . $user->Name, },
+            button  => 'Wipeout'
+        },
+        "Replace user"
+    );
+
+    my ($ret, $msg) = $user->Load($id);
+
+    is( $ret, 0,
+        'User successfully deleted with replace' );
+}
+
+# Test Remove user
+{
+    my $user = RT::Test->load_or_create_user(
+        Name       => 'user',
+        Password   => 'password',
+        Privileged => 1
+    );
+    ok( $user && $user->id );
+    my $id = $user->Id;
+
+    ok( RT::Test->set_rights(
+            { Principal => $user, Right => [qw(SuperUser)] },
+        ),
+        'set rights'
+      );
+
+    $agent->logout;
+    $agent->login( 'root' => 'password' );
+
+    $agent->get_ok( $url . "Admin/Users/Modify.html?id=" . $user->id );
+    $agent->follow_link_ok( { text => 'Remove User' } );
+
+    $agent->submit_form_ok(
+        {   form_id => 'shredder-search-form',
+            fields  => { WipeoutObject => 'RT::User-' . $user->Name, },
+            button  => 'Wipeout'
+        },
+        "Remove user"
+    );
+
+    my ($ret, $msg) = $user->Load($id);
+
+    is( $ret, 0,
+        'User successfully deleted with remove' );
+}
+
+done_testing();

commit b0b94f3b19d599a148e0b331e11033cd75aea81c
Author: Craig Kaiser <craig at bestpractical.com>
Date:   Mon Dec 17 16:36:51 2018 -0500

    Update download user info tests
    
    Changes to how the column header for the TSV file means the
    tests need to be updated accordingly.

diff --git a/t/web/download_user_info.t b/t/web/download_user_info.t
index cc100d686e..44155d49a7 100644
--- a/t/web/download_user_info.t
+++ b/t/web/download_user_info.t
@@ -46,11 +46,11 @@ $agent->login( 'root' => 'password' );
 
     # TSV file for user record information
     $agent->get_ok( $url . "Admin/Users/Modify.html?id=" . $root->id );
-    $agent->follow_link_ok( { text => 'Download User Data' } );
+    $agent->follow_link_ok( { text => 'User Data' } );
 
     my $user_info_tsv = <<EOF;
-id\tName\tEmailAddress\tRealName\tNickName\tOrganization\tHomePhone\tWorkPhone\tMobilePhone\tPagerPhone\tAddress1\tAddress2\tCity\tState\tZip\tCountry\tGecos\tLang\tFreeFormContactInfo
-14\troot\troot\@localhost\tEnoch Root\t\t\t\t\t\t\t\t\t\t\t\t\troot\t\t
+id\tName\tEmailAddress\tRealName\tNickName\tOrganization\tHomePhone\tWorkPhone\tMobilePhone\tPagerPhone\tAddress1\tAddress2\tCity\tState\tZip\tCountry\tGecos\tLang\tTimezone\tFreeFormContactInfo
+14\troot\troot\@localhost\tEnoch Root\t\t\t\t\t\t\t\t\t\t\t\t\troot\t\t\t
 EOF
 
     is $agent->content, $user_info_tsv,
@@ -58,10 +58,10 @@ EOF
 
     # TSV file for Transactions
     $agent->get_ok( $url . "Admin/Users/Modify.html?id=" . $root->id );
-    $agent->follow_link_ok( { text => 'Download User Transaction Data' } );
+    $agent->follow_link_ok( { text => 'User Transactions' } );
 
     my $transaction_info_tsv = <<EOF;
-ObjectId\tid\tCreated\tDescription\tOldValue\tNewValue\tContent
+Ticket Id\tid\tCreated\tDescription\tOldValue\tNewValue\tContent
 1\t30\t$date_created\tTicket created\t\t\tThis transaction appears to have no content
 1\t32\t$date_commented\tComments added\t\t\tTest - Comment
 1\t33\t$date_correspondence\tCorrespondence added\t\t\tTest - Reply
@@ -72,7 +72,7 @@ EOF
 
     # TSV file for user's Tickets
     $agent->get_ok( $url . "Admin/Users/Modify.html?id=" . $root->id );
-    $agent->follow_link_ok( { text => 'Download User Tickets' } );
+    $agent->follow_link_ok( { text => 'User Tickets' } );
 
     my $ticket_info_tsv = <<EOF;
 id\tSubject\tStatus\tQueueName\tOwner\tPriority\tRequestors

-----------------------------------------------------------------------


More information about the rt-commit mailing list