buglist.cgi 42.4 KB
Newer Older
1
#!/usr/bin/perl -T
2 3 4
# 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/.
terry%netscape.com's avatar
terry%netscape.com committed
5
#
6 7
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.
terry%netscape.com's avatar
terry%netscape.com committed
8

9
use 5.10.1;
10
use strict;
11 12
use warnings;

13
use lib qw(. lib);
14

15
use Bugzilla;
16 17 18
use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Util;
19
use Bugzilla::Search;
20
use Bugzilla::Search::Quicksearch;
21
use Bugzilla::Search::Recent;
22
use Bugzilla::Search::Saved;
23
use Bugzilla::Bug;
24
use Bugzilla::Product;
25
use Bugzilla::Field;
26
use Bugzilla::Status;
27
use Bugzilla::Token;
28

29 30
use Date::Parse;

31
my $cgi = Bugzilla->cgi;
32
my $dbh = Bugzilla->dbh;
33 34
my $template = Bugzilla->template;
my $vars = {};
35

36 37 38
# We have to check the login here to get the correct footer if an error is
# thrown and to prevent a logged out user to use QuickSearch if 'requirelogin'
# is turned 'on'.
39
my $user = Bugzilla->login();
40

41 42 43
$cgi->redirect_search_url();

my $buffer = $cgi->query_string();
44
if (length($buffer) == 0) {
45
    ThrowUserError("buglist_parameters_required");
46
}
47

48

49 50 51 52 53 54 55 56 57
# Determine whether this is a quicksearch query.
my $searchstring = $cgi->param('quicksearch');
if (defined($searchstring)) {
    $buffer = quicksearch($searchstring);
    # Quicksearch may do a redirect, in which case it does not return.
    # If it does return, it has modified $cgi->params so we can use them here
    # as if this had been a normal query from the beginning.
}

58
# If configured to not allow empty words, reject empty searches from the
59 60
# Find a Specific Bug search form, including words being a single or 
# several consecutive whitespaces only.
61
if (!Bugzilla->params->{'search_allow_no_criteria'}
62 63
    && defined($cgi->param('content')) && $cgi->param('content') =~ /^\s*$/)
{
64 65 66
    ThrowUserError("buglist_parameters_required");
}

67 68 69
################################################################################
# Data and Security Validation
################################################################################
70

71
# Whether or not the user wants to change multiple bugs.
72
my $dotweak = $cgi->param('tweak') ? 1 : 0;
73 74 75

# Log the user in
if ($dotweak) {
76
    Bugzilla->login(LOGIN_REQUIRED);
77 78
}

79
# Hack to support legacy applications that think the RDF ctype is at format=rdf.
80 81 82 83
if (defined $cgi->param('format') && $cgi->param('format') eq "rdf"
    && !defined $cgi->param('ctype')) {
    $cgi->param('ctype', "rdf");
    $cgi->delete('format');
84
}
85

86 87 88 89 90
# Treat requests for ctype=rss as requests for ctype=atom
if (defined $cgi->param('ctype') && $cgi->param('ctype') eq "rss") {
    $cgi->param('ctype', "atom");
}

91 92 93
# Determine the format in which the user would like to receive the output.
# Uses the default format if the user did not specify an output format;
# otherwise validates the user's choice against the list of available formats.
94 95
my $format = $template->get_format("list/list", scalar $cgi->param('format'),
                                   scalar $cgi->param('ctype'));
96

97 98 99 100 101
# Use server push to display a "Please wait..." message for the user while
# executing their query if their browser supports it and they are viewing
# the bug list as HTML and they have not disabled it by adding &serverpush=0
# to the URL.
#
102 103 104
# Server push is compatible with Gecko-based browsers and Opera, but not with
# MSIE, Lynx or Safari (bug 441496).

105
my $serverpush =
106 107
  $format->{'extension'} eq "html"
    && exists $ENV{'HTTP_USER_AGENT'} 
108
      && $ENV{'HTTP_USER_AGENT'} =~ /(Mozilla.[3-9]|Opera)/
109
        && $ENV{'HTTP_USER_AGENT'} !~ /compatible/i
110
          && $ENV{'HTTP_USER_AGENT'} !~ /(?:WebKit|Trident|KHTML)/
111 112
            && !defined($cgi->param('serverpush'))
              || $cgi->param('serverpush');
113

114
my $order = $cgi->param('order') || "";
115

116 117 118
# The params object to use for the actual query itself
my $params;

119 120
# If the user is retrieving the last bug list they looked at, hack the buffer
# storing the query string so that it looks like a query retrieving those bugs.
121
if (my $last_list = $cgi->param('regetlastlist')) {
122
    my $bug_ids;
123 124 125

    # Logged-out users use the old cookie method for storing the last search.
    if (!$user->id or $last_list eq 'cookie') {
126
        $bug_ids = $cgi->cookie('BUGLIST') or ThrowUserError("missing_cookie");
127
        $bug_ids =~ s/[:-]/,/g;
128
        $order ||= "reuse last sort";
129 130 131 132 133 134 135
    }
    # But logged in users store the last X searches in the DB so they can
    # have multiple bug lists available.
    else {
        my $last_search = Bugzilla::Search::Recent->check(
            { id => $last_list });
        $bug_ids = join(',', @{ $last_search->bug_list });
136
        $order ||= $last_search->list_order;
137
    }
138
    # set up the params for this new query
139
    $params = new Bugzilla::CGI({ bug_id => $bug_ids, order => $order });
140
    $params->param('list_id', $last_list);
141 142
}

143 144 145 146
# Figure out whether or not the user is doing a fulltext search.  If not,
# we'll remove the relevance column from the lists of columns to display
# and order by, since relevance only exists when doing a fulltext search.
my $fulltext = 0;
147
if ($cgi->param('content')) { $fulltext = 1 }
148
my @charts = map(/^field(\d-\d-\d)$/ ? $1 : (), $cgi->param());
149
foreach my $chart (@charts) {
150
    if ($cgi->param("field$chart") eq 'content' && $cgi->param("value$chart")) {
151 152 153 154 155
        $fulltext = 1;
        last;
    }
}

