diff options
Diffstat (limited to 'extensions/BMO/lib')
-rw-r--r-- | extensions/BMO/lib/Reports.pm | 1191 | ||||
-rw-r--r-- | extensions/BMO/lib/Reports/EmailQueue.pm | 69 | ||||
-rw-r--r-- | extensions/BMO/lib/Reports/Groups.pm | 243 | ||||
-rw-r--r-- | extensions/BMO/lib/Reports/ReleaseTracking.pm | 393 | ||||
-rw-r--r-- | extensions/BMO/lib/Reports/Triage.pm | 217 | ||||
-rw-r--r-- | extensions/BMO/lib/Reports/UserActivity.pm | 302 | ||||
-rw-r--r-- | extensions/BMO/lib/Util.pm | 84 |
7 files changed, 1308 insertions, 1191 deletions
diff --git a/extensions/BMO/lib/Reports.pm b/extensions/BMO/lib/Reports.pm deleted file mode 100644 index 0f3a391d9..000000000 --- a/extensions/BMO/lib/Reports.pm +++ /dev/null @@ -1,1191 +0,0 @@ -# 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::BMO::Reports; -use strict; - -use Bugzilla::Extension::BMO::Data qw($cf_disabled_flags); - -use Bugzilla::Constants; -use Bugzilla::Error; -use Bugzilla::Field; -use Bugzilla::Group; -use Bugzilla::User; -use Bugzilla::Util qw(trim detaint_natural trick_taint correct_urlbase); - -use Date::Parse; -use DateTime; -use JSON qw(-convert_blessed_universally); -use List::MoreUtils qw(uniq); - -use base qw(Exporter); - -our @EXPORT = qw( user_activity_report - triage_reports - group_admins_report - email_queue_report - release_tracking_report - group_membership_report - group_members_report ); - -sub user_activity_report { - my ($vars) = @_; - my $dbh = Bugzilla->dbh; - my $input = Bugzilla->input_params; - - my @who = (); - my $from = trim($input->{'from'} || ''); - my $to = trim($input->{'to'} || ''); - my $action = $input->{'action'} || ''; - - # fix non-breaking hyphens - $from =~ s/\N{U+2011}/-/g; - $to =~ s/\N{U+2011}/-/g; - - if ($from eq '') { - my $dt = DateTime->now()->subtract('weeks' => 8); - $from = $dt->ymd('-'); - } - if ($to eq '') { - my $dt = DateTime->now(); - $to = $dt->ymd('-'); - } - - if ($action eq 'run') { - if ($input->{'who'} eq '') { - ThrowUserError('user_activity_missing_username'); - } - Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} }); - - my $from_dt = _string_to_datetime($from); - $from = $from_dt->ymd(); - - my $to_dt = _string_to_datetime($to); - $to = $to_dt->ymd(); - # add one day to include all activity that happened on the 'to' date - $to_dt->add(days => 1); - - my ($activity_joins, $activity_where) = ('', ''); - my ($attachments_joins, $attachments_where) = ('', ''); - if (Bugzilla->params->{"insidergroup"} - && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'})) - { - $activity_joins = "LEFT JOIN attachments - ON attachments.attach_id = bugs_activity.attach_id"; - $activity_where = "AND COALESCE(attachments.isprivate, 0) = 0"; - $attachments_where = $activity_where; - } - - my @who_bits; - foreach my $who ( - ref $input->{'who'} - ? @{$input->{'who'}} - : $input->{'who'} - ) { - push @who, $who; - push @who_bits, '?'; - } - my $who_bits = join(',', @who_bits); - - if (!@who) { - my $template = Bugzilla->template; - my $cgi = Bugzilla->cgi; - my $vars = {}; - $vars->{'script'} = $cgi->url(-relative => 1); - $vars->{'fields'} = {}; - $vars->{'matches'} = []; - $vars->{'matchsuccess'} = 0; - $vars->{'matchmultiple'} = 1; - print $cgi->header(); - $template->process("global/confirm-user-match.html.tmpl", $vars) - || ThrowTemplateError($template->error()); - exit; - } - - $from_dt = $from_dt->ymd() . ' 00:00:00'; - $to_dt = $to_dt->ymd() . ' 23:59:59'; - my @params; - for (1..4) { - push @params, @who; - push @params, ($from_dt, $to_dt); - } - - my $order = ($input->{'sort'} && $input->{'sort'} eq 'bug') - ? 'bug_id, bug_when' : 'bug_when'; - - my $comment_filter = ''; - if (!Bugzilla->user->is_insider) { - $comment_filter = 'AND longdescs.isprivate = 0'; - } - - my $query = " - SELECT - fielddefs.name, - bugs_activity.bug_id, - bugs_activity.attach_id, - ".$dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s')." AS ts, - bugs_activity.removed, - bugs_activity.added, - profiles.login_name, - bugs_activity.comment_id, - bugs_activity.bug_when - FROM bugs_activity - $activity_joins - LEFT JOIN fielddefs - ON bugs_activity.fieldid = fielddefs.id - INNER JOIN profiles - ON profiles.userid = bugs_activity.who - WHERE profiles.login_name IN ($who_bits) - AND bugs_activity.bug_when >= ? AND bugs_activity.bug_when <= ? - $activity_where - - UNION ALL - - SELECT - 'bug_id' AS name, - bugs.bug_id, - NULL AS attach_id, - ".$dbh->sql_date_format('bugs.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts, - '(new bug)' AS removed, - bugs.short_desc AS added, - profiles.login_name, - NULL AS comment_id, - bugs.creation_ts AS bug_when - FROM bugs - INNER JOIN profiles - ON profiles.userid = bugs.reporter - WHERE profiles.login_name IN ($who_bits) - AND bugs.creation_ts >= ? AND bugs.creation_ts <= ? - - UNION ALL - - SELECT - 'longdesc' AS name, - longdescs.bug_id, - NULL AS attach_id, - DATE_FORMAT(longdescs.bug_when, '%Y.%m.%d %H:%i:%s') AS ts, - '' AS removed, - '' AS added, - profiles.login_name, - longdescs.comment_id AS comment_id, - longdescs.bug_when - FROM longdescs - INNER JOIN profiles - ON profiles.userid = longdescs.who - WHERE profiles.login_name IN ($who_bits) - AND longdescs.bug_when >= ? AND longdescs.bug_when <= ? - $comment_filter - - UNION ALL - - SELECT - 'attachments.description' AS name, - attachments.bug_id, - attachments.attach_id, - ".$dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts, - '(new attachment)' AS removed, - attachments.description AS added, - profiles.login_name, - NULL AS comment_id, - attachments.creation_ts AS bug_when - FROM attachments - INNER JOIN profiles - ON profiles.userid = attachments.submitter_id - WHERE profiles.login_name IN ($who_bits) - AND attachments.creation_ts >= ? AND attachments.creation_ts <= ? - $attachments_where - - ORDER BY $order "; - - my $list = $dbh->selectall_arrayref($query, undef, @params); - - if ($input->{debug}) { - while (my $param = shift @params) { - $query =~ s/\?/$dbh->quote($param)/e; - } - $vars->{debug_sql} = $query; - } - - my @operations; - my $operation = {}; - my $changes = []; - my $incomplete_data = 0; - my %bug_ids; - - foreach my $entry (@$list) { - my ($fieldname, $bugid, $attachid, $when, $removed, $added, $who, - $comment_id) = @$entry; - my %change; - my $activity_visible = 1; - - next unless Bugzilla->user->can_see_bug($bugid); - - # check if the user should see this field's activity - if ($fieldname eq 'remaining_time' - || $fieldname eq 'estimated_time' - || $fieldname eq 'work_time' - || $fieldname eq 'deadline') - { - $activity_visible = Bugzilla->user->is_timetracker; - } - elsif ($fieldname eq 'longdescs.isprivate' - && !Bugzilla->user->is_insider - && $added) - { - $activity_visible = 0; - } - else { - $activity_visible = 1; - } - - if ($activity_visible) { - # Check for the results of an old Bugzilla data corruption bug - if (($added eq '?' && $removed eq '?') - || ($added =~ /^\? / || $removed =~ /^\? /)) { - $incomplete_data = 1; - } - - # Start a new changeset if required (depends on the sort order) - my $is_new_changeset; - if ($order eq 'bug_when') { - $is_new_changeset = - $operation->{'who'} && - ( - $who ne $operation->{'who'} - || $when ne $operation->{'when'} - || $bugid != $operation->{'bug'} - ); - } else { - $is_new_changeset = - $operation->{'bug'} && - $bugid != $operation->{'bug'}; - } - if ($is_new_changeset) { - $operation->{'changes'} = $changes; - push (@operations, $operation); - $operation = {}; - $changes = []; - } - - $bug_ids{$bugid} = 1; - - $operation->{'bug'} = $bugid; - $operation->{'who'} = $who; - $operation->{'when'} = $when; - - $change{'fieldname'} = $fieldname; - $change{'attachid'} = $attachid; - $change{'removed'} = $removed; - $change{'added'} = $added; - $change{'when'} = $when; - - if ($comment_id) { - $change{'comment'} = Bugzilla::Comment->new($comment_id); - next if $change{'comment'}->count == 0; - } - - if ($attachid) { - $change{'attach'} = Bugzilla::Attachment->new($attachid); - } - - push (@$changes, \%change); - } - } - - if ($operation->{'who'}) { - $operation->{'changes'} = $changes; - push (@operations, $operation); - } - - $vars->{'incomplete_data'} = $incomplete_data; - $vars->{'operations'} = \@operations; - - my @bug_ids = sort { $a <=> $b } keys %bug_ids; - $vars->{'bug_ids'} = \@bug_ids; - } - - $vars->{'action'} = $action; - $vars->{'who'} = join(',', @who); - $vars->{'who_count'} = scalar @who; - $vars->{'from'} = $from; - $vars->{'to'} = $to; - $vars->{'sort'} = $input->{'sort'}; -} - -sub _string_to_datetime { - my $input = shift; - my $time = _parse_date($input) - or ThrowUserError('report_invalid_date', { date => $input }); - return _time_to_datetime($time); -} - -sub _time_to_datetime { - my $time = shift; - return DateTime->from_epoch(epoch => $time) - ->set_time_zone('local') - ->truncate(to => 'day'); -} - -sub _parse_date { - my ($str) = @_; - if ($str =~ /^(-|\+)?(\d+)([hHdDwWmMyY])$/) { - # relative date - my ($sign, $amount, $unit, $date) = ($1, $2, lc $3, time); - my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime($date); - $amount = -$amount if $sign && $sign eq '+'; - if ($unit eq 'w') { - # convert weeks to days - $amount = 7*$amount + $wday; - $unit = 'd'; - } - if ($unit eq 'd') { - $date -= $sec + 60*$min + 3600*$hour + 24*3600*$amount; - return $date; - } - elsif ($unit eq 'y') { - return str2time(sprintf("%4d-01-01 00:00:00", $year+1900-$amount)); - } - elsif ($unit eq 'm') { - $month -= $amount; - while ($month<0) { $year--; $month += 12; } - return str2time(sprintf("%4d-%02d-01 00:00:00", $year+1900, $month+1)); - } - elsif ($unit eq 'h') { - # Special case 0h for 'beginning of this hour' - if ($amount == 0) { - $date -= $sec + 60*$min; - } else { - $date -= 3600*$amount; - } - return $date; - } - return undef; - } - return str2time($str); -} - -sub triage_reports { - my ($vars, $filter) = @_; - my $dbh = Bugzilla->dbh; - my $input = Bugzilla->input_params; - my $user = Bugzilla->user; - - if (exists $input->{'action'} && $input->{'action'} eq 'run' && $input->{'product'}) { - - # load product and components from input - - my $product = Bugzilla::Product->new({ name => $input->{'product'} }) - || ThrowUserError('invalid_object', { object => 'Product', value => $input->{'product'} }); - - my @component_ids; - if ($input->{'component'} ne '') { - my $ra_components = ref($input->{'component'}) - ? $input->{'component'} : [ $input->{'component'} ]; - foreach my $component_name (@$ra_components) { - my $component = Bugzilla::Component->new({ name => $component_name, product => $product }) - || ThrowUserError('invalid_object', { object => 'Component', value => $component_name }); - push @component_ids, $component->id; - } - } - - # determine which comment filters to run - - my $filter_commenter = $input->{'filter_commenter'}; - my $filter_commenter_on = $input->{'commenter'}; - my $filter_last = $input->{'filter_last'}; - my $filter_last_period = $input->{'last'}; - - if (!$filter_commenter || $filter_last) { - $filter_commenter = '1'; - $filter_commenter_on = 'reporter'; - } - - my $filter_commenter_id; - if ($filter_commenter && $filter_commenter_on eq 'is') { - Bugzilla::User::match_field({ 'commenter_is' => {'type' => 'single'} }); - my $user = Bugzilla::User->new({ name => $input->{'commenter_is'} }) - || ThrowUserError('invalid_object', { object => 'User', value => $input->{'commenter_is'} }); - $filter_commenter_id = $user ? $user->id : 0; - } - - my $filter_last_time; - if ($filter_last) { - if ($filter_last_period eq 'is') { - $filter_last_period = -1; - $filter_last_time = str2time($input->{'last_is'} . " 00:00:00") || 0; - } else { - detaint_natural($filter_last_period); - $filter_last_period = 14 if $filter_last_period < 14; - } - } - - # form sql queries - - my $now = (time); - my $bugs_sql = " - SELECT bug_id, short_desc, reporter, creation_ts - FROM bugs - WHERE product_id = ? - AND bug_status = 'UNCONFIRMED'"; - if (@component_ids) { - $bugs_sql .= " AND component_id IN (" . join(',', @component_ids) . ")"; - } - $bugs_sql .= " - ORDER BY creation_ts - "; - - my $comment_count_sql = " - SELECT COUNT(*) - FROM longdescs - WHERE bug_id = ? - "; - - my $comment_sql = " - SELECT who, bug_when, type, thetext, extra_data - FROM longdescs - WHERE bug_id = ? - "; - if (!Bugzilla->user->is_insider) { - $comment_sql .= " AND isprivate = 0 "; - } - $comment_sql .= " - ORDER BY bug_when DESC - LIMIT 1 - "; - - my $attach_sql = " - SELECT description, isprivate - FROM attachments - WHERE attach_id = ? - "; - - # work on an initial list of bugs - - my $list = $dbh->selectall_arrayref($bugs_sql, undef, $product->id); - my @bugs; - - foreach my $entry (@$list) { - my ($bug_id, $summary, $reporter_id, $creation_ts) = @$entry; - - next unless $user->can_see_bug($bug_id); - - # get last comment information - - my ($comment_count) = $dbh->selectrow_array($comment_count_sql, undef, $bug_id); - my ($commenter_id, $comment_ts, $type, $comment, $extra) - = $dbh->selectrow_array($comment_sql, undef, $bug_id); - my $commenter = 0; - - # apply selected filters - - if ($filter_commenter) { - next if $comment_count <= 1; - - if ($filter_commenter_on eq 'reporter') { - next if $commenter_id != $reporter_id; - - } elsif ($filter_commenter_on eq 'noconfirm') { - $commenter = Bugzilla::User->new($commenter_id); - next if $commenter_id != $reporter_id - || $commenter->in_group('canconfirm'); - - } elsif ($filter_commenter_on eq 'is') { - next if $commenter_id != $filter_commenter_id; - } - } else { - $input->{'commenter'} = ''; - $input->{'commenter_is'} = ''; - } - - if ($filter_last) { - my $comment_time = str2time($comment_ts) - or next; - if ($filter_last_period == -1) { - next if $comment_time >= $filter_last_time; - } else { - next if $now - $comment_time <= 60 * 60 * 24 * $filter_last_period; - } - } else { - $input->{'last'} = ''; - $input->{'last_is'} = ''; - } - - # get data for attachment comments - - if ($comment eq '' && $type == CMT_ATTACHMENT_CREATED) { - my ($description, $is_private) = $dbh->selectrow_array($attach_sql, undef, $extra); - next if $is_private && !Bugzilla->user->is_insider; - $comment = "(Attachment) " . $description; - } - - # truncate long comments - - if (length($comment) > 80) { - $comment = substr($comment, 0, 80) . '...'; - } - - # build bug hash for template - - my $bug = {}; - $bug->{id} = $bug_id; - $bug->{summary} = $summary; - $bug->{reporter} = Bugzilla::User->new($reporter_id); - $bug->{creation_ts} = $creation_ts; - $bug->{commenter} = $commenter || Bugzilla::User->new($commenter_id); - $bug->{comment_ts} = $comment_ts; - $bug->{comment} = $comment; - $bug->{comment_count} = $comment_count; - push @bugs, $bug; - } - - @bugs = sort { $b->{comment_ts} cmp $a->{comment_ts} } @bugs; - - $vars->{bugs} = \@bugs; - } else { - $input->{action} = ''; - } - - if (!$input->{filter_commenter} && !$input->{filter_last}) { - $input->{filter_commenter} = 1; - } - - $vars->{'input'} = $input; -} - -sub group_admins_report { - my ($vars) = @_; - my $dbh = Bugzilla->dbh; - my $user = Bugzilla->user; - - ($user->in_group('editusers') || $user->in_group('infrasec')) - || ThrowUserError('auth_failure', { group => 'editusers', - action => 'run', - object => 'group_admins' }); - - my $query = " - SELECT groups.name, " . - $dbh->sql_group_concat('profiles.login_name', "','", 1) . " - FROM groups - LEFT JOIN user_group_map - ON user_group_map.group_id = groups.id - AND user_group_map.isbless = 1 - AND user_group_map.grant_type = 0 - LEFT JOIN profiles - ON user_group_map.user_id = profiles.userid - WHERE groups.isbuggroup = 1 - GROUP BY groups.name"; - - my @groups; - foreach my $group (@{ $dbh->selectall_arrayref($query) }) { - my @admins; - if ($group->[1]) { - foreach my $admin (split(/,/, $group->[1])) { - push(@admins, Bugzilla::User->new({ name => $admin })); - } - } - push(@groups, { name => $group->[0], admins => \@admins }); - } - - $vars->{'groups'} = \@groups; -} - -sub group_membership_report { - my ($page, $vars) = @_; - my $dbh = Bugzilla->dbh; - my $user = Bugzilla->user; - my $cgi = Bugzilla->cgi; - - ($user->in_group('editusers') || $user->in_group('infrasec')) - || ThrowUserError('auth_failure', { group => 'editusers', - action => 'run', - object => 'group_admins' }); - - my $who = $cgi->param('who'); - if (!defined($who) || $who eq '') { - if ($page eq 'group_membership.txt') { - print $cgi->redirect("page.cgi?id=group_membership.html&output=txt"); - exit; - } - $vars->{'output'} = $cgi->param('output'); - return; - } - - Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} }); - $who = Bugzilla->input_params->{'who'}; - $who = ref($who) ? $who : [ $who ]; - - my @users; - foreach my $login (@$who) { - my $u = Bugzilla::User->new(login_to_id($login, 1)); - - # this is lifted from $user->groups() - # we need to show which groups are direct and which are inherited - - my $groups_to_check = $dbh->selectcol_arrayref( - q{SELECT DISTINCT group_id - FROM user_group_map - WHERE user_id = ? AND isbless = 0}, undef, $u->id); - - my $rows = $dbh->selectall_arrayref( - "SELECT DISTINCT grantor_id, member_id - FROM group_group_map - WHERE grant_type = " . GROUP_MEMBERSHIP); - - my %group_membership; - foreach my $row (@$rows) { - my ($grantor_id, $member_id) = @$row; - push (@{ $group_membership{$member_id} }, $grantor_id); - } - - my %checked_groups; - my %direct_groups; - my %indirect_groups; - my %groups; - - foreach my $member_id (@$groups_to_check) { - $direct_groups{$member_id} = 1; - } - - while (scalar(@$groups_to_check) > 0) { - my $member_id = shift @$groups_to_check; - if (!$checked_groups{$member_id}) { - $checked_groups{$member_id} = 1; - my $members = $group_membership{$member_id}; - my @new_to_check = grep(!$checked_groups{$_}, @$members); - push(@$groups_to_check, @new_to_check); - foreach my $id (@new_to_check) { - $indirect_groups{$id} = $member_id; - } - $groups{$member_id} = 1; - } - } - - my @groups; - my $ra_groups = Bugzilla::Group->new_from_list([keys %groups]); - foreach my $group (@$ra_groups) { - my $via; - if ($direct_groups{$group->id}) { - $via = ''; - } else { - foreach my $g (@$ra_groups) { - if ($g->id == $indirect_groups{$group->id}) { - $via = $g->name; - last; - } - } - } - push @groups, { - name => $group->name, - desc => $group->description, - via => $via, - }; - } - - push @users, { - user => $u, - groups => \@groups, - }; - } - - $vars->{'who'} = $who; - $vars->{'users'} = \@users; -} - -sub group_members_report { - my ($vars) = @_; - my $dbh = Bugzilla->dbh; - my $user = Bugzilla->user; - my $cgi = Bugzilla->cgi; - - ($user->in_group('editusers') || $user->in_group('infrasec')) - || ThrowUserError('auth_failure', { group => 'editusers', - action => 'run', - object => 'group_admins' }); - - my $include_disabled = $cgi->param('include_disabled') ? 1 : 0; - $vars->{'include_disabled'} = $include_disabled; - - # don't allow all groups, to avoid putting pain on the servers - my @group_names = - sort - grep { !/^(?:bz_.+|canconfirm|editbugs|everyone)$/ } - map { lc($_->name) } - Bugzilla::Group->get_all; - unshift(@group_names, ''); - $vars->{'groups'} = \@group_names; - - # load selected group - my $group = lc(trim($cgi->param('group') || '')); - $group = '' unless grep { $_ eq $group } @group_names; - return if $group eq ''; - my $group_obj = Bugzilla::Group->new({ name => $group }); - $vars->{'group'} = $group; - - # direct members - my @types = ( - { - name => 'direct', - members => _filter_userlist($group_obj->members_direct, $include_disabled), - }, - ); - - # indirect members, by group - foreach my $member_group (sort @{ $group_obj->grant_direct(GROUP_MEMBERSHIP) }) { - push @types, { - name => $member_group->name, - members => _filter_userlist($member_group->members_direct, $include_disabled), - }, - } - - # make it easy for the template to detect an empty group - my $has_members = 0; - foreach my $type (@types) { - $has_members += scalar(@{ $type->{members} }); - last if $has_members; - } - @types = () unless $has_members; - - if (@types) { - # add last-login - my $user_ids = join(',', map { map { $_->id } @{ $_->{members} } } @types); - my $tokens = $dbh->selectall_hashref(" - SELECT profiles.userid, - (SELECT DATEDIFF(curdate(), logincookies.lastused) lastseen - FROM logincookies - WHERE logincookies.userid = profiles.userid - ORDER BY lastused DESC - LIMIT 1) lastseen - FROM profiles - WHERE userid IN ($user_ids)", - 'userid'); - foreach my $type (@types) { - foreach my $member (@{ $type->{members} }) { - $member->{lastseen} = - defined $tokens->{$member->id}->{lastseen} - ? $tokens->{$member->id}->{lastseen} - : '>' . MAX_LOGINCOOKIE_AGE; - } - } - } - - $vars->{'types'} = \@types; -} - -sub _filter_userlist { - my ($list, $include_disabled) = @_; - $list = [ grep { $_->is_enabled } @$list ] unless $include_disabled; - return [ sort { lc($a->identity) cmp lc($b->identity) } @$list ]; -} - -sub email_queue_report { - my ($vars, $filter) = @_; - my $dbh = Bugzilla->dbh; - my $user = Bugzilla->user; - - $user->in_group('admin') || $user->in_group('infra') - || ThrowUserError('auth_failure', { group => 'admin', - action => 'run', - object => 'email_queue' }); - - my $query = " - SELECT j.jobid, - j.insert_time, - j.run_after AS run_time, - COUNT(e.jobid) AS error_count, - MAX(e.error_time) AS error_time, - e.message AS error_message - FROM ts_job j - LEFT JOIN ts_error e ON e.jobid = j.jobid - GROUP BY j.jobid - ORDER BY j.run_after"; - - $vars->{'jobs'} = $dbh->selectall_arrayref($query, { Slice => {} }); - $vars->{'now'} = (time); -} - -sub release_tracking_report { - my ($vars) = @_; - my $dbh = Bugzilla->dbh; - my $input = Bugzilla->input_params; - my $user = Bugzilla->user; - - my @flag_names = qw( - approval-mozilla-release - approval-mozilla-beta - approval-mozilla-aurora - approval-mozilla-central - approval-comm-release - approval-comm-beta - approval-comm-aurora - approval-calendar-release - approval-calendar-beta - approval-calendar-aurora - approval-mozilla-esr10 - ); - - my @flags_json; - my @fields_json; - my @products_json; - - # - # tracking flags - # - - my $all_products = $user->get_selectable_products; - my @usable_products; - - # build list of flags and their matching products - - my @invalid_flag_names; - foreach my $flag_name (@flag_names) { - # grab all matching flag_types - my @flag_types = @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })}; - - # remove invalid flags - if (!@flag_types) { - push @invalid_flag_names, $flag_name; - next; - } - - # we need a list of products, based on inclusions/exclusions - my @products; - my %flag_types; - foreach my $flag_type (@flag_types) { - $flag_types{$flag_type->name} = $flag_type->id; - my $has_all = 0; - my @exclusion_ids; - my @inclusion_ids; - foreach my $flag_type (@flag_types) { - if (scalar keys %{$flag_type->inclusions}) { - my $inclusions = $flag_type->inclusions; - foreach my $key (keys %$inclusions) { - push @inclusion_ids, ($inclusions->{$key} =~ /^(\d+)/); - } - } elsif (scalar keys %{$flag_type->exclusions}) { - my $exclusions = $flag_type->exclusions; - foreach my $key (keys %$exclusions) { - push @exclusion_ids, ($exclusions->{$key} =~ /^(\d+)/); - } - } else { - $has_all = 1; - last; - } - } - - if ($has_all) { - push @products, @$all_products; - } elsif (scalar @exclusion_ids) { - push @products, @$all_products; - foreach my $exclude_id (uniq @exclusion_ids) { - @products = grep { $_->id != $exclude_id } @products; - } - } else { - foreach my $include_id (uniq @inclusion_ids) { - push @products, grep { $_->id == $include_id } @$all_products; - } - } - } - @products = uniq @products; - push @usable_products, @products; - my @product_ids = map { $_->id } sort { lc($a->name) cmp lc($b->name) } @products; - - push @flags_json, { - name => $flag_name, - id => $flag_types{$flag_name} || 0, - products => \@product_ids, - fields => [], - }; - } - foreach my $flag_name (@invalid_flag_names) { - @flag_names = grep { $_ ne $flag_name } @flag_names; - } - @usable_products = uniq @usable_products; - - # build a list of tracking flags for each product - # also build the list of all fields - - my @unlink_products; - foreach my $product (@usable_products) { - my @fields = - grep { _is_active_status_field($_->name) } - Bugzilla->active_custom_fields({ product => $product }); - my @field_ids = map { $_->id } @fields; - if (!scalar @fields) { - push @unlink_products, $product; - next; - } - - # product - push @products_json, { - name => $product->name, - id => $product->id, - fields => \@field_ids, - }; - - # add fields to flags - foreach my $rh (@flags_json) { - if (grep { $_ eq $product->id } @{$rh->{products}}) { - push @{$rh->{fields}}, @field_ids; - } - } - - # add fields to fields_json - foreach my $field (@fields) { - my $existing = 0; - foreach my $rh (@fields_json) { - if ($rh->{id} == $field->id) { - $existing = 1; - last; - } - } - if (!$existing) { - push @fields_json, { - name => $field->name, - id => $field->id, - }; - } - } - } - foreach my $rh (@flags_json) { - my @fields = uniq @{$rh->{fields}}; - $rh->{fields} = \@fields; - } - - # remove products which aren't linked with status fields - - foreach my $rh (@flags_json) { - my @product_ids; - foreach my $id (@{$rh->{products}}) { - unless (grep { $_->id == $id } @unlink_products) { - push @product_ids, $id; - } - $rh->{products} = \@product_ids; - } - } - - # - # rapid release dates - # - - my @ranges; - my $start_date = _string_to_datetime('2011-08-16'); - my $end_date = $start_date->clone->add(weeks => 6)->add(days => -1); - my $now_date = _string_to_datetime('2012-11-19'); - - while ($start_date <= $now_date) { - unshift @ranges, { - value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), - label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), - }; - - $start_date = $end_date->clone;; - $start_date->add(days => 1); - $end_date->add(weeks => 6); - } - - # 2012-11-20 - 2013-01-06 was a 7 week release cycle instead of 6 - $start_date = _string_to_datetime('2012-11-20'); - $end_date = $start_date->clone->add(weeks => 7)->add(days => -1); - unshift @ranges, { - value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), - label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), - }; - - # Back on track with 6 week releases - $start_date = _string_to_datetime('2013-01-08'); - $end_date = $start_date->clone->add(weeks => 6)->add(days => -1); - $now_date = _time_to_datetime((time)); - - while ($start_date <= $now_date) { - unshift @ranges, { - value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), - label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), - }; - - $start_date = $end_date->clone;; - $start_date->add(days => 1); - $end_date->add(weeks => 6); - } - - push @ranges, { - value => '*', - label => 'Anytime', - }; - - # - # run report - # - - if ($input->{q} && !$input->{edit}) { - my $q = _parse_query($input->{q}); - - my @where; - my @params; - my $query = " - SELECT DISTINCT b.bug_id - FROM bugs b - INNER JOIN flags f ON f.bug_id = b.bug_id "; - - if ($q->{start_date}) { - $query .= "INNER JOIN bugs_activity a ON a.bug_id = b.bug_id "; - } - - $query .= "WHERE "; - - if ($q->{start_date}) { - push @where, "(a.fieldid = ?)"; - push @params, $q->{field_id}; - - push @where, "(a.bug_when >= ?)"; - push @params, $q->{start_date} . ' 00:00:00'; - push @where, "(a.bug_when < ?)"; - push @params, $q->{end_date} . ' 00:00:00'; - - push @where, "(a.added LIKE ?)"; - push @params, '%' . $q->{flag_name} . $q->{flag_status} . '%'; - } - - push @where, "(f.type_id IN (SELECT id FROM flagtypes WHERE name = ?))"; - push @params, $q->{flag_name}; - - push @where, "(f.status = ?)"; - push @params, $q->{flag_status}; - - if ($q->{product_id}) { - push @where, "(b.product_id = ?)"; - push @params, $q->{product_id}; - } - - if (scalar @{$q->{fields}}) { - my @fields; - foreach my $field (@{$q->{fields}}) { - push @fields, - "(" . - ($field->{value} eq '+' ? '' : '!') . - "(b.".$field->{name}." IN ('fixed','verified'))" . - ") "; - } - my $join = uc $q->{join}; - push @where, '(' . join(" $join ", @fields) . ')'; - } - - $query .= join("\nAND ", @where); - - if ($input->{debug}) { - print "Content-Type: text/plain\n\n"; - $query =~ s/\?/\000/g; - foreach my $param (@params) { - $query =~ s/\000/$param/; - } - print "$query\n"; - exit; - } - - my $bugs = $dbh->selectcol_arrayref($query, undef, @params); - push @$bugs, 0 unless @$bugs; - - my $urlbase = correct_urlbase(); - my $cgi = Bugzilla->cgi; - print $cgi->redirect( - -url => "${urlbase}buglist.cgi?bug_id=" . join(',', @$bugs) - ); - exit; - } - - # - # set template vars - # - - my $json = JSON->new(); - if (0) { - # debugging - $json->shrink(0); - $json->canonical(1); - $vars->{flags_json} = $json->pretty->encode(\@flags_json); - $vars->{products_json} = $json->pretty->encode(\@products_json); - $vars->{fields_json} = $json->pretty->encode(\@fields_json); - } else { - $json->shrink(1); - $vars->{flags_json} = $json->encode(\@flags_json); - $vars->{products_json} = $json->encode(\@products_json); - $vars->{fields_json} = $json->encode(\@fields_json); - } - - $vars->{flag_names} = \@flag_names; - $vars->{ranges} = \@ranges; - $vars->{default_query} = $input->{q}; - foreach my $field (qw(product flags range)) { - $vars->{$field} = $input->{$field}; - } -} - -sub _parse_query { - my $q = shift; - my @query = split(/:/, $q); - my $query; - - # field_id for flag changes - $query->{field_id} = get_field_id('flagtypes.name'); - - # flag_name - my $flag_name = shift @query; - @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })} - or ThrowUserError('report_invalid_parameter', { name => 'flag_name' }); - trick_taint($flag_name); - $query->{flag_name} = $flag_name; - - # flag_status - my $flag_status = shift @query; - $flag_status =~ /^([\?\-\+])$/ - or ThrowUserError('report_invalid_parameter', { name => 'flag_status' }); - $query->{flag_status} = $1; - - # date_range -> from_ymd to_ymd - my $date_range = shift @query; - if ($date_range ne '*') { - $date_range =~ /^(\d\d\d\d)(\d\d)(\d\d)-(\d\d\d\d)(\d\d)(\d\d)$/ - or ThrowUserError('report_invalid_parameter', { name => 'date_range' }); - $query->{start_date} = "$1-$2-$3"; - $query->{end_date} = "$4-$5-$6"; - } - - # product_id - my $product_id = shift @query; - $product_id =~ /^(\d+)$/ - or ThrowUserError('report_invalid_parameter', { name => 'product_id' }); - $query->{product_id} = $1; - - # join - my $join = shift @query; - $join =~ /^(and|or)$/ - or ThrowUserError('report_invalid_parameter', { name => 'join' }); - $query->{join} = $1; - - # fields - my @fields; - foreach my $field (@query) { - $field =~ /^(\d+)([\-\+])$/ - or ThrowUserError('report_invalid_parameter', { name => 'fields' }); - my ($id, $value) = ($1, $2); - my $field_obj = Bugzilla::Field->new($id) - or ThrowUserError('report_invalid_parameter', { name => 'field_id' }); - push @fields, { id => $id, value => $value, name => $field_obj->name }; - } - $query->{fields} = \@fields; - - return $query; -} - -sub _is_active_status_field { - my ($field_name) = @_; - if ($field_name =~ /^cf_status/) { - return !grep { $field_name eq $_ } @$cf_disabled_flags - } - return 0; -} - -1; diff --git a/extensions/BMO/lib/Reports/EmailQueue.pm b/extensions/BMO/lib/Reports/EmailQueue.pm new file mode 100644 index 000000000..1bf2ca003 --- /dev/null +++ b/extensions/BMO/lib/Reports/EmailQueue.pm @@ -0,0 +1,69 @@ +# 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::BMO::Reports::EmailQueue; +use strict; +use warnings; + +use Bugzilla::Error; +use Scalar::Util qw(blessed); +use Storable (); + +sub report { + my ($vars, $filter) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + + $user->in_group('admin') || $user->in_group('infra') + || ThrowUserError('auth_failure', { group => 'admin', + action => 'run', + object => 'email_queue' }); + + my $query = " + SELECT j.jobid, + j.arg, + j.insert_time, + j.run_after AS run_time, + COUNT(e.jobid) AS error_count, + MAX(e.error_time) AS error_time, + e.message AS error_message + FROM ts_job j + LEFT JOIN ts_error e ON e.jobid = j.jobid + GROUP BY j.jobid + ORDER BY j.run_after"; + + $vars->{'jobs'} = $dbh->selectall_arrayref($query, { Slice => {} }); + foreach my $job (@{ $vars->{'jobs'} }) { + eval { + my $msg = _cond_thaw(delete $job->{'arg'})->{msg}; + if (ref($msg) && blessed($msg) eq 'Email::MIME') { + $job->{'subject'} = $msg->header('subject'); + } else { + ($job->{'subject'}) = $msg =~ /\nSubject: ([^\n]+)/; + } + }; + } + $vars->{'now'} = (time); +} + +sub _cond_thaw { + my $data = shift; + my $magic = eval { Storable::read_magic($data); }; + if ($magic && $magic->{major} && $magic->{major} >= 2 && $magic->{major} <= 5) { + my $thawed = eval { Storable::thaw($data) }; + if ($@) { + # false alarm... looked like a Storable, but wasn't. + return $data; + } + return $thawed; + } else { + return $data; + } +} + + +1; diff --git a/extensions/BMO/lib/Reports/Groups.pm b/extensions/BMO/lib/Reports/Groups.pm new file mode 100644 index 000000000..9fc9cfbe5 --- /dev/null +++ b/extensions/BMO/lib/Reports/Groups.pm @@ -0,0 +1,243 @@ +# 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::BMO::Reports::Groups; +use strict; +use warnings; + +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Group; +use Bugzilla::User; +use Bugzilla::Util qw(trim); + +sub admins_report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + + ($user->in_group('editusers') || $user->in_group('infrasec')) + || ThrowUserError('auth_failure', { group => 'editusers', + action => 'run', + object => 'group_admins' }); + + my $query = " + SELECT groups.name, " . + $dbh->sql_group_concat('profiles.login_name', "','", 1) . " + FROM groups + LEFT JOIN user_group_map + ON user_group_map.group_id = groups.id + AND user_group_map.isbless = 1 + AND user_group_map.grant_type = 0 + LEFT JOIN profiles + ON user_group_map.user_id = profiles.userid + WHERE groups.isbuggroup = 1 + GROUP BY groups.name"; + + my @groups; + foreach my $group (@{ $dbh->selectall_arrayref($query) }) { + my @admins; + if ($group->[1]) { + foreach my $admin (split(/,/, $group->[1])) { + push(@admins, Bugzilla::User->new({ name => $admin })); + } + } + push(@groups, { name => $group->[0], admins => \@admins }); + } + + $vars->{'groups'} = \@groups; +} + +sub membership_report { + my ($page, $vars) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my $cgi = Bugzilla->cgi; + + ($user->in_group('editusers') || $user->in_group('infrasec')) + || ThrowUserError('auth_failure', { group => 'editusers', + action => 'run', + object => 'group_admins' }); + + my $who = $cgi->param('who'); + if (!defined($who) || $who eq '') { + if ($page eq 'group_membership.txt') { + print $cgi->redirect("page.cgi?id=group_membership.html&output=txt"); + exit; + } + $vars->{'output'} = $cgi->param('output'); + return; + } + + Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} }); + $who = Bugzilla->input_params->{'who'}; + $who = ref($who) ? $who : [ $who ]; + + my @users; + foreach my $login (@$who) { + my $u = Bugzilla::User->new(login_to_id($login, 1)); + + # this is lifted from $user->groups() + # we need to show which groups are direct and which are inherited + + my $groups_to_check = $dbh->selectcol_arrayref( + q{SELECT DISTINCT group_id + FROM user_group_map + WHERE user_id = ? AND isbless = 0}, undef, $u->id); + + my $rows = $dbh->selectall_arrayref( + "SELECT DISTINCT grantor_id, member_id + FROM group_group_map + WHERE grant_type = " . GROUP_MEMBERSHIP); + + my %group_membership; + foreach my $row (@$rows) { + my ($grantor_id, $member_id) = @$row; + push (@{ $group_membership{$member_id} }, $grantor_id); + } + + my %checked_groups; + my %direct_groups; + my %indirect_groups; + my %groups; + + foreach my $member_id (@$groups_to_check) { + $direct_groups{$member_id} = 1; + } + + while (scalar(@$groups_to_check) > 0) { + my $member_id = shift @$groups_to_check; + if (!$checked_groups{$member_id}) { + $checked_groups{$member_id} = 1; + my $members = $group_membership{$member_id}; + my @new_to_check = grep(!$checked_groups{$_}, @$members); + push(@$groups_to_check, @new_to_check); + foreach my $id (@new_to_check) { + $indirect_groups{$id} = $member_id; + } + $groups{$member_id} = 1; + } + } + + my @groups; + my $ra_groups = Bugzilla::Group->new_from_list([keys %groups]); + foreach my $group (@$ra_groups) { + my $via; + if ($direct_groups{$group->id}) { + $via = ''; + } else { + foreach my $g (@$ra_groups) { + if ($g->id == $indirect_groups{$group->id}) { + $via = $g->name; + last; + } + } + } + push @groups, { + name => $group->name, + desc => $group->description, + via => $via, + }; + } + + push @users, { + user => $u, + groups => \@groups, + }; + } + + $vars->{'who'} = $who; + $vars->{'users'} = \@users; +} + +sub members_report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $user = Bugzilla->user; + my $cgi = Bugzilla->cgi; + + ($user->in_group('editusers') || $user->in_group('infrasec')) + || ThrowUserError('auth_failure', { group => 'editusers', + action => 'run', + object => 'group_admins' }); + + my $include_disabled = $cgi->param('include_disabled') ? 1 : 0; + $vars->{'include_disabled'} = $include_disabled; + + # don't allow all groups, to avoid putting pain on the servers + my @group_names = + sort + grep { !/^(?:bz_.+|canconfirm|editbugs|everyone)$/ } + map { lc($_->name) } + Bugzilla::Group->get_all; + unshift(@group_names, ''); + $vars->{'groups'} = \@group_names; + + # load selected group + my $group = lc(trim($cgi->param('group') || '')); + $group = '' unless grep { $_ eq $group } @group_names; + return if $group eq ''; + my $group_obj = Bugzilla::Group->new({ name => $group }); + $vars->{'group'} = $group; + + # direct members + my @types = ( + { + name => 'direct', + members => _filter_userlist($group_obj->members_direct, $include_disabled), + }, + ); + + # indirect members, by group + foreach my $member_group (sort @{ $group_obj->grant_direct(GROUP_MEMBERSHIP) }) { + push @types, { + name => $member_group->name, + members => _filter_userlist($member_group->members_direct, $include_disabled), + }, + } + + # make it easy for the template to detect an empty group + my $has_members = 0; + foreach my $type (@types) { + $has_members += scalar(@{ $type->{members} }); + last if $has_members; + } + @types = () unless $has_members; + + if (@types) { + # add last-login + my $user_ids = join(',', map { map { $_->id } @{ $_->{members} } } @types); + my $tokens = $dbh->selectall_hashref(" + SELECT profiles.userid, + (SELECT DATEDIFF(curdate(), logincookies.lastused) lastseen + FROM logincookies + WHERE logincookies.userid = profiles.userid + ORDER BY lastused DESC + LIMIT 1) lastseen + FROM profiles + WHERE userid IN ($user_ids)", + 'userid'); + foreach my $type (@types) { + foreach my $member (@{ $type->{members} }) { + $member->{lastseen} = + defined $tokens->{$member->id}->{lastseen} + ? $tokens->{$member->id}->{lastseen} + : '>' . MAX_LOGINCOOKIE_AGE; + } + } + } + + $vars->{'types'} = \@types; +} + +sub _filter_userlist { + my ($list, $include_disabled) = @_; + $list = [ grep { $_->is_enabled } @$list ] unless $include_disabled; + return [ sort { lc($a->identity) cmp lc($b->identity) } @$list ]; +} + +1; diff --git a/extensions/BMO/lib/Reports/ReleaseTracking.pm b/extensions/BMO/lib/Reports/ReleaseTracking.pm new file mode 100644 index 000000000..e307da192 --- /dev/null +++ b/extensions/BMO/lib/Reports/ReleaseTracking.pm @@ -0,0 +1,393 @@ +# 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::BMO::Reports::ReleaseTracking; +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Extension::BMO::Util; +use Bugzilla::Field; +use Bugzilla::FlagType; +use Bugzilla::Util qw(correct_urlbase trick_taint); +use JSON qw(-convert_blessed_universally); +use List::MoreUtils qw(uniq); + +sub report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $input = Bugzilla->input_params; + my $user = Bugzilla->user; + + my @flag_names = qw( + approval-mozilla-release + approval-mozilla-beta + approval-mozilla-aurora + approval-mozilla-central + approval-comm-release + approval-comm-beta + approval-comm-aurora + approval-calendar-release + approval-calendar-beta + approval-calendar-aurora + approval-mozilla-esr10 + ); + + my @flags_json; + my @fields_json; + my @products_json; + + # + # tracking flags + # + + my $all_products = $user->get_selectable_products; + my @usable_products; + + # build list of flags and their matching products + + my @invalid_flag_names; + foreach my $flag_name (@flag_names) { + # grab all matching flag_types + my @flag_types = @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })}; + + # remove invalid flags + if (!@flag_types) { + push @invalid_flag_names, $flag_name; + next; + } + + # we need a list of products, based on inclusions/exclusions + my @products; + my %flag_types; + foreach my $flag_type (@flag_types) { + $flag_types{$flag_type->name} = $flag_type->id; + my $has_all = 0; + my @exclusion_ids; + my @inclusion_ids; + foreach my $flag_type (@flag_types) { + if (scalar keys %{$flag_type->inclusions}) { + my $inclusions = $flag_type->inclusions; + foreach my $key (keys %$inclusions) { + push @inclusion_ids, ($inclusions->{$key} =~ /^(\d+)/); + } + } elsif (scalar keys %{$flag_type->exclusions}) { + my $exclusions = $flag_type->exclusions; + foreach my $key (keys %$exclusions) { + push @exclusion_ids, ($exclusions->{$key} =~ /^(\d+)/); + } + } else { + $has_all = 1; + last; + } + } + + if ($has_all) { + push @products, @$all_products; + } elsif (scalar @exclusion_ids) { + push @products, @$all_products; + foreach my $exclude_id (uniq @exclusion_ids) { + @products = grep { $_->id != $exclude_id } @products; + } + } else { + foreach my $include_id (uniq @inclusion_ids) { + push @products, grep { $_->id == $include_id } @$all_products; + } + } + } + @products = uniq @products; + push @usable_products, @products; + my @product_ids = map { $_->id } sort { lc($a->name) cmp lc($b->name) } @products; + + push @flags_json, { + name => $flag_name, + id => $flag_types{$flag_name} || 0, + products => \@product_ids, + fields => [], + }; + } + foreach my $flag_name (@invalid_flag_names) { + @flag_names = grep { $_ ne $flag_name } @flag_names; + } + @usable_products = uniq @usable_products; + + # build a list of tracking flags for each product + # also build the list of all fields + + my @unlink_products; + foreach my $product (@usable_products) { + my @fields = + grep { is_active_status_field($_->name) } + Bugzilla->active_custom_fields({ product => $product }); + my @field_ids = map { $_->id } @fields; + if (!scalar @fields) { + push @unlink_products, $product; + next; + } + + # product + push @products_json, { + name => $product->name, + id => $product->id, + fields => \@field_ids, + }; + + # add fields to flags + foreach my $rh (@flags_json) { + if (grep { $_ eq $product->id } @{$rh->{products}}) { + push @{$rh->{fields}}, @field_ids; + } + } + + # add fields to fields_json + foreach my $field (@fields) { + my $existing = 0; + foreach my $rh (@fields_json) { + if ($rh->{id} == $field->id) { + $existing = 1; + last; + } + } + if (!$existing) { + push @fields_json, { + name => $field->name, + id => $field->id, + }; + } + } + } + foreach my $rh (@flags_json) { + my @fields = uniq @{$rh->{fields}}; + $rh->{fields} = \@fields; + } + + # remove products which aren't linked with status fields + + foreach my $rh (@flags_json) { + my @product_ids; + foreach my $id (@{$rh->{products}}) { + unless (grep { $_->id == $id } @unlink_products) { + push @product_ids, $id; + } + $rh->{products} = \@product_ids; + } + } + + # + # rapid release dates + # + + my @ranges; + my $start_date = string_to_datetime('2011-08-16'); + my $end_date = $start_date->clone->add(weeks => 6)->add(days => -1); + my $now_date = string_to_datetime('2012-11-19'); + + while ($start_date <= $now_date) { + unshift @ranges, { + value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), + label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), + }; + + $start_date = $end_date->clone;; + $start_date->add(days => 1); + $end_date->add(weeks => 6); + } + + # 2012-11-20 - 2013-01-06 was a 7 week release cycle instead of 6 + $start_date = string_to_datetime('2012-11-20'); + $end_date = $start_date->clone->add(weeks => 7)->add(days => -1); + unshift @ranges, { + value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), + label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), + }; + + # Back on track with 6 week releases + $start_date = string_to_datetime('2013-01-08'); + $end_date = $start_date->clone->add(weeks => 6)->add(days => -1); + $now_date = time_to_datetime((time)); + + while ($start_date <= $now_date) { + unshift @ranges, { + value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), + label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), + }; + + $start_date = $end_date->clone;; + $start_date->add(days => 1); + $end_date->add(weeks => 6); + } + + push @ranges, { + value => '*', + label => 'Anytime', + }; + + # + # run report + # + + if ($input->{q} && !$input->{edit}) { + my $q = _parse_query($input->{q}); + + my @where; + my @params; + my $query = " + SELECT DISTINCT b.bug_id + FROM bugs b + INNER JOIN flags f ON f.bug_id = b.bug_id "; + + if ($q->{start_date}) { + $query .= "INNER JOIN bugs_activity a ON a.bug_id = b.bug_id "; + } + + $query .= "WHERE "; + + if ($q->{start_date}) { + push @where, "(a.fieldid = ?)"; + push @params, $q->{field_id}; + + push @where, "(a.bug_when >= ?)"; + push @params, $q->{start_date} . ' 00:00:00'; + push @where, "(a.bug_when < ?)"; + push @params, $q->{end_date} . ' 00:00:00'; + + push @where, "(a.added LIKE ?)"; + push @params, '%' . $q->{flag_name} . $q->{flag_status} . '%'; + } + + push @where, "(f.type_id IN (SELECT id FROM flagtypes WHERE name = ?))"; + push @params, $q->{flag_name}; + + push @where, "(f.status = ?)"; + push @params, $q->{flag_status}; + + if ($q->{product_id}) { + push @where, "(b.product_id = ?)"; + push @params, $q->{product_id}; + } + + if (scalar @{$q->{fields}}) { + my @fields; + foreach my $field (@{$q->{fields}}) { + push @fields, + "(" . + ($field->{value} eq '+' ? '' : '!') . + "(b.".$field->{name}." IN ('fixed','verified'))" . + ") "; + } + my $join = uc $q->{join}; + push @where, '(' . join(" $join ", @fields) . ')'; + } + + $query .= join("\nAND ", @where); + + if ($input->{debug}) { + print "Content-Type: text/plain\n\n"; + $query =~ s/\?/\000/g; + foreach my $param (@params) { + $query =~ s/\000/$param/; + } + print "$query\n"; + exit; + } + + my $bugs = $dbh->selectcol_arrayref($query, undef, @params); + push @$bugs, 0 unless @$bugs; + + my $urlbase = correct_urlbase(); + my $cgi = Bugzilla->cgi; + print $cgi->redirect( + -url => "${urlbase}buglist.cgi?bug_id=" . join(',', @$bugs) + ); + exit; + } + + # + # set template vars + # + + my $json = JSON->new(); + if (0) { + # debugging + $json->shrink(0); + $json->canonical(1); + $vars->{flags_json} = $json->pretty->encode(\@flags_json); + $vars->{products_json} = $json->pretty->encode(\@products_json); + $vars->{fields_json} = $json->pretty->encode(\@fields_json); + } else { + $json->shrink(1); + $vars->{flags_json} = $json->encode(\@flags_json); + $vars->{products_json} = $json->encode(\@products_json); + $vars->{fields_json} = $json->encode(\@fields_json); + } + + $vars->{flag_names} = \@flag_names; + $vars->{ranges} = \@ranges; + $vars->{default_query} = $input->{q}; + foreach my $field (qw(product flags range)) { + $vars->{$field} = $input->{$field}; + } +} + +sub _parse_query { + my $q = shift; + my @query = split(/:/, $q); + my $query; + + # field_id for flag changes + $query->{field_id} = get_field_id('flagtypes.name'); + + # flag_name + my $flag_name = shift @query; + @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })} + or ThrowUserError('report_invalid_parameter', { name => 'flag_name' }); + trick_taint($flag_name); + $query->{flag_name} = $flag_name; + + # flag_status + my $flag_status = shift @query; + $flag_status =~ /^([\?\-\+])$/ + or ThrowUserError('report_invalid_parameter', { name => 'flag_status' }); + $query->{flag_status} = $1; + + # date_range -> from_ymd to_ymd + my $date_range = shift @query; + if ($date_range ne '*') { + $date_range =~ /^(\d\d\d\d)(\d\d)(\d\d)-(\d\d\d\d)(\d\d)(\d\d)$/ + or ThrowUserError('report_invalid_parameter', { name => 'date_range' }); + $query->{start_date} = "$1-$2-$3"; + $query->{end_date} = "$4-$5-$6"; + } + + # product_id + my $product_id = shift @query; + $product_id =~ /^(\d+)$/ + or ThrowUserError('report_invalid_parameter', { name => 'product_id' }); + $query->{product_id} = $1; + + # join + my $join = shift @query; + $join =~ /^(and|or)$/ + or ThrowUserError('report_invalid_parameter', { name => 'join' }); + $query->{join} = $1; + + # fields + my @fields; + foreach my $field (@query) { + $field =~ /^(\d+)([\-\+])$/ + or ThrowUserError('report_invalid_parameter', { name => 'fields' }); + my ($id, $value) = ($1, $2); + my $field_obj = Bugzilla::Field->new($id) + or ThrowUserError('report_invalid_parameter', { name => 'field_id' }); + push @fields, { id => $id, value => $value, name => $field_obj->name }; + } + $query->{fields} = \@fields; + + return $query; +} + +1; diff --git a/extensions/BMO/lib/Reports/Triage.pm b/extensions/BMO/lib/Reports/Triage.pm new file mode 100644 index 000000000..debb50577 --- /dev/null +++ b/extensions/BMO/lib/Reports/Triage.pm @@ -0,0 +1,217 @@ +# 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::BMO::Reports::Triage; +use strict; + +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Product; +use Bugzilla::User; +use Bugzilla::Util qw(detaint_natural); +use Date::Parse; + +# set an upper limit on the *unfiltered* number of bugs to process +use constant MAX_NUMBER_BUGS => 4000; + +sub report { + my ($vars, $filter) = @_; + my $dbh = Bugzilla->dbh; + my $input = Bugzilla->input_params; + my $user = Bugzilla->user; + + if (exists $input->{'action'} && $input->{'action'} eq 'run' && $input->{'product'}) { + + # load product and components from input + + my $product = Bugzilla::Product->new({ name => $input->{'product'} }) + || ThrowUserError('invalid_object', { object => 'Product', value => $input->{'product'} }); + + my @component_ids; + if ($input->{'component'} ne '') { + my $ra_components = ref($input->{'component'}) + ? $input->{'component'} : [ $input->{'component'} ]; + foreach my $component_name (@$ra_components) { + my $component = Bugzilla::Component->new({ name => $component_name, product => $product }) + || ThrowUserError('invalid_object', { object => 'Component', value => $component_name }); + push @component_ids, $component->id; + } + } + + # determine which comment filters to run + + my $filter_commenter = $input->{'filter_commenter'}; + my $filter_commenter_on = $input->{'commenter'}; + my $filter_last = $input->{'filter_last'}; + my $filter_last_period = $input->{'last'}; + + if (!$filter_commenter || $filter_last) { + $filter_commenter = '1'; + $filter_commenter_on = 'reporter'; + } + + my $filter_commenter_id; + if ($filter_commenter && $filter_commenter_on eq 'is') { + Bugzilla::User::match_field({ 'commenter_is' => {'type' => 'single'} }); + my $user = Bugzilla::User->new({ name => $input->{'commenter_is'} }) + || ThrowUserError('invalid_object', { object => 'User', value => $input->{'commenter_is'} }); + $filter_commenter_id = $user ? $user->id : 0; + } + + my $filter_last_time; + if ($filter_last) { + if ($filter_last_period eq 'is') { + $filter_last_period = -1; + $filter_last_time = str2time($input->{'last_is'} . " 00:00:00") || 0; + } else { + detaint_natural($filter_last_period); + $filter_last_period = 14 if $filter_last_period < 14; + } + } + + # form sql queries + + my $now = (time); + my $bugs_sql = " + SELECT bug_id, short_desc, reporter, creation_ts + FROM bugs + WHERE product_id = ? + AND bug_status = 'UNCONFIRMED'"; + if (@component_ids) { + $bugs_sql .= " AND component_id IN (" . join(',', @component_ids) . ")"; + } + $bugs_sql .= " + ORDER BY creation_ts + "; + + my $comment_count_sql = " + SELECT COUNT(*) + FROM longdescs + WHERE bug_id = ? + "; + + my $comment_sql = " + SELECT who, bug_when, type, thetext, extra_data + FROM longdescs + WHERE bug_id = ? + "; + if (!Bugzilla->user->is_insider) { + $comment_sql .= " AND isprivate = 0 "; + } + $comment_sql .= " + ORDER BY bug_when DESC + LIMIT 1 + "; + + my $attach_sql = " + SELECT description, isprivate + FROM attachments + WHERE attach_id = ? + "; + + # work on an initial list of bugs + + my $list = $dbh->selectall_arrayref($bugs_sql, undef, $product->id); + my @bugs; + + # this can be slow to process, resulting in 'service unavailable' errors from zeus + # so if too many bugs are returned, throw an error + + if (scalar(@$list) > MAX_NUMBER_BUGS) { + ThrowUserError('report_too_many_bugs'); + } + + foreach my $entry (@$list) { + my ($bug_id, $summary, $reporter_id, $creation_ts) = @$entry; + + next unless $user->can_see_bug($bug_id); + + # get last comment information + + my ($comment_count) = $dbh->selectrow_array($comment_count_sql, undef, $bug_id); + my ($commenter_id, $comment_ts, $type, $comment, $extra) + = $dbh->selectrow_array($comment_sql, undef, $bug_id); + my $commenter = 0; + + # apply selected filters + + if ($filter_commenter) { + next if $comment_count <= 1; + + if ($filter_commenter_on eq 'reporter') { + next if $commenter_id != $reporter_id; + + } elsif ($filter_commenter_on eq 'noconfirm') { + $commenter = Bugzilla::User->new({ id => $commenter_id, cache => 1 }); + next if $commenter_id != $reporter_id + || $commenter->in_group('canconfirm'); + + } elsif ($filter_commenter_on eq 'is') { + next if $commenter_id != $filter_commenter_id; + } + } else { + $input->{'commenter'} = ''; + $input->{'commenter_is'} = ''; + } + + if ($filter_last) { + my $comment_time = str2time($comment_ts) + or next; + if ($filter_last_period == -1) { + next if $comment_time >= $filter_last_time; + } else { + next if $now - $comment_time <= 60 * 60 * 24 * $filter_last_period; + } + } else { + $input->{'last'} = ''; + $input->{'last_is'} = ''; + } + + # get data for attachment comments + + if ($comment eq '' && $type == CMT_ATTACHMENT_CREATED) { + my ($description, $is_private) = $dbh->selectrow_array($attach_sql, undef, $extra); + next if $is_private && !Bugzilla->user->is_insider; + $comment = "(Attachment) " . $description; + } + + # truncate long comments + + if (length($comment) > 80) { + $comment = substr($comment, 0, 80) . '...'; + } + + # build bug hash for template + + my $bug = {}; + $bug->{id} = $bug_id; + $bug->{summary} = $summary; + $bug->{reporter} = Bugzilla::User->new({ id => $reporter_id, cache => 1 }); + $bug->{creation_ts} = $creation_ts; + $bug->{commenter} = $commenter || Bugzilla::User->new({ id => $commenter_id, cache => 1 }); + $bug->{comment_ts} = $comment_ts; + $bug->{comment} = $comment; + $bug->{comment_count} = $comment_count; + push @bugs, $bug; + } + + @bugs = sort { $b->{comment_ts} cmp $a->{comment_ts} } @bugs; + + $vars->{bugs} = \@bugs; + } else { + $input->{action} = ''; + } + + if (!$input->{filter_commenter} && !$input->{filter_last}) { + $input->{filter_commenter} = 1; + } + + $vars->{'input'} = $input; +} + +1; diff --git a/extensions/BMO/lib/Reports/UserActivity.pm b/extensions/BMO/lib/Reports/UserActivity.pm new file mode 100644 index 000000000..feca7c4b7 --- /dev/null +++ b/extensions/BMO/lib/Reports/UserActivity.pm @@ -0,0 +1,302 @@ +# 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::BMO::Reports::UserActivity; +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Extension::BMO::Util; +use Bugzilla::User; +use Bugzilla::Util qw(trim); +use DateTime; + +sub report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $input = Bugzilla->input_params; + + my @who = (); + my $from = trim($input->{'from'} || ''); + my $to = trim($input->{'to'} || ''); + my $action = $input->{'action'} || ''; + + # fix non-breaking hyphens + $from =~ s/\N{U+2011}/-/g; + $to =~ s/\N{U+2011}/-/g; + + if ($from eq '') { + my $dt = DateTime->now()->subtract('weeks' => 8); + $from = $dt->ymd('-'); + } + if ($to eq '') { + my $dt = DateTime->now(); + $to = $dt->ymd('-'); + } + + if ($action eq 'run') { + if ($input->{'who'} eq '') { + ThrowUserError('user_activity_missing_username'); + } + Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} }); + + my $from_dt = string_to_datetime($from); + $from = $from_dt->ymd(); + + my $to_dt = string_to_datetime($to); + $to = $to_dt->ymd(); + # add one day to include all activity that happened on the 'to' date + $to_dt->add(days => 1); + + my ($activity_joins, $activity_where) = ('', ''); + my ($attachments_joins, $attachments_where) = ('', ''); + if (Bugzilla->params->{"insidergroup"} + && !Bugzilla->user->in_group(Bugzilla->params->{'insidergroup'})) + { + $activity_joins = "LEFT JOIN attachments + ON attachments.attach_id = bugs_activity.attach_id"; + $activity_where = "AND COALESCE(attachments.isprivate, 0) = 0"; + $attachments_where = $activity_where; + } + + my @who_bits; + foreach my $who ( + ref $input->{'who'} + ? @{$input->{'who'}} + : $input->{'who'} + ) { + push @who, $who; + push @who_bits, '?'; + } + my $who_bits = join(',', @who_bits); + + if (!@who) { + my $template = Bugzilla->template; + my $cgi = Bugzilla->cgi; + my $vars = {}; + $vars->{'script'} = $cgi->url(-relative => 1); + $vars->{'fields'} = {}; + $vars->{'matches'} = []; + $vars->{'matchsuccess'} = 0; + $vars->{'matchmultiple'} = 1; + print $cgi->header(); + $template->process("global/confirm-user-match.html.tmpl", $vars) + || ThrowTemplateError($template->error()); + exit; + } + + $from_dt = $from_dt->ymd() . ' 00:00:00'; + $to_dt = $to_dt->ymd() . ' 23:59:59'; + my @params; + for (1..4) { + push @params, @who; + push @params, ($from_dt, $to_dt); + } + + my $order = ($input->{'sort'} && $input->{'sort'} eq 'bug') + ? 'bug_id, bug_when' : 'bug_when'; + + my $comment_filter = ''; + if (!Bugzilla->user->is_insider) { + $comment_filter = 'AND longdescs.isprivate = 0'; + } + + my $query = " + SELECT + fielddefs.name, + bugs_activity.bug_id, + bugs_activity.attach_id, + ".$dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s')." AS ts, + bugs_activity.removed, + bugs_activity.added, + profiles.login_name, + bugs_activity.comment_id, + bugs_activity.bug_when + FROM bugs_activity + $activity_joins + LEFT JOIN fielddefs + ON bugs_activity.fieldid = fielddefs.id + INNER JOIN profiles + ON profiles.userid = bugs_activity.who + WHERE profiles.login_name IN ($who_bits) + AND bugs_activity.bug_when >= ? AND bugs_activity.bug_when <= ? + $activity_where + + UNION ALL + + SELECT + 'bug_id' AS name, + bugs.bug_id, + NULL AS attach_id, + ".$dbh->sql_date_format('bugs.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts, + '(new bug)' AS removed, + bugs.short_desc AS added, + profiles.login_name, + NULL AS comment_id, + bugs.creation_ts AS bug_when + FROM bugs + INNER JOIN profiles + ON profiles.userid = bugs.reporter + WHERE profiles.login_name IN ($who_bits) + AND bugs.creation_ts >= ? AND bugs.creation_ts <= ? + + UNION ALL + + SELECT + 'longdesc' AS name, + longdescs.bug_id, + NULL AS attach_id, + DATE_FORMAT(longdescs.bug_when, '%Y.%m.%d %H:%i:%s') AS ts, + '' AS removed, + '' AS added, + profiles.login_name, + longdescs.comment_id AS comment_id, + longdescs.bug_when + FROM longdescs + INNER JOIN profiles + ON profiles.userid = longdescs.who + WHERE profiles.login_name IN ($who_bits) + AND longdescs.bug_when >= ? AND longdescs.bug_when <= ? + $comment_filter + + UNION ALL + + SELECT + 'attachments.description' AS name, + attachments.bug_id, + attachments.attach_id, + ".$dbh->sql_date_format('attachments.creation_ts', '%Y.%m.%d %H:%i:%s')." AS ts, + '(new attachment)' AS removed, + attachments.description AS added, + profiles.login_name, + NULL AS comment_id, + attachments.creation_ts AS bug_when + FROM attachments + INNER JOIN profiles + ON profiles.userid = attachments.submitter_id + WHERE profiles.login_name IN ($who_bits) + AND attachments.creation_ts >= ? AND attachments.creation_ts <= ? + $attachments_where + + ORDER BY $order "; + + my $list = $dbh->selectall_arrayref($query, undef, @params); + + if ($input->{debug}) { + while (my $param = shift @params) { + $query =~ s/\?/$dbh->quote($param)/e; + } + $vars->{debug_sql} = $query; + } + + my @operations; + my $operation = {}; + my $changes = []; + my $incomplete_data = 0; + my %bug_ids; + + foreach my $entry (@$list) { + my ($fieldname, $bugid, $attachid, $when, $removed, $added, $who, + $comment_id) = @$entry; + my %change; + my $activity_visible = 1; + + next unless Bugzilla->user->can_see_bug($bugid); + + # check if the user should see this field's activity + if ($fieldname eq 'remaining_time' + || $fieldname eq 'estimated_time' + || $fieldname eq 'work_time' + || $fieldname eq 'deadline') + { + $activity_visible = Bugzilla->user->is_timetracker; + } + elsif ($fieldname eq 'longdescs.isprivate' + && !Bugzilla->user->is_insider + && $added) + { + $activity_visible = 0; + } + else { + $activity_visible = 1; + } + + if ($activity_visible) { + # Check for the results of an old Bugzilla data corruption bug + if (($added eq '?' && $removed eq '?') + || ($added =~ /^\? / || $removed =~ /^\? /)) { + $incomplete_data = 1; + } + + # Start a new changeset if required (depends on the sort order) + my $is_new_changeset; + if ($order eq 'bug_when') { + $is_new_changeset = + $operation->{'who'} && + ( + $who ne $operation->{'who'} + || $when ne $operation->{'when'} + || $bugid != $operation->{'bug'} + ); + } else { + $is_new_changeset = + $operation->{'bug'} && + $bugid != $operation->{'bug'}; + } + if ($is_new_changeset) { + $operation->{'changes'} = $changes; + push (@operations, $operation); + $operation = {}; + $changes = []; + } + + $bug_ids{$bugid} = 1; + + $operation->{'bug'} = $bugid; + $operation->{'who'} = $who; + $operation->{'when'} = $when; + + $change{'fieldname'} = $fieldname; + $change{'attachid'} = $attachid; + $change{'removed'} = $removed; + $change{'added'} = $added; + $change{'when'} = $when; + + if ($comment_id) { + $change{'comment'} = Bugzilla::Comment->new($comment_id); + next if $change{'comment'}->count == 0; + } + + if ($attachid) { + $change{'attach'} = Bugzilla::Attachment->new($attachid); + } + + push (@$changes, \%change); + } + } + + if ($operation->{'who'}) { + $operation->{'changes'} = $changes; + push (@operations, $operation); + } + + $vars->{'incomplete_data'} = $incomplete_data; + $vars->{'operations'} = \@operations; + + my @bug_ids = sort { $a <=> $b } keys %bug_ids; + $vars->{'bug_ids'} = \@bug_ids; + } + + $vars->{'action'} = $action; + $vars->{'who'} = join(',', @who); + $vars->{'who_count'} = scalar @who; + $vars->{'from'} = $from; + $vars->{'to'} = $to; + $vars->{'sort'} = $input->{'sort'}; +} + +1; diff --git a/extensions/BMO/lib/Util.pm b/extensions/BMO/lib/Util.pm new file mode 100644 index 000000000..ccb25758b --- /dev/null +++ b/extensions/BMO/lib/Util.pm @@ -0,0 +1,84 @@ +# 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::BMO::Util; +use strict; +use warnings; + +use Bugzilla::Error; +use Bugzilla::Extension::BMO::Data qw($cf_disabled_flags); +use Date::Parse; +use DateTime; + +use base qw(Exporter); + +our @EXPORT = qw( string_to_datetime + time_to_datetime + parse_date + is_active_status_field ); + +sub string_to_datetime { + my $input = shift; + my $time = parse_date($input) + or ThrowUserError('report_invalid_date', { date => $input }); + return time_to_datetime($time); +} + +sub time_to_datetime { + my $time = shift; + return DateTime->from_epoch(epoch => $time) + ->set_time_zone('local') + ->truncate(to => 'day'); +} + +sub parse_date { + my ($str) = @_; + if ($str =~ /^(-|\+)?(\d+)([hHdDwWmMyY])$/) { + # relative date + my ($sign, $amount, $unit, $date) = ($1, $2, lc $3, time); + my ($sec, $min, $hour, $mday, $month, $year, $wday) = localtime($date); + $amount = -$amount if $sign && $sign eq '+'; + if ($unit eq 'w') { + # convert weeks to days + $amount = 7 * $amount + $wday; + $unit = 'd'; + } + if ($unit eq 'd') { + $date -= $sec + 60 * $min + 3600 * $hour + 24 * 3600 * $amount; + return $date; + } + elsif ($unit eq 'y') { + return str2time(sprintf("%4d-01-01 00:00:00", $year + 1900 - $amount)); + } + elsif ($unit eq 'm') { + $month -= $amount; + while ($month < 0) { $year--; $month += 12; } + return str2time(sprintf("%4d-%02d-01 00:00:00", $year + 1900, $month + 1)); + } + elsif ($unit eq 'h') { + # Special case 0h for 'beginning of this hour' + if ($amount == 0) { + $date -= $sec + 60 * $min; + } else { + $date -= 3600 * $amount; + } + return $date; + } + return undef; + } + return str2time($str); +} + +sub is_active_status_field { + my ($field_name) = @_; + if ($field_name =~ /^cf_status/) { + return !grep { $field_name eq $_ } @$cf_disabled_flags + } + return 0; +} + +1; |