From f6091bab35bcd9b354bc12b93b416c0b398b9cd7 Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 22:29:12 +0100 Subject: [PATCH 1/8] add feedback model --- api/models.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/api/models.py b/api/models.py index 8b90b99..91221fc 100644 --- a/api/models.py +++ b/api/models.py @@ -15,14 +15,14 @@ def validate_username(cls, v): raise ValueError( "Use only alphanumeric characters and underscores for username" ) - if len(v) > 60: # noqa: PLR2004 + if len(v) > 60: raise ValueError("Username must be at most 60 characters long") return v @field_validator("password") @classmethod def validate_password(cls, v): - if len(v) < 8 or len(v) > 60: # noqa: PLR2004 + if len(v) < 8 or len(v) > 60: raise ValueError("Password must be 8-60 characters long") return v @@ -57,13 +57,24 @@ def validate_time(cls, v): @field_validator("rating") @classmethod def validate_rating(cls, v): - if v < 1 or v > 10: # noqa: PLR2004 + if v < 1 or v > 10: raise ValueError("Rating must be 1-10") return v @field_validator("note") @classmethod def validate_note(cls, v): - if len(v) > 100: # noqa: PLR2004 + if len(v) > 100: raise ValueError("Note must be at most 100 characters") return v + + +class Feedback(BaseModel): + note: str + + @field_validator("note") + @classmethod + def validate_note(cls, v): + if len(v) < 30 or len(v) > 300: + raise ValueError("Feedback note must be at 30-300 characters") + return v From 7bf26f1a66bffc265c6c050b99fbc3cfc73922d6 Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 22:29:29 +0100 Subject: [PATCH 2/8] add api /feedback endpoint --- api/main.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/api/main.py b/api/main.py index 0c475ad..f18db5d 100644 --- a/api/main.py +++ b/api/main.py @@ -1,4 +1,5 @@ import csv +import datetime import io import json import logging @@ -20,7 +21,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBasicCredentials from httpbasic import HTTPBasic -from models import Flush, User +from models import Feedback, Flush, User app = fastapi.FastAPI() origins = [ @@ -116,7 +117,13 @@ def create_user(user: User): users = database.users pass_hash = hash_password(user.password) try: - users.insert_one({"_id": user.username, "pass_hash": pass_hash}) + users.insert_one( + { + "_id": user.username, + "pass_hash": pass_hash, + "registered_at": datetime.datetime.now(datetime.UTC), + } + ) except pymongo.errors.DuplicateKeyError as e: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="User already exists" @@ -365,3 +372,29 @@ def get_flush_stats(credentials: HTTPBasicCredentials = Depends(security)): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Error getting stats" ) from e + + +@app.post("/feedback", status_code=status.HTTP_201_CREATED) +def give_feedback( + feedback: Feedback = Query(), credentials: HTTPBasicCredentials = Depends(security) +): + check_creds(credentials) + feedbacks = client.flush.feedbacks + try: + feedbacks.insert_one( + { + "user_id": credentials.username, + "note": feedback.note, + "submission_time": datetime.datetime.now(datetime.UTC), + } + ) + return Response(status_code=status.HTTP_201_CREATED) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Error giving feedback" + ) from e + + +def get_feedback_count(username: str) -> int: + feedbacks = client.flush.feedbacks + return feedbacks.count_documents({"user_id": username}) From 58095d9f8ab7a463768b6c525acc7779826f9b88 Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 22:29:47 +0100 Subject: [PATCH 3/8] add feedback model tests --- api/tests/unit/test_feedback_model.py | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 api/tests/unit/test_feedback_model.py diff --git a/api/tests/unit/test_feedback_model.py b/api/tests/unit/test_feedback_model.py new file mode 100644 index 0000000..5f7b2f1 --- /dev/null +++ b/api/tests/unit/test_feedback_model.py @@ -0,0 +1,29 @@ +import pytest +from models import Feedback +from pydantic import ValidationError + +valid_cases = [ + "dzien dobry esaseaseas easeas ease ase as ease asedsasdasd", + r"k;\κIàðãÀv,ëm,ÓÅèïº)ÿ7dèÉó×EàrXê3liæWãÎtÅÑ%²Xse²w¥=ðÿë8+Îå6_ÊÁ¶w£,!Iaú¾%¤×øzNíæ¤æ\ØåÐo7\ÿk;\κIàðãÀv,ëm,ÓÅèïº)ÿ7dèÉó×EàrXê3liæWãÎtÅÑ%²Xse²w¥=ðÿë8+Îå6_ÊÁ¶w£,!Iaú¾%¤×øzNíæ¤æ\ØåÐo7\ÿk;\κIàðãÀv,ëm,ÓÅèïº)ÿ7dèÉó×EàrXê3liæWãÎtÅÑ%²Xse²w¥=ðÿë8+Îå6_ÊÁ¶w£,!Iaú¾%¤×øzNíæ¤æ\ØåÐo7\ÿ", # noqa: RUF001 + "witam witam witam witam witamm", + "ęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćźęśąćź", +] +invalid_cases = [ + "", + "za krotkie", + "za dlugieeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", # noqa: E501 + "dalej za krotkie, dalejjjjjjj", +] + + +def test_valid_feedback_models(): + for feedback in valid_cases: + print(feedback) + _ = Feedback.model_validate({"note": feedback}, strict=True) + + +def test_invalid_feedback_models(): + for feedback in invalid_cases: + print(feedback) + with pytest.raises(ValidationError): + _ = Feedback.model_validate({"note": feedback}, strict=True) From b9b7ecc11d4378a77ab8900d776340970a1266da Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 22:30:05 +0100 Subject: [PATCH 4/8] add feedback api tests --- api/tests/universal/helpers.py | 11 +++ api/tests/universal/test_feedback_creation.py | 68 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 api/tests/universal/test_feedback_creation.py diff --git a/api/tests/universal/helpers.py b/api/tests/universal/helpers.py index a4343a1..56b3ec3 100644 --- a/api/tests/universal/helpers.py +++ b/api/tests/universal/helpers.py @@ -15,3 +15,14 @@ def create_flush(client: TestClient, username: str, password: str, flush: dict): auth=BasicAuth(username=username, password=password), ) assert response.status_code == status.HTTP_201_CREATED + + +def create_feedback(client: TestClient, username: str, password: str, note: str): + response = client.post( + "/feedback", + auth=BasicAuth(username=username, password=password), + params={"note": note}, + ) + print(response.text) + print(response.status_code) + assert response.status_code == status.HTTP_201_CREATED diff --git a/api/tests/universal/test_feedback_creation.py b/api/tests/universal/test_feedback_creation.py new file mode 100644 index 0000000..2734b20 --- /dev/null +++ b/api/tests/universal/test_feedback_creation.py @@ -0,0 +1,68 @@ +import httpx +from fastapi import status +from fastapi.testclient import TestClient +from universal.helpers import create_feedback, create_user + +from api.main import app, get_feedback_count + +client = TestClient(app) + + +def test_insert_new_feedback(): + test_users = { + "testcreatefeedback": "asdasdasd", + "testcreatefeedback2": "asdasdasd", + "testcreatefeedback3": "asdasdasd", + } + notes = [ + "w0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0ww0w", + "w0ww0www0ww0ww0ww0ww0ww0wdsasd", + "ęśąćź_#Ý7ж;ü÷VëÏqÑõfYÛ,ÛéO¡ü$âKÜòUúæeEá2iï:ÇüN¡¹eÑ;yïùà?þ<Ù÷G¹PëgùX8ó", + ] + for test_user in test_users.keys(): + create_user(client, test_user, test_users[test_user]) + for i, note in enumerate(notes): + create_feedback( + client, + test_user, + test_users[test_user], + note, + ) + assert get_feedback_count(test_user) == i + 1 + + +def test_insert_feedback_bad_auth(): + response = client.post( + "/feedback", + auth=httpx.BasicAuth( + username="usernamenonexistent", password="passwordnexistent" + ), + params={"note": "notenotenotenotenotenotenotenotenotenotenotenotenote"}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_insert_feedback_note_too_long(): + create_user(client, "testfeedbacknotetoolong", "testfeedbacknotetoolong") + response = client.post( + "/feedback", + auth=httpx.BasicAuth( + username="testfeedbacknotetoolong", password="testfeedbacknotetoolong" + ), + params={ + "note": "notenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenotenote" # noqa: E501 + }, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_insert_feedback_note_too_short(): + create_user(client, "testfeedbacknotetooshort", "testfeedbacknotetooshort") + response = client.post( + "/feedback", + auth=httpx.BasicAuth( + username="testfeedbacknotetooshort", password="testfeedbacknotetooshort" + ), + params={"note": "note"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY From 6492f8d62922c051e7d61f0653e765f352ff2f73 Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 23:45:31 +0100 Subject: [PATCH 5/8] add feedback api call --- pwa/flush/apicalls.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pwa/flush/apicalls.go b/pwa/flush/apicalls.go index 5055135..41f4d63 100644 --- a/pwa/flush/apicalls.go +++ b/pwa/flush/apicalls.go @@ -316,3 +316,24 @@ func GetStats(ctx app.Context) (FlushStatsInt, error) { statsInt.PercentPhoneUsed = int(stats.PercentPhoneUsed) return statsInt, nil } + +func GiveFeedback(creds Creds, note string) (int, error) { + apiUrl, err := GetApiUrl() + if err != nil { + return 0, err + } + req, err := http.NewRequest("POST", apiUrl+"/feedback", nil) + if err != nil { + return 0, err + } + req.Header.Add("Authorization", "Basic "+creds.UserColonPass) + q := url.Values{} + q.Add("note", note) + req.URL.RawQuery = q.Encode() + r, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer CloseBody(r) + return r.StatusCode, nil +} From c2a3aa6ac661a29308223420c4ff67490020b970 Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 23:45:57 +0100 Subject: [PATCH 6/8] add feedback view --- pwa/flush/components.go | 88 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/pwa/flush/components.go b/pwa/flush/components.go index 362b957..c63960c 100644 --- a/pwa/flush/components.go +++ b/pwa/flush/components.go @@ -145,15 +145,20 @@ func (b *RootContainer) Render() app.UI { app.Div().Body( &buttonLogout{}, &LinkButton{ - Text: "Settings", + Text: "Settings ⚙️", Location: "settings", AdditionalCss: "hover:bg-amber-800 m-1", }, &LinkButton{ - Text: "(+)", - Location: "new", + Text: "Give Feedback ✨", + Location: "feedback", AdditionalCss: "hover:bg-amber-800", }, + &LinkButton{ + Text: "Add 🧻", + Location: "new", + AdditionalCss: "hover:bg-amber-800 m-1", + }, &InstallButton{}, ).ID("root-buttons-container").Class(InviCss), app.P().Text("Tracked flushes:").Class("py-2"), @@ -997,3 +1002,80 @@ func StatsDiv(ctx app.Context) (app.UI, error) { ).Class("flex flex-col p-4 border-1 shadow-lg rounded-lg font-bold"), nil } + +type GiveFeedbackContainer struct { + app.Compo +} + +func (c *GiveFeedbackContainer) OnMount(ctx app.Context) { + var creds Creds + ctx.GetState("creds", &creds) + log.Println("Logged in: ", creds.LoggedIn) + if !creds.LoggedIn { + app.Window().Set("location", "login") + return + } +} +func (c *GiveFeedbackContainer) Render() app.UI { + return app.Div().Body( + &UpdateButton{}, + app.Div().Body( + app.Div().Body( + app.P().Text("Feedback").Class("font-bold"), + app.Br(), + app.Textarea().Placeholder("your feedback").ID( + "feedback-text").MaxLength(300), + app.Br(), + &SubmitFeedbackButton{}, + &LoadingWidget{id: "new-feedback-loading"}, + ).Class("p-4 text-center text-xl shadow-lg bg-zinc-800 rounded-lg"), + app.Br(), + &LinkButton{ + Text: "Back to Home Screen", + Location: ".", + AdditionalCss: "hover:bg-amber-800", + }, + ). + Class("flex flex-col"), + app.Div().Body(&ErrorContainer{}), + ).Class(CenteringDivCss) +} + +type SubmitFeedbackButton struct { + app.Compo +} + +func (c *SubmitFeedbackButton) Render() app.UI { + return app.Button(). + Text("Submit"). + OnClick(c.onClick). + Class(YellowButtonCss + " hover:bg-amber-800").ID("submit-feedback-button") +} +func (c *SubmitFeedbackButton) onClick(ctx app.Context, e app.Event) { + ShowLoading("new-feedback-loading") + var creds Creds + ctx.GetState("creds", &creds) + note := app.Window().GetElementByID("feedback-text").Get("value").String() + if len([]rune(note)) < 30 { + Hide("new-feedback-loading") + ShowErrorDiv(ctx, errors.New("Feedback too short (< 30 characters)"), 1) + return + } + ctx.Async(func() { + statusCode, err := GiveFeedback(creds, note) + ctx.Dispatch(func(ctx app.Context) { + defer Hide("new-feedback-loading") + if err != nil { + Hide("new-feedback-loading") + ShowErrorDiv(ctx, err, 2) + return + } + if statusCode >= 400 { + Hide("new-feedback-loading") + ShowErrorDiv(ctx, errors.New("Failed to submit feedback"), 2) + return + } + }) + }) + app.Window().Set("location", ".") +} From e18f6afa5996156b217a38ca80dcbc183706ab6f Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 23:46:11 +0100 Subject: [PATCH 7/8] mount /feedback --- pwa/flush/pwa.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pwa/flush/pwa.go b/pwa/flush/pwa.go index 54cfcf6..796a74c 100644 --- a/pwa/flush/pwa.go +++ b/pwa/flush/pwa.go @@ -15,6 +15,7 @@ func Run() { app.Route("/login", func() app.Composer { return &LoginContainer{} }) app.Route("/new", func() app.Composer { return &NewFlushContainer{} }) app.Route("/settings", func() app.Composer { return &SettingsContainer{} }) + app.Route("/feedback", func() app.Composer { return &GiveFeedbackContainer{} }) app.RunWhenOnBrowser() if os.Getenv("BUILD_STATIC") == "true" { From d60299116aa3e7268e19a2bb22c47a6699d1b8ee Mon Sep 17 00:00:00 2001 From: Piotr Gulbinowicz Date: Wed, 20 Nov 2024 23:46:30 +0100 Subject: [PATCH 8/8] add pwa feedback tests --- pwa/test/integration/feedback_test.go | 33 +++++++++++++++++++ pwa/test/integration/helpers.go | 46 +++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 pwa/test/integration/feedback_test.go diff --git a/pwa/test/integration/feedback_test.go b/pwa/test/integration/feedback_test.go new file mode 100644 index 0000000..0e1c5ad --- /dev/null +++ b/pwa/test/integration/feedback_test.go @@ -0,0 +1,33 @@ +package intest + +import ( + "testing" +) + +func TestFeedback(t *testing.T) { + user, pass := "feedback_create_test", "feedback_create_test" + p, b, err := CreateFeedback( + true, + user, + pass, + "testing feedback text lolololololol", + "placeholder", + ) + defer b.MustClose() + defer p.MustClose() + if err != nil { + t.Fatal(err) + } + p2, b2, err := CreateFeedback( + false, + user, + pass, + "too short", + "Feedback too short", + ) + defer b2.MustClose() + defer p2.MustClose() + if err != nil { + t.Fatal(err) + } +} diff --git a/pwa/test/integration/helpers.go b/pwa/test/integration/helpers.go index 1379764..0313f18 100644 --- a/pwa/test/integration/helpers.go +++ b/pwa/test/integration/helpers.go @@ -136,6 +136,19 @@ func Login(username string, password string) (*rod.Page, *rod.Browser) { return p, b } +func RegisterAndGoToFeedback(user string, pass string, + repeatPass string) (*rod.Page, *rod.Browser) { + log.Println("using RegisterAndGoToFeedback()") + p, b := Register(user, pass, repeatPass) + p.Navigate(os.Getenv("GOAPP_URL") + "/feedback") + err := p.WaitStable(time.Second * 2) + if err != nil { + log.Fatal(err) + } + log.Println("return from RegisterAndGoToFeedback()") + return p, b +} + func CreateFlush( register bool, user string, @@ -172,3 +185,36 @@ func CreateFlush( } return p, b, nil } +func CreateFeedback( + register bool, + user string, + pass string, + note string, + expectedErrorDivText string, +) (*rod.Page, *rod.Browser, error) { + var p *rod.Page + var b *rod.Browser + if register { + p, b = RegisterAndGoToFeedback(user, pass, pass) + } else { + p, b = Login(user, pass) + err := p.Navigate(os.Getenv("GOAPP_URL") + "/feedback") + if err != nil { + return p, b, err + } + err = p.WaitStable(time.Second * 2) + if err != nil { + return p, b, err + } + } + p.MustElement("#feedback-text").MustInput(note) + p.MustElement("#submit-feedback-button").MustClick() + err := p.WaitStable(time.Second * 2) + if err != nil { + return p, b, err + } + if err := CheckErrorDivText(p, expectedErrorDivText); err != nil { + return p, b, err + } + return p, b, nil +}