# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # This Source Code Form is "Incompatible With Secondary Licenses", as # defined by the Mozilla Public License, v. 2.0. package Bugzilla::Extension::TrackingFlags; use 5.10.1; use strict; use warnings; use base qw(Bugzilla::Extension); use Bugzilla::Extension::TrackingFlags::Constants; use Bugzilla::Extension::TrackingFlags::Flag; use Bugzilla::Extension::TrackingFlags::Flag::Bug; use Bugzilla::Extension::TrackingFlags::Admin; use Bugzilla::Bug; use Bugzilla::Component; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Extension::BMO::Data; use Bugzilla::Field; use Bugzilla::Install::Filesystem; use Bugzilla::Product; use JSON; use List::MoreUtils qw(none); our $VERSION = '1'; our @FLAG_CACHE; BEGIN { *Bugzilla::tracking_flags = \&_tracking_flags; *Bugzilla::tracking_flag_names = \&_tracking_flag_names; } sub _tracking_flags { return Bugzilla::Extension::TrackingFlags::Flag->get_all(); } sub _tracking_flag_names { return Bugzilla::Extension::TrackingFlags::Flag->get_all_names(); } sub page_before_template { my ($self, $args) = @_; my $page = $args->{'page_id'}; my $vars = $args->{'vars'}; if ($page eq 'tracking_flags_admin_list.html') { Bugzilla->user->in_group('admin') || ThrowUserError('auth_failure', { group => 'admin', action => 'access', object => 'administrative_pages' }); admin_list($vars); } elsif ($page eq 'tracking_flags_admin_edit.html') { Bugzilla->user->in_group('admin') || ThrowUserError('auth_failure', { group => 'admin', action => 'access', object => 'administrative_pages' }); admin_edit($vars); } } sub template_before_process { my ($self, $args) = @_; my $file = $args->{'file'}; my $vars = $args->{'vars'}; if ($file eq 'bug/create/create.html.tmpl') { my $flags = Bugzilla::Extension::TrackingFlags::Flag->match({ product => $vars->{'product'}->name, enter_bug => 1, is_active => 1, }); $vars->{tracking_flags} = $flags; $vars->{tracking_flags_json} = _flags_to_json($flags); $vars->{tracking_flag_types} = FLAG_TYPES; $vars->{tracking_flag_components} = _flags_to_components($flags, $vars->{product}); $vars->{highest_status_firefox} = _get_highest_status_firefox($flags); } elsif ($file eq 'bug/edit.html.tmpl'|| $file eq 'bug/show.xml.tmpl' || $file eq 'email/bugmail.html.tmpl' || $file eq 'email/bugmail.txt.tmpl') { # note: bug/edit.html.tmpl doesn't support multiple bugs my $bug = exists $vars->{'bugs'} ? $vars->{'bugs'}[0] : $vars->{'bug'}; if ($bug && !$bug->{error}) { my $flags = Bugzilla::Extension::TrackingFlags::Flag->match({ product => $bug->product, component => $bug->component, bug_id => $bug->id, is_active => 1, }); $vars->{tracking_flags} = $flags; $vars->{tracking_flags_json} = _flags_to_json($flags); } $vars->{'tracking_flag_types'} = FLAG_TYPES; } elsif ($file eq 'list/edit-multiple.html.tmpl' && $vars->{'one_product'}) { $vars->{'tracking_flags'} = Bugzilla::Extension::TrackingFlags::Flag->match({ product => $vars->{'one_product'}->name, is_active => 1 }); } } sub _flags_to_json { my ($flags) = @_; my $json = { flags => {}, types => [], comments => {}, }; my %type_map = map { $_->{name} => $_ } @{ FLAG_TYPES() }; foreach my $flag (@$flags) { my $flag_type = $flag->flag_type; $json->{flags}->{$flag_type}->{$flag->name} = $flag->bug_flag->value; if ($type_map{$flag_type}->{collapsed} && !grep { $_ eq $flag_type } @{ $json->{types} }) { push @{ $json->{types} }, $flag_type; } foreach my $value (@{ $flag->values }) { if (defined($value->comment) && $value->comment ne '') { $json->{comments}->{$flag->name}->{$value->value} = $value->comment; } } } return encode_json($json); } sub _flags_to_components { my ($flags, $product) = @_; # for each component, generate a list of visible tracking flags my $json = {}; foreach my $component (@{ $product->components }) { next unless $component->is_active; foreach my $flag (@$flags) { foreach my $visibility (@{ $flag->visibility }) { if ($visibility->product_id == $product->id && (!$visibility->component_id || $visibility->component_id == $component->id)) { $json->{$component->name} //= []; push @{ $json->{$component->name} }, $flag->name; } } } } return encode_json($json); } sub _get_highest_status_firefox { my ($flags) = @_; my @status_flags = sort { $b <=> $a } map { $_->name =~ /(\d+)$/; $1 } grep { $_->is_active && $_->name =~ /^cf_status_firefox\d/ } @$flags; return @status_flags ? $status_flags[0] : undef; } sub db_schema_abstract_schema { my ($self, $args) = @_; $args->{'schema'}->{'tracking_flags'} = { FIELDS => [ id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1, }, field_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'fielddefs', COLUMN => 'id', DELETE => 'CASCADE' } }, name => { TYPE => 'varchar(64)', NOTNULL => 1, }, description => { TYPE => 'varchar(64)', NOTNULL => 1, }, type => { TYPE => 'varchar(64)', NOTNULL => 1, }, sortkey => { TYPE => 'INT2', NOTNULL => 1, DEFAULT => '0', }, enter_bug => { TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE', }, is_active => { TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE', }, ], INDEXES => [ tracking_flags_idx => { FIELDS => ['name'], TYPE => 'UNIQUE', }, ], }; $args->{'schema'}->{'tracking_flags_values'} = { FIELDS => [ id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1, }, tracking_flag_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'tracking_flags', COLUMN => 'id', DELETE => 'CASCADE', }, }, setter_group_id => { TYPE => 'INT3', NOTNULL => 0, REFERENCES => { TABLE => 'groups', COLUMN => 'id', DELETE => 'SET NULL', }, }, value => { TYPE => 'varchar(64)', NOTNULL => 1, }, sortkey => { TYPE => 'INT2', NOTNULL => 1, DEFAULT => '0', }, enter_bug => { TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE', }, is_active => { TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE', }, comment => { TYPE => 'TEXT', NOTNULL => 0, }, ], INDEXES => [ tracking_flags_values_idx => { FIELDS => ['tracking_flag_id', 'value'], TYPE => 'UNIQUE', }, ], }; $args->{'schema'}->{'tracking_flags_bugs'} = { FIELDS => [ id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1, }, tracking_flag_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'tracking_flags', COLUMN => 'id', DELETE => 'CASCADE', }, }, bug_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'bugs', COLUMN => 'bug_id', DELETE => 'CASCADE', }, }, value => { TYPE => 'varchar(64)', NOTNULL => 1, }, ], INDEXES => [ tracking_flags_bugs_idx => { FIELDS => ['tracking_flag_id', 'bug_id'], TYPE => 'UNIQUE', }, ], }; $args->{'schema'}->{'tracking_flags_visibility'} = { FIELDS => [ id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1, }, tracking_flag_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'tracking_flags', COLUMN => 'id', DELETE => 'CASCADE', }, }, product_id => { TYPE => 'INT2', NOTNULL => 1, REFERENCES => { TABLE => 'products', COLUMN => 'id', DELETE => 'CASCADE', }, }, component_id => { TYPE => 'INT2', NOTNULL => 0, REFERENCES => { TABLE => 'components', COLUMN => 'id', DELETE => 'CASCADE', }, }, ], INDEXES => [ tracking_flags_visibility_idx => { FIELDS => ['tracking_flag_id', 'product_id', 'component_id'], TYPE => 'UNIQUE', }, ], }; } sub install_update_db { my $dbh = Bugzilla->dbh; my $fk = $dbh->bz_fk_info('tracking_flags', 'field_id'); if ($fk and !defined $fk->{DELETE}) { $fk->{DELETE} = 'CASCADE'; $dbh->bz_alter_fk('tracking_flags', 'field_id', $fk); } $dbh->bz_add_column( 'tracking_flags', 'enter_bug', { TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE', } ); $dbh->bz_add_column( 'tracking_flags_values', 'comment', { TYPE => 'TEXT', NOTNULL => 0, }, ); } sub install_filesystem { my ($self, $args) = @_; my $files = $args->{files}; my $extensions_dir = bz_locations()->{extensionsdir}; $files->{"$extensions_dir/TrackingFlags/bin/bulk_flag_clear.pl"} = { perms => Bugzilla::Install::Filesystem::OWNER_EXECUTE }; } sub active_custom_fields { my ($self, $args) = @_; my $fields = $args->{'fields'}; my $params = $args->{'params'}; my $product = $params->{'product'}; my $component = $params->{'component'}; return if $params->{skip_extensions}; # Create a hash of current fields based on field names my %field_hash = map { $_->name => $_ } @$$fields; my @tracking_flags; if ($product) { $params->{'product_id'} = $product->id; $params->{'component_id'} = $component->id if $component; $params->{'is_active'} = 1; @tracking_flags = @{ Bugzilla::Extension::TrackingFlags::Flag->match($params) }; } else { @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; } # Add tracking flags to fields hash replacing if already exists for our # flag object instead of the usual Field.pm object foreach my $flag (@tracking_flags) { $field_hash{$flag->name} = $flag; } @$$fields = sort { $a->sortkey <=> $b->sortkey } values %field_hash; } sub buglist_columns { my ($self, $args) = @_; my $columns = $args->{columns}; my $dbh = Bugzilla->dbh; my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; foreach my $flag (@tracking_flags) { $columns->{$flag->name} = { name => "COALESCE(map_" . $flag->name . ".value, '---')", title => $flag->description }; } } sub buglist_column_joins { my ($self, $args) = @_; # if there are elements in the tracking_flags array, then they have been # removed from the query, so we mustn't generate joins return if scalar @{ $args->{search}->{tracking_flags} }; my $column_joins = $args->{'column_joins'}; my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; foreach my $flag (@tracking_flags) { $column_joins->{$flag->name} = { as => 'map_' . $flag->name, table => 'tracking_flags_bugs', extra => [ 'map_' . $flag->name . '.tracking_flag_id = ' . $flag->flag_id ] }; } } sub bug_create_cf_accessors { my ($self, $args) = @_; # Create the custom accessors for the flag values my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; foreach my $flag (@tracking_flags) { my $flag_name = $flag->name; if (!Bugzilla::Bug->can($flag_name)) { my $accessor = sub { my $self = shift; return $self->{$flag_name} if defined $self->{$flag_name}; if (!exists $self->{'_tf_bug_values_preloaded'}) { # preload all values currently set for this bug my $bug_values = Bugzilla::Extension::TrackingFlags::Flag::Bug->match({ bug_id => $self->id }); foreach my $value (@$bug_values) { $self->{$value->tracking_flag->name} = $value->value; } $self->{'_tf_bug_values_preloaded'} = 1; } return $self->{$flag_name} ||= '---'; }; no strict 'refs'; *{"Bugzilla::Bug::$flag_name"} = $accessor; } if (!Bugzilla::Bug->can("set_$flag_name")) { my $setter = sub { my ($self, $value) = @_; $value = ref($value) eq 'ARRAY' ? $value->[0] : $value; $self->set($flag_name, $value); }; no strict 'refs'; *{"Bugzilla::Bug::set_$flag_name"} = $setter; } } } sub bug_editable_bug_fields { my ($self, $args) = @_; my $fields = $args->{'fields'}; my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; foreach my $flag (@tracking_flags) { push(@$fields, $flag->name); } } sub search_operator_field_override { my ($self, $args) = @_; my $operators = $args->{'operators'}; my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; foreach my $flag (@tracking_flags) { $operators->{$flag->name} = { _non_changed => sub { _tracking_flags_search_nonchanged($flag->flag_id, @_) } }; } } sub _tracking_flags_search_nonchanged { my ($flag_id, $search, $args) = @_; my ($bugs_table, $chart_id, $joins, $value, $operator) = @$args{qw(bugs_table chart_id joins value operator)}; my $dbh = Bugzilla->dbh; return if ($operator =~ m/^changed/); my $bugs_alias = "tracking_flags_bugs_$chart_id"; my $flags_alias = "tracking_flags_$chart_id"; my $bugs_join = { table => 'tracking_flags_bugs', as => $bugs_alias, from => $bugs_table . ".bug_id", to => "bug_id", extra => [$bugs_alias . ".tracking_flag_id = $flag_id"] }; push(@$joins, $bugs_join); if ($operator eq 'isempty' or $operator eq 'isnotempty') { $args->{'full_field'} = "$bugs_alias.value"; } else { $args->{'full_field'} = "COALESCE($bugs_alias.value, '---')"; } } sub request_cleanup { foreach my $flag (@FLAG_CACHE) { my $bug_flag = delete $flag->{bug_flag}; if ($bug_flag) { delete $bug_flag->{bug}; delete $bug_flag->{tracking_flag}; } foreach my $value (@{ $flag->{values} }) { delete $value->{tracking_flag}; } } @FLAG_CACHE = (); } sub bug_end_of_create { my ($self, $args) = @_; my $bug = $args->{'bug'}; my $timestamp = $args->{'timestamp'}; my $user = Bugzilla->user; my $params = Bugzilla->request_cache->{tracking_flags_create_params}; return if !$params; my $tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->match({ product => $bug->product, component => $bug->component, is_active => 1, }); foreach my $flag (@$tracking_flags) { next if !$params->{$flag->name}; foreach my $value (@{$flag->values}) { next if $value->value ne $params->{$flag->name}; next if $value->value eq '---'; # do not insert if value is '---', same as empty if (!$flag->can_set_value($value->value)) { ThrowUserError('tracking_flags_change_denied', { flag => $flag, value => $value }); } Bugzilla::Extension::TrackingFlags::Flag::Bug->create({ tracking_flag_id => $flag->flag_id, bug_id => $bug->id, value => $value->value, }); # Add the name/value pair to the bug object $bug->{$flag->name} = $value->value; } } } sub object_end_of_set_all { my ($self, $args) = @_; my $object = $args->{object}; my $params = $args->{params}; return unless $object->isa('Bugzilla::Bug'); # Do not filter by product/component as we may be changing those my $tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->match({ bug_id => $object->id, is_active => 1, }); foreach my $flag (@$tracking_flags) { my $flag_name = $flag->name; if (exists $params->{$flag_name}) { my $value = ref($params->{$flag_name}) eq 'ARRAY' ? $params->{$flag_name}->[0] : $params->{$flag_name}; $object->set($flag_name, $value); } } } sub bug_check_can_change_field { my ($self, $args) = @_; my ($bug, $field, $old_value, $new_value, $priv_results) = @$args{qw(bug field old_value new_value priv_results)}; return if $field !~ /^cf_/ or $old_value eq $new_value; return unless my $flag = Bugzilla::Extension::TrackingFlags::Flag->new({ name => $field }); if ($flag->can_set_value($new_value)) { push @$priv_results, PRIVILEGES_REQUIRED_NONE; } else { push @$priv_results, PRIVILEGES_REQUIRED_EMPOWERED; } } sub bug_end_of_update { my ($self, $args) = @_; my ($bug, $old_bug, $timestamp, $changes) = @$args{qw(bug old_bug timestamp changes)}; my $user = Bugzilla->user; # Do not filter by product/component as we may be changing those my $tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->match({ bug_id => $bug->id, is_active => 1, }); my $product_id = $bug->product_id; my $component_id = $bug->component_id; my $is_visible = sub { $_->product_id == $product_id && (!$_->component_id || $_->component_id == $component_id); }; my (@flag_changes); foreach my $flag (@$tracking_flags) { my $flag_name = $flag->name; my $new_value = $bug->$flag_name; my $old_value = $old_bug->$flag_name; if ($flag->bug_flag->id) { my $visibility = $flag->visibility; if (none { $is_visible->() } @$visibility) { push(@flag_changes, { flag => $flag, added => '---', removed => $new_value }); next; } } if ($new_value ne $old_value) { # Do not allow if the user cannot set the old value or the new value if (!$flag->can_set_value($new_value)) { ThrowUserError('tracking_flags_change_denied', { flag => $flag, value => $new_value }); } push(@flag_changes, { flag => $flag, added => $new_value, removed => $old_value }); } } foreach my $change (@flag_changes) { my $flag = $change->{'flag'}; my $added = $change->{'added'}; my $removed = $change->{'removed'}; if ($added eq '---') { $flag->bug_flag->remove_from_db(); } elsif ($removed eq '---') { Bugzilla::Extension::TrackingFlags::Flag::Bug->create({ tracking_flag_id => $flag->flag_id, bug_id => $bug->id, value => $added, }); } else { $flag->bug_flag->set_value($added); $flag->bug_flag->update($timestamp); } $changes->{$flag->name} = [ $removed, $added ]; LogActivityEntry($bug->id, $flag->name, $removed, $added, $user->id, $timestamp); # Update the name/value pair in the bug object $bug->{$flag->name} = $added; } } sub bug_end_of_create_validators { my ($self, $args) = @_; my $params = $args->{params}; # We need to stash away any params that are setting/updating tracking # flags early on. Otherwise set_all or insert_create_data will complain. my @tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->get_all; my $cache = Bugzilla->request_cache->{tracking_flags_create_params} ||= {}; foreach my $flag (@tracking_flags) { my $flag_name = $flag->name; if (defined $params->{$flag_name}) { $cache->{$flag_name} = delete $params->{$flag_name}; } } } sub mailer_before_send { my ($self, $args) = @_; my $email = $args->{email}; # Add X-Bugzilla-Tracking header or add to it # if already exists if ($email->header('X-Bugzilla-ID')) { my $bug_id = $email->header('X-Bugzilla-ID'); my $tracking_flags = Bugzilla::Extension::TrackingFlags::Flag->match({ bug_id => $bug_id }); my @set_values = (); foreach my $flag (@$tracking_flags) { next if $flag->bug_flag->value eq '---'; push(@set_values, $flag->description . ":" . $flag->bug_flag->value); } if (@set_values) { my $set_values_string = join(' ', @set_values); if ($email->header('X-Bugzilla-Tracking')) { $set_values_string = $email->header('X-Bugzilla-Tracking') . " " . $set_values_string; } $email->header_set('X-Bugzilla-Tracking' => $set_values_string); } } } # Purpose: generically handle generating pretty blocking/status "flags" from # custom field names. sub quicksearch_map { my ($self, $args) = @_; my $map = $args->{'map'}; foreach my $name (keys %$map) { if ($name =~ /^cf_(blocking|tracking|status)_([a-z]+)?(\d+)?$/) { my $type = $1; my $product = $2; my $version = $3; if ($version) { $version = join('.', split(//, $version)); } my $pretty_name = $type; if ($product) { $pretty_name .= "-" . $product; } if ($version) { $pretty_name .= $version; } $map->{$pretty_name} = $name; } } } sub reorg_move_component { my ($self, $args) = @_; my $new_product = $args->{new_product}; my $component = $args->{component}; Bugzilla->dbh->do( "UPDATE tracking_flags_visibility SET product_id=? WHERE component_id=?", undef, $new_product->id, $component->id, ); } sub sanitycheck_check { my ($self, $args) = @_; my $status = $args->{status}; $status->('tracking_flags_check'); my ($count) = Bugzilla->dbh->selectrow_array(" SELECT COUNT(*) FROM tracking_flags_visibility INNER JOIN components ON components.id = tracking_flags_visibility.component_id WHERE tracking_flags_visibility.product_id <> components.product_id "); if ($count) { $status->('tracking_flags_alert', undef, 'alert'); $status->('tracking_flags_repair'); } } sub sanitycheck_repair { my ($self, $args) = @_; return unless Bugzilla->cgi->param('tracking_flags_repair'); my $status = $args->{'status'}; my $dbh = Bugzilla->dbh; $status->('tracking_flags_repairing'); my $rows = $dbh->selectall_arrayref(" SELECT DISTINCT tracking_flags_visibility.product_id AS bad_product_id, components.product_id AS good_product_id, tracking_flags_visibility.component_id FROM tracking_flags_visibility INNER JOIN components ON components.id = tracking_flags_visibility.component_id WHERE tracking_flags_visibility.product_id <> components.product_id ", { Slice => {} } ); foreach my $row (@$rows) { $dbh->do(" UPDATE tracking_flags_visibility SET product_id=? WHERE product_id=? AND component_id=? ", undef, $row->{good_product_id}, $row->{bad_product_id}, $row->{component_id}, ); } } __PACKAGE__->NAME;