diff --git a/CHANGELOG.md b/CHANGELOG.md index e5cc485f68..c3da3d4c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +# Version 9.4.0 +*2024-09-23* + +* Adds features to trigger reports that allow them to directly send emails, e.g. for replying to + partially complete reports of Asian Hornets. +* Adds fields to the `groups` entity (for iRecord Activities) for the following: + * Controlling if blogs are enabled and, if so, whether any member can post a blog or only admins. + See https://github.com/BiologicalRecordsCentre/iRecord/issues/1703. + * Defining if a group is a container for other groups, e.g. a project divided by years. See + https://github.com/BiologicalRecordsCentre/iRecord/issues/1678. + * Defining if a group is contained by another group. +* Update the `library/groups/find_group_by_url` report to include information about container and + contained groups. See https://github.com/BiologicalRecordsCentre/iRecord/issues/1678. +* Bugfixes for the new bulk edit tool. See https://github.com/BiologicalRecordsCentre/iRecord/issues/1673. +* Updated download field formats to support sensitive record download requirements. See + https://github.com/BiologicalRecordsCentre/iRecord/issues/1714 and the columns documentation at + https://indicia-docs.readthedocs.io/en/latest/site-building/iform/helpers/elasticsearch-report-helper.html#elasticsearchreporthelper-datagrid. +* Bugfix for the handling of the current common name when AddSynonym operations are processed by + the UKSI History processor. See https://github.com/Indicia-Team/warehouse/pull/522. +* Update the `library/groups/group_list` report to include a full text search parameter and also so + that setting the parameter `userFilterMode=joinable` excludes groups you are already a member of. +* The Elasticsearch special field handler for "sitename" now supports additional options - + `obscureifsensitive` - shows a warning message instead of the site name if sensitive and + `showifsensitive` - displays the full site name for sensitive records (only if the user has + access to full precision sensitive data). +* In Elasticsearch, sensitive or private records have their site names replaced by '!' to + distinguish them from records where there is no site name given. See + https://github.com/BiologicalRecordsCentre/iRecord/issues/1714. + +# Version 9.3.0 +*2024-08-19* + +* Adds `search_code` to parameters of Rest endpoint, `services/rest/taxa/search` + and includes it in the response. + # Version 9.2.0 *2024-06-17* diff --git a/README.md b/README.md index 706486ea10..d1bf8075bb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Indicia Warehouse [![Build Status](https://travis-ci.com/Indicia-Team/warehouse.svg?branch=master)](https://travis-ci.com/Indicia-Team/warehouse) +# Indicia Warehouse This is the repository for the Indicia Warehouse, the server-side component of Indicia, the online wildlife recording toolkit. Indicia accelerates development of wildlife recording websites and mobile applications. Documentation is diff --git a/application/config/version.php b/application/config/version.php index 50e1759d4a..827dd78688 100644 --- a/application/config/version.php +++ b/application/config/version.php @@ -29,14 +29,14 @@ * * @var string */ -$config['version'] = '9.3.3'; +$config['version'] = '9.4.0'; /** * Version release date. * * @var string */ -$config['release_date'] = '2024-09-02'; +$config['release_date'] = '2024-09-25'; /** * Link to the code repository downloads page. diff --git a/application/controllers/attribute_by_survey.php b/application/controllers/attribute_by_survey.php index 6245dc03fb..8ecc2b1af3 100644 --- a/application/controllers/attribute_by_survey.php +++ b/application/controllers/attribute_by_survey.php @@ -20,7 +20,7 @@ */ /** - * Controller providing the ability to configure the list of attributes joined to a survey. + * Controller for relationship between custom attributes and surveys. */ class Attribute_By_Survey_Controller extends Indicia_Controller { private $_survey = NULL; @@ -83,8 +83,10 @@ public function edit($id) { } /** - * Handle the layout_update action, which uses $_POST data to find a list of commands - * for re-ordering the controls. + * Handle the layout_update action. + * + * Which uses $_POST data to find a list of commands for re-ordering the + * controls. */ public function layout_update() { // Get the survey id from the segments in the URI. @@ -223,8 +225,13 @@ protected function editViewName() { } /** - * Setup the values to be loaded into the edit view. For this class, we need to explode the - * items out of the validation_rules field, which our base class can do. + * Setup the values to be loaded into the edit view. + * + * For this class, we need to explode the items out of the validation_rules + * field, which our base class can do. + * + * @return array + * Key value pairs of data for the edit view. */ protected function getModelValues() { $r = parent::getModelValues(); @@ -308,8 +315,12 @@ protected function prepareOtherViewData(array $values) { return $otherData; } + /** + * Override save handler to format validation rules correctly. + */ public function save() { - // Build the validation_rules field from the set of controls that are associated with it. + // Build the validation_rules field from the set of controls that are + // associated with it. $rules = []; $ruleNames = ([ 'required', @@ -340,12 +351,20 @@ public function save() { parent::save(); } + /** + * Determine the page to return to after saving. + * + * @return string + * Path to the page to return to. + */ protected function get_return_page() { - $surveyPostKey = $this->type.'_attributes_website:restrict_to_survey_id'; + $surveyPostKey = $this->type . '_attributes_website:restrict_to_survey_id'; if (isset($_POST[$surveyPostKey])) { - return 'attribute_by_survey/'.$_POST[$surveyPostKey].'?type='.$this->type; - } else { - // If $_POST data not available, then just return to the survey list. Shouldn't really happen. + return 'attribute_by_survey/' . $_POST[$surveyPostKey] . '?type=' . $this->type; + } + else { + // If $_POST data not available, then just return to the survey list. + // Shouldn't really happen. return 'survey'; } } @@ -356,7 +375,8 @@ protected function get_return_page() { protected function defineEditBreadcrumbs() { $this->page_breadcrumbs[] = html::anchor('survey', 'Survey datasets'); $survey = ORM::Factory('survey', $this->model->restrict_to_survey_id); - $this->page_breadcrumbs[] = html::anchor('/attribute_by_survey/'.$this->model->restrict_to_survey_id.'?type='.$this->type, 'Attributes for '.$survey->title); + $this->page_breadcrumbs[] = html::anchor( + "/attribute_by_survey/$survey->id?type=$this->type", "Attributes for $survey->title"); $this->page_breadcrumbs[] = $this->model->caption(); } @@ -371,8 +391,10 @@ protected function page_authorised() { $this->_website_id = $survey->website_id; } return in_array($this->_website_id, $this->auth_filter['values']); - } else + } + else { return true; + } } /** @@ -385,5 +407,4 @@ protected function getSurvey() { return $this->_survey; } - -} \ No newline at end of file +} diff --git a/application/controllers/forgotten_password.php b/application/controllers/forgotten_password.php index f3975c6fda..55367b710a 100644 --- a/application/controllers/forgotten_password.php +++ b/application/controllers/forgotten_password.php @@ -26,6 +26,9 @@ */ class Forgotten_Password_Controller extends Indicia_Controller { + /** + * Set up the main controller page. + */ public function index() { if ($this->auth->logged_in()) { @@ -59,10 +62,25 @@ public function index() { } } + /** + * Return true if the user can login to the warehouse. + * + * User's only have login rughts if they have site editor role or higher, or + * core admin. + * + * @param ORM $user + * User object. + * + * @return bool + * True if the user is allowed to login. + */ public function check_can_login($user) { - if (is_null($user->core_role_id) && ORM::factory('users_website') - ->where('user_id', $user->id)->where('site_role_id IS NOT ', NULL)->find_all() === 0) { - $this->template->content->error_message = $_POST['UserID'] . ' does not have permission to log on to this website'; + if (is_null($user->core_role_id) + && ORM::factory('users_website') + ->where('user_id', $user->id) + ->where('site_role_id IS NOT ', NULL) + ->find_all() === 0) { + $this->template->content->error_message = "$_POST[UserID] does not have permission to log on to this website"; return FALSE; } return TRUE; diff --git a/application/controllers/location_comment.php b/application/controllers/location_comment.php index 051526fabd..eb1cd9cf14 100644 --- a/application/controllers/location_comment.php +++ b/application/controllers/location_comment.php @@ -65,6 +65,9 @@ protected function getDefaults() { * * After saving a comment you are returned to the location entry which has * the comment. + * + * @return string + * Page path to return to. */ protected function get_return_page() { if (array_key_exists('location_comment:location_id', $_POST)) { diff --git a/application/controllers/logout.php b/application/controllers/logout.php index 91f4f6d3fa..d9a7e0a1b4 100644 --- a/application/controllers/logout.php +++ b/application/controllers/logout.php @@ -19,19 +19,17 @@ * @link https://github.com/indicia-team/warehouse */ -/* +/** * Provides application support for logging users out. */ class Logout_Controller extends Indicia_Controller { - /* - * description: Logs the current user out of the application. Destroys the current session - * parameters: None expected. - */ - public function index() - { + /** + * Logs the current user out of the application. Destroys the current session. + */ + public function index() { $this->auth->logout(TRUE); url::redirect(); } -} \ No newline at end of file +} diff --git a/application/controllers/scheduled_tasks.php b/application/controllers/scheduled_tasks.php index 0fa0d884b2..a4b4961ae4 100644 --- a/application/controllers/scheduled_tasks.php +++ b/application/controllers/scheduled_tasks.php @@ -149,7 +149,7 @@ protected function checkTriggers() { $params = json_decode($trigger->params_json, TRUE); $reportEngine = new ReportEngine(); // Get parameter for last run specific to this trigger. - $params['date'] = variable::get("trigger_last_run-$trigger->id", $this->lastRunDate); + $params['date'] = variable::get("trigger_last_run-$trigger->id", $this->lastRunDate, FALSE); $currentTime = time(); try { $data = $reportEngine->requestReport($trigger->trigger_template_file . '.xml', 'local', 'xml', $params); @@ -239,6 +239,13 @@ protected function checkTriggers() { ], $digestMode ); + $this->doTriggerImmediateEmails( + $trigger->name, + [ + 'headings' => $parsedData['headingData'], + 'data' => $parsedData['websiteRecordData'], + ] + ); } // Remember when this specific trigger last ran. variable::set("trigger_last_run-$trigger->id", date('c', $currentTime)); @@ -354,6 +361,101 @@ private function doDirectTriggerNotifications($triggerName, array $data, $digest } } + /** + * Send emails for trigger reports that directly define an email to send. + * + * These emails can be sent directly, bypassing the notifications system and + * therefore can be sent to anyone not just registered users. E.g. a thank + * you email to anonymous recorders. + * + * @param string $triggerName + * Name of the trigger which fired. + * @param array $data + * Info regarding the trigger report columns and associated retrieved data. + */ + private function doTriggerImmediateEmails($triggerName, array $data) { + if (count($data['data']) === 0 || !in_array('email_to', $data['headings'])) { + return; + } + $emailConfig = Kohana::config('email'); + if (!isset($emailConfig['address'])) { + self::msg('Email address not provided in email configuration', 'error'); + return; + } + $colIndexes = []; + $sysCols = ['email_to', 'email_subject', 'email_body', 'email_name']; + foreach ($sysCols as $col) { + if (($colIdx = array_search($col, $data['headings'])) !== FALSE) { + $colIndexes[$col] = $colIdx; + } + } + $defaultSubject = empty(kohana::config('email.notification_subject')) + ? kohana::lang('misc.notification_subject') + : kohana::config('email.notification_subject'); + $emails = []; + foreach ($data['data'] as $records) { + foreach ($records as $record) { + if (!empty($record[$colIndexes['email_to']])) { + $to = $record[$colIndexes['email_to']]; + $name = empty($colIndexes['email_name']) || empty($record[$colIndexes['email_name']]) + ? $to + : $record[$colIndexes['email_name']]; + if (!isset($emails["$to $name"])) { + $emails["$to $name"] = []; + } + + $subject = empty($colIndexes['email_subject']) || empty($record[$colIndexes['email_subject']]) + ? $defaultSubject + : $record[$colIndexes['email_subject']]; + if (empty($colIndexes['email_body']) || empty($record[$colIndexes['email_body']])) { + // Email body not provided, so construct it from the other columns. + $body = ''; + foreach ($data['headings'] as $idx => $colTitle) { + // Skip the functional email columns. + if (!in_array($colTitle, $sysCols)) { + $body[] = '

