diff options
author | myk%mozilla.org <> | 2002-09-29 03:42:23 +0200 |
---|---|---|
committer | myk%mozilla.org <> | 2002-09-29 03:42:23 +0200 |
commit | 91b171e7584920d03abb9c45e779c84f3dee975c (patch) | |
tree | fc59becfe02d1a4dc84e5f3501f0139effcf1c7a /Bugzilla | |
parent | 90975fe914d066726d06f53abe8696399b13a61a (diff) | |
download | bugzilla-91b171e7584920d03abb9c45e779c84f3dee975c.tar.gz bugzilla-91b171e7584920d03abb9c45e779c84f3dee975c.tar.xz |
Fix for bug 98801: Implementation of the request tracker, a set of enhancements to attachment statuses.
r=gerv,bbaetz
Diffstat (limited to 'Bugzilla')
-rw-r--r-- | Bugzilla/Attachment.pm | 42 | ||||
-rw-r--r-- | Bugzilla/Flag.pm | 591 | ||||
-rw-r--r-- | Bugzilla/FlagType.pm | 325 | ||||
-rw-r--r-- | Bugzilla/Search.pm | 128 | ||||
-rw-r--r-- | Bugzilla/User.pm | 176 |
5 files changed, 1176 insertions, 86 deletions
diff --git a/Bugzilla/Attachment.pm b/Bugzilla/Attachment.pm index 3a6248cf4..53690170e 100644 --- a/Bugzilla/Attachment.pm +++ b/Bugzilla/Attachment.pm @@ -31,10 +31,32 @@ package Attachment; # This module requires that its caller have said "require CGI.pl" to import # relevant functions from that script and its companion globals.pl. +# Use the Flag module to handle flags. +use Bugzilla::Flag; + ############################################################################ # Functions ############################################################################ +sub new { + # Returns a hash of information about the attachment with the given ID. + + my ($invocant, $id) = @_; + return undef if !$id; + my $self = { 'id' => $id }; + my $class = ref($invocant) || $invocant; + bless($self, $class); + + &::PushGlobalSQLState(); + &::SendSQL("SELECT 1, description, bug_id FROM attachments " . + "WHERE attach_id = $id"); + ($self->{'exists'}, $self->{'summary'}, $self->{'bug_id'}) = + &::FetchSQLData(); + &::PopGlobalSQLState(); + + return $self; +} + sub query { # Retrieves and returns an array of attachment records for a given bug. @@ -65,23 +87,9 @@ sub query $a{'date'} = "$1-$2-$3 $4:$5"; } - # Retrieve a list of status flags that have been set on the attachment. - &::PushGlobalSQLState(); - &::SendSQL(" - SELECT name - FROM attachstatuses, attachstatusdefs - WHERE attach_id = $a{'attachid'} - AND attachstatuses.statusid = attachstatusdefs.id - ORDER BY sortkey - "); - my @statuses = (); - while (&::MoreSQLData()) { - my ($status) = &::FetchSQLData(); - push @statuses , $status; - } - $a{'statuses'} = \@statuses; - &::PopGlobalSQLState(); - + # Retrieve a list of flags for this attachment. + $a{'flags'} = Bugzilla::Flag::match({ 'attach_id' => $a{'attachid'} }); + # We will display the edit link if the user can edit the attachment; # ie the are the submitter, or they have canedit. # Also show the link if the user is not logged in - in that cae, diff --git a/Bugzilla/Flag.pm b/Bugzilla/Flag.pm new file mode 100644 index 000000000..3feaae4cd --- /dev/null +++ b/Bugzilla/Flag.pm @@ -0,0 +1,591 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Netscape Communications +# Corporation. Portions created by Netscape are +# Copyright (C) 1998 Netscape Communications Corporation. All +# Rights Reserved. +# +# Contributor(s): Myk Melez <myk@mozilla.org> + +################################################################################ +# Module Initialization +################################################################################ + +# Make it harder for us to do dangerous things in Perl. +use strict; + +# This module implements bug and attachment flags. +package Bugzilla::Flag; + +use Bugzilla::FlagType; +use Bugzilla::User; +use Attachment; + +use vars qw($template $vars); + +# Note! This module requires that its caller have said "require CGI.pl" +# to import relevant functions from that script and its companion globals.pl. + +################################################################################ +# Global Variables +################################################################################ + +# basic sets of columns and tables for getting flags from the database + +my @base_columns = + ("1", "id", "type_id", "bug_id", "attach_id", "requestee_id", "setter_id", + "status"); + +# Note: when adding tables to @base_tables, make sure to include the separator +# (i.e. a comma or words like "LEFT OUTER JOIN") before the table name, +# since tables take multiple separators based on the join type, and therefore +# it is not possible to join them later using a single known separator. + +my @base_tables = ("flags"); + +################################################################################ +# Searching/Retrieving Flags +################################################################################ + +# !!! Implement a cache for this function! +sub get { + # Retrieves and returns a flag from the database. + + my ($id) = @_; + + my $select_clause = "SELECT " . join(", ", @base_columns); + my $from_clause = "FROM " . join(" ", @base_tables); + + # Execute the query, retrieve the result, and write it into a record. + &::PushGlobalSQLState(); + &::SendSQL("$select_clause $from_clause WHERE flags.id = $id"); + my $flag = perlify_record(&::FetchSQLData()); + &::PopGlobalSQLState(); + + return $flag; +} + +sub match { + # Queries the database for flags matching the given criteria + # (specified as a hash of field names and their matching values) + # and returns an array of matching records. + + my ($criteria) = @_; + + my $select_clause = "SELECT " . join(", ", @base_columns); + my $from_clause = "FROM " . join(" ", @base_tables); + + my @criteria = sqlify_criteria($criteria); + + my $where_clause = "WHERE " . join(" AND ", @criteria); + + # Execute the query, retrieve the results, and write them into records. + &::PushGlobalSQLState(); + &::SendSQL("$select_clause $from_clause $where_clause"); + my @flags; + while (&::MoreSQLData()) { + my $flag = perlify_record(&::FetchSQLData()); + push(@flags, $flag); + } + &::PopGlobalSQLState(); + + return \@flags; +} + +sub count { + # Queries the database for flags matching the given criteria + # (specified as a hash of field names and their matching values) + # and returns an array of matching records. + + my ($criteria) = @_; + + my @criteria = sqlify_criteria($criteria); + + my $where_clause = "WHERE " . join(" AND ", @criteria); + + # Execute the query, retrieve the result, and write it into a record. + &::PushGlobalSQLState(); + &::SendSQL("SELECT COUNT(id) FROM flags $where_clause"); + my $count = &::FetchOneColumn(); + &::PopGlobalSQLState(); + + return $count; +} + +################################################################################ +# Creating and Modifying +################################################################################ + +sub validate { + # Validates fields containing flag modifications. + + my ($data) = @_; + + # Get a list of flags to validate. Uses the "map" function + # to extract flag IDs from form field names by matching fields + # whose name looks like "flag-nnn", where "nnn" is the ID, + # and returning just the ID portion of matching field names. + my @ids = map(/^flag-(\d+)$/ ? $1 : (), keys %$data); + + foreach my $id (@ids) + { + my $status = $data->{"flag-$id"}; + + # Make sure the flag exists. + my $flag = get($id); + $flag || &::ThrowCodeError("flag_nonexistent", { id => $id }); + + # Don't bother validating flags the user didn't change. + next if $status eq $flag->{'status'}; + + # Make sure the user chose a valid status. + grep($status eq $_, qw(X + - ?)) + || &::ThrowCodeError("flag_status_invalid", + { id => $id , status => $status }); + } +} + +sub process { + # Processes changes to flags. + + # The target is the bug or attachment this flag is about, the timestamp + # is the date/time the bug was last touched (so that changes to the flag + # can be stamped with the same date/time), the data is the form data + # with flag fields that the user submitted, the old bug is the bug record + # before the user made changes to it, and the new bug is the bug record + # after the user made changes to it. + + my ($target, $timestamp, $data, $oldbug, $newbug) = @_; + + # Use the date/time we were given if possible (allowing calling code + # to synchronize the comment's timestamp with those of other records). + $timestamp = ($timestamp ? &::SqlQuote($timestamp) : "NOW()"); + + # Take a snapshot of flags before any changes. + my $flags = match({ 'bug_id' => $target->{'bug'}->{'id'} , + 'attach_id' => $target->{'attachment'}->{'id'} }); + my @old_summaries; + foreach my $flag (@$flags) { + my $summary = $flag->{'type'}->{'name'} . $flag->{'status'}; + push(@old_summaries, $summary); + } + + # Create new flags and update existing flags. + my $new_flags = FormToNewFlags($target, $data); + foreach my $flag (@$new_flags) { create($flag, $timestamp) } + modify($data, $timestamp); + + # In case the bug's product/component has changed, clear flags that are + # no longer valid. + &::SendSQL(" + SELECT flags.id + FROM flags, bugs LEFT OUTER JOIN flaginclusions i + ON (flags.type_id = i.type_id + AND (bugs.product_id = i.product_id OR i.product_id IS NULL) + AND (bugs.component_id = i.component_id OR i.component_id IS NULL)) + WHERE flags.type_id = $target->{'bug'}->{'id'} + AND flags.bug_id = bugs.bug_id + AND i.type_id IS NULL + "); + clear(&::FetchOneColumn()) while &::MoreSQLData(); + &::SendSQL(" + SELECT flags.id + FROM flags, bugs, flagexclusions e + WHERE flags.type_id = $target->{'bug'}->{'id'} + AND flags.bug_id = bugs.bug_id + AND flags.type_id = e.type_id + AND (bugs.product_id = e.product_id OR e.product_id IS NULL) + AND (bugs.component_id = e.component_id OR e.component_id IS NULL) + "); + clear(&::FetchOneColumn()) while &::MoreSQLData(); + + # Take a snapshot of flags after changes. + $flags = match({ 'bug_id' => $target->{'bug'}->{'id'} , + 'attach_id' => $target->{'attachment'}->{'id'} }); + my @new_summaries; + foreach my $flag (@$flags) { + my $summary = $flag->{'type'}->{'name'} . $flag->{'status'}; + push(@new_summaries, $summary); + } + + my $old_summaries = join(", ", @old_summaries); + my $new_summaries = join(", ", @new_summaries); + my ($removed, $added) = &::DiffStrings($old_summaries, $new_summaries); + if ($removed ne $added) { + my $sql_removed = &::SqlQuote($removed); + my $sql_added = &::SqlQuote($added); + my $field_id = &::GetFieldID('flagtypes.name'); + my $attach_id = $target->{'attachment'}->{'id'} || 'NULL'; + &::SendSQL("INSERT INTO bugs_activity (bug_id, attach_id, who, " . + "bug_when, fieldid, removed, added) VALUES " . + "($target->{'bug'}->{'id'}, $attach_id, $::userid, " . + "$timestamp, $field_id, $sql_removed, $sql_added)"); + } +} + + +sub create { + # Creates a flag record in the database. + + my ($flag, $timestamp) = @_; + + # Determine the ID for the flag record by retrieving the last ID used + # and incrementing it. + &::SendSQL("SELECT MAX(id) FROM flags"); + $flag->{'id'} = (&::FetchOneColumn() || 0) + 1; + + # Insert a record for the flag into the flags table. + my $attach_id = $flag->{'target'}->{'attachment'}->{'id'} || "NULL"; + my $requestee_id = $flag->{'requestee'} ? $flag->{'requestee'}->{'id'} : "NULL"; + &::SendSQL("INSERT INTO flags (id, type_id, + bug_id, attach_id, + requestee_id, setter_id, status, + creation_date, modification_date) + VALUES ($flag->{'id'}, + $flag->{'type'}->{'id'}, + $flag->{'target'}->{'bug'}->{'id'}, + $attach_id, + $requestee_id, + $flag->{'setter'}->{'id'}, + '$flag->{'status'}', + $timestamp, + $timestamp)"); + + # Send an email notifying the relevant parties about the flag creation. + if ($flag->{'requestee'} && $flag->{'requestee'}->email_prefs->{'FlagRequestee'} + || $flag->{'type'}->{'cc_list'}) { + notify($flag, "request/created-email.txt.tmpl"); + } +} + +sub migrate { + # Moves a flag from one attachment to another. Useful for migrating + # a flag from an obsolete attachment to the attachment that obsoleted it. + + my ($old_attach_id, $new_attach_id) = @_; + + # Update the record in the flags table to point to the new attachment. + &::SendSQL("UPDATE flags " . + "SET attach_id = $new_attach_id , " . + " modification_date = NOW() " . + "WHERE attach_id = $old_attach_id"); +} + +sub modify { + # Modifies flags in the database when a user changes them. + + my ($data, $timestamp) = @_; + + # Use the date/time we were given if possible (allowing calling code + # to synchronize the comment's timestamp with those of other records). + $timestamp = ($timestamp ? &::SqlQuote($timestamp) : "NOW()"); + + # Extract a list of flags from the form data. + my @ids = map(/^flag-(\d+)$/ ? $1 : (), keys %$data); + + # Loop over flags and update their record in the database. + my @flags; + foreach my $id (@ids) { + my $flag = get($id); + my $status = $data->{"flag-$id"}; + + # Ignore flags the user didn't change. + next if $status eq $flag->{'status'}; + + # Since the status is validated, we know it's safe, but it's still + # tainted, so we have to detaint it before using it in a query. + &::trick_taint($status); + + if ($status eq '+' || $status eq '-') { + &::SendSQL("UPDATE flags + SET setter_id = $::userid , + status = '$status' , + modification_date = $timestamp + WHERE id = $flag->{'id'}"); + + # Send an email notifying the relevant parties about the fulfillment. + if ($flag->{'setter'}->email_prefs->{'FlagRequester'} + || $flag->{'type'}->{'cc_list'}) + { + $flag->{'status'} = $status; + notify($flag, "request/fulfilled-email.txt.tmpl"); + } + } + elsif ($status eq '?') { + &::SendSQL("UPDATE flags + SET status = '$status' , + modification_date = $timestamp + WHERE id = $flag->{'id'}"); + } + # The user unset the flag, so delete it from the database. + elsif ($status eq 'X') { + clear($flag->{'id'}); + } + + push(@flags, $flag); + } + + return \@flags; +} + +sub clear { + my ($id) = @_; + + my $flag = get($id); + + &::PushGlobalSQLState(); + &::SendSQL("DELETE FROM flags WHERE id = $id"); + &::PopGlobalSQLState(); + + # Set the flag's status to "cleared" so the email template + # knows why email is being sent about the request. + $flag->{'status'} = "X"; + + notify($flag, "request/fulfilled-email.txt.tmpl") if $flag->{'requestee'}; +} + + +################################################################################ +# Utility Functions +################################################################################ + +sub FormToNewFlags { + my ($target, $data) = @_; + + # Flag for whether or not we must get verification of the requestees + # (if the user did not uniquely identify them). + my $verify_requestees = 0; + + # Get information about the setter to add to each flag. + # Uses a conditional to suppress Perl's "used only once" warnings. + my $setter = new Bugzilla::User($::userid); + + # Extract a list of flag type IDs from field names. + my @type_ids = map(/^flag_type-(\d+)$/ ? $1 : (), keys %$data); + @type_ids = grep($data->{"flag_type-$_"} ne 'X', @type_ids); + + # Process the form data and create an array of flag objects. + my @flags; + foreach my $type_id (@type_ids) { + my $status = $data->{"flag_type-$type_id"}; + &::trick_taint($status); + + # Create the flag record and populate it with data from the form. + my $flag = { + type => Bugzilla::FlagType::get($type_id) , + target => $target , + setter => $setter , + status => $status + }; + + my $requestee_str = $data->{"requestee-$type_id"} || $data->{'requestee'}; + if ($requestee_str) { + $flag->{'requestee_str'} = $requestee_str; + MatchRequestees($flag); + $verify_requestees = 1 if scalar(@{$flag->{'requestees'}}) != 1; + } + + # Add the flag to the array of flags. + push(@flags, $flag); + } + + if ($verify_requestees) { + $vars->{'target'} = $target; + $vars->{'flags'} = \@flags; + $vars->{'form'} = $data; + $vars->{'mform'} = \%::MFORM || \%::MFORM; + + print "Content-Type: text/html\n\n" unless $vars->{'header_done'}; + $::template->process("request/verify.html.tmpl", $vars) + || &::ThrowTemplateError($template->error()); + exit; + } + + # Return the list of flags. + return \@flags; +} + +sub MatchRequestees { + my ($flag) = @_; + + my $requestee_str = $flag->{'requestee_str'}; + + # To reduce the size of queries, require the user to enter at least + # three characters of each requestee's name unless this installation + # automatically appends an email suffix to each user's login name, + # in which case we can't guarantee their names are at least three + # characters long. + if (!&Param('emailsuffix') && length($requestee_str) < 3) { + &::ThrowUserError("requestee_too_short"); + } + + # Get a list of potential requestees whose email address or real name + # matches the substring entered by the user. Try an exact match first, + # then fall back to a substring search. Limit search to 100 matches, + # since at that point there are too many to make the user wade through, + # and we need to get the user to enter a more constrictive match string. + my $user_id = &::DBname_to_id($requestee_str); + if ($user_id) { $flag->{'requestees'} = [ new Bugzilla::User($user_id) ] } + else { $flag->{'requestees'} = Bugzilla::User::match($requestee_str, 101, 1) } + + # If there is only one requestee match, make them the requestee. + if (scalar(@{$flag->{'requestees'}}) == 1) { + $flag->{'requestee'} = $flag->{'requestees'}[0]; + } + + # If there are too many requestee matches, throw an error. + elsif (scalar(@{$flag->{'requestees'}}) == 101) { + &::ThrowUserError("requestee_too_many_matches", + { requestee => $requestee_str }); + } +} + + +# Ideally, we'd use Bug.pm, but it's way too heavyweight, and it can't be +# made lighter without totally rewriting it, so we'll use this function +# until that one gets rewritten. +sub GetBug { + # Returns a hash of information about a target bug. + my ($id) = @_; + + # Save the currently running query (if any) so we do not overwrite it. + &::PushGlobalSQLState(); + + &::SendSQL("SELECT 1, short_desc, product_id, component_id + FROM bugs + WHERE bug_id = $id"); + + my $bug = { 'id' => $id }; + + ($bug->{'exists'}, $bug->{'summary'}, $bug->{'product_id'}, + $bug->{'component_id'}) = &::FetchSQLData(); + + # Restore the previously running query (if any). + &::PopGlobalSQLState(); + + return $bug; +} + +sub GetTarget { + my ($bug_id, $attach_id) = @_; + + # Create an object representing the target bug/attachment. + my $target = { 'exists' => 0 }; + + if ($attach_id) { + $target->{'attachment'} = new Attachment($attach_id); + if ($bug_id) { + # Make sure the bug and attachment IDs correspond to each other + # (i.e. this is the bug to which this attachment is attached). + $bug_id == $target->{'attachment'}->{'bug_id'} + || return { 'exists' => 0 }; + } + $target->{'bug'} = GetBug($target->{'attachment'}->{'bug_id'}); + $target->{'exists'} = $target->{'attachment'}->{'exists'}; + $target->{'type'} = "attachment"; + } + elsif ($bug_id) { + $target->{'bug'} = GetBug($bug_id); + $target->{'exists'} = $target->{'bug'}->{'exists'}; + $target->{'type'} = "bug"; + } + + return $target; +} + +sub notify { + # Sends an email notification about a flag being created or fulfilled. + + my ($flag, $template_file) = @_; + + # Work around the intricacies of globals.pl not being templatized + # by defining local variables for the $::template and $::vars globals. + my $template = $::template; + my $vars = $::vars; + + $vars->{'flag'} = $flag; + + my $message; + my $rv = + $template->process($template_file, $vars, \$message); + if (!$rv) { + print "Content-Type: text/html\n\n" unless $vars->{'header_done'}; + &::ThrowTemplateError($template->error()); + } + + my $delivery_mode = &::Param("sendmailnow") ? "" : "-ODeliveryMode=deferred"; + open(SENDMAIL, "|/usr/lib/sendmail $delivery_mode -t -i") + || die "Can't open sendmail"; + print SENDMAIL $message; + close(SENDMAIL); +} + +################################################################################ +# Private Functions +################################################################################ + +sub sqlify_criteria { + # Converts a hash of criteria into a list of SQL criteria. + + # a reference to a hash containing the criteria (field => value) + my ($criteria) = @_; + + # the generated list of SQL criteria; "1=1" is a clever way of making sure + # there's something in the list so calling code doesn't have to check list + # size before building a WHERE clause out of it + my @criteria = ("1=1"); + + # If the caller specified only bug or attachment flags, + # limit the query to those kinds of flags. + if (defined($criteria->{'target_type'})) { + if ($criteria->{'target_type'} eq 'bug') { push(@criteria, "attach_id IS NULL") } + elsif ($criteria->{'target_type'} eq 'attachment') { push(@criteria, "attach_id IS NOT NULL") } + } + + # Go through each criterion from the calling code and add it to the query. + foreach my $field (keys %$criteria) { + my $value = $criteria->{$field}; + next unless defined($value); + if ($field eq 'type_id') { push(@criteria, "type_id = $value") } + elsif ($field eq 'bug_id') { push(@criteria, "bug_id = $value") } + elsif ($field eq 'attach_id') { push(@criteria, "attach_id = $value") } + elsif ($field eq 'requestee_id') { push(@criteria, "requestee_id = $value") } + elsif ($field eq 'setter_id') { push(@criteria, "setter_id = $value") } + elsif ($field eq 'status') { push(@criteria, "status = '$value'") } + } + + return @criteria; +} + +sub perlify_record { + # Converts a row from the database into a Perl record. + my ($exists, $id, $type_id, $bug_id, $attach_id, + $requestee_id, $setter_id, $status) = @_; + + my $flag = + { + exists => $exists , + id => $id , + type => Bugzilla::FlagType::get($type_id) , + target => GetTarget($bug_id, $attach_id) , + requestee => new Bugzilla::User($requestee_id) , + setter => new Bugzilla::User($setter_id) , + status => $status , + }; + + return $flag; +} + +1; diff --git a/Bugzilla/FlagType.pm b/Bugzilla/FlagType.pm new file mode 100644 index 000000000..2e272f67c --- /dev/null +++ b/Bugzilla/FlagType.pm @@ -0,0 +1,325 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Netscape Communications +# Corporation. Portions created by Netscape are +# Copyright (C) 1998 Netscape Communications Corporation. All +# Rights Reserved. +# +# Contributor(s): Myk Melez <myk@mozilla.org> + +################################################################################ +# Module Initialization +################################################################################ + +# Make it harder for us to do dangerous things in Perl. +use strict; + +# This module implements flag types for the flag tracker. +package Bugzilla::FlagType; + +# Use Bugzilla's User module which contains utilities for handling users. +use Bugzilla::User; + +# Note! This module requires that its caller have said "require CGI.pl" +# to import relevant functions from that script and its companion globals.pl. + +################################################################################ +# Global Variables +################################################################################ + +# basic sets of columns and tables for getting flag types from the database + +my @base_columns = + ("1", "flagtypes.id", "flagtypes.name", "flagtypes.description", + "flagtypes.cc_list", "flagtypes.target_type", "flagtypes.sortkey", + "flagtypes.is_active", "flagtypes.is_requestable", + "flagtypes.is_requesteeble", "flagtypes.is_multiplicable"); + +# Note: when adding tables to @base_tables, make sure to include the separator +# (i.e. a comma or words like "LEFT OUTER JOIN") before the table name, +# since tables take multiple separators based on the join type, and therefore +# it is not possible to join them later using a single known separator. + +my @base_tables = ("flagtypes"); + +################################################################################ +# Public Functions +################################################################################ + +sub get { + # Returns a hash of information about a flag type. + + my ($id) = @_; + + my $select_clause = "SELECT " . join(", ", @base_columns); + my $from_clause = "FROM " . join(" ", @base_tables); + + &::PushGlobalSQLState(); + &::SendSQL("$select_clause $from_clause WHERE flagtypes.id = $id"); + my @data = &::FetchSQLData(); + my $type = perlify_record(@data); + &::PopGlobalSQLState(); + + return $type; +} + +sub get_inclusions { + my ($id) = @_; + return get_clusions($id, "in"); +} + +sub get_exclusions { + my ($id) = @_; + return get_clusions($id, "ex"); +} + +sub get_clusions { + my ($id, $type) = @_; + + &::PushGlobalSQLState(); + &::SendSQL("SELECT products.name, components.name " . + "FROM flagtypes, flag${type}clusions " . + "LEFT OUTER JOIN products ON flag${type}clusions.product_id = products.id " . + "LEFT OUTER JOIN components ON flag${type}clusions.component_id = components.id " . + "WHERE flagtypes.id = $id AND flag${type}clusions.type_id = flagtypes.id"); + my @clusions = (); + while (&::MoreSQLData()) { + my ($product, $component) = &::FetchSQLData(); + $product ||= "Any"; + $component ||= "Any"; + push(@clusions, "$product:$component"); + } + &::PopGlobalSQLState(); + + return \@clusions; +} + +sub match { + # Queries the database for flag types matching the given criteria + # and returns the set of matching types. + + my ($criteria, $include_count) = @_; + + my @tables = @base_tables; + my @columns = @base_columns; + my $having = ""; + + # Include a count of the number of flags per type if requested. + if ($include_count) { + push(@columns, "COUNT(flags.id)"); + push(@tables, "LEFT OUTER JOIN flags ON flagtypes.id = flags.type_id"); + } + + # Generate the SQL WHERE criteria. + my @criteria = sqlify_criteria($criteria, \@tables, \@columns, \$having); + + # Build the query, grouping the types if we are counting flags. + my $select_clause = "SELECT " . join(", ", @columns); + my $from_clause = "FROM " . join(" ", @tables); + my $where_clause = "WHERE " . join(" AND ", @criteria); + + my $query = "$select_clause $from_clause $where_clause"; + $query .= " GROUP BY flagtypes.id " if ($include_count || $having ne ""); + $query .= " HAVING $having " if $having ne ""; + $query .= " ORDER BY flagtypes.sortkey, flagtypes.name"; + + # Execute the query and retrieve the results. + &::PushGlobalSQLState(); + &::SendSQL($query); + my @types; + while (&::MoreSQLData()) { + my @data = &::FetchSQLData(); + my $type = perlify_record(@data); + push(@types, $type); + } + &::PopGlobalSQLState(); + + return \@types; +} + +sub count { + # Returns the total number of flag types matching the given criteria. + + my ($criteria) = @_; + + # Generate query components. + my @tables = @base_tables; + my @columns = ("COUNT(flagtypes.id)"); + my $having = ""; + my @criteria = sqlify_criteria($criteria, \@tables, \@columns, \$having); + + # Build the query. + my $select_clause = "SELECT " . join(", ", @columns); + my $from_clause = "FROM " . join(" ", @tables); + my $where_clause = "WHERE " . join(" AND ", @criteria); + my $query = "$select_clause $from_clause $where_clause"; + $query .= " GROUP BY flagtypes.id HAVING $having " if $having ne ""; + + # Execute the query and get the results. + &::PushGlobalSQLState(); + &::SendSQL($query); + my $count = &::FetchOneColumn(); + &::PopGlobalSQLState(); + + return $count; +} + +sub validate { + my ($data) = @_; + + # Get a list of flags types to validate. Uses the "map" function + # to extract flag type IDs from form field names by matching columns + # whose name looks like "flag_type-nnn", where "nnn" is the ID, + # and returning just the ID portion of matching field names. + my @ids = map(/^flag_type-(\d+)$/ ? $1 : (), keys %$data); + + foreach my $id (@ids) + { + my $status = $data->{"flag_type-$id"}; + + # Don't bother validating types the user didn't touch. + next if $status eq "X"; + + # Make sure the flag exists. + get($id) + || &::ThrowCodeError("flag_type_nonexistent", { id => $id }); + + # Make sure the value of the field is a valid status. + grep($status eq $_, qw(X + - ?)) + || &::ThrowCodeError("flag_status_invalid", + { id => $id , status => $status }); + } +} + +sub normalize { + # Given a list of flag types, checks its flags to make sure they should + # still exist after a change to the inclusions/exclusions lists. + + # A list of IDs of flag types to normalize. + my (@ids) = @_; + + my $ids = join(", ", @ids); + + # Check for flags whose product/component is no longer included. + &::SendSQL(" + SELECT flags.id + FROM flags, bugs LEFT OUTER JOIN flaginclusions AS i + ON (flags.type_id = i.type_id + AND (bugs.product_id = i.product_id OR i.product_id IS NULL) + AND (bugs.component_id = i.component_id OR i.component_id IS NULL)) + WHERE flags.type_id IN ($ids) + AND flags.bug_id = bugs.bug_id + AND i.type_id IS NULL + "); + Bugzilla::Flag::clear(&::FetchOneColumn()) while &::MoreSQLData(); + + &::SendSQL(" + SELECT flags.id + FROM flags, bugs, flagexclusions AS e + WHERE flags.type_id IN ($ids) + AND flags.bug_id = bugs.bug_id + AND flags.type_id = e.type_id + AND (bugs.product_id = e.product_id OR e.product_id IS NULL) + AND (bugs.component_id = e.component_id OR e.component_id IS NULL) + "); + Bugzilla::Flag::clear(&::FetchOneColumn()) while &::MoreSQLData(); +} + +################################################################################ +# Private Functions +################################################################################ + +sub sqlify_criteria { + # Converts a hash of criteria into a list of SQL criteria. + # $criteria is a reference to the criteria (field => value), + # $tables is a reference to an array of tables being accessed + # by the query, $columns is a reference to an array of columns + # being returned by the query, and $having is a reference to + # a criterion to put into the HAVING clause. + my ($criteria, $tables, $columns, $having) = @_; + + # the generated list of SQL criteria; "1=1" is a clever way of making sure + # there's something in the list so calling code doesn't have to check list + # size before building a WHERE clause out of it + my @criteria = ("1=1"); + + if ($criteria->{name}) { + push(@criteria, "flagtypes.name = " . &::SqlQuote($criteria->{name})); + } + if ($criteria->{target_type}) { + # The target type is stored in the database as a one-character string + # ("a" for attachment and "b" for bug), but this function takes complete + # names ("attachment" and "bug") for clarity, so we must convert them. + my $target_type = &::SqlQuote(substr($criteria->{target_type}, 0, 1)); + push(@criteria, "flagtypes.target_type = $target_type"); + } + if (exists($criteria->{is_active})) { + my $is_active = $criteria->{is_active} ? "1" : "0"; + push(@criteria, "flagtypes.is_active = $is_active"); + } + if ($criteria->{product_id} && $criteria->{'component_id'}) { + my $product_id = $criteria->{product_id}; + my $component_id = $criteria->{component_id}; + + # Add inclusions to the query, which simply involves joining the table + # by flag type ID and target product/component. + push(@$tables, ", flaginclusions"); + push(@criteria, "flagtypes.id = flaginclusions.type_id"); + push(@criteria, "(flaginclusions.product_id = $product_id " . + " OR flaginclusions.product_id IS NULL)"); + push(@criteria, "(flaginclusions.component_id = $component_id " . + " OR flaginclusions.component_id IS NULL)"); + + # Add exclusions to the query, which is more complicated. First of all, + # we do a LEFT JOIN so we don't miss flag types with no exclusions. + # Then, as with inclusions, we join on flag type ID and target product/ + # component. However, since we want flag types that *aren't* on the + # exclusions list, we count the number of exclusions records returned + # and use a HAVING clause to weed out types with one or more exclusions. + my $join_clause = "flagtypes.id = flagexclusions.type_id " . + "AND (flagexclusions.product_id = $product_id " . + "OR flagexclusions.product_id IS NULL) " . + "AND (flagexclusions.component_id = $component_id " . + "OR flagexclusions.component_id IS NULL)"; + push(@$tables, "LEFT JOIN flagexclusions ON ($join_clause)"); + push(@$columns, "COUNT(flagexclusions.type_id) AS num_exclusions"); + $$having = "num_exclusions = 0"; + } + + return @criteria; +} + +sub perlify_record { + # Converts data retrieved from the database into a Perl record. + + my $type = {}; + + $type->{'exists'} = $_[0]; + $type->{'id'} = $_[1]; + $type->{'name'} = $_[2]; + $type->{'description'} = $_[3]; + $type->{'cc_list'} = $_[4]; + $type->{'target_type'} = $_[5] eq "b" ? "bug" : "attachment"; + $type->{'sortkey'} = $_[6]; + $type->{'is_active'} = $_[7]; + $type->{'is_requestable'} = $_[8]; + $type->{'is_requesteeble'} = $_[9]; + $type->{'is_multiplicable'} = $_[10]; + $type->{'flag_count'} = $_[11]; + + return $type; +} + +1; diff --git a/Bugzilla/Search.pm b/Bugzilla/Search.pm index 642965eb2..6d11c0739 100644 --- a/Bugzilla/Search.pm +++ b/Bugzilla/Search.pm @@ -62,6 +62,7 @@ sub init { my @fields; my @supptables; my @wherepart; + my @having = ("(cntuseringroups = cntbugingroups OR canseeanyway)"); @fields = @$fieldsref if $fieldsref; my %F; my %M; @@ -265,8 +266,8 @@ sub init { } my $chartid; - # $statusid is used by the code that queries for attachment statuses. - my $statusid = 0; + # $type_id is used by the code that queries for attachment flags. + my $type_id = 0; my $f; my $ff; my $t; @@ -358,69 +359,61 @@ sub init { } $f = "$table.$field"; }, - "^attachstatusdefs.name," => sub { - # The below has Fun with the names for attachment statuses. This - # isn't needed for changed* queries, so exclude those - the - # generic stuff will cope + "^flagtypes.name," => sub { + # Matches bugs by flag name/status. + # Note that--for the purposes of querying--a flag comprises + # its name plus its status (i.e. a flag named "review" + # with a status of "+" can be found by searching for "review+"). + + # Don't do anything if this condition is about changes to flags, + # as the generic change condition processors can handle those. return if ($t =~ m/^changed/); - - # Searching for "status != 'bar'" wants us to look for an - # attachment without the 'bar' status, not for an attachment with - # a status not equal to 'bar' (Which would pick up an attachment - # with more than one status). We do this by LEFT JOINS, after - # grabbing the matching attachment status ids. - # Note that this still won't find bugs with no attachments, since - # that isn't really what people would expect. - - # First, get the attachment status ids, using the other funcs - # to match the WHERE term. - # Note that we need to reverse the negated bits for this to work - # This somewhat abuses the definitions of the various terms - - # eg, does 'contains all' mean that the status has to contain all - # those words, or that all those words must be exact matches to - # statuses, which must all be on a single attachment, or should - # the match on the status descriptions be a contains match, too? - - my $inverted = 0; - if ($t =~ m/not(.*)/) { - $t = $1; - $inverted = 1; - } - - $ref = $funcsbykey{",$t"}; - &$ref; - &::SendSQL("SELECT id FROM attachstatusdefs WHERE $term"); - - my @as_ids; - while (&::MoreSQLData()) { - push @as_ids, &::FetchOneColumn(); - } - - # When searching for multiple statuses within a single boolean chart, - # we want to match each status record separately. In other words, - # "status = 'foo' AND status = 'bar'" should match attachments with - # one status record equal to "foo" and another one equal to "bar", - # not attachments where the same status record equals both "foo" and - # "bar" (which is nonsensical). In order to do this we must add an - # additional counter to the end of the "attachstatuses" table - # reference. - ++$statusid; - - my $attachtable = "attachments_$chartid"; - my $statustable = "attachstatuses_${chartid}_$statusid"; - - push(@supptables, "attachments $attachtable"); - my $join = "LEFT JOIN attachstatuses $statustable ON ". - "($attachtable.attach_id = $statustable.attach_id AND " . - "$statustable.statusid IN (" . join(",", @as_ids) . "))"; - push(@supptables, $join); - push(@wherepart, "bugs.bug_id = $attachtable.bug_id"); - if ($inverted) { - $term = "$statustable.statusid IS NULL"; - } else { - $term = "$statustable.statusid IS NOT NULL"; + + # Add the flags and flagtypes tables to the query. We do + # a left join here so bugs without any flags still match + # negative conditions (f.e. "flag isn't review+"). + my $flags = "flags_$chartid"; + push(@supptables, "LEFT JOIN flags $flags " . + "ON bugs.bug_id = $flags.bug_id"); + my $flagtypes = "flagtypes_$chartid"; + push(@supptables, "LEFT JOIN flagtypes $flagtypes " . + "ON $flags.type_id = $flagtypes.id"); + + # Generate the condition by running the operator-specific function. + # Afterwards the condition resides in the global $term variable. + $ff = "CONCAT($flagtypes.name, $flags.status)"; + &{$funcsbykey{",$t"}}; + + # If this is a negative condition (f.e. flag isn't "review+"), + # we only want bugs where all flags match the condition, not + # those where any flag matches, which needs special magic. + # Instead of adding the condition to the WHERE clause, we select + # the number of flags matching the condition and the total number + # of flags on each bug, then compare them in a HAVING clause. + # If the numbers are the same, all flags match the condition, + # so this bug should be included. + if ($t =~ m/not/) { + push(@fields, "SUM($ff IS NOT NULL) AS allflags_$chartid"); + push(@fields, "SUM($term) AS matchingflags_$chartid"); + push(@having, "allflags_$chartid = matchingflags_$chartid"); + $term = "0=0"; } }, + "^requesters.login_name," => sub { + push(@supptables, "flags flags_$chartid"); + push(@wherepart, "bugs.bug_id = flags_$chartid.bug_id"); + push(@supptables, "profiles requesters_$chartid"); + push(@wherepart, "flags_$chartid.requester_id = requesters_$chartid.userid"); + $f = "requesters_$chartid.login_name"; + }, + "^setters.login_name," => sub { + push(@supptables, "flags flags_$chartid"); + push(@wherepart, "bugs.bug_id = flags_$chartid.bug_id"); + push(@supptables, "profiles setters_$chartid"); + push(@wherepart, "flags_$chartid.setter_id = setters_$chartid.userid"); + $f = "setters_$chartid.login_name"; + }, + "^changedin," => sub { $f = "(to_days(now()) - to_days(bugs.delta_ts))"; }, @@ -817,8 +810,7 @@ sub init { # Make sure we create a legal SQL query. @andlist = ("1 = 1") if !@andlist; - my $query = ("SELECT DISTINCT " . - join(', ', @fields) . + my $query = ("SELECT " . join(', ', @fields) . ", COUNT(DISTINCT ugmap.group_id) AS cntuseringroups, " . " COUNT(DISTINCT bgmap.group_id) AS cntbugingroups, " . " ((COUNT(DISTINCT ccmap.who) AND cclist_accessible) " . @@ -834,11 +826,9 @@ sub init { " LEFT JOIN cc AS ccmap " . " ON ccmap.who = $::userid AND ccmap.bug_id = bugs.bug_id " . " WHERE " . join(' AND ', (@wherepart, @andlist)) . - " GROUP BY bugs.bug_id " . - " HAVING cntuseringroups = cntbugingroups" . - " OR canseeanyway" - ); - + " GROUP BY bugs.bug_id" . + " HAVING " . join(" AND ", @having)); + if ($debug) { print "<p><code>" . value_quote($query) . "</code></p>\n"; exit; diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm new file mode 100644 index 000000000..72870d544 --- /dev/null +++ b/Bugzilla/User.pm @@ -0,0 +1,176 @@ +# -*- Mode: perl; indent-tabs-mode: nil -*- +# +# The contents of this file are subject to the Mozilla Public +# License Version 1.1 (the "License"); you may not use this file +# except in compliance with the License. You may obtain a copy of +# the License at http://www.mozilla.org/MPL/ +# +# Software distributed under the License is distributed on an "AS +# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or +# implied. See the License for the specific language governing +# rights and limitations under the License. +# +# The Original Code is the Bugzilla Bug Tracking System. +# +# The Initial Developer of the Original Code is Netscape Communications +# Corporation. Portions created by Netscape are +# Copyright (C) 1998 Netscape Communications Corporation. All +# Rights Reserved. +# +# Contributor(s): Myk Melez <myk@mozilla.org> + +################################################################################ +# Module Initialization +################################################################################ + +# Make it harder for us to do dangerous things in Perl. +use strict; + +# This module implements utilities for dealing with Bugzilla users. +package Bugzilla::User; + +################################################################################ +# Functions +################################################################################ + +my $user_cache = {}; +sub new { + # Returns a hash of information about a particular user. + + my $invocant = shift; + my $class = ref($invocant) || $invocant; + + my $exists = 1; + my ($id, $name, $email) = @_; + + return undef if !$id; + return $user_cache->{$id} if exists($user_cache->{$id}); + + my $self = { 'id' => $id }; + + bless($self, $class); + + if (!$name && !$email) { + &::PushGlobalSQLState(); + &::SendSQL("SELECT 1, realname, login_name FROM profiles WHERE userid = $id"); + ($exists, $name, $email) = &::FetchSQLData(); + &::PopGlobalSQLState(); + } + + $self->{'name'} = $name; + $self->{'email'} = $email; + $self->{'exists'} = $exists; + + # Generate a string to identify the user by name + email if the user + # has a name or by email only if she doesn't. + $self->{'identity'} = $name ? "$name <$email>" : $email; + + # Generate a user "nickname" -- i.e. a shorter, not-necessarily-unique name + # by which to identify the user. Currently the part of the user's email + # address before the at sign (@), but that could change, especially if we + # implement usernames not dependent on email address. + my @email_components = split("@", $email); + $self->{'nick'} = $email_components[0]; + + $user_cache->{$id} = $self; + + return $self; +} + +sub match { + # Generates a list of users whose login name (email address) or real name + # matches a substring. + + # $str contains the string to match against, while $limit contains the + # maximum number of records to retrieve. + my ($str, $limit, $exclude_disabled) = @_; + + # Build the query. + my $sqlstr = &::SqlQuote($str); + my $qry = " + SELECT userid, realname, login_name + FROM profiles + WHERE (INSTR(login_name, $sqlstr) OR INSTR(realname, $sqlstr)) + "; + $qry .= "AND disabledtext = '' " if $exclude_disabled; + $qry .= "ORDER BY realname, login_name "; + $qry .= "LIMIT $limit " if $limit; + + # Execute the query, retrieve the results, and make them into User objects. + my @users; + &::PushGlobalSQLState(); + &::SendSQL($qry); + push(@users, new Bugzilla::User(&::FetchSQLData())) while &::MoreSQLData(); + &::PopGlobalSQLState(); + + return \@users; +} + +sub email_prefs { + # Get or set (not implemented) the user's email notification preferences. + + my $self = shift; + + # If the calling code is setting the email preferences, update the object + # but don't do anything else. This needs to write email preferences back + # to the database. + if (@_) { $self->{email_prefs} = shift; return; } + + # If we already got them from the database, return the existing values. + return $self->{email_prefs} if $self->{email_prefs}; + + # Retrieve the values from the database. + &::SendSQL("SELECT emailflags FROM profiles WHERE userid = $self->{id}"); + my ($flags) = &::FetchSQLData(); + + my @roles = qw(Owner Reporter QAcontact CClist Voter); + my @reasons = qw(Removeme Comments Attachments Status Resolved Keywords + CC Other Unconfirmed); + + # If the prefs are empty, this user hasn't visited the email pane + # of userprefs.cgi since before the change to use the "emailflags" + # column, so initialize that field with the default prefs. + if (!$flags) { + # Create a default prefs string that causes the user to get all email. + $flags = "ExcludeSelf~on~FlagRequestee~on~FlagRequester~on~"; + foreach my $role (@roles) { + foreach my $reason (@reasons) { + $flags .= "email$role$reason~on~"; + } + } + chop $flags; + } + + # Convert the prefs from the flags string from the database into + # a Perl record. The 255 param is here because split will trim + # any trailing null fields without a third param, which causes Perl + # to eject lots of warnings. Any suitably large number would do. + my $prefs = { split(/~/, $flags, 255) }; + + # Determine the value of the "excludeself" global email preference. + # Note that the value of "excludeself" is assumed to be off if the + # preference does not exist in the user's list, unlike other + # preferences whose value is assumed to be on if they do not exist. + $prefs->{ExcludeSelf} = + exists($prefs->{ExcludeSelf}) && $prefs->{ExcludeSelf} eq "on"; + + # Determine the value of the global request preferences. + foreach my $pref qw(FlagRequestee FlagRequester) { + $prefs->{$pref} = !exists($prefs->{$pref}) || $prefs->{$pref} eq "on"; + } + + # Determine the value of the rest of the preferences by looping over + # all roles and reasons and converting their values to Perl booleans. + foreach my $role (@roles) { + foreach my $reason (@reasons) { + my $key = "email$role$reason"; + $prefs->{$key} = !exists($prefs->{$key}) || $prefs->{$key} eq "on"; + } + } + + $self->{email_prefs} = $prefs; + + return $self->{email_prefs}; +} + +1; |