156 157 158 159 160 161 162 163
################################################################################
# Utilities
################################################################################

sub DiffDate {
    my ($datestr) = @_;
    my $date = str2time($datestr);
    my $age = time() - $date;
164

165
    if( $age < 18*60*60 ) {
166
        $date = format_time($datestr, '%H:%M:%S');
167
    } elsif( $age < 6*24*60*60 ) {
168
        $date = format_time($datestr, '%a %H:%M');
169
    } else {
170
        $date = format_time($datestr, '%Y-%m-%d');
171 172 173
    }
    return $date;
}
174

175
sub LookupNamedQuery {
176
    my ($name, $sharer_id) = @_;
177

178
    Bugzilla->login(LOGIN_REQUIRED);
179

180
    my $query = Bugzilla::Search::Saved->check(
181
        { user => $sharer_id, name => $name, _error => 'missing_query' });
182 183

    $query->url
184
       || ThrowUserError("buglist_parameters_required");
185

186 187 188
    # Detaint $sharer_id.
    $sharer_id = $query->user->id if $sharer_id;
    return wantarray ? ($query->url, $query->id, $sharer_id) : $query->url;
189 190
}

191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
# Inserts a Named Query (a "Saved Search") into the database, or
# updates a Named Query that already exists..
# Takes four arguments:
# userid - The userid who the Named Query will belong to.
# query_name - A string that names the new Named Query, or the name
#              of an old Named Query to update. If this is blank, we
#              will throw a UserError. Leading and trailing whitespace
#              will be stripped from this value before it is inserted
#              into the DB.
# query - The query part of the buglist.cgi URL, unencoded. Must not be 
#         empty, or we will throw a UserError.
# link_in_footer (optional) - 1 if the Named Query should be 
# displayed in the user's footer, 0 otherwise.
#
# All parameters are validated before passing them into the database.
#
# Returns: A boolean true value if the query existed in the database 
# before, and we updated it. A boolean false value otherwise.
209
sub InsertNamedQuery {
210
    my ($query_name, $query, $link_in_footer) = @_;
211
    my $dbh = Bugzilla->dbh;
212 213

    $query_name = trim($query_name);
214
    my ($query_obj) = grep {lc($_->name) eq lc($query_name)} @{Bugzilla->user->queries};
215 216

    if ($query_obj) {
217
        $query_obj->set_name($query_name);
218 219
        $query_obj->set_url($query);
        $query_obj->update();
220
    } else {
221 222 223 224 225
        Bugzilla::Search::Saved->create({
            name           => $query_name,
            query          => $query,
            link_in_footer => $link_in_footer
        });
226 227
    }

228
    return $query_obj ? 1 : 0;
229 230
}

231 232 233 234 235 236
sub LookupSeries {
    my ($series_id) = @_;
    detaint_natural($series_id) || ThrowCodeError("invalid_series_id");
    
    my $dbh = Bugzilla->dbh;
    my $result = $dbh->selectrow_array("SELECT query FROM series " .
237 238
                                       "WHERE series_id = ?"
                                       , undef, ($series_id));
239 240 241 242 243
    $result
           || ThrowCodeError("invalid_series_id", {'series_id' => $series_id});
    return $result;
}

244
sub GetQuip {
245
    my $dbh = Bugzilla->dbh;
246 247
    # COUNT is quick because it is cached for MySQL. We may want to revisit
    # this when we support other databases.
248 249
    my $count = $dbh->selectrow_array("SELECT COUNT(quip)"
                                    . " FROM quips WHERE approved = 1");
250
    my $random = int(rand($count));
251
    my $quip = 
252 253
        $dbh->selectrow_array("SELECT quip FROM quips WHERE approved = 1 " . 
                              $dbh->sql_limit(1, $random));
254
    return $quip;
255
}
256

257
# Return groups available for at least one product of the buglist.
258
sub GetGroups {
259
    my $product_names = shift;
260
    my $user = Bugzilla->user;
261 262 263
    my %legal_groups;

    foreach my $product_name (@$product_names) {
264
        my $product = Bugzilla::Product->new({name => $product_name, cache => 1});
265 266

        foreach my $gid (keys %{$product->group_controls}) {
267
            # The user can only edit groups they belong to.
268 269 270 271 272 273
            next unless $user->in_group_id($gid);

            # The user has no control on groups marked as NA or MANDATORY.
            my $group = $product->group_controls->{$gid};
            next if ($group->{membercontrol} == CONTROLMAPMANDATORY
                     || $group->{membercontrol} == CONTROLMAPNA);
274

275 276 277 278 279 280 281
            # It's fine to include inactive groups. Those will be marked
            # as "remove only" when editing several bugs at once.
            $legal_groups{$gid} ||= $group->{group};
        }
    }
    # Return a list of group objects.
    return [values %legal_groups];
282
}
283

284 285
sub _get_common_flag_types {
    my $component_ids = shift;
286
    my $user = Bugzilla->user;
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306

    # Get all the different components in the bug list
    my $components = Bugzilla::Component->new_from_list($component_ids);
    my %flag_types;
    my @flag_types_ids;
    foreach my $component (@$components) {
        foreach my $flag_type (@{$component->flag_types->{'bug'}}) {
            push @flag_types_ids, $flag_type->id;
            $flag_types{$flag_type->id} = $flag_type;
        }
    }

    # We only want flags that appear in all components
    my %common_flag_types;
    foreach my $id (keys %flag_types) {
        my $flag_type_count = scalar grep { $_ == $id } @flag_types_ids;
        $common_flag_types{$id} = $flag_types{$id}
            if $flag_type_count == scalar @$components;
    }

307
    # We only show flags that a user can request.
308
    my @show_flag_types
309 310
        = grep { $user->can_request_flag($_) } values %common_flag_types;
    my $any_flags_requesteeble = grep { $_->is_requesteeble } @show_flag_types;
311 312 313 314

    return(\@show_flag_types, $any_flags_requesteeble);
}

