From eae2e25f6697c32494e51a76a7edbd09b970a3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20K=C3=BCnzel?= <simonk@fsmpi.rwth-aachen.de> Date: Tue, 18 Jun 2024 23:11:04 +0200 Subject: [PATCH] Add feedback --- api_specification.md | 54 +++++++++- config/db_schema_postgres.sql | 7 ++ config/db_schema_sqlite.sql | 7 ++ config/db_test_data.sql | 6 ++ src/api/feedback.py | 57 +++++++++++ src/api/routes/__init__.py | 1 + src/api/routes/feedback.py | 31 ++++++ tests/routes/feedback.py | 181 ++++++++++++++++++++++++++++++++++ 8 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 src/api/feedback.py create mode 100644 src/api/routes/feedback.py create mode 100644 tests/routes/feedback.py diff --git a/api_specification.md b/api_specification.md index c06a2b9..7b1c185 100644 --- a/api_specification.md +++ b/api_specification.md @@ -1,4 +1,4 @@ -# Specification of the Web API for the Video-AG Website (v0.53). +# Specification of the Web API for the Video-AG Website (v0.54). ## Introduction @@ -522,6 +522,41 @@ Only logs moderators out. --- +### Feedback + +#### `PUT /feedback/new` + +| Field | Type | Notes | +|------------|---------|------------------------------------------------------------------------------------------------------------------| +| email | ?string | May not be longer than 128 characters. Must match `\S+@\S+\.\S+` (More sophisticated checks are allowed, though) | +| text | string | May not be longer than 16384 characters | + +###### Response: + +`201 CREATED` if the feedback was saved. Otherwise, an [api error](#api_error). + +--- + +#### `GET /feedback?entries_per_page={entries_per_page}&page={page}` + +| Query Parameter | Type | Notes | +|------------------|------------|------------------------------------------------------------------------------------------------------| +| entries_per_page | ?int | Must be between 10 and 500 | +| page | ?int | Zero-indexed. Must not be negative. If this is bigger than the page count, an empty page is returned | + +This may be used while the site is [read-only](#site-status). + +This route is only for moderators. + +###### Response: + +| Field | Type | Notes | +|------------|--------------------------------|-------| +| page_count | int | | +| page | array of [feedback](#feedback) | | + +--- + ### Objects These routes allow moderators to change fields of objects. For most objects this is done indirectly. The API @@ -954,8 +989,8 @@ This may be used while the site is [read-only](#site-status). | Field | Type | Notes | |------------|----------------------------|-------| -| page | int | | -| page_count | array of [job](#job) | | +| page_count | int | | +| page | array of [job](#job) | | | workers | array of [worker](#worker) | | --- @@ -1180,6 +1215,15 @@ video or audio file | aspect_ratio | string | | | priority | int | | +#### feedback + +| Field | Type | Notes | +|--------------|----------|-------| +| id | int | | +| time_created | datetime | | +| email | ?string | | +| text | string | | + #### user_info | Field | Type | Notes | @@ -1522,6 +1566,10 @@ Possible `error_code`: ## Changelog +### v0.54 + +* Added `PUT /feedback/new`, `GET /feedback?entries_per_page={entries_per_page}&page={page}` + ### v0.53 * Changed type of `course_id_string` to regex. Added forbidden values. diff --git a/config/db_schema_postgres.sql b/config/db_schema_postgres.sql index 7fe8034..def571a 100644 --- a/config/db_schema_postgres.sql +++ b/config/db_schema_postgres.sql @@ -410,6 +410,13 @@ CREATE TABLE IF NOT EXISTS "responsible" ( ); -- TODO info: this had collation latin1_swedish_ci CREATE UNIQUE INDEX IF NOT EXISTS "responsible_course_id_user_id_index" ON "responsible" ("course_id","user_id"); +CREATE TABLE IF NOT EXISTS "feedback" ( + "id" serial NOT NULL, + "time_created" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "email" character varying(128) DEFAULT NULL, + "text" text COLLATE "pg_catalog"."und-x-icu" NOT NULL, + CONSTRAINT "feedback_pkey" PRIMARY KEY ("id") +); CREATE VIEW "courses" AS SELECT * diff --git a/config/db_schema_sqlite.sql b/config/db_schema_sqlite.sql index a438e2b..5146154 100644 --- a/config/db_schema_sqlite.sql +++ b/config/db_schema_sqlite.sql @@ -331,6 +331,13 @@ CREATE TABLE IF NOT EXISTS `responsible` ( UNIQUE (course_id, user_id) ); +CREATE TABLE IF NOT EXISTS `feedback` ( + `id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + `time_created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `email` varchar(128) DEFAULT NULL, + `text` text NOT NULL +); + CREATE VIEW IF NOT EXISTS `courses` AS select * from `courses_data` where (not(`courses_data`.`deleted`)); CREATE VIEW IF NOT EXISTS `lectures` AS select `lectures_data`.* from `lectures_data` join `courses_data` on (`courses_data`.`id` = `course_id`) where (not(`lectures_data`.`deleted` or `courses_data`.`deleted`)); CREATE VIEW IF NOT EXISTS `videos` AS select `videos_data`.* from `videos_data` join `lectures_data` on (`lectures_data`.`id` = `lecture_id`) join `courses_data` on (`courses_data`.`id` = `course_id`) where (not(`videos_data`.`deleted` or `lectures_data`.`deleted` or `courses_data`.`deleted`)); diff --git a/config/db_test_data.sql b/config/db_test_data.sql index ca1f297..eb6bfa2 100644 --- a/config/db_test_data.sql +++ b/config/db_test_data.sql @@ -137,3 +137,9 @@ DELETE FROM "sources"; INSERT INTO "sources" ("id", "lecture_id", "path", "type", "hash", "time_created") VALUES (24, 186, 'pub/something.mp4', 'plain', 'c4ebf870cdf5691c75fbc5c09fda6a53', '2024-03-18 11:22:40'); -- $POSTGRES_FIX_SEQUENCE(sources, id) + +DELETE FROM "feedback"; +INSERT INTO "feedback" ("id", "time_created", "email", "text") VALUES +(1, '2024-03-18 18:53:02', NULL, 'Hier FuNkToNiErT NICHTS!'), +(2, '2024-03-10 20:11:08', 'no-one@example.com', 'Ein bisschen Feedback'); +-- $POSTGRES_FIX_SEQUENCE(feedback, id) diff --git a/src/api/feedback.py b/src/api/feedback.py new file mode 100644 index 0000000..b78fda9 --- /dev/null +++ b/src/api/feedback.py @@ -0,0 +1,57 @@ +import math +import re + +from api.database import * +from api.miscellaneous import * + + +FEEDBACK_EMAIL_MAX_LENGTH = 128 +FEEDBACK_EMAIL_REGEX = "\\S+@\\S+\\.\\S+" +FEEDBACK_EMAIL_PATTERN = re.compile(FEEDBACK_EMAIL_REGEX) +FEEDBACK_TEXT_MAX_LENGTH = 16384 + +FEEDBACK_MIN_PAGE_SIZE = 10 +FEEDBACK_MAX_PAGE_SIZE = 500 + + +_SQL_INSERT_FEEDBACK = PreparedStatement(f""" +INSERT INTO "feedback" ("email", "text") +VALUES (?, ?) +""") + + +def _feedback_db_to_json(feedback_db: DbResultRow): + feedback_json = { + "id": feedback_db["id"], + "time_created": feedback_db["time_created"].strftime(API_DATETIME_FORMAT), + "text": feedback_db["text"] + } + if feedback_db["email"] is not None: + feedback_json["email"] = feedback_db["email"] + return feedback_json + + +def get_feedback_entries( + entries_per_page: int, + page: int) -> tuple[int, JsonTypes]: + if entries_per_page < FEEDBACK_MIN_PAGE_SIZE or entries_per_page > FEEDBACK_MAX_PAGE_SIZE or page < 0: + raise ValueError("Invalid page size or page number") # pragma: no cover + + row_count_set, entries_db = db_pool.execute_read_statements_in_transaction( + (f""" + SELECT COUNT(*) AS "count" FROM "feedback" + """, []), + (f""" + SELECT * FROM "feedback" + ORDER BY "time_created" DESC, "id" DESC + LIMIT {entries_per_page} OFFSET {entries_per_page * page} + """, []) + ) + return (math.ceil(row_count_set[0]["count"] / entries_per_page), + list(map(_feedback_db_to_json, entries_db))) + + +def put_feedback(email: str or None, text: str): + db_pool.execute_write_transaction(lambda trans: trans.execute_statement_and_commit( + _SQL_INSERT_FEEDBACK, email, text + )) diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py index 0e1d073..278ce7b 100644 --- a/src/api/routes/__init__.py +++ b/src/api/routes/__init__.py @@ -6,6 +6,7 @@ from api.authentication import api_moderator_route, is_moderator, get_user_id, c import api.routes.authentication import api.routes.courses +import api.routes.feedback import api.routes.site import api.routes.object_modifications import api.routes.user diff --git a/src/api/routes/feedback.py b/src/api/routes/feedback.py new file mode 100644 index 0000000..6bdc38a --- /dev/null +++ b/src/api/routes/feedback.py @@ -0,0 +1,31 @@ +from flask import request + +from api.routes import * +from api.feedback import (FEEDBACK_EMAIL_MAX_LENGTH, FEEDBACK_EMAIL_REGEX, FEEDBACK_EMAIL_PATTERN, + FEEDBACK_TEXT_MAX_LENGTH, FEEDBACK_MIN_PAGE_SIZE, FEEDBACK_MAX_PAGE_SIZE, + get_feedback_entries, put_feedback) + + +@api_route("/feedback/new", ["PUT"]) +def api_route_feedback_new(): + json_request = get_client_json(request) + email = json_request.get_string("email", FEEDBACK_EMAIL_MAX_LENGTH, optional=True) + text = json_request.get_string("text", FEEDBACK_TEXT_MAX_LENGTH) + if email is not None and not FEEDBACK_EMAIL_PATTERN.fullmatch(email): + raise json_request.get("email").raise_error(f"Does not match pattern {FEEDBACK_EMAIL_REGEX}") + + put_feedback(email, text) + return {}, HTTP_201_CREATED + + +@api_route("/feedback", ["GET"], allow_while_readonly=True) +@api_moderator_route() +def api_route_feedback(): + entries_per_page = api_request_get_query_int("entries_per_page", 50, FEEDBACK_MIN_PAGE_SIZE, FEEDBACK_MAX_PAGE_SIZE) + page = api_request_get_query_int("page", 0) + + page_count, page = get_feedback_entries(entries_per_page, page) + return { + "page_count": page_count, + "page": page + } diff --git a/tests/routes/feedback.py b/tests/routes/feedback.py new file mode 100644 index 0000000..e679f5a --- /dev/null +++ b/tests/routes/feedback.py @@ -0,0 +1,181 @@ +from api.miscellaneous.constants import * + +from api_test import ApiTest + +# The python json parser does not unescape the escaped strings, so we need to put the extra \ here. +_TEST_DATA_FEEDBACK_1 = { + "id": 1, + "time_created": "2024-03-18T18:53:02", + "text": "Hier FuNkToNiErT NICHTS!" +} +_TEST_DATA_FEEDBACK_2 = { + "id": 2, + "time_created": "2024-03-10T20:11:08", + "email": "no-one@example.com", + "text": "Ein bisschen Feedback" +} + + +class FeedbackTest(ApiTest): + + def test_get_feedback(self): + self.do_json_request( + "GET", + "/feedback", + expected_response_code=HTTP_401_UNAUTHORIZED + ) + self.do_json_request( + "GET", + "/feedback", + None, + { + "page_count": 1, + "page": [ + _TEST_DATA_FEEDBACK_1, + _TEST_DATA_FEEDBACK_2, + ] + }, + use_moderator_login=True + ) + self.do_json_request( + "GET", + "/feedback?entries_per_page=10&page=0", + None, + { + "page_count": 1, + "page": [ + _TEST_DATA_FEEDBACK_1, + _TEST_DATA_FEEDBACK_2, + ], + }, + use_moderator_login=True + ) + self.do_json_request( + "GET", + "/feedback?entries_per_page=10&page=1", + None, + { + "page_count": 1, + "page": [] + }, + use_moderator_login=True + ) + + def test_put_feedback(self): + self.do_json_request( + "PUT", + "/feedback/new", + { + "email": "", + "text": "This is an invalid email" + }, + expected_response_code=HTTP_400_BAD_REQUEST + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "email": "something", + "text": "This is an invalid email" + }, + expected_response_code=HTTP_400_BAD_REQUEST + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "email": "something@", + "text": "This is an invalid email" + }, + expected_response_code=HTTP_400_BAD_REQUEST + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "email": "@still-wrong", + "text": "This is an invalid email" + }, + expected_response_code=HTTP_400_BAD_REQUEST + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "email": "something@still-wrong", + "text": "This is an invalid email" + }, + expected_response_code=HTTP_400_BAD_REQUEST + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "email": f"something@still-wrong.now-correctButWa{'a' * 100}ayTooLong", + "text": "This is an invalid email" + }, + expected_response_code=HTTP_400_BAD_REQUEST + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "text": f"This text is soo{'o' * 20000} long" + }, + expected_response_code=HTTP_400_BAD_REQUEST + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "text": "Noch mehr feedback" + }, + expected_response_code=HTTP_201_CREATED + ) + self.do_json_request( + "GET", + "/feedback", + None, + { + "page_count": 1, + "page": [ + { + "text": "Noch mehr feedback", + }, + _TEST_DATA_FEEDBACK_1, + _TEST_DATA_FEEDBACK_2, + ] + }, + ignore_value_keys=["$.page[0].id", "$.page[0].time_created"], + use_moderator_login=True + ) + self.do_json_request( + "PUT", + "/feedback/new", + { + "email": "me@example.com", + "text": "Geht so" + }, + expected_response_code=HTTP_201_CREATED + ) + self.do_json_request( + "GET", + "/feedback", + None, + { + "page_count": 1, + "page": [ + { + "email": "me@example.com", + "text": "Geht so", + }, + { + "text": "Noch mehr feedback", + }, + _TEST_DATA_FEEDBACK_1, + _TEST_DATA_FEEDBACK_2, + ] + }, + ignore_value_keys=["$.page[0].id", "$.page[0].time_created", "$.page[1].id", "$.page[1].time_created"], + use_moderator_login=True + ) -- GitLab