summaryrefslogtreecommitdiffstats
path: root/extensions/UserProfile
diff options
context:
space:
mode:
Diffstat (limited to 'extensions/UserProfile')
-rw-r--r--extensions/UserProfile/Config.pm15
-rw-r--r--extensions/UserProfile/Extension.pm549
-rwxr-xr-xextensions/UserProfile/bin/migrate.pl43
-rwxr-xr-xextensions/UserProfile/bin/update.pl80
-rw-r--r--extensions/UserProfile/lib/TimeAgo.pm179
-rw-r--r--extensions/UserProfile/lib/Util.pm378
-rw-r--r--extensions/UserProfile/template/en/default/hook/account/prefs/account-field.html.tmpl11
-rw-r--r--extensions/UserProfile/template/en/default/pages/user_profile.html.tmpl192
-rw-r--r--extensions/UserProfile/web/styles/user_profile.css44
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>&nbsp;</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
+ %]
+ &nbsp;&nbsp;<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 %]
+ &nbsp;
+ [% 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>&nbsp;</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&amp;action=run&amp;who=[% target.login FILTER uri %]&amp;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&amp;emailtype1=exact&amp;emailreporter1=1&amp;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&amp;emailtype1=exact&amp;emailassigned_to1=1&amp;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&amp;emailtype1=exact&amp;emaillongdesc1=1&amp;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&amp;emailtype1=exact&amp;emailqa_contact1=1&amp;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>&nbsp;</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>&nbsp;</td>
+ <td>&nbsp;</td>
+ <td>&nbsp;</td>
+ <td width="100%">&nbsp;</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;
+}