315 316 317
################################################################################
# Command Execution
################################################################################
318

319 320
my $cmdtype   = $cgi->param('cmdtype')   || '';
my $remaction = $cgi->param('remaction') || '';
321
my $sharer_id;
322

323 324
# Backwards-compatibility - the old interface had cmdtype="runnamed" to run
# a named command, and we can't break this because it's in bookmarks.
325 326 327
if ($cmdtype eq "runnamed") {  
    $cmdtype = "dorem";
    $remaction = "run";
328 329
}

330 331 332 333 334 335
# Now we're going to be running, so ensure that the params object is set up,
# using ||= so that we only do so if someone hasn't overridden this 
# earlier, for example by setting up a named query search.

# This will be modified, so make a copy.
$params ||= new Bugzilla::CGI($cgi);
336

337 338 339 340 341
# Generate a reasonable filename for the user agent to suggest to the user
# when the user saves the bug list.  Uses the name of the remembered query
# if available.  We have to do this now, even though we return HTTP headers 
# at the end, because the fact that there is a remembered query gets 
# forgotten in the process of retrieving it.
342
my $disp_prefix = "bugs";
343
if ($cmdtype eq "dorem" && $remaction =~ /^run/) {
344
    $disp_prefix = $cgi->param('namedcmd');
345 346
}

