diff --git a/Dockerfile b/Dockerfile index 199dc54..d45d7f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # builder -FROM python:3.11.5-bullseye as builder +FROM python:3.14.3-alpine as builder RUN mkdir /opt/fooder WORKDIR /opt/fooder @@ -10,17 +10,17 @@ COPY fooder /opt/fooder/fooder COPY setup.py /opt/fooder/setup.py RUN python /opt/fooder/setup.py sdist -RUN mv /opt/fooder/dist/FooderApi*.tar.gz /opt/fooder/dist/fooder.tar.gz +RUN mv /opt/fooder/dist/*.tar.gz /opt/fooder/dist/fooder.tar.gz # final image -FROM python:3.11.5-bullseye +FROM python:3.14.3-alpine -RUN apt-get -y install libpq-dev +RUN apk add --no-cache postgresql-dev COPY requirements/docker.txt requirements.txt RUN pip install -r requirements.txt -RUN useradd fooder +RUN adduser -D fooder RUN mkdir /opt/fooder && chown fooder:fooder /opt/fooder WORKDIR /opt/fooder diff --git a/doc/openapi.json b/doc/openapi.json new file mode 100644 index 0000000..0f7a37e --- /dev/null +++ b/doc/openapi.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Fooder","version":"0.1.0"},"paths":{"/api/token":{"post":{"tags":["token","token"],"summary":"Token Create","operationId":"token_create_api_token_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_token_create_api_token_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/token/refresh":{"post":{"tags":["token","token"],"summary":"Token Refresh","operationId":"token_refresh_api_token_refresh_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshTokenRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/product":{"get":{"tags":["product","product"],"summary":"List Products","operationId":"list_products_api_product_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":10,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","default":0,"title":"Offset"}},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Q"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProductModel"},"title":"Response List Products Api Product Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["product","product"],"summary":"Create Product","operationId":"create_product_api_product_post","security":[{"OAuth2PasswordBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductCreateModel"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/product/{product_id}":{"patch":{"tags":["product","product"],"summary":"Update Product","operationId":"update_product_api_product__product_id__patch","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"product_id","in":"path","required":true,"schema":{"type":"integer","title":"Product Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductUpdateModel"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/product/barcode/{barcode}":{"get":{"tags":["product","product"],"summary":"Get By Barcode","operationId":"get_by_barcode_api_product_barcode__barcode__get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"barcode","in":"path","required":true,"schema":{"type":"string","title":"Barcode"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProductModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/user":{"post":{"tags":["user","user"],"summary":"User Create","operationId":"user_create_api_user_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreateModel"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/user/password":{"patch":{"tags":["user","user"],"summary":"User Change Password","operationId":"user_change_password_api_user_password_patch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserChangePasswordModel"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/user/settings":{"get":{"tags":["user_settings","user_settings"],"summary":"Get User Settings","operationId":"get_user_settings_api_user_settings_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSettingsModel"}}}}},"security":[{"OAuth2PasswordBearer":[]}]},"patch":{"tags":["user_settings","user_settings"],"summary":"Update User Settings","operationId":"update_user_settings_api_user_settings_patch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSettingsUpdateModel"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserSettingsModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/diary/{date}":{"get":{"tags":["diary","diary"],"summary":"Get Diary","operationId":"get_diary_api_diary__date__get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["diary","diary"],"summary":"Update Diary","operationId":"update_diary_api_diary__date__patch","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryUpdateModel"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/diary":{"post":{"tags":["diary","diary"],"summary":"Create Diary Route","operationId":"create_diary_route_api_diary_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryCreateModel"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiaryModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"OAuth2PasswordBearer":[]}]}},"/api/diary/{date}/meal":{"post":{"tags":["meal","meal"],"summary":"Create Meal","operationId":"create_meal_api_diary__date__meal_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealCreateModel"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/diary/{date}/meal/from_preset":{"post":{"tags":["meal","meal"],"summary":"Load Preset As Meal View","operationId":"load_preset_as_meal_view_api_diary__date__meal_from_preset_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoadPresetAsMealModel"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/diary/{date}/meal/{meal_id}":{"patch":{"tags":["meal","meal"],"summary":"Update Meal","operationId":"update_meal_api_diary__date__meal__meal_id__patch","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}},{"name":"meal_id","in":"path","required":true,"schema":{"type":"integer","title":"Meal Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealUpdateModel"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MealModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["meal","meal"],"summary":"Delete Meal","operationId":"delete_meal_api_diary__date__meal__meal_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}},{"name":"meal_id","in":"path","required":true,"schema":{"type":"integer","title":"Meal Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/diary/{date}/meal/{meal_id}/preset":{"post":{"tags":["meal","meal"],"summary":"Save Meal As Preset View","operationId":"save_meal_as_preset_view_api_diary__date__meal__meal_id__preset_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}},{"name":"meal_id","in":"path","required":true,"schema":{"type":"integer","title":"Meal Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SaveAsPresetModel"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/diary/{date}/meal/{meal_id}/entry":{"post":{"tags":["entry","entry"],"summary":"Create Entry Route","operationId":"create_entry_route_api_diary__date__meal__meal_id__entry_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}},{"name":"meal_id","in":"path","required":true,"schema":{"type":"integer","title":"Meal Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryCreateModel"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/diary/{date}/meal/{meal_id}/entry/{entry_id}":{"patch":{"tags":["entry","entry"],"summary":"Update Entry","operationId":"update_entry_api_diary__date__meal__meal_id__entry__entry_id__patch","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}},{"name":"meal_id","in":"path","required":true,"schema":{"type":"integer","title":"Meal Id"}},{"name":"entry_id","in":"path","required":true,"schema":{"type":"integer","title":"Entry Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryUpdateModel"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["entry","entry"],"summary":"Delete Entry","operationId":"delete_entry_api_diary__date__meal__meal_id__entry__entry_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Date"}},{"name":"meal_id","in":"path","required":true,"schema":{"type":"integer","title":"Meal Id"}},{"name":"entry_id","in":"path","required":true,"schema":{"type":"integer","title":"Entry Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/preset":{"get":{"tags":["preset"],"summary":"List Presets","operationId":"list_presets_api_preset_get","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":10,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","default":0,"title":"Offset"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PresetModel"},"title":"Response List Presets Api Preset Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/preset/{preset_id}":{"patch":{"tags":["preset"],"summary":"Update Preset","operationId":"update_preset_api_preset__preset_id__patch","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preset_id","in":"path","required":true,"schema":{"type":"integer","title":"Preset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetUpdateModel"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["preset"],"summary":"Delete Preset","operationId":"delete_preset_api_preset__preset_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preset_id","in":"path","required":true,"schema":{"type":"integer","title":"Preset Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/preset/{preset_id}/entry":{"post":{"tags":["preset"],"summary":"Create Preset Entry","operationId":"create_preset_entry_api_preset__preset_id__entry_post","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preset_id","in":"path","required":true,"schema":{"type":"integer","title":"Preset Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryCreateModel"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetEntryModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/preset/{preset_id}/entry/{entry_id}":{"patch":{"tags":["preset"],"summary":"Update Preset Entry","operationId":"update_preset_entry_api_preset__preset_id__entry__entry_id__patch","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preset_id","in":"path","required":true,"schema":{"type":"integer","title":"Preset Id"}},{"name":"entry_id","in":"path","required":true,"schema":{"type":"integer","title":"Entry Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntryUpdateModel"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresetEntryModel"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["preset"],"summary":"Delete Preset Entry","operationId":"delete_preset_entry_api_preset__preset_id__entry__entry_id__delete","security":[{"OAuth2PasswordBearer":[]}],"parameters":[{"name":"preset_id","in":"path","required":true,"schema":{"type":"integer","title":"Preset Id"}},{"name":"entry_id","in":"path","required":true,"schema":{"type":"integer","title":"Entry Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"Body_token_create_api_token_post":{"properties":{"grant_type":{"anyOf":[{"type":"string","pattern":"^password$"},{"type":"null"}],"title":"Grant Type"},"username":{"type":"string","title":"Username"},"password":{"type":"string","format":"password","title":"Password"},"scope":{"type":"string","title":"Scope","default":""},"client_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Id"},"client_secret":{"anyOf":[{"type":"string"},{"type":"null"}],"format":"password","title":"Client Secret"}},"type":"object","required":["username","password"],"title":"Body_token_create_api_token_post"},"DiaryCreateModel":{"properties":{"date":{"type":"string","format":"date","title":"Date"}},"type":"object","required":["date"],"title":"DiaryCreateModel"},"DiaryModel":{"properties":{"id":{"type":"integer","title":"Id"},"date":{"type":"string","format":"date","title":"Date"},"protein_goal":{"type":"number","minimum":0.0,"title":"Protein Goal"},"carb_goal":{"type":"number","minimum":0.0,"title":"Carb Goal"},"fat_goal":{"type":"number","minimum":0.0,"title":"Fat Goal"},"fiber_goal":{"type":"number","minimum":0.0,"title":"Fiber Goal"},"calories_goal":{"type":"number","minimum":0.0,"title":"Calories Goal"},"protein":{"type":"number","title":"Protein"},"carb":{"type":"number","title":"Carb"},"fat":{"type":"number","title":"Fat"},"fiber":{"type":"number","title":"Fiber"},"calories":{"type":"number","title":"Calories"},"meals":{"items":{"$ref":"#/components/schemas/MealModel"},"type":"array","title":"Meals"}},"type":"object","required":["id","date","protein_goal","carb_goal","fat_goal","fiber_goal","calories_goal","protein","carb","fat","fiber","calories","meals"],"title":"DiaryModel"},"DiaryUpdateModel":{"properties":{"protein_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Protein Goal"},"carb_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Carb Goal"},"fat_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Fat Goal"},"fiber_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Fiber Goal"},"calories_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Calories Goal"}},"type":"object","title":"DiaryUpdateModel"},"EntryCreateModel":{"properties":{"grams":{"type":"number","exclusiveMinimum":0.0,"title":"Grams"},"product_id":{"type":"integer","title":"Product Id"}},"type":"object","required":["grams","product_id"],"title":"EntryCreateModel"},"EntryModel":{"properties":{"id":{"type":"integer","title":"Id"},"grams":{"type":"number","exclusiveMinimum":0.0,"title":"Grams"},"product_id":{"type":"integer","title":"Product Id"},"meal_id":{"type":"integer","title":"Meal Id"},"product":{"$ref":"#/components/schemas/ProductModel"},"protein":{"type":"number","title":"Protein"},"carb":{"type":"number","title":"Carb"},"fat":{"type":"number","title":"Fat"},"fiber":{"type":"number","title":"Fiber"},"calories":{"type":"number","title":"Calories"}},"type":"object","required":["id","grams","product_id","meal_id","product","protein","carb","fat","fiber","calories"],"title":"EntryModel"},"EntryUpdateModel":{"properties":{"grams":{"anyOf":[{"type":"number","exclusiveMinimum":0.0},{"type":"null"}],"title":"Grams"}},"type":"object","title":"EntryUpdateModel"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"LoadPresetAsMealModel":{"properties":{"preset_id":{"type":"integer","title":"Preset Id"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["preset_id"],"title":"LoadPresetAsMealModel"},"MealCreateModel":{"properties":{"name":{"type":"string","title":"Name"},"order":{"anyOf":[{"type":"integer","minimum":0.0},{"type":"null"}],"title":"Order"}},"type":"object","required":["name"],"title":"MealCreateModel"},"MealModel":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"order":{"type":"integer","minimum":0.0,"title":"Order"},"diary_id":{"type":"integer","title":"Diary Id"},"protein":{"type":"number","title":"Protein"},"carb":{"type":"number","title":"Carb"},"fat":{"type":"number","title":"Fat"},"fiber":{"type":"number","title":"Fiber"},"calories":{"type":"number","title":"Calories"},"entries":{"items":{"$ref":"#/components/schemas/EntryModel"},"type":"array","title":"Entries"}},"type":"object","required":["id","name","order","diary_id","protein","carb","fat","fiber","calories","entries"],"title":"MealModel"},"MealUpdateModel":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"order":{"anyOf":[{"type":"integer","minimum":0.0},{"type":"null"}],"title":"Order"}},"type":"object","title":"MealUpdateModel"},"PresetEntryModel":{"properties":{"id":{"type":"integer","title":"Id"},"grams":{"type":"number","exclusiveMinimum":0.0,"title":"Grams"},"product_id":{"type":"integer","title":"Product Id"},"preset_id":{"type":"integer","title":"Preset Id"},"product":{"$ref":"#/components/schemas/ProductModel"},"protein":{"type":"number","title":"Protein"},"carb":{"type":"number","title":"Carb"},"fat":{"type":"number","title":"Fat"},"fiber":{"type":"number","title":"Fiber"},"calories":{"type":"number","title":"Calories"}},"type":"object","required":["id","grams","product_id","preset_id","product","protein","carb","fat","fiber","calories"],"title":"PresetEntryModel"},"PresetModel":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"user_id":{"type":"integer","title":"User Id"},"protein":{"type":"number","title":"Protein"},"carb":{"type":"number","title":"Carb"},"fat":{"type":"number","title":"Fat"},"fiber":{"type":"number","title":"Fiber"},"calories":{"type":"number","title":"Calories"},"entries":{"items":{"$ref":"#/components/schemas/PresetEntryModel"},"type":"array","title":"Entries"}},"type":"object","required":["id","name","user_id","protein","carb","fat","fiber","calories","entries"],"title":"PresetModel"},"PresetUpdateModel":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","title":"PresetUpdateModel"},"ProductCreateModel":{"properties":{"name":{"type":"string","title":"Name"},"protein":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Protein"},"carb":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Carb"},"fat":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Fat"},"fiber":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Fiber"},"calories":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Calories"},"barcode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Barcode"}},"type":"object","required":["name","protein","carb","fat","fiber"],"title":"ProductCreateModel"},"ProductModel":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"protein":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Protein"},"carb":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Carb"},"fat":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Fat"},"fiber":{"type":"number","maximum":100.0,"minimum":0.0,"title":"Fiber"},"calories":{"type":"number","minimum":0.0,"title":"Calories"},"barcode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Barcode"}},"type":"object","required":["id","name","protein","carb","fat","fiber","calories"],"title":"ProductModel"},"ProductUpdateModel":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"protein":{"anyOf":[{"type":"number","maximum":100.0,"minimum":0.0},{"type":"null"}],"title":"Protein"},"carb":{"anyOf":[{"type":"number","maximum":100.0,"minimum":0.0},{"type":"null"}],"title":"Carb"},"fat":{"anyOf":[{"type":"number","maximum":100.0,"minimum":0.0},{"type":"null"}],"title":"Fat"},"fiber":{"anyOf":[{"type":"number","maximum":100.0,"minimum":0.0},{"type":"null"}],"title":"Fiber"},"calories":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Calories"},"barcode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Barcode"}},"type":"object","title":"ProductUpdateModel"},"RefreshTokenRequest":{"properties":{"refresh_token":{"type":"string","title":"Refresh Token"}},"type":"object","required":["refresh_token"],"title":"RefreshTokenRequest"},"SaveAsPresetModel":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","title":"SaveAsPresetModel"},"TokenResponse":{"properties":{"access_token":{"type":"string","title":"Access Token"},"refresh_token":{"type":"string","title":"Refresh Token"},"token_type":{"type":"string","title":"Token Type","default":"bearer"}},"type":"object","required":["access_token","refresh_token"],"title":"TokenResponse"},"UserChangePasswordModel":{"properties":{"current_password":{"type":"string","title":"Current Password"},"new_password":{"type":"string","maxLength":128,"minLength":8,"title":"New Password"}},"type":"object","required":["current_password","new_password"],"title":"UserChangePasswordModel"},"UserCreateModel":{"properties":{"username":{"type":"string","maxLength":64,"minLength":1,"title":"Username"},"password":{"type":"string","maxLength":128,"minLength":8,"title":"Password"},"captcha_token":{"type":"string","title":"Captcha Token"}},"type":"object","required":["username","password","captcha_token"],"title":"UserCreateModel"},"UserSettingsModel":{"properties":{"id":{"type":"integer","title":"Id"},"protein_goal":{"type":"number","minimum":0.0,"title":"Protein Goal"},"carb_goal":{"type":"number","minimum":0.0,"title":"Carb Goal"},"fat_goal":{"type":"number","minimum":0.0,"title":"Fat Goal"},"fiber_goal":{"type":"number","minimum":0.0,"title":"Fiber Goal"},"calories_goal":{"type":"number","minimum":0.0,"title":"Calories Goal"}},"type":"object","required":["id","protein_goal","carb_goal","fat_goal","fiber_goal","calories_goal"],"title":"UserSettingsModel"},"UserSettingsUpdateModel":{"properties":{"protein_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Protein Goal"},"carb_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Carb Goal"},"fat_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Fat Goal"},"fiber_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Fiber Goal"},"calories_goal":{"anyOf":[{"type":"number","minimum":0.0},{"type":"null"}],"title":"Calories Goal"}},"type":"object","title":"UserSettingsUpdateModel"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"OAuth2PasswordBearer":{"type":"oauth2","flows":{"password":{"scopes":{},"tokenUrl":"/token"}}}}}} \ No newline at end of file diff --git a/fooder/alembic/versions/a1b2c3d4e5f6_.py b/fooder/alembic/versions/a1b2c3d4e5f6_.py new file mode 100644 index 0000000..854cf35 --- /dev/null +++ b/fooder/alembic/versions/a1b2c3d4e5f6_.py @@ -0,0 +1,24 @@ +"""add unique constraint to user.username + +Revision ID: a1b2c3d4e5f6 +Revises: 4e8d78ff6e9e +Create Date: 2026-04-07 15:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "4e8d78ff6e9e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_unique_constraint("uq_user_username", "user", ["username"]) + + +def downgrade() -> None: + op.drop_constraint("uq_user_username", "user", type_="unique") diff --git a/fooder/captcha.py b/fooder/captcha.py new file mode 100644 index 0000000..021c6bf --- /dev/null +++ b/fooder/captcha.py @@ -0,0 +1,19 @@ +import httpx + +from fooder.exc import CaptchaFailed +from fooder.settings import settings + + +async def verify_turnstile(token: str, ip: str | None = None) -> None: + data = {"secret": settings.TURNSTILE_SECRET_KEY, "response": token} + if ip: + data["remoteip"] = ip + + async with httpx.AsyncClient() as client: + r = await client.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data=data, + ) + + if not r.json().get("success"): + raise CaptchaFailed() diff --git a/fooder/command/create_user.py b/fooder/command/create_user.py new file mode 100644 index 0000000..fc2b40f --- /dev/null +++ b/fooder/command/create_user.py @@ -0,0 +1,21 @@ +from fooder.context import Context +from fooder.domain.user import User +from fooder.domain.user_settings import UserSettings + + +async def create_user(ctx: Context, username: str, password: str) -> User: + user = User(username=username) + user.set_password(password) + await ctx.repo.user.create(user) + + user_settings = UserSettings( + user_id=user.id, + protein_goal=0.0, + carb_goal=0.0, + fat_goal=0.0, + fiber_goal=0.0, + calories_goal=0.0, + ) + await ctx.repo.user_settings.create(user_settings) + + return user diff --git a/fooder/controller/user.py b/fooder/controller/user.py index 46333dd..150051d 100644 --- a/fooder/controller/user.py +++ b/fooder/controller/user.py @@ -21,3 +21,9 @@ class UserController(ModelController[User]): raise Unauthorized() return cls(ctx, obj) + + async def change_password(self, current_password: str, new_password: str) -> None: + if not self.obj.verify_password(current_password): + raise Unauthorized() + self.obj.set_password(new_password) + await self.ctx.repo.user.update(self.obj) diff --git a/fooder/domain/user.py b/fooder/domain/user.py index 4e78c0b..d5cf22d 100644 --- a/fooder/domain/user.py +++ b/fooder/domain/user.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from sqlalchemy.orm import Mapped, relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship from fooder.domain.base import Base, CommonMixin, PasswordMixin, SoftDeleteMixin @@ -13,7 +13,7 @@ if TYPE_CHECKING: class User(Base, CommonMixin, PasswordMixin, SoftDeleteMixin): """User.""" - username: Mapped[str] + username: Mapped[str] = mapped_column(unique=True) settings: Mapped[UserSettings] = relationship( back_populates="user", lazy="selectin", uselist=False ) diff --git a/fooder/exc.py b/fooder/exc.py index 0122172..ef289b2 100644 --- a/fooder/exc.py +++ b/fooder/exc.py @@ -28,3 +28,8 @@ class InvalidValue(ApiException): class Conflict(ApiException): HTTP_CODE = 409 MESSAGE = "Conflict" + + +class CaptchaFailed(ApiException): + HTTP_CODE = 403 + MESSAGE = "Captcha verification failed" diff --git a/fooder/model/user.py b/fooder/model/user.py new file mode 100644 index 0000000..125d82c --- /dev/null +++ b/fooder/model/user.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from pydantic import BaseModel, Field + +Password = Annotated[str, Field(min_length=8, max_length=128)] + + +class UserCreateModel(BaseModel): + username: Annotated[str, Field(min_length=1, max_length=64)] + password: Password + captcha_token: str + + +class UserChangePasswordModel(BaseModel): + current_password: str + new_password: Password diff --git a/fooder/router.py b/fooder/router.py index 722fb5c..e980120 100644 --- a/fooder/router.py +++ b/fooder/router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from fooder.view.token import router as token_router from fooder.view.product import router as product_router +from fooder.view.user import router as user_router from fooder.view.user_settings import router as user_settings_router from fooder.view.diary import router as diary_router from fooder.view.meal import router as meal_router @@ -11,6 +12,7 @@ from fooder.view.preset import router as preset_router router = APIRouter(prefix="/api") router.include_router(token_router, prefix="/token", tags=["token"]) router.include_router(product_router, prefix="/product", tags=["product"]) +router.include_router(user_router, prefix="/user", tags=["user"]) router.include_router( user_settings_router, prefix="/user/settings", tags=["user_settings"] ) diff --git a/fooder/settings.py b/fooder/settings.py index b02b68c..877f8c2 100644 --- a/fooder/settings.py +++ b/fooder/settings.py @@ -17,5 +17,7 @@ class Settings(BaseSettings): PASSWORD_SCHEMES: list[str] = ["bcrypt"] + TURNSTILE_SECRET_KEY: str + settings = Settings() diff --git a/fooder/test/conftest.py b/fooder/test/conftest.py index 531e7d1..0bdd7e3 100644 --- a/fooder/test/conftest.py +++ b/fooder/test/conftest.py @@ -11,6 +11,7 @@ os.environ.update( "SECRET_KEY": "test-secret", "REFRESH_SECRET_KEY": "test-refresh", "API_KEY": "test-key", + "TURNSTILE_SECRET_KEY": "test-turnstile", } ) diff --git a/fooder/test/view/test_user.py b/fooder/test/view/test_user.py new file mode 100644 index 0000000..7c4f084 --- /dev/null +++ b/fooder/test/view/test_user.py @@ -0,0 +1,112 @@ +import pytest + +import fooder.view.user as user_view +from fooder.exc import CaptchaFailed + + +@pytest.fixture(autouse=True) +def bypass_captcha(monkeypatch): + async def _noop(token: str, ip=None) -> None: + pass + + monkeypatch.setattr(user_view, "verify_turnstile", _noop) + + +@pytest.fixture +def new_user_payload(): + return { + "username": "newuser", + "password": "securepassword1", + "captcha_token": "test-token", + } + + +async def test_create_user_returns_201(client, new_user_payload): + response = await client.post("/api/user", json=new_user_payload) + assert response.status_code == 201 + + +async def test_create_user_returns_tokens(client, new_user_payload): + response = await client.post("/api/user", json=new_user_payload) + body = response.json() + assert "access_token" in body + assert "refresh_token" in body + assert body["token_type"] == "bearer" + + +async def test_create_user_can_login(client, new_user_payload): + await client.post("/api/user", json=new_user_payload) + response = await client.post( + "/api/token", + data={ + "username": new_user_payload["username"], + "password": new_user_payload["password"], + }, + ) + assert response.status_code == 200 + + +async def test_create_user_duplicate_username_returns_409(client, new_user_payload): + await client.post("/api/user", json=new_user_payload) + response = await client.post("/api/user", json=new_user_payload) + assert response.status_code == 409 + + +async def test_create_user_password_too_short_returns_422(client, new_user_payload): + new_user_payload["password"] = "short" + response = await client.post("/api/user", json=new_user_payload) + assert response.status_code == 422 + + +async def test_create_user_captcha_failure_returns_403(client, monkeypatch, new_user_payload): + async def _fail(token: str, ip=None) -> None: + raise CaptchaFailed() + + monkeypatch.setattr(user_view, "verify_turnstile", _fail) + response = await client.post("/api/user", json=new_user_payload) + assert response.status_code == 403 + + +async def test_change_password_returns_204(auth_client, user_password): + response = await auth_client.patch( + "/api/user/password", + json={"current_password": user_password, "new_password": "newpassword1"}, + ) + assert response.status_code == 204 + + +async def test_change_password_can_login_with_new_password(auth_client, user, user_password): + new_password = "newpassword1" + await auth_client.patch( + "/api/user/password", + json={"current_password": user_password, "new_password": new_password}, + ) + response = await auth_client.post( + "/api/token", + data={"username": user.username, "password": new_password}, + ) + assert response.status_code == 200 + + +async def test_change_password_wrong_current_returns_401(auth_client): + response = await auth_client.patch( + "/api/user/password", + json={"current_password": "wrongpassword", "new_password": "newpassword1"}, + ) + assert response.status_code == 401 + + +async def test_change_password_new_too_short_returns_422(auth_client, user_password): + response = await auth_client.patch( + "/api/user/password", + json={"current_password": user_password, "new_password": "short"}, + ) + assert response.status_code == 422 + + +async def test_change_password_unauthenticated_returns_401(client): + response = await client.patch( + "/api/user/password", + json={"current_password": "any", "new_password": "newpassword1"}, + ) + assert response.status_code == 401 diff --git a/fooder/view/user.py b/fooder/view/user.py new file mode 100644 index 0000000..44c0d3b --- /dev/null +++ b/fooder/view/user.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, Request + +from fooder.captcha import verify_turnstile +from fooder.command.create_user import create_user +from fooder.context import Context, ContextDependency, AuthContextDependency +from fooder.controller.user import UserController +from fooder.model.token import TokenResponse +from fooder.model.user import UserCreateModel, UserChangePasswordModel +from fooder.view.token import gen_token_response + +router = APIRouter(tags=["user"]) + +_ctx = ContextDependency() +_auth_ctx = AuthContextDependency() + + +@router.post("", response_model=TokenResponse, status_code=201) +async def user_create( + data: UserCreateModel, + request: Request, + ctx: Context = Depends(_ctx), +) -> TokenResponse: + ip = request.client.host if request.client else None + await verify_turnstile(data.captcha_token, ip) + async with ctx.repo.transaction(): + user = await create_user(ctx, data.username, data.password) + return gen_token_response(user.id, ctx.clock()) + + +@router.patch("/password", status_code=204) +async def user_change_password( + data: UserChangePasswordModel, + ctx: Context = Depends(_auth_ctx), +) -> None: + async with ctx.repo.transaction(): + await UserController(ctx, ctx.user).change_password( + data.current_password, data.new_password + ) diff --git a/requirements/docker.txt b/requirements/docker.txt index ba4a37f..5b4e6f0 100644 --- a/requirements/docker.txt +++ b/requirements/docker.txt @@ -5,10 +5,11 @@ sqlalchemy[postgresql_asyncpg] alembic uvicorn[standard] asyncpg -psycopg2-binary==2.9.3 +psycopg2-binary python-jose[cryptography] bcrypt<5.0.0 passlib[bcrypt] fastapi-users requests black # for alembic +httpx