Merge branch 'master' of https://github.com/jgoodman/Freeside
[freeside.git] / rt / lib / RT / Article.pm
1 # BEGIN BPS TAGGED BLOCK {{{
2 #
3 # COPYRIGHT:
4 #
5 # This software is Copyright (c) 1996-2014 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     unless ( $uri_obj->FromURI( $args{'Target'}||$args{'Base'} )) {
403         my $msg = $self->loc( "Couldn't resolve '[_1]' into a Link.", $args{'Target'} || $args{'Base'} );
404         $RT::Logger->warning( $msg );
405         return( 0, $msg );
406     }
407
408
409     $self->_AddLink(%args);
410 }
411
412 sub URI {
413     my $self = shift;
414
415     unless ( $self->CurrentUserHasRight('ShowArticle') ) {
416         return $self->loc("Permission Denied");
417     }
418
419     my $uri = RT::URI::fsck_com_article->new( $self->CurrentUser );
420     return ( $uri->URIForObject($self) );
421 }
422
423 # }}}
424
425 # {{{ sub URIObj
426
427 =head2 URIObj
428
429 Returns this article's URI
430
431
432 =cut
433
434 sub URIObj {
435     my $self = shift;
436     my $uri  = RT::URI->new( $self->CurrentUser );
437     if ( $self->CurrentUserHasRight('ShowArticle') ) {
438         $uri->FromObject($self);
439     }
440
441     return ($uri);
442 }
443
444 # }}}
445 # }}}
446
447 # {{{ Topics
448
449 # {{{ Topics
450 sub Topics {
451     my $self = shift;
452
453     my $topics = RT::ObjectTopics->new( $self->CurrentUser );
454     if ( $self->CurrentUserHasRight('ShowArticle') ) {
455         $topics->LimitToObject($self);
456     }
457     return $topics;
458 }
459
460 # }}}
461
462 # {{{ AddTopic
463 sub AddTopic {
464     my $self = shift;
465     my %args = (@_);
466
467     unless ( $self->CurrentUserHasRight('ModifyArticleTopics') ) {
468         return ( 0, $self->loc("Permission Denied") );
469     }
470
471     my $t = RT::ObjectTopic->new( $self->CurrentUser );
472     my ($tid) = $t->Create(
473         Topic      => $args{'Topic'},
474         ObjectType => ref($self),
475         ObjectId   => $self->Id
476     );
477     if ($tid) {
478         return ( $tid, $self->loc("Topic membership added") );
479     }
480     else {
481         return ( 0, $self->loc("Unable to add topic membership") );
482     }
483 }
484
485 # }}}
486
487 sub DeleteTopic {
488     my $self = shift;
489     my %args = (@_);
490
491     unless ( $self->CurrentUserHasRight('ModifyArticleTopics') ) {
492         return ( 0, $self->loc("Permission Denied") );
493     }
494
495     my $t = RT::ObjectTopic->new( $self->CurrentUser );
496     $t->LoadByCols(
497         Topic      => $args{'Topic'},
498         ObjectId   => $self->Id,
499         ObjectType => ref($self)
500     );
501     if ( $t->Id ) {
502         my $del = $t->Delete;
503         unless ($del) {
504             return (
505                 undef,
506                 $self->loc(
507                     "Unable to delete topic membership in [_1]",
508                     $t->TopicObj->Name
509                 )
510             );
511         }
512         else {
513             return ( 1, $self->loc("Topic membership removed") );
514         }
515     }
516     else {
517         return (
518             undef,
519             $self->loc(
520                 "Couldn't load topic membership while trying to delete it")
521         );
522     }
523 }
524
525 =head2 CurrentUserHasRight
526
527 Returns true if the current user has the right for this article, for the whole system or for this article's class
528
529 =cut
530
531 sub CurrentUserHasRight {
532     my $self  = shift;
533     my $right = shift;
534
535     return (
536         $self->CurrentUser->HasRight(
537             Right        => $right,
538             Object       => $self,
539             EquivObjects => [ $RT::System, $RT::System, $self->ClassObj ]
540         )
541     );
542
543 }
544
545 =head2 CurrentUserCanSee
546
547 Returns true if the current user can see the article, using ShowArticle
548
549 =cut
550
551 sub CurrentUserCanSee {
552     my $self = shift;
553     return $self->CurrentUserHasRight('ShowArticle');
554 }
555
556 # }}}
557
558 # {{{ _Set
559
560 =head2 _Set { Field => undef, Value => undef
561
562 Internal helper method to record a transaction as we update some core field of the article
563
564
565 =cut
566
567 sub _Set {
568     my $self = shift;
569     my %args = (
570         Field => undef,
571         Value => undef,
572         @_
573     );
574
575     unless ( $self->CurrentUserHasRight('ModifyArticle') ) {
576         return ( 0, $self->loc("Permission Denied") );
577     }
578
579     $self->_NewTransaction(
580         Type     => 'Set',
581         Field    => $args{'Field'},
582         NewValue => $args{'Value'},
583         OldValue => $self->__Value( $args{'Field'} )
584     );
585
586     return ( $self->SUPER::_Set(%args) );
587
588 }
589
590 =head2 _Value PARAM
591
592 Return "PARAM" for this object. if the current user doesn't have rights, returns undef
593
594 =cut
595
596 sub _Value {
597     my $self = shift;
598     my $arg  = shift;
599     unless ( ( $arg eq 'Class' )
600         || ( $self->CurrentUserHasRight('ShowArticle') ) )
601     {
602         return (undef);
603     }
604     return $self->SUPER::_Value($arg);
605 }
606
607 # }}}
608
609 sub CustomFieldLookupType {
610     "RT::Class-RT::Article";
611 }
612
613 =head2 LoadByInclude Field Value
614
615 Takes the name of a form field from "Include Article"
616 and the value submitted by the browser and attempts to load an Article.
617
618 This handles Articles included by searching, by the Name and via
619 the hotlist.
620
621 If you optionaly pass an id as the Queue argument, this will check that
622 the Article's Class is applied to that Queue.
623
624 =cut
625
626 sub LoadByInclude {
627     my $self = shift;
628     my %args = @_;
629     my $Field = $args{Field};
630     my $Value = $args{Value};
631     my $Queue = $args{Queue};
632
633     return unless $Field;
634
635     my ($ok, $msg);
636     if ( $Field eq 'Articles-Include-Article' && $Value ) {
637         ($ok, $msg) = $self->Load( $Value );
638     } elsif ( $Field =~ /^Articles-Include-Article-(\d+)$/ ) {
639         ($ok, $msg) = $self->Load( $1 );
640     } elsif ( $Field =~ /^Articles-Include-Article-Named/ && $Value ) {
641         if ( $Value =~ /\D/ ) {
642             ($ok, $msg) = $self->LoadByCols( Name => $Value );
643         } else {
644             ($ok, $msg) = $self->LoadByCols( id => $Value );
645         }
646     }
647
648     unless ($ok) { # load failed, don't check Class
649         return ($ok, $msg);
650     }
651
652     unless ($Queue) { # we haven't requested extra sanity checking
653         return ($ok, $msg);
654     }
655
656     # ensure that this article is available for the Queue we're
657     # operating under.
658     my $class = $self->ClassObj;
659     unless ($class->IsApplied(0) || $class->IsApplied($Queue)) {
660         $self->LoadById(0);
661         return (0, $self->loc("The Class of the Article identified by [_1] is not applied to the current Queue",$Value));
662     }
663
664     return ($ok, $msg);
665
666 }
667
668
669 =head2 id
670
671 Returns the current value of id. 
672 (In the database, id is stored as int(11).)
673
674
675 =cut
676
677
678 =head2 Name
679
680 Returns the current value of Name. 
681 (In the database, Name is stored as varchar(255).)
682
683
684
685 =head2 SetName VALUE
686
687
688 Set Name to VALUE. 
689 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
690 (In the database, Name will be stored as a varchar(255).)
691
692
693 =cut
694
695
696 =head2 Summary
697
698 Returns the current value of Summary. 
699 (In the database, Summary is stored as varchar(255).)
700
701
702
703 =head2 SetSummary VALUE
704
705
706 Set Summary to VALUE. 
707 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
708 (In the database, Summary will be stored as a varchar(255).)
709
710
711 =cut
712
713
714 =head2 SortOrder
715
716 Returns the current value of SortOrder. 
717 (In the database, SortOrder is stored as int(11).)
718
719
720
721 =head2 SetSortOrder VALUE
722
723
724 Set SortOrder to VALUE. 
725 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
726 (In the database, SortOrder will be stored as a int(11).)
727
728
729 =cut
730
731
732 =head2 Class
733
734 Returns the current value of Class. 
735 (In the database, Class is stored as int(11).)
736
737
738
739 =head2 SetClass VALUE
740
741
742 Set Class to VALUE. 
743 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
744 (In the database, Class will be stored as a int(11).)
745
746
747 =cut
748
749
750 =head2 ClassObj
751
752 Returns the Class Object which has the id returned by Class
753
754
755 =cut
756
757 sub ClassObj {
758         my $self = shift;
759         my $Class =  RT::Class->new($self->CurrentUser);
760         $Class->Load($self->Class());
761         return($Class);
762 }
763
764 =head2 Parent
765
766 Returns the current value of Parent. 
767 (In the database, Parent is stored as int(11).)
768
769
770
771 =head2 SetParent VALUE
772
773
774 Set Parent to VALUE. 
775 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
776 (In the database, Parent will be stored as a int(11).)
777
778
779 =cut
780
781
782 =head2 URI
783
784 Returns the current value of URI. 
785 (In the database, URI is stored as varchar(255).)
786
787
788
789 =head2 SetURI VALUE
790
791
792 Set URI to VALUE. 
793 Returns (1, 'Status message') on success and (0, 'Error Message') on failure.
794 (In the database, URI will be stored as a varchar(255).)
795
796
797 =cut
798
799
800 =head2 Creator
801
802 Returns the current value of Creator. 
803 (In the database, Creator is stored as int(11).)
804
805
806 =cut
807
808
809 =head2 Created
810
811 Returns the current value of Created. 
812 (In the database, Created is stored as datetime.)
813
814
815 =cut
816
817
818 =head2 LastUpdatedBy
819
820 Returns the current value of LastUpdatedBy. 
821 (In the database, LastUpdatedBy is stored as int(11).)
822
823
824 =cut
825
826
827 =head2 LastUpdated
828
829 Returns the current value of LastUpdated. 
830 (In the database, LastUpdated is stored as datetime.)
831
832
833 =cut
834
835
836
837 sub _CoreAccessible {
838     {
839      
840         id =>
841                 {read => 1, type => 'int(11)', default => ''},
842         Name => 
843                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
844         Summary => 
845                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
846         SortOrder => 
847                 {read => 1, write => 1, type => 'int(11)', default => '0'},
848         Class => 
849                 {read => 1, write => 1, type => 'int(11)', default => '0'},
850         Parent => 
851                 {read => 1, write => 1, type => 'int(11)', default => '0'},
852         URI => 
853                 {read => 1, write => 1, type => 'varchar(255)', default => ''},
854         Creator => 
855                 {read => 1, auto => 1, type => 'int(11)', default => '0'},
856         Created => 
857                 {read => 1, auto => 1, type => 'datetime', default => ''},
858         LastUpdatedBy => 
859                 {read => 1, auto => 1, type => 'int(11)', default => '0'},
860         LastUpdated => 
861                 {read => 1, auto => 1, type => 'datetime', default => ''},
862
863  }
864 };
865
866 RT::Base->_ImportOverlays();
867
868 1;
869
870
871 1;