# 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::UserProfile; use 5.10.1; use strict; use warnings; use base qw(Bugzilla::Extension); use Bugzilla::Constants; use Bugzilla::Extension::UserProfile::Util; use Bugzilla::Install::Filesystem; use Bugzilla::User; use Bugzilla::Util qw(datetime_from time_ago); use Email::Address; use Scalar::Util qw(blessed); use List::MoreUtils qw(any); our $VERSION = '1'; # # user methods # BEGIN { *Bugzilla::User::last_activity_ts = \&_user_last_activity_ts; *Bugzilla::User::set_last_activity_ts = \&_user_set_last_activity_ts; *Bugzilla::User::last_statistics_ts = \&_user_last_statistics_ts; *Bugzilla::User::clear_last_statistics_ts = \&_user_clear_last_statistics_ts; *Bugzilla::User::address = \&_user_address; } sub _user_last_activity_ts { $_[0]->{last_activity_ts} } sub _user_last_statistics_ts { $_[0]->{last_statistics_ts} } sub _user_address { my $mode = Bugzilla->usage_mode; Email::Address->disable_cache if any { $mode == $_ } USAGE_MODE_CMDLINE, USAGE_MODE_TEST, USAGE_MODE_EMAIL; return Email::Address->new(undef, $_[0]->email); } sub _user_set_last_activity_ts { my ($self, $value) = @_; $self->set('last_activity_ts', $_[1]); # we update the database directly to avoid audit_log entries Bugzilla->dbh->do( "UPDATE profiles SET last_activity_ts = ? WHERE userid = ?", undef, $value, $self->id); Bugzilla->memcached->clear({ table => 'profiles', id => $self->id }); } sub _user_clear_last_statistics_ts { my ($self) = @_; $self->set('last_statistics_ts', undef); # we update the database directly to avoid audit_log entries Bugzilla->dbh->do( "UPDATE profiles SET last_statistics_ts = NULL WHERE userid = ?", undef, $self->id); Bugzilla->memcached->clear({ table => 'profiles', id => $self->id }); } # # hooks # sub request_cleanup { Email::Address->purge_cache } sub bug_after_create { my ($self, $args) = @_; $self->_bug_touched($args); } sub bug_after_update { my ($self, $args) = @_; $self->_bug_touched($args); } sub _bug_touched { my ($self, $args) = @_; my $bug = $args->{bug}; my $user = Bugzilla->user; my ($assigned_to, $qa_contact); # bug update if (exists $args->{changes}) { return unless scalar(keys %{ $args->{changes} }) || exists $args->{bug}->{added_comments}; # if the assignee or qa-contact is changed to someone other than the # current user, update them if (exists $args->{changes}->{assigned_to} && $args->{changes}->{assigned_to}->[1] ne $user->login) { $assigned_to = $bug->assigned_to; } if (exists $args->{changes}->{qa_contact} && ($args->{changes}->{qa_contact}->[1] || '') ne $user->login) { $qa_contact = $bug->qa_contact; } # if the product is changed, we need to recount everyone involved with # this bug if (exists $args->{changes}->{product}) { tag_for_recount_from_bug($bug->id); } } # new bug else { # if the assignee or qa-contact is created set to someone other than # the current user, update them if ($bug->assigned_to->id != $user->id) { $assigned_to = $bug->assigned_to; } if ($bug->qa_contact && $bug->qa_contact->id != $user->id) { $qa_contact = $bug->qa_contact; } } my $dbh = Bugzilla->dbh; $dbh->bz_start_transaction(); # update user's last_activity_ts eval { $user->set_last_activity_ts($args->{timestamp}); $self->_recalc_remove($user); }; if ($@) { warn $@; $self->_recalc_insert($user); } # clear the last_statistics_ts for assignee/qa-contact to force a recount # at the next poll if ($assigned_to) { eval { $assigned_to->clear_last_statistics_ts(); $self->_recalc_remove($assigned_to); }; if ($@) { warn $@; $self->_recalc_insert($assigned_to); } } if ($qa_contact) { eval { $qa_contact->clear_last_statistics_ts(); $self->_recalc_remove($qa_contact); }; if ($@) { warn $@; $self->_recalc_insert($qa_contact); } } $dbh->bz_commit_transaction(); } sub _recalc_insert { my ($self, $user) = @_; Bugzilla->dbh->do( "INSERT IGNORE INTO profiles_statistics_recalc SET user_id=?", undef, $user->id ); } sub _recalc_remove { my ($self, $user) = @_; Bugzilla->dbh->do( "DELETE FROM profiles_statistics_recalc WHERE user_id=?", undef, $user->id ); } sub object_end_of_create { my ($self, $args) = @_; $self->_object_touched($args); } sub object_end_of_update { my ($self, $args) = @_; $self->_object_touched($args); } sub _object_touched { my ($self, $args) = @_; my $object = $args->{object} or return; return if exists $args->{changes} && !scalar(keys %{ $args->{changes} }); if ($object->isa('Bugzilla::Attachment')) { # if an attachment is created or updated, that counts as user activity my $user = Bugzilla->user; my $timestamp = Bugzilla->dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); eval { $user->set_last_activity_ts($timestamp); $self->_recalc_remove($user); }; if ($@) { warn $@; $self->_recalc_insert($user); } } elsif ($object->isa('Bugzilla::Product') && exists $args->{changes}->{name}) { # if a product is renamed by an admin, rename in the # profiles_statistics_products table Bugzilla->dbh->do( "UPDATE profiles_statistics_products SET product=? where product=?", undef, $args->{changes}->{name}->[1], $args->{changes}->{name}->[0], ); } } sub reorg_move_bugs { my ($self, $args) = @_; my $bug_ids = $args->{bug_ids}; printf "Touching user profile data for %s bugs.\n", scalar(@$bug_ids); my $count = 0; foreach my $bug_id (@$bug_ids) { $count += tag_for_recount_from_bug($bug_id); } print "Updated $count users.\n"; } sub merge_users_before { my ($self, $args) = @_; my ($old_id, $new_id) = @$args{qw(old_id new_id)}; # when users are merged, we have to delete all the statistics for both users # we'll recalcuate the stats after the merge print "deleting user profile statistics for $old_id and $new_id\n"; my $dbh = Bugzilla->dbh; foreach my $table (qw( profiles_statistics profiles_statistics_status profiles_statistics_products )) { $dbh->do("DELETE FROM $table WHERE " . $dbh->sql_in('user_id', [ $old_id, $new_id ])); } } sub merge_users_after { my ($self, $args) = @_; my $new_id = $args->{new_id}; print "generating user profile statistics $new_id\n"; update_statistics_by_user($new_id); } sub webservice_user_get { my ($self, $args) = @_; my ($service, $users) = @$args{qw(webservice users)}; my $dbh = Bugzilla->dbh; my $ids = [ map { blessed($_->{id}) ? $_->{id}->value : $_->{id} } grep { exists $_->{id} } @$users ]; return unless @$ids; my $timestamps = $dbh->selectall_hashref( "SELECT userid,last_activity_ts FROM profiles WHERE " . $dbh->sql_in('userid', $ids), 'userid', ); foreach my $user (@$users) { my $id = blessed($user->{id}) ? $user->{id}->value : $user->{id}; $user->{last_activity} = $service->type('dateTime', $timestamps->{$id}->{last_activity_ts}); } } sub template_before_create { my ($self, $args) = @_; $args->{config}->{FILTERS}->{timeago} = sub { my ($time_str) = @_; return time_ago(datetime_from($time_str, 'UTC')); }; } sub page_before_template { my ($self, $args) = @_; my ($vars, $page) = @$args{qw(vars page_id)}; return unless $page eq 'user_profile.html'; my $user = Bugzilla->user; # determine user to display my ($target, $login); my $input = Bugzilla->input_params; if (my $user_id = $input->{user_id}) { # load from user_id $user_id = 0 if $user_id =~ /\D/; $target = Bugzilla::User->check({ id => $user_id }); } else { # loading from login name requires authentication Bugzilla->login(LOGIN_REQUIRED); $login = $input->{login}; if (!$login) { # show current user's profile by default $target = $user; } else { my $limit = Bugzilla->params->{'maxusermatches'} + 1; my $users = Bugzilla::User::match($login, $limit, 1); if (scalar(@$users) == 1) { # always allow singular matches without confirmation $target = $users->[0]; } else { Bugzilla::User::match_field({ 'login' => {'type' => 'single'} }); $target = Bugzilla::User->check($login); } } } $login ||= $target->login; # load statistics into $vars my $dbh = Bugzilla->switch_to_shadow_db; my $stats = $dbh->selectall_hashref( "SELECT name, count FROM profiles_statistics WHERE user_id = ?", "name", undef, $target->id, ); map { $stats->{$_} = $stats->{$_}->{count} } keys %$stats; my $statuses = $dbh->selectall_hashref( "SELECT status, count FROM profiles_statistics_status WHERE user_id = ?", "status", undef, $target->id, ); map { $statuses->{$_} = $statuses->{$_}->{count} } keys %$statuses; my $products = $dbh->selectall_arrayref( "SELECT product, count FROM profiles_statistics_products WHERE user_id = ? ORDER BY product = '', count DESC", { Slice => {} }, $target->id, ); # ensure there's always an "other" product entry my ($other_product) = grep { $_->{product} eq '' } @$products; if (!$other_product) { $other_product = { product => '', count => 0 }; push @$products, $other_product; } # load product objects and validate product visibility foreach my $product (@$products) { next if $product->{product} eq ''; my $product_obj = Bugzilla::Product->new({ name => $product->{product} }); if (!$product_obj || !$user->can_see_product($product_obj->name)) { # products not accessible to current user are moved into "other" $other_product->{count} += $product->{count}; $product->{count} = 0; } else { $product->{product} = $product_obj; } } # set other's name, and remove empty products $other_product->{product} = { name => 'Other' }; $products = [ grep { $_->{count} } @$products ]; $vars->{stats} = $stats; $vars->{statuses} = $statuses; $vars->{products} = $products; $vars->{login} = $login; $vars->{target} = $target; } sub object_columns { my ($self, $args) = @_; my ($class, $columns) = @$args{qw(class columns)}; if ($class->isa('Bugzilla::User')) { my $dbh = Bugzilla->dbh; my @new_columns = qw(last_activity_ts last_statistics_ts); push @$columns, grep { $dbh->bz_column_info($class->DB_TABLE, $_) } @new_columns; } } sub object_update_columns { my ($self, $args) = @_; my ($object, $columns) = @$args{qw(object columns)}; if ($object->isa('Bugzilla::User')) { push(@$columns, qw(last_activity_ts last_statistics_ts)); } } # # installation # sub db_schema_abstract_schema { my ($self, $args) = @_; $args->{'schema'}->{'profiles_statistics'} = { FIELDS => [ id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1, }, user_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE', } }, name => { TYPE => 'VARCHAR(30)', NOTNULL => 1, }, count => { TYPE => 'INT', NOTNULL => 1, }, ], INDEXES => [ profiles_statistics_name_idx => { FIELDS => [ 'user_id', 'name' ], TYPE => 'UNIQUE', }, ], }; $args->{'schema'}->{'profiles_statistics_status'} = { FIELDS => [ id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1, }, user_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE', } }, status => { TYPE => 'VARCHAR(64)', NOTNULL => 1, }, count => { TYPE => 'INT', NOTNULL => 1, }, ], INDEXES => [ profiles_statistics_status_idx => { FIELDS => [ 'user_id', 'status' ], TYPE => 'UNIQUE', }, ], }; $args->{'schema'}->{'profiles_statistics_products'} = { FIELDS => [ id => { TYPE => 'MEDIUMSERIAL', NOTNULL => 1, PRIMARYKEY => 1, }, user_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE', } }, product => { TYPE => 'VARCHAR(64)', NOTNULL => 1, }, count => { TYPE => 'INT', NOTNULL => 1, }, ], INDEXES => [ profiles_statistics_products_idx => { FIELDS => [ 'user_id', 'product' ], TYPE => 'UNIQUE', }, ], }; $args->{'schema'}->{'profiles_statistics_recalc'} = { FIELDS => [ user_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE', } }, ], INDEXES => [ profiles_statistics_recalc_idx => { FIELDS => [ 'user_id' ], TYPE => 'UNIQUE', }, ], }; $args->{'schema'}->{'profiles_statistics_recalc'} = { FIELDS => [ user_id => { TYPE => 'INT3', NOTNULL => 1, REFERENCES => { TABLE => 'profiles', COLUMN => 'userid', DELETE => 'CASCADE', } }, ], INDEXES => [ profiles_statistics_recalc_idx => { FIELDS => [ 'user_id' ], TYPE => 'UNIQUE', }, ], }; } sub install_update_db { my $dbh = Bugzilla->dbh; $dbh->bz_add_column('profiles', 'last_activity_ts', { TYPE => 'DATETIME' }); $dbh->bz_add_column('profiles', 'last_statistics_ts', { TYPE => 'DATETIME' }); } sub install_filesystem { my ($self, $args) = @_; my $files = $args->{'files'}; my $extensions_dir = bz_locations()->{'extensionsdir'}; my $script_name = $extensions_dir . "/" . __PACKAGE__->NAME . "/bin/update.pl"; $files->{$script_name} = { perms => Bugzilla::Install::Filesystem::WS_EXECUTE }; $script_name = $extensions_dir . "/" . __PACKAGE__->NAME . "/bin/migrate.pl"; $files->{$script_name} = { perms => Bugzilla::Install::Filesystem::OWNER_EXECUTE }; } __PACKAGE__->NAME;