1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4#
5# This Source Code Form is "Incompatible With Secondary Licenses", as
6# defined by the Mozilla Public License, v. 2.0.
7
8package Bugzilla::Product;
9use strict;
10use base qw(Bugzilla::Field::ChoiceInterface Bugzilla::Object);
11
12use Bugzilla::Constants;
13use Bugzilla::Util;
14use Bugzilla::Error;
15use Bugzilla::Group;
16use Bugzilla::Version;
17use Bugzilla::Milestone;
18use Bugzilla::Field;
19use Bugzilla::Status;
20use Bugzilla::Install::Requirements;
21use Bugzilla::Mailer;
22use Bugzilla::Series;
23use Bugzilla::Hook;
24use Bugzilla::FlagType;
25
26use Scalar::Util qw(blessed);
27
28use constant DEFAULT_CLASSIFICATION_ID => 1;
29
30###############################
31####    Initialization     ####
32###############################
33
34use constant DB_TABLE => 'products';
35
36use constant DB_COLUMNS => qw(
37   id
38   name
39   classification_id
40   description
41   isactive
42   defaultmilestone
43   allows_unconfirmed
44);
45
46use constant UPDATE_COLUMNS => qw(
47    name
48    description
49    defaultmilestone
50    isactive
51    allows_unconfirmed
52);
53
54use constant VALIDATORS => {
55    allows_unconfirmed => \&Bugzilla::Object::check_boolean,
56    classification   => \&_check_classification,
57    name             => \&_check_name,
58    description      => \&_check_description,
59    version          => \&_check_version,
60    defaultmilestone => \&_check_default_milestone,
61    isactive         => \&Bugzilla::Object::check_boolean,
62    create_series    => \&Bugzilla::Object::check_boolean
63};
64
65###############################
66####     Constructors     #####
67###############################
68
69sub create {
70    my $class = shift;
71    my $dbh = Bugzilla->dbh;
72
73    $dbh->bz_start_transaction();
74
75    $class->check_required_create_fields(@_);
76
77    my $params = $class->run_create_validators(@_);
78    # Some fields do not exist in the DB as is.
79    if (defined $params->{classification}) {
80        $params->{classification_id} = delete $params->{classification};
81    }
82    my $version = delete $params->{version};
83    my $create_series = delete $params->{create_series};
84
85    my $product = $class->insert_create_data($params);
86    Bugzilla->user->clear_product_cache();
87
88    # Add the new version and milestone into the DB as valid values.
89    Bugzilla::Version->create({ value => $version, product => $product });
90    Bugzilla::Milestone->create({ value => $product->default_milestone,
91                                  product => $product });
92
93    # Create groups and series for the new product, if requested.
94    $product->_create_bug_group() if Bugzilla->params->{'makeproductgroups'};
95    $product->_create_series() if $create_series;
96
97    Bugzilla::Hook::process('product_end_of_create', { product => $product });
98
99    $dbh->bz_commit_transaction();
100    return $product;
101}
102
103# This is considerably faster than calling new_from_list three times
104# for each product in the list, particularly with hundreds or thousands
105# of products.
106sub preload {
107    my ($products, $preload_flagtypes) = @_;
108    my %prods = map { $_->id => $_ } @$products;
109    my @prod_ids = keys %prods;
110    return unless @prod_ids;
111
112    # We cannot |use| it due to a dependency loop with Bugzilla::User.
113    require Bugzilla::Component;
114    foreach my $field (qw(component version milestone)) {
115        my $classname = "Bugzilla::" . ucfirst($field);
116        my $objects = $classname->match({ product_id => \@prod_ids });
117
118        # Now populate the products with this set of objects.
119        foreach my $obj (@$objects) {
120            my $product_id = $obj->product_id;
121            $prods{$product_id}->{"${field}s"} ||= [];
122            push(@{$prods{$product_id}->{"${field}s"}}, $obj);
123        }
124    }
125    if ($preload_flagtypes) {
126        $_->flag_types foreach @$products;
127    }
128}
129
130sub update {
131    my $self = shift;
132    my $dbh = Bugzilla->dbh;
133
134    # Don't update the DB if something goes wrong below -> transaction.
135    $dbh->bz_start_transaction();
136    my ($changes, $old_self) = $self->SUPER::update(@_);
137
138    # Also update group settings.
139    if ($self->{check_group_controls}) {
140        require Bugzilla::Bug;
141        import Bugzilla::Bug qw(LogActivityEntry);
142
143        my $old_settings = $old_self->group_controls;
144        my $new_settings = $self->group_controls;
145        my $timestamp = $dbh->selectrow_array('SELECT NOW()');
146
147        foreach my $gid (keys %$new_settings) {
148            my $old_setting = $old_settings->{$gid} || {};
149            my $new_setting = $new_settings->{$gid};
150            # If all new settings are 0 for a given group, we delete the entry
151            # from group_control_map, so we have to track it here.
152            my $all_zero = 1;
153            my @fields;
154            my @values;
155
156            foreach my $field ('entry', 'membercontrol', 'othercontrol', 'canedit',
157                               'editcomponents', 'editbugs', 'canconfirm')
158            {
159                my $old_value = $old_setting->{$field};
160                my $new_value = $new_setting->{$field};
161                $all_zero = 0 if $new_value;
162                next if (defined $old_value && $old_value == $new_value);
163                push(@fields, $field);
164                # The value has already been validated.
165                detaint_natural($new_value);
166                push(@values, $new_value);
167            }
168            # Is there anything to update?
169            next unless scalar @fields;
170
171            if ($all_zero) {
172                $dbh->do('DELETE FROM group_control_map
173                          WHERE product_id = ? AND group_id = ?',
174                          undef, $self->id, $gid);
175            }
176            else {
177                if (exists $old_setting->{group}) {
178                    # There is already an entry in the DB.
179                    my $set_fields = join(', ', map {"$_ = ?"} @fields);
180                    $dbh->do("UPDATE group_control_map SET $set_fields
181                              WHERE product_id = ? AND group_id = ?",
182                              undef, (@values, $self->id, $gid));
183                }
184                else {
185                    # No entry yet.
186                    my $fields = join(', ', @fields);
187                    # +2 because of the product and group IDs.
188                    my $qmarks = join(',', ('?') x (scalar @fields + 2));
189                    $dbh->do("INSERT INTO group_control_map (product_id, group_id, $fields)
190                              VALUES ($qmarks)", undef, ($self->id, $gid, @values));
191                }
192            }
193
194            # If the group is mandatory, restrict all bugs to it.
195            if ($new_setting->{membercontrol} == CONTROLMAPMANDATORY) {
196                my $bug_ids =
197                  $dbh->selectcol_arrayref('SELECT bugs.bug_id
198                                              FROM bugs
199                                                   LEFT JOIN bug_group_map
200                                                   ON bug_group_map.bug_id = bugs.bug_id
201                                                   AND group_id = ?
202                                             WHERE product_id = ?
203                                                   AND bug_group_map.bug_id IS NULL',
204                                             undef, $gid, $self->id);
205
206                if (scalar @$bug_ids) {
207                    my $sth = $dbh->prepare('INSERT INTO bug_group_map (bug_id, group_id)
208                                             VALUES (?, ?)');
209
210                    foreach my $bug_id (@$bug_ids) {
211                        $sth->execute($bug_id, $gid);
212                        # Add this change to the bug history.
213                        LogActivityEntry($bug_id, 'bug_group', '',
214                                         $new_setting->{group}->name,
215                                         Bugzilla->user->id, $timestamp);
216                    }
217                    push(@{$changes->{'_group_controls'}->{'now_mandatory'}},
218                         {name      => $new_setting->{group}->name,
219                          bug_count => scalar @$bug_ids});
220                }
221            }
222            # If the group can no longer be used to restrict bugs, remove them.
223            elsif ($new_setting->{membercontrol} == CONTROLMAPNA) {
224                my $bug_ids =
225                  $dbh->selectcol_arrayref('SELECT bugs.bug_id
226                                              FROM bugs
227                                                   INNER JOIN bug_group_map
228                                                   ON bug_group_map.bug_id = bugs.bug_id
229                                             WHERE product_id = ? AND group_id = ?',
230                                             undef, $self->id, $gid);
231
232                if (scalar @$bug_ids) {
233                    $dbh->do('DELETE FROM bug_group_map WHERE group_id = ? AND ' .
234                              $dbh->sql_in('bug_id', $bug_ids), undef, $gid);
235
236                    # Add this change to the bug history.
237                    foreach my $bug_id (@$bug_ids) {
238                        LogActivityEntry($bug_id, 'bug_group',
239                                         $old_setting->{group}->name, '',
240                                         Bugzilla->user->id, $timestamp);
241                    }
242                    push(@{$changes->{'_group_controls'}->{'now_na'}},
243                         {name => $old_setting->{group}->name,
244                          bug_count => scalar @$bug_ids});
245                }
246            }
247        }
248
249        delete $self->{groups_available};
250        delete $self->{groups_mandatory};
251    }
252    $dbh->bz_commit_transaction();
253    # Changes have been committed.
254    delete $self->{check_group_controls};
255    Bugzilla->user->clear_product_cache();
256
257    return $changes;
258}
259
260sub remove_from_db {
261    my ($self, $params) = @_;
262    my $user = Bugzilla->user;
263    my $dbh = Bugzilla->dbh;
264
265    $dbh->bz_start_transaction();
266
267    $self->_check_if_controller();
268
269    if ($self->bug_count) {
270        if (Bugzilla->params->{'allowbugdeletion'}) {
271            require Bugzilla::Bug;
272            foreach my $bug_id (@{$self->bug_ids}) {
273                # Note that we allow the user to delete bugs he can't see,
274                # which is okay, because he's deleting the whole Product.
275                my $bug = new Bugzilla::Bug($bug_id);
276                $bug->remove_from_db();
277            }
278        }
279        else {
280            ThrowUserError('product_has_bugs', { nb => $self->bug_count });
281        }
282    }
283
284    if ($params->{delete_series}) {
285        my $series_ids =
286          $dbh->selectcol_arrayref('SELECT series_id
287                                      FROM series
288                                INNER JOIN series_categories
289                                        ON series_categories.id = series.category
290                                     WHERE series_categories.name = ?',
291                                    undef, $self->name);
292
293        if (scalar @$series_ids) {
294            $dbh->do('DELETE FROM series WHERE ' . $dbh->sql_in('series_id', $series_ids));
295        }
296
297        # If no subcategory uses this product name, completely purge it.
298        my $in_use =
299          $dbh->selectrow_array('SELECT 1
300                                   FROM series
301                             INNER JOIN series_categories
302                                     ON series_categories.id = series.subcategory
303                                  WHERE series_categories.name = ? ' .
304                                   $dbh->sql_limit(1),
305                                  undef, $self->name);
306        if (!$in_use) {
307            $dbh->do('DELETE FROM series_categories WHERE name = ?', undef, $self->name);
308        }
309    }
310
311    $self->SUPER::remove_from_db();
312
313    $dbh->bz_commit_transaction();
314
315    # We have to delete these internal variables, else we get
316    # the old lists of products and classifications again.
317    delete $user->{selectable_products};
318    delete $user->{selectable_classifications};
319
320}
321
322###############################
323####      Validators       ####
324###############################
325
326sub _check_classification {
327    my ($invocant, $classification_name) = @_;
328
329    my $classification_id = 1;
330    if (Bugzilla->params->{'useclassification'}) {
331        my $classification = Bugzilla::Classification->check($classification_name);
332        $classification_id = $classification->id;
333    }
334    return $classification_id;
335}
336
337sub _check_name {
338    my ($invocant, $name) = @_;
339
340    $name = trim($name);
341    $name || ThrowUserError('product_blank_name');
342
343    if (length($name) > MAX_PRODUCT_SIZE) {
344        ThrowUserError('product_name_too_long', {'name' => $name});
345    }
346
347    my $product = new Bugzilla::Product({name => $name});
348    if ($product && (!ref $invocant || $product->id != $invocant->id)) {
349        # Check for exact case sensitive match:
350        if ($product->name eq $name) {
351            ThrowUserError('product_name_already_in_use', {'product' => $product->name});
352        }
353        else {
354            ThrowUserError('product_name_diff_in_case', {'product'          => $name,
355                                                         'existing_product' => $product->name});
356        }
357    }
358    return $name;
359}
360
361sub _check_description {
362    my ($invocant, $description) = @_;
363
364    $description  = trim($description);
365    $description || ThrowUserError('product_must_have_description');
366    return $description;
367}
368
369sub _check_version {
370    my ($invocant, $version) = @_;
371
372    $version = trim($version);
373    $version || ThrowUserError('product_must_have_version');
374    # We will check the version length when Bugzilla::Version->create will do it.
375    return $version;
376}
377
378sub _check_default_milestone {
379    my ($invocant, $milestone) = @_;
380
381    # Do nothing if target milestones are not in use.
382    unless (Bugzilla->params->{'usetargetmilestone'}) {
383        return (ref $invocant) ? $invocant->default_milestone : '---';
384    }
385
386    $milestone = trim($milestone);
387
388    if (ref $invocant) {
389        # The default milestone must be one of the existing milestones.
390        my $mil_obj = new Bugzilla::Milestone({name => $milestone, product => $invocant});
391
392        $mil_obj || ThrowUserError('product_must_define_defaultmilestone',
393                                   {product   => $invocant->name,
394                                    milestone => $milestone});
395    }
396    else {
397        $milestone ||= '---';
398    }
399    return $milestone;
400}
401
402sub _check_milestone_url {
403    my ($invocant, $url) = @_;
404
405    # Do nothing if target milestones are not in use.
406    unless (Bugzilla->params->{'usetargetmilestone'}) {
407        return (ref $invocant) ? $invocant->milestone_url : '';
408    }
409
410    $url = trim($url || '');
411    return $url;
412}
413
414#####################################
415# Implement Bugzilla::Field::Choice #
416#####################################
417
418use constant FIELD_NAME => 'product';
419use constant is_default => 0;
420
421###############################
422####       Methods         ####
423###############################
424
425sub _create_bug_group {
426    my $self = shift;
427    my $dbh = Bugzilla->dbh;
428
429    my $group_name = $self->name;
430    while (new Bugzilla::Group({name => $group_name})) {
431        $group_name .= '_';
432    }
433    my $group_description = get_text('bug_group_description', {product => $self});
434
435    my $group = Bugzilla::Group->create({name        => $group_name,
436                                         description => $group_description,
437                                         isbuggroup  => 1});
438
439    # Associate the new group and new product.
440    $dbh->do('INSERT INTO group_control_map
441              (group_id, product_id, membercontrol, othercontrol)
442              VALUES (?, ?, ?, ?)',
443              undef, ($group->id, $self->id, CONTROLMAPDEFAULT, CONTROLMAPNA));
444}
445
446sub _create_series {
447    my $self = shift;
448
449    my @series;
450    # We do every status, every resolution, and an "opened" one as well.
451    foreach my $bug_status (@{get_legal_field_values('bug_status')}) {
452        push(@series, [$bug_status, "bug_status=" . url_quote($bug_status)]);
453    }
454
455    foreach my $resolution (@{get_legal_field_values('resolution')}) {
456        next if !$resolution;
457        push(@series, [$resolution, "resolution=" . url_quote($resolution)]);
458    }
459
460    my @openedstatuses = BUG_STATE_OPEN;
461    my $query = join("&", map { "bug_status=" . url_quote($_) } @openedstatuses);
462    push(@series, [get_text('series_all_open'), $query]);
463
464    foreach my $sdata (@series) {
465        my $series = new Bugzilla::Series(undef, $self->name,
466                        get_text('series_subcategory'),
467                        $sdata->[0], Bugzilla->user->id, 1,
468                        $sdata->[1] . "&product=" . url_quote($self->name), 1);
469        $series->writeToDatabase();
470    }
471}
472
473sub set_name { $_[0]->set('name', $_[1]); }
474sub set_description { $_[0]->set('description', $_[1]); }
475sub set_default_milestone { $_[0]->set('defaultmilestone', $_[1]); }
476sub set_is_active { $_[0]->set('isactive', $_[1]); }
477sub set_allows_unconfirmed { $_[0]->set('allows_unconfirmed', $_[1]); }
478
479sub set_group_controls {
480    my ($self, $group, $settings) = @_;
481
482    $group->is_active_bug_group
483      || ThrowUserError('product_illegal_group', {group => $group});
484
485    scalar(keys %$settings)
486      || ThrowCodeError('product_empty_group_controls', {group => $group});
487
488    # We store current settings for this group.
489    my $gs = $self->group_controls->{$group->id};
490    # If there is no entry for this group yet, create a default hash.
491    unless (defined $gs) {
492        $gs = { entry          => 0,
493                membercontrol  => CONTROLMAPNA,
494                othercontrol   => CONTROLMAPNA,
495                canedit        => 0,
496                editcomponents => 0,
497                editbugs       => 0,
498                canconfirm     => 0,
499                group          => $group };
500    }
501
502    # Both settings must be defined, or none of them can be updated.
503    if (defined $settings->{membercontrol} && defined $settings->{othercontrol}) {
504        #  Legality of control combination is a function of
505        #  membercontrol\othercontrol
506        #                 NA SH DE MA
507        #              NA  +  -  -  -
508        #              SH  +  +  +  +
509        #              DE  +  -  +  +
510        #              MA  -  -  -  +
511        foreach my $field ('membercontrol', 'othercontrol') {
512            my ($is_legal) = grep { $settings->{$field} == $_ }
513              (CONTROLMAPNA, CONTROLMAPSHOWN, CONTROLMAPDEFAULT, CONTROLMAPMANDATORY);
514            defined $is_legal || ThrowCodeError('product_illegal_group_control',
515                                   { field => $field, value => $settings->{$field} });
516        }
517        unless ($settings->{membercontrol} == $settings->{othercontrol}
518                || $settings->{membercontrol} == CONTROLMAPSHOWN
519                || ($settings->{membercontrol} == CONTROLMAPDEFAULT
520                    && $settings->{othercontrol} != CONTROLMAPSHOWN))
521        {
522            ThrowUserError('illegal_group_control_combination', {groupname => $group->name});
523        }
524        $gs->{membercontrol} = $settings->{membercontrol};
525        $gs->{othercontrol} = $settings->{othercontrol};
526    }
527
528    foreach my $field ('entry', 'canedit', 'editcomponents', 'editbugs', 'canconfirm') {
529        next unless defined $settings->{$field};
530        $gs->{$field} = $settings->{$field} ? 1 : 0;
531    }
532    $self->{group_controls}->{$group->id} = $gs;
533    $self->{check_group_controls} = 1;
534}
535
536sub components {
537    my $self = shift;
538    my $dbh = Bugzilla->dbh;
539
540    if (!defined $self->{components}) {
541        my $ids = $dbh->selectcol_arrayref(q{
542            SELECT id FROM components
543            WHERE product_id = ?
544            ORDER BY name}, undef, $self->id);
545
546        require Bugzilla::Component;
547        $self->{components} = Bugzilla::Component->new_from_list($ids);
548    }
549    return $self->{components};
550}
551
552sub group_controls {
553    my ($self, $full_data) = @_;
554    my $dbh = Bugzilla->dbh;
555
556    # By default, we don't return groups which are not listed in
557    # group_control_map. If $full_data is true, then we also
558    # return groups whose settings could be set for the product.
559    my $where_or_and = 'WHERE';
560    my $and_or_where = 'AND';
561    if ($full_data) {
562        $where_or_and = 'AND';
563        $and_or_where = 'WHERE';
564    }
565
566    # If $full_data is true, we collect all the data in all cases,
567    # even if the cache is already populated.
568    # $full_data is never used except in the very special case where
569    # all configurable bug groups are displayed to administrators,
570    # so we don't care about collecting all the data again in this case.
571    if (!defined $self->{group_controls} || $full_data) {
572        # Include name to the list, to allow us sorting data more easily.
573        my $query = qq{SELECT id, name, entry, membercontrol, othercontrol,
574                              canedit, editcomponents, editbugs, canconfirm
575                         FROM groups
576                              LEFT JOIN group_control_map
577                              ON id = group_id
578                $where_or_and product_id = ?
579                $and_or_where isbuggroup = 1};
580        $self->{group_controls} =
581            $dbh->selectall_hashref($query, 'id', undef, $self->id);
582
583        # For each group ID listed above, create and store its group object.
584        my @gids = keys %{$self->{group_controls}};
585        my $groups = Bugzilla::Group->new_from_list(\@gids);
586        $self->{group_controls}->{$_->id}->{group} = $_ foreach @$groups;
587    }
588
589    # We never cache bug counts, for the same reason as above.
590    if ($full_data) {
591        my $counts =
592          $dbh->selectall_arrayref('SELECT group_id, COUNT(bugs.bug_id) AS bug_count
593                                      FROM bug_group_map
594                                INNER JOIN bugs
595                                        ON bugs.bug_id = bug_group_map.bug_id
596                                     WHERE bugs.product_id = ? ' .
597                                     $dbh->sql_group_by('group_id'),
598                          {'Slice' => {}}, $self->id);
599        foreach my $data (@$counts) {
600            $self->{group_controls}->{$data->{group_id}}->{bug_count} = $data->{bug_count};
601        }
602    }
603    return $self->{group_controls};
604}
605
606sub groups_available {
607    my ($self) = @_;
608    return $self->{groups_available} if defined $self->{groups_available};
609    my $dbh = Bugzilla->dbh;
610    my $shown = CONTROLMAPSHOWN;
611    my $default = CONTROLMAPDEFAULT;
612    my %member_groups = @{ $dbh->selectcol_arrayref(
613        "SELECT group_id, membercontrol
614           FROM group_control_map
615                INNER JOIN groups ON group_control_map.group_id = groups.id
616          WHERE isbuggroup = 1 AND isactive = 1 AND product_id = ?
617                AND (membercontrol = $shown OR membercontrol = $default)
618                AND " . Bugzilla->user->groups_in_sql(),
619        {Columns=>[1,2]}, $self->id) };
620    # We don't need to check the group membership here, because we only
621    # add these groups to the list below if the group isn't already listed
622    # for membercontrol.
623    my %other_groups = @{ $dbh->selectcol_arrayref(
624        "SELECT group_id, othercontrol
625           FROM group_control_map
626                INNER JOIN groups ON group_control_map.group_id = groups.id
627          WHERE isbuggroup = 1 AND isactive = 1 AND product_id = ?
628                AND (othercontrol = $shown OR othercontrol = $default)",
629        {Columns=>[1,2]}, $self->id) };
630
631    # If the user is a member, then we use the membercontrol value.
632    # Otherwise, we use the othercontrol value.
633    my %all_groups = %member_groups;
634    foreach my $id (keys %other_groups) {
635        if (!defined $all_groups{$id}) {
636            $all_groups{$id} = $other_groups{$id};
637        }
638    }
639
640    my $available = Bugzilla::Group->new_from_list([keys %all_groups]);
641    foreach my $group (@$available) {
642        $group->{is_default} = 1 if $all_groups{$group->id} == $default;
643    }
644
645    $self->{groups_available} = $available;
646    return $self->{groups_available};
647}
648
649sub groups_mandatory {
650    my ($self) = @_;
651    return $self->{groups_mandatory} if $self->{groups_mandatory};
652    my $groups = Bugzilla->user->groups_as_string;
653    my $mandatory = CONTROLMAPMANDATORY;
654    # For membercontrol we don't check group_id IN, because if membercontrol
655    # is Mandatory, the group is Mandatory for everybody, regardless of their
656    # group membership.
657    my $ids = Bugzilla->dbh->selectcol_arrayref(
658        "SELECT group_id
659           FROM group_control_map
660                INNER JOIN groups ON group_control_map.group_id = groups.id
661          WHERE product_id = ? AND isactive = 1
662                AND (membercontrol = $mandatory
663                     OR (othercontrol = $mandatory
664                         AND group_id NOT IN ($groups)))",
665        undef, $self->id);
666    $self->{groups_mandatory} = Bugzilla::Group->new_from_list($ids);
667    return $self->{groups_mandatory};
668}
669
670# We don't just check groups_valid, because we want to know specifically
671# if this group can be validly set by the currently-logged-in user.
672sub group_is_settable {
673    my ($self, $group) = @_;
674
675    return 0 unless ($group->is_active && $group->is_bug_group);
676
677    my $is_mandatory = grep { $group->id == $_->id }
678                            @{ $self->groups_mandatory };
679    my $is_available = grep { $group->id == $_->id }
680                            @{ $self->groups_available };
681    return ($is_mandatory or $is_available) ? 1 : 0;
682}
683
684sub group_is_valid {
685    my ($self, $group) = @_;
686    return grep($_->id == $group->id, @{ $self->groups_valid }) ? 1 : 0;
687}
688
689sub groups_valid {
690    my ($self) = @_;
691    return $self->{groups_valid} if defined $self->{groups_valid};
692
693    # Note that we don't check OtherControl below, because there is no
694    # valid NA/* combination.
695    my $ids = Bugzilla->dbh->selectcol_arrayref(
696        "SELECT DISTINCT group_id
697          FROM group_control_map AS gcm
698               INNER JOIN groups ON gcm.group_id = groups.id
699         WHERE product_id = ? AND isbuggroup = 1
700               AND membercontrol != " . CONTROLMAPNA,  undef, $self->id);
701    $self->{groups_valid} = Bugzilla::Group->new_from_list($ids);
702    return $self->{groups_valid};
703}
704
705sub versions {
706    my $self = shift;
707    my $dbh = Bugzilla->dbh;
708
709    if (!defined $self->{versions}) {
710        my $ids = $dbh->selectcol_arrayref(q{
711            SELECT id FROM versions
712            WHERE product_id = ?}, undef, $self->id);
713
714        $self->{versions} = Bugzilla::Version->new_from_list($ids);
715    }
716    return $self->{versions};
717}
718
719sub milestones {
720    my $self = shift;
721    my $dbh = Bugzilla->dbh;
722
723    if (!defined $self->{milestones}) {
724        my $ids = $dbh->selectcol_arrayref(q{
725            SELECT id FROM milestones
726             WHERE product_id = ?}, undef, $self->id);
727
728        $self->{milestones} = Bugzilla::Milestone->new_from_list($ids);
729    }
730    return $self->{milestones};
731}
732
733sub bug_count {
734    my $self = shift;
735    my $dbh = Bugzilla->dbh;
736
737    if (!defined $self->{'bug_count'}) {
738        $self->{'bug_count'} = $dbh->selectrow_array(qq{
739            SELECT COUNT(bug_id) FROM bugs
740            WHERE product_id = ?}, undef, $self->id);
741
742    }
743    return $self->{'bug_count'};
744}
745
746sub bug_ids {
747    my $self = shift;
748    my $dbh = Bugzilla->dbh;
749
750    if (!defined $self->{'bug_ids'}) {
751        $self->{'bug_ids'} =
752            $dbh->selectcol_arrayref(q{SELECT bug_id FROM bugs
753                                       WHERE product_id = ?},
754                                     undef, $self->id);
755    }
756    return $self->{'bug_ids'};
757}
758
759sub user_has_access {
760    my ($self, $user) = @_;
761
762    return Bugzilla->dbh->selectrow_array(
763        'SELECT CASE WHEN group_id IS NULL THEN 1 ELSE 0 END
764           FROM products LEFT JOIN group_control_map
765                ON group_control_map.product_id = products.id
766                   AND group_control_map.entry != 0
767                   AND group_id NOT IN (' . $user->groups_as_string . ')
768          WHERE products.id = ? ' . Bugzilla->dbh->sql_limit(1),
769          undef, $self->id);
770}
771
772sub flag_types {
773    my $self = shift;
774
775    return $self->{'flag_types'} if defined $self->{'flag_types'};
776
777    # We cache flag types to avoid useless calls to get_clusions().
778    my $cache = Bugzilla->request_cache->{flag_types_per_product} ||= {};
779    $self->{flag_types} = {};
780    my $prod_id = $self->id;
781    my $flagtypes = Bugzilla::FlagType::match({ product_id => $prod_id });
782
783    foreach my $type ('bug', 'attachment') {
784        my @flags = grep { $_->target_type eq $type } @$flagtypes;
785        $self->{flag_types}->{$type} = \@flags;
786
787        # Also populate component flag types, while we are here.
788        foreach my $comp (@{$self->components}) {
789            $comp->{flag_types} ||= {};
790            my $comp_id = $comp->id;
791
792            foreach my $flag (@flags) {
793                my $flag_id = $flag->id;
794                $cache->{$flag_id} ||= $flag;
795                my $i = $cache->{$flag_id}->inclusions_as_hash;
796                my $e = $cache->{$flag_id}->exclusions_as_hash;
797                my $included = $i->{0}->{0} || $i->{0}->{$comp_id}
798                               || $i->{$prod_id}->{0} || $i->{$prod_id}->{$comp_id};
799                my $excluded = $e->{0}->{0} || $e->{0}->{$comp_id}
800                               || $e->{$prod_id}->{0} || $e->{$prod_id}->{$comp_id};
801                push(@{$comp->{flag_types}->{$type}}, $flag) if ($included && !$excluded);
802            }
803        }
804    }
805    return $self->{'flag_types'};
806}
807
808sub classification {
809    my $self = shift;
810    $self->{'classification'} ||=
811        new Bugzilla::Classification($self->classification_id);
812    return $self->{'classification'};
813}
814
815###############################
816####      Accessors      ######
817###############################
818
819sub allows_unconfirmed { return $_[0]->{'allows_unconfirmed'}; }
820sub description       { return $_[0]->{'description'};       }
821sub is_active         { return $_[0]->{'isactive'};       }
822sub default_milestone { return $_[0]->{'defaultmilestone'};  }
823sub classification_id { return $_[0]->{'classification_id'}; }
824
825###############################
826####      Subroutines    ######
827###############################
828
829sub check {
830    my ($class, $params) = @_;
831    $params = { name => $params } if !ref $params;
832    if (!$params->{allow_inaccessible}) {
833        $params->{_error} = 'product_access_denied';
834    }
835    my $product = $class->SUPER::check($params);
836
837    if (!$params->{allow_inaccessible}
838        && !Bugzilla->user->can_access_product($product))
839    {
840        ThrowUserError('product_access_denied', $params);
841    }
842    return $product;
843}
844
8451;
846
847__END__
848
849=head1 NAME
850
851Bugzilla::Product - Bugzilla product class.
852
853=head1 SYNOPSIS
854
855    use Bugzilla::Product;
856
857    my $product = new Bugzilla::Product(1);
858    my $product = new Bugzilla::Product({ name => 'AcmeProduct' });
859
860    my @components      = $product->components();
861    my $groups_controls = $product->group_controls();
862    my @milestones      = $product->milestones();
863    my @versions        = $product->versions();
864    my $bugcount        = $product->bug_count();
865    my $bug_ids         = $product->bug_ids();
866    my $has_access      = $product->user_has_access($user);
867    my $flag_types      = $product->flag_types();
868    my $classification  = $product->classification();
869
870    my $id               = $product->id;
871    my $name             = $product->name;
872    my $description      = $product->description;
873    my isactive          = $product->is_active;
874    my $defaultmilestone = $product->default_milestone;
875    my $classificationid = $product->classification_id;
876    my $allows_unconfirmed = $product->allows_unconfirmed;
877
878=head1 DESCRIPTION
879
880Product.pm represents a product object. It is an implementation
881of L<Bugzilla::Object>, and thus provides all methods that
882L<Bugzilla::Object> provides.
883
884The methods that are specific to C<Bugzilla::Product> are listed
885below.
886
887=head1 METHODS
888
889=over
890
891=item C<components>
892
893 Description: Returns an array of component objects belonging to
894              the product.
895
896 Params:      none.
897
898 Returns:     An array of Bugzilla::Component object.
899
900=item C<group_controls()>
901
902 Description: Returns a hash (group id as key) with all product
903              group controls.
904
905 Params:      $full_data (optional, false by default) - when true,
906              the number of bugs per group applicable to the product
907              is also returned. Moreover, bug groups which have no
908              special settings for the product are also returned.
909
910 Returns:     A hash with group id as key and hash containing
911              a Bugzilla::Group object and the properties of group
912              relative to the product.
913
914=item C<groups_available>
915
916Tells you what groups are set to Default or Shown for the
917currently-logged-in user (taking into account both OtherControl and
918MemberControl). Returns an arrayref of L<Bugzilla::Group> objects with
919an extra hash keys set, C<is_default>, which is true if the group
920is set to Default for the currently-logged-in user.
921
922=item C<groups_mandatory>
923
924Tells you what groups are mandatory for bugs in this product, for the
925currently-logged-in user. Returns an arrayref of C<Bugzilla::Group> objects.
926
927=item C<group_is_settable>
928
929=over
930
931=item B<Description>
932
933Tells you whether or not the currently-logged-in user can set a group
934on a bug (whether or not they match the MemberControl/OtherControl
935settings for a group in this product). Groups that are C<Mandatory> for
936the currently-loggeed-in user are also acceptable since from Bugzilla's
937perspective, there's no problem with "setting" a Mandatory group on
938a bug. (In fact, the user I<must> set the Mandatory group on the bug.)
939
940=item B<Params>
941
942=over
943
944=item C<$group> - A L<Bugzilla::Group> object.
945
946=back
947
948=item B<Returns>
949
950C<1> if the group is valid in this product, C<0> otherwise.
951
952=back
953
954
955=item C<groups_valid>
956
957=over
958
959=item B<Description>
960
961Returns an arrayref of L<Bugzilla::Group> objects, representing groups
962that bugs could validly be restricted to within this product. Used mostly
963when you need the list of all possible groups that could be set in a product
964by anybody, disregarding whether or not the groups are active or who the
965currently logged-in user is.
966
967B<Note>: This doesn't check whether or not the current user can add/remove
968bugs to/from these groups. It just tells you that bugs I<could be in> these
969groups, in this product.
970
971=item B<Params> (none)
972
973=item B<Returns> An arrayref of L<Bugzilla::Group> objects.
974
975=back
976
977=item C<group_is_valid>
978
979Returns C<1> if the passed-in L<Bugzilla::Group> or group id could be set
980on a bug by I<anybody>, in this product. Even inactive groups are considered
981valid. (This is a shortcut for searching L</groups_valid> to find out if
982a group is valid in a particular product.)
983
984=item C<versions>
985
986 Description: Returns all valid versions for that product.
987
988 Params:      none.
989
990 Returns:     An array of Bugzilla::Version objects.
991
992=item C<milestones>
993
994 Description: Returns all valid milestones for that product.
995
996 Params:      none.
997
998 Returns:     An array of Bugzilla::Milestone objects.
999
1000=item C<bug_count()>
1001
1002 Description: Returns the total of bugs that belong to the product.
1003
1004 Params:      none.
1005
1006 Returns:     Integer with the number of bugs.
1007
1008=item C<bug_ids()>
1009
1010 Description: Returns the IDs of bugs that belong to the product.
1011
1012 Params:      none.
1013
1014 Returns:     An array of integer.
1015
1016=item C<user_has_access()>
1017
1018 Description: Tells you whether or not the user is allowed to enter
1019              bugs into this product, based on the C<entry> group
1020              control. To see whether or not a user can actually
1021              enter a bug into a product, use C<$user-&gt;can_enter_product>.
1022
1023 Params:      C<$user> - A Bugzilla::User object.
1024
1025 Returns      C<1> If this user's groups allow him C<entry> access to
1026              this Product, C<0> otherwise.
1027
1028=item C<flag_types()>
1029
1030 Description: Returns flag types available for at least one of
1031              its components.
1032
1033 Params:      none.
1034
1035 Returns:     Two references to an array of flagtype objects.
1036
1037=item C<classification()>
1038
1039 Description: Returns the classification the product belongs to.
1040
1041 Params:      none.
1042
1043 Returns:     A Bugzilla::Classification object.
1044
1045=back
1046
1047=head1 SUBROUTINES
1048
1049=over
1050
1051=item C<preload>
1052
1053When passed an arrayref of C<Bugzilla::Product> objects, preloads their
1054L</milestones>, L</components>, and L</versions>, which is much faster
1055than calling those accessors on every item in the array individually.
1056
1057If the 2nd argument passed to C<preload> is true, flag types for these
1058products and their components are also preloaded.
1059
1060This function is not exported, so must be called like
1061C<Bugzilla::Product::preload($products)>.
1062
1063=back
1064
1065=head1 SEE ALSO
1066
1067L<Bugzilla::Object>
1068
1069=cut
1070