' . htmlspecialchars($colTitle) . '

'; + $body[] = '

' . htmlspecialchars($record[$idx]) . '

'; + } + } + } + else { + $body = $record[$colIndexes['email_body']]; + } + // Aggregate emails per email address, so we don't send multiple. + $emails["$to $name"][] = [ + 'to' => $to, + 'name' => $name, + 'subject' => $subject, + 'body' => $body, + ]; + } + } + } + $swift = email::connect(); + // Now send the emails as a digest so each recipient only gets one email. + foreach ($emails as $infoList) { + // If a single email for this recipient we can use the subject, otherwise + // we use a generic subject and put each email subject in as a subtitle. + $subject = count($infoList) === 1 ? $infoList[0]['subject'] : $defaultSubject; + $emailContent = ''; + foreach ($infoList as $infoItem) { + if (count($infoList) > 1) { + $emailContent .= '

' . htmlspecialchars($infoItem['subject']) . '

'; + } + $emailContent .= '
' . $infoItem['body'] . '
'; + } + $message = new Swift_Message( + $subject, + "$emailContent", + 'text/html' + ); + $recipients = new Swift_RecipientList(); + $recipients->addTo($infoList[0]['to'], $infoList[0]['name']); + // Send the email. + $swift->send($message, $recipients, $emailConfig['address']); + } + } + /** * Create email digests for trigger notifications. * diff --git a/application/controllers/taxa_taxon_list.php b/application/controllers/taxa_taxon_list.php index 660ac1e7f6..c3ba36928e 100644 --- a/application/controllers/taxa_taxon_list.php +++ b/application/controllers/taxa_taxon_list.php @@ -31,9 +31,9 @@ public function __construct() { $this->columns = [ 'taxon' => '', 'authority' => '', - 'taxon_group' => 'Taxon Group', + 'taxon_group' => 'Taxon group', 'language' => '', - 'taxonomic_sort_order' => 'Sort Order', + 'taxonomic_sort_order' => 'Sort order', ]; $this->pagetitle = "Species"; } diff --git a/application/i18n/en_GB/form_error_messages.php b/application/i18n/en_GB/form_error_messages.php index 4976e624ff..68cd954abb 100644 --- a/application/i18n/en_GB/form_error_messages.php +++ b/application/i18n/en_GB/form_error_messages.php @@ -75,7 +75,7 @@ 'default' => 'Invalid input.', ], 'centroid_sref_system' => [ - 'required' => 'The centorid spatial reference system must be supplied.', + 'required' => 'The centroid spatial reference system must be supplied.', 'default' => 'Invalid input.', ], 'external_key' => [ diff --git a/application/models/group.php b/application/models/group.php index 930537752b..dd60bf9f77 100644 --- a/application/models/group.php +++ b/application/models/group.php @@ -46,6 +46,8 @@ public function validate(Validation $array, $save = FALSE) { $array->add_rules('group_type_id', 'required'); $array->add_rules('website_id', 'required'); $array->add_rules('code', 'length[1,20]'); + $array->add_rules('post_blog_permission', 'regex[/^(A|M)$/]'); + $array->add_rules('contained_by_group_id', 'integer'); $this->unvalidatedFields = [ 'code', 'description', @@ -59,6 +61,7 @@ public function validate(Validation $array, $save = FALSE) { 'view_full_precision', 'logo_path', 'licence_id', + 'container', ]; // Has the private records flag changed? $this->wantToUpdateReleaseStatus = isset($this->submission['fields']['private_records']) && diff --git a/application/models/notification.php b/application/models/notification.php index facf1a8c6e..04c2e0aadf 100644 --- a/application/models/notification.php +++ b/application/models/notification.php @@ -35,13 +35,13 @@ public function validate(Validation $array, $save = FALSE) { // fields before validation. $array->pre_filter('trim'); $array->add_rules('source', 'required'); - $array->add_rules('source_type', 'required'); + $array->add_rules('source_type', 'required', 'regex[/^(T|V|C|Q|A|S|VT|M|PT|GU|FE)$/]'); + $array->add_rules('digest_mode', 'regex[/^[NDWI]$/]'); $array->add_rules('data', 'required'); $array->add_rules('acknowledged', 'required'); $array->add_rules('user_id', 'required'); $array->add_rules('triggered_on', 'required'); $this->unvalidatedFields = [ - 'digest_mode', 'cc', 'linked_id', ]; diff --git a/application/views/termlists_term/termlists_term_edit.php b/application/views/termlists_term/termlists_term_edit.php index 211abcc531..612c7dca49 100644 --- a/application/views/termlists_term/termlists_term_edit.php +++ b/application/views/termlists_term/termlists_term_edit.php @@ -56,7 +56,7 @@ 'label' => 'Code', 'fieldname' => 'term:code', 'default' => html::initial_value($values, 'term:code'), - 'helpText' => 'A code or other reference number associatd with the term.', + 'helpText' => 'A code or other reference number associated with the term.', ]); echo data_entry_helper::textarea([ 'label' => 'Description', diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 67573f4872..43635ea7fb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -58,8 +58,8 @@ services: # A fake mail server which allows the warehouse to send emails. # Received mail can be viewed at http://localhost:8025 - mailhog: - image: mailhog/mailhog + mailpit: + image: axllent/mailpit ports: - "8025:8025" @@ -95,7 +95,7 @@ services: depends_on: postgres: condition: service_healthy - mailhog: + mailpit: condition: service_started elastic: condition: service_healthy diff --git a/docker/warehouse/Dockerfile b/docker/warehouse/Dockerfile index 2a14cf518f..eecff0644e 100644 --- a/docker/warehouse/Dockerfile +++ b/docker/warehouse/Dockerfile @@ -1,6 +1,6 @@ -# This image contains Debian's Apache httpd in conjunction with PHP8.1. +# This image contains Debian's Apache httpd in conjunction with PHP. # https://hub.docker.com/_/php -FROM php:8.1-apache +FROM php:8.2-apache # Use PHP development configuration file as a basis RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" diff --git a/docker/warehouse/config/email.php b/docker/warehouse/config/email.php index 1b8d595635..4ce5bb7c00 100644 --- a/docker/warehouse/config/email.php +++ b/docker/warehouse/config/email.php @@ -42,7 +42,7 @@ * @param array smtp: hostname, (username), (password), (port), (auth), (encryption) */ $config['options'] = [ - 'hostname' => 'mailhog', + 'hostname' => 'mailpit', 'username' => '', 'password' => '', 'port' => '1025', diff --git a/modules/indicia_setup/db/version_9_3_0/202408051352_groups_fields.sql b/modules/indicia_setup/db/version_9_3_0/202408051352_groups_fields.sql new file mode 100644 index 0000000000..310b9f3191 --- /dev/null +++ b/modules/indicia_setup/db/version_9_3_0/202408051352_groups_fields.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN post_blog_permission character, + ADD CONSTRAINT groups_blog_post_permisison_check CHECK (post_blog_permission::text = ANY (ARRAY[NULL::bpchar, 'A'::bpchar, 'M'::bpchar]::text[])); + +COMMENT ON COLUMN groups.post_blog_permission IS 'Requirements for posting blog entries for the group. Can be "A" - group admins can post, "M" - any group member can post, or NULL if blogs are disabled.'; \ No newline at end of file diff --git a/modules/indicia_setup/db/version_9_3_0/202408061314_groups_views.sql b/modules/indicia_setup/db/version_9_3_0/202408061314_groups_views.sql new file mode 100644 index 0000000000..1b424a4a6f --- /dev/null +++ b/modules/indicia_setup/db/version_9_3_0/202408061314_groups_views.sql @@ -0,0 +1,55 @@ +DROP VIEW list_groups; +CREATE VIEW list_groups AS + SELECT g.id, + g.title, + g.code, + g.group_type_id, + g.description, + g.from_date, + g.to_date, + g.private_records, + g.website_id, + g.filter_id, + g.joining_method, + g.logo_path, + g.implicit_record_inclusion, + g.licence_id, + g.view_full_precision, + g.post_blog_permission + FROM groups g + WHERE g.deleted = false; + +DROP VIEW detail_groups; +CREATE VIEW detail_groups AS + SELECT g.id, + g.title, + g.code, + g.group_type_id, + g.description, + g.from_date, + g.to_date, + g.private_records, + g.website_id, + g.joining_method, + g.filter_id, + f.definition AS filter_definition, + g.created_by_id, + c.username AS created_by, + g.updated_by_id, + u.username AS updated_by, + g.logo_path, + CASE + WHEN g.joining_method='P' OR g.joining_method='R' THEN btrim(regexp_replace(regexp_replace(lower(g.title::text), '[ ]'::text, '-'::text, 'g'::text), '[^a-z0-9\-]'::text, ''::text, 'g'::text), '-'::text) + ELSE NULL::text + END AS url_safe_title, + g.implicit_record_inclusion, + g.licence_id, + l.code AS licence_code, + g.view_full_precision, + g.post_blog_permission + FROM groups g + LEFT JOIN filters f ON f.id = g.filter_id AND f.deleted = false + LEFT JOIN licences l ON l.id = g.licence_id AND l.deleted = false + JOIN users c ON c.id = g.created_by_id + JOIN users u ON u.id = g.updated_by_id + WHERE g.deleted = false; diff --git a/modules/indicia_setup/db/version_9_4_0/202409171807_group_containers.sql b/modules/indicia_setup/db/version_9_4_0/202409171807_group_containers.sql new file mode 100644 index 0000000000..2a918d597a --- /dev/null +++ b/modules/indicia_setup/db/version_9_4_0/202409171807_group_containers.sql @@ -0,0 +1,10 @@ +ALTER TABLE groups + ADD COLUMN container BOOLEAN DEFAULT false, + ADD COLUMN contained_by_group_id INTEGER, + ADD CONSTRAINT fk_groups_contained_by_group_id FOREIGN KEY (contained_by_group_id) + REFERENCES groups (id); + +COMMENT ON COLUMN groups.container IS 'Is the group a container for other sub-groups? Changes the behaviour of the group as it''s purpose is as an organisational tool for the contained groups, so only has administrator members.'; +COMMENT ON COLUMN groups.contained_by_group_id IS 'If the group is contained by another group, points to the container. Inherits filtering and admin members from the parent group.'; + +CREATE INDEX fki_groups_contained_by_group_id ON groups(contained_by_group_id); \ No newline at end of file diff --git a/modules/indicia_setup/db/version_9_4_0/202409171808_group_container_views.sql b/modules/indicia_setup/db/version_9_4_0/202409171808_group_container_views.sql new file mode 100644 index 0000000000..34e25bd17a --- /dev/null +++ b/modules/indicia_setup/db/version_9_4_0/202409171808_group_container_views.sql @@ -0,0 +1,57 @@ +CREATE OR REPLACE VIEW list_groups AS + SELECT g.id, + g.title, + g.code, + g.group_type_id, + g.description, + g.from_date, + g.to_date, + g.private_records, + g.website_id, + g.filter_id, + g.joining_method, + g.logo_path, + g.implicit_record_inclusion, + g.licence_id, + g.view_full_precision, + g.post_blog_permission, + g.container, + g.contained_by_group_id + FROM groups g + WHERE g.deleted = false; + +CREATE OR REPLACE VIEW detail_groups AS + SELECT g.id, + g.title, + g.code, + g.group_type_id, + g.description, + g.from_date, + g.to_date, + g.private_records, + g.website_id, + g.joining_method, + g.filter_id, + f.definition AS filter_definition, + g.created_by_id, + c.username AS created_by, + g.updated_by_id, + u.username AS updated_by, + g.logo_path, + CASE + WHEN g.joining_method='P' OR g.joining_method='R' THEN btrim(regexp_replace(regexp_replace(lower(g.title::text), '[ ]'::text, '-'::text, 'g'::text), '[^a-z0-9\-]'::text, ''::text, 'g'::text), '-'::text) + ELSE NULL::text + END AS url_safe_title, + g.implicit_record_inclusion, + g.licence_id, + l.code AS licence_code, + g.view_full_precision, + g.post_blog_permission, + g.container, + g.contained_by_group_id + FROM groups g + LEFT JOIN filters f ON f.id = g.filter_id AND f.deleted = false + LEFT JOIN licences l ON l.id = g.licence_id AND l.deleted = false + JOIN users c ON c.id = g.created_by_id + JOIN users u ON u.id = g.updated_by_id + WHERE g.deleted = false; \ No newline at end of file diff --git a/modules/indicia_svc_data/controllers/services/data_utils.php b/modules/indicia_svc_data/controllers/services/data_utils.php index 519c5e58e4..c1c7247e67 100644 --- a/modules/indicia_svc_data/controllers/services/data_utils.php +++ b/modules/indicia_svc_data/controllers/services/data_utils.php @@ -1134,7 +1134,6 @@ public function bulk_edit() { return; } $db = new Database(); - // @todo think through behaviour in parent/child sample data. $results = $this->checkAffectedSamplesDontContainOtherOccurrences($db, $occurrenceIds); if ($results) { if (!empty($options->allowSampleSplits)) { @@ -1157,39 +1156,59 @@ public function bulk_edit() { $this->fail('Unauthorized', 404, 'You cannot edit samples belonging to other users.'); return FALSE; } - $sampleFieldUpdates = []; - if (!empty($updates->date)) { - $sampleFieldUpdates[] = "date_start='$updates->date'"; - $sampleFieldUpdates[] = "date_end='$updates->date'"; - $sampleFieldUpdates[] = "date_type='D'"; - } - if (!empty($updates->location_name)) { - $locationName = pg_escape_literal($db->getLink(), $updates->location_name); - $sampleFieldUpdates[] = "location_name=$locationName"; - } - if (!empty($updates->sref)) { - $sref = spatial_ref::sref_format_tidy($updates->sref, $updates->sref_system); - $sampleFieldUpdates[] = "entered_sref=$sref"; - $sampleFieldUpdates[] = "entered_sref_system=$updates->sref_system"; - $geom = "st_geomfromtext('" . spatial_ref::sref_to_internal_wkt($updates->sref, $updates->sref_system) . "', 900913)"; - $sampleFieldUpdates[] = "geom=$geom"; - } - if (!empty($sampleFieldUpdates)) { - $sampleFieldUpdateSql = implode(',', $sampleFieldUpdates); - $qry = <<user_id - WHERE id in ($sampleIds) - AND created_by_id=$this->user_id; + $sampleFieldUpdates = $this->getSampleFieldUpdates($db, $updates); + $sampleFieldUpdateSql = empty($sampleFieldUpdates) ? '' : implode(',', $sampleFieldUpdates) . ', '; + $sampleFieldChangedCheckSql = empty($sampleFieldUpdates) ? 'false' : 'NOT (s.' . implode(' AND s.', $sampleFieldUpdates) . ')'; + $recorderNameFieldChangedCheckSql = empty($updates->recorder_name) ? '' : "OR snf.recorders<>'$updates->recorder_name'"; + $langRecheck = pg_escape_literal($db->getLink(), kohana::lang('misc.recheck_verification')); + $qry = <<user_id; + + UPDATE samples s + SET $sampleFieldUpdateSql + updated_on=now(), + updated_by_id=$this->user_id, + record_status='C', + verified_by_id=null, + verified_on=null + FROM changing_samples cs + WHERE cs.id=s.id; + + -- Also reset verification status on changed occurrences. + INSERT INTO occurrence_comments (occurrence_id, comment, auto_generated, created_on, created_by_id, updated_on, updated_by_id) + SELECT o.id, $langRecheck, 't', now(), $this->user_id, now(), $this->user_id + FROM changing_samples cs + JOIN occurrences o ON o.sample_id=cs.id + AND o.deleted=false + AND (o.record_status<>'C' OR o.record_substatus IS NOT NULL); + + UPDATE occurrences o + SET updated_on=now(), + updated_by_id=$this->user_id, + record_status='C', + record_substatus=null, + verified_by_id=null, + verified_on=null + FROM changing_samples cs + WHERE cs.id=o.sample_id + AND o.deleted=false; + SQL; - $db->query($qry); - } + $db->query($qry); if (!empty($updates->recorder_name)) { // Recorder name a little different as it might be a custom attribute. $this->bulkEditRecorderNames($db, $sampleIds, $updates->recorder_name); } + // Update the cache_* data using the work queue. $qry = <<date)) { + $sampleFieldUpdates[] = "date_start='$updates->date'::date"; + $sampleFieldUpdates[] = "date_end='$updates->date'::date"; + $sampleFieldUpdates[] = "date_type='D'"; + } + if (!empty($updates->location_name)) { + $locationName = pg_escape_literal($db->getLink(), $updates->location_name); + $sampleFieldUpdates[] = "location_name=$locationName"; + } + if (!empty($updates->sref)) { + $sref = pg_escape_literal($db->getLink(), spatial_ref::sref_format_tidy($updates->sref, $updates->sref_system)); + $sampleFieldUpdates[] = "entered_sref=$sref"; + $system = pg_escape_literal($db->getLink(), $updates->sref_system); + $sampleFieldUpdates[] = "entered_sref_system=$system"; + $sampleFieldUpdates[] = "geom=st_geomfromtext('" . spatial_ref::sref_to_internal_wkt($updates->sref, $updates->sref_system) . "', 900913)"; + } + return $sampleFieldUpdates; + } + /** * Confirms that a list of sample IDs are all created by the current user. * @@ -1241,7 +1292,7 @@ private function checkSamplesAllBelongToUser($db, $sampleIds) { } /** - * Handle the bulk edit of recorder names + * Handle the bulk edit of recorder names. * * Complex due to optional custom attributes vs samples.recorder_names field. * diff --git a/modules/log_browser/controllers/browse_server_logs.php b/modules/log_browser/controllers/browse_server_logs.php index 28b982d76b..366dac0cf5 100644 --- a/modules/log_browser/controllers/browse_server_logs.php +++ b/modules/log_browser/controllers/browse_server_logs.php @@ -40,7 +40,7 @@ public function index() { } // put the most recent first arsort($files); - $this->template->title='Browse Server Logs'; + $this->template->title='Browse server logs'; $this->template->content = new View('browse_server_logs'); $this->template->content->files = $files; } diff --git a/modules/log_browser/views/browse_server_logs.php b/modules/log_browser/views/browse_server_logs.php index cad79950ef..7d3efe7b80 100644 --- a/modules/log_browser/views/browse_server_logs.php +++ b/modules/log_browser/views/browse_server_logs.php @@ -68,7 +68,7 @@ ), )); ?> - + 23, 'url' => 'http://www.brc.ac.uk/irecord/record-details?occurrence_id=#id#') -); +$config['record_details_page_urls'] = [ + ['website_id' => 23, 'url' => 'http://www.brc.ac.uk/irecord/record-details?occurrence_id=#id#'] +]; // Populate the following array with a list of notification types you want to control the output of email text for. // Each entry must have the type code as a key ('S', 'C', 'V', 'Q', 'RD', 'A', 'VT', 'M', 'PT', 'GU') and an array as the value. // The sub arrays can contain optional title and/or description elements to set the text included in the notification // email. -$config['notification_types']=array(); \ No newline at end of file +$config['notification_types'] = []; \ No newline at end of file diff --git a/modules/notification_emails/plugins/notification_emails.php b/modules/notification_emails/plugins/notification_emails.php index af5e43a054..c3456614d2 100644 --- a/modules/notification_emails/plugins/notification_emails.php +++ b/modules/notification_emails/plugins/notification_emails.php @@ -195,7 +195,7 @@ function run_email_notification_jobs($db, array $frequenciesToRun) { } else { // Get address to send emails from. - $email_config = array(); + $email_config = []; // Try and get from configuration file if possible. try { $email_config['address'] = kohana::config('notification_emails.email_sender_address'); @@ -217,7 +217,7 @@ function run_email_notification_jobs($db, array $frequenciesToRun) { // user was for the previous notification. When this user id then changes, // we know we need to start building an new email to a new user. $previousUserId = 0; - $notificationIds = array(); + $notificationIds = []; $emailContent = start_building_new_email($notificationsToSendEmailsFor[0]); $currentType = ''; $sourceTypes = notification_emails::getNotificationTypes(); @@ -249,7 +249,7 @@ function run_email_notification_jobs($db, array $frequenciesToRun) { // Used to mark the notifications in an email if an email send is // successful, once email send attempt has been made we can reset the // list ready for the next email. - $notificationIds = array(); + $notificationIds = []; $emailSentCounter++; // As we just sent out a an email, we can start building a new one. $emailContent = start_building_new_email($notificationToSendEmailsFor); @@ -370,7 +370,7 @@ function notification_emails_hyperlink_id($id, $websiteId) { // Handle config file not present. } catch (Exception $e) { - $recordDetailsPages = array(); + $recordDetailsPages = []; } foreach ($recordDetailsPages as $page) { $found = $page['website_id'] == $websiteId; diff --git a/modules/rest_api/i18n/en_GB/es_fields.php b/modules/rest_api/i18n/en_GB/es_fields.php new file mode 100644 index 0000000000..1bcd31c206 --- /dev/null +++ b/modules/rest_api/i18n/en_GB/es_fields.php @@ -0,0 +1,5 @@ + 'sensitive record, location hidden', +]; diff --git a/modules/rest_api/libraries/RestApiElasticsearch.php b/modules/rest_api/libraries/RestApiElasticsearch.php index 4e83c3cb2a..5113b5f1ed 100644 --- a/modules/rest_api/libraries/RestApiElasticsearch.php +++ b/modules/rest_api/libraries/RestApiElasticsearch.php @@ -95,7 +95,8 @@ class RestApiElasticsearch { 'field' => 'location.coordinate_uncertainty_in_meters', ], ['caption' => 'Lat/Long', 'field' => 'location.point'], - ['caption' => 'Location name', 'field' => 'location.verbatim_locality'], + ['caption' => 'Location name', 'field' => '#sitename:obscureifsensitive#'], + ['caption' => 'Sensitive location', 'field' => '#sitename:showifsensitive#'], ['caption' => 'Higher geography', 'field' => '#higher_geography::name#'], [ 'caption' => 'Vice County', @@ -156,7 +157,8 @@ class RestApiElasticsearch { ['caption' => 'Order', 'field' => 'taxon.order'], ['caption' => 'Family', 'field' => 'taxon.family'], ['caption' => 'TaxonVersionKey', 'field' => 'taxon.accepted_taxon_id'], - ['caption' => 'Site name', 'field' => 'location.verbatim_locality'], + ['caption' => 'Site name', 'field' => '#sitename:obscureifsensitive#'], + ['caption' => 'Sensitive site', 'field' => '#sitename:showifsensitive#'], ['caption' => 'Original map ref', 'field' => 'location.input_sref'], ['caption' => 'Latitude', 'field' => '#lat:decimal#'], ['caption' => 'Longitude', 'field' => '#lon:decimal#'], @@ -234,6 +236,7 @@ class RestApiElasticsearch { "mapmate" => [ ['caption' => 'Taxon', 'field' => 'taxon.accepted_name'], ['caption' => 'Site', 'field' => '#sitename:mapmate#'], + ['caption' => 'Sensitive site', 'field' => '#sitename:showifsensitive#'], ['caption' => 'Gridref', 'field' => 'location.output_sref'], [ 'caption' => 'VC', @@ -440,8 +443,8 @@ private function applyEsPermissionsQuery(&$postObj) { } else { // Otherwise, only verification or user's own records get full - // precision, but not if downloading currently. - $blur = ($this->pagingMode !== 'scroll' && (RestObjects::$scope === 'verification' || substr(RestObjects::$scope, 0, 4) === 'user')) ? 'F' : 'B'; + // precision. + $blur = (RestObjects::$scope === 'verification' || substr(RestObjects::$scope, 0, 4) === 'user') ? 'F' : 'B'; } $queryStringParts = []; if (empty($this->resourceOptions['allow_confidential']) || $this->resourceOptions['allow_confidential'] !== TRUE) { @@ -1107,10 +1110,10 @@ private function esGetSpecialFieldLocality(array $doc) { $info = []; if (!empty($doc['location']['verbatim_locality'])) { $info[] = $doc['location']['verbatim_locality']; - if (!empty($doc['location']['higher_geography'])) { - foreach ($doc['location']['higher_geography'] as $loc) { - $info[] = "$loc[type]: $loc[name]"; - } + } + if (!empty($doc['location']['higher_geography'])) { + foreach ($doc['location']['higher_geography'] as $loc) { + $info[] = "$loc[type]: $loc[name]"; } } return implode('; ', $info); @@ -1421,7 +1424,19 @@ private function esGetSpecialFieldSex(array $doc, array $params) { /** * Special field handler for ES sitename. * - * Return location.verbatim_locality formatted as specified. + * Return location.verbatim_locality formatted as specified. The parameter + * supplied dictates the format - select one of the following: + * * obscureifsensitive - for sensitive records with a site name, replace the + * name with a placeholder indicating that it's witheld, for non-sensitive + * records the entire site name is returned. + * * showifsensitive - only show the name for sensitive records. + * * mapmate - for sensitive records with a site name, replace the name with + * a placeholder indicating that it's witheld, for non-sensitive records + * return the first 62 characters of the site name, or 'unnamed site' if no + * name given. + * If the parameter is not supplied then the site name is always shown if the + * user has permission to see sensitive records unblurred or the record is + * not sensitive. * * @param array $doc * Elasticsearch document. @@ -1432,18 +1447,38 @@ private function esGetSpecialFieldSex(array $doc, array $params) { * Formatted string */ private function esGetSpecialFieldSitename(array $doc, array $params) { - if (count($params) !== 1) { - return 'Incorrect params for sitename field'; - } + $format = !empty($params) ? $params[0] : ''; $value = $this->getRawEsFieldValue($doc, 'location.verbatim_locality'); - if ($params[0] === 'mapmate') { - if ($value === '') { - $value = 'unnamed site'; - } - // Truncation to 62 characters required for MapMate. - $value = substr($value, 0, 62); + $shouldBlur = $doc['metadata']['sensitive'] === 'true' || $doc['metadata']['private'] === 'true'; + switch ($format) { + case 'obscureifsensitive': + if ($shouldBlur && !empty($value)) { + return '[' . kohana::lang('es_fields.sensitiveLocation'). ']'; + } + // Full site name. + return $value; + + case 'showifsensitive': + if ($shouldBlur && $value !== '!') { + return $value; + } + return ''; + + case 'mapmate': + if ($shouldBlur && !empty($value)) { + return '[' . kohana::lang('es_fields.sensitiveLocation'). ']'; + } + // Truncation to 62 characters required for MapMate. + if (empty($value)) { + return 'unnamed site'; + } + // Truncation to 62 characters required for MapMate, skipping the site + // witheld ! character. + return $value === '!' ? '' : substr($value, 0, 62); + + default: + return $value === '!' ? '' : $value; } - return $value; } /** @@ -2071,8 +2106,10 @@ private function getEsPostData($postObj, $format, $file, $isSearch) { $fields[] = 'event.recorded_by'; $fields[] = 'identification.identified_by'; } - elseif (preg_match('/^#sitename(.*)#$/', $field)) { + elseif (preg_match('/^#sitename(:.*)?#$/', $field)) { $fields[] = 'location.verbatim_locality'; + $fields[] = 'metadata.sensitive'; + $fields[] = 'metadata.private'; } elseif (preg_match('/^#template(.*)#$/', $field)) { // Find fields embedded in the template and add them. diff --git a/modules/uksi_operations/controllers/uksi_operation.php b/modules/uksi_operations/controllers/uksi_operation.php index f9b6a60627..ee76620faa 100644 --- a/modules/uksi_operations/controllers/uksi_operation.php +++ b/modules/uksi_operations/controllers/uksi_operation.php @@ -235,6 +235,7 @@ public function processAddSynonym($operation) { // Copy over details from the preferred taxon to define a synonym. $fields['taxa_taxon_list:taxon_meaning_id'] = $allExistingNames->current()->taxon_meaning_id; $fields['taxa_taxon_list:parent_id'] = $allExistingNames->current()->parent_id; + $fields['taxa_taxon_list:common_taxon_id'] = $allExistingNames->current()->common_taxon_id; $fields['taxon:taxon_rank_id'] = $allExistingNames->current()->taxon_rank_id; $fields['taxon:taxon_group_id'] = $allExistingNames->current()->taxon_group_id; $fields['taxon:marine_flag'] = $allExistingNames->current()->marine_flag; @@ -286,7 +287,11 @@ public function processAmendName($operation) { $tx->taxon = $operation->taxon_name; } if (!empty($operation->attribute)) { - $tx->attribute = $operation->attribute; + if ($operation->attribute === 'NONE') { + $tx->attribute = NULL; + } else { + $tx->attribute = $operation->attribute; + } } if (!empty($operation->authority)) { $tx->authority = $operation->authority; @@ -705,6 +710,7 @@ public function processRenameTaxon($operation) { } $fields['taxon:organism_key'] = $operation->current_organism_key; $fields['taxa_taxon_list:taxon_meaning_id'] = $previousPreferredName->taxon_meaning_id; + $fields['taxa_taxon_list:common_taxon_id'] = $previousPreferredName->common_taxon_id; if (empty($fields['taxa_taxon_list:parent_id'])) { // If parent not specified in operation, keep the original. $fields['taxa_taxon_list:parent_id'] = $previousPreferredName->parent_id; @@ -983,7 +989,7 @@ private function getTaxaForKeys(array $search) { // Table alias defaults to t. $where[strpos($key, '.') === FALSE ? "t.$key" : $key] = $value; } - return $this->db->select('ttl.id, ttl.taxon_meaning_id, ttl.taxon_id, ttl.preferred, ttl.parent_id, ttl.allow_data_entry, ' . + return $this->db->select('ttl.id, ttl.taxon_meaning_id, ttl.taxon_id, ttl.preferred, ttl.parent_id, ttl.allow_data_entry, ttl.common_taxon_id, ' . 't.taxon, t.authority, t.attribute, t.search_code, t.external_key, t.organism_key, t.taxon_rank_id, ' . 't.taxon_group_id, t.marine_flag, t.freshwater_flag, t.terrestrial_flag, t.non_native_flag, ' . 't.organism_deprecated, t.name_deprecated') diff --git a/reports/library/groups/find_group_by_url.xml b/reports/library/groups/find_group_by_url.xml index 646f6ec4a9..70a895335a 100644 --- a/reports/library/groups/find_group_by_url.xml +++ b/reports/library/groups/find_group_by_url.xml @@ -8,6 +8,7 @@ (((gp.administrator=false OR gu.administrator=true) and gu.id is not null AND gu.pending=false) OR gp.administrator is null) AND (gu.administrator=true OR COALESCE(gu.access_level, 0)>=COALESCE(gp.access_level, 0)) LEFT JOIN group_pages gpall ON gpall.group_id=g.id AND gpall.deleted=false + LEFT JOIN groups gcont ON gcont.id=g.contained_by_group_id AND gcont.container=true AND gcont.deleted=false JOIN cache_termlists_terms gt on gt.id=g.group_type_id #joins# WHERE g.deleted = false @@ -46,6 +47,12 @@ + + + + + + @@ -58,5 +65,6 @@ aggregate="true" template="{pages}"/> + diff --git a/reports/library/groups/groups_list.xml b/reports/library/groups/groups_list.xml index fc412f8033..0fd9916fc1 100644 --- a/reports/library/groups/groups_list.xml +++ b/reports/library/groups/groups_list.xml @@ -24,7 +24,7 @@ datatype="lookup" default="member" lookup_values='all:All groups,joinable:Groups they can join or request,allvisible:Groups they can join or request or are already a member of,pending:Groups they are pending approval to join,member:Groups they are a member of,admin:Groups they administer,create:Groups they created,create_admin:Groups they created or administer'> - g.joining_method not in ('A','I') + g.joining_method not in ('A','I') AND gu.id IS NULL (g.joining_method not in ('A','I') OR gu.id IS NOT NULL OR '#CMSAdminPerm#' = '1') gu.id is not null AND gu.pending=true gu.id is not null AND gu.pending=false @@ -70,6 +70,9 @@ (g.title ilike '%#search_text#%' or g.description ilike '%#search_text#%') + + websearch_to_tsquery('english', '#search_fulltext#') @@ to_tsvector('english', coalesce(g.title, '') || ' ' || coalesce(g.description, '')) + diff --git a/reports/library/occurrences/list_for_elastic.xml b/reports/library/occurrences/list_for_elastic.xml index 397b906b99..055bf7a319 100644 --- a/reports/library/occurrences/list_for_elastic.xml +++ b/reports/library/occurrences/list_for_elastic.xml @@ -28,7 +28,7 @@ JOIN cache_taxa_taxon_lists cttl ON cttl.id=o.taxa_taxon_list_id LEFT JOIN locations l ON l.id=o.location_id AND l.deleted=false AND o.private=false AND o.sensitive=false; - -- Parent sample data + -- Parent sample data. UPDATE output_rows o SET recorded_parent_location_id = CASE WHEN o.private='false' AND o.sensitive='false' THEN lp.id ELSE null END, recorded_parent_location_name = CASE WHEN o.private='false' AND o.sensitive='false' THEN lp.name ELSE null END, @@ -51,6 +51,17 @@ AND o.recorded_parent_location_id IS NULL AND o.private='false' AND o.sensitive='false'; + -- Sensitive site name warnings. + UPDATE output_rows o + SET given_locality_name='!' + FROM samples s + LEFT JOIN samples sp ON sp.id=s.parent_id AND sp.deleted=false + LEFT JOIN locations l ON l.id=s.location_id AND l.deleted=false + LEFT JOIN locations lp ON lp.id=sp.location_id AND lp.deleted=false + WHERE s.id=o.sample_id AND s.deleted=false + AND (o.private='true' OR o.sensitive='true') + AND COALESCE(l.name, s.location_name, lp.name, sp.location_name, '')<>''; + SELECT * FROM output_rows o #order_by# diff --git a/reports/library/occurrences/list_for_elastic_all.xml b/reports/library/occurrences/list_for_elastic_all.xml index d0403798bd..aceab85b6d 100644 --- a/reports/library/occurrences/list_for_elastic_all.xml +++ b/reports/library/occurrences/list_for_elastic_all.xml @@ -28,7 +28,7 @@ JOIN cache_taxa_taxon_lists cttl ON cttl.id=o.taxa_taxon_list_id LEFT JOIN locations l ON l.id=o.location_id AND l.deleted=false AND o.private=false AND o.sensitive=false; - -- Parent sample data + -- Parent sample data. UPDATE output_rows o SET recorded_parent_location_id = CASE WHEN o.private='false' AND o.sensitive='false' THEN lp.id ELSE null END, recorded_parent_location_name = CASE WHEN o.private='false' AND o.sensitive='false' THEN lp.name ELSE null END, @@ -51,6 +51,17 @@ AND o.recorded_parent_location_id IS NULL AND o.private='false' AND o.sensitive='false'; + -- Sensitive site name warnings. + UPDATE output_rows o + SET given_locality_name='!' + FROM samples s + LEFT JOIN samples sp ON sp.id=s.parent_id AND sp.deleted=false + LEFT JOIN locations l ON l.id=s.location_id AND l.deleted=false + LEFT JOIN locations lp ON lp.id=sp.location_id AND lp.deleted=false + WHERE s.id=o.sample_id AND s.deleted=false + AND (o.private='true' OR o.sensitive='true') + AND COALESCE(l.name, s.location_name, lp.name, sp.location_name, '')<>''; + SELECT * FROM output_rows o #order_by# diff --git a/reports/library/samples/list_for_elastic.xml b/reports/library/samples/list_for_elastic.xml index 459200b307..db60ba50e3 100644 --- a/reports/library/samples/list_for_elastic.xml +++ b/reports/library/samples/list_for_elastic.xml @@ -48,6 +48,7 @@ JOIN cache_samples_nonfunctional snf ON snf.id=s.id LEFT JOIN locations l ON l.id=s.location_id AND l.deleted=false; + -- Parent sample data. UPDATE output_rows o SET recorded_parent_location_id = lp.id, recorded_parent_location_name = lp.name, @@ -69,6 +70,17 @@ WHERE sc.id=o.sample_id AND sc.deleted=false AND o.recorded_parent_location_id IS NULL; + -- Sensitive site name warnings. + UPDATE output_rows o + SET given_locality_name='!' + FROM samples s + LEFT JOIN samples sp ON sp.id=s.parent_id AND sp.deleted=false + LEFT JOIN locations l ON l.id=s.location_id AND l.deleted=false + LEFT JOIN locations lp ON lp.id=sp.location_id AND lp.deleted=false + WHERE s.id=o.id AND s.deleted=false + AND o.private='true' + AND COALESCE(l.name, s.location_name, lp.name, sp.location_name, '')<>''; + SELECT * FROM output_rows s #order_by# diff --git a/reports/library/samples/list_for_elastic_all.xml b/reports/library/samples/list_for_elastic_all.xml index 78f1c9b848..96882f986d 100644 --- a/reports/library/samples/list_for_elastic_all.xml +++ b/reports/library/samples/list_for_elastic_all.xml @@ -48,6 +48,7 @@ JOIN cache_samples_nonfunctional snf ON snf.id=s.id LEFT JOIN locations l ON l.id=s.location_id AND l.deleted=false; + -- Parent sample data. UPDATE output_rows o SET recorded_parent_location_id = lp.id, recorded_parent_location_name = lp.name, @@ -69,6 +70,17 @@ WHERE sc.id=o.sample_id AND sc.deleted=false AND o.recorded_parent_location_id IS NULL; + -- Sensitive site name warnings. + UPDATE output_rows o + SET given_locality_name='!' + FROM samples s + LEFT JOIN samples sp ON sp.id=s.parent_id AND sp.deleted=false + LEFT JOIN locations l ON l.id=s.location_id AND l.deleted=false + LEFT JOIN locations lp ON lp.id=sp.location_id AND lp.deleted=false + WHERE s.id=o.id AND s.deleted=false + AND o.private='true' + AND COALESCE(l.name, s.location_name, lp.name, sp.location_name, '')<>''; + SELECT * FROM output_rows s #order_by# diff --git a/reports/trigger_templates/record_thanks_email.xml b/reports/trigger_templates/record_thanks_email.xml new file mode 100644 index 0000000000..6645915c8f --- /dev/null +++ b/reports/trigger_templates/record_thanks_email.xml @@ -0,0 +1,27 @@ + + + select #columns# + from cache_occurrences_functional o + join users u on u.id=o.created_by_id and u.deleted=false + join people p on p.id=u.person_id and p.deleted=false + where o.updated_on>'#date#' + and o.created_on>'#date#' + and o.training=false + + + o.updated_on ASC + + + + + + + + + + + + + \ No newline at end of file diff --git a/system/vendor/swift/Swift/Connection/SMTP.php b/system/vendor/swift/Swift/Connection/SMTP.php index 393261b8ae..547c8d22a5 100644 --- a/system/vendor/swift/Swift/Connection/SMTP.php +++ b/system/vendor/swift/Swift/Connection/SMTP.php @@ -88,7 +88,14 @@ class Swift_Connection_SMTP extends Swift_ConnectionBase * @var string */ protected $errstr; - + + /** + * Server hostname. + * + * @var string + */ + protected $server; + /** * Constructor * @param string The remote server to connect to @@ -287,17 +294,17 @@ public function start() break; } } - + $server = $this->server; if ($this->encryption == self::ENC_TLS) $server = "tls://" . $server; elseif ($this->encryption == self::ENC_SSL) $server = "ssl://" . $server; - + $log = Swift_LogContainer::getLog(); if ($log->hasLevel(Swift_log::LOG_EVERYTHING)) { $log->add("Trying to connect to SMTP server at '" . $server . ":" . $this->port); } - + if (!$this->handle = @fsockopen($server, $this->port, $errno, $errstr, $this->timeout)) { $error_msg = "The SMTP connection failed to start [" . $server . ":" . $this->port . "]: fsockopen returned Error Number " . $errno . " and Error String '" . $errstr . "'"; @@ -367,10 +374,10 @@ public function runAuthenticators($user, $pass, Swift $swift) } closedir($handle); } - + $tried = 0; $looks_supported = true; - + //Allow everything we have if the server has the audacity not to help us out. if (!$this->hasExtension("AUTH")) { @@ -381,7 +388,7 @@ public function runAuthenticators($user, $pass, Swift $swift) $looks_supported = false; $this->setExtension("AUTH", array_keys($this->authenticators)); } - + foreach ($this->authenticators as $name => $obj) { //Server supports this authentication mechanism @@ -402,11 +409,11 @@ public function runAuthenticators($user, $pass, Swift $swift) } } } - + //Server doesn't support authentication if (!$looks_supported && $tried == 0) throw new Swift_ConnectionException("Authentication is not supported by the server but a username and password was given."); - + if ($tried == 0) throw new Swift_ConnectionException("No authentication mechanisms were tried since the server did not support any of the ones loaded. " . "Loaded authenticators: [" . implode(", ", array_keys($this->authenticators)) . "]");