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