Warps logging implementations. Now on to error messages

This commit is contained in:
Marcello Rocha
2024-07-19 11:01:04 +02:00
parent bdbd41a9d0
commit ee9f1315c9
14 changed files with 229 additions and 213 deletions
+1
View File
@@ -128,6 +128,7 @@ ignore_unused:
- 'permission_*'
- '{devise,kaminari,will_paginate}.*'
- '*.permission_header_explanation'
- 'services.*'
## Exclude these keys from the `i18n-tasks eq-base' report:
# ignore_eq_base:
@@ -33,6 +33,8 @@ module Storages
module StorageInteraction
module Nextcloud
class AddUserToGroupCommand
include Snitch
def initialize(storage)
@storage = storage
@username = storage.username
@@ -46,48 +48,52 @@ module Storages
end
def call(user:, group: @group)
response = OpenProject
.httpx
.basic_auth(@username, @password)
.with(headers: { "OCS-APIRequest" => "true" })
.post(
UrlBuilder.url(@storage.uri, "ocs/v1.php/cloud/users", user, "groups"),
form: { "groupid" => CGI.escapeURIComponent(group) }
)
with_tagged_logger do
url = UrlBuilder.url(@storage.uri, "ocs/v1.php/cloud/users", user, "groups")
info "Adding #{user} to #{group} through #{url}"
error_data = StorageErrorData.new(source: self.class, payload: response)
response = OpenProject.httpx
.basic_auth(@username, @password)
.with(headers: { "OCS-APIRequest" => "true" })
.post(
UrlBuilder.url(@storage.uri, "ocs/v1.php/cloud/users", user, "groups"),
form: { "groupid" => CGI.escapeURIComponent(group) }
)
case response
in { status: 200..299 }
statuscode = Nokogiri::XML(response.body.to_s).xpath("/ocs/meta/statuscode").text
error_data = StorageErrorData.new(source: self.class, payload: response)
case statuscode
when "100"
ServiceResult.success(message: "User has been added successfully")
when "101"
Util.error(:error, "No group specified", error_data)
when "102"
Util.error(:error, "Group does not exist", error_data)
when "103"
Util.error(:error, "User does not exist", error_data)
when "104"
Util.error(:error, "Insufficient privileges", error_data)
when "105"
Util.error(:error, "Failed to add user to group", error_data)
case response
in { status: 200..299 }
statuscode = Nokogiri::XML(response.body.to_s).xpath("/ocs/meta/statuscode").text
case statuscode
when "100"
info "User has been added to the group"
ServiceResult.success
when "101"
Util.error(:error, "No group specified", error_data)
when "102"
Util.error(:group_does_not_exist, "Group does not exist", error_data)
when "103"
Util.error(:user_does_not_exist, "User does not exist", error_data)
when "104"
Util.error(:insufficient_privileges, "Insufficient privileges", error_data)
when "105"
Util.error(:failed_to_add, "Failed to add user to group", error_data)
end
in { status: 405 }
Util.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
Util.error(:not_found, "Outbound request destination not found", error_data)
in { status: 404 }
Util.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 409 }
Util.error(:conflict, Util.error_text_from_response(response), error_data)
else
Util.error(:error, "Outbound request failed", error_data)
end
in { status: 405 }
Util.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
Util.error(:not_found, "Outbound request destination not found", error_data)
in { status: 404 }
Util.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 409 }
Util.error(:conflict, Util.error_text_from_response(response), error_data)
else
Util.error(:error, "Outbound request failed", error_data)
end
end
# rubocop:enable Metrics/AbcSize
end
end
@@ -28,55 +28,59 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::Nextcloud
class GroupUsersQuery
using Storages::Peripherals::ServiceResultRefinements
module Storages
module Peripherals
module StorageInteraction
module Nextcloud
class GroupUsersQuery
include Snitch
using ServiceResultRefinements
def initialize(storage)
@storage = storage
@username = storage.username
@password = storage.password
end
def self.call(storage:, group: storage.group)
new(storage).call(group:)
end
def self.call(storage:, group: storage.group)
new(storage).call(group:)
end
def initialize(storage)
@storage = storage
@username = storage.username
@password = storage.password
end
# rubocop:disable Metrics/AbcSize
def call(group:)
response = OpenProject
.httpx
.basic_auth(@username, @password)
.with(headers: { "OCS-APIRequest" => "true" })
.get(
Storages::UrlBuilder.url(
@storage.uri,
"ocs/v1.php/cloud/groups",
CGI.escapeURIComponent(group)
)
)
# rubocop:disable Metrics/AbcSize
def call(group:)
with_tagged_logger do
url = UrlBuilder.url(@storage.uri, "ocs/v1.php/cloud/groups", CGI.escapeURIComponent(group))
error_data = Storages::StorageErrorData.new(source: self.class, payload: response)
info "Requesting user list for group #{group} via url #{url} "
response = OpenProject.httpx
.basic_auth(@username, @password)
.with(headers: { "OCS-APIRequest" => "true" })
.get(url)
case response
in { status: 200..299 }
group_users = Nokogiri::XML(response.body.to_s)
.xpath("/ocs/data/users/element")
.map(&:text)
ServiceResult.success(result: group_users)
in { status: 405 }
Util.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
Util.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 404 }
Util.error(:not_found, "Outbound request destination not found", error_data)
in { status: 409 }
Util.error(:conflict, error_text_from_response(response), error_data)
else
Util.error(:error, "Outbound request failed", error_data)
error_data = StorageErrorData.new(source: self.class, payload: response)
case response
in { status: 200..299 }
group_users = Nokogiri::XML(response.body.to_s).xpath("/ocs/data/users/element").map(&:text)
info "#{group_users.size} users found"
ServiceResult.success(result: group_users)
in { status: 405 }
Util.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
Util.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 404 }
Util.error(:not_found, "Outbound request destination not found", error_data)
in { status: 409 }
Util.error(:conflict, error_text_from_response(response), error_data)
else
Util.error(:error, "Outbound request failed", error_data)
end
end
end
# rubocop:enable Metrics/AbcSize
end
end
end
# rubocop:enable Metrics/AbcSize
end
end
@@ -28,67 +28,73 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::Nextcloud
class RemoveUserFromGroupCommand
def initialize(storage)
@storage = storage
@username = storage.username
@password = storage.password
@group = storage.group
end
module Storages
module Peripherals
module StorageInteraction
module Nextcloud
class RemoveUserFromGroupCommand
include Snitch
def self.call(storage:, user:, group: storage.group)
new(storage).call(user:, group:)
end
def self.call(storage:, user:, group: storage.group)
new(storage).call(user:, group:)
end
def initialize(storage)
@storage = storage
@username = storage.username
@password = storage.password
@group = storage.group
end
# rubocop:disable Metrics/AbcSize
def call(user:, group: @group)
response = OpenProject
.httpx
.basic_auth(@username, @password)
.with(headers: { "OCS-APIRequest" => "true" })
.delete(
Storages::UrlBuilder.url(
@storage.uri,
"ocs/v1.php/cloud/users",
user,
"groups"
) + "?groupid=#{CGI.escapeURIComponent(group)}"
)
# rubocop:disable Metrics/AbcSize
def call(user:, group: @group)
with_tagged_logger do
url = UrlBuilder.url(@storage.uri, "ocs/v1.php/cloud/users", user, "groups") +
"?groupid=#{CGI.escapeURIComponent(group)}"
error_data = Storages::StorageErrorData.new(source: self.class, payload: response)
info "Removing #{user} from #{group} through #{url}"
case response
in { status: 200..299 }
statuscode = Nokogiri::XML(response.body.to_s).xpath("/ocs/meta/statuscode").text
case statuscode
when "100"
ServiceResult.success(message: "User has been removed from group")
when "101"
Util.error(:error, "No group specified", error_data)
when "102"
Util.error(:error, "Group does not exist", error_data)
when "103"
Util.error(:error, "User does not exist", error_data)
when "104"
Util.error(:error, "Insufficient privileges", error_data)
when "105"
message = Nokogiri::XML(response.body).xpath("/ocs/meta/message").text
Util.error(:error, "Failed to remove user #{user} from group #{group}: #{message}", error_data)
response = OpenProject.httpx.basic_auth(@username, @password)
.with(headers: { "OCS-APIRequest" => "true" })
.delete(url)
error_data = StorageErrorData.new(source: self.class, payload: response)
case response
in { status: 200..299 }
statuscode = Nokogiri::XML(response.body.to_s).xpath("/ocs/meta/statuscode").text
case statuscode
when "100"
info "User has been removed from group"
ServiceResult.success
when "101"
Util.error(:error, "No group specified", error_data)
when "102"
Util.error(:group_does_not_exist, "Group does not exist", error_data)
when "103"
Util.error(:user_does_not_exist, "User does not exist", error_data)
when "104"
Util.error(:insufficient_privileges, "Insufficient privileges", error_data)
when "105"
message = Nokogiri::XML(response.body).xpath("/ocs/meta/message").text
Util.error(:failed_to_remove, message, error_data)
end
in { status: 405 }
Util.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
Util.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 404 }
Util.error(:not_found, "Outbound request destination not found", error_data)
in { status: 409 }
Util.error(:conflict, Util.error_text_from_response(response), error_data)
else
Util.error(:error, "Outbound request failed", error_data)
end
end
end
# rubocop:enable Metrics/AbcSize
end
in { status: 405 }
Util.error(:not_allowed, "Outbound request method not allowed", error_data)
in { status: 401 }
Util.error(:unauthorized, "Outbound request not authorized", error_data)
in { status: 404 }
Util.error(:not_found, "Outbound request destination not found", error_data)
in { status: 409 }
Util.error(:conflict, Util.error_text_from_response(response), error_data)
else
Util.error(:error, "Outbound request failed", error_data)
end
end
# rubocop:enable Metrics/AbcSize
end
end
@@ -33,6 +33,7 @@ module Storages
module StorageInteraction
module Nextcloud
class SetPermissionsCommand
include Snitch
using ServiceResultRefinements
SUCCESS_XPATH = "/d:multistatus/d:response/d:propstat[d:status[text() = 'HTTP/1.1 200 OK']]/d:prop/nc:acl-list"
@@ -125,14 +126,6 @@ module Storages
end.to_xml
end
# rubocop:enable Metrics/AbcSize
def with_tagged_logger(&)
Rails.logger.tagged(self.class, &)
end
def info(message)
Rails.logger.info message
end
end
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
#-- copyright
#++
module Storages
module Snitch
delegate :info, :error, to: :logger
def with_tagged_logger(tag = self.class, &)
logger.tagged(*tag, &)
end
def logger
Rails.logger
end
end
end
@@ -31,7 +31,7 @@
module Storages::Storages
class NextcloudContract < ::ModelContract
attribute :host
validates :host, url: { message: I18n.t("activerecord.errors.messages.invalid_url") }, length: { maximum: 255 }
validates :host, url: { message: :invalid_host_url }, length: { maximum: 255 }
# Check that a host actually is a storage server.
# But only do so if the validations above for URL were successful.
validates :host, secure_context_uri: true, nextcloud_compatible_host: true, unless: -> { errors.include?(:host) }
@@ -32,6 +32,7 @@ module Storages
class NextcloudGroupFolderPropertiesSyncService
extend ActiveModel::Naming
extend ActiveModel::Translation
include Snitch
using Peripherals::ServiceResultRefinements
@@ -59,21 +60,27 @@ module Storages
end
def call
with_logging do
with_tagged_logger([self.class, "storage-#{@storage.id}"]) do
info "Starting AMPF Sync for Nextcloud Storage #{@storage.id}"
prepare_remote_folders.on_failure { return @result }
prepare_remote_folders.on_failure { return epilogue }
apply_permissions_to_folders
epilogue
end
end
private
def epilogue
info "Synchronization process for Nextcloud Storage #{@storage.id} has ended. #{@result.errors.count} errors found."
@result
end
# @param attribute [Symbol] attribute to which the error will be tied to
# @param storage_error [Storages::StorageError] an StorageError instance
# @param options [Hash<Symbol, Object>] optional extra parameters for the message generation
# @return [ServiceResult]
def add_error(attribute, storage_error, options: {})
case storage_error
case storage_error.code
when :error, :unauthorized
@result.errors.add(:base, storage_error.code, **options)
else
@@ -96,31 +103,35 @@ module Storages
end
def apply_permissions_to_folders
info "Setting permissions to project folders"
remote_admins = admin_client_tokens_scope.pluck(:origin_user_id)
active_project_storages_scope.where.not(project_folder_id: nil).find_each do |project_storage|
set_folders_permissions(remote_admins, project_storage)
end
add_remove_users_to_group
info "Updating user access on automatically managed project folders"
add_remove_users_to_group(@storage.group, @storage.username)
ServiceResult.success
end
def add_remove_users_to_group
def add_remove_users_to_group(group, username)
remote_users = remote_group_users.result_or do |error|
return format_and_log_error(error, group: @storage.group)
format_and_log_error(error, group:)
return add_error(:remote_group_users, error, options: { group: }).fail!
end
local_users = client_tokens_scope.order(:id).pluck(:origin_user_id)
remove_users_from_remote_group(remote_users - local_users - [@storage.username])
add_users_to_remote_group(local_users - remote_users - [@storage.username])
remove_users_from_remote_group(remote_users - local_users - [username])
add_users_to_remote_group(local_users - remote_users - [username])
end
def add_users_to_remote_group(users_to_add)
users_to_add.each do |user|
add_user_to_group.call(storage: @storage, user:).error_and do |error|
add_error(:add_user_to_group, error, options: { user:, group: @storage.group, reason: error.log_message })
format_and_log_error(error, group: @storage.group, user:)
end
end
@@ -129,6 +140,7 @@ module Storages
def remove_users_from_remote_group(users_to_remove)
users_to_remove.each do |user|
remove_user_from_group.call(storage: @storage, user:).error_and do |error|
add_error(:remove_user_from_group, error, options: { user:, group: @storage.group, reason: error.log_message })
format_and_log_error(error, group: @storage.group, user:)
end
end
@@ -288,6 +300,7 @@ module Storages
end
def remote_group_users
info "Retrieving users that a part of the #{@storage.group} group"
group_users.call(storage: @storage, group: @storage.group)
end
@@ -321,20 +334,8 @@ module Storages
payload.to_s
end
error_message = context.merge({ command: error.data.source, message: error.log_message, data: })
logger.error error_message
end
def info(message)
logger.info(message)
end
def with_logging(&)
logger.tagged(self.class, "storage-#{@storage.id}", &)
end
def logger
Rails.logger
error_message = context.merge({ error_code: error.code, data: })
error error_message
end
end
end
+21 -19
View File
@@ -1,23 +1,5 @@
---
en:
services:
attributes:
storages/nextcloud_group_folder_properties_sync_service:
remote_folders: 'Reading contents of the group folder:'
ensure_root_folder_permissions: 'Setting Basic Permissions:'
create_folder: 'Managed Project Folder Creation:'
rename_project_folder: 'Renaming managed project Folder:'
hide_inactive_folders: 'Hide Inactive Folders Step:'
errors:
models:
storages/nextcloud_group_folder_properties_sync_service:
unauthorized: "OpenProject could not sync with Nextcloud. Please check you storage and Nextcloud configuration"
error: "An unexpected error occurred. Please ensure that you Nextcloud instance is reachable and check OpenProject worker logs for more information"
attributes:
remote_folders:
not_found: "%{group_folder} wasn't found. Please check your Nextcloud setup."
not_allowed: "The %{username} doesn't have access to the %{group_folder}. Please check the folder permissions on Nextcloud"
activerecord:
attributes:
storages/file_link:
@@ -31,8 +13,8 @@ en:
tenant: Directory (tenant) ID
errors:
messages:
invalid_host_url: is not a valid URL.
not_linked_to_project: is not linked to project.
invalid_url: is not a valid URL.
models:
storages/file_link:
attributes:
@@ -87,6 +69,26 @@ en:
heading: Remove project from %{storage_type}
text: This action is irreversible and will remove all links from work packages of this project to files and folders of that storage.
label: Remove project
services:
attributes:
storages/nextcloud_group_folder_properties_sync_service:
create_folder: 'Managed Project Folder Creation:'
ensure_root_folder_permissions: 'Setting Basic Permissions:'
hide_inactive_folders: 'Hide Inactive Folders Step:'
remote_folders: 'Reading contents of the group folder:'
remove_user_from_group: 'Revoke User from Group:'
rename_project_folder: 'Renaming managed project Folder:'
errors:
models:
storages/nextcloud_group_folder_properties_sync_service:
attributes:
remote_folders:
not_allowed: The %{username} doesn't have access to the %{group_folder}. Please check the folder permissions on Nextcloud
not_found: "%{group_folder} wasn't found. Please check your Nextcloud setup."
remove_user_from_group:
failed_to_remove: 'The user %{user} could not be removed from the %{group} group for the following reason: %{reason}'
error: An unexpected error occurred. Please ensure that you Nextcloud instance is reachable and check OpenProject worker logs for more information
unauthorized: OpenProject could not sync with Nextcloud. Please check you storage and Nextcloud configuration
storages:
buttons:
complete_without_setup: Complete without it
@@ -144,7 +144,6 @@ RSpec.describe Storages::Peripherals::Registry, :webmock do
it "adds user to the group" do
result = registry.resolve("nextcloud.commands.add_user_to_group").call(storage:, user: origin_user_id)
expect(result).to be_success
expect(result.message).to eq("User has been added successfully")
end
end
@@ -186,7 +185,6 @@ RSpec.describe Storages::Peripherals::Registry, :webmock do
it "removes user from the group" do
result = registry.resolve("nextcloud.commands.remove_user_from_group").call(storage:, user: origin_user_id)
expect(result).to be_success
expect(result.message).to eq("User has been removed from group")
end
context "when Nextcloud reponds with 105 code in the response body" do
@@ -210,8 +208,7 @@ RSpec.describe Storages::Peripherals::Registry, :webmock do
result = registry.resolve("nextcloud.commands.remove_user_from_group").call(storage:, user: origin_user_id)
expect(result).to be_failure
expect(result.errors.log_message)
.to eq("Failed to remove user #{origin_user_id} from group OpenProject: " \
"Not viable to remove user from the last group you are SubAdmin of")
.to eq("Not viable to remove user from the last group you are SubAdmin of")
end
end
end
@@ -116,13 +116,13 @@ RSpec.shared_examples_for "nextcloud storage contract", :storage_server_helpers,
context "as host is not a URL" do
let(:storage_host) { "---invalid-url---" }
include_examples "contract is invalid", host: I18n.t("activerecord.errors.messages.invalid_url")
include_examples "contract is invalid", host: :invalid_host_url
end
context "as host is an empty string" do
let(:storage_host) { "" }
include_examples "contract is invalid", host: I18n.t("activerecord.errors.messages.invalid_url")
include_examples "contract is invalid", host: :invalid_host_url
end
context "as host is longer than 255" do
@@ -178,7 +178,7 @@ RSpec.describe "API v3 storages resource", :webmock, content_type: :json do
end
it_behaves_like "constraint violation" do
let(:message) { "Host is not a valid URL" }
let(:message) { "Host is not a valid URL." }
end
end
end
@@ -691,10 +691,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
expect(Rails.logger)
.to have_received(:error)
.with(folder: "OpenProject",
command: Storages::Peripherals::StorageInteraction::Nextcloud::Internal::PropfindQueryLegacy,
message: "Outbound request destination not found",
data: { status: 404, body: "" })
.with(folder: "OpenProject", error_code: :not_found, data: { status: 404, body: "" })
end
it "returns a failure" do
@@ -758,8 +755,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
expect(Rails.logger)
.to have_received(:error)
.with(folder: "OpenProject",
command: Storages::Peripherals::StorageInteraction::Nextcloud::SetPermissionsCommand,
message: "Outbound request not authorized",
error_code: :unauthorized,
data: { status: 401, body: "Heute nicht" })
end
@@ -767,8 +763,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
result = described_class.new(storage).call
expect(result).to be_failure
expect(result.errors[:ensure_root_folder_permissions])
.to contain_exactly(I18n.t("#{prefix}.unauthorized"))
expect(result.errors[:base]).to contain_exactly(I18n.t("#{prefix}.unauthorized"))
end
end
end
@@ -804,8 +799,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
expect(Rails.logger)
.to have_received(:error)
.with(folder_name: "/OpenProject/[Sample] Project Name | Ehuu (#{project1.id})/",
command: Storages::Peripherals::StorageInteraction::Nextcloud::CreateFolderCommand,
message: "Outbound request destination not found!",
error_code: :not_found,
data: "not found")
end
end
@@ -834,9 +828,8 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
expect(Rails.logger)
.to have_received(:error)
.with(folder_id: project_storage2.project_folder_id,
error_code: :not_found,
folder_name: "Jedi Project Folder ||| (#{project2.id})",
command: Storages::Peripherals::StorageInteraction::Nextcloud::RenameFileCommand,
message: "Outbound request destination not found",
data: { status: 404, body: "" })
end
end
@@ -867,8 +860,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
.to have_received(:error)
.with(context: "hide_folder",
folder: "/OpenProject/Lost Jedi Project Folder #2/",
command: Storages::Peripherals::StorageInteraction::Nextcloud::SetPermissionsCommand,
message: "Outbound request failed",
error_code: :error,
data: { status: 500, body: "A server error occurred" })
end
end
@@ -898,8 +890,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
expect(Rails.logger)
.to have_received(:error)
.with(folder: "/OpenProject/Jedi Project Folder ||| (#{project2.id})/",
command: Storages::Peripherals::StorageInteraction::Nextcloud::SetPermissionsCommand,
message: "Outbound request failed",
error_code: :error,
data: { status: 500, body: "Divide by cucumber error. Please reinstall universe and reboot." })
end
end
@@ -930,8 +921,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
.to have_received(:error)
.with(group: "OpenProject",
user: "Obi-Wan",
command: Storages::Peripherals::StorageInteraction::Nextcloud::AddUserToGroupCommand,
message: "Outbound request failed",
error_code: :error,
data: { status: 302, body: "" })
end
end
@@ -965,9 +955,7 @@ RSpec.describe Storages::NextcloudGroupFolderPropertiesSyncService, :webmock do
.to have_received(:error)
.with(group: "OpenProject",
user: "Darth Maul",
command: Storages::Peripherals::StorageInteraction::Nextcloud::RemoveUserFromGroupCommand,
message: "Failed to remove user Darth Maul from group OpenProject: " \
"Not viable to remove user from the last group you are SubAdmin of",
error_code: :failed_to_remove,
data: { status: 200, body: remove_user_from_group_response })
end
end
@@ -92,7 +92,7 @@ RSpec.describe Storages::AutomaticallyManagedStorageSyncJob, type: :job do
allow(job).to receive(:perform_later)
errors = ActiveModel::Errors.new(Storages::NextcloudGroupFolderPropertiesSyncService.new(managed_nextcloud))
errors.add(:group_folder, :not_found, group_folder: managed_nextcloud.group_folder)
errors.add(:remote_folders, :not_found, group_folder: managed_nextcloud.group_folder)
allow(Storages::NextcloudGroupFolderPropertiesSyncService)
.to receive(:call)