# -*- Mode: perl; indent-tabs-mode: nil -*- # # The contents of this file are subject to the Mozilla Public # License Version 1.1 (the "License"); you may not use this file # except in compliance with the License. You may obtain a copy of # the License at http://www.mozilla.org/MPL/ # # Software distributed under the License is distributed on an "AS # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or # implied. See the License for the specific language governing # rights and limitations under the License. # # The Original Code is the Bugzilla Bug Tracking System. # # The Initial Developer of the Original Code is Netscape Communications # Corporation. Portions created by Netscape are # Copyright (C) 1998 Netscape Communications Corporation. All # Rights Reserved. # # Contributor(s): Dawn Endico # Terry Weissman # Chris Yeh # Bradley Baetz # Dave Miller package Bugzilla::Bug; use strict; use vars qw($legal_keywords @legal_platform @legal_priority @legal_severity @legal_opsys @legal_bugs_status @settable_resolution %components %versions %target_milestone @enterable_products %milestoneurl %prodmaxvotes); use CGI::Carp qw(fatalsToBrowser); use Bugzilla; use Bugzilla::Attachment; use Bugzilla::Config; use Bugzilla::Constants; use Bugzilla::Flag; use Bugzilla::FlagType; use Bugzilla::User; use Bugzilla::Util; use Bugzilla::Error; sub fields { # Keep this ordering in sync with bugzilla.dtd my @fields = qw(bug_id alias creation_ts short_desc delta_ts reporter_accessible cclist_accessible classification_id classification product component version rep_platform op_sys bug_status resolution bug_file_loc status_whiteboard keywords priority bug_severity target_milestone dependson blocked votes reporter assigned_to cc ); if (Param('useqacontact')) { push @fields, "qa_contact"; } if (Param('timetrackinggroup')) { push @fields, qw(estimated_time remaining_time actual_time deadline); } return @fields; } my %ok_field; foreach my $key (qw(error groups longdescs milestoneurl attachments isopened isunconfirmed flag_types num_attachment_flag_types show_attachment_flags use_keywords any_flags_requesteeble ), fields()) { $ok_field{$key}++; } # create a new empty bug # sub new { my $type = shift(); my %bug; # create a ref to an empty hash and bless it # my $self = {%bug}; bless $self, $type; # construct from a hash containing a bug's info # if ($#_ == 1) { $self->initBug(@_); } else { confess("invalid number of arguments \($#_\)($_)"); } # bless as a Bug # return $self; } # dump info about bug into hash unless user doesn't have permission # user_id 0 is used when person is not logged in. # sub initBug { my $self = shift(); my ($bug_id, $user_id) = (@_); my $dbh = Bugzilla->dbh; $bug_id = trim($bug_id); my $old_bug_id = $bug_id; # If the bug ID isn't numeric, it might be an alias, so try to convert it. $bug_id = &::BugAliasToID($bug_id) if $bug_id !~ /^0*[1-9][0-9]*$/; if ((! defined $bug_id) || (!$bug_id) || (!detaint_natural($bug_id))) { # no bug number given or the alias didn't match a bug $self->{'bug_id'} = $old_bug_id; $self->{'error'} = "InvalidBugId"; return $self; } # default userid 0, or get DBID if you used an email address unless (defined $user_id) { $user_id = 0; } else { if ($user_id =~ /^\@/) { $user_id = login_to_id($user_id); } } $self->{'who'} = new Bugzilla::User($user_id); my $query = " SELECT bugs.bug_id, alias, products.classification_id, classifications.name, bugs.product_id, products.name, version, rep_platform, op_sys, bug_status, resolution, priority, bug_severity, bugs.component_id, components.name, assigned_to, reporter, bug_file_loc, short_desc, target_milestone, qa_contact, status_whiteboard, " . $dbh->sql_date_format('creation_ts', '%Y.%m.%d %H:%i') . ", delta_ts, COALESCE(SUM(votes.vote_count), 0), reporter_accessible, cclist_accessible, estimated_time, remaining_time, " . $dbh->sql_date_format('deadline', '%Y-%m-%d') . " FROM bugs LEFT JOIN votes using(bug_id), classifications, products, components WHERE bugs.bug_id = ? AND classifications.id = products.classification_id AND products.id = bugs.product_id AND components.id = bugs.component_id GROUP BY bugs.bug_id"; my $bug_sth = $dbh->prepare($query); $bug_sth->execute($bug_id); my @row; if ((@row = $bug_sth->fetchrow_array()) && $self->{'who'}->can_see_bug($bug_id)) { my $count = 0; my %fields; foreach my $field ("bug_id", "alias", "classification_id", "classification", "product_id", "product", "version", "rep_platform", "op_sys", "bug_status", "resolution", "priority", "bug_severity", "component_id", "component", "assigned_to", "reporter", "bug_file_loc", "short_desc", "target_milestone", "qa_contact", "status_whiteboard", "creation_ts", "delta_ts", "votes", "reporter_accessible", "cclist_accessible", "estimated_time", "remaining_time", "deadline") { $fields{$field} = shift @row; if (defined $fields{$field}) { $self->{$field} = $fields{$field}; } $count++; } } elsif (@row) { $self->{'bug_id'} = $bug_id; $self->{'error'} = "NotPermitted"; return $self; } else { $self->{'bug_id'} = $bug_id; $self->{'error'} = "NotFound"; return $self; } $self->{'assigned_to'} = new Bugzilla::User($self->{'assigned_to'}); $self->{'reporter'} = new Bugzilla::User($self->{'reporter'}); if (Param('useqacontact') && $self->{'qa_contact'} > 0) { $self->{'qa_contact'} = new Bugzilla::User($self->{'qa_contact'}); } else { $self->{'qa_contact'} = undef; } my $cc_ref = $dbh->selectcol_arrayref( q{SELECT profiles.login_name FROM cc, profiles WHERE bug_id = ? AND cc.who = profiles.userid ORDER BY profiles.login_name}, undef, $bug_id); if (scalar(@$cc_ref)) { $self->{'cc'} = $cc_ref; } if (@::legal_keywords) { # Get all entries and make them an array. my $list_ref = $dbh->selectcol_arrayref( "SELECT keyworddefs.name FROM keyworddefs, keywords WHERE keywords.bug_id = ? AND keyworddefs.id = keywords.keywordid ORDER BY keyworddefs.name", undef, ($bug_id)); if ($list_ref) { $self->{'keywords'} = join(', ', @$list_ref); } } $self->{'attachments'} = Bugzilla::Attachment::query($self->{bug_id}); # The types of flags that can be set on this bug. # If none, no UI for setting flags will be displayed. my $flag_types = Bugzilla::FlagType::match({ 'target_type' => 'bug', 'product_id' => $self->{'product_id'}, 'component_id' => $self->{'component_id'} }); foreach my $flag_type (@$flag_types) { $flag_type->{'flags'} = Bugzilla::Flag::match({ 'bug_id' => $self->{bug_id}, 'type_id' => $flag_type->{'id'}, 'target_type' => 'bug', 'is_active' => 1 }); } $self->{'flag_types'} = $flag_types; $self->{'any_flags_requesteeble'} = grep($_->{'is_requesteeble'}, @$flag_types); # The number of types of flags that can be set on attachments to this bug # and the number of flags on those attachments. One of these counts must be # greater than zero in order for the "flags" column to appear in the table # of attachments. my $num_attachment_flag_types = Bugzilla::FlagType::count({ 'target_type' => 'attachment', 'product_id' => $self->{'product_id'}, 'component_id' => $self->{'component_id'} }); my $num_attachment_flags = Bugzilla::Flag::count({ 'target_type' => 'attachment', 'bug_id' => $self->{bug_id}, 'is_active' => 1 }); $self->{'show_attachment_flags'} = $num_attachment_flag_types || $num_attachment_flags; $self->{'milestoneurl'} = $::milestoneurl{$self->{product}}; $self->{'isunconfirmed'} = ($self->{bug_status} eq 'UNCONFIRMED'); $self->{'isopened'} = &::IsOpenedState($self->{bug_status}); my @depends = EmitDependList("blocked", "dependson", $bug_id); if (@depends) { $self->{'dependson'} = \@depends; } my @blocked = EmitDependList("dependson", "blocked", $bug_id); if (@blocked) { $self->{'blocked'} = \@blocked; } return $self; } sub dup_id { my ($self) = @_; return $self->{'dup_id'} if exists $self->{'dup_id'}; $self->{'dup_id'} = undef; if ($self->{'resolution'} eq 'DUPLICATE') { my $dbh = Bugzilla->dbh; $self->{'dup_id'} = $dbh->selectrow_array(q{SELECT dupe_of FROM duplicates WHERE dupe = ?}, undef, $self->{'bug_id'}); } return $self->{'dup_id'}; } sub actual_time { my ($self) = @_; return $self->{'actual_time'} if exists $self->{'actual_time'}; return undef unless Bugzilla->user->in_group(Param("timetrackinggroup")); my $sth = Bugzilla->dbh->prepare("SELECT SUM(work_time) FROM longdescs WHERE longdescs.bug_id=?"); $sth->execute($self->{bug_id}); $self->{'actual_time'} = $sth->fetchrow_array(); return $self->{'actual_time'}; } sub longdescs { my ($self) = @_; return $self->{'longdescs'} if exists $self->{'longdescs'}; $self->{'longdescs'} = GetComments($self->{bug_id}); return $self->{'longdescs'}; } sub use_keywords { return @::legal_keywords; } sub use_votes { my ($self) = @_; return Param('usevotes') && $::prodmaxvotes{$self->{product}} > 0; } sub groups { my $self = shift; return $self->{'groups'} if exists $self->{'groups'}; my $dbh = Bugzilla->dbh; my @groups; # Some of this stuff needs to go into Bugzilla::User # For every group, we need to know if there is ANY bug_group_map # record putting the current bug in that group and if there is ANY # user_group_map record putting the user in that group. # The LEFT JOINs are checking for record existence. # my $sth = $dbh->prepare( "SELECT DISTINCT groups.id, name, description," . " bug_group_map.group_id IS NOT NULL," . " user_group_map.group_id IS NOT NULL," . " isactive, membercontrol, othercontrol" . " FROM groups" . " LEFT JOIN bug_group_map" . " ON bug_group_map.group_id = groups.id" . " AND bug_id = ?" . " LEFT JOIN user_group_map" . " ON user_group_map.group_id = groups.id" . " AND user_id = ?" . " AND isbless = 0" . " LEFT JOIN group_control_map" . " ON group_control_map.group_id = groups.id" . " AND group_control_map.product_id = ? " . " WHERE isbuggroup = 1"); $sth->execute($self->{'bug_id'}, Bugzilla->user->id, $self->{'product_id'}); while (my ($groupid, $name, $description, $ison, $ingroup, $isactive, $membercontrol, $othercontrol) = $sth->fetchrow_array()) { $membercontrol ||= 0; # For product groups, we only want to use the group if either # (1) The bit is set and not required, or # (2) The group is Shown or Default for members and # the user is a member of the group. if ($ison || ($isactive && $ingroup && (($membercontrol == CONTROLMAPDEFAULT) || ($membercontrol == CONTROLMAPSHOWN)) )) { my $ismandatory = $isactive && ($membercontrol == CONTROLMAPMANDATORY); push (@groups, { "bit" => $groupid, "name" => $name, "ison" => $ison, "ingroup" => $ingroup, "mandatory" => $ismandatory, "description" => $description }); } } $self->{'groups'} = \@groups; return $self->{'groups'}; } sub user { my $self = shift; return $self->{'user'} if exists $self->{'user'}; my @movers = map { trim $_ } split(",", Param("movers")); my $canmove = Param("move-enabled") && Bugzilla->user->id && (lsearch(\@movers, Bugzilla->user->login) != -1); # In the below, if the person hasn't logged in, then we treat them # as if they can do anything. That's because we don't know why they # haven't logged in; it may just be because they don't use cookies. # Display everything as if they have all the permissions in the # world; their permissions will get checked when they log in and # actually try to make the change. my $unknown_privileges = !Bugzilla->user->id || Bugzilla->user->in_group("editbugs"); my $canedit = $unknown_privileges || Bugzilla->user->id == $self->{'assigned_to'}{'id'} || (Param('useqacontact') && $self->{'qa_contact'} && Bugzilla->user->id == $self->{'qa_contact'}{'id'}); my $canconfirm = $unknown_privileges || Bugzilla->user->in_group("canconfirm"); my $isreporter = Bugzilla->user->id && Bugzilla->user->id == $self->{'reporter'}{'id'}; $self->{'user'} = {canmove => $canmove, canconfirm => $canconfirm, canedit => $canedit, isreporter => $isreporter}; return $self->{'user'}; } sub choices { my $self = shift; return $self->{'choices'} if exists $self->{'choices'}; &::GetVersionTable(); $self->{'choices'} = {}; # Fiddle the product list. my $seen_curr_prod; my @prodlist; foreach my $product (@::enterable_products) { if ($product eq $self->{'product'}) { # if it's the product the bug is already in, it's ALWAYS in # the popup, period, whether the user can see it or not, and # regardless of the disallownew setting. $seen_curr_prod = 1; push(@prodlist, $product); next; } if (!&::CanEnterProduct($product)) { # If we're using bug groups to restrict entry on products, and # this product has an entry group, and the user is not in that # group, we don't want to include that product in this list. next; } push(@prodlist, $product); } # The current product is part of the popup, even if new bugs are no longer # allowed for that product if (!$seen_curr_prod) { push (@prodlist, $self->{'product'}); @prodlist = sort @prodlist; } # Hack - this array contains "". See bug 106589. my @res = grep ($_, @::settable_resolution); $self->{'choices'} = { 'product' => \@prodlist, 'rep_platform' => \@::legal_platform, 'priority' => \@::legal_priority, 'bug_severity' => \@::legal_severity, 'op_sys' => \@::legal_opsys, 'bug_status' => \@::legal_bugs_status, 'resolution' => \@res, 'component' => $::components{$self->{product}}, 'version' => $::versions{$self->{product}}, 'target_milestone' => $::target_milestone{$self->{product}}, }; return $self->{'choices'}; } sub EmitDependList { my ($myfield, $targetfield, $bug_id) = (@_); my $dbh = Bugzilla->dbh; my $list_ref = $dbh->selectcol_arrayref( "SELECT dependencies.$targetfield FROM dependencies, bugs WHERE dependencies.$myfield = ? AND bugs.bug_id = dependencies.$targetfield ORDER BY dependencies.$targetfield", undef, ($bug_id)); return @$list_ref; } sub ValidateTime { my ($time, $field) = @_; # regexp verifies one or more digits, optionally followed by a period and # zero or more digits, OR we have a period followed by one or more digits # (allow negatives, though, so people can back out errors in time reporting) if ($time !~ /^-?(?:\d+(?:\.\d*)?|\.\d+)$/) { ThrowUserError("number_not_numeric", {field => "$field", num => "$time"}, "abort"); } # Only the "work_time" field is allowed to contain a negative value. if ( ($time < 0) && ($field ne "work_time") ) { ThrowUserError("number_too_small", {field => "$field", num => "$time", min_num => "0"}, "abort"); } if ($time > 99999.99) { ThrowUserError("number_too_large", {field => "$field", num => "$time", max_num => "99999.99"}, "abort"); } } sub GetComments { my ($id) = (@_); my $dbh = Bugzilla->dbh; my @comments; my $sth = $dbh->prepare( "SELECT profiles.realname AS name, profiles.login_name AS email, " . $dbh->sql_date_format('longdescs.bug_when', '%Y.%m.%d %H:%i') . " AS time, longdescs.thetext AS body, longdescs.work_time, isprivate, already_wrapped, " . $dbh->sql_date_format('longdescs.bug_when', '%Y%m%d%H%i%s') . " AS bug_when FROM longdescs, profiles WHERE profiles.userid = longdescs.who AND longdescs.bug_id = ? ORDER BY longdescs.bug_when"); $sth->execute($id); while (my $comment_ref = $sth->fetchrow_hashref()) { my %comment = %$comment_ref; # Can't use "when" as a field name in MySQL $comment{'when'} = $comment{'bug_when'}; delete($comment{'bug_when'}); $comment{'email'} .= Param('emailsuffix'); $comment{'name'} = $comment{'name'} || $comment{'email'}; push (@comments, \%comment); } return \@comments; } # CountOpenDependencies counts the number of open dependent bugs for a # list of bugs and returns a list of bug_id's and their dependency count # It takes one parameter: # - A list of bug numbers whose dependencies are to be checked sub CountOpenDependencies { my (@bug_list) = @_; my @dependencies; my $dbh = Bugzilla->dbh; my $sth = $dbh->prepare( "SELECT blocked, count(bug_status) " . "FROM bugs, dependencies " . "WHERE blocked IN (" . (join "," , @bug_list) . ") " . "AND bug_id = dependson " . "AND bug_status IN ('" . (join "','", &::OpenStates()) . "') " . "GROUP BY blocked "); $sth->execute(); while (my ($bug_id, $dependencies) = $sth->fetchrow_array()) { push(@dependencies, { bug_id => $bug_id, dependencies => $dependencies }); } return @dependencies; } sub AUTOLOAD { use vars qw($AUTOLOAD); my $attr = $AUTOLOAD; $attr =~ s/.*:://; return unless $attr=~ /[^A-Z]/; confess ("invalid bug attribute $attr") unless $ok_field{$attr}; no strict 'refs'; *$AUTOLOAD = sub { my $self = shift; if (defined $self->{$attr}) { return $self->{$attr}; } else { return ''; } }; goto &$AUTOLOAD; } 1;