Skip to content

Commit 58f4c1d

Browse files
authored
Merge pull request #2198 from opensource-workshop/refacting-learningtasks-csv-export
[課題管理]レポートのCSV出力処理をリファクタリングしました
2 parents 220edfc + eb30c80 commit 58f4c1d

20 files changed

+1589
-759
lines changed

app/Enums/LearningtaskExportType.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
use App\Enums\EnumsBase;
6+
7+
/**
8+
* 課題管理エクスポートタイプ
9+
*/
10+
final class LearningtaskExportType extends EnumsBase
11+
{
12+
// 定数メンバ
13+
const report = 'export_report';
14+
15+
// key/valueの連想配列
16+
const enum = [
17+
self::report => 'レポート提出',
18+
];
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Plugins\User\Learningtasks\Contracts;
4+
5+
use App\Models\Common\Page;
6+
use App\Models\User\Learningtasks\LearningtasksPosts;
7+
8+
/**
9+
* CSV エクスポート用のデータ行を提供するクラスのためのインターフェース
10+
*/
11+
interface CsvDataProviderInterface
12+
{
13+
/**
14+
* 指定されたコンテキストとカラム定義に基づき、CSVに出力するデータ行を取得する。
15+
*
16+
* 大量データを効率的に扱うため、iterable (配列または Generator) を返すことを推奨。
17+
* 返される配列の各要素(1行分のデータ)は、
18+
* ColumnDefinitionInterface->getHeaders() で返されるヘッダーの
19+
* 順序に対応した「値の配列」であること。
20+
*
21+
* @param ColumnDefinitionInterface $column_definition カラム定義 (ヘッダー順序等の参照用)
22+
* @param LearningtasksPosts $post 課題投稿コンテキスト
23+
* @param Page $page ページコンテキスト
24+
* @param string $site_url サイトURL (ファイルURL等生成用)
25+
* @return iterable<array<int, string|null>> データ行の iterable (各行は値の配列)
26+
*/
27+
public function getRows(
28+
ColumnDefinitionInterface $column_definition,
29+
LearningtasksPosts $post,
30+
Page $page,
31+
string $site_url
32+
): iterable;
33+
}

app/Plugins/User/Learningtasks/Contracts/RowProcessorExceptionHandlerInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ interface RowProcessorExceptionHandlerInterface
2323
// 配列キーを示す定数
2424
public const KEY_OUTCOME = 'outcome';
2525
public const KEY_TYPE = 'type';
26-
public const KEY_LOG_LEVEL = 'logLevel';
26+
public const KEY_LOG_LEVEL = 'log_level';
2727

2828
/**
2929
* 捕捉された例外を処理し、その結果(エラーかスキップか、詳細タイプ、ログレベル)を返す。
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
namespace App\Plugins\User\Learningtasks\Csv;
4+
5+
use App\Enums\CsvCharacterCode;
6+
use App\Models\Common\Page;
7+
use App\Models\User\Learningtasks\LearningtasksPosts;
8+
use App\User;
9+
use App\Plugins\User\Learningtasks\Contracts\ColumnDefinitionInterface;
10+
use App\Plugins\User\Learningtasks\Contracts\CsvDataProviderInterface;
11+
use App\Plugins\User\Learningtasks\Repositories\LearningtaskUserRepository;
12+
use App\Utilities\Csv\CsvUtils;
13+
use App\Utilities\File\FileUtils;
14+
use Illuminate\Support\Facades\Log;
15+
use Symfony\Component\HttpFoundation\StreamedResponse;
16+
17+
/**
18+
* 汎用的なCSVエクスポート処理を行うクラス
19+
*
20+
* データ取得は CsvDataProviderInterface に、
21+
* カラム定義は ColumnDefinitionInterface に委譲する。
22+
*/
23+
class LearningtasksCsvExporter
24+
{
25+
// --- 依存性 ---
26+
private LearningtasksPosts $learningtask_post;
27+
private Page $page;
28+
private ColumnDefinitionInterface $column_definition;
29+
private CsvDataProviderInterface $data_provider;
30+
private LearningtaskUserRepository $user_repository;
31+
32+
/**
33+
* コンストラクタ
34+
*
35+
* @param LearningtasksPosts $learningtask_post
36+
* @param Page $page
37+
* @param ColumnDefinitionInterface $column_definition
38+
* @param CsvDataProviderInterface $data_provider
39+
* @param LearningtaskUserRepository $user_repository
40+
*/
41+
public function __construct(
42+
LearningtasksPosts $learningtask_post,
43+
Page $page,
44+
ColumnDefinitionInterface $column_definition,
45+
CsvDataProviderInterface $data_provider,
46+
LearningtaskUserRepository $user_repository
47+
) {
48+
$this->learningtask_post = $learningtask_post;
49+
$this->page = $page;
50+
$this->column_definition = $column_definition;
51+
$this->data_provider = $data_provider;
52+
$this->user_repository = $user_repository;
53+
}
54+
55+
/**
56+
* CSVエクスポートを実行し、HTTPレスポンスを返す
57+
*/
58+
public function export(string $site_url, string $character_code): StreamedResponse
59+
{
60+
// 1. ヘッダー行を取得
61+
$header_row = $this->column_definition->getHeaders();
62+
63+
// 2. ファイル名生成
64+
$filename = FileUtils::toValidFilename($this->learningtask_post->post_title . '_Export.csv');
65+
66+
// 3. レスポンスヘッダー (streamDownload が Content-Disposition を主に設定)
67+
// Content-Type は必要に応じて明示的に指定する
68+
$headers = [
69+
'Content-Type' => 'text/csv; charset='. $character_code,
70+
];
71+
72+
// 4. ストリーミング処理のコールバックを定義
73+
$callback = function () use ($site_url, $character_code, $header_row) {
74+
// 出力ストリーム 'php://output' を書き込みモードで開く
75+
$handle = fopen('php://output', 'w');
76+
if ($handle === false) {
77+
Log::error("CSV Export Streaming: Failed to open php://output.");
78+
// ここで処理を中断する(例外を投げるなど)
79+
return;
80+
}
81+
82+
// ロケール設定 (fputcsv の挙動のため念のため)
83+
CsvUtils::setLocale();
84+
85+
// 文字コードに応じた処理: BOM 追加 (UTF-8の場合)
86+
if ($character_code === CsvCharacterCode::utf_8) {
87+
// Excel での互換性のため UTF-8 BOM を先頭に書き込む
88+
fwrite($handle, CsvUtils::bom);
89+
}
90+
91+
// 文字コードに応じた処理: ヘッダー行のエンコーディング変換
92+
if ($character_code === CsvCharacterCode::sjis_win) {
93+
// ヘッダー行を Shift-JIS に変換
94+
$header_row = array_map(
95+
fn($value) => mb_convert_encoding((string)$value, CsvCharacterCode::sjis_win, 'UTF-8'),
96+
$header_row
97+
);
98+
}
99+
// ヘッダー行を CSV として書き込み
100+
fputcsv($handle, $header_row);
101+
// データ行を取得 (DataProvider から iterable で)
102+
$data_rows_iterable = $this->data_provider->getRows(
103+
$this->column_definition,
104+
$this->learningtask_post,
105+
$this->page,
106+
$site_url
107+
);
108+
109+
// データ行を一件ずつ処理して出力ストリームに書き込み
110+
foreach ($data_rows_iterable as $row_array) {
111+
// Shift-JIS で出力する場合の変換
112+
if ($character_code === CsvCharacterCode::sjis_win) {
113+
$row_array = array_map(fn($value) => mb_convert_encoding((string)$value, 'SJIS-win', 'UTF-8'), $row_array);
114+
}
115+
116+
// RFC4180 準拠: fputcsv は基本的なダブルクォートのエスケープは行うが、
117+
// 改行コード等の扱いでより厳密な処理が必要な場合は、自前でエスケープ処理を追加検討。
118+
// (CsvUtils::getResponseCsvData にあった str_replace('"', '""', ...) の処理は fputcsv が行う)
119+
fputcsv($handle, $row_array);
120+
}
121+
// php://output は fclose 不要
122+
};
123+
124+
// 5. ストリーミングダウンロードレスポンスを生成して返す
125+
return response()->streamDownload($callback, $filename, $headers);
126+
}
127+
128+
/**
129+
* ユーザーがCSVエクスポート可能か判定
130+
*
131+
* @param User $user
132+
* @return bool
133+
*/
134+
public function canExport(User $user): bool
135+
{
136+
if ($user->can('role_article_admin')) {
137+
return true;
138+
}
139+
$teachers = $this->user_repository->getTeachers($this->learningtask_post, $this->page);
140+
if ($teachers->contains('id', $user->id)) {
141+
return true;
142+
}
143+
return false;
144+
}
145+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<?php
2+
3+
namespace App\Plugins\User\Learningtasks\DataProviders;
4+
5+
use App\Models\Common\Page;
6+
use App\Models\User\Learningtasks\LearningtasksPosts;
7+
use App\Models\User\Learningtasks\LearningtasksUsersStatuses;
8+
use App\User;
9+
use App\Plugins\User\Learningtasks\Contracts\ColumnDefinitionInterface;
10+
use App\Plugins\User\Learningtasks\Contracts\CsvDataProviderInterface;
11+
use App\Plugins\User\Learningtasks\Repositories\LearningtaskUserRepository;
12+
use Generator;
13+
use Illuminate\Support\Collection;
14+
use Illuminate\Support\Facades\Log;
15+
16+
/**
17+
* レポート課題のCSVエクスポート用データを提供するクラス
18+
* CsvDataProviderInterface を実装する。
19+
*/
20+
class ReportCsvDataProvider implements CsvDataProviderInterface
21+
{
22+
/**
23+
* ユーザーリポジトリ
24+
* @var LearningtaskUserRepository
25+
*/
26+
private LearningtaskUserRepository $user_repository;
27+
28+
/**
29+
* コンストラクタ
30+
* @param LearningtaskUserRepository $user_repository
31+
*/
32+
public function __construct(LearningtaskUserRepository $user_repository)
33+
{
34+
$this->user_repository = $user_repository;
35+
}
36+
37+
/**
38+
* レポート課題のCSVデータ行を生成して yield する
39+
* (CsvDataProviderInterface の実装)
40+
*
41+
* @param ColumnDefinitionInterface $column_definition カラム定義
42+
* @param LearningtasksPosts $post 課題投稿コンテキスト
43+
* @param Page $page ページコンテキスト
44+
* @param string $site_url サイトURL
45+
* @return Generator<int, array<int, string|null>> Generator オブジェクトを返す
46+
*/
47+
public function getRows(
48+
ColumnDefinitionInterface $column_definition,
49+
LearningtasksPosts $post,
50+
Page $page,
51+
string $site_url
52+
): Generator {
53+
// 1. ヘッダーを取得 (順序の参照用に内部で使う)
54+
$header_columns = $column_definition->getHeaders();
55+
if (empty($header_columns)) {
56+
// ヘッダーがなければ何も yield しない
57+
return;
58+
}
59+
60+
// 2. 対象学生を取得
61+
$students = $this->user_repository->getStudents($post, $page);
62+
if ($students->isEmpty()) {
63+
// 対象がいなければ何も yield しない
64+
return;
65+
}
66+
67+
// 3. 関連ステータスを一括取得・グループ化
68+
$statuses_by_user = $this->fetchAllStatusesGroupedByUser($students, $post);
69+
70+
// 4. 学生ごとにループして行データを yield
71+
foreach ($students as $student) {
72+
$student_statuses = $statuses_by_user->get($student->id, collect());
73+
$submission_eval_pair = $this->findLastSubmissionAndEvaluation($student_statuses);
74+
$submission_count = $student_statuses->where('task_status', 1)->count();
75+
76+
// 一行分のデータを生成 (ヘルパーメソッド利用)
77+
$row_values = $this->generateRowForStudent(
78+
$student,
79+
$submission_eval_pair['last_submission'],
80+
$submission_eval_pair['last_evaluation'],
81+
$submission_count,
82+
$header_columns, // 要求ヘッダーリスト
83+
$site_url
84+
);
85+
// 配列に追加する代わりに yield で返す
86+
yield $row_values;
87+
}
88+
}
89+
90+
/**
91+
* 対象学生全員の関連ステータス(提出・評価)を一括取得し、ユーザーIDでグループ化する
92+
*
93+
* @param Collection $students 対象学生のコレクション
94+
* @param LearningtasksPosts $post 課題投稿コンテキスト
95+
* @return Collection グループ化されたステータスのコレクション
96+
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
97+
*/
98+
private function fetchAllStatusesGroupedByUser(Collection $students, LearningtasksPosts $post): Collection
99+
{
100+
return LearningtasksUsersStatuses::where('post_id', $post->id)
101+
->whereIn('task_status', [1, 2])
102+
->whereIn('user_id', $students->pluck('id'))
103+
->orderBy('id', 'desc')
104+
->get()
105+
->groupBy('user_id');
106+
}
107+
108+
/**
109+
* 学生一人のステータスコレクションから、最新の提出と対応する評価を見つける
110+
*
111+
* @param Collection $student_statuses 学生のステータスコレクション
112+
* @return array 最新の提出と評価を含む配列
113+
*/
114+
private function findLastSubmissionAndEvaluation(Collection $student_statuses): array
115+
{
116+
$student_submissions = $student_statuses->where('task_status', 1);
117+
$student_evaluations = $student_statuses->where('task_status', 2);
118+
$last_submission = $student_submissions->first();
119+
$last_evaluation = null;
120+
if ($last_submission && $student_submissions->count() === $student_evaluations->count()) {
121+
$last_evaluation = $student_evaluations->first();
122+
}
123+
return ['last_submission' => $last_submission, 'last_evaluation' => $last_evaluation];
124+
}
125+
126+
/**
127+
* 学生一人分のCSV行データを生成 (データ生成マップ利用)
128+
*/
129+
private function generateRowForStudent(
130+
User $student,
131+
?LearningtasksUsersStatuses $last_submission,
132+
?LearningtasksUsersStatuses $last_evaluation,
133+
int $submit_count,
134+
array $required_headers,
135+
string $site_url
136+
): array {
137+
$row_values = [];
138+
$data_generators = $this->getColumnDataGenerators(
139+
$student, $last_submission, $last_evaluation, $submit_count, $site_url
140+
);
141+
foreach ($required_headers as $header) {
142+
if (isset($data_generators[$header])) {
143+
$row_values[] = $data_generators[$header]();
144+
} else {
145+
Log::warning("ReportCsvDataProvider: Unknown header '{$header}' requested.");
146+
$row_values[] = null;
147+
}
148+
}
149+
return $row_values; // 値のみの配列
150+
}
151+
152+
/**
153+
* 各カラムのデータ生成ロジック(クロージャ)を連想配列で返すヘルパー
154+
*/
155+
private function getColumnDataGenerators(
156+
User $student,
157+
?LearningtasksUsersStatuses $last_submission,
158+
?LearningtasksUsersStatuses $last_evaluation,
159+
int $submit_count,
160+
string $site_url
161+
): array {
162+
return [
163+
'ログインID' => fn() => $student->userid,
164+
'ユーザ名' => fn() => $student->name,
165+
'提出日時' => fn() => optional($last_submission)->created_at,
166+
'提出回数' => fn() => $submit_count,
167+
'本文' => fn() => optional($last_submission)->comment,
168+
'ファイルURL' => function () use ($last_submission, $site_url) {
169+
$upload_id = optional($last_submission)->upload_id;
170+
return $upload_id ? $site_url . '/file/' . $upload_id : null;
171+
},
172+
'評価' => fn() => optional($last_evaluation)->grade,
173+
'評価コメント' => fn() => optional($last_evaluation)->comment,
174+
];
175+
}
176+
}

0 commit comments

Comments
 (0)