From 4d2349c21efb5fe2c4445aa6a7e6b2d5a150a443 Mon Sep 17 00:00:00 2001 From: trenc Date: Tue, 19 Mar 2024 17:15:05 +0100 Subject: [PATCH 1/8] feat: add endpoint for user stats --- src/routes/api.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/api.php b/src/routes/api.php index c1d67c5..1438653 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\StoryStatsController; use App\Http\Controllers\TeamController; use App\Http\Controllers\UserController; +use App\Http\Controllers\UserStatsController; use App\Http\Controllers\ScoreController; /* @@ -72,6 +73,7 @@ Route::get('/users', [UserController::class, 'index']); Route::get('/users/{id}', [UserController::class, 'show']); + Route::get('/users/{id}/statistics', [UserStatsController::class, 'show']); Route::get('/scores', [ScoreController::class, 'index']); Route::post('/scores', [ScoreController::class, 'store']); From f1bc734a85d6bc89aa1f4f9feef5b0f458e11b36 Mon Sep 17 00:00:00 2001 From: trenc Date: Tue, 19 Mar 2024 17:17:05 +0100 Subject: [PATCH 2/8] feat: migration for new ScoreType and table view user_stats_view --- ...24_03_18_103600_create_user_stats_view.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/database/migrations/2024_03_18_103600_create_user_stats_view.php diff --git a/src/database/migrations/2024_03_18_103600_create_user_stats_view.php b/src/database/migrations/2024_03_18_103600_create_user_stats_view.php new file mode 100644 index 0000000..4079986 --- /dev/null +++ b/src/database/migrations/2024_03_18_103600_create_user_stats_view.php @@ -0,0 +1,43 @@ +insertOrIgnore([ + [ + 'ScoreTypeId' => 5, + 'Name' => 'HTR-Transcription', + 'Rate' => 0.0033 + ] + ]); + + DB::statement(' + CREATE VIEW user_stats_view AS + SELECT + s.UserId, + COUNT(DISTINCT s.ItemId) AS Items, + SUM(CASE WHEN s.ScoreTypeId = 1 THEN s.Amount ELSE 0 END) AS Locations, + SUM(CASE WHEN s.ScoreTypeId = 2 THEN s.Amount ELSE 0 END) AS ManualTranscriptions, + SUM(CASE WHEN s.ScoreTypeId = 3 THEN s.Amount ELSE 0 END) AS Enrichments, + SUM(CASE WHEN s.ScoreTypeId = 4 THEN s.Amount ELSE 0 END) AS Descriptions, + SUM(CASE WHEN s.ScoreTypeId = 5 THEN s.Amount ELSE 0 END) AS HTRTranscriptions, + ROUND(SUM(s.Amount * st.Rate) + 0.5, 0) AS Miles + FROM + Score s + JOIN + ScoreType st ON s.ScoreTypeId = st.ScoreTypeId + GROUP BY + UserId; + '); + } + + public function down(): void + { + DB::statement('DROP VIEW IF EXISTS user_stats_view'); + } +}; From 4b55d440d28def93bad6bb8f052ba8410aba43a3 Mon Sep 17 00:00:00 2001 From: trenc Date: Tue, 19 Mar 2024 17:22:48 +0100 Subject: [PATCH 3/8] feat: add resource and controller for user stats --- .../Http/Controllers/UserStatsController.php | 31 +++++++++++++++++++ src/app/Http/Resources/UserStatsResource.php | 13 ++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/app/Http/Controllers/UserStatsController.php create mode 100644 src/app/Http/Resources/UserStatsResource.php diff --git a/src/app/Http/Controllers/UserStatsController.php b/src/app/Http/Controllers/UserStatsController.php new file mode 100644 index 0000000..50e0a53 --- /dev/null +++ b/src/app/Http/Controllers/UserStatsController.php @@ -0,0 +1,31 @@ +sendError('Not found', 'No statistics exists for this UserId.'); + } + + $collection = collect($data[0]); + + $resource = new UserStatsResource($collection); + + return $this->sendResponse($resource, 'ItemStats fetched.'); + } catch (\Exception $exception) { + return $this->sendError('Invalid data', $exception->getMessage(), 400); + } + } + +} diff --git a/src/app/Http/Resources/UserStatsResource.php b/src/app/Http/Resources/UserStatsResource.php new file mode 100644 index 0000000..cd594db --- /dev/null +++ b/src/app/Http/Resources/UserStatsResource.php @@ -0,0 +1,13 @@ + Date: Tue, 19 Mar 2024 17:23:17 +0100 Subject: [PATCH 4/8] tests: add specific migrations to test migrations --- src/tests/TestCase.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index 7dd94d3..f6301bc 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -18,7 +18,16 @@ protected function setUp(): void $this->makeMigrations(); + // and then the rest for all migration are just created or the test + // (for all already existing databases) $this->artisan('migrate', ['--path' => 'database/testMigrations']); + + // since we cannot use all migrations from the begining select here specific ones + $this->artisan( + 'migrate', + ['--path' => 'database/migrations/2024_03_18_103600_create_user_stats_view.php'] + ); + } protected function makeMigrations(): void From 9f9915bda6a1d5b9b48cbaa05990c84df8611115 Mon Sep 17 00:00:00 2001 From: trenc Date: Tue, 19 Mar 2024 17:23:35 +0100 Subject: [PATCH 5/8] tests: tests for user stats --- src/tests/Feature/UserStatsTest.php | 152 ++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/tests/Feature/UserStatsTest.php diff --git a/src/tests/Feature/UserStatsTest.php b/src/tests/Feature/UserStatsTest.php new file mode 100644 index 0000000..c644821 --- /dev/null +++ b/src/tests/Feature/UserStatsTest.php @@ -0,0 +1,152 @@ + 1, + 'Name' => 'Location', + 'Rate' => 0.2 + ], + [ + 'ScoreTypeId' => 2, + 'Name' => 'Transcription', + 'Rate' => 0.0033 + ], + [ + 'ScoreTypeId' => 3, + 'Name' => 'Enrichment', + 'Rate' => 0.2 + ], + [ + 'ScoreTypeId' => 4, + 'Name' => 'Description', + 'Rate' => 0.2 + ], + [ + 'ScoreTypeId' => 5, + 'Name' => 'HTR-Transcription', + 'Rate' => 0.0033 + ] + ]; + + private static $tableName = 'Score'; + + private static $tableData = [ + [ + 'ScoreId' => 1, + 'ItemId' => 1, + 'UserId' => 1, + 'ScoreTypeId' => 2, + 'Amount' => 55, + 'Timestamp' => '2021-01-01T12:00:00.000000Z' + ], + [ + 'ScoreId' => 2, + 'ItemId' => 2, + 'UserId' => 2, + 'ScoreTypeId' => 2, + 'Amount' => 0, + 'Timestamp' => '2021-02-01T12:00:00.000000Z' + ], + [ + 'ScoreId' => 3, + 'ItemId' => 3, + 'UserId' => 1, + 'ScoreTypeId' => 3, + 'Amount' => 0, + 'Timestamp' => '2021-03-01T12:00:00.000000Z' + ] + ]; + + public function setUp(): void + { + parent::setUp(); + self::populateTable(); + } + + public static function populateTable (): void + { + DB::table(self::$scoreTypeTableName)->insertOrIgnore(self::$scoreTypeTableData); + DB::table(self::$tableName)->insert(self::$tableData); + } + + public function testGetNotFoundOnNonExistentUser(): void + { + $userId = 0; + $endpoint = '/users/' . $userId . '/statistics'; + $queryParams = '?limit=1&page=1&orderBy=ScoreId&orderDir=desc'; + $awaitedSuccess = ['success' => false]; + + $response = $this->get($endpoint . $queryParams); + + $response + ->assertNotFound() + ->assertJson($awaitedSuccess); + } + + public function testGetStatisticsForAnUser(): void + { + $userId = 1; + $endpoint = '/users/' . $userId . '/statistics'; + $queryParams = '?limit=1&page=1&orderBy=ScoreId&orderDir=desc'; + $awaitedSuccess = ['success' => true]; + $userFiltered = array_filter(self::$tableData, function($entry) use ($userId) { + return $entry['UserId'] == $userId; + }); + function scoreFiltered (array $array, int $scoreTypeId): int + { + return array_sum( + array_column( + array_filter($array, function($entry) use ($scoreTypeId) { + return $entry['ScoreTypeId'] === $scoreTypeId; + }), + 'Amount' + ) + ); + } + $awaitedData = [ + 'data' => [ + 'UserId' => $userId, + 'Items' => count( + array_unique(array_column(array_filter($userFiltered), 'ItemId')) + ), + 'Locations' => scoreFiltered($userFiltered, 1), + 'ManualTranscriptions' => scoreFiltered($userFiltered, 2), + 'Enrichments' => scoreFiltered($userFiltered, 3), + 'Descriptions' => scoreFiltered($userFiltered, 4), + 'HTRTranscriptions' => scoreFiltered($userFiltered, 5) + ] + ]; + function rate (array $array, int $scoreTypeId): float + { + $filtered = array_filter($array, function ($entry) use ($scoreTypeId) { + return $entry['ScoreTypeId'] === $scoreTypeId; + }); + $filtered = reset($filtered); + + return $filtered['Rate']; + } + $awaitedData['data']['Miles'] = ceil( + $awaitedData['data']['Locations'] * rate(self::$scoreTypeTableData, 1) + + $awaitedData['data']['ManualTranscriptions'] * rate(self::$scoreTypeTableData, 2) + + $awaitedData['data']['Enrichments'] * rate(self::$scoreTypeTableData, 3) + + $awaitedData['data']['Descriptions'] * rate(self::$scoreTypeTableData, 4) + + $awaitedData['data']['HTRTranscriptions'] * rate(self::$scoreTypeTableData, 5) + ); + + $response = $this->get($endpoint . $queryParams); + + $response + ->assertOk() + ->assertJson($awaitedSuccess) + ->assertJson($awaitedData); + } +} From a6c0a6f8356c319e1383d4c073baf12b7dd7c265 Mon Sep 17 00:00:00 2001 From: trenc Date: Tue, 19 Mar 2024 17:52:47 +0100 Subject: [PATCH 6/8] fix: cast all user stats integer --- src/app/Http/Controllers/UserStatsController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/Http/Controllers/UserStatsController.php b/src/app/Http/Controllers/UserStatsController.php index 50e0a53..8be65e8 100644 --- a/src/app/Http/Controllers/UserStatsController.php +++ b/src/app/Http/Controllers/UserStatsController.php @@ -18,7 +18,10 @@ public function show(int $id): JsonResponse return $this->sendError('Not found', 'No statistics exists for this UserId.'); } - $collection = collect($data[0]); + // cast all as integer + $collection = collect($data[0])->map(function ($value) { + return is_numeric($value) ? (int) $value : $value; + }); $resource = new UserStatsResource($collection); From aa45adadf843406c063885771549d87ce6c0b9f3 Mon Sep 17 00:00:00 2001 From: trenc Date: Tue, 19 Mar 2024 17:53:21 +0100 Subject: [PATCH 7/8] docs: document user stats endpoint in OpenApi --- src/storage/api-docs/api-docs.yaml | 2 + .../api-docs/users-statistics-schema.yaml | 37 +++++++++++++++++++ .../users-userId-statistics-path.yaml | 29 +++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 src/storage/api-docs/users-statistics-schema.yaml create mode 100644 src/storage/api-docs/users-userId-statistics-path.yaml diff --git a/src/storage/api-docs/api-docs.yaml b/src/storage/api-docs/api-docs.yaml index d50b9d8..4f0b9de 100644 --- a/src/storage/api-docs/api-docs.yaml +++ b/src/storage/api-docs/api-docs.yaml @@ -101,6 +101,8 @@ paths: $ref: 'users-path.yaml' /users/{UserId}: $ref: 'users-userId-path.yaml' + /users/{UserId}/statistics: + $ref: 'users-userId-statistics-path.yaml' /teams: $ref: 'teams-path.yaml' diff --git a/src/storage/api-docs/users-statistics-schema.yaml b/src/storage/api-docs/users-statistics-schema.yaml new file mode 100644 index 0000000..18a9aa8 --- /dev/null +++ b/src/storage/api-docs/users-statistics-schema.yaml @@ -0,0 +1,37 @@ +UserStatisticsGetResponseSchema: + allOf: + - type: object + - description: The data object of a single response entry + properties: + UserId: + type: integer + description: ID of the User + example: 2 + Items: + type: integer + description: Number of items the user has participated in + example: 4 + Locations: + type: integer + description: Number of locations and places the user has geolocated + example: 12 + ManualTranscriptions: + type: integer + description: Number of chars the user has transcribed manually + example: 2365 + Enrichments: + type: integer + description: Number of enrichments the user has created + example: 12 + Descriptions: + type: integer + description: Number of chars of the descriptions the user has created + example: 513 + HTRTranscriptions: + type: integer + description: Number of chars the user has transcribed with HTR + example: 1285 + Miles: + type: integer + description: Number of points the user has so far + example: 23655 diff --git a/src/storage/api-docs/users-userId-statistics-path.yaml b/src/storage/api-docs/users-userId-statistics-path.yaml new file mode 100644 index 0000000..1e9c63d --- /dev/null +++ b/src/storage/api-docs/users-userId-statistics-path.yaml @@ -0,0 +1,29 @@ +get: + tags: + - statistics + - users + summary: Get stored statistics data of an user + description: The returned data is single object + parameters: + - in: path + name: UserId + 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: 'users-statistics-schema.yaml#/UserStatisticsGetResponseSchema' + 400: + $ref: 'responses.yaml#/400ErrorResponse' + 401: + $ref: 'responses.yaml#/401ErrorResponse' + 404: + $ref: 'responses.yaml#/404ErrorResponse' From 3e8c24720f5dc5ef5058bf9ec7fe35ba7139b4c8 Mon Sep 17 00:00:00 2001 From: trenc Date: Tue, 19 Mar 2024 17:53:43 +0100 Subject: [PATCH 8/8] build: bump version --- src/storage/api-docs/api-docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/storage/api-docs/api-docs.yaml b/src/storage/api-docs/api-docs.yaml index 4f0b9de..261ba51 100644 --- a/src/storage/api-docs/api-docs.yaml +++ b/src/storage/api-docs/api-docs.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: - version: 1.31.0 + version: 1.32.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.