Merge branch 'patch-6' of https://github.com/gjones2/Freeside (#13854 as this bug...
[freeside.git] / rt / lib / RT / Article.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2 #
3 # COPYRIGHT:
4 #
5 # This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC
6 #                                          <sales@bestpractical.com>
7 #
8 # (Except where explicitly superseded by other copyright notices)
9 #
10 #
11 # LICENSE:
12 #
13 # This work is made available to you under the terms of Version 2 of
14 # the GNU General Public License. A copy of that license should have
15 # been provided with this software, but in any event can be snarfed
16 # from www.gnu.org.
17 #
18 # This work is distributed in the hope that it will be useful, but
19 # WITHOUT ANY WARRANTY; without even the implied warranty of
20 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
21 # General Public License for more details.
22 #
23 # You should have received a copy of the GNU General Public License
24 # along with this program; if not, write to the Free Software
25 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26 # 02110-1301 or visit their web page on the internet at
27 # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28 #
29 #
30 # CONTRIBUTION SUBMISSION POLICY:
31 #
32 # (The following paragraph is not intended to limit the rights granted
33 # to you to modify and distribute this software under the terms of
34 # the GNU General Public License and is only of importance to you if
35 # you choose to contribute your changes and enhancements to the
36 # community by submitting them to Best Practical Solutions, LLC.)
37 #
38 # By intentionally submitting any modifications, corrections or
39 # derivatives to this work, or any other work intended for use with
40 # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41 # you are the copyright holder for those contributions and you grant
42 # Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
43 # royalty-free, perpetual, license to use, copy, create derivative
44 # works based on those contributions, and sublicense and distribute
45 # those contributions and any derivatives thereof.
46 #
47 # END BPS TAGGED BLOCK }}}
48
49 use strict;
50 use warnings;
51
52 package RT::Article;
53
54 use base 'RT::Record';
55
56 use RT::Articles;
57 use RT::ObjectTopics;
58 use RT::Classes;
59 use RT::Links;
60 use RT::CustomFields;
61 use RT::URI::fsck_com_article;
62 use RT::Transactions;
63
64
65 sub Table {'Articles'}
66
67 # This object takes custom fields
68
69 use RT::CustomField;
70 RT::CustomField->_ForObjectType( CustomFieldLookupType() => 'Articles' )
71   ;    #loc
72
73 # {{{ Create
74
75 =head2 Create PARAMHASH
76
77 Create takes a hash of values and creates a row in the database:
78
79   varchar(200) 'Name'.
80   varchar(200) 'Summary'.
81   int(11) 'Content'.
82   Class ID  'Class'
83
84   A paramhash called  'CustomFields', which contains 
85   arrays of values for each custom field you want to fill in.
86   Arrays aRe ordered. 
87
88
89
90
91 =cut
92
93 sub Create {
94     my $self = shift;
95     my %args = (
96         Name         => '',
97         Summary      => '',
98         Class        => '0',
99         CustomFields => {},
100         Links        => {},
101         Topics       => [],
102         @_
103     );
104
105     my $class = RT::Class->new( $self->CurrentUser );
106     $class->Load( $args{'Class'} );
107     unless ( $class->Id ) {
108         return ( 0, $self->loc('Invalid Class') );
109     }
110
111     unless ( $class->CurrentUserHasRight('CreateArticle') ) {
112         return ( 0, $self->loc("Permission Denied") );
113     }
114
115     return ( undef, $self->loc('Name in use') )
116       unless $self->ValidateName( $args{'Name'} );
117
118     $RT::Handle->BeginTransaction();
119     my ( $id, $msg ) = $self->SUPER::Create(
120         Name    => $args{'Name'},
121         Class   => $class->Id,
122         Summary => $args{'Summary'},
123     );
124     unless ($id) {
125         $RT::Handle->Rollback();
126         return ( undef, $msg );
127     }
128
129     # {{{ Add custom fields
130
131     foreach my $key ( keys %args ) {
132         next unless ( $key =~ /CustomField-(.*)$/ );
133         my $cf   = $1;
134         my @vals = ref( $args{$key} ) eq 'ARRAY' ? @{ $args{$key} } : ( $args{$key} );
135         foreach my $value (@vals) {
136
137             my ( $cfid, $cfmsg ) = $self->_AddCustomFieldValue(
138                 (UNIVERSAL::isa( $value => 'HASH' )
139                     ? %$value
140                     : (Value => $value)
141                 ),
142                 Field             => $cf,
143                 RecordTransaction => 0
144             );
145
146             unless ($cfid) {
147                 $RT::Handle->Rollback();
148                 return ( undef, $cfmsg );
149             }
150         }
151
152     }
153
154     # }}}
155     # {{{ Add topics
156
157     foreach my $topic ( @{ $args{Topics} } ) {
158         my ( $cfid, $cfmsg ) = $self->AddTopic( Topic => $topic );
159
160         unless ($cfid) {
161             $RT::Handle->Rollback();
162             return ( undef, $cfmsg );
163         }
164     }
165
166     # }}}
167     # {{{ Add relationships
168
169     foreach my $type ( keys %args ) {
170         next unless ( $type =~ /^(RefersTo-new|new-RefersTo)$/ );
171         my @vals =
172           ref( $args{$type} ) eq 'ARRAY' ? @{ $args{$type} } : ( $args{$type} );
173         foreach my $val (@vals) {
174             my ( $base, $target );
175             if ( $type =~ /^new-(.*)$/ ) {
176                 $type   = $1;
177                 $base   = undef;
178                 $target = $val;
179             }
180             elsif ( $type =~ /^(.*)-new$/ ) {
181                 $type   = $1;
182                 $base   = $val;
183                 $target = undef;
184             }
185
186             my ( $linkid, $linkmsg ) = $self->AddLink(
187                 Type              => $type,
188                 Target            => $target,
189                 Base              => $base,
190                 RecordTransaction => 0
191             );
192
193             unless ($linkid) {
194                 $RT::Handle->Rollback();
195                 return ( undef, $linkmsg );
196             }
197         }
198
199     }
200
201     # }}}
202
203     # We override the URI lookup. the whole reason
204     # we have a URI column is so that joins on the links table
205     # aren't expensive and stupid
206     $self->__Set( Field => 'URI', Value => $self->URI );
207
208     my ( $txn_id, $txn_msg, $txn ) = $self->_NewTransaction( Type => 'Create' );
209     unless ($txn_id) {
210         $RT::Handle->Rollback();
211         return ( undef, $self->loc( 'Internal error: [_1]', $txn_msg ) );
212     }
213     $RT::Handle->Commit();
214
215     return ( $id, $self->loc('Article [_1] created',$self->id ));
216 }
217
218 # }}}
219
220 # {{{ ValidateName
221
222 =head2 ValidateName NAME
223
224 Takes a string name. Returns true if that name isn't in use by another article
225
226 Empty names are permitted.
227
228
229 =cut
230
231 sub ValidateName {
232     my $self = shift;
233     my $name = shift;
234
235     if ( !$name ) {
236         return (1);
237     }
238
239     my $temp = RT::Article->new($RT::SystemUser);
240     $temp->LoadByCols( Name => $name );
241     if ( $temp->id && 
242          (!$self->id || ($temp->id != $self->id ))) {
243         return (undef);
244     }
245
246     return (1);
247
248 }
249
250 # }}}
251
252 # {{{ Delete
253
254 =head2 Delete
255
256 Delete all its transactions
257 Delete all its custom field values
258 Delete all its relationships
259 Delete this article.
260
261 =cut
262
263 sub Delete {
264     my $self = shift;
265     unless ( $self->CurrentUserHasRight('DeleteArticle') ) {
266         return ( 0, $self->loc("Permission Denied") );
267     }
268
269     $RT::Handle->BeginTransaction();
270     my $linksto   = $self->_Links(  'Target' );
271     my $linksfrom = $self->_Links(  'Base' );
272     my $cfvalues = $self->CustomFieldValues;
273     my $txns     = $self->Transactions;
274     my $topics   = $self->Topics;
275
276     while ( my $item = $linksto->Next ) {
277         my ( $val, $msg ) = $item->Delete();
278         unless ($val) {
279             $RT::Logger->crit( ref($item) . ": $msg" );
280             $RT::Handle->Rollback();
281             return ( 0, $self->loc('Internal Error') );
282         }
283     }
284
285     while ( my $item = $linksfrom->Next ) {
286         my ( $val, $msg ) = $item->Delete();
287         unless ($val) {
288             $RT::Logger->crit( ref($item) . ": $msg" );
289             $RT::Handle->Rollback();
290             return ( 0, $self->loc('Internal Error') );
291         }
292     }
293
294     while ( my $item = $txns->Next ) {
295         my ( $val, $msg ) = $item->Delete();
296         unless ($val) {
297             $RT::Logger->crit( ref($item) . ": $msg" );
298             $RT::Handle->Rollback();
299             return ( 0, $self->loc('Internal Error') );
300         }
301     }
302
303     while ( my $item = $cfvalues->Next ) {
304         my ( $val, $msg ) = $item->Delete();
305         unless ($val) {
306             $RT::Logger->crit( ref($item) . ": $msg" );
307             $RT::Handle->Rollback();
308             return ( 0, $self->loc('Internal Error') );
309         }
310     }
311
312     while ( my $item = $topics->Next ) {
313         my ( $val, $msg ) = $item->Delete();
314         unless ($val) {
315             $RT::Logger->crit( ref($item) . ": $msg" );
316             $RT::Handle->Rollback();
317             return ( 0, $self->loc('Internal Error') );
318         }
319     }
320
321     $self->SUPER::Delete();
322     $RT::Handle->Commit();
323     return ( 1, $self->loc('Article Deleted') );
324
325 }
326
327 # }}}
328
329 # {{{ Children
330
331 =head2 Children
332
333 Returns an RT::Articles object which contains
334 all articles which have this article as their parent.  This 
335 routine will not recurse and will not find grandchildren, great-grandchildren, uncles, aunts, nephews or any other such thing.  
336
337 =cut
338
339 sub Children {
340     my $self = shift;
341     my $kids = RT::Articles->new( $self->CurrentUser );
342
343     unless ( $self->CurrentUserHasRight('ShowArticle') ) {
344         $kids->LimitToParent( $self->Id );
345     }
346     return ($kids);
347 }
348
349 # }}}
350
351 # {{{ sub AddLink
352
353 =head2 AddLink
354
355 Takes a paramhash of Type and one of Base or Target. Adds that link to this tick
356 et.
357
358 =cut
359
360 sub DeleteLink {
361     my $self = shift;
362     my %args = (
363         Target => '',
364         Base   => '',
365         Type   => '',
366         Silent => undef,
367         @_
368     );
369
370     unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
371         return ( 0, $self->loc("Permission Denied") );
372     }
373
374     $self->_DeleteLink(%args);
375 }
376
377 sub AddLink {
378     my $self = shift;
379     my %args = (
380         Target => '',
381         Base   => '',
382         Type   => '',
383         Silent => undef,
384         @_
385     );
386
387     unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
388         return ( 0, $self->loc("Permission Denied") );
389     }
390
391     # Disallow parsing of plain numbers in article links.  If they are
392     # allowed, they default to being tickets instead of articles, which
393     # is counterintuitive.
394     if (   $args{'Target'} && $args{'Target'} =~ /^\d+$/
395         || $args{'Base'} && $args{'Base'} =~ /^\d+$/ )
396     {
397         return ( 0, $self->loc("Cannot add link to plain number") );
398     }
399
400     # Check that we're actually getting a valid URI
401     my $uri_obj = RT::URI->new( $self->CurrentUser );
402     $uri_obj->FromURI( $args{'Target'}||$args{'Base'} );
403     unless ( $uri_obj->Resolver && $uri_obj->Scheme ) {
404         my $msg = $self->loc( "Couldn't resolve '[_1]' into a Link.", $args{'Target'} );
405         $RT::Logger->warning( $msg );
406         return( 0, $msg );
407     }
408
409
410     $self->_AddLink(%args);
411 }
412
413 sub URI {
414     my $self = shift;
415
416     unless ( $self->CurrentUserHasRight('ShowArticle') ) {
417         return $self->loc("Permission Denied");
418     }
419
420     my $uri = RT::URI::fsck_com_article->new( $self->CurrentUser );
421     return ( $uri->URIForObject($self) );
422 }
423
424 # }}}
425
426 # {{{ sub URIObj
427
428 =head2 URIObj
429
430 Returns this article's URI
431
432
433 =cut
434
435 sub URIObj {
436     my $self = shift;
437     my $uri  = RT::URI->new( $self->CurrentUser );
438     if ( $self->CurrentUserHasRight('ShowArticle') ) {
439         $uri->FromObject($self);
440     }
441
442     return ($uri);
443 }
444
445 # }}}
446 # }}}
447
448 # {{{ Topics
449
450 # {{{ Topics
451 sub Topics {
452     my $self = shift;
453
454     my $topics = RT::ObjectTopics->new( $self->CurrentUser );
455     if ( $self->CurrentUserHasRight('ShowArticle') ) {
456         $topics->LimitToObject($self);
457     }
458     return $topics;
459 }
460
461 # }}}
462
463 # {{{ AddTopic
464 sub AddTopic {
465     my $self = shift;
466     my %args = (@_);
467
468     unless ( $self->CurrentUserHasRight('ModifyArticleTopics') ) {
469         return ( 0, $self->loc("Permission Denied") );
470     }
471
472     my $t = RT::ObjectTopic->new( $self->CurrentUser );
473     my ($tid) = $t->Create(
474         Topic      => $args{'Topic'},
475         ObjectType => ref($self),
476         ObjectId   => $self->Id
477     );
478     if ($tid) {
479         return ( $tid, $self->loc("Topic membership added") );
480     }
481     else {
482         return ( 0, $self->loc("Unable to add topic membership") );
483     }
484 }
485
486 # }}}
487
488 sub DeleteTopic {
489     my $self = shift;
490     my %args = (@_);
491
492     unless ( $self->CurrentUserHasRight('ModifyArticleTopics') ) {
493         return ( 0, $self->loc("Permission Denied") );
494     }
495
496     my $t = RT::ObjectTopic->new( $self->CurrentUser );
497     $t->LoadByCols(
498         Topic      => $args{'Topic'},
499         ObjectId   => $self->Id,
500         ObjectType => ref($self)
501     );
502     if ( $t->Id ) {
503         my $del = $t->Delete;
504         unless ($del) {
505             return (
506                 undef,
507                 $self->loc(
508                     "Unable to delete topic membership in [_1]",
509                     $t->TopicObj->Name
510                 )
511             );
512         }
513         else {
514             return ( 1, $self->loc("Topic membership removed") );
515         }
516     }
517     else {
518         return (
519             undef,
520             $self->loc(
521                 "Couldn't load topic membership while trying to delete it")
522         );
523     }
524 }
525
526 =head2 CurrentUserHasRight
527
528 Returns true if the current user has the right for this article, for the whole system or for this article's class
529
530 =cut
531
532 sub CurrentUserHasRight {
533     my $self  = shift;
534     my $right = shift;
535
536     return (
537         $self->CurrentUser->HasRight(
538             Right        => $right,
539             Object       => $self,
540             EquivObjects => [ $RT::System, $RT::System, $self->ClassObj ]
541         )
542     );
543
544 }
545
546 =head2 CurrentUserCanSee
547
548 Returns true if the current user can see the article, using ShowArticle
549
550 =cut
551
552 sub CurrentUserCanSee {
553     my $self = shift;
554     return $self->CurrentUserHasRight('ShowArticle');
555 }
556
557 # }}}
558
559 # {{{ _Set
560
561 =head2 _Set { Field => undef, Value => undef
562
563 Internal helper method to record a transaction as we update some core field of the article
564
565
566 =cut
567
568 sub _Set {
569     my $self = shift;
570     my %args = (
571         Field => undef,
572         Value => undef,
573         @_
574     );
575
576     unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
577         return ( 0, $self->loc("Permission Denied") );
578     }
579
580     $self->_NewTransaction(
581         Type     => 'Set',
582         Field    => $args{'Field'},
583         NewValue => $args{'Value'},
584         OldValue => $self->__Value( $args{'Field'} )
585     );
586
587     return ( $self->SUPER::_Set(%args) );
588
589 }
590
591 =head2 _Value PARAM
592
593 Return "PARAM" for this object. if the current user doesn't have rights, returns undef
594
595 =cut
596
597 sub _Value {
598     my $self = shift;
599     my $arg  = shift;
600     unless ( ( $arg eq 'Class' )
601         || ( $self->CurrentUserHasRight('ShowArticle') ) )
602     {
603         return (undef);
604     }
605     return $self->SUPER::_Value($arg);
606 }
607
608 # }}}
609
610 sub CustomFieldLookupType {
611     "RT::Class-RT::Article";
612 }
613
614 # _LookupId is the id of the toplevel type object the customfield is joined to
615 # in this case, that's an RT::Class.
616
617 sub _LookupId {
618     my $self = shift;
619     return $self->ClassObj->id;
620
621 }
622
623 =head2 LoadByInclude Field Value
624
625 Takes the name of a form field from "Include Article"
626 and the value submitted by the browser and attempts to load an Article.
627
628 This handles Articles included by searching, by the Name and via
629 the hotlist.
630
631 If you optionaly pass an id as the Queue argument, this will check that
632 the Article's Class is applied to that Queue.
633
634 =cut
635
636 sub LoadByInclude {
637     my $self = shift;
638     my %args = @_;
639     my $Field = $args{Field};
640     my $Value = $args{Value};
641     my $Queue = $args{Queue};
642
643     return unless $Field;
644
645     my ($ok, $msg);
646     if ( $Field eq 'Articles-Include-Article' && $Value ) {
647         ($ok, $msg) = $self->Load( $Value );
648     } elsif ( $Field =~ /^Articles-Include-Article-(\d+)$/ ) {
649         ($ok, $msg) = $self->Load( $1 );
650     } elsif ( $Field =~ /^Articles-Include-Article-Named/ && $Value ) {
651         if ( $Value =~ /\D/ ) {
652             ($ok, $msg) = $self->LoadByCols( Name => $Value );
653         } else {
654             ($ok, $msg) = $self->LoadByCols( id => $Value );
655         }
656     }
657
658     unless ($ok) { # load failed, don't check Class
659         return ($ok, $msg);
660     }
661
662     unless ($Queue) { # we haven't requested extra sanity checking
663         return ($ok, $msg);
664     }
665
666     # ensure that this article is available for the Queue we're
667     # operating under.
668     my $class = $self->ClassObj;
669     unless ($class->IsApplied(0) || $class->IsApplied($Queue)) {
670         $self->LoadById(0);
671         return (0, $self->loc("The Class of the Article identified by [_1] is not applied to the current Queue",$Value));
672     }
673
674     return ($ok, $msg);
675
676 }
677
678
679 =head2 id
680
681 Returns the current value of id. 
682 (In the database, id is stored as int(11).)
683
684
685 =cut
686
687
688 =head2 Name
689
690 Returns the current value of Name. 
691 (In the database, Name is stored as varchar(255).)
692
693
694
695 =head2 SetName VALUE
696
697
698 Set Name to VALUE. 
699 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
700 (In the database, Name will be stored as a varchar(255).)
701
702
703 =cut
704
705
706 =head2 Summary
707
708 Returns the current value of Summary. 
709 (In the database, Summary is stored as varchar(255).)
710
711
712
713 =head2 SetSummary VALUE
714
715
716 Set Summary to VALUE. 
717 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
718 (In the database, Summary will be stored as a varchar(255).)
719
720
721 =cut
722
723
724 =head2 SortOrder
725
726 Returns the current value of SortOrder. 
727 (In the database, SortOrder is stored as int(11).)
728
729
730
731 =head2 SetSortOrder VALUE
732
733
734 Set SortOrder to VALUE. 
735 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
736 (In the database, SortOrder will be stored as a int(11).)
737
738
739 =cut
740
741
742 =head2 Class
743
744 Returns the current value of Class. 
745 (In the database, Class is stored as int(11).)
746
747
748
749 =head2 SetClass VALUE
750
751
752 Set Class to VALUE. 
753 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
754 (In the database, Class will be stored as a int(11).)
755
756
757 =cut
758
759
760 =head2 ClassObj
761
762 Returns the Class Object which has the id returned by Class
763
764
765 =cut
766
767 sub ClassObj {
768         my $self = shift;
769         my $Class =  RT::Class->new($self->CurrentUser);
770         $Class->Load($self->Class());
771         return($Class);
772 }
773
774 =head2 Parent
775
776 Returns the current value of Parent. 
777 (In the database, Parent is stored as int(11).)
778
779
780
781 =head2 SetParent VALUE
782
783
784 Set Parent to VALUE. 
785 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
786 (In the database, Parent will be stored as a int(11).)
787
788
789 =cut
790
791
792 =head2 URI
793
794 Returns the current value of URI. 
795 (In the database, URI is stored as varchar(255).)
796
797
798
799 =head2 SetURI VALUE
800
801
802 Set URI to VALUE. 
803 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
804 (In the database, URI will be stored as a varchar(255).)
805
806
807 =cut
808
809
810 =head2 Creator
811
812 Returns the current value of Creator. 
813 (In the database, Creator is stored as int(11).)
814
815
816 =cut
817
818
819 =head2 Created
820
821 Returns the current value of Created. 
822 (In the database, Created is stored as datetime.)
823
824
825 =cut
826
827
828 =head2 LastUpdatedBy
829
830 Returns the current value of LastUpdatedBy. 
831 (In the database, LastUpdatedBy is stored as int(11).)
832
833
834 =cut
835
836
837 =head2 LastUpdated
838
839 Returns the current value of LastUpdated. 
840 (In the database, LastUpdated is stored as datetime.)
841
842
843 =cut
844
845
846
847 sub _CoreAccessible {
848     {
849      
850         id =>
851                 {read => 1, type => 'int(11)', default => ''},
852         Name => 
853                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
854         Summary => 
855                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
856         SortOrder => 
857                 {read => 1, write => 1, type => 'int(11)', default => '0'},
858         Class => 
859                 {read => 1, write => 1, type => 'int(11)', default => '0'},
860         Parent => 
861                 {read => 1, write => 1, type => 'int(11)', default => '0'},
862         URI => 
863                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
864         Creator => 
865                 {read => 1, auto => 1, type => 'int(11)', default => '0'},
866         Created => 
867                 {read => 1, auto => 1, type => 'datetime', default => ''},
868         LastUpdatedBy => 
869                 {read => 1, auto => 1, type => 'int(11)', default => '0'},
870         LastUpdated => 
871                 {read => 1, auto => 1, type => 'datetime', default => ''},
872
873  }
874 };
875
876 RT::Base->_ImportOverlays();
877
878 1;
879
880
881 1;