forked from e621ng/e621ng
[Posts] Overhaul destroyed posts (#672)
This commit is contained in:
parent
c186b4ee2f
commit
d9f6653e02
34
app/controllers/admin/destroyed_posts_controller.rb
Normal file
34
app/controllers/admin/destroyed_posts_controller.rb
Normal file
@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class DestroyedPostsController < ApplicationController
|
||||
before_action :admin_only
|
||||
before_action :is_bd_staff_only, only: %i[update]
|
||||
respond_to :html
|
||||
|
||||
def index
|
||||
@destroyed_posts = DestroyedPost.search(search_params).paginate(params[:page], limit: params[:limit])
|
||||
end
|
||||
|
||||
def show
|
||||
redirect_to(admin_destroyed_posts_path(search: { post_id: params[:id] }))
|
||||
end
|
||||
|
||||
def update
|
||||
@destroyed_post = DestroyedPost.find_by!(post_id: params[:id])
|
||||
@destroyed_post.update(dp_params)
|
||||
flash[:notice] = dp_params[:notify] == "true" ? "Re-uploads of that post will now notify admins" : "Re-uploads of that post will no longer notify admins"
|
||||
redirect_to(admin_destroyed_posts_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_params
|
||||
permit_search_params(%i[destroyer_id destroyer_name destroyer_ip_addr uploader_id uploader_name uploader_ip_addr post_id md5])
|
||||
end
|
||||
|
||||
def dp_params
|
||||
params.require(:destroyed_post).permit(:notify)
|
||||
end
|
||||
end
|
||||
end
|
@ -48,7 +48,7 @@ module Moderator
|
||||
|
||||
def expunge
|
||||
@post = ::Post.find(params[:id])
|
||||
@post.expunge!
|
||||
@post.expunge!(reason: params[:reason])
|
||||
respond_with(@post)
|
||||
end
|
||||
|
||||
|
@ -23,6 +23,7 @@ class PostReplacementsController < ApplicationController
|
||||
check_allow_create
|
||||
@post = Post.find(params[:post_id])
|
||||
@post_replacement = @post.replacements.create(create_params.merge(creator_id: CurrentUser.id, creator_ip_addr: CurrentUser.ip_addr))
|
||||
@post_replacement.notify_reupload
|
||||
if @post_replacement.errors.none?
|
||||
flash[:notice] = "Post replacement submitted"
|
||||
end
|
||||
|
@ -354,9 +354,9 @@ Post.initialize_links = function() {
|
||||
});
|
||||
$("#destroy-post-link").on('click', e => {
|
||||
e.preventDefault();
|
||||
if(!confirm("This will permanently delete this post (meaning the file will be deleted). Are you sure you want to delete this post?"))
|
||||
return;
|
||||
Post.destroy($(e.target).data('pid'));
|
||||
const reason = prompt("This will permanently delete this post (meaning the file will be deleted). What is the reason for destroying the post?")
|
||||
if(reason === null) return;
|
||||
Post.destroy($(e.target).data('pid'), reason);
|
||||
});
|
||||
$("#regenerate-image-samples-link").on('click', e => {
|
||||
e.preventDefault();
|
||||
@ -841,13 +841,13 @@ Post.unapprove = function(post_id) {
|
||||
});
|
||||
}
|
||||
|
||||
Post.destroy = function(post_id) {
|
||||
$.post(`/moderator/post/posts/${post_id}/expunge.json`, {}
|
||||
Post.destroy = function(post_id, reason) {
|
||||
$.post(`/moderator/post/posts/${post_id}/expunge.json`, { reason }
|
||||
).fail(data => {
|
||||
var message = $.map(data.responseJSON.errors, function(msg, attr) { return msg; }).join("; ");
|
||||
$(window).trigger("danbooru:error", "Error: " + message);
|
||||
}).done(data => {
|
||||
location.reload();
|
||||
location.href = `/admin/destroyed_posts/${post_id}`;
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -39,6 +39,7 @@
|
||||
@import "specific/bans.scss";
|
||||
@import "specific/blips.scss";
|
||||
@import "specific/comments.scss";
|
||||
@import "specific/destroyed_posts.scss";
|
||||
@import "specific/dmails.scss";
|
||||
@import "specific/edit_history.scss";
|
||||
@import "specific/error.scss";
|
||||
|
5
app/javascript/src/styles/specific/destroyed_posts.scss
Normal file
5
app/javascript/src/styles/specific/destroyed_posts.scss
Normal file
@ -0,0 +1,5 @@
|
||||
#c-admin-destroyed-posts {
|
||||
tr[data-notify=false] {
|
||||
background-color: $negative-record-background;
|
||||
}
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DummyTicket
|
||||
def initialize(accused, post_id)
|
||||
@ticket = Ticket.new(
|
||||
id: 0,
|
||||
created_at: Time.now,
|
||||
updated_at: Time.now,
|
||||
creator_id: User.system.id,
|
||||
disp_id: 0,
|
||||
status: "pending",
|
||||
qtype: "user",
|
||||
reason: "User ##{accused.id} (#{accused.name}) tried to reupload destroyed post ##{post_id}",
|
||||
)
|
||||
end
|
||||
|
||||
def notify
|
||||
@ticket.push_pubsub("create")
|
||||
end
|
||||
end
|
@ -1,5 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DestroyedPost < ApplicationRecord
|
||||
belongs_to :destroyer, class_name: "User"
|
||||
belongs_to :uploader, class_name: "User", optional: true
|
||||
after_update :log_notify_change, if: :saved_change_to_notify?
|
||||
|
||||
def log_notify_change
|
||||
action = notify? ? :enable_post_notifications : :disable_post_notifications
|
||||
StaffAuditLog.log(action, CurrentUser.user, { destroyed_post_id: id, post_id: post_id })
|
||||
end
|
||||
|
||||
module SearchMethods
|
||||
def search(params)
|
||||
q = super
|
||||
|
||||
q = q.where_user(:destroyer_id, :destroyer, params)
|
||||
q = q.where_user(:uploader_id, :uploader, params)
|
||||
|
||||
if params[:destroyer_ip_addr].present?
|
||||
q = q.where("destroyer_ip_addr <<= ?", params[:destroyer_ip_addr])
|
||||
end
|
||||
|
||||
if params[:uploader_ip_addr].present?
|
||||
q = q.where("uploader_ip_addr <<= ?", params[:uploader_ip_addr])
|
||||
end
|
||||
|
||||
if params[:post_id].present?
|
||||
q = q.attribute_matches(:post_id, params[:post_id])
|
||||
end
|
||||
|
||||
if params[:md5].present?
|
||||
q = q.attribute_matches(:md5, params[:md5])
|
||||
end
|
||||
|
||||
q.apply_basic_order(params)
|
||||
end
|
||||
end
|
||||
|
||||
extend SearchMethods
|
||||
|
||||
def notify_reupload(uploader, replacement_post_id: nil)
|
||||
return if notify == false
|
||||
reason = "User tried to re-upload \"previously destroyed post ##{post_id}\":/admin/destroyed_posts/#{post_id}"
|
||||
reason += " as a replacement for post ##{replacement_post_id}" if replacement_post_id.present?
|
||||
Ticket.create!(
|
||||
creator_id: User.system.id,
|
||||
creator_ip_addr: "127.0.0.1",
|
||||
disp_id: uploader.id,
|
||||
status: "pending",
|
||||
qtype: "user",
|
||||
reason: reason,
|
||||
).push_pubsub("create")
|
||||
end
|
||||
end
|
||||
|
@ -1133,7 +1133,7 @@ class Post < ApplicationRecord
|
||||
end
|
||||
|
||||
module DeletionMethods
|
||||
def backup_post_data_destroy
|
||||
def backup_post_data_destroy(reason: "")
|
||||
post_data = {
|
||||
id: id,
|
||||
description: description,
|
||||
@ -1157,17 +1157,17 @@ class Post < ApplicationRecord
|
||||
DestroyedPost.create!(post_id: id, post_data: post_data, md5: md5,
|
||||
uploader_ip_addr: uploader_ip_addr, uploader_id: uploader_id,
|
||||
destroyer_id: CurrentUser.id, destroyer_ip_addr: CurrentUser.ip_addr,
|
||||
upload_date: created_at)
|
||||
upload_date: created_at, reason: reason || "")
|
||||
end
|
||||
|
||||
def expunge!
|
||||
def expunge!(reason: "")
|
||||
if is_status_locked?
|
||||
self.errors.add(:is_status_locked, "; cannot delete post")
|
||||
return false
|
||||
end
|
||||
|
||||
transaction do
|
||||
backup_post_data_destroy
|
||||
backup_post_data_destroy(reason: reason)
|
||||
end
|
||||
|
||||
transaction do
|
||||
|
@ -6,7 +6,7 @@ class PostReplacement < ApplicationRecord
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :approver, class_name: "User", optional: true
|
||||
belongs_to :uploader_on_approve, class_name: "User", foreign_key: :uploader_id_on_approve, optional: true
|
||||
attr_accessor :replacement_file, :replacement_url, :tags, :is_backup
|
||||
attr_accessor :replacement_file, :replacement_url, :tags, :is_backup, :is_destroyed_reupload
|
||||
|
||||
validate :user_is_not_limited, on: :create
|
||||
validate :post_is_valid, on: :create
|
||||
@ -33,6 +33,13 @@ class PostReplacement < ApplicationRecord
|
||||
Addressable::URI.heuristic_parse(replacement_url) rescue nil
|
||||
end
|
||||
|
||||
def notify_reupload
|
||||
return unless is_destroyed_reupload
|
||||
if (destroyed_post = DestroyedPost.find_by(md5: md5))
|
||||
destroyed_post.notify_reupload(creator, replacement_post_id: post_id)
|
||||
end
|
||||
end
|
||||
|
||||
module PostMethods
|
||||
def post_is_valid
|
||||
if post.is_deleted?
|
||||
@ -45,9 +52,9 @@ class PostReplacement < ApplicationRecord
|
||||
def no_pending_duplicates
|
||||
return true if is_backup
|
||||
|
||||
if (destroyed_post = DestroyedPost.find_by(md5: md5))
|
||||
errors.add(:base, "An unexpected errror occured")
|
||||
DummyTicket.new(creator, destroyed_post.post_id).notify
|
||||
if DestroyedPost.find_by(md5: md5)
|
||||
errors.add(:base, "That image had been deleted from our site, and cannot be re-uploaded")
|
||||
self.is_destroyed_reupload = true
|
||||
return
|
||||
end
|
||||
|
||||
|
@ -187,6 +187,7 @@ class Ticket < ApplicationRecord
|
||||
end
|
||||
|
||||
def validate_creator_is_not_limited
|
||||
return if creator == User.system
|
||||
allowed = creator.can_ticket_with_reason
|
||||
if allowed != true
|
||||
errors.add(:creator, User.throttle_reason(allowed))
|
||||
@ -330,6 +331,7 @@ class Ticket < ApplicationRecord
|
||||
|
||||
module NotificationMethods
|
||||
def create_dmail
|
||||
return if creator == User.system
|
||||
should_send = saved_change_to_status? || (send_update_dmail.to_s.truthy? && saved_change_to_response?)
|
||||
return unless should_send
|
||||
|
||||
|
@ -178,8 +178,8 @@ class Upload < ApplicationRecord
|
||||
end
|
||||
|
||||
if (destroyed_post = DestroyedPost.find_by(md5: md5))
|
||||
errors.add(:base, "An unexpected errror occured")
|
||||
DummyTicket.new(uploader, destroyed_post.post_id).notify
|
||||
errors.add(:base, "That image had been deleted from our site, and cannot be re-uploaded")
|
||||
destroyed_post.notify_reupload(uploader)
|
||||
return
|
||||
end
|
||||
|
||||
|
70
app/views/admin/destroyed_posts/index.html.erb
Normal file
70
app/views/admin/destroyed_posts/index.html.erb
Normal file
@ -0,0 +1,70 @@
|
||||
<div id="c-admin-destroyed-posts">
|
||||
<div id="a-index">
|
||||
<%= form_search(path: admin_destroyed_posts_path) do |f| %>
|
||||
<%= f.user :destroyer %>
|
||||
<%= f.input :destroyer_ip_addr, label: "Destroyer IP Address" %>
|
||||
<%= f.user :uploader %>
|
||||
<%= f.input :uploader_ip_addr, label: "Uploader IP Address" %>
|
||||
<%= f.input :post_id, label: "Post ID" %>
|
||||
<%= f.input :md5 %>
|
||||
<% end %>
|
||||
|
||||
<table class="striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="4%">ID</th>
|
||||
<th width="12%">Destroyed At</th>
|
||||
<th width="12%">Destroyer</th>
|
||||
<th width="12%">Uploader</th>
|
||||
<th width="20%">MD5</th>
|
||||
<th>Reason</th>
|
||||
<% if CurrentUser.user.is_bd_staff? %>
|
||||
<th width="10%"></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @destroyed_posts.each do |destroyed_post| %>
|
||||
<tr data-notify="<%= destroyed_post.notify %>">
|
||||
<td><%= destroyed_post.post_id %></td>
|
||||
<td><%= compact_time(destroyed_post.created_at) %></td>
|
||||
<td>
|
||||
<%= link_to_user(destroyed_post.destroyer) %><br>
|
||||
<%= link_to_ip(destroyed_post.destroyer_ip_addr) %>
|
||||
</td>
|
||||
<td>
|
||||
<% if destroyed_post.uploader_id.present? %>
|
||||
<%= link_to_user(destroyed_post.uploader) %>
|
||||
<% end %><br>
|
||||
<% if destroyed_post.uploader_ip_addr.present? %>
|
||||
<%= link_to_ip(destroyed_post.uploader_ip_addr) %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<%= destroyed_post.md5 %>
|
||||
</td>
|
||||
<td>
|
||||
<%= destroyed_post.reason %>
|
||||
</td>
|
||||
<% if CurrentUser.user.is_bd_staff? %>
|
||||
<td>
|
||||
<% if destroyed_post.notify %>
|
||||
<%= link_to("Disable Notifications", admin_destroyed_post_path(id: destroyed_post.post_id, destroyed_post: { notify: "false" }), method: :put) %>
|
||||
<% else %>
|
||||
<%= link_to("Enable Notifications", admin_destroyed_post_path(id: destroyed_post.post_id, destroyed_post: { notify: "true" }), method: :put) %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<% content_for(:page_title) do %>
|
||||
Destroyed Posts
|
||||
<% end %>
|
||||
|
||||
<%= numbered_paginator(@destroyed_posts) %>
|
||||
|
||||
</div>
|
||||
</div>
|
@ -116,6 +116,7 @@
|
||||
<li><%= link_to("IP Bans", ip_bans_path) %></li>
|
||||
<li><%= link_to("Alt list", alt_list_admin_users_path) %></li>
|
||||
<li><%= link_to("Stuck DNP tags", new_admin_stuck_dnp_path) %></li>
|
||||
<li><%= link_to("Destroyed Posts", admin_destroyed_posts_path) %></li>
|
||||
<% end %>
|
||||
<li><%= link_to("Upload Whitelist", upload_whitelists_path) %></li>
|
||||
<li><%= link_to("Mod Actions", mod_actions_path) %></li>
|
||||
|
@ -24,6 +24,7 @@ Rails.application.routes.draw do
|
||||
resources :exceptions, only: [:index, :show]
|
||||
resource :reowner, controller: 'reowner', only: [:new, :create]
|
||||
resource :stuck_dnp, controller: "stuck_dnp", only: %i[new create]
|
||||
resources :destroyed_posts, only: %i[index show update]
|
||||
resources :staff_notes, only: [:index]
|
||||
resources :danger_zone, only: [:index] do
|
||||
collection do
|
||||
|
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddReasonAndNotifyToDestroyedPosts < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column(:destroyed_posts, :reason, :string, null: false, default: "")
|
||||
add_column(:destroyed_posts, :notify, :boolean, null: false, default: true)
|
||||
end
|
||||
end
|
@ -407,7 +407,9 @@ CREATE TABLE public.destroyed_posts (
|
||||
upload_date timestamp without time zone,
|
||||
post_data json NOT NULL,
|
||||
created_at timestamp(6) without time zone NOT NULL,
|
||||
updated_at timestamp(6) without time zone NOT NULL
|
||||
updated_at timestamp(6) without time zone NOT NULL,
|
||||
reason character varying DEFAULT ''::character varying NOT NULL,
|
||||
notify boolean DEFAULT true NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@ -4493,6 +4495,7 @@ ALTER TABLE ONLY public.favorites
|
||||
SET search_path TO "$user", public;
|
||||
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20240709134926'),
|
||||
('20240101042716'),
|
||||
('20230531080817'),
|
||||
('20230518182034'),
|
||||
|
@ -22,5 +22,9 @@ FactoryBot.define do
|
||||
factory(:large_jpg_upload) do
|
||||
file { fixture_file_upload("test-large.jpg") }
|
||||
end
|
||||
|
||||
factory(:png_upload) do
|
||||
file { fixture_file_upload("test.png") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -47,5 +47,11 @@ FactoryBot.define do
|
||||
level { 50 }
|
||||
can_approve_posts { true }
|
||||
end
|
||||
|
||||
factory(:bd_staff_user) do
|
||||
level { 50 }
|
||||
can_approve_posts { true }
|
||||
is_bd_staff { true }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
50
test/functional/admin/destroyed_posts_controller_test.rb
Normal file
50
test/functional/admin/destroyed_posts_controller_test.rb
Normal file
@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module Admin
|
||||
class DestroyedPostsControllerTest < ActionDispatch::IntegrationTest
|
||||
context "The destroyed posts controller" do
|
||||
setup do
|
||||
@admin = create(:admin_user)
|
||||
@bd_staff = create(:bd_staff_user)
|
||||
@upload = UploadService.new(attributes_for(:jpg_upload).merge({ uploader: @admin })).start!
|
||||
@post = @upload.post
|
||||
as(@admin) { @post.expunge! }
|
||||
@destroyed_post = DestroyedPost.find_by!(post_id: @post.id)
|
||||
end
|
||||
|
||||
context "index action" do
|
||||
should "render" do
|
||||
get_auth admin_destroyed_posts_path, @admin
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
context "show action" do
|
||||
should "redirect" do
|
||||
get_auth admin_destroyed_post_path(@post), @admin
|
||||
assert_redirected_to(admin_destroyed_posts_path(search: { post_id: @post.id }))
|
||||
end
|
||||
end
|
||||
|
||||
context "update action" do
|
||||
should "work" do
|
||||
assert_difference("StaffAuditLog.count", 1) do
|
||||
put_auth admin_destroyed_post_path(@post), @bd_staff, params: { destroyed_post: { notify: "false" } }
|
||||
assert_redirected_to(admin_destroyed_posts_path)
|
||||
assert_equal(false, @destroyed_post.reload.notify)
|
||||
assert_equal("disable_post_notifications", StaffAuditLog.last.action)
|
||||
end
|
||||
|
||||
assert_difference("StaffAuditLog.count", 1) do
|
||||
put_auth admin_destroyed_post_path(@post), @bd_staff, params: { destroyed_post: { notify: "true" } }
|
||||
assert_redirected_to(admin_destroyed_posts_path)
|
||||
assert_equal(true, @destroyed_post.reload.notify)
|
||||
assert_equal("enable_post_notifications", StaffAuditLog.last.action)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -80,12 +80,20 @@ module Moderator
|
||||
end
|
||||
|
||||
context "expunge action" do
|
||||
should "render" do
|
||||
should "work" do
|
||||
post_auth expunge_moderator_post_post_path(@post), @admin, params: { format: :json }
|
||||
|
||||
assert_response :success
|
||||
assert_equal(false, ::Post.exists?(@post.id))
|
||||
end
|
||||
|
||||
should "work with reason" do
|
||||
post_auth expunge_moderator_post_post_path(@post), @admin, params: { reason: "test", format: :json }
|
||||
|
||||
assert_response :success
|
||||
assert_equal(false, ::Post.exists?(@post.id))
|
||||
assert_equal("test", DestroyedPost.last.reason)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -32,6 +32,34 @@ class PostReplacementsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_equal @response.parsed_body["location"], post_path(@post)
|
||||
end
|
||||
|
||||
context "with a previously destroyed post" do
|
||||
setup do
|
||||
@admin = create(:admin_user)
|
||||
as(@admin) do
|
||||
@replacement.destroy
|
||||
@upload2 = UploadService.new(attributes_for(:png_upload).merge({ uploader: @user })).start!
|
||||
@post2 = @upload2.post
|
||||
@post2.expunge!
|
||||
end
|
||||
end
|
||||
|
||||
should "fail and create ticket" do
|
||||
assert_difference({ "PostReplacement.count" => 0, "Ticket.count" => 1 }) do
|
||||
file = fixture_file_upload("test.png")
|
||||
post_auth post_replacements_path, @user, params: { post_id: @post.id, post_replacement: { replacement_file: file, reason: "test replacement" }, format: :json }
|
||||
Rails.logger.debug PostReplacement.all.map(&:md5).join(", ")
|
||||
end
|
||||
end
|
||||
|
||||
should "fail and not create ticket if notify=false" do
|
||||
DestroyedPost.find_by!(post_id: @post2.id).update_column(:notify, false)
|
||||
assert_difference(%(Post.count Ticket.count), 0) do
|
||||
file = fixture_file_upload("test.png")
|
||||
post_auth post_replacements_path, @user, params: { post_id: @post.id, post_replacement: { replacement_file: file, reason: "test replacement" }, format: :json }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "reject action" do
|
||||
|
@ -111,6 +111,30 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
||||
post_auth uploads_path, @user, params: { upload: { file: file, tag_string: "aaa", rating: "q", source: "aaa" } }
|
||||
end
|
||||
end
|
||||
|
||||
context "with a previously destroyed post" do
|
||||
setup do
|
||||
@admin = create(:admin_user)
|
||||
@upload = UploadService.new(attributes_for(:jpg_upload).merge({ uploader: @user })).start!
|
||||
@post = @upload.post
|
||||
as(@admin) { @post.expunge! }
|
||||
end
|
||||
|
||||
should "fail and create ticket" do
|
||||
assert_difference({ "Post.count" => 0, "Ticket.count" => 1 }) do
|
||||
file = fixture_file_upload("test.jpg")
|
||||
post_auth uploads_path, @user, params: { upload: { file: file, tag_string: "aaa", rating: "q", source: "aaa" } }
|
||||
end
|
||||
end
|
||||
|
||||
should "fail and not create ticket if notify=false" do
|
||||
DestroyedPost.find_by!(post_id: @post.id).update_column(:notify, false)
|
||||
assert_difference(%(Post.count Ticket.count), 0) do
|
||||
file = fixture_file_upload("test.jpg")
|
||||
post_auth uploads_path, @user, params: { upload: { file: file, tag_string: "aaa", rating: "q", source: "aaa" } }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
Loading…
Reference in New Issue
Block a user