audited-serialize 0.0.2 → 1.0.1
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 +26 -1
- data/lib/audited/audit_extensions.rb +41 -0
- data/lib/audited/serialization.rb +42 -147
- data/lib/audited/serialization_changes/array_module.rb +22 -0
- data/lib/audited/serialization_changes/boolean_module.rb +23 -0
- data/lib/audited/serialization_changes/common_module.rb +23 -0
- data/lib/audited/serialization_changes/date_module.rb +29 -0
- data/lib/audited/serialization_changes/datetime_module.rb +29 -0
- data/lib/audited/serialization_changes/enum_module.rb +22 -0
- data/lib/audited/serialization_changes/object_module.rb +29 -0
- data/lib/audited/serialization_changes/related_record_module.rb +47 -0
- data/lib/audited_serialize.rb +1 -1
- metadata +11 -3
- data/lib/audited/original_audit.rb +0 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c13c23cc79d29cf37a1c663634909b2f699bb38a7e69722d0d68c71159d01d96
|
4
|
+
data.tar.gz: a65ad64ea718d380b56711155a13dc2bb7f85fb65143ef52079ae584816960a8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c24d1457e9cf4f2103d38ac8f37bde5799b13bc812cea1a3cded11f5743e8b728761ab267bc6578de42ea21c60e890305f35fe7bdf8dca11df9bc5e507fc0e5f
|
7
|
+
data.tar.gz: 9eacbfb7355928d94a0c948d7063fe5d50d2ccb8f695b173b5718a97a3648b85aa49b7f10d8c9ecfc8b6d082520c52ca09877c89684d0980ede7e02d6dc8f2ab
|
data/README.md
CHANGED
@@ -26,6 +26,31 @@ audit = Audited::Audit.first
|
|
26
26
|
audit.changes_list
|
27
27
|
```
|
28
28
|
|
29
|
+
## Заголовки аудит-логов
|
30
|
+
|
31
|
+
На экземплярах audit-класса доступен метод `.title` для вывода заголовка c информацией о том, какое действие на какой сущности было совершено
|
32
|
+
|
33
|
+
```
|
34
|
+
audit = Audited::Audit.first
|
35
|
+
audit.title
|
36
|
+
|
37
|
+
=> "Изменение пользователя"
|
38
|
+
```
|
39
|
+
|
40
|
+
## Заголовки связанных записей
|
41
|
+
|
42
|
+
При наличии в аудит-логах полей, содержащих ID связанных сущностей, по умолчанию будет отображено название (поле `name`) этой сущности. При отсутствии у сущности поля `name` или необходимости отображения значения другого поля можно задать название этого поля в модели
|
43
|
+
|
44
|
+
```
|
45
|
+
# app/models/user.rb
|
46
|
+
|
47
|
+
class User < ApplicationRecord
|
48
|
+
|
49
|
+
audit title: :email
|
50
|
+
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
29
54
|
## Конфигурация
|
30
55
|
|
31
56
|
Изменение настроек сериализации осуществляется путем редактирования стандартного файла конфигурации `Audited`
|
@@ -38,7 +63,7 @@ Audited.config do |config|
|
|
38
63
|
end
|
39
64
|
```
|
40
65
|
|
41
|
-
|
66
|
+
## Исключения
|
42
67
|
|
43
68
|
Для добавления исключений для полей сущностей, которые не будут отображаться в списке добавьте в конфиг `serialization_exceptions`.
|
44
69
|
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'audited/serialization'
|
2
|
+
require 'audited/serialization_changes/array_module'
|
3
|
+
require 'audited/serialization_changes/boolean_module'
|
4
|
+
require 'audited/serialization_changes/common_module'
|
5
|
+
require 'audited/serialization_changes/date_module'
|
6
|
+
require 'audited/serialization_changes/datetime_module'
|
7
|
+
require 'audited/serialization_changes/enum_module'
|
8
|
+
require 'audited/serialization_changes/object_module'
|
9
|
+
require 'audited/serialization_changes/related_record_module'
|
10
|
+
|
11
|
+
module Audited
|
12
|
+
class Audit < ::ActiveRecord::Base
|
13
|
+
|
14
|
+
include Audited::Serialization
|
15
|
+
include Audited::SerializationChanges::ArrayModule
|
16
|
+
include Audited::SerializationChanges::BooleanModule
|
17
|
+
include Audited::SerializationChanges::CommonModule
|
18
|
+
include Audited::SerializationChanges::DateModule
|
19
|
+
include Audited::SerializationChanges::DatetimeModule
|
20
|
+
include Audited::SerializationChanges::EnumModule
|
21
|
+
include Audited::SerializationChanges::ObjectModule
|
22
|
+
include Audited::SerializationChanges::RelatedRecordModule
|
23
|
+
|
24
|
+
scope :own_and_associated_for, (lambda do |record|
|
25
|
+
where('
|
26
|
+
(auditable_type = :type AND auditable_id = :id)
|
27
|
+
OR
|
28
|
+
(associated_type = :type AND associated_id = :id)',
|
29
|
+
type: record.class.to_s, id: record.id
|
30
|
+
).order(id: :desc)
|
31
|
+
end)
|
32
|
+
|
33
|
+
def title
|
34
|
+
operation = I18n.t("audited.actions.#{action}")
|
35
|
+
entity = I18n.t("audited.auditable_types.#{auditable_type}")
|
36
|
+
|
37
|
+
"#{operation} #{entity}"
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
@@ -4,21 +4,29 @@ module Audited
|
|
4
4
|
extend ActiveSupport::Concern
|
5
5
|
|
6
6
|
def changes_list
|
7
|
-
|
7
|
+
# проверка на наличие модели
|
8
|
+
# в случае ее отсутствия, верятнее всего она была удалена
|
9
|
+
begin
|
10
|
+
model = auditable_type.constantize
|
11
|
+
rescue NameError
|
12
|
+
return
|
13
|
+
end
|
14
|
+
|
8
15
|
columns_hash = model.columns_hash
|
9
|
-
@reflections = model
|
16
|
+
@reflections = model_reflections(model)
|
10
17
|
|
11
18
|
audited_changes.map do |column, value|
|
12
|
-
@column = column
|
13
|
-
|
14
19
|
# пропускаем, если колонка в списке исключений
|
15
|
-
next if Audited.serialization_exceptions[auditable_type]&.include?(
|
20
|
+
next if Audited.serialization_exceptions[auditable_type]&.include?(column)
|
16
21
|
|
17
|
-
|
22
|
+
@column = column
|
18
23
|
@db_column = columns_hash[column]
|
19
24
|
|
25
|
+
# пропускаем, если колонка теперь отсутствует в БД
|
26
|
+
next unless @db_column
|
27
|
+
|
20
28
|
# русскоязычное название поля
|
21
|
-
|
29
|
+
field = password_changes? ? 'Пароль' : @db_column.comment
|
22
30
|
|
23
31
|
if action == 'update' && value.is_a?(Array)
|
24
32
|
# значения, измененные во время редактирования
|
@@ -28,158 +36,45 @@ module Audited
|
|
28
36
|
@changed = value
|
29
37
|
end
|
30
38
|
|
31
|
-
|
32
|
-
|
33
|
-
elsif object?
|
34
|
-
json_changes
|
35
|
-
elsif array?
|
36
|
-
array_changes
|
37
|
-
elsif password?
|
38
|
-
password_changes
|
39
|
-
elsif date?
|
40
|
-
date_changes
|
41
|
-
elsif datetime?
|
42
|
-
datetime_changes
|
43
|
-
elsif boolean?
|
44
|
-
boolean_changes
|
45
|
-
elsif enum_changes?
|
46
|
-
enum_changes
|
47
|
-
else
|
48
|
-
common_column_changes
|
49
|
-
end
|
39
|
+
# определение изменений было/стало
|
40
|
+
from, to = changes_from_to
|
50
41
|
|
51
|
-
|
42
|
+
# пропускаем, если изменения не зафиксированы
|
43
|
+
next if from.blank? && to.blank?
|
52
44
|
|
53
45
|
{
|
54
|
-
field:
|
55
|
-
from: @from,
|
56
|
-
to: @to
|
46
|
+
field:, from:, to:
|
57
47
|
}
|
58
48
|
end.compact
|
59
49
|
end
|
60
50
|
|
61
51
|
private
|
62
52
|
|
63
|
-
def
|
64
|
-
|
65
|
-
end
|
66
|
-
|
67
|
-
def
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
def date?
|
85
|
-
@initial&.to_date&.acts_like?(:date) && @initial.to_s.include?('-') && @initial.to_s.exclude?(':') rescue false
|
86
|
-
@changed&.to_date&.acts_like?(:date) && @changed.to_s.include?('-') && @changed.to_s.exclude?(':') rescue false
|
87
|
-
end
|
88
|
-
|
89
|
-
def boolean?
|
90
|
-
bool_classes = [TrueClass, FalseClass]
|
91
|
-
|
92
|
-
bool_classes.include?(@initial.class) || bool_classes.include?(@changed.class)
|
93
|
-
end
|
94
|
-
|
95
|
-
def enum_changes?
|
96
|
-
@db_column.type == :enum
|
97
|
-
end
|
98
|
-
|
99
|
-
def related_record_changes
|
100
|
-
# модель связанной сущности от колонки
|
101
|
-
related_model = @reflections[@column]
|
102
|
-
|
103
|
-
# заданные/измененные значения
|
104
|
-
@from = related_record_value(related_model, @initial)
|
105
|
-
@to = related_record_value(related_model, @changed)
|
106
|
-
end
|
107
|
-
|
108
|
-
def related_record_value(related_model, value)
|
109
|
-
if value
|
110
|
-
record = related_model.find_by(id: value)
|
111
|
-
auditing_title(record) || "Запись ##{value}"
|
53
|
+
def model_reflections(model)
|
54
|
+
model.reflections.to_h { |_rel, reflection| [reflection.foreign_key, reflection.klass] }
|
55
|
+
end
|
56
|
+
|
57
|
+
def changes_from_to
|
58
|
+
if related_record_changes?
|
59
|
+
related_record_changes
|
60
|
+
elsif object_changes?
|
61
|
+
object_changes
|
62
|
+
elsif array_changes?
|
63
|
+
array_changes
|
64
|
+
elsif password_changes?
|
65
|
+
password_changes
|
66
|
+
elsif date_changes?
|
67
|
+
date_changes
|
68
|
+
elsif datetime_changes?
|
69
|
+
datetime_changes
|
70
|
+
elsif boolean_changes?
|
71
|
+
boolean_changes
|
72
|
+
elsif enum_changes?
|
73
|
+
enum_changes
|
112
74
|
else
|
113
|
-
|
75
|
+
common_column_changes
|
114
76
|
end
|
115
77
|
end
|
116
78
|
|
117
|
-
def auditing_title(record)
|
118
|
-
return unless record
|
119
|
-
|
120
|
-
if record.is_a?(House)
|
121
|
-
record.number
|
122
|
-
elsif record.klass.column_names.include?('name')
|
123
|
-
record.name
|
124
|
-
else
|
125
|
-
"##{record.id}"
|
126
|
-
end
|
127
|
-
end
|
128
|
-
|
129
|
-
def enum_changes
|
130
|
-
enum_type = @db_column.sql_type
|
131
|
-
values = I18n.t("enums.#{enum_type}").stringify_keys
|
132
|
-
|
133
|
-
@from = values[@initial]
|
134
|
-
@to = values[@changed]
|
135
|
-
end
|
136
|
-
|
137
|
-
def json_changes
|
138
|
-
# удаление дубликатов ключей/значений
|
139
|
-
initial = (@initial.to_a - @changed.to_a).to_h
|
140
|
-
changed = (@changed.to_a - @initial.to_a).to_h
|
141
|
-
|
142
|
-
@from = initial.map { |k, v| "#{k.humanize}: #{v}" }
|
143
|
-
@to = changed.map { |k, v| "#{k.humanize}: #{v}" }
|
144
|
-
end
|
145
|
-
|
146
|
-
def array_changes
|
147
|
-
@from = @initial&.join(', ')
|
148
|
-
@to = @changed&.join(', ')
|
149
|
-
end
|
150
|
-
|
151
|
-
def password_changes
|
152
|
-
@field = 'Пароль'
|
153
|
-
@from = nil
|
154
|
-
@to = '<новый>'
|
155
|
-
end
|
156
|
-
|
157
|
-
def date_changes
|
158
|
-
@from = Date.parse(@initial).strftime('%d.%m.%Y') if @initial.present?
|
159
|
-
@to = Date.parse(@changed).strftime('%d.%m.%Y') if @changed.present?
|
160
|
-
end
|
161
|
-
|
162
|
-
def datetime_changes
|
163
|
-
if @initial.present?
|
164
|
-
@from = DateTime.parse(@initial).in_time_zone('Europe/Moscow').strftime('%d.%m.%Y %H:%M')
|
165
|
-
end
|
166
|
-
|
167
|
-
if @changed.present?
|
168
|
-
@to = DateTime.parse(@changed).in_time_zone('Europe/Moscow').strftime('%d.%m.%Y %H:%M')
|
169
|
-
end
|
170
|
-
end
|
171
|
-
|
172
|
-
def boolean_changes
|
173
|
-
values = { true => 'Да', false => 'Нет' }
|
174
|
-
|
175
|
-
@from = values[@initial]
|
176
|
-
@to = values[@changed]
|
177
|
-
end
|
178
|
-
|
179
|
-
def common_column_changes
|
180
|
-
@from = @initial
|
181
|
-
@to = @changed
|
182
|
-
end
|
183
|
-
|
184
79
|
end
|
185
80
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module ArrayModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def array_changes?
|
10
|
+
@initial.is_a?(Array) || @changed.is_a?(Array)
|
11
|
+
end
|
12
|
+
|
13
|
+
def array_changes
|
14
|
+
from = @initial&.join(', ')
|
15
|
+
to = @changed&.join(', ')
|
16
|
+
|
17
|
+
return from, to
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module BooleanModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def boolean_changes?
|
10
|
+
classes = [TrueClass, FalseClass]
|
11
|
+
|
12
|
+
classes.include?(@initial.class) || classes.include?(@changed.class)
|
13
|
+
end
|
14
|
+
|
15
|
+
def boolean_changes
|
16
|
+
values = { true => 'Да', false => 'Нет' }
|
17
|
+
|
18
|
+
return values[@initial], values[@changed]
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module CommonModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def password_changes?
|
10
|
+
@column == 'encrypted_password'
|
11
|
+
end
|
12
|
+
|
13
|
+
def password_changes
|
14
|
+
return nil, '<новый>'
|
15
|
+
end
|
16
|
+
|
17
|
+
def common_column_changes
|
18
|
+
return @initial, @changed
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module DateModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def date_changes?
|
10
|
+
@initial&.to_date&.acts_like?(:date) && @initial.to_s.include?('-') && @initial.to_s.exclude?(':') rescue false
|
11
|
+
@changed&.to_date&.acts_like?(:date) && @changed.to_s.include?('-') && @changed.to_s.exclude?(':') rescue false
|
12
|
+
end
|
13
|
+
|
14
|
+
def date_changes
|
15
|
+
from = date_change_formatted(@initial)
|
16
|
+
to = date_change_formatted(@changed)
|
17
|
+
|
18
|
+
return from, to
|
19
|
+
end
|
20
|
+
|
21
|
+
def date_change_formatted(value)
|
22
|
+
return if value.blank?
|
23
|
+
|
24
|
+
Date.parse(value).strftime('%d.%m.%Y')
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module DatetimeModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def datetime_changes?
|
10
|
+
@initial&.to_date&.acts_like?(:date) && @initial.include?(':') rescue false
|
11
|
+
@changed&.to_date&.acts_like?(:date) && @changed.include?(':') rescue false
|
12
|
+
end
|
13
|
+
|
14
|
+
def datetime_changes
|
15
|
+
from = datetime_change_formatted(@initial)
|
16
|
+
to = datetime_change_formatted(@changed)
|
17
|
+
|
18
|
+
return from, to
|
19
|
+
end
|
20
|
+
|
21
|
+
def datetime_change_formatted(value)
|
22
|
+
return if value.blank?
|
23
|
+
|
24
|
+
DateTime.parse(value).in_time_zone('Europe/Moscow').strftime('%d.%m.%Y %H:%M')
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module EnumModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def enum_changes?
|
10
|
+
@db_column.type == :enum
|
11
|
+
end
|
12
|
+
|
13
|
+
def enum_changes
|
14
|
+
type = @db_column.sql_type
|
15
|
+
values = I18n.t("enums.#{type}").stringify_keys
|
16
|
+
|
17
|
+
return values[@initial], values[@changed]
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module ObjectModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def object_changes?
|
10
|
+
@initial.is_a?(Hash) || @changed.is_a?(Hash)
|
11
|
+
end
|
12
|
+
|
13
|
+
def object_changes
|
14
|
+
from = object_change_formatted(@initial, @changed)
|
15
|
+
to = object_change_formatted(@changed, @initial)
|
16
|
+
|
17
|
+
return from, to
|
18
|
+
end
|
19
|
+
|
20
|
+
def object_change_formatted(from, to)
|
21
|
+
# удаление дубликатов ключей/значений
|
22
|
+
hash = (from.to_a - to.to_a).to_h
|
23
|
+
|
24
|
+
hash.map { |key, val| "#{key.humanize}: #{val}" }
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Audited
|
2
|
+
module SerializationChanges
|
3
|
+
module RelatedRecordModule
|
4
|
+
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def related_record_changes?
|
10
|
+
@reflections[@column].present?
|
11
|
+
end
|
12
|
+
|
13
|
+
def related_record_changes
|
14
|
+
# модель связанной сущности от колонки
|
15
|
+
related_model = @reflections[@column]
|
16
|
+
|
17
|
+
# заданные/измененные значения
|
18
|
+
from = related_record_value(related_model, @initial)
|
19
|
+
to = related_record_value(related_model, @changed)
|
20
|
+
|
21
|
+
return from, to
|
22
|
+
end
|
23
|
+
|
24
|
+
def related_record_value(related_model, related_record_id)
|
25
|
+
return '' unless related_record_id
|
26
|
+
|
27
|
+
record = related_model.find_by(id: related_record_id)
|
28
|
+
return "Запись ##{related_record_id}" unless record
|
29
|
+
|
30
|
+
related_record_auditing_title(record)
|
31
|
+
end
|
32
|
+
|
33
|
+
def related_record_auditing_title(record)
|
34
|
+
audited_title = record.class.audited_options[:title]
|
35
|
+
|
36
|
+
if audited_title
|
37
|
+
record.send(audited_title)
|
38
|
+
elsif record.class.column_names.include?('name')
|
39
|
+
record.name
|
40
|
+
else
|
41
|
+
"##{record.id}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/audited_serialize.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: audited-serialize
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 1.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Павел Бабин
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-03-
|
11
|
+
date: 2024-03-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: audited
|
@@ -32,8 +32,16 @@ extra_rdoc_files: []
|
|
32
32
|
files:
|
33
33
|
- README.md
|
34
34
|
- lib/audited-serialize.rb
|
35
|
-
- lib/audited/
|
35
|
+
- lib/audited/audit_extensions.rb
|
36
36
|
- lib/audited/serialization.rb
|
37
|
+
- lib/audited/serialization_changes/array_module.rb
|
38
|
+
- lib/audited/serialization_changes/boolean_module.rb
|
39
|
+
- lib/audited/serialization_changes/common_module.rb
|
40
|
+
- lib/audited/serialization_changes/date_module.rb
|
41
|
+
- lib/audited/serialization_changes/datetime_module.rb
|
42
|
+
- lib/audited/serialization_changes/enum_module.rb
|
43
|
+
- lib/audited/serialization_changes/object_module.rb
|
44
|
+
- lib/audited/serialization_changes/related_record_module.rb
|
37
45
|
- lib/audited_config.rb
|
38
46
|
- lib/audited_serialize.rb
|
39
47
|
homepage:
|