Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import csv
import datetime
import io
import json
import logging
Expand All @@ -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 = [
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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})
19 changes: 15 additions & 4 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
29 changes: 29 additions & 0 deletions api/tests/unit/test_feedback_model.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions api/tests/universal/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 68 additions & 0 deletions api/tests/universal/test_feedback_creation.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions pwa/flush/apicalls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
88 changes: 85 additions & 3 deletions pwa/flush/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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", ".")
}
1 change: 1 addition & 0 deletions pwa/flush/pwa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
33 changes: 33 additions & 0 deletions pwa/test/integration/feedback_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading