diff options
Diffstat (limited to 'extensions/UserProfile')
-rw-r--r-- | extensions/UserProfile/Config.pm | 15 | ||||
-rw-r--r-- | extensions/UserProfile/Extension.pm | 549 | ||||
-rwxr-xr-x | extensions/UserProfile/bin/migrate.pl | 43 | ||||
-rwxr-xr-x | extensions/UserProfile/bin/update.pl | 80 | ||||
-rw-r--r-- | extensions/UserProfile/lib/TimeAgo.pm | 179 | ||||
-rw-r--r-- | extensions/UserProfile/lib/Util.pm | 378 | ||||
-rw-r--r-- | extensions/UserProfile/template/en/default/hook/account/prefs/account-field.html.tmpl | 11 | ||||
-rw-r--r-- | extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl | 192 | ||||
-rw-r--r-- | extensions/UserProfile/web/styles/user_profile.css | 44 |
9 files changed, 1491 insertions, 0 deletions
diff --git a/extensions/UserProfile/Config.pm b/extensions/UserProfile/Config.pm new file mode 100644 index 000000000..99dca9e02 --- /dev/null +++ b/extensions/UserProfile/Config.pm @@ -0,0 +1,15 @@ +# 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 strict; + +use constant NAME => 'UserProfile'; +use constant REQUIRED_MODULES => [ ]; +use constant OPTIONAL_MODULES => [ ]; + +__PACKAGE__->NAME; diff --git a/extensions/UserProfile/Extension.pm b/extensions/UserProfile/Extension.pm new file mode 100644 index 000000000..673c0c2a1 --- /dev/null +++ b/extensions/UserProfile/Extension.pm @@ -0,0 +1,549 @@ +# 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 strict; +use warnings; + +use base qw(Bugzilla::Extension); + +use Bugzilla::Constants; +use Bugzilla::Extension::UserProfile::TimeAgo qw(time_ago); +use Bugzilla::Extension::UserProfile::Util; +use Bugzilla::Install::Filesystem; +use Bugzilla::User; +use Bugzilla::Util qw(datetime_from); +use Scalar::Util qw(blessed); + +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; +} + +sub _user_last_activity_ts { $_[0]->{last_activity_ts} } +sub _user_last_statistics_ts { $_[0]->{last_statistics_ts} } + +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); +} + +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); +} + +# +# hooks +# + +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')) { + push(@$columns, qw(last_activity_ts last_statistics_ts)); + } +} + +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; diff --git a/extensions/UserProfile/bin/migrate.pl b/extensions/UserProfile/bin/migrate.pl new file mode 100755 index 000000000..147edef9c --- /dev/null +++ b/extensions/UserProfile/bin/migrate.pl @@ -0,0 +1,43 @@ +#!/usr/bin/perl + +# 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. + +use strict; +use warnings; +$| = 1; + +use FindBin qw($Bin); +use lib "$Bin/../../.."; + +use Bugzilla; +BEGIN { Bugzilla->extensions() } + +use Bugzilla::Constants; +use Bugzilla::Extension::UserProfile::Util; +use Bugzilla::Install::Util qw(indicate_progress); + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); +my $dbh = Bugzilla->dbh; + +my $user_ids = $dbh->selectcol_arrayref( + "SELECT userid + FROM profiles + WHERE last_activity_ts IS NULL + ORDER BY userid" +); + +my ($current, $total) = (1, scalar(@$user_ids)); +foreach my $user_id (@$user_ids) { + indicate_progress({ current => $current++, total => $total, every => 25 }); + my $ts = last_user_activity($user_id); + next unless $ts; + $dbh->do( + "UPDATE profiles SET last_activity_ts = ? WHERE userid = ?", + undef, + $ts, $user_id); +} diff --git a/extensions/UserProfile/bin/update.pl b/extensions/UserProfile/bin/update.pl new file mode 100755 index 000000000..457585f8d --- /dev/null +++ b/extensions/UserProfile/bin/update.pl @@ -0,0 +1,80 @@ +#!/usr/bin/perl + +# 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. + +use strict; +use warnings; + +use FindBin qw($Bin); +use lib "$Bin/../../.."; + +use Bugzilla; +BEGIN { Bugzilla->extensions() } + +use Bugzilla::Constants; +use Bugzilla::Extension::UserProfile::Util; +use Bugzilla::User; + +Bugzilla->usage_mode(USAGE_MODE_CMDLINE); +my $dbh = Bugzilla->dbh; +my $user_ids; +my $verbose = grep { $_ eq '-v' } @ARGV; + +$user_ids = $dbh->selectcol_arrayref( + "SELECT user_id + FROM profiles_statistics_recalc + ORDER BY user_id", + { Slice => {} } +); + +if (@$user_ids) { + print "recalculating last_user_activity\n"; + my ($count, $total) = (0, scalar(@$user_ids)); + foreach my $user_id (@$user_ids) { + if ($verbose) { + $count++; + my $login = user_id_to_login($user_id); + print "$count/$total $login ($user_id)\n"; + } + $dbh->do( + "UPDATE profiles + SET last_activity_ts = ?, + last_statistics_ts = NULL + WHERE userid = ?", + undef, + last_user_activity($user_id), + $user_id + ); + } + $dbh->do( + "DELETE FROM profiles_statistics_recalc WHERE " . $dbh->sql_in('user_id', $user_ids) + ); +} + +$user_ids = $dbh->selectcol_arrayref( + "SELECT userid + FROM profiles + WHERE last_activity_ts IS NOT NULL + AND (last_statistics_ts IS NULL + OR last_activity_ts > last_statistics_ts) + ORDER BY userid", + { Slice => {} } +); + +if (@$user_ids) { + $verbose && print "updating statistics\n"; + my ($count, $total) = (0, scalar(@$user_ids)); + foreach my $user_id (@$user_ids) { + if ($verbose) { + $count++; + my $login = user_id_to_login($user_id); + print "$count/$total $login ($user_id)\n"; + } + update_statistics_by_user($user_id); + } +} diff --git a/extensions/UserProfile/lib/TimeAgo.pm b/extensions/UserProfile/lib/TimeAgo.pm new file mode 100644 index 000000000..d20f0edf5 --- /dev/null +++ b/extensions/UserProfile/lib/TimeAgo.pm @@ -0,0 +1,179 @@ +package Bugzilla::Extension::UserProfile::TimeAgo; + +use strict; +use utf8; +use DateTime; +use Carp; +use Exporter qw(import); + +use if $ENV{ARCH_64BIT}, 'integer'; + +our @EXPORT_OK = qw(time_ago); + +our $VERSION = '0.06'; + +my @ranges = ( + [ -1, 'in the future' ], + [ 60, 'just now' ], + [ 900, 'a few minutes ago'], # 15*60 + [ 3000, 'less than an hour ago'], # 50*60 + [ 4500, 'about an hour ago'], # 75*60 + [ 7200, 'more than an hour ago'], # 2*60*60 + [ 21600, 'several hours ago'], # 6*60*60 + [ 86400, 'today', sub { # 24*60*60 + my $time = shift; + my $now = shift; + if ( $time->day < $now->day + or $time->month < $now->month + or $time->year < $now->year + ) { + return 'yesterday' + } + if ($time->hour < 5) { + return 'tonight' + } + if ($time->hour < 10) { + return 'this morning' + } + if ($time->hour < 15) { + return 'today' + } + if ($time->hour < 19) { + return 'this afternoon' + } + return 'this evening' + }], + [ 172800, 'yesterday'], # 2*24*60*60 + [ 604800, 'this week'], # 7*24*60*60 + [ 1209600, 'last week'], # 2*7*24*60*60 + [ 2678400, 'this month', sub { # 31*24*60*60 + my $time = shift; + my $now = shift; + if ($time->year == $now->year and $time->month == $now->month) { + return 'this month' + } + return 'last month' + }], + [ 5356800, 'last month'], # 2*31*24*60*60 + [ 24105600, 'several months ago'], # 9*31*24*60*60 + [ 31536000, 'about a year ago'], # 365*24*60*60 + [ 34214400, 'last year'], # (365+31)*24*60*60 + [ 63072000, 'more than a year ago'], # 2*365*24*60*60 + [ 283824000, 'several years ago'], # 9*365*24*60*60 + [ 315360000, 'about a decade ago'], # 10*365*24*60*60 + [ 630720000, 'last decade'], # 20*365*24*60*60 + [ 2838240000, 'several decades ago'], # 90*365*24*60*60 + [ 3153600000, 'about a century ago'], # 100*365*24*60*60 + [ 6307200000, 'last century'], # 200*365*24*60*60 + [ 6622560000, 'more than a century ago'], # 210*365*24*60*60 + [ 28382400000, 'several centuries ago'], # 900*365*24*60*60 + [ 31536000000, 'about a millenium ago'], # 1000*365*24*60*60 + [ 63072000000, 'more than a millenium ago'], # 2000*365*24*60*60 +); + +sub time_ago { + my ($time, $now) = @_; + + if (not defined $time or not $time->isa('DateTime')) { + croak('DateTime::Duration::Fuzzy::time_ago needs a DateTime object as first parameter') + } + if (not defined $now) { + $now = DateTime->now(); + } + if (not $now->isa('DateTime')) { + croak('Invalid second parameter provided to DateTime::Duration::Fuzzy::time_ago; it must be a DateTime object if provided') + } + + my $dur = $now->subtract_datetime_absolute($time)->in_units('seconds'); + + foreach my $range ( @ranges ) { + if ( $dur <= $range->[0] ) { + if ( $range->[2] ) { + return $range->[2]->($time, $now) + } + return $range->[1] + } + } + + return 'millenia ago' +} + +1 + +__END__ + +=head1 NAME + +DateTime::Duration::Fuzzy -- express dates as fuzzy human-friendly strings + +=head1 SYNOPSIS + + use DateTime::Duration::Fuzzy qw(time_ago); + use DateTime; + + my $now = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 19, minute => 59, + ); + my $then = DateTime->new( + year => 2010, month => 12, day => 12, + hour => 15, + ); + print time_ago($then, $now); + # outputs 'several hours ago' + + print time_ago($then); + # $now taken from C<time> function + +=head1 DESCRIPTION + +DateTime::Duration::Fuzzy is inspired from the timeAgo jQuery module +L<http://timeago.yarp.com/>. + +It takes two DateTime objects -- first one representing a moment in the past +and second optional one representine the present, and returns a human-friendly +fuzzy expression of the time gone. + +=head2 functions + +=over 4 + +=item time_ago($then, $now) + +The only exportable function. + +First obligatory parameter is a DateTime object. + +Second optional parameter is also a DateTime object. +If it's not provided, then I<now> as the C<time> function returns is +substituted. + +Returns a string expression of the interval between the two DateTime +objects, like C<several hours ago>, C<yesterday> or <last century>. + +=back + +=head2 performance + +On 64bit machines, it is asvisable to 'use integer', which makes +the calculations faster. You can turn this on by setting the +C<ARCH_64BIT> environmental variable to a true value. + +If you do this on a 32bit machine, you will get wrong results for +intervals starting with "several decades ago". + +=head1 AUTHOR + +Jan Oldrich Kruza, C<< <sixtease at cpan.org> >> + +=head1 LICENSE AND COPYRIGHT + +Copyright 2010 Jan Oldrich Kruza. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See http://dev.perl.org/licenses/ for more information. + +=cut diff --git a/extensions/UserProfile/lib/Util.pm b/extensions/UserProfile/lib/Util.pm new file mode 100644 index 000000000..93313eee5 --- /dev/null +++ b/extensions/UserProfile/lib/Util.pm @@ -0,0 +1,378 @@ +# 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::Util; + +use strict; +use warnings; + +use base qw(Exporter); +our @EXPORT = qw( update_statistics_by_user + tag_for_recount_from_bug + last_user_activity ); + +use Bugzilla; + +sub update_statistics_by_user { + my ($user_id) = @_; + + # run all our queries on the slaves + + my $dbh = Bugzilla->switch_to_shadow_db(); + + my $now = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)'); + + # grab the current values + + my $last_statistics_ts = _get_last_statistics_ts($user_id); + + my $statistics = _get_stats($user_id, 'profiles_statistics', 'name'); + my $by_status = _get_stats($user_id, 'profiles_statistics_status', 'status'); + my $by_product = _get_stats($user_id, 'profiles_statistics_products', 'product'); + + # bugs filed + _update_statistics($statistics, 'bugs_filed', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM bugs + WHERE bugs.reporter = ? +EOF + + # comments made + _update_statistics($statistics, 'comments', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM longdescs + WHERE who = ? +EOF + + # commented on + _update_statistics($statistics, 'commented_on', [ $user_id ], <<EOF); + SELECT COUNT(*) FROM ( + SELECT longdescs.bug_id + FROM longdescs + WHERE who = ? + GROUP BY longdescs.bug_id + ) AS temp +EOF + + # confirmed + _update_statistics($statistics, 'confirmed', [ $user_id, _field_id('bug_status') ], <<EOF); + SELECT COUNT(*) + FROM bugs_activity + WHERE who = ? + AND fieldid = ? + AND removed = 'UNCONFIRMED' AND added = 'NEW' +EOF + + # patches submitted + _update_statistics($statistics, 'patches', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM attachments + WHERE submitter_id = ? + AND ispatch = 1 +EOF + + # patches reviewed + _update_statistics($statistics, 'reviews', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM flags + INNER JOIN attachments ON attachments.attach_id = flags.attach_id + WHERE setter_id = ? + AND attachments.ispatch = 1 + AND status IN ('+', '-') +EOF + + # assigned to + _update_statistics($statistics, 'assigned', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM bugs + WHERE assigned_to = ? +EOF + + # qa contact + _update_statistics($statistics, 'qa_contact', [ $user_id ], <<EOF); + SELECT COUNT(*) + FROM bugs + WHERE qa_contact = ? +EOF + + # bugs touched + _update_statistics($statistics, 'touched', [ $user_id, $user_id], <<EOF); + SELECT COUNT(*) FROM ( + SELECT bugs_activity.bug_id + FROM bugs_activity + WHERE who = ? + GROUP BY bugs_activity.bug_id + UNION + SELECT longdescs.bug_id + FROM longdescs + WHERE who = ? + GROUP BY longdescs.bug_id + ) temp +EOF + + # activity by status/resolution, and product + _activity_by_status($by_status, $user_id); + _activity_by_product($by_product, $user_id); + + # if nothing is dirty, no need to do anything else + if ($last_statistics_ts) { + return unless _has_dirty($statistics) + || _has_dirty($by_status) + || _has_dirty($by_product); + } + + # switch back to the main db for updating + + $dbh = Bugzilla->switch_to_main_db(); + $dbh->bz_start_transaction(); + + # commit updated statistics + + _set_stats($statistics, $user_id, 'profiles_statistics', 'name') + if _has_dirty($statistics); + _set_stats($by_status, $user_id, 'profiles_statistics_status', 'status') + if _has_dirty($by_status); + _set_stats($by_product, $user_id, 'profiles_statistics_products', 'product') + if _has_dirty($by_product); + + # update the user's last_statistics_ts + _set_last_statistics_ts($user_id, $now); + + $dbh->bz_commit_transaction(); +} + +sub tag_for_recount_from_bug { + my ($bug_id) = @_; + my $dbh = Bugzilla->dbh; + # get a list of all users associated with this bug + my $user_ids = $dbh->selectcol_arrayref(<<EOF, undef, $bug_id, _field_id('cc'), $bug_id); + SELECT DISTINCT user_id + FROM ( + SELECT DISTINCT who AS user_id + FROM bugs_activity + WHERE bug_id = ? + AND fieldid <> ? + UNION ALL + SELECT DISTINCT who AS user_id + FROM longdescs + WHERE bug_id = ? + ) tmp +EOF + # clear last_statistics_ts + $dbh->do( + "UPDATE profiles SET last_statistics_ts=NULL WHERE " . $dbh->sql_in('userid', $user_ids) + ); + return scalar(@$user_ids); +} + +sub last_user_activity { + # last comment, or change to a bug (excluding CC changes) + my ($user_id) = @_; + return Bugzilla->dbh->selectrow_array(<<EOF, undef, $user_id, $user_id, _field_id('cc')); + SELECT MAX(bug_when) + FROM ( + SELECT MAX(bug_when) AS bug_when + FROM longdescs + WHERE who = ? + UNION ALL + SELECT MAX(bug_when) AS bug_when + FROM bugs_activity + WHERE who = ? + AND fieldid <> ? + ) tmp +EOF +} + +# for performance reasons hit the db directly rather than using the user object + +sub _get_last_statistics_ts { + my ($user_id) = @_; + return Bugzilla->dbh->selectrow_array( + "SELECT last_statistics_ts FROM profiles WHERE userid = ?", + undef, $user_id + ); +} + +sub _set_last_statistics_ts { + my ($user_id, $timestamp) = @_; + Bugzilla->dbh->do( + "UPDATE profiles SET last_statistics_ts = ? WHERE userid = ?", + undef, + $timestamp, $user_id, + ); +} + +sub _update_statistics { + my ($statistics, $name, $values, $sql) = @_; + my ($count) = Bugzilla->dbh->selectrow_array($sql, undef, @$values); + if (!exists $statistics->{$name}) { + $statistics->{$name} = { + id => 0, + count => $count, + dirty => 1, + }; + } elsif ($statistics->{$name}->{count} != $count) { + $statistics->{$name}->{count} = $count; + $statistics->{$name}->{dirty} = 1; + }; +} + +sub _activity_by_status { + my ($by_status, $user_id) = @_; + my $dbh = Bugzilla->dbh; + + # we actually track both status and resolution changes as statuses + my @values = ($user_id, _field_id('bug_status'), $user_id, _field_id('resolution')); + my $rows = $dbh->selectall_arrayref(<<EOF, { Slice => {} }, @values); + SELECT added AS status, COUNT(*) AS count + FROM bugs_activity + WHERE who = ? + AND fieldid = ? + GROUP BY added + UNION ALL + SELECT CONCAT('RESOLVED/', added) AS status, COUNT(*) AS count + FROM bugs_activity + WHERE who = ? + AND fieldid = ? + AND added != '' + GROUP BY added +EOF + + foreach my $row (@$rows) { + my $status = $row->{status}; + if (!exists $by_status->{$status}) { + $by_status->{$status} = { + id => 0, + count => $row->{count}, + dirty => 1, + }; + } elsif ($by_status->{$status}->{count} != $row->{count}) { + $by_status->{$status}->{count} = $row->{count}; + $by_status->{$status}->{dirty} = 1; + } + } +} + +sub _activity_by_product { + my ($by_product, $user_id) = @_; + my $dbh = Bugzilla->dbh; + + my %products; + + # changes + my $rows = $dbh->selectall_arrayref(<<EOF, { Slice => {} }, $user_id); + SELECT products.name AS product, count(*) AS count + FROM bugs_activity + INNER JOIN bugs ON bugs.bug_id = bugs_activity.bug_id + INNER JOIN products ON products.id = bugs.product_id + WHERE who = ? + GROUP BY bugs.product_id +EOF + map { $products{$_->{product}} += $_->{count} } @$rows; + + # comments + $rows = $dbh->selectall_arrayref(<<EOF, { Slice => {} }, $user_id); + SELECT products.name AS product, count(*) AS count + FROM longdescs + INNER JOIN bugs ON bugs.bug_id = longdescs.bug_id + INNER JOIN products ON products.id = bugs.product_id + WHERE who = ? + GROUP BY bugs.product_id +EOF + map { $products{$_->{product}} += $_->{count} } @$rows; + + # store only the top 10 and 'other' (which is an empty string) + my @sorted = sort { $products{$b} <=> $products{$a} } keys %products; + my @other; + @other = splice(@sorted, 10) if scalar(@sorted) > 10; + map { $products{''} += $products{$_} } @other; + push @sorted, '' if $products{''}; + + # update by_product + foreach my $product (@sorted) { + if (!exists $by_product->{$product}) { + $by_product->{$product} = { + id => 0, + count => $products{$product}, + dirty => 1, + }; + } elsif ($by_product->{$product}->{count} != $products{$product}) { + $by_product->{$product}->{count} = $products{$product}; + $by_product->{$product}->{dirty} = 1; + } + } + foreach my $product (keys %$by_product) { + if (!grep { $_ eq $product } @sorted) { + delete $by_product->{$product}; + } + } +} + +our $_field_id_cache; +sub _field_id { + my ($name) = @_; + if (!$_field_id_cache) { + my $rows = Bugzilla->dbh->selectall_arrayref("SELECT id, name FROM fielddefs"); + foreach my $row (@$rows) { + $_field_id_cache->{$row->[1]} = $row->[0]; + } + } + return $_field_id_cache->{$name}; +} + +sub _get_stats { + my ($user_id, $table, $name_field) = @_; + my $result = {}; + my $rows = Bugzilla->dbh->selectall_arrayref( + "SELECT * FROM $table WHERE user_id = ?", + { Slice => {} }, + $user_id, + ); + foreach my $row (@$rows) { + unless (defined $row->{$name_field}) { + print "$user_id $table $name_field\n"; + die; + } + $result->{$row->{$name_field}} = { + id => $row->{id}, + count => $row->{count}, + dirty => 0, + } + } + return $result; +} + +sub _set_stats { + my ($statistics, $user_id, $table, $name_field) = @_; + my $dbh = Bugzilla->dbh; + foreach my $name (keys %$statistics) { + next unless $statistics->{$name}->{dirty}; + if ($statistics->{$name}->{id}) { + $dbh->do( + "UPDATE $table SET count = ? WHERE user_id = ? AND $name_field = ?", + undef, + $statistics->{$name}->{count}, $user_id, $name, + ); + } else { + $dbh->do( + "INSERT INTO $table(user_id, $name_field, count) VALUES (?, ?, ?)", + undef, + $user_id, $name, $statistics->{$name}->{count}, + ); + } + } +} + +sub _has_dirty { + my ($statistics) = @_; + foreach my $name (keys %$statistics) { + return 1 if $statistics->{$name}->{dirty}; + } + return 0; +} + +1; diff --git a/extensions/UserProfile/template/en/default/hook/account/prefs/account-field.html.tmpl b/extensions/UserProfile/template/en/default/hook/account/prefs/account-field.html.tmpl new file mode 100644 index 000000000..f2e3aad01 --- /dev/null +++ b/extensions/UserProfile/template/en/default/hook/account/prefs/account-field.html.tmpl @@ -0,0 +1,11 @@ +[%# 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. + #%] + +<a href="user_profile?login=[% user.login FILTER uri %]"> + [% terms.Bugzilla %] User Profile +</a><br><hr> diff --git a/extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl b/extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl new file mode 100644 index 000000000..aabc42db2 --- /dev/null +++ b/extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl @@ -0,0 +1,192 @@ +[%# 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. + #%] + +[% PROCESS global/variables.none.tmpl %] + +[% filtered_identity = target.identity FILTER html %] +[% PROCESS global/header.html.tmpl + title = "User Profile: $filtered_identity" + style_urls = [ "extensions/UserProfile/web/styles/user_profile.css" ] + yui = [ 'autocomplete' ] + javascript_urls = [ "js/field.js" ] +%] + +<table id="user_profile_table"> + +[% IF user.id %] + <tr> + <td> </td> + <th>Search</th> + <td colspan="2"> + <form action="user_profile"> + [% INCLUDE global/userselect.html.tmpl + id => "login" + name => "login" + value => login + size => 40 + emptyok => 0 + %] + <input type="submit" value="Show"> + </form> + </td> + </tr> + + <tr> + <td colspan="4" class="separator"><hr></td> + </tr> +[% END %] + +<tr> + <td rowspan="[% user.id ? 16 : 15 %]" id="gravatar-container"> + [% IF user.gravatar %] + <img id="gravatar" src="[% target.gravatar(256) FILTER none %]" width="128" height="128"><br> + [% IF target.id == user.id %] + <a href="http://gravatar.com/">Change my image</a> + [% END %] + [% ELSE %] + + [% END %] + </td> + <th>Name</th> + <td colspan="2">[% target.name FILTER html %]</td> +</tr> + +[% IF user.id %] + <tr> + <th>Email</th> + <td colspan="2"><a href="mailto:[% target.login FILTER uri %]">[% target.login FILTER html %]</a></td> + </tr> +[% END %] + +<tr> + <td> </td> +</tr> + +[%# user.creation_ts is added by the TagNewUsers extension %] +[% IF target.can('creation_ts') %] + <tr> + <th>Created</th> + <td colspan="2"> + [% target.creation_ts FILTER time %] ([% target.creation_ts FILTER timeago FILTER html %]) + </td> + </tr> +[% END %] + +<tr> + <th>Last activity</th> + <td colspan="2"> + <a href="page.cgi?id=user_activity.html&action=run&who=[% target.login FILTER uri %]&from=-4w"> + [% target.last_activity_ts FILTER time %] + </a> + </td> +</tr> +<tr> + <th>[% terms.Bugs %] filed</th> + <td class="numeric"> + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emailreporter1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% stats.bugs_filed || 0 FILTER html %] + </a> + </td> +</tr> +<tr> + <th>Comments made</th> + <td class="numeric">[% stats.comments || 0 FILTER html %]</td> +</tr> +<tr> + <th>Assigned to</th> + <td class="numeric"> + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emailassigned_to1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% stats.assigned || 0 FILTER html %] + </a> + </td> +</tr> +<tr> + <th>Commented on</th> + <td class="numeric"> + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emaillongdesc1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% stats.commented_on || 0 FILTER html %] + </a> + </td> +</tr> +<tr> + <th>QA-Contact</th> + <td class="numeric"> + <a href="buglist.cgi?query_format=advanced&emailtype1=exact&emailqa_contact1=1&email1=[% target.login FILTER uri %]" + target="_blank"> + [% stats.qa_contact || 0 FILTER html %] + </a> + </td> +</tr> +<tr> + <th>Patches submitted</th> + <td class="numeric">[% stats.patches || 0 FILTER html %]</td> +</tr> +<tr> + <th>Patches reviewed</th> + <td class="numeric">[% stats.reviews || 0 FILTER html %]</td> +</tr> +<tr> + <th>[% terms.Bugs %] poked</th> + <td class="numeric">[% stats.touched || 0 FILTER html %]</td> +</tr> + +<tr> + <td> </td> +</tr> + +<tr> + <th>Statuses changed</th> + <td colspan="2"> + RESOLVED ([% statuses.item('RESOLVED') || 0 FILTER html %]), + FIXED ([% statuses.item('RESOLVED/FIXED') || 0 FILTER html %]), + VERIFIED ([% statuses.item('VERIFIED') || 0 FILTER html %]), + INVALID ([% statuses.item('RESOLVED/INVALID') || 0 FILTER html %]) + </td> +</tr> + +<tr> + <th>Activity by product</th> + <td colspan="2"> + [% FOREACH p = products %] + <span class="product_span"> + [% IF p.product.id %] + <a href="describecomponents.cgi?product=[% p.product.name FILTER uri %]" + target="_blank"> + [% END %] + [% p.product.name FILTER html %] ([% p.count || 0 FILTER html %]) + [% "</a>" IF p.product.id %] + [% "," UNLESS loop.last ~%] + </span> + [%+ END %] + </td> +</tr> + +<tr> + <td> </td> + <td> </td> + <td> </td> + <td width="100%"> </td> +</tr> + +</table> + +<div id="what"> + <a href="https://wiki.mozilla.org/BMO/User_profile_fields" target="_blank"> + What do these fields mean? + </a> +</div> + +<div id="updated"> + This information is updated daily +</div> + +[% PROCESS global/footer.html.tmpl %] + diff --git a/extensions/UserProfile/web/styles/user_profile.css b/extensions/UserProfile/web/styles/user_profile.css new file mode 100644 index 000000000..c261c24b6 --- /dev/null +++ b/extensions/UserProfile/web/styles/user_profile.css @@ -0,0 +1,44 @@ +/* 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. */ + +#login_autocomplete { + float: left; +} + +#user_profile_table th { + text-align: right; + padding-right: 1em; + vertical-align: middle; + white-space: nowrap; +} + +#user_profile_table .numeric { + text-align: right; +} + +#user_profile_table .product_span { + white-space: nowrap; +} + +#updated { + font-style: italic; + font-size: x-small; +} + +#gravatar-container { + text-align: center; + font-size: x-small; + vertical-align: top; + padding-right: 15px; +} + +#gravatar { + -moz-box-shadow: 2px 2px 5px #888; + -webkit-box-shadow: 2px 2px 5px #888; + box-shadow: 2px 2px 5px #888; + margin-bottom: 5px; +} |