first pass RT4 merge, RT#13852
[freeside.git] / rt / lib / RT / Article.pm
diff --git a/rt/lib/RT/Article.pm b/rt/lib/RT/Article.pm
new file mode 100644 (file)
index 0000000..7310241
--- /dev/null
@@ -0,0 +1,870 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC
+#                                          <sales@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;
+
+package RT::Article;
+
+use base 'RT::Record';
+
+use RT::Articles;
+use RT::ObjectTopics;
+use RT::Classes;
+use RT::Links;
+use RT::CustomFields;
+use RT::URI::fsck_com_article;
+use RT::Transactions;
+
+
+sub Table {'Articles'}
+
+# This object takes custom fields
+
+use RT::CustomField;
+RT::CustomField->_ForObjectType( CustomFieldLookupType() => 'Articles' )
+  ;    #loc
+
+# {{{ Create
+
+=head2 Create PARAMHASH
+
+Create takes a hash of values and creates a row in the database:
+
+  varchar(200) 'Name'.
+  varchar(200) 'Summary'.
+  int(11) 'Content'.
+  Class ID  'Class'
+
+  A paramhash called  'CustomFields', which contains 
+  arrays of values for each custom field you want to fill in.
+  Arrays aRe ordered. 
+
+
+
+
+=cut
+
+sub Create {
+    my $self = shift;
+    my %args = (
+        Name         => '',
+        Summary      => '',
+        Class        => '0',
+        CustomFields => {},
+        Links        => {},
+        Topics       => [],
+        @_
+    );
+
+    my $class = RT::Class->new($RT::SystemUser);
+    $class->Load( $args{'Class'} );
+    unless ( $class->Id ) {
+        return ( 0, $self->loc('Invalid Class') );
+    }
+
+    unless ( $class->CurrentUserHasRight('CreateArticle') ) {
+        return ( 0, $self->loc("Permission Denied") );
+    }
+
+    return ( undef, $self->loc('Name in use') )
+      unless $self->ValidateName( $args{'Name'} );
+
+    $RT::Handle->BeginTransaction();
+    my ( $id, $msg ) = $self->SUPER::Create(
+        Name    => $args{'Name'},
+        Class   => $class->Id,
+        Summary => $args{'Summary'},
+    );
+    unless ($id) {
+        $RT::Handle->Rollback();
+        return ( undef, $msg );
+    }
+
+    # {{{ Add custom fields
+
+    foreach my $key ( keys %args ) {
+        next unless ( $key =~ /CustomField-(.*)$/ );
+        my $cf   = $1;
+        my @vals = ref( $args{$key} ) eq 'ARRAY' ? @{ $args{$key} } : ( $args{$key} );
+        foreach my $value (@vals) {
+
+            my ( $cfid, $cfmsg ) = $self->_AddCustomFieldValue(
+                (UNIVERSAL::isa( $value => 'HASH' )
+                    ? %$value
+                    : (Value => $value)
+                ),
+                Field             => $cf,
+                RecordTransaction => 0
+            );
+
+            unless ($cfid) {
+                $RT::Handle->Rollback();
+                return ( undef, $cfmsg );
+            }
+        }
+
+    }
+
+    # }}}
+    # {{{ Add topics
+
+    foreach my $topic ( @{ $args{Topics} } ) {
+        my ( $cfid, $cfmsg ) = $self->AddTopic( Topic => $topic );
+
+        unless ($cfid) {
+            $RT::Handle->Rollback();
+            return ( undef, $cfmsg );
+        }
+    }
+
+    # }}}
+    # {{{ Add relationships
+
+    foreach my $type ( keys %args ) {
+        next unless ( $type =~ /^(RefersTo-new|new-RefersTo)$/ );
+        my @vals =
+          ref( $args{$type} ) eq 'ARRAY' ? @{ $args{$type} } : ( $args{$type} );
+        foreach my $val (@vals) {
+            my ( $base, $target );
+            if ( $type =~ /^new-(.*)$/ ) {
+                $type   = $1;
+                $base   = undef;
+                $target = $val;
+            }
+            elsif ( $type =~ /^(.*)-new$/ ) {
+                $type   = $1;
+                $base   = $val;
+                $target = undef;
+            }
+
+            my ( $linkid, $linkmsg ) = $self->AddLink(
+                Type              => $type,
+                Target            => $target,
+                Base              => $base,
+                RecordTransaction => 0
+            );
+
+            unless ($linkid) {
+                $RT::Handle->Rollback();
+                return ( undef, $linkmsg );
+            }
+        }
+
+    }
+
+    # }}}
+
+    # We override the URI lookup. the whole reason
+    # we have a URI column is so that joins on the links table
+    # aren't expensive and stupid
+    $self->__Set( Field => 'URI', Value => $self->URI );
+
+    my ( $txn_id, $txn_msg, $txn ) = $self->_NewTransaction( Type => 'Create' );
+    unless ($txn_id) {
+        $RT::Handle->Rollback();
+        return ( undef, $self->loc( 'Internal error: [_1]', $txn_msg ) );
+    }
+    $RT::Handle->Commit();
+
+    return ( $id, $self->loc('Article [_1] created',$self->id ));
+}
+
+# }}}
+
+# {{{ ValidateName
+
+=head2 ValidateName NAME
+
+Takes a string name. Returns true if that name isn't in use by another article
+
+Empty names are permitted.
+
+
+=cut
+
+sub ValidateName {
+    my $self = shift;
+    my $name = shift;
+
+    if ( !$name ) {
+        return (1);
+    }
+
+    my $temp = RT::Article->new($RT::SystemUser);
+    $temp->LoadByCols( Name => $name );
+    if ( $temp->id && 
+         (!$self->id || ($temp->id != $self->id ))) {
+        return (undef);
+    }
+
+    return (1);
+
+}
+
+# }}}
+
+# {{{ Delete
+
+=head2 Delete
+
+Delete all its transactions
+Delete all its custom field values
+Delete all its relationships
+Delete this article.
+
+=cut
+
+sub Delete {
+    my $self = shift;
+    unless ( $self->CurrentUserHasRight('DeleteArticle') ) {
+        return ( 0, $self->loc("Permission Denied") );
+    }
+
+    $RT::Handle->BeginTransaction();
+    my $linksto   = $self->_Links(  'Target' );
+    my $linksfrom = $self->_Links(  'Base' );
+    my $cfvalues = $self->CustomFieldValues;
+    my $txns     = $self->Transactions;
+    my $topics   = $self->Topics;
+
+    while ( my $item = $linksto->Next ) {
+        my ( $val, $msg ) = $item->Delete();
+        unless ($val) {
+            $RT::Logger->crit( ref($item) . ": $msg" );
+            $RT::Handle->Rollback();
+            return ( 0, $self->loc('Internal Error') );
+        }
+    }
+
+    while ( my $item = $linksfrom->Next ) {
+        my ( $val, $msg ) = $item->Delete();
+        unless ($val) {
+            $RT::Logger->crit( ref($item) . ": $msg" );
+            $RT::Handle->Rollback();
+            return ( 0, $self->loc('Internal Error') );
+        }
+    }
+
+    while ( my $item = $txns->Next ) {
+        my ( $val, $msg ) = $item->Delete();
+        unless ($val) {
+            $RT::Logger->crit( ref($item) . ": $msg" );
+            $RT::Handle->Rollback();
+            return ( 0, $self->loc('Internal Error') );
+        }
+    }
+
+    while ( my $item = $cfvalues->Next ) {
+        my ( $val, $msg ) = $item->Delete();
+        unless ($val) {
+            $RT::Logger->crit( ref($item) . ": $msg" );
+            $RT::Handle->Rollback();
+            return ( 0, $self->loc('Internal Error') );
+        }
+    }
+
+    while ( my $item = $topics->Next ) {
+        my ( $val, $msg ) = $item->Delete();
+        unless ($val) {
+            $RT::Logger->crit( ref($item) . ": $msg" );
+            $RT::Handle->Rollback();
+            return ( 0, $self->loc('Internal Error') );
+        }
+    }
+
+    $self->SUPER::Delete();
+    $RT::Handle->Commit();
+    return ( 1, $self->loc('Article Deleted') );
+
+}
+
+# }}}
+
+# {{{ Children
+
+=head2 Children
+
+Returns an RT::Articles object which contains
+all articles which have this article as their parent.  This 
+routine will not recurse and will not find grandchildren, great-grandchildren, uncles, aunts, nephews or any other such thing.  
+
+=cut
+
+sub Children {
+    my $self = shift;
+    my $kids = RT::Articles->new( $self->CurrentUser );
+
+    unless ( $self->CurrentUserHasRight('ShowArticle') ) {
+        $kids->LimitToParent( $self->Id );
+    }
+    return ($kids);
+}
+
+# }}}
+
+# {{{ sub AddLink
+
+=head2 AddLink
+
+Takes a paramhash of Type and one of Base or Target. Adds that link to this tick
+et.
+
+=cut
+
+sub DeleteLink {
+    my $self = shift;
+    my %args = (
+        Target => '',
+        Base   => '',
+        Type   => '',
+        Silent => undef,
+        @_
+    );
+
+    unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
+        return ( 0, $self->loc("Permission Denied") );
+    }
+
+    $self->_DeleteLink(%args);
+}
+
+sub AddLink {
+    my $self = shift;
+    my %args = (
+        Target => '',
+        Base   => '',
+        Type   => '',
+        Silent => undef,
+        @_
+    );
+
+    unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
+        return ( 0, $self->loc("Permission Denied") );
+    }
+
+    # Disallow parsing of plain numbers in article links.  If they are
+    # allowed, they default to being tickets instead of articles, which
+    # is counterintuitive.
+    if (   $args{'Target'} && $args{'Target'} =~ /^\d+$/
+        || $args{'Base'} && $args{'Base'} =~ /^\d+$/ )
+    {
+        return ( 0, $self->loc("Cannot add link to plain number") );
+    }
+
+    # Check that we're actually getting a valid URI
+    my $uri_obj = RT::URI->new( $self->CurrentUser );
+    $uri_obj->FromURI( $args{'Target'}||$args{'Base'} );
+    unless ( $uri_obj->Resolver && $uri_obj->Scheme ) {
+        my $msg = $self->loc( "Couldn't resolve '[_1]' into a Link.", $args{'Target'} );
+        $RT::Logger->warning( $msg );
+        return( 0, $msg );
+    }
+
+
+    $self->_AddLink(%args);
+}
+
+sub URI {
+    my $self = shift;
+
+    unless ( $self->CurrentUserHasRight('ShowArticle') ) {
+        return $self->loc("Permission Denied");
+    }
+
+    my $uri = RT::URI::fsck_com_article->new( $self->CurrentUser );
+    return ( $uri->URIForObject($self) );
+}
+
+# }}}
+
+# {{{ sub URIObj
+
+=head2 URIObj
+
+Returns this article's URI
+
+
+=cut
+
+sub URIObj {
+    my $self = shift;
+    my $uri  = RT::URI->new( $self->CurrentUser );
+    if ( $self->CurrentUserHasRight('ShowArticle') ) {
+        $uri->FromObject($self);
+    }
+
+    return ($uri);
+}
+
+# }}}
+# }}}
+
+# {{{ Topics
+
+# {{{ Topics
+sub Topics {
+    my $self = shift;
+
+    my $topics = RT::ObjectTopics->new( $self->CurrentUser );
+    if ( $self->CurrentUserHasRight('ShowArticle') ) {
+        $topics->LimitToObject($self);
+    }
+    return $topics;
+}
+
+# }}}
+
+# {{{ AddTopic
+sub AddTopic {
+    my $self = shift;
+    my %args = (@_);
+
+    unless ( $self->CurrentUserHasRight('ModifyArticleTopics') ) {
+        return ( 0, $self->loc("Permission Denied") );
+    }
+
+    my $t = RT::ObjectTopic->new( $self->CurrentUser );
+    my ($tid) = $t->Create(
+        Topic      => $args{'Topic'},
+        ObjectType => ref($self),
+        ObjectId   => $self->Id
+    );
+    if ($tid) {
+        return ( $tid, $self->loc("Topic membership added") );
+    }
+    else {
+        return ( 0, $self->loc("Unable to add topic membership") );
+    }
+}
+
+# }}}
+
+sub DeleteTopic {
+    my $self = shift;
+    my %args = (@_);
+
+    unless ( $self->CurrentUserHasRight('ModifyArticleTopics') ) {
+        return ( 0, $self->loc("Permission Denied") );
+    }
+
+    my $t = RT::ObjectTopic->new( $self->CurrentUser );
+    $t->LoadByCols(
+        Topic      => $args{'Topic'},
+        ObjectId   => $self->Id,
+        ObjectType => ref($self)
+    );
+    if ( $t->Id ) {
+        my $del = $t->Delete;
+        unless ($del) {
+            return (
+                undef,
+                $self->loc(
+                    "Unable to delete topic membership in [_1]",
+                    $t->TopicObj->Name
+                )
+            );
+        }
+        else {
+            return ( 1, $self->loc("Topic membership removed") );
+        }
+    }
+    else {
+        return (
+            undef,
+            $self->loc(
+                "Couldn't load topic membership while trying to delete it")
+        );
+    }
+}
+
+=head2 CurrentUserHasRight
+
+Returns true if the current user has the right for this article, for the whole system or for this article's class
+
+=cut
+
+sub CurrentUserHasRight {
+    my $self  = shift;
+    my $right = shift;
+
+    return (
+        $self->CurrentUser->HasRight(
+            Right        => $right,
+            Object       => $self,
+            EquivObjects => [ $RT::System, $RT::System, $self->ClassObj ]
+        )
+    );
+
+}
+
+# }}}
+
+# {{{ _Set
+
+=head2 _Set { Field => undef, Value => undef
+
+Internal helper method to record a transaction as we update some core field of the article
+
+
+=cut
+
+sub _Set {
+    my $self = shift;
+    my %args = (
+        Field => undef,
+        Value => undef,
+        @_
+    );
+
+    unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
+        return ( 0, $self->loc("Permission Denied") );
+    }
+
+    $self->_NewTransaction(
+        Type     => 'Set',
+        Field    => $args{'Field'},
+        NewValue => $args{'Value'},
+        OldValue => $self->__Value( $args{'Field'} )
+    );
+
+    return ( $self->SUPER::_Set(%args) );
+
+}
+
+=head2 _Value PARAM
+
+Return "PARAM" for this object. if the current user doesn't have rights, returns undef
+
+=cut
+
+sub _Value {
+    my $self = shift;
+    my $arg  = shift;
+    unless ( ( $arg eq 'Class' )
+        || ( $self->CurrentUserHasRight('ShowArticle') ) )
+    {
+        return (undef);
+    }
+    return $self->SUPER::_Value($arg);
+}
+
+# }}}
+
+sub CustomFieldLookupType {
+    "RT::Class-RT::Article";
+}
+
+# _LookupId is the id of the toplevel type object the customfield is joined to
+# in this case, that's an RT::Class.
+
+sub _LookupId {
+    my $self = shift;
+    return $self->ClassObj->id;
+
+}
+
+=head2 LoadByInclude Field Value
+
+Takes the name of a form field from "Include Article"
+and the value submitted by the browser and attempts to load an Article.
+
+This handles Articles included by searching, by the Name and via
+the hotlist.
+
+If you optionaly pass an id as the Queue argument, this will check that
+the Article's Class is applied to that Queue.
+
+=cut
+
+sub LoadByInclude {
+    my $self = shift;
+    my %args = @_;
+    my $Field = $args{Field};
+    my $Value = $args{Value};
+    my $Queue = $args{Queue};
+
+    return unless $Field;
+
+    my ($ok, $msg);
+    if ( $Field eq 'Articles-Include-Article' && $Value ) {
+        ($ok, $msg) = $self->Load( $Value );
+    } elsif ( $Field =~ /^Articles-Include-Article-(\d+)$/ ) {
+        ($ok, $msg) = $self->Load( $1 );
+    } elsif ( $Field =~ /^Articles-Include-Article-Named/ && $Value ) {
+        if ( $Value =~ /\D/ ) {
+            ($ok, $msg) = $self->LoadByCols( Name => $Value );
+        } else {
+            ($ok, $msg) = $self->LoadByCols( id => $Value );
+        }
+    }
+
+    unless ($ok) { # load failed, don't check Class
+        return ($ok, $msg);
+    }
+
+    unless ($Queue) { # we haven't requested extra sanity checking
+        return ($ok, $msg);
+    }
+
+    # ensure that this article is available for the Queue we're
+    # operating under.
+    my $class = $self->ClassObj;
+    unless ($class->IsApplied(0) || $class->IsApplied($Queue)) {
+        $self->LoadById(0);
+        return (0, $self->loc("The Class of the Article identified by [_1] is not applied to the current Queue",$Value));
+    }
+
+    return ($ok, $msg);
+
+}
+
+
+=head2 id
+
+Returns the current value of id. 
+(In the database, id is stored as int(11).)
+
+
+=cut
+
+
+=head2 Name
+
+Returns the current value of Name. 
+(In the database, Name is stored as varchar(255).)
+
+
+
+=head2 SetName VALUE
+
+
+Set Name to VALUE. 
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, Name will be stored as a varchar(255).)
+
+
+=cut
+
+
+=head2 Summary
+
+Returns the current value of Summary. 
+(In the database, Summary is stored as varchar(255).)
+
+
+
+=head2 SetSummary VALUE
+
+
+Set Summary to VALUE. 
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, Summary will be stored as a varchar(255).)
+
+
+=cut
+
+
+=head2 SortOrder
+
+Returns the current value of SortOrder. 
+(In the database, SortOrder is stored as int(11).)
+
+
+
+=head2 SetSortOrder VALUE
+
+
+Set SortOrder to VALUE. 
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, SortOrder will be stored as a int(11).)
+
+
+=cut
+
+
+=head2 Class
+
+Returns the current value of Class. 
+(In the database, Class is stored as int(11).)
+
+
+
+=head2 SetClass VALUE
+
+
+Set Class to VALUE. 
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, Class will be stored as a int(11).)
+
+
+=cut
+
+
+=head2 ClassObj
+
+Returns the Class Object which has the id returned by Class
+
+
+=cut
+
+sub ClassObj {
+       my $self = shift;
+       my $Class =  RT::Class->new($self->CurrentUser);
+       $Class->Load($self->Class());
+       return($Class);
+}
+
+=head2 Parent
+
+Returns the current value of Parent. 
+(In the database, Parent is stored as int(11).)
+
+
+
+=head2 SetParent VALUE
+
+
+Set Parent to VALUE. 
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, Parent will be stored as a int(11).)
+
+
+=cut
+
+
+=head2 URI
+
+Returns the current value of URI. 
+(In the database, URI is stored as varchar(255).)
+
+
+
+=head2 SetURI VALUE
+
+
+Set URI to VALUE. 
+Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
+(In the database, URI will be stored as a varchar(255).)
+
+
+=cut
+
+
+=head2 Creator
+
+Returns the current value of Creator. 
+(In the database, Creator is stored as int(11).)
+
+
+=cut
+
+
+=head2 Created
+
+Returns the current value of Created. 
+(In the database, Created is stored as datetime.)
+
+
+=cut
+
+
+=head2 LastUpdatedBy
+
+Returns the current value of LastUpdatedBy. 
+(In the database, LastUpdatedBy is stored as int(11).)
+
+
+=cut
+
+
+=head2 LastUpdated
+
+Returns the current value of LastUpdated. 
+(In the database, LastUpdated is stored as datetime.)
+
+
+=cut
+
+
+
+sub _CoreAccessible {
+    {
+     
+        id =>
+               {read => 1, type => 'int(11)', default => ''},
+        Name => 
+               {read => 1, write => 1, type => 'varchar(255)', default => ''},
+        Summary => 
+               {read => 1, write => 1, type => 'varchar(255)', default => ''},
+        SortOrder => 
+               {read => 1, write => 1, type => 'int(11)', default => '0'},
+        Class => 
+               {read => 1, write => 1, type => 'int(11)', default => '0'},
+        Parent => 
+               {read => 1, write => 1, type => 'int(11)', default => '0'},
+        URI => 
+               {read => 1, write => 1, type => 'varchar(255)', default => ''},
+        Creator => 
+               {read => 1, auto => 1, type => 'int(11)', default => '0'},
+        Created => 
+               {read => 1, auto => 1, type => 'datetime', default => ''},
+        LastUpdatedBy => 
+               {read => 1, auto => 1, type => 'int(11)', default => '0'},
+        LastUpdated => 
+               {read => 1, auto => 1, type => 'datetime', default => ''},
+
+ }
+};
+
+RT::Base->_ImportOverlays();
+
+1;
+
+
+1;