347
# Take appropriate action based on user's request.
348 349
if ($cmdtype eq "dorem") {  
    if ($remaction eq "run") {
350
        my $query_id;
351 352 353
        ($buffer, $query_id, $sharer_id) =
          LookupNamedQuery(scalar $cgi->param("namedcmd"),
                           scalar $cgi->param('sharer_id'));
354 355
        # If this is the user's own query, remember information about it
        # so that it can be modified easily.
356
        $vars->{'searchname'} = $cgi->param('namedcmd');
357
        if (!$cgi->param('sharer_id') ||
358
            $cgi->param('sharer_id') == $user->id) {
359
            $vars->{'searchtype'} = "saved";
360
            $vars->{'search_id'} = $query_id;
361
        }
362
        $params = new Bugzilla::CGI($buffer);
363
        $order = $params->param('order') || $order;
364

365
    }
366
    elsif ($remaction eq "runseries") {
367
        $buffer = LookupSeries(scalar $cgi->param("series_id"));
368
        $vars->{'searchname'} = $cgi->param('namedcmd');
369
        $vars->{'searchtype'} = "series";
370
        $params = new Bugzilla::CGI($buffer);
371 372
        $order = $params->param('order') || $order;
    }
373
    elsif ($remaction eq "forget") {
374
        $user = Bugzilla->login(LOGIN_REQUIRED);
375 376 377
        # Copy the name into a variable, so that we can trick_taint it for
        # the DB. We know it's safe, because we're using placeholders in 
        # the SQL, and the SQL is only a DELETE.
378
        my $qname = $cgi->param('namedcmd');
379
        trick_taint($qname);
380 381 382 383 384 385 386 387 388 389 390 391

        # Do not forget the saved search if it is being used in a whine
        my $whines_in_use = 
            $dbh->selectcol_arrayref('SELECT DISTINCT whine_events.subject
                                                 FROM whine_events
                                           INNER JOIN whine_queries
                                                   ON whine_queries.eventid
                                                      = whine_events.id
                                                WHERE whine_events.owner_userid
                                                      = ?
                                                  AND whine_queries.query_name
                                                      = ?
392
                                      ', undef, $user->id, $qname);
393 394 395 396 397 398 399 400
        if (scalar(@$whines_in_use)) {
            ThrowUserError('saved_search_used_by_whines', 
                           { subjects    => join(',', @$whines_in_use),
                             search_name => $qname                      }
            );
        }

        # If we are here, then we can safely remove the saved search
401 402 403
        my $query_id;
        ($buffer, $query_id) = LookupNamedQuery(scalar $cgi->param("namedcmd"),
                                                $user->id);
404
        if ($query_id) {
405
            # Make sure the user really wants to delete their saved search.
406 407 408
            my $token = $cgi->param('token');
            check_hash_token($token, [$query_id, $qname]);

409 410 411 412 413 414 415 416 417
            $dbh->do('DELETE FROM namedqueries
                            WHERE id = ?',
                     undef, $query_id);
            $dbh->do('DELETE FROM namedqueries_link_in_footer
                            WHERE namedquery_id = ?',
                     undef, $query_id);
            $dbh->do('DELETE FROM namedquery_group_map
                            WHERE namedquery_id = ?',
                     undef, $query_id);
418
            Bugzilla->memcached->clear({ table => 'namedqueries', id => $query_id });
419
        }
420 421

        # Now reset the cached queries
422
        $user->flush_queries_cache();
423

424
        print $cgi->header();
425
        # Generate and return the UI (HTML page) from the appropriate template.
426
        $vars->{'message'} = "buglist_query_gone";
427
        $vars->{'namedcmd'} = $qname;
428 429 430
        $vars->{'url'} = "buglist.cgi?newquery=" . url_quote($buffer)
                         . "&cmdtype=doit&remtype=asnamed&newqueryname=" . url_quote($qname)
                         . "&token=" . url_quote(issue_hash_token(['savedsearch']));
431
        $template->process("global/message.html.tmpl", $vars)
432
          || ThrowTemplateError($template->error());
433
        exit;
434 435
    }
}
436
elsif (($cmdtype eq "doit") && defined $cgi->param('remtype')) {
437
    if ($cgi->param('remtype') eq "asdefault") {
438
        $user = Bugzilla->login(LOGIN_REQUIRED);
439 440
        my $token = $cgi->param('token');
        check_hash_token($token, ['searchknob']);
441 442
        $buffer = $params->canonicalise_query('cmdtype', 'remtype',
                                              'query_based_on', 'token');
443
        InsertNamedQuery(DEFAULT_QUERY_NAME, $buffer);
444
        $vars->{'message'} = "buglist_new_default_query";
445
    }
446
    elsif ($cgi->param('remtype') eq "asnamed") {
447
        $user = Bugzilla->login(LOGIN_REQUIRED);
448
        my $query_name = $cgi->param('newqueryname');
449
        my $new_query = $cgi->param('newquery');
450 451
        my $token = $cgi->param('token');
        check_hash_token($token, ['savedsearch']);
452 453 454
        my $existed_before = InsertNamedQuery($query_name, $new_query, 1);
        if ($existed_before) {
            $vars->{'message'} = "buglist_updated_named_query";
455
        }
456
        else {
457
            $vars->{'message'} = "buglist_new_named_query";
458
        }
459 460 461 462 463
        $vars->{'queryname'} = $query_name;

        # Make sure to invalidate any cached query data, so that the footer is
        # correctly displayed
        $user->flush_queries_cache();
464

465
        print $cgi->header();
466 467 468
        $template->process("global/message.html.tmpl", $vars)
          || ThrowTemplateError($template->error());
        exit;
469
    }
terry%netscape.com's avatar
terry%netscape.com committed
470 471
}

472 473 474 475 476
# backward compatibility hack: if the saved query doesn't say which
# form was used to create it, assume it was on the advanced query
# form - see bug 252295
if (!$params->param('query_format')) {
    $params->param('query_format', 'advanced');
477
    $buffer = $params->query_string;
478
}
terry%netscape.com's avatar
terry%netscape.com committed
479

480 481 482 483
################################################################################
# Column Definition
################################################################################

484
my $columns = Bugzilla::Search::COLUMNS;
485

486 487 488 489 490 491 492
################################################################################
# Display Column Determination
################################################################################

# Determine the columns that will be displayed in the bug list via the 
# columnlist CGI parameter, the user's preferences, or the default.
my @displaycolumns = ();
493 494
if (defined $params->param('columnlist')) {
    if ($params->param('columnlist') eq "all") {
495
        # If the value of the CGI parameter is "all", display all columns,
496 497
        # but remove the redundant "short_desc" column.
        @displaycolumns = grep($_ ne 'short_desc', keys(%$columns));
terry%netscape.com's avatar
terry%netscape.com committed
498
    }
499
    else {
500
        @displaycolumns = split(/[ ,]+/, $params->param('columnlist'));
501
    }
terry%netscape.com's avatar
terry%netscape.com committed
502
}
503
elsif (defined $cgi->cookie('COLUMNLIST')) {
504
    # 2002-10-31 Rename column names (see bug 176461)
505
    my $columnlist = $cgi->cookie('COLUMNLIST');
506 507 508 509 510 511 512
    $columnlist =~ s/\bowner\b/assigned_to/;
    $columnlist =~ s/\bowner_realname\b/assigned_to_realname/;
    $columnlist =~ s/\bplatform\b/rep_platform/;
    $columnlist =~ s/\bseverity\b/bug_severity/;
    $columnlist =~ s/\bstatus\b/bug_status/;
    $columnlist =~ s/\bsummaryfull\b/short_desc/;
    $columnlist =~ s/\bsummary\b/short_short_desc/;
513

514
    # Use the columns listed in the user's preferences.
515
    @displaycolumns = split(/ /, $columnlist);
terry%netscape.com's avatar
terry%netscape.com committed
516
}
517 518
else {
    # Use the default list of columns.
519
    @displaycolumns = DEFAULT_COLUMN_LIST;
520 521
}

522 523 524 525
# Weed out columns that don't actually exist to prevent the user 
# from hacking their column list cookie to grab data to which they 
# should not have access.  Detaint the data along the way.
@displaycolumns = grep($columns->{$_} && trick_taint($_), @displaycolumns);
526

527 528
# Remove the "ID" column from the list because bug IDs are always displayed
# and are hard-coded into the display templates.
529
@displaycolumns = grep($_ ne 'bug_id', @displaycolumns);
terry%netscape.com's avatar
terry%netscape.com committed
530

531 532
# Remove the timetracking columns if they are not a part of the group
# (happens if a user had access to time tracking and it was revoked/disabled)
533
if (!$user->is_timetracker) {
534 535 536
   foreach my $tt_field (TIMETRACKING_FIELDS) {
       @displaycolumns = grep($_ ne $tt_field, @displaycolumns);
   }
537
}
terry%netscape.com's avatar
terry%netscape.com committed
538

539 540 541 542 543
# Remove the relevance column if the user is not doing a fulltext search.
if (grep('relevance', @displaycolumns) && !$fulltext) {
    @displaycolumns = grep($_ ne 'relevance', @displaycolumns);
}

544 545 546
################################################################################
# Select Column Determination
################################################################################
terry%netscape.com's avatar
terry%netscape.com committed
547

548
# Generate the list of columns that will be selected in the SQL query.
terry%netscape.com's avatar
terry%netscape.com committed
549

550
# The bug ID is always selected because bug IDs are always displayed.
551 552 553
# Severity, priority, resolution and status are required for buglist
# CSS classes.
my @selectcolumns = ("bug_id", "bug_severity", "priority", "bug_status",
554
                     "resolution", "product");
555

556
# remaining and actual_time are required for percentage_complete calculation:
557
if (grep { $_ eq "percentage_complete" } @displaycolumns) {
558 559 560 561
    push (@selectcolumns, "remaining_time");
    push (@selectcolumns, "actual_time");
}

562 563 564 565 566 567 568 569 570 571 572 573
# Make sure that the login_name version of a field is always also
# requested if the realname version is requested, so that we can
# display the login name when the realname is empty.
my @realname_fields = grep(/_realname$/, @displaycolumns);
foreach my $item (@realname_fields) {
    my $login_field = $item;
    $login_field =~ s/_realname$//;
    if (!grep($_ eq $login_field, @selectcolumns)) {
        push(@selectcolumns, $login_field);
    }
}

574
# Display columns are selected because otherwise we could not display them.
575 576 577
foreach my $col (@displaycolumns) {
    push (@selectcolumns, $col) if !grep($_ eq $col, @selectcolumns);
}
terry%netscape.com's avatar
terry%netscape.com committed
578

579 580
# If the user is editing multiple bugs, we also make sure to select the 
# status, because the values of that field determines what options the user
581 582
# has for modifying the bugs.
if ($dotweak) {
583
    push(@selectcolumns, "bug_status") if !grep($_ eq 'bug_status', @selectcolumns);
584
    push(@selectcolumns, "bugs.component_id");
585 586
}

587 588
if ($format->{'extension'} eq 'ics') {
    push(@selectcolumns, "opendate") if !grep($_ eq 'opendate', @selectcolumns);
589 590 591
    if (Bugzilla->params->{'timetrackinggroup'}) {
        push(@selectcolumns, "deadline") if !grep($_ eq 'deadline', @selectcolumns);
    }
592
}
593

594 595
if ($format->{'extension'} eq 'atom') {
    # The title of the Atom feed will be the same one as for the bug list.
596 597
    $vars->{'title'} = $cgi->param('title');

598 599
    # This is the list of fields that are needed by the Atom filter.
    my @required_atom_columns = (
600 601 602
      'short_desc',
      'opendate',
      'changeddate',
603
      'reporter',
604 605 606
      'reporter_realname',
      'priority',
      'bug_severity',
607
      'assigned_to',
608
      'assigned_to_realname',
609 610 611 612
      'bug_status',
      'product',
      'component',
      'resolution'
613
    );
614
    push(@required_atom_columns, 'target_milestone') if Bugzilla->params->{'usetargetmilestone'};
615

616
    foreach my $required (@required_atom_columns) {
617 618 619 620
        push(@selectcolumns, $required) if !grep($_ eq $required,@selectcolumns);
    }
}

621 622 623
################################################################################
# Sort Order Determination
################################################################################
624

625
# Add to the query some instructions for sorting the bug list.
626 627 628 629 630 631

# First check if we'll want to reuse the last sorting order; that happens if
# the order is not defined or its value is "reuse last sort"
if (!$order || $order =~ /^reuse/i) {
    if ($cgi->cookie('LASTORDER')) {
        $order = $cgi->cookie('LASTORDER');
632 633 634 635
       
        # Cookies from early versions of Specific Search included this text,
        # which is now invalid.
        $order =~ s/ LIMIT 200//;
636 637 638 639
    }
    else {
        $order = '';  # Remove possible "reuse" identifier as unnecessary
    }
640
}
641

642
my @order_columns;
643 644 645 646
if ($order) {
    # Convert the value of the "order" form field into a list of columns
    # by which to sort the results.
    ORDER: for ($order) {
647
        /^Bug Number$/ && do {
648
            @order_columns = ("bug_id");
649 650 651
            last ORDER;
        };
        /^Importance$/ && do {
652
            @order_columns = ("priority", "bug_severity");
653 654 655
            last ORDER;
        };
        /^Assignee$/ && do {
656 657
            @order_columns = ("assigned_to", "bug_status", "priority",
                              "bug_id");
658 659 660
            last ORDER;
        };
        /^Last Changed$/ && do {
661 662
            @order_columns = ("changeddate", "bug_status", "priority",
                              "assigned_to", "bug_id");
663 664 665
            last ORDER;
        };
        do {
666 667
            # A custom list of columns. Bugzilla::Search will validate items.
            @order_columns = split(/\s*,\s*/, $order);
668
        };
terry%netscape.com's avatar
terry%netscape.com committed
669
    }
670
}
671

672
if (!scalar @order_columns) {
673
    # DEFAULT
674
    @order_columns = ("bug_status", "priority", "assigned_to", "bug_id");
675 676
}

677 678 679
# In the HTML interface, by default, we limit the returned results,
# which speeds up quite a few searches where people are really only looking
# for the top results.
680
if ($format->{'extension'} eq 'html' && !defined $params->param('limit')) {
681 682 683 684
    $params->param('limit', Bugzilla->params->{'default_search_limit'});
    $vars->{'default_limited'} = 1;
}

685
# Generate the basic SQL query that will be used to generate the bug list.
686
my $search = new Bugzilla::Search('fields' => \@selectcolumns, 
687
                                  'params' => scalar $params->Vars,
688 689
                                  'order'  => \@order_columns,
                                  'sharer' => $sharer_id);
690

691 692 693 694 695 696 697 698 699 700
$order = join(',', $search->order);

if (scalar @{$search->invalid_order_columns}) {
    $vars->{'message'} = 'invalid_column_name';
    $vars->{'invalid_fragments'} = $search->invalid_order_columns;
}

if ($fulltext and grep { /^relevance/ } $search->order) {
    $vars->{'message'} = 'buglist_sorted_by_relevance'
}
701

702 703 704
# We don't want saved searches and other buglist things to save
# our default limit.
$params->delete('limit') if $vars->{'default_limited'};
705

706 707 708
################################################################################
# Query Execution
################################################################################
709

710 711 712
# Time to use server push to display an interim message to the user until
# the query completes and we can display the bug list.
if ($serverpush) {
713 714
    print $cgi->multipart_init();
    print $cgi->multipart_start(-type => 'text/html');
715

716
    # Generate and return the UI (HTML page) from the appropriate template.
717 718
    $template->process("list/server-push.html.tmpl", $vars)
      || ThrowTemplateError($template->error());
719

720 721 722 723 724 725
    # Under mod_perl, flush stdout so that the page actually shows up.
    if ($ENV{MOD_PERL}) {
        require Apache2::RequestUtil;
        Apache2::RequestUtil->request->rflush();
    }

726 727 728
    # Don't do multipart_end() until we're ready to display the replacement
    # page, otherwise any errors that happen before then (like SQL errors)
    # will result in a blank page being shown to the user instead of the error.
terry%netscape.com's avatar
terry%netscape.com committed
729 730
}

731 732
# Connect to the shadow database if this installation is using one to improve
# query performance.
733
$dbh = Bugzilla->switch_to_shadow_db();
terry%netscape.com's avatar
terry%netscape.com committed
734

735
# Normally, we ignore SIGTERM and SIGPIPE, but we need to
736 737 738 739 740
# respond to them here to prevent someone DOSing us by reloading a query
# a large number of times.
$::SIG{TERM} = 'DEFAULT';
$::SIG{PIPE} = 'DEFAULT';

741
# Execute the query.
742 743
my ($data, $extra_data) = $search->data;
$vars->{'search_description'} = $search->search_description;
744

745 746 747 748
if ($cgi->param('debug')
    && Bugzilla->params->{debug_group}
    && $user->in_group(Bugzilla->params->{debug_group})
) {
749 750 751 752 753 754 755 756 757 758 759 760 761 762 763
    $vars->{'debug'} = 1;
    $vars->{'queries'} = $extra_data;
    my $query_time = 0;
    $query_time += $_->{'time'} foreach @$extra_data;
    $vars->{'query_time'} = $query_time;
    # Explains are limited to admins because you could use them to figure
    # out how many hidden bugs are in a particular product (by doing
    # searches and looking at the number of rows the explain says it's
    # examining).
    if ($user->in_group('admin')) {
        foreach my $query (@$extra_data) {
            $query->{explain} = $dbh->bz_explain($query->{sql});
        }
    }
}
terry%netscape.com's avatar
terry%netscape.com committed
764

765 766 767
################################################################################
# Results Retrieval
################################################################################
terry%netscape.com's avatar
terry%netscape.com committed
768

769 770
# Retrieve the query results one row at a time and write the data into a list
# of Perl records.
terry%netscape.com's avatar
terry%netscape.com committed
771

772
# If we're doing time tracking, then keep totals for all bugs.
773 774 775 776 777 778
my $percentage_complete = grep($_ eq 'percentage_complete', @displaycolumns);
my $estimated_time      = grep($_ eq 'estimated_time', @displaycolumns);
my $remaining_time      = grep($_ eq 'remaining_time', @displaycolumns)
                            || $percentage_complete;
my $actual_time         = grep($_ eq 'actual_time', @displaycolumns)
                            || $percentage_complete;
779 780 781 782 783 784 785 786

my $time_info = { 'estimated_time' => 0,
                  'remaining_time' => 0,
                  'actual_time' => 0,
                  'percentage_complete' => 0,
                  'time_present' => ($estimated_time || $remaining_time ||
                                     $actual_time || $percentage_complete),
                };
787

788 789
my $bugowners = {};
my $bugproducts = {};
790
my $bugcomponentids = {};
791
my $bugcomponents = {};
792
my $bugstatuses = {};
793
my @bugidlist;
terry%netscape.com's avatar
terry%netscape.com committed
794

795
my @bugs; # the list of records
796

797
foreach my $row (@$data) {
798
    my $bug = {}; # a record
799

800
    # Slurp the row of data into the record.
801 802
    # The second from last column in the record is the number of groups
    # to which the bug is restricted.
803
    foreach my $column (@selectcolumns) {
804
        $bug->{$column} = shift @$row;
805
    }
terry%netscape.com's avatar
terry%netscape.com committed
806

807 808 809
    # Process certain values further (i.e. date format conversion).
    if ($bug->{'changeddate'}) {
        $bug->{'changeddate'} =~ 
810
            s/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/$1-$2-$3 $4:$5:$6/;
811

812 813
        $bug->{'changedtime'} = $bug->{'changeddate'}; # for iCalendar and Atom
        $bug->{'changeddate'} = DiffDate($bug->{'changeddate'});
814 815 816
    }

    if ($bug->{'opendate'}) {
817
        $bug->{'opentime'} = $bug->{'opendate'}; # for iCalendar
818
        $bug->{'opendate'} = DiffDate($bug->{'opendate'});
819
    }
terry%netscape.com's avatar
terry%netscape.com committed
820

821
    # Record the assignee, product, and status in the big hashes of those things.
822
    $bugowners->{$bug->{'assigned_to'}} = 1 if $bug->{'assigned_to'};
823
    $bugproducts->{$bug->{'product'}} = 1 if $bug->{'product'};
824
    $bugcomponentids->{$bug->{'bugs.component_id'}} = 1 if $bug->{'bugs.component_id'};
825
    $bugcomponents->{$bug->{'component'}} = 1 if $bug->{'component'};
826
    $bugstatuses->{$bug->{'bug_status'}} = 1 if $bug->{'bug_status'};
terry%netscape.com's avatar
terry%netscape.com committed
827

828
    $bug->{'secure_mode'} = undef;
829

830 831
    # Add the record to the list.
    push(@bugs, $bug);
832 833

    # Add id to list for checking for bug privacy later
834
    push(@bugidlist, $bug->{'bug_id'});
835 836 837 838 839

    # Compute time tracking info.
    $time_info->{'estimated_time'} += $bug->{'estimated_time'} if ($estimated_time);
    $time_info->{'remaining_time'} += $bug->{'remaining_time'} if ($remaining_time);
    $time_info->{'actual_time'}    += $bug->{'actual_time'}    if ($actual_time);
840 841
}

842 843 844 845
# Check for bug privacy and set $bug->{'secure_mode'} to 'implied' or 'manual'
# based on whether the privacy is simply product implied (by mandatory groups)
# or because of human choice
my %min_membercontrol;
846
if (@bugidlist) {
847
    my $sth = $dbh->prepare(
848 849 850 851 852 853 854
        "SELECT DISTINCT bugs.bug_id, MIN(group_control_map.membercontrol) " .
          "FROM bugs " .
    "INNER JOIN bug_group_map " .
            "ON bugs.bug_id = bug_group_map.bug_id " .
     "LEFT JOIN group_control_map " .
            "ON group_control_map.product_id = bugs.product_id " .
           "AND group_control_map.group_id = bug_group_map.group_id " .
855
         "WHERE " . $dbh->sql_in('bugs.bug_id', \@bugidlist) . 
856
            $dbh->sql_group_by('bugs.bug_id'));
857 858
    $sth->execute();
    while (my ($bug_id, $min_membercontrol) = $sth->fetchrow_array()) {
859
        $min_membercontrol{$bug_id} = $min_membercontrol || CONTROLMAPNA;
860 861
    }
    foreach my $bug (@bugs) {
862
        next unless defined($min_membercontrol{$bug->{'bug_id'}});
863
        if ($min_membercontrol{$bug->{'bug_id'}} == CONTROLMAPMANDATORY) {
864
            $bug->{'secure_mode'} = 'implied';
865
        }
866 867 868
        else {
            $bug->{'secure_mode'} = 'manual';
        }
869 870
    }
}
871

872 873 874 875 876 877 878 879 880
# Compute percentage complete without rounding.
my $sum = $time_info->{'actual_time'}+$time_info->{'remaining_time'};
if ($sum > 0) {
    $time_info->{'percentage_complete'} = 100*$time_info->{'actual_time'}/$sum;
}
else { # remaining_time <= 0 
    $time_info->{'percentage_complete'} = 0
}                             

881 882 883
################################################################################
# Template Variable Definition
################################################################################
884

885
# Define the variables and functions that will be passed to the UI template.
886

887
$vars->{'bugs'} = \@bugs;
888
$vars->{'buglist'} = \@bugidlist;
889 890
$vars->{'columns'} = $columns;
$vars->{'displaycolumns'} = \@displaycolumns;
891

892
$vars->{'openstates'} = [BUG_STATE_OPEN];
893
$vars->{'closedstates'} = [map {$_->name} closed_bug_statuses()];
894

895 896 897 898 899 900 901 902 903 904 905
# The iCal file needs priorities ordered from 1 to 9 (highest to lowest)
# If there are more than 9 values, just make all the lower ones 9
if ($format->{'extension'} eq 'ics') {
    my $n = 1;
    $vars->{'ics_priorities'} = {};
    my $priorities = get_legal_field_values('priority');
    foreach my $p (@$priorities) {
        $vars->{'ics_priorities'}->{$p} = ($n > 9) ? 9 : $n++;
    }
}

906
$vars->{'order'} = $order;
907
$vars->{'caneditbugs'} = 1;
908
$vars->{'time_info'} = $time_info;
909

910
if (!$user->in_group('editbugs')) {
911
    foreach my $product (keys %$bugproducts) {
912
        my $prod = Bugzilla::Product->new({name => $product, cache => 1});
913
        if (!$user->in_group('editbugs', $prod->id)) {
914 915 916 917 918
            $vars->{'caneditbugs'} = 0;
            last;
        }
    }
}
terry%netscape.com's avatar
terry%netscape.com committed
919

920
my @bugowners = keys %$bugowners;
921
if (scalar(@bugowners) > 1 && $user->in_group('editbugs')) {
922
    my $suffix = Bugzilla->params->{'emailsuffix'};
923 924 925
    map(s/$/$suffix/, @bugowners) if $suffix;
    my $bugowners = join(",", @bugowners);
    $vars->{'bugowners'} = $bugowners;
terry%netscape.com's avatar
terry%netscape.com committed
926 927
}

928 929
# Whether or not to split the column titles across two rows to make
# the list more compact.
930
$vars->{'splitheader'} = $cgi->cookie('SPLITHEADER') ? 1 : 0;
terry%netscape.com's avatar
terry%netscape.com committed
931

932 933 934 935
if ($user->settings->{'display_quips'}->{'value'} eq 'on') {
    $vars->{'quip'} = GetQuip();
}

936
$vars->{'currenttime'} = localtime(time());
937

938 939 940 941 942
# See if there's only one product in all the results (or only one product
# that we searched for), which allows us to provide more helpful links.
my @products = keys %$bugproducts;
my $one_product;
if (scalar(@products) == 1) {
943
    $one_product = Bugzilla::Product->new({ name => $products[0], cache => 1 });
944 945 946 947
}
# This is used in the "Zarroo Boogs" case.
elsif (my @product_input = $cgi->param('product')) {
    if (scalar(@product_input) == 1 and $product_input[0] ne '') {
948
        $one_product = Bugzilla::Product->new({ name => $product_input[0], cache => 1 });
949 950 951 952
    }
}
# We only want the template to use it if the user can actually 
# enter bugs against it.
953
if ($one_product && $user->can_enter_product($one_product)) {
954 955 956
    $vars->{'one_product'} = $one_product;
}

957 958 959 960 961 962 963 964 965 966 967 968 969 970
# See if there's only one component in all the results (or only one component
# that we searched for), which allows us to provide more helpful links.
my @components = keys %$bugcomponents;
my $one_component;
if (scalar(@components) == 1) {
    $vars->{one_component} = $components[0];
}
# This is used in the "Zarroo Boogs" case.
elsif (my @component_input = $cgi->param('component')) {
    if (scalar(@component_input) == 1 and $component_input[0] ne '') {
        $vars->{one_component}= $cgi->param('component');
    }
}

971
# The following variables are used when the user is making changes to multiple bugs.
972
if ($dotweak && scalar @bugs) {
973 974 975 976 977
    if (!$vars->{'caneditbugs'}) {
        ThrowUserError('auth_failure', {group  => 'editbugs',
                                        action => 'modify',
                                        object => 'multiple_bugs'});
    }
978
    $vars->{'dotweak'} = 1;
979 980 981
  
    # issue_session_token needs to write to the master DB.
    Bugzilla->switch_to_main_db();
982
    $vars->{'token'} = issue_session_token('buglist_mass_change');
983
    Bugzilla->switch_to_shadow_db();
984

985
    $vars->{'products'} = $user->get_enterable_products;
986 987 988 989
    $vars->{'platforms'} = get_legal_field_values('rep_platform');
    $vars->{'op_sys'} = get_legal_field_values('op_sys');
    $vars->{'priorities'} = get_legal_field_values('priority');
    $vars->{'severities'} = get_legal_field_values('bug_severity');
990
    $vars->{'resolutions'} = get_legal_field_values('resolution');
991

992 993 994
    ($vars->{'flag_types'}, $vars->{any_flags_requesteeble})
        = _get_common_flag_types([keys %$bugcomponentids]);

995 996 997 998
    # Convert bug statuses to their ID.
    my @bug_statuses = map {$dbh->quote($_)} keys %$bugstatuses;
    my $bug_status_ids =
      $dbh->selectcol_arrayref('SELECT id FROM bug_status
999
                               WHERE ' . $dbh->sql_in('value', \@bug_statuses));
1000 1001 1002 1003

    # This query collects new statuses which are common to all current bug statuses.
    # It also accepts transitions where the bug status doesn't change.
    $bug_status_ids =
1004
      $dbh->selectcol_arrayref(
1005
            'SELECT DISTINCT sw1.new_status
1006
               FROM status_workflow sw1
1007 1008 1009 1010
         INNER JOIN bug_status
                 ON bug_status.id = sw1.new_status
              WHERE bug_status.isactive = 1
                AND NOT EXISTS 
1011 1012 1013 1014 1015 1016 1017 1018
                   (SELECT * FROM status_workflow sw2
                     WHERE sw2.old_status != sw1.new_status 
                           AND '
                         . $dbh->sql_in('sw2.old_status', $bug_status_ids)
                         . ' AND NOT EXISTS 
                           (SELECT * FROM status_workflow sw3
                             WHERE sw3.new_status = sw1.new_status
                                   AND sw3.old_status = sw2.old_status))');
1019 1020 1021

    $vars->{'current_bug_statuses'} = [keys %$bugstatuses];
    $vars->{'new_bug_statuses'} = Bugzilla::Status->new_from_list($bug_status_ids);
1022 1023 1024

    # The groups the user belongs to and which are editable for the given buglist.
    $vars->{'groups'} = GetGroups(\@products);
1025 1026 1027 1028 1029

    # If all bugs being changed are in the same product, the user can change
    # their version and component, so generate a list of products, a list of
    # versions for the product (if there is only one product on the list of
    # products), and a list of components for the product.
1030
    if ($one_product) {
1031 1032
        $vars->{'versions'} = [map($_->name, grep($_->is_active, @{ $one_product->versions }))];
        $vars->{'components'} = [map($_->name, grep($_->is_active, @{ $one_product->components }))];
1033
        if (Bugzilla->params->{'usetargetmilestone'}) {
1034
            $vars->{'milestones'} = [map($_->name, grep($_->is_active,
1035
                                               @{ $one_product->milestones }))];
1036
        }
terry%netscape.com's avatar
terry%netscape.com committed
1037
    }
1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074
    else {
        # We will only show the values at are active in all products.
        my %values = ();
        my @fields = ('components', 'versions');
        if (Bugzilla->params->{'usetargetmilestone'}) {
            push @fields, 'milestones';
        }

        # Go through each product and count the number of times each field
        # is used
        foreach my $product_name (@products) {
            my $product = Bugzilla::Product->new({name => $product_name, cache => 1});
            foreach my $field (@fields) {
                my $list = $product->$field;
                foreach my $item (@$list) {
                    ++$values{$field}{$item->name} if $item->is_active;
                }
            }
        }

        # Now we get the list of each field and see which values have
        # $product_count (i.e. appears in every product)
        my $product_count = scalar(@products);
        foreach my $field (@fields) {
            my @values = grep { $values{$field}{$_} == $product_count } keys %{$values{$field}};
            if (scalar @values) {
                @{$vars->{$field}} = $field eq 'version'
                    ? sort { vers_cmp(lc($a), lc($b)) } @values
                    : sort { lc($a) cmp lc($b) } @values
            }

            # Do we need to show a warning about limited visiblity?
            if (@values != scalar keys %{$values{$field}}) {
                $vars->{excluded_values} = 1;
            }
        }
    }
terry%netscape.com's avatar
terry%netscape.com committed
1075
}
1076

1077 1078 1079 1080
# If we're editing a stored query, use the existing query name as default for
# the "Remember search as" field.
$vars->{'defaultsavename'} = $cgi->param('query_based_on');

1081 1082 1083
# If we did a quick search then redisplay the previously entered search 
# string in the text field.
$vars->{'quicksearch'} = $searchstring;
1084

1085 1086 1087
################################################################################
# HTTP Header Generation
################################################################################
1088

1089
# Generate HTTP headers
terry%netscape.com's avatar
terry%netscape.com committed
1090

1091
my $contenttype;
1092
my $disposition = "inline";
terry%netscape.com's avatar
terry%netscape.com committed
1093

1094
if ($format->{'extension'} eq "html") {
1095 1096 1097 1098
    my $list_id = $cgi->param('list_id') || $cgi->param('regetlastlist');
    my $search = $user->save_last_search(
        { bugs => \@bugidlist, order => $order, vars => $vars, list_id => $list_id });
    $cgi->param('list_id', $search->id) if $search;
1099
    $contenttype = "text/html";
1100 1101
}
else {
1102
    $contenttype = $format->{'ctype'};
terry%netscape.com's avatar
terry%netscape.com committed
1103 1104
}

1105 1106
# Set 'urlquerypart' once the buglist ID is known.
$vars->{'urlquerypart'} = $params->canonicalise_query('order', 'cmdtype',
1107 1108
                                                      'query_based_on',
                                                      'token');
1109

1110 1111 1112
if ($format->{'extension'} eq "csv") {
    # We set CSV files to be downloaded, as they are designed for importing
    # into other programs.
1113
    $disposition = "attachment";
1114 1115 1116 1117

    # If the user clicked the CSV link in the search results,
    # They should get the Field Description, not the column name in the db
    $vars->{'human'} = $cgi->param('human');
1118 1119
}

1120
$cgi->close_standby_message($contenttype, $disposition, $disp_prefix, $format->{'extension'});
1121

1122 1123 1124
################################################################################
# Content Generation
################################################################################
1125

1126
# Generate and return the UI (HTML page) from the appropriate template.
1127
$template->process($format->{'template'}, $vars)
1128
  || ThrowTemplateError($template->error());
1129

1130

1131 1132 1133 1134
################################################################################
# Script Conclusion
################################################################################

1135
print $cgi->multipart_final() if $serverpush;