Skip to content

[課題管理]レポートのCSV出力処理をリファクタリングしました #2198

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/Enums/LearningtaskExportType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Enums;

use App\Enums\EnumsBase;

/**
* 課題管理エクスポートタイプ
*/
final class LearningtaskExportType extends EnumsBase
{
// 定数メンバ
const report = 'export_report';

// key/valueの連想配列
const enum = [
self::report => 'レポート提出',
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Plugins\User\Learningtasks\Contracts;

use App\Models\Common\Page;
use App\Models\User\Learningtasks\LearningtasksPosts;

/**
* CSV エクスポート用のデータ行を提供するクラスのためのインターフェース
*/
interface CsvDataProviderInterface
{
/**
* 指定されたコンテキストとカラム定義に基づき、CSVに出力するデータ行を取得する。
*
* 大量データを効率的に扱うため、iterable (配列または Generator) を返すことを推奨。
* 返される配列の各要素(1行分のデータ)は、
* ColumnDefinitionInterface->getHeaders() で返されるヘッダーの
* 順序に対応した「値の配列」であること。
*
* @param ColumnDefinitionInterface $column_definition カラム定義 (ヘッダー順序等の参照用)
* @param LearningtasksPosts $post 課題投稿コンテキスト
* @param Page $page ページコンテキスト
* @param string $site_url サイトURL (ファイルURL等生成用)
* @return iterable<array<int, string|null>> データ行の iterable (各行は値の配列)
*/
public function getRows(
ColumnDefinitionInterface $column_definition,
LearningtasksPosts $post,
Page $page,
string $site_url
): iterable;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ interface RowProcessorExceptionHandlerInterface
// 配列キーを示す定数
public const KEY_OUTCOME = 'outcome';
public const KEY_TYPE = 'type';
public const KEY_LOG_LEVEL = 'logLevel';
public const KEY_LOG_LEVEL = 'log_level';

/**
* 捕捉された例外を処理し、その結果(エラーかスキップか、詳細タイプ、ログレベル)を返す。
Expand Down
145 changes: 145 additions & 0 deletions app/Plugins/User/Learningtasks/Csv/LearningtasksCsvExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

namespace App\Plugins\User\Learningtasks\Csv;

use App\Enums\CsvCharacterCode;
use App\Models\Common\Page;
use App\Models\User\Learningtasks\LearningtasksPosts;
use App\User;
use App\Plugins\User\Learningtasks\Contracts\ColumnDefinitionInterface;
use App\Plugins\User\Learningtasks\Contracts\CsvDataProviderInterface;
use App\Plugins\User\Learningtasks\Repositories\LearningtaskUserRepository;
use App\Utilities\Csv\CsvUtils;
use App\Utilities\File\FileUtils;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
* 汎用的なCSVエクスポート処理を行うクラス
*
* データ取得は CsvDataProviderInterface に、
* カラム定義は ColumnDefinitionInterface に委譲する。
*/
class LearningtasksCsvExporter
{
// --- 依存性 ---
private LearningtasksPosts $learningtask_post;
private Page $page;
private ColumnDefinitionInterface $column_definition;
private CsvDataProviderInterface $data_provider;
private LearningtaskUserRepository $user_repository;

/**
* コンストラクタ
*
* @param LearningtasksPosts $learningtask_post
* @param Page $page
* @param ColumnDefinitionInterface $column_definition
* @param CsvDataProviderInterface $data_provider
* @param LearningtaskUserRepository $user_repository
*/
public function __construct(
LearningtasksPosts $learningtask_post,
Page $page,
ColumnDefinitionInterface $column_definition,
CsvDataProviderInterface $data_provider,
LearningtaskUserRepository $user_repository
) {
$this->learningtask_post = $learningtask_post;
$this->page = $page;
$this->column_definition = $column_definition;
$this->data_provider = $data_provider;
$this->user_repository = $user_repository;
}

/**
* CSVエクスポートを実行し、HTTPレスポンスを返す
*/
public function export(string $site_url, string $character_code): StreamedResponse
{
// 1. ヘッダー行を取得
$header_row = $this->column_definition->getHeaders();

// 2. ファイル名生成
$filename = FileUtils::toValidFilename($this->learningtask_post->post_title . '_Export.csv');

// 3. レスポンスヘッダー (streamDownload が Content-Disposition を主に設定)
// Content-Type は必要に応じて明示的に指定する
$headers = [
'Content-Type' => 'text/csv; charset='. $character_code,
];

// 4. ストリーミング処理のコールバックを定義
$callback = function () use ($site_url, $character_code, $header_row) {
// 出力ストリーム 'php://output' を書き込みモードで開く
$handle = fopen('php://output', 'w');
if ($handle === false) {
Log::error("CSV Export Streaming: Failed to open php://output.");
// ここで処理を中断する(例外を投げるなど)
return;
}

// ロケール設定 (fputcsv の挙動のため念のため)
CsvUtils::setLocale();

// 文字コードに応じた処理: BOM 追加 (UTF-8の場合)
if ($character_code === CsvCharacterCode::utf_8) {
// Excel での互換性のため UTF-8 BOM を先頭に書き込む
fwrite($handle, CsvUtils::bom);
}

// 文字コードに応じた処理: ヘッダー行のエンコーディング変換
if ($character_code === CsvCharacterCode::sjis_win) {
// ヘッダー行を Shift-JIS に変換
$header_row = array_map(
fn($value) => mb_convert_encoding((string)$value, CsvCharacterCode::sjis_win, 'UTF-8'),
$header_row
);
}
// ヘッダー行を CSV として書き込み
fputcsv($handle, $header_row);
// データ行を取得 (DataProvider から iterable で)
$data_rows_iterable = $this->data_provider->getRows(
$this->column_definition,
$this->learningtask_post,
$this->page,
$site_url
);

// データ行を一件ずつ処理して出力ストリームに書き込み
foreach ($data_rows_iterable as $row_array) {
// Shift-JIS で出力する場合の変換
if ($character_code === CsvCharacterCode::sjis_win) {
$row_array = array_map(fn($value) => mb_convert_encoding((string)$value, 'SJIS-win', 'UTF-8'), $row_array);
}

// RFC4180 準拠: fputcsv は基本的なダブルクォートのエスケープは行うが、
// 改行コード等の扱いでより厳密な処理が必要な場合は、自前でエスケープ処理を追加検討。
// (CsvUtils::getResponseCsvData にあった str_replace('"', '""', ...) の処理は fputcsv が行う)
fputcsv($handle, $row_array);
}
// php://output は fclose 不要
};

// 5. ストリーミングダウンロードレスポンスを生成して返す
return response()->streamDownload($callback, $filename, $headers);
}

/**
* ユーザーがCSVエクスポート可能か判定
*
* @param User $user
* @return bool
*/
public function canExport(User $user): bool
{
if ($user->can('role_article_admin')) {
return true;
}
$teachers = $this->user_repository->getTeachers($this->learningtask_post, $this->page);
if ($teachers->contains('id', $user->id)) {
return true;
}
return false;
}
}
176 changes: 176 additions & 0 deletions app/Plugins/User/Learningtasks/DataProviders/ReportCsvDataProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<?php

namespace App\Plugins\User\Learningtasks\DataProviders;

use App\Models\Common\Page;
use App\Models\User\Learningtasks\LearningtasksPosts;
use App\Models\User\Learningtasks\LearningtasksUsersStatuses;
use App\User;
use App\Plugins\User\Learningtasks\Contracts\ColumnDefinitionInterface;
use App\Plugins\User\Learningtasks\Contracts\CsvDataProviderInterface;
use App\Plugins\User\Learningtasks\Repositories\LearningtaskUserRepository;
use Generator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;

/**
* レポート課題のCSVエクスポート用データを提供するクラス
* CsvDataProviderInterface を実装する。
*/
class ReportCsvDataProvider implements CsvDataProviderInterface
{
/**
* ユーザーリポジトリ
* @var LearningtaskUserRepository
*/
private LearningtaskUserRepository $user_repository;

/**
* コンストラクタ
* @param LearningtaskUserRepository $user_repository
*/
public function __construct(LearningtaskUserRepository $user_repository)
{
$this->user_repository = $user_repository;
}

/**
* レポート課題のCSVデータ行を生成して yield する
* (CsvDataProviderInterface の実装)
*
* @param ColumnDefinitionInterface $column_definition カラム定義
* @param LearningtasksPosts $post 課題投稿コンテキスト
* @param Page $page ページコンテキスト
* @param string $site_url サイトURL
* @return Generator<int, array<int, string|null>> Generator オブジェクトを返す
*/
public function getRows(
ColumnDefinitionInterface $column_definition,
LearningtasksPosts $post,
Page $page,
string $site_url
): Generator {
// 1. ヘッダーを取得 (順序の参照用に内部で使う)
$header_columns = $column_definition->getHeaders();
if (empty($header_columns)) {
// ヘッダーがなければ何も yield しない
return;
}

// 2. 対象学生を取得
$students = $this->user_repository->getStudents($post, $page);
if ($students->isEmpty()) {
// 対象がいなければ何も yield しない
return;
}

// 3. 関連ステータスを一括取得・グループ化
$statuses_by_user = $this->fetchAllStatusesGroupedByUser($students, $post);

// 4. 学生ごとにループして行データを yield
foreach ($students as $student) {
$student_statuses = $statuses_by_user->get($student->id, collect());
$submission_eval_pair = $this->findLastSubmissionAndEvaluation($student_statuses);
$submission_count = $student_statuses->where('task_status', 1)->count();

// 一行分のデータを生成 (ヘルパーメソッド利用)
$row_values = $this->generateRowForStudent(
$student,
$submission_eval_pair['last_submission'],
$submission_eval_pair['last_evaluation'],
$submission_count,
$header_columns, // 要求ヘッダーリスト
$site_url
);
// 配列に追加する代わりに yield で返す
yield $row_values;
}
}

/**
* 対象学生全員の関連ステータス(提出・評価)を一括取得し、ユーザーIDでグループ化する
*
* @param Collection $students 対象学生のコレクション
* @param LearningtasksPosts $post 課題投稿コンテキスト
* @return Collection グループ化されたステータスのコレクション
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
private function fetchAllStatusesGroupedByUser(Collection $students, LearningtasksPosts $post): Collection
{
return LearningtasksUsersStatuses::where('post_id', $post->id)
->whereIn('task_status', [1, 2])
->whereIn('user_id', $students->pluck('id'))
->orderBy('id', 'desc')
->get()
->groupBy('user_id');
}

/**
* 学生一人のステータスコレクションから、最新の提出と対応する評価を見つける
*
* @param Collection $student_statuses 学生のステータスコレクション
* @return array 最新の提出と評価を含む配列
*/
private function findLastSubmissionAndEvaluation(Collection $student_statuses): array
{
$student_submissions = $student_statuses->where('task_status', 1);
$student_evaluations = $student_statuses->where('task_status', 2);
$last_submission = $student_submissions->first();
$last_evaluation = null;
if ($last_submission && $student_submissions->count() === $student_evaluations->count()) {
$last_evaluation = $student_evaluations->first();
}
return ['last_submission' => $last_submission, 'last_evaluation' => $last_evaluation];
}

/**
* 学生一人分のCSV行データを生成 (データ生成マップ利用)
*/
private function generateRowForStudent(
User $student,
?LearningtasksUsersStatuses $last_submission,
?LearningtasksUsersStatuses $last_evaluation,
int $submit_count,
array $required_headers,
string $site_url
): array {
$row_values = [];
$data_generators = $this->getColumnDataGenerators(
$student, $last_submission, $last_evaluation, $submit_count, $site_url
);
foreach ($required_headers as $header) {
if (isset($data_generators[$header])) {
$row_values[] = $data_generators[$header]();
} else {
Log::warning("ReportCsvDataProvider: Unknown header '{$header}' requested.");
$row_values[] = null;
}
}
return $row_values; // 値のみの配列
}

/**
* 各カラムのデータ生成ロジック(クロージャ)を連想配列で返すヘルパー
*/
private function getColumnDataGenerators(
User $student,
?LearningtasksUsersStatuses $last_submission,
?LearningtasksUsersStatuses $last_evaluation,
int $submit_count,
string $site_url
): array {
return [
'ログインID' => fn() => $student->userid,
'ユーザ名' => fn() => $student->name,
'提出日時' => fn() => optional($last_submission)->created_at,
'提出回数' => fn() => $submit_count,
'本文' => fn() => optional($last_submission)->comment,
'ファイルURL' => function () use ($last_submission, $site_url) {
$upload_id = optional($last_submission)->upload_id;
return $upload_id ? $site_url . '/file/' . $upload_id : null;
},
'評価' => fn() => optional($last_evaluation)->grade,
'評価コメント' => fn() => optional($last_evaluation)->comment,
];
}
}
Loading