このコミットが含まれているのは:
@@ -379,12 +379,20 @@ class TagsController < ApplicationController
|
||||
end
|
||||
|
||||
def visible_root_tag_ids graph
|
||||
graph[:tags_by_id].filter_map do |tag_id, _attrs|
|
||||
root_ids = Set.new
|
||||
|
||||
graph[:tags_by_id].each do |tag_id, attrs|
|
||||
next unless visible_root_tag?(tag_id, graph)
|
||||
next unless visible_subtree?(tag_id, graph)
|
||||
|
||||
tag_id
|
||||
if attrs[:deprecated]
|
||||
collect_visible_child_tag_ids(tag_id, graph, root_ids, Set.new)
|
||||
else
|
||||
root_ids << tag_id
|
||||
end
|
||||
end
|
||||
|
||||
root_ids.to_a
|
||||
end
|
||||
|
||||
def visible_root_tag? tag_id, graph
|
||||
|
||||
@@ -44,6 +44,12 @@ class MaterialSyncImporter
|
||||
:update
|
||||
end
|
||||
uploaded_blob = uploaded_blob!
|
||||
if @import_block
|
||||
return Result.new(material: nil,
|
||||
action: :suppressed,
|
||||
suppressed: true,
|
||||
suppression: @import_block)
|
||||
end
|
||||
|
||||
Material.transaction do
|
||||
if material.persisted?
|
||||
@@ -129,11 +135,19 @@ class MaterialSyncImporter
|
||||
return nil unless tempfile
|
||||
|
||||
tempfile.rewind
|
||||
file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest
|
||||
@attributes[:file_sha256] = file_sha256
|
||||
|
||||
@import_block = MaterialImportBlockMatcher.match_for_sha256(file_sha256)
|
||||
if @import_block
|
||||
tempfile.rewind
|
||||
return nil
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: tempfile,
|
||||
filename: @attributes[:filename],
|
||||
content_type: @attributes[:content_type])
|
||||
file_sha256 = @attributes[:file_sha256] || Digest::SHA256.file(tempfile.path).hexdigest
|
||||
blob.metadata['sha256'] = file_sha256 if file_sha256.present?
|
||||
blob.save! if blob.changed?
|
||||
tempfile.rewind
|
||||
|
||||
@@ -2,9 +2,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
change_table :materials, bulk: true do |t|
|
||||
t.integer :version_no, null: false, default: 1
|
||||
t.datetime :file_suppressed_at
|
||||
t.references :file_suppressed_by_user, foreign_key: { to_table: :users }
|
||||
t.string :file_suppression_reason
|
||||
end
|
||||
|
||||
change_table :material_versions, bulk: true do |t|
|
||||
@@ -18,8 +15,6 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
|
||||
t.bigint :file_byte_size
|
||||
t.string :file_checksum
|
||||
t.string :file_sha256
|
||||
t.datetime :file_suppressed_at
|
||||
t.string :file_suppression_reason
|
||||
end
|
||||
|
||||
execute <<~SQL.squish
|
||||
@@ -35,7 +30,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
|
||||
add_index :material_versions, :file_blob_id
|
||||
|
||||
add_check_constraint :material_versions,
|
||||
"event_type IN ('create', 'update', 'discard', 'restore', 'suppress')",
|
||||
"event_type IN ('create', 'update', 'discard', 'restore')",
|
||||
name: 'material_versions_event_type_valid'
|
||||
|
||||
create_table :material_export_items do |t|
|
||||
@@ -87,12 +82,7 @@ class EnhanceMaterialManagement < ActiveRecord::Migration[8.0]
|
||||
remove_column :material_versions, :file_byte_size
|
||||
remove_column :material_versions, :file_checksum
|
||||
remove_column :material_versions, :file_sha256
|
||||
remove_column :material_versions, :file_suppressed_at
|
||||
remove_column :material_versions, :file_suppression_reason
|
||||
|
||||
remove_reference :materials, :file_suppressed_by_user, foreign_key: { to_table: :users }
|
||||
remove_column :materials, :version_no
|
||||
remove_column :materials, :file_suppressed_at
|
||||
remove_column :materials, :file_suppression_reason
|
||||
end
|
||||
end
|
||||
|
||||
@@ -64,27 +64,36 @@ RSpec.describe 'Materials API', type: :request do
|
||||
'name' => 'material_index_b',
|
||||
'category' => 'material'
|
||||
)
|
||||
expect(row['created_by_user']).to include(
|
||||
'id' => member_user.id,
|
||||
'name' => member_user.name
|
||||
)
|
||||
expect(row['content_type']).to eq('image/png')
|
||||
expect(row['media_kind']).to eq('image')
|
||||
end
|
||||
|
||||
it 'filters materials by tag_id' do
|
||||
get '/materials', params: { tag_id: material_a.tag_id }
|
||||
it 'filters materials by q' do
|
||||
get '/materials', params: { q: 'material_index_a' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['count']).to eq(1)
|
||||
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
|
||||
end
|
||||
|
||||
it 'filters materials by parent_id' do
|
||||
get '/materials', params: { parent_id: material_a.id }
|
||||
it 'filters materials by tag_state' do
|
||||
untagged =
|
||||
build_material(tag: nil, user: member_user,
|
||||
file: dummy_upload(filename: 'untagged.png'))
|
||||
|
||||
get '/materials', params: { tag_state: 'untagged' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['count']).to eq(1)
|
||||
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
|
||||
expect(response_materials.map { |m| m['id'] }).to eq([untagged.id])
|
||||
end
|
||||
|
||||
it 'filters materials by media_kind' do
|
||||
get '/materials', params: { media_kind: 'image' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['count']).to eq(2)
|
||||
expect(response_materials.map { |m| m['media_kind'] }).to all(eq('image'))
|
||||
end
|
||||
|
||||
it 'paginates and keeps total count' do
|
||||
@@ -528,9 +537,8 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(response.body.b).not_to include('素材/b.png'.b)
|
||||
end
|
||||
|
||||
it 'does not include suppressed materials' do
|
||||
material_b.update!(file_suppressed_at: Time.current,
|
||||
file_suppression_reason: 'copyright_high_risk')
|
||||
it 'does not include disabled export items' do
|
||||
material_b.material_export_items.first.update!(enabled: false)
|
||||
|
||||
get '/materials/download.zip', params: { profile: 'legacy_drive' }
|
||||
|
||||
@@ -540,62 +548,78 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /materials/:id/suppress_file' do
|
||||
describe 'GET /materials/versions' do
|
||||
let!(:tag) do
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_suppress'), category: :material)
|
||||
Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material)
|
||||
end
|
||||
let!(:material) do
|
||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'suppress.png'))
|
||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png'))
|
||||
end
|
||||
|
||||
it 'allows admin to suppress a file and records a suppress version' do
|
||||
sign_in_as(admin_user)
|
||||
it 'returns material versions in reverse chronological order' do
|
||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||
created_by_user: member_user)
|
||||
material.update!(url: 'https://example.com/history')
|
||||
MaterialVersionRecorder.record!(material:, event_type: :update,
|
||||
created_by_user: admin_user)
|
||||
|
||||
get '/materials/versions', params: { material_id: material.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['count']).to eq(2)
|
||||
versions = json.fetch('versions')
|
||||
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
|
||||
expect(versions.first).to include(
|
||||
'material_id' => material.id,
|
||||
'event_type' => 'update',
|
||||
'tag_name' => 'material_history',
|
||||
'file_filename' => 'history.png',
|
||||
'url' => 'https://example.com/history'
|
||||
)
|
||||
expect(versions.first['created_by_user']).to include('id' => admin_user.id)
|
||||
end
|
||||
|
||||
it 'filters material versions by tag and event_type' do
|
||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||
created_by_user: member_user)
|
||||
|
||||
expect do
|
||||
patch "/materials/#{ material.id }/suppress_file",
|
||||
params: { reason: 'copyright_high_risk' }
|
||||
end.to change(MaterialVersion, :count).by(1)
|
||||
get '/materials/versions', params: {
|
||||
tag: 'material_history',
|
||||
event_type: 'create'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('versions').map { |v| v['material_id'] }).to include(material.id)
|
||||
expect(json.fetch('versions').map { |v| v['event_type'] }).to all(eq('create'))
|
||||
end
|
||||
end
|
||||
|
||||
material.reload
|
||||
expect(material.file_suppressed_at).to be_present
|
||||
expect(material.file_suppressed_by_user).to eq(admin_user)
|
||||
expect(material.file_suppression_reason).to eq('copyright_high_risk')
|
||||
expect(material.material_versions.order(:version_no).last.event_type).to eq('suppress')
|
||||
expect(json['file']).to be_nil
|
||||
expect(json['file_suppressed_at']).to be_present
|
||||
describe 'material sync suppressions' do
|
||||
it 'requires member permission for index' do
|
||||
sign_out
|
||||
|
||||
get '/materials/suppressions'
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'purges blob when purge=true is requested' do
|
||||
sign_in_as(admin_user)
|
||||
old_blob_id = material.file.blob.id
|
||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||
created_by_user: member_user)
|
||||
|
||||
expect do
|
||||
patch "/materials/#{ material.id }/suppress_file",
|
||||
params: { reason: 'copyright_takedown', purge: '1' }
|
||||
end.to have_enqueued_job(ActiveStorage::PurgeJob)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
version = material.material_versions.order(:version_no).last
|
||||
expect(version.event_type).to eq('suppress')
|
||||
expect(version.file_blob_id).to eq(old_blob_id)
|
||||
expect(version.file_filename).to eq('suppress.png')
|
||||
expect(version.file_sha256).to be_present
|
||||
end
|
||||
|
||||
it 'rejects member suppression' do
|
||||
it 'creates source suppression as member' do
|
||||
sign_in_as(member_user)
|
||||
|
||||
patch "/materials/#{ material.id }/suppress_file",
|
||||
params: { reason: 'copyright_high_risk' }
|
||||
expect do
|
||||
post '/materials/suppressions', params: {
|
||||
source_kind: 'google_drive_path_prefix',
|
||||
drive_path: '伊地知ニジカ/危険素材',
|
||||
reason: 'copyright_high_risk'
|
||||
}
|
||||
end.to change(MaterialSyncSuppression, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(json).to include(
|
||||
'source_kind' => 'google_drive_path_prefix',
|
||||
'drive_path' => '伊地知ニジカ/危険素材',
|
||||
'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MaterialSyncImporter do
|
||||
let(:user) { create(:user, :member) }
|
||||
let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) }
|
||||
|
||||
def tempfile_for body
|
||||
Tempfile.new(['material-sync-importer', '.png']).tap do |file|
|
||||
file.binmode
|
||||
file.write(body)
|
||||
file.rewind
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates an untagged material from a sync candidate with file_downloader' do
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/a.png',
|
||||
source_file_id: 'drive-file-a',
|
||||
normalized_source_key: 'google_drive_file:drive-file-a',
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png',
|
||||
file_sha256: Digest::SHA256.hexdigest('sync-body'),
|
||||
file_downloader: -> { tempfile_for('sync-body') },
|
||||
export_path: '素材/a.png',
|
||||
tag: nil,
|
||||
url: nil,
|
||||
created_by_user: user,
|
||||
updated_by_user: user)
|
||||
|
||||
material = result.material.reload
|
||||
expect(result.action).to eq(:imported)
|
||||
expect(material.tag).to be_nil
|
||||
expect(material.url).to be_nil
|
||||
expect(material.file).to be_attached
|
||||
expect(material.file.blob.filename.to_s).to eq('a.png')
|
||||
expect(material.material_export_items.first.export_path).to eq('素材/a.png')
|
||||
end
|
||||
|
||||
it 'does not clear a human-assigned tag or URL on existing Drive sync' do
|
||||
material =
|
||||
Material.create!(tag:, url: 'https://example.com/human',
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/a.png',
|
||||
source_file_id: 'drive-file-a',
|
||||
created_by_user: user,
|
||||
updated_by_user: user)
|
||||
material.file.attach(
|
||||
io: StringIO.new('same-body'),
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png')
|
||||
material.file.blob.metadata['sha256'] = Digest::SHA256.hexdigest('same-body')
|
||||
material.file.blob.save!
|
||||
MaterialExportItem.create!(material:,
|
||||
profile: 'legacy_drive',
|
||||
export_path: '素材/a.png',
|
||||
created_by_user: user)
|
||||
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/a.png',
|
||||
source_file_id: 'drive-file-a',
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png',
|
||||
file_sha256: Digest::SHA256.hexdigest('same-body'),
|
||||
export_path: '素材/a.png',
|
||||
tag: nil,
|
||||
url: nil,
|
||||
updated_by_user: user)
|
||||
|
||||
expect(result.action).to eq(:unchanged)
|
||||
expect(material.reload.tag).to eq(tag)
|
||||
expect(material.url).to eq('https://example.com/human')
|
||||
end
|
||||
|
||||
it 'suppresses a Google Drive candidate by relative path prefix' do
|
||||
MaterialSyncSuppression.create!(source_kind: 'google_drive_path_prefix',
|
||||
drive_path: '素材/危険',
|
||||
reason: 'copyright_high_risk',
|
||||
created_by_user: user)
|
||||
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/危険/a.png',
|
||||
source_file_id: 'drive-file-b',
|
||||
filename: 'a.png',
|
||||
content_type: 'image/png',
|
||||
file_sha256: Digest::SHA256.hexdigest('blocked-body'),
|
||||
file_downloader: -> { tempfile_for('blocked-body') },
|
||||
export_path: '素材/危険/a.png',
|
||||
updated_by_user: user)
|
||||
|
||||
expect(result.action).to eq(:suppressed)
|
||||
expect(result.suppression.normalized_source_key)
|
||||
.to eq('google_drive_path_prefix:素材/危険')
|
||||
expect(Material.find_by(source_file_id: 'drive-file-b')).to be_nil
|
||||
end
|
||||
|
||||
it 'rechecks import blocks after downloaded file sha256 is known' do
|
||||
MaterialImportBlock.create!(match_kind: 'sha256',
|
||||
sha256: Digest::SHA256.hexdigest('blocked-body'),
|
||||
reason: 'copyright_high_risk',
|
||||
created_by_user: user)
|
||||
blob_count = ActiveStorage::Blob.count
|
||||
|
||||
expect do
|
||||
result =
|
||||
described_class.import!(
|
||||
source_kind: 'google_drive_file',
|
||||
source_path: '素材/blocked.png',
|
||||
source_file_id: 'drive-file-blocked',
|
||||
filename: 'blocked.png',
|
||||
content_type: 'image/png',
|
||||
file_downloader: -> { tempfile_for('blocked-body') },
|
||||
export_path: '素材/blocked.png',
|
||||
updated_by_user: user)
|
||||
|
||||
expect(result.action).to eq(:suppressed)
|
||||
expect(result.suppression.reason).to eq('copyright_high_risk')
|
||||
end.not_to change(Material, :count)
|
||||
|
||||
expect(ActiveStorage::Blob.count).to eq(blob_count)
|
||||
expect(Material.find_by(source_file_id: 'drive-file-blocked')).to be_nil
|
||||
end
|
||||
end
|
||||
新しい課題から参照
ユーザをブロックする