diff --git a/src/app/Http/Controllers/ProjectStatsController.php b/src/app/Http/Controllers/ProjectStatsController.php new file mode 100644 index 0000000..e4c167b --- /dev/null +++ b/src/app/Http/Controllers/ProjectStatsController.php @@ -0,0 +1,150 @@ +sendError('Not found', 'No statistics exists for this Project yet.'); + } + + $collection = collect($data); + + $summary = $this->buildProjectStatisticsSummary($collection); + + $users = []; + $collection->groupBy('UserId')->each(function ($item, $userId) use (&$users) { + $user = $this->buildStatistics($item); + $user['UserId'] = $userId; + $users[] = $user; + }); + + $teams = $this->buildTeamStatisticsByProject($id); + + $grouped = [ + 'ProjectId' => $id, + 'Summary' => $summary, + 'Teams' => $teams, + 'Users' => $users, + ]; + + $resource = new ProjectStatsResource($grouped); + + return $this->sendResponse($resource, 'Projects statistics fetched.'); + } catch (\Exception $exception) { + return $this->sendError('Invalid data', $exception->getMessage(), 400); + } + } + + protected function buildTeamStatisticsByProject(int $projectId): array + { + $project = Project::findOrFail($projectId); + + $teams = Team::query() + ->whereExists(function ($query) use ($projectId) { + $query->select(DB::raw(1)) + ->from('TeamScore as ts') + ->join('Score as s', 's.ScoreId', '=', 'ts.ScoreId') + ->join('Item as i', 'i.ItemId', '=', 's.ItemId') + ->join('Story as st', 'st.StoryId', '=', 'i.StoryId') + ->whereColumn('ts.TeamId', 'Team.TeamId') + ->where('st.ProjectId', $projectId); + }) + ->get(); + + $allTeamsStats = []; + + foreach ($teams as $team) { + $teamItemIds = DB::table('TeamScore as ts') + ->join('Score as s', 's.ScoreId', '=', 'ts.ScoreId') + ->join('Item as i', 'i.ItemId', '=', 's.ItemId') + ->join('Story as st', 'st.StoryId', '=', 'i.StoryId') + ->where('ts.TeamId', $team->TeamId) + ->where('st.ProjectId', $projectId) + ->pluck('s.ItemId') + ->unique(); + + $row = ProjectStatsView::query() + ->where('ProjectId', $projectId) + ->whereIn('ItemId', $teamItemIds) + ->selectRaw(' + SUM(Locations) AS Locations, + SUM(ManualTranscriptions) AS ManualTranscriptions, + SUM(Enrichments) AS Enrichments, + SUM(Descriptions) AS Descriptions, + SUM(HTRTranscriptions) AS HTRTranscriptions, + SUM(Miles) AS Miles, + COUNT(DISTINCT StoryId) AS Stories, + COUNT(DISTINCT ItemId) AS Items + ') + ->first(); + + $allTeamsStats[] = [ + 'TeamId' => $team->TeamId, + 'Locations' => (int) ($row->Locations ?? 0), + 'ManualTranscriptions' => (int) ($row->ManualTranscriptions ?? 0), + 'Enrichments' => (int) ($row->Enrichments ?? 0), + 'Descriptions' => (int) ($row->Descriptions ?? 0), + 'HTRTranscriptions' => (int) ($row->HTRTranscriptions ?? 0), + 'Stories' => (int) ($row->Stories ?? 0), + 'Items' => (int) ($row->Items ?? 0), + 'Miles' => (int) ceil($row->Miles ?? 0), + ]; + } + + return $allTeamsStats; + } + + protected function emptyTeamStats(int $teamId): array + { + return [ + 'TeamId' => $teamId, + 'Locations' => 0, + 'ManualTranscriptions' => 0, + 'Enrichments' => 0, + 'Descriptions' => 0, + 'HTRTranscriptions' => 0, + 'Stories' => 0, + 'Items' => 0, + 'Miles' => 0, + ]; + } + + protected function buildProjectStatisticsSummary(Collection $collection): array + { + $data = $this->buildStatistics($collection); + + return $data; + } + + protected function buildStatistics(Collection $collection): array + { + return [ + 'Stories' => $collection->pluck('StoryId')->unique()->count(), + 'Items' => $collection->pluck('ItemId')->unique()->count(), + 'Locations' => $collection->pluck('Locations')->sum(), + 'ManualTranscriptions' => $collection->pluck('ManualTranscriptions')->sum(), + 'Enrichments' => $collection->pluck('Enrichments')->sum(), + 'Descriptions' => $collection->pluck('Descriptions')->sum(), + 'HTRTranscriptions' => $collection->pluck('HTRTranscriptions')->sum(), + 'Miles' => ceil($collection->pluck('Miles')->sum()), + ]; + } +} diff --git a/src/app/Http/Resources/ProjectStatsResource.php b/src/app/Http/Resources/ProjectStatsResource.php new file mode 100644 index 0000000..65284de --- /dev/null +++ b/src/app/Http/Resources/ProjectStatsResource.php @@ -0,0 +1,13 @@ +increments('TeamId'); + $table->string('Name', 255); + $table->string('ShortName', 10); + $table->string('Code', 255)->nullable(); + $table->string('Description', 255)->nullable(); + $table->boolean('EventUser')->default(false); + }); + } + + public function down(): void + { + Schema::dropIfExists('Team'); + } +}; diff --git a/src/database/testMigrations/2026_05_21_123700_create_team_user_table.php b/src/database/testMigrations/2026_05_21_123700_create_team_user_table.php new file mode 100644 index 0000000..094752b --- /dev/null +++ b/src/database/testMigrations/2026_05_21_123700_create_team_user_table.php @@ -0,0 +1,34 @@ +unsignedInteger('TeamId'); + $table->unsignedInteger('UserId'); + + $table->primary(['TeamId', 'UserId']); + + $table->foreign('TeamId', 'FK_46') + ->references('TeamId') + ->on('Team') + ->onDelete('cascade') + ->onUpdate('cascade'); + + $table->foreign('UserId', 'FK_50') + ->references('UserId') + ->on('User') + ->onDelete('cascade') + ->onUpdate('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('TeamUser'); + } +}; diff --git a/src/routes/api.php b/src/routes/api.php index 70b4db0..bb9a27a 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -15,6 +15,9 @@ use App\Http\Controllers\PlaceController; use App\Http\Controllers\PersonController; use App\Http\Controllers\ProjectController; +use App\Http\Controllers\ProjectStatsController; + +; use App\Http\Controllers\PropertyController; use App\Http\Controllers\SolrController; use App\Http\Controllers\StoryController; @@ -143,6 +146,7 @@ Route::put('/projects/{id}', [ProjectController::class, 'update']); Route::delete('/projects/{id}', [ProjectController::class, 'destroy']); Route::get('/projects/{id}/places', [PlaceController::class, 'showByProjectId']); + Route::get('/projects/{id}/statistics', [ProjectStatsController::class, 'show']); Route::get('/statistics', [StatisticsController::class, 'index']); Route::get('/statistics/alltime', [StatisticsController::class, 'alltimeIndex']); diff --git a/src/storage/api-docs/api-docs.yaml b/src/storage/api-docs/api-docs.yaml index 1335162..08bf9b7 100644 --- a/src/storage/api-docs/api-docs.yaml +++ b/src/storage/api-docs/api-docs.yaml @@ -1,7 +1,7 @@ openapi: 3.1.1 info: - version: 2.0.2 + version: 2.1.0 title: Transcribathon Platform API v2 description: This is the documentation of the Transcribathon API v2 used by [https:transcribathon.eu](https://transcribathon.eu/).
For authorization you can use the the bearer token you are provided with. @@ -182,6 +182,8 @@ paths: $ref: 'projects-projectId-path.yaml' /projects/{ProjectId}/places: $ref: 'projects-projectId-places-path.yaml' + /projects/{ProjectId}/statistics: + $ref: 'projects-projectId-statistics-path.yaml' /properties: $ref: 'properties-path.yaml' diff --git a/src/storage/api-docs/campaigns-campaignId-statistics-path.yaml b/src/storage/api-docs/campaigns-campaignId-statistics-path.yaml index 16c14d0..5fa08c0 100644 --- a/src/storage/api-docs/campaigns-campaignId-statistics-path.yaml +++ b/src/storage/api-docs/campaigns-campaignId-statistics-path.yaml @@ -1,8 +1,9 @@ get: tags: - - statistics - campaigns - summary: Get stored statistics data of a campaign + - scores + - statistics + summary: Get statistics/scores of a Campaign description: The returned data is single object parameters: - in: path diff --git a/src/storage/api-docs/items-statistics-schema.yaml b/src/storage/api-docs/items-statistics-schema.yaml index f0e3e9f..1cd05af 100644 --- a/src/storage/api-docs/items-statistics-schema.yaml +++ b/src/storage/api-docs/items-statistics-schema.yaml @@ -49,8 +49,6 @@ ItemsStatisticsUserIdsReferenceSchema: example: 2865 ItemsStatisticsEnrichmentsReferenceSchema: - type: object - description: Object with statistics data of enrichments properties: Places: type: integer @@ -80,7 +78,7 @@ ItemsStatisticsGetResponseSchema: - $ref: '#/ItemsStatisticsMinimalDataReferenceSchema' - $ref: '#/ItemsStatisticsAdditionalGetReferenceSchema' - $ref: '#/ItemsStatisticsUserIdsReferenceSchema' - properties: + - properties: Enrichments: $ref: '#/ItemsStatisticsEnrichmentsReferenceSchema' diff --git a/src/storage/api-docs/projects-projectId-statistics-path.yaml b/src/storage/api-docs/projects-projectId-statistics-path.yaml new file mode 100644 index 0000000..5a50f51 --- /dev/null +++ b/src/storage/api-docs/projects-projectId-statistics-path.yaml @@ -0,0 +1,30 @@ +get: + tags: + - scores + - statistics + - projects + summary: Get statistics/scores of a Project + description: The returned data is single object + parameters: + - in: path + name: ProjectId + description: Numeric ID of the entry + type: integer + required: true + responses: + 200: + description: Ok + content: + application/json: + schema: + allOf: + - $ref: 'responses.yaml#/BasicSuccessResponse' + - properties: + data: + $ref: 'projects-statistics-schema.yaml#/ProjectStatisticsGetResponseSchema' + 400: + $ref: 'responses.yaml#/400ErrorResponse' + 401: + $ref: 'responses.yaml#/401ErrorResponse' + 404: + $ref: 'responses.yaml#/404ErrorResponse' diff --git a/src/storage/api-docs/projects-statistics-schema.yaml b/src/storage/api-docs/projects-statistics-schema.yaml new file mode 100644 index 0000000..a11481a --- /dev/null +++ b/src/storage/api-docs/projects-statistics-schema.yaml @@ -0,0 +1,86 @@ +ProjectStatisticsMinimal: + properties: + Stories: + type: integer + description: Amount of stories that were transcribed/enriched + example: 4 + Items: + type: integer + description: Amount of items that were transcribed/enriched + example: 4 + Locations: + type: integer + description: Number of locations and places that has been geolocated + example: 12 + ManualTranscriptions: + type: integer + description: Number of chars that has been transcribed manually + example: 2365 + Enrichments: + type: integer + description: Number of enrichments that has been created + example: 12 + Descriptions: + type: integer + description: Number of chars of the descriptions that has been created + example: 513 + HTRTranscriptions: + type: integer + description: Number of chars that has been transcribed with HTR + example: 1285 + Miles: + type: integer + description: Number of points the user/s has/have so far + example: 23655 + +ProjectUserStatistics: + allOf: + - $ref: '#/ProjectStatisticsMinimal' + - type: object + properties: + UserId: + type: integer + description: UserId of the user + example: 5426 + +ProjectTeamStatistics: + allOf: + - $ref: '#/ProjectStatisticsMinimal' + - type: object + properties: + TeamId: + type: integer + description: TeamId of the team + example: 55 + +ProjectStatisticsGetResponseSchema: + allOf: + - type: object + - description: The data object of a single response entry + properties: + ProjectId: + type: integer + description: ID of the Project + example: 2 + Summary: + type: object + description: Summarized statistics that has been achieved in this Project so far + $ref: '#/ProjectStatisticsMinimal' + properties: + Users: + type: integer + description: Amount of users that particpated in this Project + Users: + type: array + description: Breakdown of achieved statistic of each users that particpated in this Project + items: + type: object + description: Summarized statistics that has been achieved by an user in this Project + $ref: '#/ProjectUserStatistics' + Teams: + type: array + description: Breakdown of achieved statistic of each team that particpated in this Project + items: + type: object + description: Summarized statistics that has been achieved by a team in this Project + $ref: '#/ProjectTeamStatistics' diff --git a/src/tests/Feature/ProjectStatsTest.php b/src/tests/Feature/ProjectStatsTest.php new file mode 100644 index 0000000..e9e8132 --- /dev/null +++ b/src/tests/Feature/ProjectStatsTest.php @@ -0,0 +1,143 @@ + ProjectDataSeeder::class]); + Artisan::call('db:seed', ['--class' => UserDataSeeder::class]); + Artisan::call('db:seed', ['--class' => StoryDataSeeder::class]); + Artisan::call('db:seed', ['--class' => ItemDataSeeder::class]); + Artisan::call('db:seed', ['--class' => ScoreTypeDataSeeder::class]); + Artisan::call('db:seed', ['--class' => ScoreDataSeeder::class]); + } + + public function test_get_not_found_on_non_existent_project(): void + { + $project_id = 9999; + $endpoint = '/projects/' . $project_id . '/statistics'; + + $response = $this->get($endpoint); + + $response + ->assertNotFound() + ->assertJson([ + 'success' => false, + 'message' => 'Not found', + 'data' => 'No statistics exists for this Project yet.', + ]); + } + + public function test_get_statistics_for_a_project(): void + { + $project_id = 1; + $endpoint = '/projects/' . $project_id . '/statistics'; + + $response = $this->get($endpoint); + $response->dump(); + + $response + ->assertOk() + ->assertJson([ + 'success' => true, + 'message' => 'Projects statistics fetched.', + 'data' => [ + 'ProjectId' => 1, + 'Summary' => [ + 'Stories' => 2, + 'Items' => 2, + 'Locations' => 0, + 'ManualTranscriptions' => 57, + 'Enrichments' => 100, + 'Descriptions' => 0, + 'HTRTranscriptions' => 0, + 'Miles' => 22, + ], + 'Teams' => [], + 'Users' => [ + [ + 'Stories' => 1, + 'Items' => 1, + 'Locations' => 0, + 'ManualTranscriptions' => 55, + 'Enrichments' => 100, + 'Descriptions' => 0, + 'HTRTranscriptions' => 0, + 'Miles' => 21, + 'UserId' => 1, + ], + [ + 'Stories' => 1, + 'Items' => 1, + 'Locations' => 0, + 'ManualTranscriptions' => 2, + 'Enrichments' => 0, + 'Descriptions' => 0, + 'HTRTranscriptions' => 0, + 'Miles' => 1, + 'UserId' => 2, + ], + ], + ], + ]); + } + + public function test_get_statistics_for_a_project_has_expected_structure(): void + { + $project_id = 1; + $endpoint = '/projects/' . $project_id . '/statistics'; + + $response = $this->get($endpoint); + + $response + ->assertOk() + ->assertJsonStructure([ + 'success', + 'data' => [ + 'ProjectId', + 'Summary' => [ + 'Stories', + 'Items', + 'Locations', + 'ManualTranscriptions', + 'Enrichments', + 'Descriptions', + 'HTRTranscriptions', + 'Miles', + ], + 'Teams', + 'Users' => [ + '*' => [ + 'UserId', + 'Stories', + 'Items', + 'Locations', + 'ManualTranscriptions', + 'Enrichments', + 'Descriptions', + 'HTRTranscriptions', + 'Miles', + ], + ], + ], + 'message', + ]); + } +} diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index 1b25adf..f8a0b1d 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -42,6 +42,8 @@ protected function setUp(): void '2026_04_15_101800_add_permissions_and_description_to_personal_access_tokens_table.php', '2026_04_20_094300_create_place_link_table.php', '2026_04_24_100100_create_export_cache_table.php', + '2026_05_21_112100_create_project_stats_view.php', + '2024_03_28_110000_create_team_score_table.php', ]; foreach ($additionalMigrations as $migration) {