first pass RT4 merge, RT#13852
[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($RT::SystemUser);
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 # }}}
547
548 # {{{ _Set
549
550 =head2 _Set { Field => undef, Value => undef
551
552 Internal helper method to record a transaction as we update some core field of the article
553
554
555 =cut
556
557 sub _Set {
558     my $self = shift;
559     my %args = (
560         Field => undef,
561         Value => undef,
562         @_
563     );
564
565     unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
566         return ( 0, $self->loc("Permission Denied") );
567     }
568
569     $self->_NewTransaction(
570         Type     => 'Set',
571         Field    => $args{'Field'},
572         NewValue => $args{'Value'},
573         OldValue => $self->__Value( $args{'Field'} )
574     );
575
576     return ( $self->SUPER::_Set(%args) );
577
578 }
579
580 =head2 _Value PARAM
581
582 Return "PARAM" for this object. if the current user doesn't have rights, returns undef
583
584 =cut
585
586 sub _Value {
587     my $self = shift;
588     my $arg  = shift;
589     unless ( ( $arg eq 'Class' )
590         || ( $self->CurrentUserHasRight('ShowArticle') ) )
591     {
592         return (undef);
593     }
594     return $self->SUPER::_Value($arg);
595 }
596
597 # }}}
598
599 sub CustomFieldLookupType {
600     "RT::Class-RT::Article";
601 }
602
603 # _LookupId is the id of the toplevel type object the customfield is joined to
604 # in this case, that's an RT::Class.
605
606 sub _LookupId {
607     my $self = shift;
608     return $self->ClassObj->id;
609
610 }
611
612 =head2 LoadByInclude Field Value
613
614 Takes the name of a form field from "Include Article"
615 and the value submitted by the browser and attempts to load an Article.
616
617 This handles Articles included by searching, by the Name and via
618 the hotlist.
619
620 If you optionaly pass an id as the Queue argument, this will check that
621 the Article's Class is applied to that Queue.
622
623 =cut
624
625 sub LoadByInclude {
626     my $self = shift;
627     my %args = @_;
628     my $Field = $args{Field};
629     my $Value = $args{Value};
630     my $Queue = $args{Queue};
631
632     return unless $Field;
633
634     my ($ok, $msg);
635     if ( $Field eq 'Articles-Include-Article' && $Value ) {
636         ($ok, $msg) = $self->Load( $Value );
637     } elsif ( $Field =~ /^Articles-Include-Article-(\d+)$/ ) {
638         ($ok, $msg) = $self->Load( $1 );
639     } elsif ( $Field =~ /^Articles-Include-Article-Named/ && $Value ) {
640         if ( $Value =~ /\D/ ) {
641             ($ok, $msg) = $self->LoadByCols( Name => $Value );
642         } else {
643             ($ok, $msg) = $self->LoadByCols( id => $Value );
644         }
645     }
646
647     unless ($ok) { # load failed, don't check Class
648         return ($ok, $msg);
649     }
650
651     unless ($Queue) { # we haven't requested extra sanity checking
652         return ($ok, $msg);
653     }
654
655     # ensure that this article is available for the Queue we're
656     # operating under.
657     my $class = $self->ClassObj;
658     unless ($class->IsApplied(0) || $class->IsApplied($Queue)) {
659         $self->LoadById(0);
660         return (0, $self->loc("The Class of the Article identified by [_1] is not applied to the current Queue",$Value));
661     }
662
663     return ($ok, $msg);
664
665 }
666
667
668 =head2 id
669
670 Returns the current value of id. 
671 (In the database, id is stored as int(11).)
672
673
674 =cut
675
676
677 =head2 Name
678
679 Returns the current value of Name. 
680 (In the database, Name is stored as varchar(255).)
681
682
683
684 =head2 SetName VALUE
685
686
687 Set Name to VALUE. 
688 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
689 (In the database, Name will be stored as a varchar(255).)
690
691
692 =cut
693
694
695 =head2 Summary
696
697 Returns the current value of Summary. 
698 (In the database, Summary is stored as varchar(255).)
699
700
701
702 =head2 SetSummary VALUE
703
704
705 Set Summary to VALUE. 
706 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
707 (In the database, Summary will be stored as a varchar(255).)
708
709
710 =cut
711
712
713 =head2 SortOrder
714
715 Returns the current value of SortOrder. 
716 (In the database, SortOrder is stored as int(11).)
717
718
719
720 =head2 SetSortOrder VALUE
721
722
723 Set SortOrder to VALUE. 
724 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
725 (In the database, SortOrder will be stored as a int(11).)
726
727
728 =cut
729
730
731 =head2 Class
732
733 Returns the current value of Class. 
734 (In the database, Class is stored as int(11).)
735
736
737
738 =head2 SetClass VALUE
739
740
741 Set Class to VALUE. 
742 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
743 (In the database, Class will be stored as a int(11).)
744
745
746 =cut
747
748
749 =head2 ClassObj
750
751 Returns the Class Object which has the id returned by Class
752
753
754 =cut
755
756 sub ClassObj {
757         my $self = shift;
758         my $Class =  RT::Class->new($self->CurrentUser);
759         $Class->Load($self->Class());
760         return($Class);
761 }
762
763 =head2 Parent
764
765 Returns the current value of Parent. 
766 (In the database, Parent is stored as int(11).)
767
768
769
770 =head2 SetParent VALUE
771
772
773 Set Parent to VALUE. 
774 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
775 (In the database, Parent will be stored as a int(11).)
776
777
778 =cut
779
780
781 =head2 URI
782
783 Returns the current value of URI. 
784 (In the database, URI is stored as varchar(255).)
785
786
787
788 =head2 SetURI VALUE
789
790
791 Set URI to VALUE. 
792 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
793 (In the database, URI will be stored as a varchar(255).)
794
795
796 =cut
797
798
799 =head2 Creator
800
801 Returns the current value of Creator. 
802 (In the database, Creator is stored as int(11).)
803
804
805 =cut
806
807
808 =head2 Created
809
810 Returns the current value of Created. 
811 (In the database, Created is stored as datetime.)
812
813
814 =cut
815
816
817 =head2 LastUpdatedBy
818
819 Returns the current value of LastUpdatedBy. 
820 (In the database, LastUpdatedBy is stored as int(11).)
821
822
823 =cut
824
825
826 =head2 LastUpdated
827
828 Returns the current value of LastUpdated. 
829 (In the database, LastUpdated is stored as datetime.)
830
831
832 =cut
833
834
835
836 sub _CoreAccessible {
837     {
838      
839         id =>
840                 {read => 1, type => 'int(11)', default => ''},
841         Name => 
842                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
843         Summary => 
844                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
845         SortOrder => 
846                 {read => 1, write => 1, type => 'int(11)', default => '0'},
847         Class => 
848                 {read => 1, write => 1, type => 'int(11)', default => '0'},
849         Parent => 
850                 {read => 1, write => 1, type => 'int(11)', default => '0'},
851         URI => 
852                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
853         Creator => 
854                 {read => 1, auto => 1, type => 'int(11)', default => '0'},
855         Created => 
856                 {read => 1, auto => 1, type => 'datetime', default => ''},
857         LastUpdatedBy => 
858                 {read => 1, auto => 1, type => 'int(11)', default => '0'},
859         LastUpdated => 
860                 {read => 1, auto => 1, type => 'datetime', default => ''},
861
862  }
863 };
864
865 RT::Base->_ImportOverlays();
866
867 1;
868
869
870 1;