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