decidim-api 0.31.4 → 0.32.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +3 -13
- data/app/controllers/decidim/api/sessions_controller.rb +1 -1
- data/app/models/decidim/api/api_user.rb +5 -1
- data/config/locales/ca-IT.yml +7 -0
- data/config/locales/ca.yml +7 -0
- data/config/locales/cs.yml +7 -0
- data/config/locales/el.yml +11 -0
- data/config/locales/en.yml +7 -0
- data/config/locales/es-MX.yml +7 -0
- data/config/locales/es-PY.yml +7 -0
- data/config/locales/es.yml +7 -0
- data/config/locales/eu.yml +7 -0
- data/config/locales/fi-plain.yml +7 -0
- data/config/locales/fi.yml +7 -0
- data/config/locales/fr-CA.yml +4 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/ja.yml +7 -0
- data/config/locales/no.yml +1 -0
- data/config/locales/pt-BR.yml +7 -0
- data/config/locales/ro-RO.yml +7 -0
- data/config/locales/sv.yml +7 -0
- data/decidim-api.gemspec +12 -15
- data/docs/usage.md +3 -634
- data/lib/decidim/api/component_mutation_type.rb +1 -2
- data/lib/decidim/api/errors/attribute_validation_error.rb +73 -0
- data/lib/decidim/api/errors/invalid_locale_error.rb +14 -0
- data/lib/decidim/api/errors/locale_error.rb +14 -0
- data/lib/decidim/api/errors/mutation_not_authorized_error.rb +14 -0
- data/lib/decidim/api/errors/not_found_error.rb +14 -0
- data/lib/decidim/api/errors/permission_not_set_error.rb +14 -0
- data/lib/decidim/api/errors/unauthorized_field_error.rb +14 -0
- data/lib/decidim/api/errors/unauthorized_object_error.rb +14 -0
- data/lib/decidim/api/errors/validation_error.rb +13 -0
- data/lib/decidim/api/graphiql/config.rb +1 -1
- data/lib/decidim/api/graphql_permissions.rb +17 -12
- data/lib/decidim/api/query_type.rb +91 -0
- data/lib/decidim/api/schema.rb +27 -1
- data/lib/decidim/api/test/component_context.rb +59 -51
- data/lib/decidim/api/test/shared_examples/commentable_interface_examples.rb +46 -0
- data/lib/decidim/api/test/shared_examples/followable_interface_examples.rb +12 -1
- data/lib/decidim/api/test/shared_examples/statistics_examples.rb +0 -2
- data/lib/decidim/api/test/type_context.rb +10 -2
- data/lib/decidim/api/test.rb +1 -0
- data/lib/decidim/api/types/access_mode_enum.rb +15 -0
- data/lib/decidim/api/types/base_mutation.rb +28 -0
- data/lib/decidim/api/types/base_object.rb +12 -0
- data/lib/decidim/api/types.rb +12 -1
- data/lib/decidim/api/version.rb +1 -1
- data/lib/decidim/api.rb +22 -32
- metadata +59 -34
- /data/lib/decidim/api/test/{mutation_context.rb → shared_examples/mutation_context.rb} +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
class AttributeValidationError < GraphQL::ExecutionError
|
|
7
|
+
def initialize(messages, ast_node: nil, options: nil, extensions: nil)
|
|
8
|
+
@ast_node = ast_node
|
|
9
|
+
@options = options
|
|
10
|
+
@extensions = extensions
|
|
11
|
+
|
|
12
|
+
@messages = messages
|
|
13
|
+
|
|
14
|
+
message_str =
|
|
15
|
+
if messages.is_a?(ActiveModel::Errors)
|
|
16
|
+
messages.full_messages.join(", ")
|
|
17
|
+
elsif messages.is_a?(Array)
|
|
18
|
+
messages.map { |a| a[:message] }.join(", ")
|
|
19
|
+
else
|
|
20
|
+
messages.to_s
|
|
21
|
+
end
|
|
22
|
+
super(message_str)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
hash = {}
|
|
27
|
+
if @messages.is_a?(ActiveModel::Errors)
|
|
28
|
+
hash["message"] = @messages.map do |error|
|
|
29
|
+
# This is the GraphQL argument which corresponds to the validation error:
|
|
30
|
+
local_path = ["attributes", error.attribute.to_s.camelize(:lower)]
|
|
31
|
+
{
|
|
32
|
+
path: local_path,
|
|
33
|
+
message: error.message
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
hash["message"] = @messages if @messages.is_a?(Array)
|
|
39
|
+
|
|
40
|
+
if ast_node
|
|
41
|
+
hash["locations"] = [
|
|
42
|
+
{
|
|
43
|
+
"line" => ast_node.line,
|
|
44
|
+
"column" => ast_node.col
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
hash["path"] = path if path
|
|
50
|
+
|
|
51
|
+
hash.merge!(options) if options
|
|
52
|
+
|
|
53
|
+
if extensions
|
|
54
|
+
hash["extensions"] = extensions.transform_keys do |(key, value), ext|
|
|
55
|
+
ext[key.to_s] = value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
hash.merge!({ "extensions" => { "code" => "ATTRIBUTE_VALIDATION_ERROR" } })
|
|
60
|
+
|
|
61
|
+
hash
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def message
|
|
65
|
+
return @messages.full_messages.join(", ") if @messages.is_a?(ActiveModel::Errors)
|
|
66
|
+
return @messages.map { |a| [a[:path].last, a[:message]].join(": ") }.join(", ") if @messages.is_a?(Array)
|
|
67
|
+
|
|
68
|
+
@messages.to_s
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
# i18n-tasks-use t("decidim.api.errors.invalid_locale")
|
|
7
|
+
class InvalidLocaleError < GraphQL::ExecutionError
|
|
8
|
+
def to_h
|
|
9
|
+
super.merge({ "extensions" => { "code" => "INVALID_LOCALE_ERROR" } })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
# i18n-tasks-use t("decidim.api.errors.locale_argument_error")
|
|
7
|
+
class LocaleError < GraphQL::ExecutionError
|
|
8
|
+
def to_h
|
|
9
|
+
super.merge({ "extensions" => { "code" => "LOCALE_ERROR" } })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
# i18n-tasks-use t("decidim.api.errors.unauthorized_mutation")
|
|
7
|
+
class MutationNotAuthorizedError < GraphQL::ExecutionError
|
|
8
|
+
def to_h
|
|
9
|
+
super.merge({ "extensions" => { "code" => "MUTATION_NOT_AUTHORIZED_ERROR" } })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
# i18n-tasks-use t("decidim.api.errors.not_found")
|
|
7
|
+
class NotFoundError < GraphQL::ExecutionError
|
|
8
|
+
def to_h
|
|
9
|
+
super.merge({ "extensions" => { "code" => "NOT_FOUND_ERROR" } })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
# i18n-tasks-use t("decidim.api.errors.permission_not_set")
|
|
7
|
+
class PermissionNotSetError < GraphQL::ExecutionError
|
|
8
|
+
def to_h
|
|
9
|
+
super.merge({ "extensions" => { "code" => "PERMISSION_NOT_SET_ERROR" } })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
# i18n-tasks-use t("decidim.api.errors.unauthorized_field")
|
|
7
|
+
class UnauthorizedFieldError < GraphQL::ExecutionError
|
|
8
|
+
def to_h
|
|
9
|
+
super.merge({ "extensions" => { "code" => "UNAUTHORIZED_FIELD_ERROR" } })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decidim
|
|
4
|
+
module Api
|
|
5
|
+
module Errors
|
|
6
|
+
# i18n-tasks-use t("decidim.api.errors.unauthorized_object")
|
|
7
|
+
class UnauthorizedObjectError < GraphQL::ExecutionError
|
|
8
|
+
def to_h
|
|
9
|
+
super.merge({ "extensions" => { "code" => "UNAUTHORIZED_OBJECT_ERROR" } })
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -7,7 +7,7 @@ module Decidim
|
|
|
7
7
|
# @example Adding a header to the request
|
|
8
8
|
# config.headers["My-Header"] = -> (view_context) { "My-Value" }
|
|
9
9
|
#
|
|
10
|
-
# @return [Hash
|
|
10
|
+
# @return [Hash{String => Proc}] Keys are headers to include in GraphQL requests, values are `->(view_context) { ... }` procs to determine values
|
|
11
11
|
attr_accessor :headers
|
|
12
12
|
|
|
13
13
|
attr_accessor :query_params, :initial_query, :csrf, :title, :logo
|
|
@@ -17,8 +17,8 @@ module Decidim
|
|
|
17
17
|
subject = determine_subject_name(object)
|
|
18
18
|
context[subject] = object
|
|
19
19
|
|
|
20
|
-
chain.unshift(allowed_to?(:read, :participatory_space, object, context)) if object.respond_to?(:participatory_space)
|
|
21
|
-
chain.unshift(allowed_to?(:read, :component, object, context)) if object.respond_to?(:component) && object.component.present?
|
|
20
|
+
chain.unshift(allowed_to?(:read, :participatory_space, object.participatory_space, context)) if object.respond_to?(:participatory_space)
|
|
21
|
+
chain.unshift(allowed_to?(:read, :component, object.component, context)) if object.respond_to?(:component) && object.component.present?
|
|
22
22
|
|
|
23
23
|
super && chain.all?
|
|
24
24
|
end
|
|
@@ -34,6 +34,7 @@ module Decidim
|
|
|
34
34
|
# @param context [GraphQL::Query::Context] The GraphQL context
|
|
35
35
|
#
|
|
36
36
|
# @return Boolean
|
|
37
|
+
# @param [Symbol] scope
|
|
37
38
|
def allowed_to?(action, subject, object, context, scope: :public)
|
|
38
39
|
unless subject.is_a?(::Symbol)
|
|
39
40
|
subject = determine_subject_name(object)
|
|
@@ -43,12 +44,7 @@ module Decidim
|
|
|
43
44
|
permission_action = Decidim::PermissionAction.new(scope:, action:, subject:)
|
|
44
45
|
|
|
45
46
|
permission_chain(object).inject(permission_action) do |current_permission_action, permission_class|
|
|
46
|
-
permission_context =
|
|
47
|
-
if scope == :admin
|
|
48
|
-
local_admin_context(object, context)
|
|
49
|
-
else
|
|
50
|
-
local_context(object, context)
|
|
51
|
-
end
|
|
47
|
+
permission_context = local_user_context(object, context)
|
|
52
48
|
|
|
53
49
|
permission_class.new(
|
|
54
50
|
context[:current_user],
|
|
@@ -56,6 +52,8 @@ module Decidim
|
|
|
56
52
|
permission_context
|
|
57
53
|
).permissions
|
|
58
54
|
end.allowed?
|
|
55
|
+
rescue Decidim::PermissionAction::PermissionNotSetError
|
|
56
|
+
false
|
|
59
57
|
end
|
|
60
58
|
|
|
61
59
|
# Injects into context object current_participatory_space and current_component keys as they are needed
|
|
@@ -65,8 +63,13 @@ module Decidim
|
|
|
65
63
|
#
|
|
66
64
|
# @return Hash
|
|
67
65
|
def local_context(object, context)
|
|
68
|
-
context[:current_participatory_space]
|
|
69
|
-
|
|
66
|
+
context[:current_participatory_space] ||=
|
|
67
|
+
if object.respond_to?(:participatory_space)
|
|
68
|
+
object.participatory_space
|
|
69
|
+
elsif object.is_a?(Decidim::Participable)
|
|
70
|
+
object
|
|
71
|
+
end
|
|
72
|
+
context[:current_component] ||=
|
|
70
73
|
if object.is_a?(Decidim::Component)
|
|
71
74
|
object
|
|
72
75
|
elsif object.respond_to?(:component)
|
|
@@ -76,7 +79,7 @@ module Decidim
|
|
|
76
79
|
context.to_h
|
|
77
80
|
end
|
|
78
81
|
|
|
79
|
-
def
|
|
82
|
+
def local_user_context(object, context)
|
|
80
83
|
context = local_context(object, context)
|
|
81
84
|
|
|
82
85
|
component = context[:current_component]
|
|
@@ -103,7 +106,9 @@ module Decidim
|
|
|
103
106
|
Decidim::Permissions
|
|
104
107
|
]
|
|
105
108
|
|
|
106
|
-
if object.is_a?(Decidim::
|
|
109
|
+
if object.is_a?(Decidim::Participable)
|
|
110
|
+
permissions.unshift(object.manifest.permissions_class)
|
|
111
|
+
elsif object.is_a?(Decidim::Component)
|
|
107
112
|
permissions.unshift(object.participatory_space.manifest.permissions_class)
|
|
108
113
|
permissions.unshift(object.manifest.permissions_class)
|
|
109
114
|
else
|
|
@@ -5,6 +5,97 @@ module Decidim
|
|
|
5
5
|
# This type represents the root query type of the whole API.
|
|
6
6
|
class QueryType < Decidim::Api::Types::BaseObject
|
|
7
7
|
description "The root query of this schema"
|
|
8
|
+
|
|
9
|
+
field :component, Decidim::Core::ComponentInterface, null: true do
|
|
10
|
+
description "Lists the components this space contains."
|
|
11
|
+
argument :id, GraphQL::Types::ID, required: true, description: "The ID of the component to be found"
|
|
12
|
+
end
|
|
13
|
+
field :decidim, Core::DecidimType, "Decidim's framework properties.", null: true
|
|
14
|
+
field :moderated_users, type: [Decidim::Core::UserModerationType], null: true,
|
|
15
|
+
description: "The moderated users for the current organization"
|
|
16
|
+
field :moderations, type: [Decidim::Core::ModerationType], null: true,
|
|
17
|
+
description: "The moderation for the current organization"
|
|
18
|
+
field :organization, Core::OrganizationType, "The current organization", null: true
|
|
19
|
+
field :participant_details, type: Decidim::Core::ParticipantDetailsType, null: true do
|
|
20
|
+
description "Participant details visible to admin users only"
|
|
21
|
+
argument :id, GraphQL::Types::ID, "The ID of the participant", required: true
|
|
22
|
+
argument :nickname, GraphQL::Types::String, "The @nickname of the participant", required: false
|
|
23
|
+
end
|
|
24
|
+
field :session, Core::SessionType, description: "Return's information about the logged in user", null: true
|
|
25
|
+
field :static_page_topics, type: [Decidim::Core::StaticPageTopicType], null: true,
|
|
26
|
+
description: "The static page topics for the current organization"
|
|
27
|
+
field :static_pages, type: [Decidim::Core::StaticPageType], null: true,
|
|
28
|
+
description: "The static pages for the current organization"
|
|
29
|
+
field :user,
|
|
30
|
+
type: Core::UserType, null: true,
|
|
31
|
+
description: "A participant (user or group) in the current organization" do
|
|
32
|
+
argument :id, GraphQL::Types::ID, "The ID of the participant", required: false
|
|
33
|
+
argument :nickname, GraphQL::Types::String, "The @nickname of the participant", required: false
|
|
34
|
+
end
|
|
35
|
+
field :users,
|
|
36
|
+
type: [Core::UserType], null: true,
|
|
37
|
+
description: "The participants (users or groups) for the current organization" do
|
|
38
|
+
argument :filter, Decidim::Core::UserEntityInputFilter, "Provides several methods to filter the results", required: false
|
|
39
|
+
argument :order, Decidim::Core::UserEntityInputSort, "Provides several methods to order the results", required: false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def component(id: {})
|
|
43
|
+
component = Decidim::Component.published.find_by(id:)
|
|
44
|
+
component&.organization == context[:current_organization] ? component : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def session
|
|
48
|
+
context[:current_user]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def decidim
|
|
52
|
+
Decidim
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def organization
|
|
56
|
+
context[:current_organization]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def user(id: nil, nickname: nil)
|
|
60
|
+
Core::UserEntityFinder.new.call(object, { id:, nickname: }, context)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def users(filter: {}, order: {})
|
|
64
|
+
Core::UserEntityList.new.call(object, { filter:, order: }, context)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def participant_details(id: nil, nickname: nil)
|
|
68
|
+
participant = Decidim::Core::UserEntityFinder.new.call(object, { id:, nickname: }, context)
|
|
69
|
+
return nil unless participant
|
|
70
|
+
|
|
71
|
+
return nil unless Decidim::Core::ParticipantDetailsType.authorized?(participant, context)
|
|
72
|
+
|
|
73
|
+
Decidim::ActionLogger.log(
|
|
74
|
+
"read",
|
|
75
|
+
context[:current_user],
|
|
76
|
+
participant,
|
|
77
|
+
nil,
|
|
78
|
+
{}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
participant
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def static_pages
|
|
85
|
+
Decidim::StaticPage.accessible_for(organization, context[:current_user])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def static_page_topics
|
|
89
|
+
static_pages.collect(&:topic).uniq.compact_blank
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def moderated_users
|
|
93
|
+
Decidim::UserModeration.joins(:user).where(decidim_users: { decidim_organization_id: organization&.id }).where.not(decidim_users: { blocked_at: nil })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def moderations
|
|
97
|
+
Decidim::Moderation.where(participatory_space: organization.participatory_spaces).includes(:reports).hidden
|
|
98
|
+
end
|
|
8
99
|
end
|
|
9
100
|
end
|
|
10
101
|
end
|
data/lib/decidim/api/schema.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Decidim
|
|
|
7
7
|
mutation(MutationType)
|
|
8
8
|
query(QueryType)
|
|
9
9
|
|
|
10
|
-
introspection
|
|
10
|
+
introspection(IntrospectionAnalyzer)
|
|
11
11
|
query_analyzer RecursionAnalyzer
|
|
12
12
|
query_analyzer AliasAnalyzer
|
|
13
13
|
|
|
@@ -16,6 +16,32 @@ module Decidim
|
|
|
16
16
|
max_complexity Decidim::Api.schema_max_complexity
|
|
17
17
|
|
|
18
18
|
orphan_types(Api.orphan_types)
|
|
19
|
+
|
|
20
|
+
def self.unauthorized_object(error)
|
|
21
|
+
# Add a top-level error to the response instead of returning nil:
|
|
22
|
+
raise Decidim::Api::Errors::UnauthorizedObjectError, I18n.t("decidim.api.errors.unauthorized_object", type: error.type.graphql_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.unauthorized_field(error)
|
|
26
|
+
# Add a top-level error to the response instead of returning nil:
|
|
27
|
+
raise Decidim::Api::Errors::UnauthorizedFieldError, I18n.t("decidim.api.errors.unauthorized_field", type: error.type.graphql_name, field: error.field.graphql_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
rescue_from(ActiveRecord::RecordNotFound) do |_err, _obj, _args, _ctx, field|
|
|
31
|
+
raise Decidim::Api::Errors::NotFoundError, I18n.t("decidim.api.errors.not_found", type: field.type.unwrap.graphql_name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
rescue_from(Decidim::PermissionAction::PermissionNotSetError) do |_err, _obj, _args, _ctx, field|
|
|
35
|
+
raise Decidim::Api::Errors::PermissionNotSetError, I18n.t("decidim.api.errors.permission_not_set", type: field.type.unwrap.graphql_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
rescue_from(I18n::InvalidLocale) do |_err, _obj, _args, _ctx, _field|
|
|
39
|
+
raise Decidim::Api::Errors::InvalidLocaleError, I18n.t("decidim.api.errors.invalid_locale")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
rescue_from(I18n::ArgumentError) do |err, _obj, _args, _ctx, _field|
|
|
43
|
+
raise Decidim::Api::Errors::LocaleError, I18n.t("decidim.api.errors.locale_argument_error", message: err.message)
|
|
44
|
+
end
|
|
19
45
|
end
|
|
20
46
|
end
|
|
21
47
|
end
|