From 4c15fa8843330f93c83c8429fe6dface5d0f71be Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Wed, 15 Nov 2023 09:26:34 +0100 Subject: [PATCH 01/95] Normalize endpoints (final '/') redirect endpoints ends by '/' to the endpoints without '/' --- main.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/main.py b/main.py index 1cfe210..e3d5439 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ from fastapi import Depends, FastAPI, HTTPException from sqlalchemy.orm import Session from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse import crud import models @@ -42,12 +43,19 @@ def validate_access_level( ) +@app.middleware("http") +async def normalize_path_middleware(request, call_next): + if request.url.path.endswith("/"): + # if the URL ends by "/", redirect to the same URL without "/" + return RedirectResponse(request.url.path[:-1], status_code=301) + return await call_next(request) + @app.get("/healthz", status_code=200) def health_check(): return {"status": "ok"} -@app.post("/exercises/", response_model=schemas.FullExerciseResponse) +@app.post("/exercises", response_model=schemas.FullExerciseResponse) def create_exercise( exercise: schemas.ExerciseCreate, db: Session = Depends(get_db), @@ -57,7 +65,7 @@ def create_exercise( return crud.create_exercise(db=db, exercise=exercise) -@app.get("/exercises/", response_model=list[schemas.ExerciseResponse]) +@app.get("/exercises", response_model=list[schemas.ExerciseResponse]) def read_exercises( skip: int = 0, limit: int = 100, @@ -129,7 +137,7 @@ def update_exercise( return updated_exercise -@app.post("/accounts/", response_model=schemas.AccountOut) +@app.post("/accounts", response_model=schemas.AccountOut) def create_account( account_in: schemas.AccountIn, db: Session = Depends(get_db), @@ -142,7 +150,7 @@ def create_account( return account_saved -@app.post("/register/", response_model=schemas.AccountOut) +@app.post("/register", response_model=schemas.AccountOut) def register_account(account_in: schemas.AccountIn, db: Session = Depends(get_db)): if crud.email_is_registered(db, account_in.email): raise HTTPException(status_code=409, detail="Email already registered") @@ -150,7 +158,7 @@ def register_account(account_in: schemas.AccountIn, db: Session = Depends(get_db return account_saved -@app.get("/accounts/", response_model=list[schemas.AccountOut]) +@app.get("/accounts", response_model=list[schemas.AccountOut]) def get_all_accounts( skip: int = 0, limit: int = 100, @@ -212,7 +220,7 @@ def update_account( # User -@app.get("/users/", response_model=list[schemas.UserSchema]) +@app.get("/users", response_model=list[schemas.UserSchema]) def read_all_users( skip: int = 0, limit: int = 100, @@ -259,7 +267,7 @@ def delete_user( return {"ok": True} -@app.post("/users/", response_model=schemas.UserSchema) +@app.post("/users", response_model=schemas.UserSchema) def create_user( user: schemas.UserCreate, db: Session = Depends(get_db), @@ -276,7 +284,7 @@ def create_category(category: str, db: Session = Depends(get_db)): return crud.create_category(db=db, category=category) -@app.get("/categories/", response_model=list[str]) +@app.get("/categories", response_model=list[str]) def read_categories( skip: int = 0, limit: int = 100, @@ -345,7 +353,7 @@ def me( # CreateSuperadmin just for Debugging -@app.post("/createsuperadmin/", response_model=schemas.AccountOut) +@app.post("/createsuperadmin", response_model=schemas.AccountOut) def createsuperadmin_only_for_debugging( account_in: schemas.AccountIn, db: Session = Depends(get_db) ): From e7858cd4ae2f095e48c898bb8ac293f987fcfefc Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Wed, 15 Nov 2023 09:29:02 +0100 Subject: [PATCH 02/95] test endpoints ends by '\' or not --- test/test_categories.py | 2 +- test/test_exercises.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_categories.py b/test/test_categories.py index 9c218c9..d273e33 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -107,7 +107,7 @@ def test_update_exercise_with_category(admin_token): def test_rename_category(admin_token): - categories = client.get("http://localhost:8000/categories").json() + categories = client.get("http://localhost:8000/categories/").json() assert set(categories) == {"cats", "dogs", "mice"} body = {"category": "one mouse"} updated_category = client.patch( diff --git a/test/test_exercises.py b/test/test_exercises.py index f2cea30..603394c 100644 --- a/test/test_exercises.py +++ b/test/test_exercises.py @@ -45,7 +45,7 @@ def test_create_exercise_without_complexity(admin_token): assert created_exercise[key] == new_exercise[key] assert created_exercise["complexity"] == None exercises = client.get( - "http://localhost:8000/exercises", headers=auth_header(admin_token) + "http://localhost:8000/exercises/", headers=auth_header(admin_token) ).json() assert len(exercises) == exercise_count + 1 @@ -59,7 +59,7 @@ def test_get_exercises(admin_token): def test_get_exercise(admin_token): exercises = client.get( - "http://localhost:8000/exercises", headers=auth_header(admin_token) + "http://localhost:8000/exercises/", headers=auth_header(admin_token) ).json() exercise_id = exercises[0]["id"] exercise = client.get( From cf5e219fb74abb3242f1d9cf49f12f952714d616 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Wed, 15 Nov 2023 10:16:16 +0100 Subject: [PATCH 03/95] fix return object of redirection Before post exercises ends by a '/' returned [] --- main.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index e3d5439..522e81e 100644 --- a/main.py +++ b/main.py @@ -43,11 +43,10 @@ def validate_access_level( ) -@app.middleware("http") -async def normalize_path_middleware(request, call_next): +async def redirect_trailing_slash(request, call_next): if request.url.path.endswith("/"): - # if the URL ends by "/", redirect to the same URL without "/" - return RedirectResponse(request.url.path[:-1], status_code=301) + url_without_trailing_slash = str(request.url)[:-1] + return RedirectResponse(url=url_without_trailing_slash, status_code=301) return await call_next(request) @app.get("/healthz", status_code=200) From 7426746ddfa0cc293785ed7d17997b83842c548a Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Wed, 15 Nov 2023 10:39:00 +0100 Subject: [PATCH 04/95] black . --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index 522e81e..690239c 100644 --- a/main.py +++ b/main.py @@ -49,6 +49,7 @@ async def redirect_trailing_slash(request, call_next): return RedirectResponse(url=url_without_trailing_slash, status_code=301) return await call_next(request) + @app.get("/healthz", status_code=200) def health_check(): return {"status": "ok"} From 397e0b0310a93c490ba19b727c4e30a96c982069 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Fri, 17 Nov 2023 11:34:20 +0100 Subject: [PATCH 05/95] wip on doExercise --- crud.py | 33 ++++++++++++++++++++++++++++++--- main.py | 24 +++++++++++++++++++++++- models.py | 17 ++++++++++++++++- schemas.py | 8 ++++++++ test/test_accounts.py | 15 ++++++++++++++- test/test_exercises.py | 37 +++++++++++++++++++++++++++++++++++-- test/utils.py | 12 ++++++++++++ 7 files changed, 138 insertions(+), 8 deletions(-) diff --git a/crud.py b/crud.py index 889393b..2b932d5 100644 --- a/crud.py +++ b/crud.py @@ -19,14 +19,22 @@ } -def get_exercise(db: Session, exercise_id: int): - return db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first() +def get_exercise(db: Session, exercise_id: int, user_id: int | None = None): + db_exercise = db.query(models.Exercise).filter(models.Exercise.id == exercise_id) + if db_exercise and user_id: + print(user_id) + db_exercise_with_completion = db_exercise.join(models.DoExercise).join(models.User) + return db_exercise_with_completion.first() + # if db_exercise_with_completion: + # return db_exercise_with_completion.first() + return db_exercise.first() def get_exercises( db: Session, skip: int = 0, limit: int = 100, + user_id: int | None = None, order_by: schemas.ExerciseOrderBy | None = None, order: schemas.Order | None = None, complexity: models.Complexity | None = None, @@ -46,7 +54,8 @@ def get_exercises( if order == schemas.Order.desc else exercise_order_by_column[order_by] ) - return exercises.offset(skip).limit(limit).all() + paginated_exercises = exercises.offset(skip).limit(limit) + return paginated_exercises.all() def create_exercise(db: Session, exercise: schemas.ExerciseCreate): @@ -68,6 +77,7 @@ def create_exercise(db: Session, exercise: schemas.ExerciseCreate): def delete_exercise(db: Session, exercise_id: int): + db.query(models.DoExercise).filter(models.DoExercise.exercise_id == exercise_id).delete() db.delete( db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first() ) @@ -89,6 +99,22 @@ def update_exercise(db: Session, exercise_id: int, exercise: schemas.ExercisePat return stored_exercise +def update_exercise_completion(db: Session, db_user: models.User, db_exercise: models.Exercise, completion: schemas.ExerciseCompletion): + db_do_exercise = db.query(models.DoExercise).filter(models.DoExercise.exercise_id == db_exercise.id).filter(models.DoExercise.user_id == db_user.id_user).first() + if db_do_exercise is None: + db_do_exercise = models.DoExercise() + db.add(db_do_exercise) + db_user.exercises.append(db_do_exercise) + db_do_exercise.exercise = db_exercise + update_data = completion.model_dump(exclude_unset=True) + update_data.pop('user_id') + for key in update_data: + setattr(db_do_exercise, key, update_data[key]) + db.commit() + db.refresh(db_do_exercise) + return db_do_exercise + + # Accounts def password_hasher(raw_password: str): salt = bcrypt.gensalt() @@ -205,6 +231,7 @@ def update_user(db: Session, user_id: int, user: schemas.UserPatch): def delete_user(db: Session, user_id: int): + db.query(models.DoExercise).filter(models.DoExercise.user_id == user_id).delete() db.delete(db.query(models.User).filter(models.User.id_user == user_id).first()) db.commit() diff --git a/main.py b/main.py index 1cfe210..212c6be 100644 --- a/main.py +++ b/main.py @@ -59,6 +59,7 @@ def create_exercise( @app.get("/exercises/", response_model=list[schemas.ExerciseResponse]) def read_exercises( + user_id: int | None = None, skip: int = 0, limit: int = 100, order_by: schemas.ExerciseOrderBy | None = None, @@ -90,9 +91,10 @@ def read_exercises( @app.get("/exercises/{exercise_id}", response_model=schemas.FullExerciseResponse) def read_exercise( exercise_id: int, + user_id: int | None = None, db: Session = Depends(get_db), ): - db_exercise = crud.get_exercise(db, exercise_id=exercise_id) + db_exercise = crud.get_exercise(db, exercise_id=exercise_id, user_id=user_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") return db_exercise @@ -129,6 +131,24 @@ def update_exercise( return updated_exercise +@app.post("/exercises/{exercise_id}/track_completion") +def track_exercise_completion( + exercise_id: int, + completion: schemas.ExerciseCompletion, + db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), +): + validate_access_level(auth_user, models.AccountType.Regular) + db_user = crud.get_user(db, completion.user_id) + if db_user is None or db_user.id_account != auth_user.account_id: + raise HTTPException(status_code=404, detail=f"user with id {completion.user_id} not found for this account") + db_exercise = crud.get_exercise(db, exercise_id=exercise_id) + if db_exercise is None: + raise HTTPException(status_code=404, detail="exercise not found") + db_do_exercise = crud.update_exercise_completion(db, db_user, db_exercise, completion) + return db_do_exercise + + @app.post("/accounts/", response_model=schemas.AccountOut) def create_account( account_in: schemas.AccountIn, @@ -341,6 +361,8 @@ def me( ): validate_access_level(auth_user, models.AccountType.Regular) db_account = crud.get_account(db, auth_user, auth_user.account_id) + if db_account is None: + raise HTTPException(status_code=404, detail="Account not found") return db_account diff --git a/models.py b/models.py index 275a1db..26cd33f 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,9 @@ +from typing import Optional, List import enum from sqlalchemy import ( Column, + Double, Integer, String, Float, @@ -11,7 +13,7 @@ DateTime, func, ) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, mapped_column, Mapped from database import Base @@ -37,6 +39,17 @@ class AccountType(str, enum.Enum): ) +class DoExercise(Base): + __tablename__ = "association_table" + exercise_id: Mapped[int] = mapped_column(ForeignKey("exercise.id"), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id_user"), primary_key=True) + user: Mapped["User"] = relationship(back_populates="exercises") + exercise: Mapped["Exercise"] = relationship(back_populates="users") + completion: Mapped[Optional[int]] + position: Mapped[Optional[int]] + time_spent: Mapped[Optional[int]] + + class Complexity(enum.Enum): _easy = "easy" _medium = "medium" @@ -53,6 +66,7 @@ class Exercise(Base): category = relationship( "Category", secondary=exercise_category, back_populates="exercises" ) + users: Mapped[List["DoExercise"]] = relationship(back_populates="exercise") date = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -72,6 +86,7 @@ class User(Base): id_account = Column(Integer) username = Column(String) proficiency = Column(Float) + exercises: Mapped[List["DoExercise"]] = relationship(back_populates="user") class Category(Base): diff --git a/schemas.py b/schemas.py index dd193dc..d1cc45b 100644 --- a/schemas.py +++ b/schemas.py @@ -37,6 +37,7 @@ class ExerciseResponse(BaseModel): id: int title: str complexity: models.Complexity | None + completion: int | None = None category: List[Category] date: datetime @@ -55,6 +56,13 @@ class ExercisePatch(BaseModel): category: Optional[List[str]] = [] +class ExerciseCompletion(BaseModel): + user_id: int + completion: Optional[int] = None + position: Optional[int] = None + time_spent: Optional[int] = None + + class FullExerciseResponse(ExerciseResponse): text: str # date:datetime diff --git a/test/test_accounts.py b/test/test_accounts.py index 7cddece..a2a31bb 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -1,5 +1,5 @@ from conftest import client -from utils import auth_header +from utils import auth_header, good_request, bad_request def test_create_account(superadmin_token): @@ -121,3 +121,16 @@ def test_me_endpoint(regular_token): ).json() print(resp) assert resp["account_category"] == "regular" + + +def test_me_for_deleted_account(): + new_account = { + "email": "toBeDeleted@gmail.com", + "password": "secret", + } + good_request(client.post, "http://localhost:8000/register", json=new_account) + login_resp = good_request(client.post, "http://localhost:8000/login", json=new_account) + auth_header={"Authorization": f"Bearer {login_resp['access_token']}"} + me_resp = good_request(client.get, "http://localhost:8000/me", headers=auth_header) + good_request(client.delete, f"http://localhost:8000/accounts/{me_resp['id_account']}", headers=auth_header) + bad_request(client.get, 404, "http://localhost:8000/me", headers=auth_header) diff --git a/test/test_exercises.py b/test/test_exercises.py index f2cea30..15ab668 100644 --- a/test/test_exercises.py +++ b/test/test_exercises.py @@ -1,7 +1,7 @@ from datetime import datetime import time from conftest import client -from utils import auth_header +from utils import auth_header, good_request def test_create_exercise(admin_token): @@ -54,7 +54,7 @@ def test_get_exercises(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) ).json() - assert set(exercises[0].keys()) == {"id", "title", "complexity", "category", "date"} + assert set(exercises[0].keys()) == {"id", "title", "complexity", "category", "date", "completion"} def test_get_exercise(admin_token): @@ -73,6 +73,7 @@ def test_get_exercise(admin_token): "complexity", "text", "date", + "completion", } @@ -135,6 +136,37 @@ def test_search_exercises(admin_token): assert exercise["title"] == "Title of another exercise" +def test_track_exercise_completion(): + new_account = { + "email": "user1@gmail.com", + "password": "secret", + } + client.post("http://localhost:8000/register", json=new_account) + # good_request(client.post, "http://localhost:8000/register", json=new_account) + login_resp = good_request(client.post, "http://localhost:8000/login", json=new_account) + auth_header={"Authorization": f"Bearer {login_resp['access_token']}"} + new_user = { + "username": f"userToTrack", + "profficiency": 0 + } + user_resp = good_request(client.post, "http://localhost:8000/users", json=new_user, headers=auth_header) + created_user_id = user_resp['id_user'] + + exercise_completion = { + "user_id": created_user_id, + "completion": 45, + } + good_request(client.post, "http://localhost:8000/exercises/1/track_completion", json=exercise_completion, headers=auth_header) + exercise_resp = good_request(client.get, "http://localhost:8000/exercises/1?user_id=45", headers=auth_header) + print(exercise_resp) + assert exercise_resp.completion == 45 + + me_resp = good_request(client.get, "http://localhost:8000/me", headers=auth_header) + good_request(client.delete, f"http://localhost:8000/accounts/{me_resp['id_account']}", headers=auth_header) + good_request(client.delete, f"http://localhost:8000/users/{created_user_id}", headers=auth_header) + assert True + + def test_delete_exercise(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) @@ -275,3 +307,4 @@ def test_update_exercise_modifies_date(admin_token): date_obj2 = datetime.fromisoformat(updated_exercise["date"]) assert date_obj1 < date_obj2 test_delete_exercise(admin_token) + diff --git a/test/utils.py b/test/utils.py index ee6227b..22b2580 100644 --- a/test/utils.py +++ b/test/utils.py @@ -1,2 +1,14 @@ def auth_header(token: str): return {"Authorization": f"Bearer {token}"} + + +def good_request(request_function, *args, **kwargs): + resp = request_function(*args, **kwargs) + assert resp.status_code == 200 + return resp.json() + + +def bad_request(request_function, status_code, *args, **kwargs): + resp = request_function(*args, **kwargs) + assert resp.status_code == status_code + return resp.json() \ No newline at end of file From 458ec186ace273437f6b8159bb7b37d62f183e57 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Fri, 17 Nov 2023 22:56:24 +0100 Subject: [PATCH 06/95] working join with doExercise on get_exercises/get_exercise --- README.md | 5 ++++ crud.py | 46 ++++++++++++++++++++++++++--------- main.py | 23 +++++++++++++++--- models.py | 14 ++++++++--- run_tests.sh | 8 ------ schemas.py | 19 +++++++++------ test/sqlalchemy_console.py | 42 ++++++++++++++++++++++++++++++++ test/test_accounts.py | 12 ++++++--- test/test_exercises.py | 50 +++++++++++++++++++++++++++----------- test/utils.py | 2 +- 10 files changed, 168 insertions(+), 53 deletions(-) delete mode 100755 run_tests.sh create mode 100644 test/sqlalchemy_console.py diff --git a/README.md b/README.md index b23fe00..e71f66a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ Launch tests from the root directory with python3 -m pytest ``` +To experiment with the ORM, run: +``` +ipython3 -i test/sqlalchemy_console.py +``` + # Sources diff --git a/crud.py b/crud.py index 2b932d5..573d3af 100644 --- a/crud.py +++ b/crud.py @@ -20,14 +20,24 @@ def get_exercise(db: Session, exercise_id: int, user_id: int | None = None): - db_exercise = db.query(models.Exercise).filter(models.Exercise.id == exercise_id) - if db_exercise and user_id: - print(user_id) - db_exercise_with_completion = db_exercise.join(models.DoExercise).join(models.User) - return db_exercise_with_completion.first() - # if db_exercise_with_completion: - # return db_exercise_with_completion.first() - return db_exercise.first() + if user_id: + db_exercise = ( + db.query(models.DoExercise) + .select_from(models.Exercise) + .join(models.Exercise.users) + .filter(models.Exercise.id == exercise_id) + .filter(models.DoExercise.user_id == user_id) + .add_entity(models.Exercise) + .first() + ) + if db_exercise: + exercise_completion = schemas.ExerciseCompletion.model_validate( + db_exercise[0] + ) + exercise = schemas.FullExerciseResponse.model_validate(db_exercise[1]) + exercise.completion = exercise_completion + return exercise + return db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first() def get_exercises( @@ -77,7 +87,9 @@ def create_exercise(db: Session, exercise: schemas.ExerciseCreate): def delete_exercise(db: Session, exercise_id: int): - db.query(models.DoExercise).filter(models.DoExercise.exercise_id == exercise_id).delete() + db.query(models.DoExercise).filter( + models.DoExercise.exercise_id == exercise_id + ).delete() db.delete( db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first() ) @@ -99,15 +111,25 @@ def update_exercise(db: Session, exercise_id: int, exercise: schemas.ExercisePat return stored_exercise -def update_exercise_completion(db: Session, db_user: models.User, db_exercise: models.Exercise, completion: schemas.ExerciseCompletion): - db_do_exercise = db.query(models.DoExercise).filter(models.DoExercise.exercise_id == db_exercise.id).filter(models.DoExercise.user_id == db_user.id_user).first() +def update_exercise_completion( + db: Session, + db_user: models.User, + db_exercise: models.Exercise, + completion: schemas.ExerciseCompletion, +): + db_do_exercise = ( + db.query(models.DoExercise) + .filter(models.DoExercise.exercise_id == db_exercise.id) + .filter(models.DoExercise.user_id == db_user.id_user) + .first() + ) if db_do_exercise is None: db_do_exercise = models.DoExercise() db.add(db_do_exercise) db_user.exercises.append(db_do_exercise) db_do_exercise.exercise = db_exercise update_data = completion.model_dump(exclude_unset=True) - update_data.pop('user_id') + update_data.pop("user_id") for key in update_data: setattr(db_do_exercise, key, update_data[key]) db.commit() diff --git a/main.py b/main.py index 212c6be..b9cca4f 100644 --- a/main.py +++ b/main.py @@ -94,9 +94,21 @@ def read_exercise( user_id: int | None = None, db: Session = Depends(get_db), ): - db_exercise = crud.get_exercise(db, exercise_id=exercise_id, user_id=user_id) + db_exercise = crud.get_exercise(db, exercise_id=exercise_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") + resp = schemas.FullExerciseResponse + print(db_exercise.id) + resp.id = db_exercise.id + resp.title = db_exercise.title + resp.complexity = db_exercise.complexity + resp.category = db_exercise.category + resp.date = db_exercise.date + do_exercise = db_exercise.users.filter(lambda u: u.id_user == user_id).first() + if do_exercise: + resp.completion = do_exercise.completion + resp.position = do_exercise.position + resp.time_spent = do_exercise.time_spent return db_exercise @@ -141,11 +153,16 @@ def track_exercise_completion( validate_access_level(auth_user, models.AccountType.Regular) db_user = crud.get_user(db, completion.user_id) if db_user is None or db_user.id_account != auth_user.account_id: - raise HTTPException(status_code=404, detail=f"user with id {completion.user_id} not found for this account") + raise HTTPException( + status_code=404, + detail=f"user with id {completion.user_id} not found for this account", + ) db_exercise = crud.get_exercise(db, exercise_id=exercise_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") - db_do_exercise = crud.update_exercise_completion(db, db_user, db_exercise, completion) + db_do_exercise = crud.update_exercise_completion( + db, db_user, db_exercise, completion + ) return db_do_exercise diff --git a/models.py b/models.py index 26cd33f..f005a68 100644 --- a/models.py +++ b/models.py @@ -40,8 +40,10 @@ class AccountType(str, enum.Enum): class DoExercise(Base): - __tablename__ = "association_table" - exercise_id: Mapped[int] = mapped_column(ForeignKey("exercise.id"), primary_key=True) + __tablename__ = "do_exercise" + exercise_id: Mapped[int] = mapped_column( + ForeignKey("exercise.id"), primary_key=True + ) user_id: Mapped[int] = mapped_column(ForeignKey("user.id_user"), primary_key=True) user: Mapped["User"] = relationship(back_populates="exercises") exercise: Mapped["Exercise"] = relationship(back_populates="users") @@ -66,7 +68,9 @@ class Exercise(Base): category = relationship( "Category", secondary=exercise_category, back_populates="exercises" ) - users: Mapped[List["DoExercise"]] = relationship(back_populates="exercise") + users: Mapped[List["DoExercise"]] = relationship( + back_populates="exercise", lazy="dynamic" + ) date = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -86,7 +90,9 @@ class User(Base): id_account = Column(Integer) username = Column(String) proficiency = Column(Float) - exercises: Mapped[List["DoExercise"]] = relationship(back_populates="user") + exercises: Mapped[List["DoExercise"]] = relationship( + back_populates="user", lazy="dynamic" + ) class Category(Base): diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100755 index 293563c..0000000 --- a/run_tests.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -rm db.sqlite -cat setup.sql | sqlite3 db.sqlite - -nohup venv/bin/uvicorn main:app & -sleep 3 -venv/bin/python3 -m pytest -ps -ef | grep "uvicorn main:app" | grep -v grep | awk '{print $2}' | xargs kill diff --git a/schemas.py b/schemas.py index d1cc45b..8d3456a 100644 --- a/schemas.py +++ b/schemas.py @@ -7,6 +7,9 @@ import models +BaseModel.model_config = {"from_attributes": True} + + class ExerciseOrderBy(Enum): category = "category" complexity = "complexity" @@ -33,11 +36,18 @@ class Category(BaseModel): category: str +class ExerciseCompletion(BaseModel): + user_id: int + completion: Optional[int] = None + position: Optional[int] = None + time_spent: Optional[int] = None + + class ExerciseResponse(BaseModel): id: int title: str complexity: models.Complexity | None - completion: int | None = None + completion: ExerciseCompletion | None = None category: List[Category] date: datetime @@ -56,13 +66,6 @@ class ExercisePatch(BaseModel): category: Optional[List[str]] = [] -class ExerciseCompletion(BaseModel): - user_id: int - completion: Optional[int] = None - position: Optional[int] = None - time_spent: Optional[int] = None - - class FullExerciseResponse(ExerciseResponse): text: str # date:datetime diff --git a/test/sqlalchemy_console.py b/test/sqlalchemy_console.py new file mode 100644 index 0000000..a4c7166 --- /dev/null +++ b/test/sqlalchemy_console.py @@ -0,0 +1,42 @@ +from sqlalchemy import create_engine, or_ +from sqlalchemy.orm import sessionmaker, declarative_base + +import models +import schemas + + +SQLALCHEMY_DATABASE_URL = "sqlite:///./db.sqlite" +connect_args = {"check_same_thread": False} +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args=connect_args, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() +models.Base.metadata.create_all(bind=engine) +db = SessionLocal() + + +# NOTES ON WORK IN PROGRESS + + +db_exercise = ( + db.query(models.DoExercise) + .select_from(models.Exercise) + .join(models.Exercise.users) + .filter(models.Exercise.id == 1) + .filter(models.DoExercise.user_id == 1) + .add_entity(models.Exercise) + .first() +) +exercise_completion = schemas.ExerciseCompletion.model_validate(db_exercise[0]) +exercise = schemas.FullExerciseResponse.model_validate(db_exercise[1]) +exercise.completion = exercise_completion + +db_exercises = ( + db.query(models.Exercise) + .join(models.DoExercise, isouter=True) + .add_entity(models.DoExercise) + .filter(or_(models.DoExercise.user_id == 1, models.Exercise.users == None)) + .all() +) diff --git a/test/test_accounts.py b/test/test_accounts.py index a2a31bb..29717a4 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -129,8 +129,14 @@ def test_me_for_deleted_account(): "password": "secret", } good_request(client.post, "http://localhost:8000/register", json=new_account) - login_resp = good_request(client.post, "http://localhost:8000/login", json=new_account) - auth_header={"Authorization": f"Bearer {login_resp['access_token']}"} + login_resp = good_request( + client.post, "http://localhost:8000/login", json=new_account + ) + auth_header = {"Authorization": f"Bearer {login_resp['access_token']}"} me_resp = good_request(client.get, "http://localhost:8000/me", headers=auth_header) - good_request(client.delete, f"http://localhost:8000/accounts/{me_resp['id_account']}", headers=auth_header) + good_request( + client.delete, + f"http://localhost:8000/accounts/{me_resp['id_account']}", + headers=auth_header, + ) bad_request(client.get, 404, "http://localhost:8000/me", headers=auth_header) diff --git a/test/test_exercises.py b/test/test_exercises.py index 15ab668..14922fc 100644 --- a/test/test_exercises.py +++ b/test/test_exercises.py @@ -54,7 +54,14 @@ def test_get_exercises(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) ).json() - assert set(exercises[0].keys()) == {"id", "title", "complexity", "category", "date", "completion"} + assert set(exercises[0].keys()) == { + "id", + "title", + "complexity", + "category", + "date", + "completion", + } def test_get_exercise(admin_token): @@ -143,27 +150,43 @@ def test_track_exercise_completion(): } client.post("http://localhost:8000/register", json=new_account) # good_request(client.post, "http://localhost:8000/register", json=new_account) - login_resp = good_request(client.post, "http://localhost:8000/login", json=new_account) - auth_header={"Authorization": f"Bearer {login_resp['access_token']}"} - new_user = { - "username": f"userToTrack", - "profficiency": 0 - } - user_resp = good_request(client.post, "http://localhost:8000/users", json=new_user, headers=auth_header) - created_user_id = user_resp['id_user'] + login_resp = good_request( + client.post, "http://localhost:8000/login", json=new_account + ) + auth_header = {"Authorization": f"Bearer {login_resp['access_token']}"} + new_user = {"username": f"userToTrack", "profficiency": 0} + user_resp = good_request( + client.post, "http://localhost:8000/users", json=new_user, headers=auth_header + ) + created_user_id = user_resp["id_user"] exercise_completion = { "user_id": created_user_id, "completion": 45, } - good_request(client.post, "http://localhost:8000/exercises/1/track_completion", json=exercise_completion, headers=auth_header) - exercise_resp = good_request(client.get, "http://localhost:8000/exercises/1?user_id=45", headers=auth_header) + good_request( + client.post, + "http://localhost:8000/exercises/1/track_completion", + json=exercise_completion, + headers=auth_header, + ) + exercise_resp = good_request( + client.get, "http://localhost:8000/exercises/1?user_id=45", headers=auth_header + ) print(exercise_resp) assert exercise_resp.completion == 45 me_resp = good_request(client.get, "http://localhost:8000/me", headers=auth_header) - good_request(client.delete, f"http://localhost:8000/accounts/{me_resp['id_account']}", headers=auth_header) - good_request(client.delete, f"http://localhost:8000/users/{created_user_id}", headers=auth_header) + good_request( + client.delete, + f"http://localhost:8000/accounts/{me_resp['id_account']}", + headers=auth_header, + ) + good_request( + client.delete, + f"http://localhost:8000/users/{created_user_id}", + headers=auth_header, + ) assert True @@ -307,4 +330,3 @@ def test_update_exercise_modifies_date(admin_token): date_obj2 = datetime.fromisoformat(updated_exercise["date"]) assert date_obj1 < date_obj2 test_delete_exercise(admin_token) - diff --git a/test/utils.py b/test/utils.py index 22b2580..3338853 100644 --- a/test/utils.py +++ b/test/utils.py @@ -11,4 +11,4 @@ def good_request(request_function, *args, **kwargs): def bad_request(request_function, status_code, *args, **kwargs): resp = request_function(*args, **kwargs) assert resp.status_code == status_code - return resp.json() \ No newline at end of file + return resp.json() From 840a9331d1aef14a4680b32ff19da286b4d6e7ac Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sat, 18 Nov 2023 11:14:45 +0100 Subject: [PATCH 07/95] fix tests --- main.py | 14 +------------- test/test_exercises.py | 6 ++---- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/main.py b/main.py index b9cca4f..b614f12 100644 --- a/main.py +++ b/main.py @@ -94,21 +94,9 @@ def read_exercise( user_id: int | None = None, db: Session = Depends(get_db), ): - db_exercise = crud.get_exercise(db, exercise_id=exercise_id) + db_exercise = crud.get_exercise(db, exercise_id=exercise_id, user_id=user_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") - resp = schemas.FullExerciseResponse - print(db_exercise.id) - resp.id = db_exercise.id - resp.title = db_exercise.title - resp.complexity = db_exercise.complexity - resp.category = db_exercise.category - resp.date = db_exercise.date - do_exercise = db_exercise.users.filter(lambda u: u.id_user == user_id).first() - if do_exercise: - resp.completion = do_exercise.completion - resp.position = do_exercise.position - resp.time_spent = do_exercise.time_spent return db_exercise diff --git a/test/test_exercises.py b/test/test_exercises.py index 14922fc..bff6557 100644 --- a/test/test_exercises.py +++ b/test/test_exercises.py @@ -149,7 +149,6 @@ def test_track_exercise_completion(): "password": "secret", } client.post("http://localhost:8000/register", json=new_account) - # good_request(client.post, "http://localhost:8000/register", json=new_account) login_resp = good_request( client.post, "http://localhost:8000/login", json=new_account ) @@ -171,10 +170,9 @@ def test_track_exercise_completion(): headers=auth_header, ) exercise_resp = good_request( - client.get, "http://localhost:8000/exercises/1?user_id=45", headers=auth_header + client.get, f"http://localhost:8000/exercises/1?user_id={created_user_id}", headers=auth_header ) - print(exercise_resp) - assert exercise_resp.completion == 45 + assert exercise_resp['completion']['completion'] == 45 me_resp = good_request(client.get, "http://localhost:8000/me", headers=auth_header) good_request( From b9ed4add36108791d69a9812b84f414fe3f1e680 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sat, 18 Nov 2023 12:23:48 +0100 Subject: [PATCH 08/95] add tests for do-exercise and test utils --- crud.py | 17 ++++- main.py | 3 +- models.py | 1 - test/conftest.py | 111 ++++++++++++++++++++++++++++++- test/sqlalchemy_console.py | 2 - test/test_accounts.py | 3 +- test/test_categories.py | 3 +- test/test_do_exercise.py | 133 +++++++++++++++++++++++++++++++++++++ test/test_exercises.py | 48 +------------ test/utils.py | 14 ---- 10 files changed, 264 insertions(+), 71 deletions(-) create mode 100644 test/test_do_exercise.py delete mode 100644 test/utils.py diff --git a/crud.py b/crud.py index 573d3af..f9961c3 100644 --- a/crud.py +++ b/crud.py @@ -44,12 +44,12 @@ def get_exercises( db: Session, skip: int = 0, limit: int = 100, - user_id: int | None = None, order_by: schemas.ExerciseOrderBy | None = None, order: schemas.Order | None = None, complexity: models.Complexity | None = None, category: models.Category | None = None, title_like: str | None = None, + user_id: int | None = None, ): exercises = db.query(models.Exercise) if complexity: @@ -58,6 +58,12 @@ def get_exercises( exercises = exercises.filter(models.Exercise.category.contains(category)) if title_like: exercises = exercises.filter(models.Exercise.title.like(f"%{title_like}%")) + if user_id: + exercises = ( + exercises.join(models.DoExercise, isouter=True) + .add_entity(models.DoExercise) + .filter(or_(models.DoExercise.user_id == 1, models.Exercise.users == None)) + ) if order_by: exercises = exercises.order_by( exercise_order_by_column[order_by].desc() @@ -65,6 +71,15 @@ def get_exercises( else exercise_order_by_column[order_by] ) paginated_exercises = exercises.offset(skip).limit(limit) + if user_id: + exercises = [] + for ex, do_ex in paginated_exercises: + if do_ex: + ex_completion = schemas.ExerciseCompletion.model_validate(do_ex) + ex = schemas.FullExerciseResponse.model_validate(ex) + ex.completion = ex_completion + exercises.append(ex) + return exercises return paginated_exercises.all() diff --git a/main.py b/main.py index b614f12..6affa72 100644 --- a/main.py +++ b/main.py @@ -59,7 +59,6 @@ def create_exercise( @app.get("/exercises/", response_model=list[schemas.ExerciseResponse]) def read_exercises( - user_id: int | None = None, skip: int = 0, limit: int = 100, order_by: schemas.ExerciseOrderBy | None = None, @@ -67,6 +66,7 @@ def read_exercises( complexity: models.Complexity | None = None, category: str | None = None, title_like: str | None = None, + user_id: int | None = None, db: Session = Depends(get_db), ): if category: @@ -84,6 +84,7 @@ def read_exercises( complexity=complexity, category=db_category, title_like=title_like, + user_id=user_id, ) return exercises diff --git a/models.py b/models.py index f005a68..c20ba9f 100644 --- a/models.py +++ b/models.py @@ -3,7 +3,6 @@ from sqlalchemy import ( Column, - Double, Integer, String, Float, diff --git a/test/conftest.py b/test/conftest.py index 0da12db..ab1c974 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,7 +1,8 @@ import pytest from fastapi.testclient import TestClient + from main import app -from utils import auth_header + client = TestClient(app) @@ -45,3 +46,111 @@ def superadmin_token(): resp = client.post("http://localhost:8000/login", json=account_details).json() access_token = resp["access_token"] yield access_token + + +@pytest.fixture +def create_account(): + accounts = [] + id_counter = 0 + + def new_account(): + nonlocal id_counter, accounts + new_account = { + "email": f"account_to_be_deleted{id_counter}@mail.com", + "password": "secret", + } + id_counter += 1 + account_resp = good_request( + client.post, "http://localhost:8000/register", json=new_account + ) + account_resp = good_request( + client.post, "http://localhost:8000/login", json=new_account + ) + access_token = account_resp["access_token"] + accounts.append((account_resp["id_account"], access_token)) + return access_token + + yield new_account + + for id_account, access_token in accounts: + good_request( + client.delete, + f"http://localhost:8000/accounts/{id_account}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + +@pytest.fixture +def create_user(): + users = [] + id_counter = 0 + + def new_user(access_token: str): + nonlocal id_counter, users + new_user = { + "username": f"user{id_counter}", + "proficiency": 0, + } + id_counter += 1 + user_resp = good_request( + client.post, + "http://localhost:8000/users", + json=new_user, + headers=auth_header(access_token), + ) + users.append((user_resp["id_user"], access_token)) + return user_resp + + yield new_user + + for user_id, access_token in users: + good_request( + client.delete, + f"http://localhost:8000/users/{user_id}", + headers=auth_header(access_token), + ) + + +@pytest.fixture +def create_exercise(admin_token): + exercise_ids = [] + + def new_exercise(): + nonlocal exercise_ids + new_exercise = { + "title": "Exercise to be deleted after the test", + "text": "sample text", + } + exercise_resp = good_request( + client.post, + "http://localhost:8000/exercises", + json=new_exercise, + headers=auth_header(admin_token), + ) + exercise_ids.append(exercise_resp["id"]) + return exercise_resp + + yield new_exercise + + for exercise_id in exercise_ids: + good_request( + client.delete, + f"http://localhost:8000/exercises/{exercise_id}", + headers=auth_header(admin_token), + ) + + +def auth_header(token: str): + return {"Authorization": f"Bearer {token}"} + + +def good_request(request_function, *args, **kwargs): + resp = request_function(*args, **kwargs) + assert resp.status_code == 200 + return resp.json() + + +def bad_request(request_function, status_code, *args, **kwargs): + resp = request_function(*args, **kwargs) + assert resp.status_code == status_code + return resp.json() diff --git a/test/sqlalchemy_console.py b/test/sqlalchemy_console.py index a4c7166..922f974 100644 --- a/test/sqlalchemy_console.py +++ b/test/sqlalchemy_console.py @@ -18,8 +18,6 @@ # NOTES ON WORK IN PROGRESS - - db_exercise = ( db.query(models.DoExercise) .select_from(models.Exercise) diff --git a/test/test_accounts.py b/test/test_accounts.py index 29717a4..ea46026 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -1,5 +1,4 @@ -from conftest import client -from utils import auth_header, good_request, bad_request +from conftest import client, auth_header, good_request, bad_request def test_create_account(superadmin_token): diff --git a/test/test_categories.py b/test/test_categories.py index 9c218c9..f5e3890 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -1,7 +1,6 @@ import pytest -from conftest import client -from utils import auth_header +from conftest import client, auth_header @pytest.mark.parametrize("category_name", ["Dogs", "Cats"]) diff --git a/test/test_do_exercise.py b/test/test_do_exercise.py new file mode 100644 index 0000000..1c9489f --- /dev/null +++ b/test/test_do_exercise.py @@ -0,0 +1,133 @@ +from conftest import client, auth_header, good_request + + +def test_track_exercise_completion_metrics(regular_token, create_user, create_exercise): + created_user_id = create_user(regular_token)["id_user"] + exercise_id = create_exercise()["id"] + + exercise_completion = { + "user_id": created_user_id, + "completion": 45, + "time_spent": 100, + "position": 29, + } + good_request( + client.post, + f"http://localhost:8000/exercises/{exercise_id}/track_completion", + json=exercise_completion, + headers=auth_header(regular_token), + ) + exercise_resp = good_request( + client.get, + f"http://localhost:8000/exercises/{exercise_id}?user_id={created_user_id}", + headers=auth_header(regular_token), + ) + assert exercise_resp["completion"]["completion"] == 45 + assert exercise_resp["completion"]["time_spent"] == 100 + assert exercise_resp["completion"]["position"] == 29 + + +def test_track_multiple_users_per_exercise(regular_token, create_user, create_exercise): + id_alice = create_user(regular_token)["id_user"] + id_bob = create_user(regular_token)["id_user"] + exercise_id = create_exercise()["id"] + + exercise_completion = { + "user_id": id_alice, + "completion": 20, + } + good_request( + client.post, + f"http://localhost:8000/exercises/{exercise_id}/track_completion", + json=exercise_completion, + headers=auth_header(regular_token), + ) + exercise_completion = { + "user_id": id_bob, + "completion": 40, + } + good_request( + client.post, + f"http://localhost:8000/exercises/{exercise_id}/track_completion", + json=exercise_completion, + headers=auth_header(regular_token), + ) + + exercise_resp = good_request( + client.get, + f"http://localhost:8000/exercises/{exercise_id}?user_id={id_alice}", + headers=auth_header(regular_token), + ) + assert exercise_resp["completion"]["completion"] == 20 + exercise_resp = good_request( + client.get, + f"http://localhost:8000/exercises/{exercise_id}?user_id={id_bob}", + headers=auth_header(regular_token), + ) + assert exercise_resp["completion"]["completion"] == 40 + + +def test_track_multiple_exercises_per_user(regular_token, create_user, create_exercise): + id_alice = create_user(regular_token)["id_user"] + exercise_one_id = create_exercise()["id"] + exercise_two_id = create_exercise()["id"] + + for exercise_id, completion in (exercise_one_id, 20), (exercise_two_id, 40): + exercise_completion = { + "user_id": id_alice, + "completion": completion, + } + good_request( + client.post, + f"http://localhost:8000/exercises/{exercise_id}/track_completion", + json=exercise_completion, + headers=auth_header(regular_token), + ) + + for exercise_id, completion in (exercise_one_id, 20), (exercise_two_id, 40): + exercise_resp = good_request( + client.get, + f"http://localhost:8000/exercises/{exercise_id}?user_id={id_alice}", + headers=auth_header(regular_token), + ) + assert exercise_resp["completion"]["completion"] == completion + + +def test_read_all_exercises_with_completion( + regular_token, create_user, create_exercise +): + id_alice = create_user(regular_token)["id_user"] + created_exercises: dict[str, int | None] = { + create_exercise()["id"]: i * 10 for i in range(1, 4) + } + for ex_id, completion in created_exercises.items(): + exercise_completion = { + "user_id": id_alice, + "completion": completion, + } + good_request( + client.post, + f"http://localhost:8000/exercises/{ex_id}/track_completion", + json=exercise_completion, + headers=auth_header(regular_token), + ) + created_exercises[create_exercise()["id"]] = None + exercises = [ + ex + for ex in good_request(client.get, "http://localhost:8000/exercises") + if ex["id"] in created_exercises.keys() + ] + for ex in exercises: + assert ex["completion"] == None + exercises = [ + ex + for ex in good_request( + client.get, f"http://localhost:8000/exercises?user_id={id_alice}" + ) + if ex["id"] in created_exercises.keys() + ] + for ex in exercises: + if not created_exercises[ex["id"]]: + assert ex["completion"] is None + else: + assert ex["completion"]["completion"] == created_exercises[ex["id"]] diff --git a/test/test_exercises.py b/test/test_exercises.py index bff6557..2730b6b 100644 --- a/test/test_exercises.py +++ b/test/test_exercises.py @@ -1,7 +1,6 @@ from datetime import datetime import time -from conftest import client -from utils import auth_header, good_request +from conftest import client, auth_header def test_create_exercise(admin_token): @@ -143,51 +142,6 @@ def test_search_exercises(admin_token): assert exercise["title"] == "Title of another exercise" -def test_track_exercise_completion(): - new_account = { - "email": "user1@gmail.com", - "password": "secret", - } - client.post("http://localhost:8000/register", json=new_account) - login_resp = good_request( - client.post, "http://localhost:8000/login", json=new_account - ) - auth_header = {"Authorization": f"Bearer {login_resp['access_token']}"} - new_user = {"username": f"userToTrack", "profficiency": 0} - user_resp = good_request( - client.post, "http://localhost:8000/users", json=new_user, headers=auth_header - ) - created_user_id = user_resp["id_user"] - - exercise_completion = { - "user_id": created_user_id, - "completion": 45, - } - good_request( - client.post, - "http://localhost:8000/exercises/1/track_completion", - json=exercise_completion, - headers=auth_header, - ) - exercise_resp = good_request( - client.get, f"http://localhost:8000/exercises/1?user_id={created_user_id}", headers=auth_header - ) - assert exercise_resp['completion']['completion'] == 45 - - me_resp = good_request(client.get, "http://localhost:8000/me", headers=auth_header) - good_request( - client.delete, - f"http://localhost:8000/accounts/{me_resp['id_account']}", - headers=auth_header, - ) - good_request( - client.delete, - f"http://localhost:8000/users/{created_user_id}", - headers=auth_header, - ) - assert True - - def test_delete_exercise(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) diff --git a/test/utils.py b/test/utils.py deleted file mode 100644 index 3338853..0000000 --- a/test/utils.py +++ /dev/null @@ -1,14 +0,0 @@ -def auth_header(token: str): - return {"Authorization": f"Bearer {token}"} - - -def good_request(request_function, *args, **kwargs): - resp = request_function(*args, **kwargs) - assert resp.status_code == 200 - return resp.json() - - -def bad_request(request_function, status_code, *args, **kwargs): - resp = request_function(*args, **kwargs) - assert resp.status_code == status_code - return resp.json() From 65711adb3f6053f39a4642d7190ff5cb0fc187c3 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sat, 18 Nov 2023 12:26:28 +0100 Subject: [PATCH 09/95] cleanup sqlalchemy console --- test/sqlalchemy_console.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/test/sqlalchemy_console.py b/test/sqlalchemy_console.py index 922f974..52439c0 100644 --- a/test/sqlalchemy_console.py +++ b/test/sqlalchemy_console.py @@ -16,25 +16,3 @@ models.Base.metadata.create_all(bind=engine) db = SessionLocal() - -# NOTES ON WORK IN PROGRESS -db_exercise = ( - db.query(models.DoExercise) - .select_from(models.Exercise) - .join(models.Exercise.users) - .filter(models.Exercise.id == 1) - .filter(models.DoExercise.user_id == 1) - .add_entity(models.Exercise) - .first() -) -exercise_completion = schemas.ExerciseCompletion.model_validate(db_exercise[0]) -exercise = schemas.FullExerciseResponse.model_validate(db_exercise[1]) -exercise.completion = exercise_completion - -db_exercises = ( - db.query(models.Exercise) - .join(models.DoExercise, isouter=True) - .add_entity(models.DoExercise) - .filter(or_(models.DoExercise.user_id == 1, models.Exercise.users == None)) - .all() -) From 3bda5a2ee7f13ec30fb49249d918a9779594124c Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sat, 18 Nov 2023 13:09:08 +0100 Subject: [PATCH 10/95] check account authorization to view user data --- auth_bearer.py | 3 +++ main.py | 40 +++++++++++++++++++++++++++++++++----- test/conftest.py | 15 +++++++------- test/sqlalchemy_console.py | 1 - test/test_do_exercise.py | 18 +++++++++++++++-- 5 files changed, 61 insertions(+), 16 deletions(-) diff --git a/auth_bearer.py b/auth_bearer.py index da200f4..b52dadb 100644 --- a/auth_bearer.py +++ b/auth_bearer.py @@ -5,6 +5,7 @@ class JWTBearer(HTTPBearer): def __init__(self, auto_error: bool = True): + self.auto_error = auto_error super(JWTBearer, self).__init__(auto_error=auto_error) async def __call__(self, request: Request): @@ -26,5 +27,7 @@ async def __call__(self, request: Request): status_code=403, detail="Invalid token or expired token." ) return token + elif not self.auto_error: + return None else: raise HTTPException(status_code=403, detail="Invalid authorization code.") diff --git a/main.py b/main.py index 6affa72..231351d 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ allow_methods=["*"], allow_headers=["*"], ) +optional_auth = JWTBearer(auto_error=False) # Dependency @@ -42,6 +43,22 @@ def validate_access_level( ) +def validate_user_belongs_to_account( + user_id: int, + auth_user: schemas.AuthSchema = Depends(JWTBearer()), + db: Session = Depends(get_db), +): + db_user = crud.get_user(db, user_id) + if ( + db_user is None + or schemas.UserSchema.model_validate(db_user).id_account != auth_user.account_id + ): + raise HTTPException( + status_code=404, + detail=f"user with id {user_id} not found for this account", + ) + + @app.get("/healthz", status_code=200) def health_check(): return {"status": "ok"} @@ -68,7 +85,16 @@ def read_exercises( title_like: str | None = None, user_id: int | None = None, db: Session = Depends(get_db), + auth_user: schemas.AuthSchema | None = Depends(optional_auth), ): + print(auth_user) + if user_id: + if not auth_user: + raise HTTPException( + status_code=403, detail="account must be authorized to access user data" + ) + validate_access_level(auth_user, models.AccountType.Regular) + validate_user_belongs_to_account(user_id, auth_user, db) if category: db_category = crud.get_category(db, category) if db_category is None: @@ -94,7 +120,15 @@ def read_exercise( exercise_id: int, user_id: int | None = None, db: Session = Depends(get_db), + auth_user: schemas.AuthSchema | None = Depends(optional_auth), ): + if user_id: + if not auth_user: + raise HTTPException( + status_code=403, detail="account must be authorized to access user data" + ) + validate_access_level(auth_user, models.AccountType.Regular) + validate_user_belongs_to_account(user_id, auth_user, db) db_exercise = crud.get_exercise(db, exercise_id=exercise_id, user_id=user_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") @@ -140,12 +174,8 @@ def track_exercise_completion( auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): validate_access_level(auth_user, models.AccountType.Regular) + validate_user_belongs_to_account(completion.user_id, auth_user, db) db_user = crud.get_user(db, completion.user_id) - if db_user is None or db_user.id_account != auth_user.account_id: - raise HTTPException( - status_code=404, - detail=f"user with id {completion.user_id} not found for this account", - ) db_exercise = crud.get_exercise(db, exercise_id=exercise_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") diff --git a/test/conftest.py b/test/conftest.py index ab1c974..c9ba3cc 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -60,14 +60,13 @@ def new_account(): "password": "secret", } id_counter += 1 - account_resp = good_request( + id_account = good_request( client.post, "http://localhost:8000/register", json=new_account - ) - account_resp = good_request( + )["id_account"] + access_token = good_request( client.post, "http://localhost:8000/login", json=new_account - ) - access_token = account_resp["access_token"] - accounts.append((account_resp["id_account"], access_token)) + )["access_token"] + accounts.append((id_account, access_token)) return access_token yield new_account @@ -146,11 +145,11 @@ def auth_header(token: str): def good_request(request_function, *args, **kwargs): resp = request_function(*args, **kwargs) - assert resp.status_code == 200 + assert resp.status_code == 200, resp.json() return resp.json() def bad_request(request_function, status_code, *args, **kwargs): resp = request_function(*args, **kwargs) - assert resp.status_code == status_code + assert resp.status_code == status_code, resp.json() return resp.json() diff --git a/test/sqlalchemy_console.py b/test/sqlalchemy_console.py index 52439c0..4f64c5a 100644 --- a/test/sqlalchemy_console.py +++ b/test/sqlalchemy_console.py @@ -15,4 +15,3 @@ Base = declarative_base() models.Base.metadata.create_all(bind=engine) db = SessionLocal() - diff --git a/test/test_do_exercise.py b/test/test_do_exercise.py index 1c9489f..3094468 100644 --- a/test/test_do_exercise.py +++ b/test/test_do_exercise.py @@ -1,4 +1,4 @@ -from conftest import client, auth_header, good_request +from conftest import client, auth_header, good_request, bad_request def test_track_exercise_completion_metrics(regular_token, create_user, create_exercise): @@ -122,7 +122,9 @@ def test_read_all_exercises_with_completion( exercises = [ ex for ex in good_request( - client.get, f"http://localhost:8000/exercises?user_id={id_alice}" + client.get, + f"http://localhost:8000/exercises?user_id={id_alice}", + headers=auth_header(regular_token), ) if ex["id"] in created_exercises.keys() ] @@ -131,3 +133,15 @@ def test_read_all_exercises_with_completion( assert ex["completion"] is None else: assert ex["completion"]["completion"] == created_exercises[ex["id"]] + + +def test_read_exercises_of_other_account_user(create_account, create_exercise): + access_token = create_account() + ex_id = create_exercise()["id"] + bad_request(client.get, 403, f"http://localhost:8000/exercises/{ex_id}?user_id=2") + bad_request( + client.get, + 404, + f"http://localhost:8000/exercises/{ex_id}?user_id=2", + headers=auth_header(access_token), + ) From 9b4373884216209c0c0b15c7fa165df2e316d203 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sat, 18 Nov 2023 13:11:56 +0100 Subject: [PATCH 11/95] black formatting --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 6605f66..7da1f2e 100644 --- a/main.py +++ b/main.py @@ -59,7 +59,7 @@ def validate_user_belongs_to_account( detail=f"user with id {user_id} not found for this account", ) - + async def redirect_trailing_slash(request, call_next): if request.url.path.endswith("/"): url_without_trailing_slash = str(request.url)[:-1] From 1954409b9fdb852607f0dabb9c916c01c043211b Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sat, 18 Nov 2023 18:52:14 +0100 Subject: [PATCH 12/95] use Column instead of Mapped --- models.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/models.py b/models.py index c20ba9f..e200a72 100644 --- a/models.py +++ b/models.py @@ -40,15 +40,13 @@ class AccountType(str, enum.Enum): class DoExercise(Base): __tablename__ = "do_exercise" - exercise_id: Mapped[int] = mapped_column( - ForeignKey("exercise.id"), primary_key=True - ) - user_id: Mapped[int] = mapped_column(ForeignKey("user.id_user"), primary_key=True) - user: Mapped["User"] = relationship(back_populates="exercises") - exercise: Mapped["Exercise"] = relationship(back_populates="users") - completion: Mapped[Optional[int]] - position: Mapped[Optional[int]] - time_spent: Mapped[Optional[int]] + exercise_id = Column(Integer, ForeignKey("exercise.id"), primary_key=True) + user_id = Column(Integer, ForeignKey("user.id_user"), primary_key=True) + user = relationship("User", back_populates="exercises") + exercise = relationship("Exercise", back_populates="users") + completion = Column(Integer, nullable=True) + position = Column(Integer, nullable=True) + time_spent = Column(Integer, nullable=True) class Complexity(enum.Enum): @@ -67,7 +65,7 @@ class Exercise(Base): category = relationship( "Category", secondary=exercise_category, back_populates="exercises" ) - users: Mapped[List["DoExercise"]] = relationship( + users = relationship("DoExercise", back_populates="exercise", lazy="dynamic" ) date = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -89,7 +87,7 @@ class User(Base): id_account = Column(Integer) username = Column(String) proficiency = Column(Float) - exercises: Mapped[List["DoExercise"]] = relationship( + exercises = relationship("DoExercise", back_populates="user", lazy="dynamic" ) From 46b9ff1255ac4c8c067833129654a970b3844eb2 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sat, 18 Nov 2023 18:53:19 +0100 Subject: [PATCH 13/95] black formatting --- models.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/models.py b/models.py index e200a72..024b9fa 100644 --- a/models.py +++ b/models.py @@ -65,9 +65,7 @@ class Exercise(Base): category = relationship( "Category", secondary=exercise_category, back_populates="exercises" ) - users = relationship("DoExercise", - back_populates="exercise", lazy="dynamic" - ) + users = relationship("DoExercise", back_populates="exercise", lazy="dynamic") date = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -87,9 +85,7 @@ class User(Base): id_account = Column(Integer) username = Column(String) proficiency = Column(Float) - exercises = relationship("DoExercise", - back_populates="user", lazy="dynamic" - ) + exercises = relationship("DoExercise", back_populates="user", lazy="dynamic") class Category(Base): From f4980242857ee59f6ee546a16cae09a52795cf64 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 19 Nov 2023 21:51:14 +0100 Subject: [PATCH 14/95] Add relationship between User and Account. Resolved #14 --- models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/models.py b/models.py index 024b9fa..a26a2db 100644 --- a/models.py +++ b/models.py @@ -76,16 +76,18 @@ class Account(Base): email = Column(String, unique=True) password = Column(String) account_category = Column(Enum(AccountType)) + users = relationship("User", back_populates="account") class User(Base): __tablename__ = "user" id_user = Column(Integer, primary_key=True, index=True, autoincrement=True) - id_account = Column(Integer) + id_account = Column(Integer, ForeignKey('account.id_account')) username = Column(String) proficiency = Column(Float) exercises = relationship("DoExercise", back_populates="user", lazy="dynamic") + account = relationship("Account", back_populates="users") class Category(Base): From e785a6d18abc5b1d12267733dabc84a725e8c06e Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 19 Nov 2023 21:54:35 +0100 Subject: [PATCH 15/95] black . --- models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models.py b/models.py index a26a2db..e08c8b3 100644 --- a/models.py +++ b/models.py @@ -83,7 +83,7 @@ class User(Base): __tablename__ = "user" id_user = Column(Integer, primary_key=True, index=True, autoincrement=True) - id_account = Column(Integer, ForeignKey('account.id_account')) + id_account = Column(Integer, ForeignKey("account.id_account")) username = Column(String) proficiency = Column(Float) exercises = relationship("DoExercise", back_populates="user", lazy="dynamic") From 4eab1a4fb635e7c9ac2ae6362d446fb91c9087e2 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Mon, 20 Nov 2023 16:51:58 +0100 Subject: [PATCH 16/95] CRUD superadmin --- main.py | 7 ++++++- schemas.py | 1 + test/conftest.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 7da1f2e..261e998 100644 --- a/main.py +++ b/main.py @@ -202,7 +202,12 @@ def create_account( validate_access_level(auth_user, models.AccountType.Superadmin) if crud.email_is_registered(db, account_in.email): raise HTTPException(status_code=409, detail="Email already registered") - account_saved = crud.create_account(db, account_in, models.AccountType.Admin) + + if account_in.is_superadmin: + type_account = models.AccountType.Superadmin + else: + type_account = models.AccountType.Admin + account_saved = crud.create_account(db, account_in, type_account) return account_saved diff --git a/schemas.py b/schemas.py index 8d3456a..5ac1ea6 100644 --- a/schemas.py +++ b/schemas.py @@ -74,6 +74,7 @@ class FullExerciseResponse(ExerciseResponse): class AccountIn(BaseModel): email: EmailStr password: str + is_superadmin: Optional[bool] = False class AccountOut(BaseModel): diff --git a/test/conftest.py b/test/conftest.py index c9ba3cc..fea7759 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -40,6 +40,7 @@ def superadmin_token(): account_details = {"email": "superadmin@gmail.com", "password": "superadmin"} resp = client.post("http://localhost:8000/login", json=account_details).json() if "detail" in resp and resp["detail"] == "Username/Password wrong": + account_details = {"email": "superadmin@gmail.com", "password": "superadmin", "is_superadmin": True} resp = client.post( "http://localhost:8000/createsuperadmin", json=account_details ).json() From 04f772853df4ce05998f39ed940156653abe5c91 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Mon, 20 Nov 2023 16:52:36 +0100 Subject: [PATCH 17/95] black . --- main.py | 2 +- test/conftest.py | 6 +++++- test/test_accounts.py | 5 +---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 261e998..122780e 100644 --- a/main.py +++ b/main.py @@ -202,7 +202,7 @@ def create_account( validate_access_level(auth_user, models.AccountType.Superadmin) if crud.email_is_registered(db, account_in.email): raise HTTPException(status_code=409, detail="Email already registered") - + if account_in.is_superadmin: type_account = models.AccountType.Superadmin else: diff --git a/test/conftest.py b/test/conftest.py index fea7759..d75a4fd 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -40,7 +40,11 @@ def superadmin_token(): account_details = {"email": "superadmin@gmail.com", "password": "superadmin"} resp = client.post("http://localhost:8000/login", json=account_details).json() if "detail" in resp and resp["detail"] == "Username/Password wrong": - account_details = {"email": "superadmin@gmail.com", "password": "superadmin", "is_superadmin": True} + account_details = { + "email": "superadmin@gmail.com", + "password": "superadmin", + "is_superadmin": True, + } resp = client.post( "http://localhost:8000/createsuperadmin", json=account_details ).json() diff --git a/test/test_accounts.py b/test/test_accounts.py index ea46026..98810f6 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -6,10 +6,7 @@ def test_create_account(superadmin_token): "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() account_count = len(accounts) - new_account = { - "email": "email@gmail.com", - "password": "secret", - } + new_account = {"email": "email@gmail.com", "password": "secret"} resp = client.post( "http://localhost:8000/accounts", json=new_account, From 0c2a5af9d2b784ceb03f0f0a4819874eed331e53 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 20 Nov 2023 19:23:51 +0100 Subject: [PATCH 18/95] Added Template for reset email --- html_templates/reset_password_email.html | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 html_templates/reset_password_email.html diff --git a/html_templates/reset_password_email.html b/html_templates/reset_password_email.html new file mode 100644 index 0000000..bfbd9a8 --- /dev/null +++ b/html_templates/reset_password_email.html @@ -0,0 +1,24 @@ + + + + + +Password reset email + + + +

Hey!

+ +

It seems you have forgotten your password and requested a reset.

+ +

Follow this link to reset your password:

+

{{url|e}}

+Note: This link will expire in {{expire_in_minutes}} minutes. +
+ +
+ +

If you've received this mail without filling a reqeust, it's likely that a user entered your email address by mistake. In that case, just ignore it.

+ + + \ No newline at end of file From 5a2be4a68a0821c049fbeded0ff1f16dbd89b0aa Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 20 Nov 2023 19:33:07 +0100 Subject: [PATCH 19/95] Added fastapi-mail to send password reset mail --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index fbfcfa9..22fbb9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,4 @@ bcrypt==3.2.0 uuid==1.30 httpx==0.25.1 PyJWT==2.6.0 +fastapi-mail==1.4.1 From 513a899b3f7f86149ddf572595129608b7fa79b4 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 20 Nov 2023 20:27:43 +0100 Subject: [PATCH 20/95] Added functions for "send password link" --- auth.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/auth.py b/auth.py index 3dd597d..a56f3e7 100644 --- a/auth.py +++ b/auth.py @@ -1,17 +1,36 @@ from typing import Type from sqlalchemy.orm import Session +from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType +from pydantic import EmailStr import models, schemas import jwt import time import bcrypt +import os JWT_VALID_TIME_ACCESS = 60 * 20 # 20min JWT_VALID_TIME_REFRESH = 60 * 60 * 24 * 7 # One week +JWT_VALID_TIME_PWD_RESET = 60 * 10 # 10min JWT_SECRET = "C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" JWT_ALGORITHM = "HS256" +#Mail Config +conf = ConnectionConfig( + MAIL_USERNAME = "kosjenka.readingapp@gmail.com", + MAIL_PASSWORD = "qcjb hvps xmlf rtpm", + MAIL_FROM = "kosjenka.readingapp@gmail.com", + MAIL_PORT = 587, + MAIL_SERVER = "smtp.gmail.com", + MAIL_FROM_NAME="Kosjenka Support", + MAIL_STARTTLS = True, + MAIL_SSL_TLS = False, + USE_CREDENTIALS = True, + VALIDATE_CERTS = False, + TEMPLATE_FOLDER = os.path.join(os.path.dirname(__file__), 'html_templates') +) + def createToken( account_id: int, account_category: str, valid_time: int, is_access_token: bool @@ -78,3 +97,34 @@ def generate_refresh_token(old_token: str, decoded_token: Type[schemas.AuthSchem ) reponse.refresh_token = old_token return reponse + +#Password reset +def get_account_by_email(db: Session, email: EmailStr): + account = db.query(models.Account).filter(models.Account.email == email).first() + return account + +async def send_password_reset_mail(account: models.Account, base_url: str): + token = createPasswortResetToken(email=account.email, valid_time=JWT_VALID_TIME_PWD_RESET) + template_body = { + "user": account.email, + "url": f"{base_url}reset_password?token={token}", + "expire_in_minutes": (JWT_VALID_TIME_PWD_RESET / 60) + } + message = MessageSchema( + subject="Kosjenka - Password Reset", + recipients=[account.email], + template_body=template_body, + subtype=MessageType.html + ) + fm = FastMail(conf) + await fm.send_message(message, template_name="reset_password_email.html") + +def createPasswortResetToken(email: EmailStr, valid_time: int): + payload = { + "email": email, + "expires": time.time() + valid_time, + } + token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + return token + + \ No newline at end of file From 7c92ba07f1667ab1b010422e013329b5a05c4a9e Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 20 Nov 2023 21:03:56 +0100 Subject: [PATCH 21/95] Added /forgot_password endpoint to get password reset link by email --- main.py | 18 +++++++++++++++++- schemas.py | 4 ++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 7da1f2e..68e0bd0 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ from sqlalchemy.orm import Session from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse +from fastapi import Request import crud import models @@ -409,7 +410,22 @@ def me( raise HTTPException(status_code=404, detail="Account not found") return db_account - +# Password Reset +@app.post("/forgot_password") +async def send_password_mail(forget_passwort_input: schemas.ForgetPasswordSchema,request: Request, db: Session = Depends(get_db)): + account = auth.get_account_by_email(db=db, email=forget_passwort_input.email) + if account is None: + raise HTTPException(status_code=400, detail=f"Email not found") + try: + await auth.send_password_reset_mail(account=account, base_url=str(request.base_url)) + return {"result": f"An email has been sent to {account.email} with a link for password reset."} + except Exception as e: + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred" + ) + + + # CreateSuperadmin just for Debugging @app.post("/createsuperadmin", response_model=schemas.AccountOut) def createsuperadmin_only_for_debugging( diff --git a/schemas.py b/schemas.py index 8d3456a..63d20a6 100644 --- a/schemas.py +++ b/schemas.py @@ -124,3 +124,7 @@ class RefreshSchema(BaseModel): class MeSchema(BaseModel): account_id: int account_category: str + +#Forget Password +class ForgetPasswordSchema(BaseModel): + email: EmailStr From 100cbdb57a42e4de742d6db0918f12a90e4894a1 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 20 Nov 2023 21:04:40 +0100 Subject: [PATCH 22/95] Changed Email template to make link clickable in Thunderbird --- html_templates/reset_password_email.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/html_templates/reset_password_email.html b/html_templates/reset_password_email.html index bfbd9a8..d1ca833 100644 --- a/html_templates/reset_password_email.html +++ b/html_templates/reset_password_email.html @@ -12,7 +12,7 @@

Hey!

It seems you have forgotten your password and requested a reset.

Follow this link to reset your password:

-

{{url|e}}

+

{{url}}

Note: This link will expire in {{expire_in_minutes}} minutes.
From 9c6b10f58a904f289aaeef2c26519d11c153e07a Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 20 Nov 2023 22:29:10 +0100 Subject: [PATCH 23/95] Added HTML Page for new password --- html_templates/reset_password.html | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 html_templates/reset_password.html diff --git a/html_templates/reset_password.html b/html_templates/reset_password.html new file mode 100644 index 0000000..54a3d8c --- /dev/null +++ b/html_templates/reset_password.html @@ -0,0 +1,90 @@ + + + + + + + Password reset + + + + + + +
+ {% if token %} +

Hey, let's get you back in!

+ +
+
+ +
+ + + + +
+
+
+ + {% endif %} +
+ + + + + \ No newline at end of file From bb9c590e02e6c803ecff3d809d151399cc13e642 Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 20 Nov 2023 22:45:27 +0100 Subject: [PATCH 24/95] Add /Reset_password endpoint (GET) --- main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 68e0bd0..34a15bc 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse -from fastapi import Request +from fastapi.responses import HTMLResponse import crud import models @@ -424,7 +424,11 @@ async def send_password_mail(forget_passwort_input: schemas.ForgetPasswordSchema status_code=500, detail=f"An unexpected error occurred" ) - +@app.get("/reset_password", response_class=HTMLResponse) +def account_reset_password(request: Request): + token = request.query_params.get('token') + return templates.TemplateResponse("reset_password.html",{"request": request, "token": token}) + # CreateSuperadmin just for Debugging @app.post("/createsuperadmin", response_model=schemas.AccountOut) From 6e1340c50fed74a684db305b3b645c975fcdc93d Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 12:20:57 +0100 Subject: [PATCH 25/95] Reset Password function --- auth.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/auth.py b/auth.py index a56f3e7..32ddf85 100644 --- a/auth.py +++ b/auth.py @@ -4,7 +4,7 @@ from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType from pydantic import EmailStr -import models, schemas +import models, schemas, crud import jwt import time import bcrypt @@ -126,5 +126,21 @@ def createPasswortResetToken(email: EmailStr, valid_time: int): } token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return token + +def reset_password(db: Session, new_password: str, token: str): + try: + decoded_token = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + if decoded_token["expires"] < time.time(): + return "TOKEN_EXPIRED" + account = get_account_by_email(db, decoded_token["email"]) + if account is None: + return "EMAIL_NOT_FOUND" + hashed_pw = crud.password_hasher(new_password) + setattr(account, "password", hashed_pw) + db.commit() + db.refresh(account) + return "SUCCESS" + except: + return "ERROR" \ No newline at end of file From c780a02e95d2d5607e15b3ee15d500ef8678040c Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 13:15:30 +0100 Subject: [PATCH 26/95] Html Page Password Reset result --- html_templates/reset_password_result.html | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 html_templates/reset_password_result.html diff --git a/html_templates/reset_password_result.html b/html_templates/reset_password_result.html new file mode 100644 index 0000000..d11b918 --- /dev/null +++ b/html_templates/reset_password_result.html @@ -0,0 +1,55 @@ + + + + + + + Password reset + + + + + + +
+ {% if success == "SUCCESS" %} + +

+ Your password has been reset successfully! +

+ {% elif success == "TOKEN_EXPIRED" %} + +

Oops, too slow!

+

+ The password reset token is expired. Please request a password reset email again. +

+ {% elif success == "EMAIL_NOT_FOUND" %} + +

Oops, there is something wrong with your email!

+

+ Please request a password reset email again. +

+ {% else %} + +

Oops, sorry, we got something wrong!

+

+ Please try again. If this error persists please contact the admin support! +

+ {% endif %} +
+ + \ No newline at end of file From ae679f952813c15b59efb0bb2f6039e5fa49a6f0 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 13:20:03 +0100 Subject: [PATCH 27/95] Added python-multipart for getting values of html forms --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 22fbb9b..b2c3573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,3 +33,4 @@ uuid==1.30 httpx==0.25.1 PyJWT==2.6.0 fastapi-mail==1.4.1 +python-multipart==0.0.6 \ No newline at end of file From 48efbce061fc59090b02ea9646ecbff973d72dd4 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 13:52:33 +0100 Subject: [PATCH 28/95] added endpoint /password_reset (POST) --- main.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 34a15bc..2a7b103 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,9 @@ from sqlalchemy.orm import Session from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse +from fastapi import Request, Form from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates import crud import models @@ -13,6 +15,8 @@ models.Base.metadata.create_all(bind=engine) +templates = Jinja2Templates(directory="html_templates") + app = FastAPI() app.add_middleware( CORSMiddleware, @@ -415,7 +419,7 @@ def me( async def send_password_mail(forget_passwort_input: schemas.ForgetPasswordSchema,request: Request, db: Session = Depends(get_db)): account = auth.get_account_by_email(db=db, email=forget_passwort_input.email) if account is None: - raise HTTPException(status_code=400, detail=f"Email not found") + raise HTTPException(status_code=404, detail=f"Email not found") try: await auth.send_password_reset_mail(account=account, base_url=str(request.base_url)) return {"result": f"An email has been sent to {account.email} with a link for password reset."} @@ -429,6 +433,10 @@ def account_reset_password(request: Request): token = request.query_params.get('token') return templates.TemplateResponse("reset_password.html",{"request": request, "token": token}) +@app.post("/reset_password", response_class=HTMLResponse) +def account_reset_password_result(request: Request, new_password: str = Form(...), token: str = Form(...), db: Session = Depends(get_db)): + result = auth.reset_password(db, new_password, token) + return templates.TemplateResponse("reset_password_result.html",{"request": request, "success": result}) # CreateSuperadmin just for Debugging @app.post("/createsuperadmin", response_model=schemas.AccountOut) From edb0956bcd11b508c083ea5c06caaaf795dd8fba Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 14:02:24 +0100 Subject: [PATCH 29/95] formatted with black --- auth.py | 44 ++++++++++++++++++++++++-------------------- main.py | 45 ++++++++++++++++++++++++++++++++------------- schemas.py | 5 +++-- 3 files changed, 59 insertions(+), 35 deletions(-) diff --git a/auth.py b/auth.py index 32ddf85..79523e4 100644 --- a/auth.py +++ b/auth.py @@ -16,19 +16,19 @@ JWT_SECRET = "C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" JWT_ALGORITHM = "HS256" -#Mail Config +# Mail Config conf = ConnectionConfig( - MAIL_USERNAME = "kosjenka.readingapp@gmail.com", - MAIL_PASSWORD = "qcjb hvps xmlf rtpm", - MAIL_FROM = "kosjenka.readingapp@gmail.com", - MAIL_PORT = 587, - MAIL_SERVER = "smtp.gmail.com", + MAIL_USERNAME="kosjenka.readingapp@gmail.com", + MAIL_PASSWORD="qcjb hvps xmlf rtpm", + MAIL_FROM="kosjenka.readingapp@gmail.com", + MAIL_PORT=587, + MAIL_SERVER="smtp.gmail.com", MAIL_FROM_NAME="Kosjenka Support", - MAIL_STARTTLS = True, - MAIL_SSL_TLS = False, - USE_CREDENTIALS = True, - VALIDATE_CERTS = False, - TEMPLATE_FOLDER = os.path.join(os.path.dirname(__file__), 'html_templates') + MAIL_STARTTLS=True, + MAIL_SSL_TLS=False, + USE_CREDENTIALS=True, + VALIDATE_CERTS=False, + TEMPLATE_FOLDER=os.path.join(os.path.dirname(__file__), "html_templates"), ) @@ -98,27 +98,32 @@ def generate_refresh_token(old_token: str, decoded_token: Type[schemas.AuthSchem reponse.refresh_token = old_token return reponse -#Password reset + +# Password reset def get_account_by_email(db: Session, email: EmailStr): account = db.query(models.Account).filter(models.Account.email == email).first() return account - + + async def send_password_reset_mail(account: models.Account, base_url: str): - token = createPasswortResetToken(email=account.email, valid_time=JWT_VALID_TIME_PWD_RESET) + token = createPasswortResetToken( + email=account.email, valid_time=JWT_VALID_TIME_PWD_RESET + ) template_body = { "user": account.email, "url": f"{base_url}reset_password?token={token}", - "expire_in_minutes": (JWT_VALID_TIME_PWD_RESET / 60) - } + "expire_in_minutes": (JWT_VALID_TIME_PWD_RESET / 60), + } message = MessageSchema( subject="Kosjenka - Password Reset", recipients=[account.email], template_body=template_body, - subtype=MessageType.html + subtype=MessageType.html, ) fm = FastMail(conf) await fm.send_message(message, template_name="reset_password_email.html") - + + def createPasswortResetToken(email: EmailStr, valid_time: int): payload = { "email": email, @@ -127,6 +132,7 @@ def createPasswortResetToken(email: EmailStr, valid_time: int): token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return token + def reset_password(db: Session, new_password: str, token: str): try: decoded_token = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) @@ -142,5 +148,3 @@ def reset_password(db: Session, new_password: str, token: str): return "SUCCESS" except: return "ERROR" - - \ No newline at end of file diff --git a/main.py b/main.py index 2a7b103..0ca9ada 100644 --- a/main.py +++ b/main.py @@ -414,30 +414,49 @@ def me( raise HTTPException(status_code=404, detail="Account not found") return db_account + # Password Reset @app.post("/forgot_password") -async def send_password_mail(forget_passwort_input: schemas.ForgetPasswordSchema,request: Request, db: Session = Depends(get_db)): +async def send_password_mail( + forget_passwort_input: schemas.ForgetPasswordSchema, + request: Request, + db: Session = Depends(get_db), +): account = auth.get_account_by_email(db=db, email=forget_passwort_input.email) - if account is None: + if account is None: raise HTTPException(status_code=404, detail=f"Email not found") try: - await auth.send_password_reset_mail(account=account, base_url=str(request.base_url)) - return {"result": f"An email has been sent to {account.email} with a link for password reset."} + await auth.send_password_reset_mail( + account=account, base_url=str(request.base_url) + ) + return { + "result": f"An email has been sent to {account.email} with a link for password reset." + } except Exception as e: - raise HTTPException( - status_code=500, detail=f"An unexpected error occurred" - ) - + raise HTTPException(status_code=500, detail=f"An unexpected error occurred") + + @app.get("/reset_password", response_class=HTMLResponse) def account_reset_password(request: Request): - token = request.query_params.get('token') - return templates.TemplateResponse("reset_password.html",{"request": request, "token": token}) + token = request.query_params.get("token") + return templates.TemplateResponse( + "reset_password.html", {"request": request, "token": token} + ) + @app.post("/reset_password", response_class=HTMLResponse) -def account_reset_password_result(request: Request, new_password: str = Form(...), token: str = Form(...), db: Session = Depends(get_db)): +def account_reset_password_result( + request: Request, + new_password: str = Form(...), + token: str = Form(...), + db: Session = Depends(get_db), +): result = auth.reset_password(db, new_password, token) - return templates.TemplateResponse("reset_password_result.html",{"request": request, "success": result}) - + return templates.TemplateResponse( + "reset_password_result.html", {"request": request, "success": result} + ) + + # CreateSuperadmin just for Debugging @app.post("/createsuperadmin", response_model=schemas.AccountOut) def createsuperadmin_only_for_debugging( diff --git a/schemas.py b/schemas.py index 63d20a6..5501c1f 100644 --- a/schemas.py +++ b/schemas.py @@ -124,7 +124,8 @@ class RefreshSchema(BaseModel): class MeSchema(BaseModel): account_id: int account_category: str - -#Forget Password + + +# Forget Password class ForgetPasswordSchema(BaseModel): email: EmailStr From deedf4908360679bb97d138517ab3728eea819d9 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 14:45:39 +0100 Subject: [PATCH 30/95] Changed Route Names, and dont show html pages --- main.py | 35 +++++++++++++++++++++-------------- schemas.py | 7 +++++++ 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index 0ca9ada..15d2c2b 100644 --- a/main.py +++ b/main.py @@ -416,7 +416,7 @@ def me( # Password Reset -@app.post("/forgot_password") +@app.post("/password/forgot") async def send_password_mail( forget_passwort_input: schemas.ForgetPasswordSchema, request: Request, @@ -436,25 +436,32 @@ async def send_password_mail( raise HTTPException(status_code=500, detail=f"An unexpected error occurred") -@app.get("/reset_password", response_class=HTMLResponse) -def account_reset_password(request: Request): - token = request.query_params.get("token") - return templates.TemplateResponse( - "reset_password.html", {"request": request, "token": token} - ) +#@app.get("/reset_password", response_class=HTMLResponse) +#def account_reset_password(request: Request): +# token = request.query_params.get("token") +# return templates.TemplateResponse( +# "reset_password.html", {"request": request, "token": token} +# ) -@app.post("/reset_password", response_class=HTMLResponse) +@app.post("/password/reset", response_model=schemas.ResetPasswordResultSchema) def account_reset_password_result( + input: schemas.ResetPasswordSchema, request: Request, - new_password: str = Form(...), - token: str = Form(...), db: Session = Depends(get_db), ): - result = auth.reset_password(db, new_password, token) - return templates.TemplateResponse( - "reset_password_result.html", {"request": request, "success": result} - ) + + result = auth.reset_password(db, input.password, input.token) + if(result == "SUCCESS"): + result = schemas.ResetPasswordResultSchema + result.details = "Successfully updated password" + return result + elif(result == "TOKEN_EXPIRED"): + raise HTTPException(status_code=401, detail="Token is expired") + elif(result == "EMAIL_NOT_FOUND"): + raise HTTPException(status_code=404, detail="Email not found") + else: + raise HTTPException(status_code=500, detail="An unexpected error occurred") # CreateSuperadmin just for Debugging diff --git a/schemas.py b/schemas.py index 5501c1f..00a6106 100644 --- a/schemas.py +++ b/schemas.py @@ -129,3 +129,10 @@ class MeSchema(BaseModel): # Forget Password class ForgetPasswordSchema(BaseModel): email: EmailStr + +class ResetPasswordSchema(BaseModel): + password: str + token: str + +class ResetPasswordResultSchema(BaseModel): + details: str \ No newline at end of file From 5d050b811bcf2c2e8f7f9db4a3b192889ca7521c Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 14:46:47 +0100 Subject: [PATCH 31/95] Added missing schema --- schemas.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/schemas.py b/schemas.py index 00a6106..128d3fb 100644 --- a/schemas.py +++ b/schemas.py @@ -129,10 +129,12 @@ class MeSchema(BaseModel): # Forget Password class ForgetPasswordSchema(BaseModel): email: EmailStr - + + class ResetPasswordSchema(BaseModel): password: str token: str + class ResetPasswordResultSchema(BaseModel): - details: str \ No newline at end of file + details: str From a6a384444e74060c90182993b238886b428bdd8f Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 14:58:14 +0100 Subject: [PATCH 32/95] Removed HTML Pages --- html_templates/reset_password.html | 90 ----------------------- html_templates/reset_password_result.html | 55 -------------- 2 files changed, 145 deletions(-) delete mode 100644 html_templates/reset_password.html delete mode 100644 html_templates/reset_password_result.html diff --git a/html_templates/reset_password.html b/html_templates/reset_password.html deleted file mode 100644 index 54a3d8c..0000000 --- a/html_templates/reset_password.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - Password reset - - - - - - -
- {% if token %} -

Hey, let's get you back in!

- -
-
- -
- - - - -
-
-
- - {% endif %} -
- - - - - \ No newline at end of file diff --git a/html_templates/reset_password_result.html b/html_templates/reset_password_result.html deleted file mode 100644 index d11b918..0000000 --- a/html_templates/reset_password_result.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - Password reset - - - - - - -
- {% if success == "SUCCESS" %} - -

- Your password has been reset successfully! -

- {% elif success == "TOKEN_EXPIRED" %} - -

Oops, too slow!

-

- The password reset token is expired. Please request a password reset email again. -

- {% elif success == "EMAIL_NOT_FOUND" %} - -

Oops, there is something wrong with your email!

-

- Please request a password reset email again. -

- {% else %} - -

Oops, sorry, we got something wrong!

-

- Please try again. If this error persists please contact the admin support! -

- {% endif %} -
- - \ No newline at end of file From 08740ddaae94d132e82cc2335125613ad23206e7 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 14:58:32 +0100 Subject: [PATCH 33/95] Added env vars example --- .env.example | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.env.example b/.env.example index 4e42422..bc0b6dd 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,15 @@ DATABASE_URL="sqlite:///./db.sqlite" # DATABASE_URL="postgresql://user:password@postgresserver/db" + +#AUTH SETTINGS / PASSWORT RESET +PASSWORD_RESET_LINK="127.0.0.1" #Important: No Backslash at the end! +JWT_VALID_TIME_ACCESS=1200 #In Sec = 20min +JWT_VALID_TIME_REFRESH=604800 #In Sec = one week +JWT_VALID_TIME_PWD_RESET=600 #In Sec = 10min +JWT_SECRET="C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" +JWT_ALGORITHM="HS256" +MAIL_USERNAME="kosjenka.readingapp@gmail.com" +MAIL_PASSWORD="qcjb hvps xmlf rtpm" +MAIL_PORT=587 +MAIL_SERVER="smtp.gmail.com" +MAIL_FROM_NAME="Kosjenka Support" From 627dbfa34921b0edf7c624a99483fa180b94f09e Mon Sep 17 00:00:00 2001 From: Valentin Vikhorev <33204359+vvihorev@users.noreply.github.com> Date: Tue, 21 Nov 2023 15:20:26 +0100 Subject: [PATCH 34/95] Update .env.example --- .env.example | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index bc0b6dd..5d582fc 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ DATABASE_URL="sqlite:///./db.sqlite" # DATABASE_URL="postgresql://user:password@postgresserver/db" -#AUTH SETTINGS / PASSWORT RESET -PASSWORD_RESET_LINK="127.0.0.1" #Important: No Backslash at the end! +# AUTH SETTINGS / PASSWORT RESET +# Important: No Backslash at the end! +PASSWORD_RESET_LINK="127.0.0.1" JWT_VALID_TIME_ACCESS=1200 #In Sec = 20min JWT_VALID_TIME_REFRESH=604800 #In Sec = one week JWT_VALID_TIME_PWD_RESET=600 #In Sec = 10min From a811868e2dc2d20fec6121fc7e5b64f5c317fe06 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 15:31:47 +0100 Subject: [PATCH 35/95] Changed everything to env vars --- auth.py | 28 +++++++++++++++------------- main.py | 20 +++++--------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/auth.py b/auth.py index 79523e4..e73edf5 100644 --- a/auth.py +++ b/auth.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType from pydantic import EmailStr +from dotenv import load_dotenv import models, schemas, crud import jwt @@ -10,20 +11,20 @@ import bcrypt import os -JWT_VALID_TIME_ACCESS = 60 * 20 # 20min -JWT_VALID_TIME_REFRESH = 60 * 60 * 24 * 7 # One week -JWT_VALID_TIME_PWD_RESET = 60 * 10 # 10min -JWT_SECRET = "C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" -JWT_ALGORITHM = "HS256" +JWT_VALID_TIME_ACCESS = int(os.environ["JWT_VALID_TIME_ACCESS"]) # 60 * 20 # 20min +JWT_VALID_TIME_REFRESH = int(os.environ["JWT_VALID_TIME_REFRESH"]) # 60 * 60 * 24 * 7 # One week +JWT_VALID_TIME_PWD_RESET = int(os.environ["JWT_VALID_TIME_PWD_RESET"]) # 60 * 10 # 10min +JWT_SECRET = os.environ["JWT_SECRET"] # "C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" +JWT_ALGORITHM = os.environ["JWT_ALGORITHM"] # "HS256" # Mail Config conf = ConnectionConfig( - MAIL_USERNAME="kosjenka.readingapp@gmail.com", - MAIL_PASSWORD="qcjb hvps xmlf rtpm", - MAIL_FROM="kosjenka.readingapp@gmail.com", - MAIL_PORT=587, - MAIL_SERVER="smtp.gmail.com", - MAIL_FROM_NAME="Kosjenka Support", + MAIL_USERNAME=os.environ["MAIL_USERNAME"], # "kosjenka.readingapp@gmail.com", + MAIL_PASSWORD=os.environ["MAIL_PASSWORD"], # "qcjb hvps xmlf rtpm", + MAIL_FROM=os.environ["MAIL_USERNAME"], # "kosjenka.readingapp@gmail.com", + MAIL_PORT= int(os.environ["MAIL_PORT"]), # 587, + MAIL_SERVER=os.environ["MAIL_SERVER"], # "smtp.gmail.com", + MAIL_FROM_NAME=os.environ["MAIL_FROM_NAME"], # "Kosjenka Support", MAIL_STARTTLS=True, MAIL_SSL_TLS=False, USE_CREDENTIALS=True, @@ -105,13 +106,14 @@ def get_account_by_email(db: Session, email: EmailStr): return account -async def send_password_reset_mail(account: models.Account, base_url: str): +async def send_password_reset_mail(account: models.Account): token = createPasswortResetToken( email=account.email, valid_time=JWT_VALID_TIME_PWD_RESET ) + link_base = os.environ["PASSWORD_RESET_LINK"] template_body = { "user": account.email, - "url": f"{base_url}reset_password?token={token}", + "url": f"{link_base}?token={token}", "expire_in_minutes": (JWT_VALID_TIME_PWD_RESET / 60), } message = MessageSchema( diff --git a/main.py b/main.py index 15d2c2b..16f08a5 100644 --- a/main.py +++ b/main.py @@ -419,7 +419,6 @@ def me( @app.post("/password/forgot") async def send_password_mail( forget_passwort_input: schemas.ForgetPasswordSchema, - request: Request, db: Session = Depends(get_db), ): account = auth.get_account_by_email(db=db, email=forget_passwort_input.email) @@ -427,38 +426,29 @@ async def send_password_mail( raise HTTPException(status_code=404, detail=f"Email not found") try: await auth.send_password_reset_mail( - account=account, base_url=str(request.base_url) + account=account ) return { "result": f"An email has been sent to {account.email} with a link for password reset." } except Exception as e: + print(e) raise HTTPException(status_code=500, detail=f"An unexpected error occurred") -#@app.get("/reset_password", response_class=HTMLResponse) -#def account_reset_password(request: Request): -# token = request.query_params.get("token") -# return templates.TemplateResponse( -# "reset_password.html", {"request": request, "token": token} -# ) - - @app.post("/password/reset", response_model=schemas.ResetPasswordResultSchema) def account_reset_password_result( input: schemas.ResetPasswordSchema, - request: Request, db: Session = Depends(get_db), ): - result = auth.reset_password(db, input.password, input.token) - if(result == "SUCCESS"): + if result == "SUCCESS": result = schemas.ResetPasswordResultSchema result.details = "Successfully updated password" return result - elif(result == "TOKEN_EXPIRED"): + elif result == "TOKEN_EXPIRED": raise HTTPException(status_code=401, detail="Token is expired") - elif(result == "EMAIL_NOT_FOUND"): + elif result == "EMAIL_NOT_FOUND": raise HTTPException(status_code=404, detail="Email not found") else: raise HTTPException(status_code=500, detail="An unexpected error occurred") From 298a5d2fbafb5cd950f3e6d8ca13f00f0fb834a9 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 15:35:06 +0100 Subject: [PATCH 36/95] formatted with black . --- auth.py | 10 +++++++--- main.py | 4 +--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/auth.py b/auth.py index e73edf5..2078072 100644 --- a/auth.py +++ b/auth.py @@ -12,8 +12,12 @@ import os JWT_VALID_TIME_ACCESS = int(os.environ["JWT_VALID_TIME_ACCESS"]) # 60 * 20 # 20min -JWT_VALID_TIME_REFRESH = int(os.environ["JWT_VALID_TIME_REFRESH"]) # 60 * 60 * 24 * 7 # One week -JWT_VALID_TIME_PWD_RESET = int(os.environ["JWT_VALID_TIME_PWD_RESET"]) # 60 * 10 # 10min +JWT_VALID_TIME_REFRESH = int( + os.environ["JWT_VALID_TIME_REFRESH"] +) # 60 * 60 * 24 * 7 # One week +JWT_VALID_TIME_PWD_RESET = int( + os.environ["JWT_VALID_TIME_PWD_RESET"] +) # 60 * 10 # 10min JWT_SECRET = os.environ["JWT_SECRET"] # "C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" JWT_ALGORITHM = os.environ["JWT_ALGORITHM"] # "HS256" @@ -22,7 +26,7 @@ MAIL_USERNAME=os.environ["MAIL_USERNAME"], # "kosjenka.readingapp@gmail.com", MAIL_PASSWORD=os.environ["MAIL_PASSWORD"], # "qcjb hvps xmlf rtpm", MAIL_FROM=os.environ["MAIL_USERNAME"], # "kosjenka.readingapp@gmail.com", - MAIL_PORT= int(os.environ["MAIL_PORT"]), # 587, + MAIL_PORT=int(os.environ["MAIL_PORT"]), # 587, MAIL_SERVER=os.environ["MAIL_SERVER"], # "smtp.gmail.com", MAIL_FROM_NAME=os.environ["MAIL_FROM_NAME"], # "Kosjenka Support", MAIL_STARTTLS=True, diff --git a/main.py b/main.py index 16f08a5..a701e82 100644 --- a/main.py +++ b/main.py @@ -425,9 +425,7 @@ async def send_password_mail( if account is None: raise HTTPException(status_code=404, detail=f"Email not found") try: - await auth.send_password_reset_mail( - account=account - ) + await auth.send_password_reset_mail(account=account) return { "result": f"An email has been sent to {account.email} with a link for password reset." } From 5e58d83aa4faecd38a080d3da37224e50df16a26 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 15:39:36 +0100 Subject: [PATCH 37/95] Try to fix test --- .env.example | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 5d582fc..80d2b92 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,12 @@ DATABASE_URL="sqlite:///./db.sqlite" # AUTH SETTINGS / PASSWORT RESET # Important: No Backslash at the end! PASSWORD_RESET_LINK="127.0.0.1" -JWT_VALID_TIME_ACCESS=1200 #In Sec = 20min -JWT_VALID_TIME_REFRESH=604800 #In Sec = one week -JWT_VALID_TIME_PWD_RESET=600 #In Sec = 10min +#In Sec = 20min +JWT_VALID_TIME_ACCESS=1200 +#In Sec = one week +JWT_VALID_TIME_REFRESH=604800 +#In Sec = 10min +JWT_VALID_TIME_PWD_RESET=600 JWT_SECRET="C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" JWT_ALGORITHM="HS256" MAIL_USERNAME="kosjenka.readingapp@gmail.com" From ca7fbf6af885aff8e9ca84a60d1b93b80093eb64 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 15:49:14 +0100 Subject: [PATCH 38/95] updated env example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 80d2b92..70e31b2 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ JWT_VALID_TIME_PWD_RESET=600 JWT_SECRET="C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" JWT_ALGORITHM="HS256" MAIL_USERNAME="kosjenka.readingapp@gmail.com" -MAIL_PASSWORD="qcjb hvps xmlf rtpm" +MAIL_PASSWORD="" MAIL_PORT=587 MAIL_SERVER="smtp.gmail.com" MAIL_FROM_NAME="Kosjenka Support" From 2d2faadab42f0c0b7ef465d6a03505177c176d68 Mon Sep 17 00:00:00 2001 From: Tim <30784615+tim14996@users.noreply.github.com> Date: Tue, 21 Nov 2023 15:55:39 +0100 Subject: [PATCH 39/95] Update pipeline.yml --- .github/workflows/pipeline.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 76d1376..c0e5a1e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -7,6 +7,18 @@ on: env: DATABASE_URL: "sqlite:///./db.sqlite" + PASSWORD_RESET_LINK: "127.0.0.1" + JWT_VALID_TIME_ACCESS: 1200 + JWT_VALID_TIME_REFRESH: 604800 + JWT_VALID_TIME_PWD_RESET: 600 + JWT_SECRET: "secret" + JWT_ALGORITHM: "HS256" + MAIL_USERNAME: "kosjenka.readingapp@gmail.com" + MAIL_PASSWORD: "secret" + MAIL_PORT: 587 + MAIL_SERVER: "smtp.gmail.com" + MAIL_FROM_NAME: "Kosjenka Support" + jobs: tests: From 02cc02449bec9e5283c380bd683bd0667059b2aa Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 21 Nov 2023 15:56:22 +0100 Subject: [PATCH 40/95] updated env vars --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 70e31b2..884ac57 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ JWT_VALID_TIME_ACCESS=1200 JWT_VALID_TIME_REFRESH=604800 #In Sec = 10min JWT_VALID_TIME_PWD_RESET=600 -JWT_SECRET="C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" +JWT_SECRET="secret" JWT_ALGORITHM="HS256" MAIL_USERNAME="kosjenka.readingapp@gmail.com" MAIL_PASSWORD="" From fc506ff5ca2a700fa83f0109099303ab9440077e Mon Sep 17 00:00:00 2001 From: Tim <30784615+tim14996@users.noreply.github.com> Date: Tue, 21 Nov 2023 16:13:16 +0100 Subject: [PATCH 41/95] Update dev.yml --- .github/workflows/dev.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index d7d03e7..f5fc39b 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -8,6 +8,15 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + PASSWORD_RESET_LINK: "127.0.0.1" + JWT_VALID_TIME_ACCESS: 1200 + JWT_VALID_TIME_REFRESH: 604800 + JWT_VALID_TIME_PWD_RESET: 600 + JWT_ALGORITHM: "HS256" + MAIL_USERNAME: "kosjenka.readingapp@gmail.com" + MAIL_PORT: 587 + MAIL_SERVER: "smtp.gmail.com" + MAIL_FROM_NAME: "Kosjenka Support" jobs: deploy: @@ -36,6 +45,8 @@ jobs: - name: Crate env file with secrets run: | echo "DATABASE_URL=${{ secrets.DEV_DATABASE_URL }}" >> .env + echo "JWT_SECRET=${{ secrets.DEV_JWT_SECRET }}" >> .env + echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc From babee2231c5b87edc1282bb984ace823771fefe1 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Tue, 21 Nov 2023 17:10:21 +0100 Subject: [PATCH 42/95] fix issue #16 : me endpoint --- crud.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crud.py b/crud.py index f9961c3..9628d8c 100644 --- a/crud.py +++ b/crud.py @@ -160,6 +160,12 @@ def password_hasher(raw_password: str): def get_account(db: Session, auth_user: schemas.AuthSchema, account_id: int): + if auth_user.account_id == account_id: + return ( + db.query(models.Account) + .filter(models.Account.id_account == account_id) + .first() + ) if models.AccountType(auth_user.account_category) == models.AccountType.Superadmin: return ( db.query(models.Account) @@ -169,12 +175,6 @@ def get_account(db: Session, auth_user: schemas.AuthSchema, account_id: int): ) .first() ) - if auth_user.account_id == account_id: - return ( - db.query(models.Account) - .filter(models.Account.id_account == account_id) - .first() - ) return None @@ -185,7 +185,7 @@ def delete_account(db: Session, account_id: int): db.commit() -def update_account(db: Session, account_id: int, account: schemas.AccountOut): +def update_account(db: Session, account_id: int, account: schemas.AccountPatch): stored_account = ( db.query(models.Account).filter(models.Account.id_account == account_id).first() ) From 427c3842f29ba3030ddcf3920aed3106d99e185b Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Tue, 21 Nov 2023 17:11:28 +0100 Subject: [PATCH 43/95] comment test --- test/test_accounts.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/test_accounts.py b/test/test_accounts.py index 98810f6..38682e0 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -20,24 +20,29 @@ def test_create_account(superadmin_token): def test_update_account(superadmin_token): + # Get the superadmin accounts = client.get( "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() account_id = accounts[0]["id_account"] + # Get the superadmin's account original_account = client.get( f"http://localhost:8000/accounts/{account_id}", headers=auth_header(superadmin_token), ).json() body = {"email": "update@gmail.com"} + # Update the email client.patch( f"http://localhost:8000/accounts/{account_id}", json=body, headers=auth_header(superadmin_token), ).json() + # Get the superadmin's update account updated_account = client.get( f"http://localhost:8000/accounts/{account_id}", headers=auth_header(superadmin_token), ).json() + # Check that the email has been updated for key in updated_account: if key == "email": assert updated_account[key] == "update@gmail.com" From 4b8788b72d4805ae75f39107dd1db16c797c8919 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Tue, 21 Nov 2023 17:12:37 +0100 Subject: [PATCH 44/95] Fix patch account --- main.py | 4 ++-- schemas.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 122780e..572eb2d 100644 --- a/main.py +++ b/main.py @@ -269,14 +269,14 @@ def delete_account( @app.patch("/accounts/{account_id}") def update_account( account_id: int, - account: schemas.AccountIn, + updated_data: schemas.AccountPatch, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): account = crud.get_account(db, auth_user=auth_user, account_id=account_id) if account is None: raise HTTPException(status_code=404, detail="account not found") - changed_account = crud.update_account(db, account_id=account_id, account=account) + changed_account = crud.update_account(db, account_id=account_id, account=updated_data) return changed_account diff --git a/schemas.py b/schemas.py index 5ac1ea6..0b8f5cc 100644 --- a/schemas.py +++ b/schemas.py @@ -70,6 +70,9 @@ class FullExerciseResponse(ExerciseResponse): text: str # date:datetime +class AccountPatch(BaseModel): + email: Optional[EmailStr] = None + password: Optional[str] = None class AccountIn(BaseModel): email: EmailStr From cf554d758ec54869fa8af90a60521b4477367067 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Tue, 21 Nov 2023 17:13:04 +0100 Subject: [PATCH 45/95] black . --- crud.py | 8 ++++---- main.py | 4 +++- schemas.py | 2 ++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crud.py b/crud.py index 9628d8c..b321a94 100644 --- a/crud.py +++ b/crud.py @@ -162,10 +162,10 @@ def password_hasher(raw_password: str): def get_account(db: Session, auth_user: schemas.AuthSchema, account_id: int): if auth_user.account_id == account_id: return ( - db.query(models.Account) - .filter(models.Account.id_account == account_id) - .first() - ) + db.query(models.Account) + .filter(models.Account.id_account == account_id) + .first() + ) if models.AccountType(auth_user.account_category) == models.AccountType.Superadmin: return ( db.query(models.Account) diff --git a/main.py b/main.py index 572eb2d..654024f 100644 --- a/main.py +++ b/main.py @@ -276,7 +276,9 @@ def update_account( account = crud.get_account(db, auth_user=auth_user, account_id=account_id) if account is None: raise HTTPException(status_code=404, detail="account not found") - changed_account = crud.update_account(db, account_id=account_id, account=updated_data) + changed_account = crud.update_account( + db, account_id=account_id, account=updated_data + ) return changed_account diff --git a/schemas.py b/schemas.py index 0b8f5cc..a6a9403 100644 --- a/schemas.py +++ b/schemas.py @@ -70,10 +70,12 @@ class FullExerciseResponse(ExerciseResponse): text: str # date:datetime + class AccountPatch(BaseModel): email: Optional[EmailStr] = None password: Optional[str] = None + class AccountIn(BaseModel): email: EmailStr password: str From fa48e6dc016933377fbae4dfefac16931ef468a7 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Tue, 21 Nov 2023 17:35:16 +0100 Subject: [PATCH 46/95] test workflow env variables --- .github/workflows/dev.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f5fc39b..9fa141b 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -47,6 +47,15 @@ jobs: echo "DATABASE_URL=${{ secrets.DEV_DATABASE_URL }}" >> .env echo "JWT_SECRET=${{ secrets.DEV_JWT_SECRET }}" >> .env echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env + echo "PASSWORD_RESET_LINK=127.0.0.1" >> .env + echo "JWT_VALID_TIME_ACCESS=1200" >> .env + echo "JWT_VALID_TIME_REFRESH=604800" >> .env + echo "JWT_VALID_TIME_PWD_RESET=600" >> .env + echo "JWT_ALGORITHM=HS256" >> .env + echo "MAIL_USERNAME=kosjenka.readingapp@gmail.com" >> .env + echo "MAIL_PORT=587" >> .env + echo "MAIL_SERVER=smtp.gmail.com" >> .env + echo "MAIL_FROM_NAME=Kosjenka Support" >> .env - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc From 41c3ced1d98dc35e70292cc6861fda3350d2d6e8 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Tue, 21 Nov 2023 17:40:25 +0100 Subject: [PATCH 47/95] update env variables in workflows --- .github/workflows/dev.yml | 9 --------- .github/workflows/prod.yml | 14 +++++++++++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 9fa141b..332e9e1 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -8,15 +8,6 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - PASSWORD_RESET_LINK: "127.0.0.1" - JWT_VALID_TIME_ACCESS: 1200 - JWT_VALID_TIME_REFRESH: 604800 - JWT_VALID_TIME_PWD_RESET: 600 - JWT_ALGORITHM: "HS256" - MAIL_USERNAME: "kosjenka.readingapp@gmail.com" - MAIL_PORT: 587 - MAIL_SERVER: "smtp.gmail.com" - MAIL_FROM_NAME: "Kosjenka Support" jobs: deploy: diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 84ee5bd..3148c4d 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -35,7 +35,19 @@ jobs: images: ghcr.io/dostini/api - name: Crate env file with secrets - run: echo "DATABASE_URL=${{ secrets.PROD_DATABASE_URL }}" >> .env + run: | + echo "DATABASE_URL=${{ secrets.PROD_DATABASE_URL }}" >> .env + echo "JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" >> .env + echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env + echo "PASSWORD_RESET_LINK=127.0.0.1" >> .env + echo "JWT_VALID_TIME_ACCESS=1200" >> .env + echo "JWT_VALID_TIME_REFRESH=604800" >> .env + echo "JWT_VALID_TIME_PWD_RESET=600" >> .env + echo "JWT_ALGORITHM=HS256" >> .env + echo "MAIL_USERNAME=kosjenka.readingapp@gmail.com" >> .env + echo "MAIL_PORT=587" >> .env + echo "MAIL_SERVER=smtp.gmail.com" >> .env + echo "MAIL_FROM_NAME=Kosjenka Support" >> .env - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc From 213fc5288c6ee9462394ff26f7154f6a7336b479 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Tue, 21 Nov 2023 18:00:01 +0100 Subject: [PATCH 48/95] update email html --- html_templates/reset_password_email.html | 38 ++++++++++-------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/html_templates/reset_password_email.html b/html_templates/reset_password_email.html index d1ca833..f42e6b9 100644 --- a/html_templates/reset_password_email.html +++ b/html_templates/reset_password_email.html @@ -1,24 +1,18 @@ - - - -Password reset email - - - -

Hey!

- -

It seems you have forgotten your password and requested a reset.

- -

Follow this link to reset your password:

-

{{url}}

-Note: This link will expire in {{expire_in_minutes}} minutes. -
- -
- -

If you've received this mail without filling a reqeust, it's likely that a user entered your email address by mistake. In that case, just ignore it.

- - - \ No newline at end of file + + + + Password reset email + + +

Hey!

+

It seems you have forgotten your password and requested a reset.

+

Follow this link to reset your password:

+

Reset password

+ Note: This link will expire in {{int(expire_in_minutes)}} minutes. +
+
+

If you've received this mail without filling a reqeust, it's likely that a user entered your email address by mistake. In that case, just ignore it.

+ + From 3ccf8851a21314755ddd172195448559f5f188ff Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Tue, 21 Nov 2023 18:05:26 +0100 Subject: [PATCH 49/95] fix post account separate schemas.AccountIn and schemas.AccountPostAdmin to not show is_superadmin to all endpoints --- main.py | 2 +- schemas.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 654024f..bdf3658 100644 --- a/main.py +++ b/main.py @@ -195,7 +195,7 @@ def track_exercise_completion( @app.post("/accounts", response_model=schemas.AccountOut) def create_account( - account_in: schemas.AccountIn, + account_in: schemas.AccountPostAdmin, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): diff --git a/schemas.py b/schemas.py index a6a9403..567a176 100644 --- a/schemas.py +++ b/schemas.py @@ -79,6 +79,8 @@ class AccountPatch(BaseModel): class AccountIn(BaseModel): email: EmailStr password: str + +class AccountPostAdmin(AccountIn): is_superadmin: Optional[bool] = False From a98facc866e08f8d4ce4936f4c00401110ee5369 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Tue, 21 Nov 2023 18:05:27 +0100 Subject: [PATCH 50/95] oops, broke the template, sorry --- auth.py | 2 +- html_templates/reset_password_email.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/auth.py b/auth.py index 2078072..dfcddc3 100644 --- a/auth.py +++ b/auth.py @@ -118,7 +118,7 @@ async def send_password_reset_mail(account: models.Account): template_body = { "user": account.email, "url": f"{link_base}?token={token}", - "expire_in_minutes": (JWT_VALID_TIME_PWD_RESET / 60), + "expire_in_minutes": int(JWT_VALID_TIME_PWD_RESET / 60), } message = MessageSchema( subject="Kosjenka - Password Reset", diff --git a/html_templates/reset_password_email.html b/html_templates/reset_password_email.html index f42e6b9..9c8d373 100644 --- a/html_templates/reset_password_email.html +++ b/html_templates/reset_password_email.html @@ -10,7 +10,7 @@

Hey!

It seems you have forgotten your password and requested a reset.

Follow this link to reset your password:

Reset password

- Note: This link will expire in {{int(expire_in_minutes)}} minutes. + Note: This link will expire in {{expire_in_minutes}} minutes.

If you've received this mail without filling a reqeust, it's likely that a user entered your email address by mistake. In that case, just ignore it.

From 7848a2f328054552d80484ad4a070b970c6e16cb Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Tue, 21 Nov 2023 18:05:43 +0100 Subject: [PATCH 51/95] black . --- schemas.py | 1 + 1 file changed, 1 insertion(+) diff --git a/schemas.py b/schemas.py index 567a176..2bf43be 100644 --- a/schemas.py +++ b/schemas.py @@ -80,6 +80,7 @@ class AccountIn(BaseModel): email: EmailStr password: str + class AccountPostAdmin(AccountIn): is_superadmin: Optional[bool] = False From 44d0eb9b12e01128b062c1d2053e06455a401215 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 14:12:49 +0100 Subject: [PATCH 52/95] Update get_user to only get users with the right account_id --- crud.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crud.py b/crud.py index b321a94..26acf20 100644 --- a/crud.py +++ b/crud.py @@ -253,8 +253,13 @@ def get_users(db: Session, account_id: int, skip: int = 0, limit: int = 100): ) -def get_user(db: Session, user_id: int): - return db.query(models.User).filter(models.User.id_user == user_id).first() +def get_user(db: Session, user_id: int, account_id: int): + return ( + db.query(models.User) + .filter(models.User.id_account == account_id) + .filter(models.User.id_user == user_id) + .first() + ) def update_user(db: Session, user_id: int, user: schemas.UserPatch): From b9bbbda5a70bddaa0c33a682012280976c8957bb Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 14:13:03 +0100 Subject: [PATCH 53/95] secure all user endpoints --- main.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index c6c591e..e3572b1 100644 --- a/main.py +++ b/main.py @@ -303,8 +303,9 @@ def read_all_users( def read_user( user_id: int, db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): - db_user = crud.get_user(db, user_id=user_id) + db_user = crud.get_user(db, user_id=user_id, account_id=auth_user.account_id) if db_user is None: raise HTTPException(status_code=404, detail="User not found") return db_user @@ -315,8 +316,9 @@ def update_user( user_id: int, user: schemas.UserPatch, db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): - db_user = crud.get_user(db, user_id=user_id) + db_user = crud.get_user(db, user_id=user_id, account_id=auth_user.account_id) if db_user is None: raise HTTPException(status_code=404, detail="User not found") db_user = crud.update_user(db, user_id=user_id, user=user) @@ -327,8 +329,9 @@ def update_user( def delete_user( user_id: int, db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): - db_user = crud.get_user(db, user_id=user_id) + db_user = crud.get_user(db, user_id=user_id, account_id=auth_user.account_id) if db_user is None: raise HTTPException(status_code=404, detail="User not found") crud.delete_user(db=db, user_id=user_id) From f51231cc909c8edf77f58bae306b86a114c4c88d Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 14:36:15 +0100 Subject: [PATCH 54/95] Secured categories endpoints --- main.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index e3572b1..b947c67 100644 --- a/main.py +++ b/main.py @@ -348,7 +348,8 @@ def create_user( @app.post("/categories/{category}", response_model=schemas.Category) -def create_category(category: str, db: Session = Depends(get_db)): +def create_category(category: str, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer())): + validate_access_level(auth_user, models.AccountType.Admin) stored_category = crud.get_category(db, category=category) if stored_category is not None: raise HTTPException(status_code=404, detail="category already exists") @@ -362,7 +363,9 @@ def read_categories( order: schemas.Order | None = None, name_like: str | None = None, db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): + validate_access_level(auth_user, models.AccountType.Regular) db_categories = crud.get_categories( db, skip=skip, limit=limit, order=order, name_like=name_like ) @@ -370,7 +373,8 @@ def read_categories( @app.delete("/categories/{category}") -def delete_category(category: str, db: Session = Depends(get_db)): +def delete_category(category: str, db: Session = Depends(get_db),auth_user: schemas.AuthSchema = Depends(JWTBearer())): + validate_access_level(auth_user, models.AccountType.Admin) db_category = crud.get_category(db, category=category) if db_category is None: raise HTTPException(status_code=404, detail="category not found") @@ -380,7 +384,7 @@ def delete_category(category: str, db: Session = Depends(get_db)): @app.patch("/categories/{old_category}", response_model=schemas.Category) def update_category( - old_category: str, new_category: schemas.Category, db: Session = Depends(get_db) + old_category: str, new_category: schemas.Category, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()) ): stored_category = crud.get_category(db, category=old_category) if stored_category is None: From 3e615829663e163349b18283cbea9dfb7a0170d1 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 14:37:49 +0100 Subject: [PATCH 55/95] formatted with black . --- main.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index b947c67..3c63aeb 100644 --- a/main.py +++ b/main.py @@ -348,7 +348,11 @@ def create_user( @app.post("/categories/{category}", response_model=schemas.Category) -def create_category(category: str, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer())): +def create_category( + category: str, + db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), +): validate_access_level(auth_user, models.AccountType.Admin) stored_category = crud.get_category(db, category=category) if stored_category is not None: @@ -373,7 +377,11 @@ def read_categories( @app.delete("/categories/{category}") -def delete_category(category: str, db: Session = Depends(get_db),auth_user: schemas.AuthSchema = Depends(JWTBearer())): +def delete_category( + category: str, + db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), +): validate_access_level(auth_user, models.AccountType.Admin) db_category = crud.get_category(db, category=category) if db_category is None: @@ -384,7 +392,10 @@ def delete_category(category: str, db: Session = Depends(get_db),auth_user: sche @app.patch("/categories/{old_category}", response_model=schemas.Category) def update_category( - old_category: str, new_category: schemas.Category, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()) + old_category: str, + new_category: schemas.Category, + db: Session = Depends(get_db), + auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): stored_category = crud.get_category(db, category=old_category) if stored_category is None: From b03e6f944ad911e2f2262482e4a1089f84c3805e Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 15:02:40 +0100 Subject: [PATCH 56/95] added auth for categories test --- test/test_categories.py | 62 ++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/test/test_categories.py b/test/test_categories.py index f13b5f7..fdfe2bc 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -4,43 +4,43 @@ @pytest.mark.parametrize("category_name", ["Dogs", "Cats"]) -def test_create_category(category_name): - categories = client.get("http://localhost:8000/categories").json() +def test_create_category(category_name, admin_token): + categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() category_count = len(categories) created_category = client.post( - f"http://localhost:8000/categories/{category_name}" + f"http://localhost:8000/categories/{category_name}", headers=auth_header(admin_token) ).json() assert created_category["category"] == category_name - categories = client.get("http://localhost:8000/categories").json() + categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() assert len(categories) == category_count + 1 -def test_get_categories(): - categories = client.get("http://localhost:8000/categories").json() +def test_get_categories(regular_token): + categories = client.get("http://localhost:8000/categories", headers=auth_header(regular_token)).json() assert type(categories) == list assert type(categories[0]) == str -def test_update_category(): - categories = client.get("http://localhost:8000/categories").json() +def test_update_category(admin_token): + categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() original_category = categories[0] body = {"category": "Mice"} updated_category = client.patch( - f"http://localhost:8000/categories/{original_category}", json=body + f"http://localhost:8000/categories/{original_category}", json=body, headers=auth_header(admin_token) ).json() assert updated_category["category"] == "Mice" assert ( - original_category not in client.get("http://localhost:8000/categories").json() + original_category not in client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() ) -def test_delete_category(): - categories = client.get("http://localhost:8000/categories").json() +def test_delete_category(admin_token): + categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() assert len(categories) > 0 while categories: category = categories.pop() - client.delete(f"http://localhost:8000/categories/{category}").json() - remaining_categories = client.get("http://localhost:8000/categories").json() + client.delete(f"http://localhost:8000/categories/{category}", headers=auth_header(admin_token)).json() + remaining_categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() assert len(remaining_categories) == len(categories) assert category not in remaining_categories @@ -49,10 +49,10 @@ def test_create_exercise_with_category(admin_token): client.post( "http://localhost:8000/categories/cats", headers=auth_header(admin_token) ) - categories = client.get("http://localhost:8000/categories/").json() + categories = client.get("http://localhost:8000/categories/", headers=auth_header(admin_token)).json() assert "cats" in categories assert "dogs" not in categories - exercises = client.get("http://localhost:8000/exercises").json() + exercises = client.get("http://localhost:8000/exercises", headers=auth_header(admin_token)).json() exercise_count = len(exercises) new_exercise = { "title": "Title of exercise about cats", @@ -70,19 +70,19 @@ def test_create_exercise_with_category(admin_token): continue assert created_exercise[key] == new_exercise[key] assert created_exercise["complexity"] == None - exercises = client.get("http://localhost:8000/exercises").json() + exercises = client.get("http://localhost:8000/exercises", headers=auth_header(admin_token)).json() assert len(exercises) == exercise_count + 1 - categories = client.get("http://localhost:8000/categories/").json() + categories = client.get("http://localhost:8000/categories/", headers=auth_header(admin_token)).json() assert set(categories) == {"cats", "dogs"} def test_update_exercise_with_category(admin_token): - categories = client.get("http://localhost:8000/categories").json() + categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() assert set(categories) == {"cats", "dogs"} - exercises = client.get("http://localhost:8000/exercises").json() + exercises = client.get("http://localhost:8000/exercises", headers=auth_header(admin_token)).json() exercise_id = exercises[0]["id"] original_exercise = client.get( - f"http://localhost:8000/exercises/{exercise_id}" + f"http://localhost:8000/exercises/{exercise_id}", headers=auth_header(admin_token) ).json() body = {"category": ["cats", "mice"]} client.patch( @@ -91,7 +91,7 @@ def test_update_exercise_with_category(admin_token): headers=auth_header(admin_token), ).json() updated_exercise = client.get( - f"http://localhost:8000/exercises/{exercise_id}" + f"http://localhost:8000/exercises/{exercise_id}", headers=auth_header(admin_token) ).json() for key in updated_exercise: if key == "category": @@ -101,12 +101,12 @@ def test_update_exercise_with_category(admin_token): } continue assert updated_exercise[key] == original_exercise[key] - categories = client.get("http://localhost:8000/categories").json() + categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() assert set(categories) == {"cats", "dogs", "mice"} def test_rename_category(admin_token): - categories = client.get("http://localhost:8000/categories/").json() + categories = client.get("http://localhost:8000/categories/", headers=auth_header(admin_token)).json() assert set(categories) == {"cats", "dogs", "mice"} body = {"category": "one mouse"} updated_category = client.patch( @@ -115,25 +115,25 @@ def test_rename_category(admin_token): headers=auth_header(admin_token), ).json() assert updated_category["category"] == "one mouse" - categories = client.get("http://localhost:8000/categories").json() + categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() assert set(categories) == {"cats", "dogs", "one mouse"} -def test_sort_categories(): - categories = client.get("http://localhost:8000/categories").json() +def test_sort_categories(regular_token): + categories = client.get("http://localhost:8000/categories", headers=auth_header(regular_token)).json() print(categories) assert len(categories) > 0 - assert client.get("http://localhost:8000/categories?order=asc").json() == sorted( + assert client.get("http://localhost:8000/categories?order=asc", headers=auth_header(regular_token)).json() == sorted( categories ) assert ( - client.get("http://localhost:8000/categories?order=desc").json() + client.get("http://localhost:8000/categories?order=desc", headers=auth_header(regular_token)).json() == sorted(categories)[::-1] ) -def test_search_categories(): - categories = client.get("http://localhost:8000/categories?name_like=use").json() +def test_search_categories(regular_token): + categories = client.get("http://localhost:8000/categories?name_like=use", headers=auth_header(regular_token)).json() assert len(categories) > 0 print(categories) assert categories[0] == "one mouse" From 56a020b5865c3823eb31671a934c688417bb26ad Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 15:12:18 +0100 Subject: [PATCH 57/95] Bugfix Users Auths --- main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 3c63aeb..8d6ae70 100644 --- a/main.py +++ b/main.py @@ -54,7 +54,7 @@ def validate_user_belongs_to_account( auth_user: schemas.AuthSchema = Depends(JWTBearer()), db: Session = Depends(get_db), ): - db_user = crud.get_user(db, user_id) + db_user = crud.get_user(db, user_id, auth_user.account_id) if ( db_user is None or schemas.UserSchema.model_validate(db_user).id_account != auth_user.account_id @@ -188,7 +188,7 @@ def track_exercise_completion( ): validate_access_level(auth_user, models.AccountType.Regular) validate_user_belongs_to_account(completion.user_id, auth_user, db) - db_user = crud.get_user(db, completion.user_id) + db_user = crud.get_user(db, completion.user_id,auth_user.account_id) db_exercise = crud.get_exercise(db, exercise_id=exercise_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") From bd4ec1ae64a044d0f30cd410fba2642304fdf4b3 Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 15:12:47 +0100 Subject: [PATCH 58/95] formatted with black . --- main.py | 2 +- test/test_categories.py | 103 +++++++++++++++++++++++++++++----------- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/main.py b/main.py index 8d6ae70..ab32a47 100644 --- a/main.py +++ b/main.py @@ -188,7 +188,7 @@ def track_exercise_completion( ): validate_access_level(auth_user, models.AccountType.Regular) validate_user_belongs_to_account(completion.user_id, auth_user, db) - db_user = crud.get_user(db, completion.user_id,auth_user.account_id) + db_user = crud.get_user(db, completion.user_id, auth_user.account_id) db_exercise = crud.get_exercise(db, exercise_id=exercise_id) if db_exercise is None: raise HTTPException(status_code=404, detail="exercise not found") diff --git a/test/test_categories.py b/test/test_categories.py index fdfe2bc..7b76182 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -5,42 +5,63 @@ @pytest.mark.parametrize("category_name", ["Dogs", "Cats"]) def test_create_category(category_name, admin_token): - categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() category_count = len(categories) created_category = client.post( - f"http://localhost:8000/categories/{category_name}", headers=auth_header(admin_token) + f"http://localhost:8000/categories/{category_name}", + headers=auth_header(admin_token), ).json() assert created_category["category"] == category_name - categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() assert len(categories) == category_count + 1 def test_get_categories(regular_token): - categories = client.get("http://localhost:8000/categories", headers=auth_header(regular_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(regular_token) + ).json() assert type(categories) == list assert type(categories[0]) == str def test_update_category(admin_token): - categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() original_category = categories[0] body = {"category": "Mice"} updated_category = client.patch( - f"http://localhost:8000/categories/{original_category}", json=body, headers=auth_header(admin_token) + f"http://localhost:8000/categories/{original_category}", + json=body, + headers=auth_header(admin_token), ).json() assert updated_category["category"] == "Mice" assert ( - original_category not in client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + original_category + not in client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() ) def test_delete_category(admin_token): - categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() assert len(categories) > 0 while categories: category = categories.pop() - client.delete(f"http://localhost:8000/categories/{category}", headers=auth_header(admin_token)).json() - remaining_categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + client.delete( + f"http://localhost:8000/categories/{category}", + headers=auth_header(admin_token), + ).json() + remaining_categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() assert len(remaining_categories) == len(categories) assert category not in remaining_categories @@ -49,10 +70,14 @@ def test_create_exercise_with_category(admin_token): client.post( "http://localhost:8000/categories/cats", headers=auth_header(admin_token) ) - categories = client.get("http://localhost:8000/categories/", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories/", headers=auth_header(admin_token) + ).json() assert "cats" in categories assert "dogs" not in categories - exercises = client.get("http://localhost:8000/exercises", headers=auth_header(admin_token)).json() + exercises = client.get( + "http://localhost:8000/exercises", headers=auth_header(admin_token) + ).json() exercise_count = len(exercises) new_exercise = { "title": "Title of exercise about cats", @@ -70,19 +95,28 @@ def test_create_exercise_with_category(admin_token): continue assert created_exercise[key] == new_exercise[key] assert created_exercise["complexity"] == None - exercises = client.get("http://localhost:8000/exercises", headers=auth_header(admin_token)).json() + exercises = client.get( + "http://localhost:8000/exercises", headers=auth_header(admin_token) + ).json() assert len(exercises) == exercise_count + 1 - categories = client.get("http://localhost:8000/categories/", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories/", headers=auth_header(admin_token) + ).json() assert set(categories) == {"cats", "dogs"} def test_update_exercise_with_category(admin_token): - categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() assert set(categories) == {"cats", "dogs"} - exercises = client.get("http://localhost:8000/exercises", headers=auth_header(admin_token)).json() + exercises = client.get( + "http://localhost:8000/exercises", headers=auth_header(admin_token) + ).json() exercise_id = exercises[0]["id"] original_exercise = client.get( - f"http://localhost:8000/exercises/{exercise_id}", headers=auth_header(admin_token) + f"http://localhost:8000/exercises/{exercise_id}", + headers=auth_header(admin_token), ).json() body = {"category": ["cats", "mice"]} client.patch( @@ -91,7 +125,8 @@ def test_update_exercise_with_category(admin_token): headers=auth_header(admin_token), ).json() updated_exercise = client.get( - f"http://localhost:8000/exercises/{exercise_id}", headers=auth_header(admin_token) + f"http://localhost:8000/exercises/{exercise_id}", + headers=auth_header(admin_token), ).json() for key in updated_exercise: if key == "category": @@ -101,12 +136,16 @@ def test_update_exercise_with_category(admin_token): } continue assert updated_exercise[key] == original_exercise[key] - categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() assert set(categories) == {"cats", "dogs", "mice"} def test_rename_category(admin_token): - categories = client.get("http://localhost:8000/categories/", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories/", headers=auth_header(admin_token) + ).json() assert set(categories) == {"cats", "dogs", "mice"} body = {"category": "one mouse"} updated_category = client.patch( @@ -115,25 +154,35 @@ def test_rename_category(admin_token): headers=auth_header(admin_token), ).json() assert updated_category["category"] == "one mouse" - categories = client.get("http://localhost:8000/categories", headers=auth_header(admin_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(admin_token) + ).json() assert set(categories) == {"cats", "dogs", "one mouse"} def test_sort_categories(regular_token): - categories = client.get("http://localhost:8000/categories", headers=auth_header(regular_token)).json() + categories = client.get( + "http://localhost:8000/categories", headers=auth_header(regular_token) + ).json() print(categories) assert len(categories) > 0 - assert client.get("http://localhost:8000/categories?order=asc", headers=auth_header(regular_token)).json() == sorted( - categories - ) + assert client.get( + "http://localhost:8000/categories?order=asc", headers=auth_header(regular_token) + ).json() == sorted(categories) assert ( - client.get("http://localhost:8000/categories?order=desc", headers=auth_header(regular_token)).json() + client.get( + "http://localhost:8000/categories?order=desc", + headers=auth_header(regular_token), + ).json() == sorted(categories)[::-1] ) def test_search_categories(regular_token): - categories = client.get("http://localhost:8000/categories?name_like=use", headers=auth_header(regular_token)).json() + categories = client.get( + "http://localhost:8000/categories?name_like=use", + headers=auth_header(regular_token), + ).json() assert len(categories) > 0 print(categories) assert categories[0] == "one mouse" From d6d76ad06c21068de2678dd246f3cbd2db39ec7a Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 22 Nov 2023 15:24:33 +0100 Subject: [PATCH 59/95] added validate_access_level for patch categories --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index ab32a47..30c96e6 100644 --- a/main.py +++ b/main.py @@ -397,6 +397,7 @@ def update_category( db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): + validate_access_level(auth_user, models.AccountType.Admin) stored_category = crud.get_category(db, category=old_category) if stored_category is None: raise HTTPException(status_code=404, detail="category not found") From 81f7c96d022f83312b51dbd06b595d5c6dad8ffa Mon Sep 17 00:00:00 2001 From: Ambre16 <64778044+Ambre16@users.noreply.github.com> Date: Thu, 23 Nov 2023 10:24:33 +0100 Subject: [PATCH 60/95] Update README.md Coverage report --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index e71f66a..eb16020 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,12 @@ To experiment with the ORM, run: ipython3 -i test/sqlalchemy_console.py ``` +To see the coverage report: +``` +coverage run -m pytest +coverage report +``` + # Sources From c7f1975b5175b21edb94c665ce9a8dfcd0333b4f Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Thu, 23 Nov 2023 10:33:22 +0100 Subject: [PATCH 61/95] Coverage test Omit directory test --- .coveragerc | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..5e50b42 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit=test/* \ No newline at end of file From 18a8ab842d18cc093c2c1d9db2ea39daa28aa1cf Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Thu, 23 Nov 2023 10:38:07 +0100 Subject: [PATCH 62/95] ignore .coverage --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 17d4ace..dac5a21 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ venv db.sqlite .env +.coverage From 96aff7f622c438beea295d3b5fbdfe16b34f8518 Mon Sep 17 00:00:00 2001 From: Ambre16 <64778044+Ambre16@users.noreply.github.com> Date: Thu, 23 Nov 2023 11:13:38 +0100 Subject: [PATCH 63/95] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index eb16020..a8222b0 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ To see the coverage report: ``` coverage run -m pytest coverage report +coverage html ``` From 809785636210bd53b31a60289681c44b93dd8081 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Thu, 23 Nov 2023 16:30:46 +0100 Subject: [PATCH 64/95] remove /create_superadmin endpoint --- main.py | 11 ----------- test/conftest.py | 20 ++++++++++++-------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index 3ff9833..086de54 100644 --- a/main.py +++ b/main.py @@ -476,14 +476,3 @@ def account_reset_password_result( raise HTTPException(status_code=404, detail="Email not found") else: raise HTTPException(status_code=500, detail="An unexpected error occurred") - - -# CreateSuperadmin just for Debugging -@app.post("/createsuperadmin", response_model=schemas.AccountOut) -def createsuperadmin_only_for_debugging( - account_in: schemas.AccountIn, db: Session = Depends(get_db) -): - if crud.email_is_registered(db, account_in.email): - raise HTTPException(status_code=409, detail="Email already registered") - account_saved = crud.create_account(db, account_in, models.AccountType.Superadmin) - return account_saved diff --git a/test/conftest.py b/test/conftest.py index d75a4fd..adcc9d1 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,7 +1,10 @@ import pytest from fastapi.testclient import TestClient +from crud import password_hasher +from database import SessionLocal from main import app +import models client = TestClient(app) @@ -40,14 +43,15 @@ def superadmin_token(): account_details = {"email": "superadmin@gmail.com", "password": "superadmin"} resp = client.post("http://localhost:8000/login", json=account_details).json() if "detail" in resp and resp["detail"] == "Username/Password wrong": - account_details = { - "email": "superadmin@gmail.com", - "password": "superadmin", - "is_superadmin": True, - } - resp = client.post( - "http://localhost:8000/createsuperadmin", json=account_details - ).json() + db = SessionLocal() + account_db = models.Account( + email="superadmin@gmail.com", + account_category=models.AccountType.Superadmin, + password=password_hasher("superadmin"), + ) + db.add(account_db) + db.commit() + db.close() resp = client.post("http://localhost:8000/login", json=account_details).json() access_token = resp["access_token"] yield access_token From 3a4f69d8325606d501e39c7725f175d7fd9fe72b Mon Sep 17 00:00:00 2001 From: vvihorev Date: Fri, 24 Nov 2023 10:13:58 +0100 Subject: [PATCH 65/95] add pagination to endpoints --- crud.py | 43 ++++++++------------- main.py | 38 +++++++------------ requirements.txt | 3 +- test/test_accounts.py | 25 ++++++------ test/test_categories.py | 82 +++++++++++++++++++++------------------- test/test_do_exercise.py | 4 +- test/test_exercises.py | 36 +++++++++--------- 7 files changed, 109 insertions(+), 122 deletions(-) diff --git a/crud.py b/crud.py index 26acf20..b5bd1be 100644 --- a/crud.py +++ b/crud.py @@ -1,5 +1,6 @@ -from sqlalchemy import or_ +from sqlalchemy import or_, select from sqlalchemy.orm import Session +from fastapi_pagination.ext.sqlalchemy import paginate import models, schemas import bcrypt @@ -42,8 +43,6 @@ def get_exercise(db: Session, exercise_id: int, user_id: int | None = None): def get_exercises( db: Session, - skip: int = 0, - limit: int = 100, order_by: schemas.ExerciseOrderBy | None = None, order: schemas.Order | None = None, complexity: models.Complexity | None = None, @@ -51,7 +50,7 @@ def get_exercises( title_like: str | None = None, user_id: int | None = None, ): - exercises = db.query(models.Exercise) + exercises = select(models.Exercise) if complexity: exercises = exercises.filter(models.Exercise.complexity == complexity) if category: @@ -61,7 +60,7 @@ def get_exercises( if user_id: exercises = ( exercises.join(models.DoExercise, isouter=True) - .add_entity(models.DoExercise) + .add_columns(models.DoExercise) .filter(or_(models.DoExercise.user_id == 1, models.Exercise.users == None)) ) if order_by: @@ -70,17 +69,16 @@ def get_exercises( if order == schemas.Order.desc else exercise_order_by_column[order_by] ) - paginated_exercises = exercises.offset(skip).limit(limit) if user_id: - exercises = [] - for ex, do_ex in paginated_exercises: + ex_with_completion = [] + for ex, do_ex in db.execute(exercises): if do_ex: ex_completion = schemas.ExerciseCompletion.model_validate(do_ex) ex = schemas.FullExerciseResponse.model_validate(ex) ex.completion = ex_completion - exercises.append(ex) - return exercises - return paginated_exercises.all() + ex_with_completion.append(ex) + return ex_with_completion + return paginate(db, exercises) def create_exercise(db: Session, exercise: schemas.ExerciseCreate): @@ -199,13 +197,11 @@ def update_account(db: Session, account_id: int, account: schemas.AccountPatch): def get_accounts( db: Session, - skip: int = 0, - limit: int = 100, order_by: schemas.AccountOrderBy | None = None, order: schemas.Order | None = None, email_like: str | None = None, ): - accounts = db.query(models.Account).filter( + accounts = select(models.Account).filter( or_( models.Account.account_category == models.AccountType.Admin, models.Account.account_category == models.AccountType.Superadmin, @@ -219,7 +215,7 @@ def get_accounts( if order == schemas.Order.desc else account_order_by_column[order_by] ) - return accounts.offset(skip).limit(limit).all() + return paginate(db, accounts) def create_account( @@ -243,13 +239,9 @@ def email_is_registered(db: Session, email: str): # Users -def get_users(db: Session, account_id: int, skip: int = 0, limit: int = 100): - return ( - db.query(models.User) - .filter(models.User.id_account == account_id) - .offset(skip) - .limit(limit) - .all() +def get_users(db: Session, account_id: int): + return paginate( + db, select(models.User).filter(models.User.id_account == account_id) ) @@ -304,12 +296,10 @@ def _update_exercise_categories( def get_categories( db: Session, - skip: int, - limit: int, order: schemas.Order | None, name_like: str | None, ): - categories = db.query(models.Category) + categories = select(models.Category) if name_like: categories = categories.filter(models.Category.category.like(f"%{name_like}%")) if order: @@ -318,8 +308,7 @@ def get_categories( if order == schemas.Order.desc else models.Category.category ) - categories = categories.offset(skip).limit(limit).all() - return [cat.category for cat in categories] + return paginate(db, categories) def get_category(db: Session, category: str): diff --git a/main.py b/main.py index 086de54..a593206 100644 --- a/main.py +++ b/main.py @@ -2,9 +2,9 @@ from sqlalchemy.orm import Session from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse -from fastapi import Request, Form -from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from fastapi_pagination import Page, add_pagination +import fastapi_pagination import crud import models @@ -87,10 +87,8 @@ def create_exercise( return crud.create_exercise(db=db, exercise=exercise) -@app.get("/exercises", response_model=list[schemas.ExerciseResponse]) +@app.get("/exercises", response_model=Page[schemas.ExerciseResponse]) def read_exercises( - skip: int = 0, - limit: int = 100, order_by: schemas.ExerciseOrderBy | None = None, order: schemas.Order | None = None, complexity: models.Complexity | None = None, @@ -100,7 +98,6 @@ def read_exercises( db: Session = Depends(get_db), auth_user: schemas.AuthSchema | None = Depends(optional_auth), ): - print(auth_user) if user_id: if not auth_user: raise HTTPException( @@ -111,13 +108,11 @@ def read_exercises( if category: db_category = crud.get_category(db, category) if db_category is None: - return [] + db_category = models.Category(category="NULL") else: db_category = None exercises = crud.get_exercises( db, - skip=skip, - limit=limit, order_by=order_by, order=order, complexity=complexity, @@ -125,6 +120,8 @@ def read_exercises( title_like=title_like, user_id=user_id, ) + if user_id: + return fastapi_pagination.paginate(exercises) return exercises @@ -224,10 +221,8 @@ def register_account(account_in: schemas.AccountIn, db: Session = Depends(get_db return account_saved -@app.get("/accounts", response_model=list[schemas.AccountOut]) +@app.get("/accounts", response_model=Page[schemas.AccountOut]) def get_all_accounts( - skip: int = 0, - limit: int = 100, order_by: schemas.AccountOrderBy | None = None, order: schemas.Order | None = None, email_like: str | None = None, @@ -237,8 +232,6 @@ def get_all_accounts( validate_access_level(auth_user, models.AccountType.Superadmin) accounts = crud.get_accounts( db, - skip=skip, - limit=limit, order_by=order_by, order=order, email_like=email_like, @@ -288,14 +281,12 @@ def update_account( # User -@app.get("/users", response_model=list[schemas.UserSchema]) +@app.get("/users", response_model=Page[schemas.UserSchema]) def read_all_users( - skip: int = 0, - limit: int = 100, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): - users = crud.get_users(db, account_id=auth_user.account_id, skip=skip, limit=limit) + users = crud.get_users(db, account_id=auth_user.account_id) return users @@ -360,19 +351,15 @@ def create_category( return crud.create_category(db=db, category=category) -@app.get("/categories", response_model=list[str]) +@app.get("/categories", response_model=Page[schemas.Category]) def read_categories( - skip: int = 0, - limit: int = 100, order: schemas.Order | None = None, name_like: str | None = None, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): validate_access_level(auth_user, models.AccountType.Regular) - db_categories = crud.get_categories( - db, skip=skip, limit=limit, order=order, name_like=name_like - ) + db_categories = crud.get_categories(db, order=order, name_like=name_like) return db_categories @@ -476,3 +463,6 @@ def account_reset_password_result( raise HTTPException(status_code=404, detail="Email not found") else: raise HTTPException(status_code=500, detail="An unexpected error occurred") + + +add_pagination(app) diff --git a/requirements.txt b/requirements.txt index b2c3573..4fa5afb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ click==8.1.7 exceptiongroup==1.1.3 email_validator==2.1.0 fastapi==0.104.0 +fastapi-pagination==0.12.12 greenlet==3.0.0 h11==0.14.0 idna==3.4 @@ -33,4 +34,4 @@ uuid==1.30 httpx==0.25.1 PyJWT==2.6.0 fastapi-mail==1.4.1 -python-multipart==0.0.6 \ No newline at end of file +python-multipart==0.0.6 diff --git a/test/test_accounts.py b/test/test_accounts.py index 38682e0..18e4139 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -5,7 +5,7 @@ def test_create_account(superadmin_token): accounts = client.get( "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() - account_count = len(accounts) + account_count = len(accounts["items"]) new_account = {"email": "email@gmail.com", "password": "secret"} resp = client.post( "http://localhost:8000/accounts", @@ -16,7 +16,7 @@ def test_create_account(superadmin_token): accounts = client.get( "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() - assert len(accounts) == account_count + 1 + assert len(accounts["items"]) == account_count + 1 def test_update_account(superadmin_token): @@ -24,7 +24,7 @@ def test_update_account(superadmin_token): accounts = client.get( "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() - account_id = accounts[0]["id_account"] + account_id = accounts["items"][0]["id_account"] # Get the superadmin's account original_account = client.get( f"http://localhost:8000/accounts/{account_id}", @@ -65,8 +65,8 @@ def test_search_account(superadmin_token): "http://localhost:8000/accounts?email_like=email@", headers=auth_header(superadmin_token), ).json() - assert len(accounts) == 1 - assert accounts[0]["email"] == "email@gmail.com" + assert len(accounts["items"]) == 1 + assert accounts["items"][0]["email"] == "email@gmail.com" def test_sort_account(superadmin_token): @@ -74,20 +74,20 @@ def test_sort_account(superadmin_token): "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() assert len(accounts) >= 2 - sorted_emails = sorted([acc["email"] for acc in accounts]) + sorted_emails = sorted([acc["email"] for acc in accounts["items"]]) assert sorted_emails == [ acc["email"] for acc in client.get( "http://localhost:8000/accounts?order_by=email", headers=auth_header(superadmin_token), - ).json() + ).json()["items"] ] assert sorted_emails[::-1] == [ acc["email"] for acc in client.get( "http://localhost:8000/accounts?order_by=email&order=desc", headers=auth_header(superadmin_token), - ).json() + ).json()["items"] ] @@ -95,9 +95,11 @@ def test_delete_account(superadmin_token): accounts = client.get( "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() - assert len(accounts) > 0 + assert len(accounts["items"]) > 0 account_ids = { - ex["id_account"] for ex in accounts if ex["account_category"] == "admin" + ex["id_account"] + for ex in accounts["items"] + if ex["account_category"] == "admin" } while account_ids: account_id = account_ids.pop() @@ -109,7 +111,7 @@ def test_delete_account(superadmin_token): ex["id_account"] for ex in client.get( "http://localhost:8000/accounts", headers=auth_header(superadmin_token) - ).json() + ).json()["items"] if ex["account_category"] == "admin" } assert len(remaining_account_ids) == len(account_ids) @@ -120,7 +122,6 @@ def test_me_endpoint(regular_token): resp = client.get( "http://localhost:8000/me", headers=auth_header(regular_token) ).json() - print(resp) assert resp["account_category"] == "regular" diff --git a/test/test_categories.py b/test/test_categories.py index 7b76182..5529bb6 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -7,7 +7,7 @@ def test_create_category(category_name, admin_token): categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() + ).json()["items"] category_count = len(categories) created_category = client.post( f"http://localhost:8000/categories/{category_name}", @@ -16,52 +16,54 @@ def test_create_category(category_name, admin_token): assert created_category["category"] == category_name categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert len(categories) == category_count + 1 def test_get_categories(regular_token): categories = client.get( "http://localhost:8000/categories", headers=auth_header(regular_token) - ).json() + ).json()["items"] assert type(categories) == list - assert type(categories[0]) == str + assert type(categories[0]) == dict + assert "category" in categories[0] def test_update_category(admin_token): categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() - original_category = categories[0] + ).json()["items"] + original_category = categories[0]["category"] body = {"category": "Mice"} updated_category = client.patch( f"http://localhost:8000/categories/{original_category}", json=body, headers=auth_header(admin_token), ).json() + print(updated_category) assert updated_category["category"] == "Mice" assert ( original_category not in client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() + ).json()["items"] ) def test_delete_category(admin_token): categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert len(categories) > 0 while categories: category = categories.pop() client.delete( - f"http://localhost:8000/categories/{category}", + f"http://localhost:8000/categories/{category['category']}", headers=auth_header(admin_token), ).json() remaining_categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert len(remaining_categories) == len(categories) assert category not in remaining_categories @@ -72,12 +74,12 @@ def test_create_exercise_with_category(admin_token): ) categories = client.get( "http://localhost:8000/categories/", headers=auth_header(admin_token) - ).json() - assert "cats" in categories - assert "dogs" not in categories + ).json()["items"] + assert {"category": "cats"} in categories + assert {"category": "dogs"} not in categories exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] exercise_count = len(exercises) new_exercise = { "title": "Title of exercise about cats", @@ -97,22 +99,22 @@ def test_create_exercise_with_category(admin_token): assert created_exercise["complexity"] == None exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert len(exercises) == exercise_count + 1 categories = client.get( "http://localhost:8000/categories/", headers=auth_header(admin_token) - ).json() - assert set(categories) == {"cats", "dogs"} + ).json()["items"] + assert set(c["category"] for c in categories) == {"cats", "dogs"} def test_update_exercise_with_category(admin_token): categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() - assert set(categories) == {"cats", "dogs"} + ).json()["items"] + assert set(c["category"] for c in categories) == {"cats", "dogs"} exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] exercise_id = exercises[0]["id"] original_exercise = client.get( f"http://localhost:8000/exercises/{exercise_id}", @@ -138,15 +140,15 @@ def test_update_exercise_with_category(admin_token): assert updated_exercise[key] == original_exercise[key] categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() - assert set(categories) == {"cats", "dogs", "mice"} + ).json()["items"] + assert set(c["category"] for c in categories) == {"cats", "dogs", "mice"} def test_rename_category(admin_token): categories = client.get( "http://localhost:8000/categories/", headers=auth_header(admin_token) - ).json() - assert set(categories) == {"cats", "dogs", "mice"} + ).json()["items"] + assert set(c["category"] for c in categories) == {"cats", "dogs", "mice"} body = {"category": "one mouse"} updated_category = client.patch( "http://localhost:8000/categories/mice?", @@ -156,33 +158,35 @@ def test_rename_category(admin_token): assert updated_category["category"] == "one mouse" categories = client.get( "http://localhost:8000/categories", headers=auth_header(admin_token) - ).json() - assert set(categories) == {"cats", "dogs", "one mouse"} + ).json()["items"] + assert set(c["category"] for c in categories) == {"cats", "dogs", "one mouse"} def test_sort_categories(regular_token): categories = client.get( "http://localhost:8000/categories", headers=auth_header(regular_token) - ).json() - print(categories) + ).json()["items"] assert len(categories) > 0 - assert client.get( - "http://localhost:8000/categories?order=asc", headers=auth_header(regular_token) - ).json() == sorted(categories) - assert ( - client.get( + assert [ + c["category"] + for c in client.get( + "http://localhost:8000/categories?order=asc", + headers=auth_header(regular_token), + ).json()["items"] + ] == sorted([c["category"] for c in categories]) + assert [ + c["category"] + for c in client.get( "http://localhost:8000/categories?order=desc", headers=auth_header(regular_token), - ).json() - == sorted(categories)[::-1] - ) + ).json()["items"] + ] == sorted([c["category"] for c in categories])[::-1] def test_search_categories(regular_token): categories = client.get( "http://localhost:8000/categories?name_like=use", headers=auth_header(regular_token), - ).json() + ).json()["items"] assert len(categories) > 0 - print(categories) - assert categories[0] == "one mouse" + assert categories[0] == {"category": "one mouse"} diff --git a/test/test_do_exercise.py b/test/test_do_exercise.py index 3094468..6f1739a 100644 --- a/test/test_do_exercise.py +++ b/test/test_do_exercise.py @@ -114,7 +114,7 @@ def test_read_all_exercises_with_completion( created_exercises[create_exercise()["id"]] = None exercises = [ ex - for ex in good_request(client.get, "http://localhost:8000/exercises") + for ex in good_request(client.get, "http://localhost:8000/exercises")["items"] if ex["id"] in created_exercises.keys() ] for ex in exercises: @@ -125,7 +125,7 @@ def test_read_all_exercises_with_completion( client.get, f"http://localhost:8000/exercises?user_id={id_alice}", headers=auth_header(regular_token), - ) + )["items"] if ex["id"] in created_exercises.keys() ] for ex in exercises: diff --git a/test/test_exercises.py b/test/test_exercises.py index 1054f43..43c7234 100644 --- a/test/test_exercises.py +++ b/test/test_exercises.py @@ -1,12 +1,14 @@ from datetime import datetime import time + +from fastapi_pagination import add_pagination from conftest import client, auth_header def test_create_exercise(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] exercise_count = len(exercises) new_exercise = { "title": "Title of the exercise", @@ -22,14 +24,14 @@ def test_create_exercise(admin_token): assert created_exercise[key] == new_exercise[key] exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert len(exercises) == exercise_count + 1 def test_create_exercise_without_complexity(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] exercise_count = len(exercises) new_exercise = { "title": "Title of another exercise", @@ -45,14 +47,14 @@ def test_create_exercise_without_complexity(admin_token): assert created_exercise["complexity"] == None exercises = client.get( "http://localhost:8000/exercises/", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert len(exercises) == exercise_count + 1 def test_get_exercises(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert set(exercises[0].keys()) == { "id", "title", @@ -66,7 +68,7 @@ def test_get_exercises(admin_token): def test_get_exercise(admin_token): exercises = client.get( "http://localhost:8000/exercises/", headers=auth_header(admin_token) - ).json() + ).json()["items"] exercise_id = exercises[0]["id"] exercise = client.get( f"http://localhost:8000/exercises/{exercise_id}", @@ -86,7 +88,7 @@ def test_get_exercise(admin_token): def test_update_exercise(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] exercise_id = exercises[0]["id"] original_exercise = client.get( f"http://localhost:8000/exercises/{exercise_id}", @@ -122,13 +124,13 @@ def test_sort_exercises(admin_token): exercises = client.get( "http://localhost:8000/exercises?order_by=title", headers=auth_header(admin_token), - ).json() + ).json()["items"] titles = [exercise["title"] for exercise in exercises] assert titles == sorted(titles) exercises = client.get( "http://localhost:8000/exercises?order_by=title&order=desc", headers=auth_header(admin_token), - ).json() + ).json()["items"] titles = [exercise["title"] for exercise in exercises] assert titles == sorted(titles)[::-1] @@ -137,7 +139,7 @@ def test_search_exercises(admin_token): exercises = client.get( "http://localhost:8000/exercises?title_like=another", headers=auth_header(admin_token), - ).json() + ).json()["items"] for exercise in exercises: assert exercise["title"] == "Title of another exercise" @@ -145,7 +147,7 @@ def test_search_exercises(admin_token): def test_delete_exercise(admin_token): exercises = client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] assert len(exercises) > 0 exercise_ids = {ex["id"] for ex in exercises} while exercise_ids: @@ -158,7 +160,7 @@ def test_delete_exercise(admin_token): ex["id"] for ex in client.get( "http://localhost:8000/exercises", headers=auth_header(admin_token) - ).json() + ).json()["items"] } assert len(remaining_exercise_ids) == len(exercise_ids) assert exercise_id not in remaining_exercise_ids @@ -180,7 +182,7 @@ def test_sort_complexity(admin_token): exercises = client.get( "http://localhost:8000/exercises?order_by=complexity&order=asc", headers=auth_header(admin_token), - ).json() + ).json()["items"] assert [ex["complexity"] for ex in exercises] == [ "easy", "easy", @@ -192,7 +194,7 @@ def test_sort_complexity(admin_token): exercises = client.get( "http://localhost:8000/exercises?order_by=complexity&order=desc", headers=auth_header(admin_token), - ).json() + ).json()["items"] assert [ex["complexity"] for ex in exercises] == [ "hard", "hard", @@ -219,7 +221,7 @@ def test_filter_complexity(admin_token): exercises = client.get( "http://localhost:8000/exercises?complexity=hard", headers=auth_header(admin_token), - ).json() + ).json()["items"] assert {ex["complexity"] for ex in exercises} == {"hard"} test_delete_exercise(admin_token) @@ -240,12 +242,12 @@ def test_filter_category(admin_token): exercises = client.get( "http://localhost:8000/exercises?category=cats", headers=auth_header(admin_token), - ).json() + ).json()["items"] assert {ex["id"] for ex in exercises} == {1, 3} exercises = client.get( "http://localhost:8000/exercises?category=something", headers=auth_header(admin_token), - ).json() + ).json()["items"] assert len(exercises) == 0 test_delete_exercise(admin_token) From 3662ab98379440880f3a2cb4a37dddd0dcd19901 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 15:37:02 +0100 Subject: [PATCH 66/95] test error 422, create account --- test/test_accounts.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/test_accounts.py b/test/test_accounts.py index 38682e0..7ca0027 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -141,3 +141,28 @@ def test_me_for_deleted_account(): headers=auth_header, ) bad_request(client.get, 404, "http://localhost:8000/me", headers=auth_header) + + + +def test_create_error_account(superadmin_token): + # Get the initial count of accounts + accounts_before = client.get( + "http://localhost:8000/accounts", headers=auth_header(superadmin_token) + ).json() + account_count_before = len(accounts_before) + + # Try creating an account with invalid data + invalid_account = {"email": "invalid_email", "password": "password1234"} + resp = client.post( + "http://localhost:8000/accounts", + json=invalid_account, + headers=auth_header(superadmin_token), + ) + assert resp.status_code == 422 # Expecting a validation error + + # Ensure that the count of accounts remains the same + accounts_after = client.get( + "http://localhost:8000/accounts", headers=auth_header(superadmin_token) + ).json() + account_count_after = len(accounts_after) + assert account_count_after == account_count_before \ No newline at end of file From 6900cf32796f76349ae246a50e1f416a6661d6c5 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 15:37:47 +0100 Subject: [PATCH 67/95] black . --- test/test_accounts.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_accounts.py b/test/test_accounts.py index 7ca0027..bc4f5b1 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -143,7 +143,6 @@ def test_me_for_deleted_account(): bad_request(client.get, 404, "http://localhost:8000/me", headers=auth_header) - def test_create_error_account(superadmin_token): # Get the initial count of accounts accounts_before = client.get( @@ -165,4 +164,4 @@ def test_create_error_account(superadmin_token): "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() account_count_after = len(accounts_after) - assert account_count_after == account_count_before \ No newline at end of file + assert account_count_after == account_count_before From 9bf759f5ea897715ac7296b0623f2b8309a2dbbc Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 15:50:08 +0100 Subject: [PATCH 68/95] test redirection endpoint ending / --- test/test_redirection.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 test/test_redirection.py diff --git a/test/test_redirection.py b/test/test_redirection.py new file mode 100644 index 0000000..9a10d15 --- /dev/null +++ b/test/test_redirection.py @@ -0,0 +1,9 @@ +from datetime import datetime +import time +from conftest import client, auth_header + +def test_ending_slash(regular_token): + resp = client.get( + "http://localhost:8000/me/", headers=auth_header(regular_token) + ) + assert resp.status_code == 200 From a6063e8aaf1f706cddc9b88ed60077bac18ea81e Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 15:50:47 +0100 Subject: [PATCH 69/95] test : update account invalid email --- test/test_accounts.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/test_accounts.py b/test/test_accounts.py index bc4f5b1..6c31fe1 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -165,3 +165,23 @@ def test_create_error_account(superadmin_token): ).json() account_count_after = len(accounts_after) assert account_count_after == account_count_before + +def test_update_account_invalid_email(superadmin_token): + # Try updating an account with invalid data + invalid_data = {"email": "invalid_email"} + resp = client.patch( + "http://localhost:8000/accounts/1", # superadmin is ID 1 + json=invalid_data, + headers=auth_header(superadmin_token), + ) + assert resp.status_code == 422 # Expecting a validation error + +def test_update_account_invalid_email(superadmin_token): + # Try updating a non-existent account + non_existent_account_id = 999999 # Assuming this ID doesn't exist + resp = client.patch( + f"http://localhost:8000/accounts/{non_existent_account_id}", + json={"email": "new_email@gmail.com"}, + headers=auth_header(superadmin_token), + ) + assert resp.status_code == 404 # Expecting a not found error \ No newline at end of file From 29938339bc3b8dc17aee9c382cdb44214ac62b36 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 15:57:49 +0100 Subject: [PATCH 70/95] test : get nonexistant category --- test/test_categories.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/test_categories.py b/test/test_categories.py index 7b76182..849dfa8 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -186,3 +186,13 @@ def test_search_categories(regular_token): assert len(categories) > 0 print(categories) assert categories[0] == "one mouse" + +def test_get_nonexistent_category(admin_token): + # Assume nonexistent_category doesn't exist + non_existent_category = "nonexistent_category" + resp = client.get( + f"http://localhost:8000/categories/{non_existent_category}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + assert resp.json()["detail"] == "category not found" \ No newline at end of file From af9dc7d3f712550a36e9597fc6cf204bfd20d9e8 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 16:07:20 +0100 Subject: [PATCH 71/95] test : patch and delete categories --- test/test_categories.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/test_categories.py b/test/test_categories.py index 849dfa8..ee3fc47 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -187,10 +187,20 @@ def test_search_categories(regular_token): print(categories) assert categories[0] == "one mouse" -def test_get_nonexistent_category(admin_token): +def test_delete_nonexistent_category(admin_token): # Assume nonexistent_category doesn't exist non_existent_category = "nonexistent_category" - resp = client.get( + resp = client.delete( + f"http://localhost:8000/categories/{non_existent_category}", + headers=auth_header(admin_token), + ) + assert resp.status_code == 404 + assert resp.json()["detail"] == "category not found" + +def test_patch_nonexistent_category(admin_token): + # Assume nonexistent_category doesn't exist + non_existent_category = "nonexistent_category" + resp = client.patch( f"http://localhost:8000/categories/{non_existent_category}", headers=auth_header(admin_token), ) From 00b8b0ed4b3e2b865f236492f911876af31597a8 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 16:13:17 +0100 Subject: [PATCH 72/95] add body to patch --- test/test_categories.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_categories.py b/test/test_categories.py index ee3fc47..7e1de4b 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -200,8 +200,10 @@ def test_delete_nonexistent_category(admin_token): def test_patch_nonexistent_category(admin_token): # Assume nonexistent_category doesn't exist non_existent_category = "nonexistent_category" + body = {"category": ["cats", "mice"]} resp = client.patch( f"http://localhost:8000/categories/{non_existent_category}", + json=body, headers=auth_header(admin_token), ) assert resp.status_code == 404 From a26a39dfc8a047abb3f3ee85957c48e9f916458c Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 16:45:34 +0100 Subject: [PATCH 73/95] test : refresh --- test/test_refresh.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 test/test_refresh.py diff --git a/test/test_refresh.py b/test/test_refresh.py new file mode 100644 index 0000000..79e8d2f --- /dev/null +++ b/test/test_refresh.py @@ -0,0 +1,18 @@ +import pytest + +from conftest import client, auth_header + + +def test_refresh(superadmin_token): + # Perform a login to obtain a refresh token + account_details = {"email": "superadmin@gmail.com", "password": "superadmin"} + login_resp = client.post("http://localhost:8000/login", json=account_details) + assert login_resp.status_code == 200 + refresh_token = login_resp.json().get("refresh_token") + # Use the obtained refresh token to refresh the access token + resp = client.post( + "http://localhost:8000/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200 + assert "access_token" in resp.json() # Ensure a new access token is provided From d01eec3f2eff91e413c50f6e99a2b237374dbf0f Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 16:45:56 +0100 Subject: [PATCH 74/95] black . --- test/test_accounts.py | 4 +++- test/test_categories.py | 8 +++++--- test/test_redirection.py | 5 ++--- test/test_refresh.py | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/test/test_accounts.py b/test/test_accounts.py index 6c31fe1..7378162 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -166,6 +166,7 @@ def test_create_error_account(superadmin_token): account_count_after = len(accounts_after) assert account_count_after == account_count_before + def test_update_account_invalid_email(superadmin_token): # Try updating an account with invalid data invalid_data = {"email": "invalid_email"} @@ -176,6 +177,7 @@ def test_update_account_invalid_email(superadmin_token): ) assert resp.status_code == 422 # Expecting a validation error + def test_update_account_invalid_email(superadmin_token): # Try updating a non-existent account non_existent_account_id = 999999 # Assuming this ID doesn't exist @@ -184,4 +186,4 @@ def test_update_account_invalid_email(superadmin_token): json={"email": "new_email@gmail.com"}, headers=auth_header(superadmin_token), ) - assert resp.status_code == 404 # Expecting a not found error \ No newline at end of file + assert resp.status_code == 404 # Expecting a not found error diff --git a/test/test_categories.py b/test/test_categories.py index 7e1de4b..f98d71b 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -187,6 +187,7 @@ def test_search_categories(regular_token): print(categories) assert categories[0] == "one mouse" + def test_delete_nonexistent_category(admin_token): # Assume nonexistent_category doesn't exist non_existent_category = "nonexistent_category" @@ -194,9 +195,10 @@ def test_delete_nonexistent_category(admin_token): f"http://localhost:8000/categories/{non_existent_category}", headers=auth_header(admin_token), ) - assert resp.status_code == 404 + assert resp.status_code == 404 assert resp.json()["detail"] == "category not found" + def test_patch_nonexistent_category(admin_token): # Assume nonexistent_category doesn't exist non_existent_category = "nonexistent_category" @@ -206,5 +208,5 @@ def test_patch_nonexistent_category(admin_token): json=body, headers=auth_header(admin_token), ) - assert resp.status_code == 404 - assert resp.json()["detail"] == "category not found" \ No newline at end of file + assert resp.status_code == 404 + assert resp.json()["detail"] == "category not found" diff --git a/test/test_redirection.py b/test/test_redirection.py index 9a10d15..b8ad314 100644 --- a/test/test_redirection.py +++ b/test/test_redirection.py @@ -2,8 +2,7 @@ import time from conftest import client, auth_header + def test_ending_slash(regular_token): - resp = client.get( - "http://localhost:8000/me/", headers=auth_header(regular_token) - ) + resp = client.get("http://localhost:8000/me/", headers=auth_header(regular_token)) assert resp.status_code == 200 diff --git a/test/test_refresh.py b/test/test_refresh.py index 79e8d2f..e5e6e43 100644 --- a/test/test_refresh.py +++ b/test/test_refresh.py @@ -7,7 +7,7 @@ def test_refresh(superadmin_token): # Perform a login to obtain a refresh token account_details = {"email": "superadmin@gmail.com", "password": "superadmin"} login_resp = client.post("http://localhost:8000/login", json=account_details) - assert login_resp.status_code == 200 + assert login_resp.status_code == 200 refresh_token = login_resp.json().get("refresh_token") # Use the obtained refresh token to refresh the access token resp = client.post( From 32717c9217be61ecaab9becc7b4a0bd0773d8d6b Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 16:54:17 +0100 Subject: [PATCH 75/95] Fix test patch categories --- test/test_categories.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_categories.py b/test/test_categories.py index f98d71b..7264438 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -201,8 +201,8 @@ def test_delete_nonexistent_category(admin_token): def test_patch_nonexistent_category(admin_token): # Assume nonexistent_category doesn't exist - non_existent_category = "nonexistent_category" - body = {"category": ["cats", "mice"]} + non_existent_category = {"category": "non_existent_category"} + body = {"category": "cats"} resp = client.patch( f"http://localhost:8000/categories/{non_existent_category}", json=body, From 1cea8c1c247fdcd664b80d2fc864875924b0027d Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 21:01:12 +0100 Subject: [PATCH 76/95] trailing slash already handling by fastapi --- main.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/main.py b/main.py index 086de54..adbfea9 100644 --- a/main.py +++ b/main.py @@ -65,12 +65,6 @@ def validate_user_belongs_to_account( ) -async def redirect_trailing_slash(request, call_next): - if request.url.path.endswith("/"): - url_without_trailing_slash = str(request.url)[:-1] - return RedirectResponse(url=url_without_trailing_slash, status_code=301) - return await call_next(request) - @app.get("/healthz", status_code=200) def health_check(): From 1090314ef649390587340a4dd80b4f6444f696e5 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 21:28:31 +0100 Subject: [PATCH 77/95] test forget password --- test/test_accounts.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/test_accounts.py b/test/test_accounts.py index 7378162..e148eb1 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -187,3 +187,24 @@ def test_update_account_invalid_email(superadmin_token): headers=auth_header(superadmin_token), ) assert resp.status_code == 404 # Expecting a not found error + +def test_send_password_mail_superadmin(superadmin_token): + reset_mail_payload = {"email": "superadmin@gmail.com"} + resp = client.post( + "http://localhost:8000/password/forgot", + json=reset_mail_payload, + headers=auth_header(superadmin_token), + ) + assert resp.status_code == 200 + assert "An email has been sent to superadmin@gmail.com with a link for password reset." in resp.json()["result"] + +def test_send_password_mail_user(regular_token): + reset_mail_payload = {"email": "regular@gmail.com"} + resp = client.post( + "http://localhost:8000/password/forgot", + json=reset_mail_payload, + headers=auth_header(regular_token), + ) + print(resp.json()) + assert resp.status_code == 200 + assert "An email has been sent to regular@gmail.com with a link for password reset." in resp.json()["result"] \ No newline at end of file From c9faf0ccdfc9238cb4bd891416def6f92404e0b0 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 21:40:21 +0100 Subject: [PATCH 78/95] add tests --- test/test_accounts.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/test_accounts.py b/test/test_accounts.py index e148eb1..c26b412 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -130,6 +130,7 @@ def test_me_for_deleted_account(): "password": "secret", } good_request(client.post, "http://localhost:8000/register", json=new_account) + bad_request(client.post,409,"http://localhost:8000/register", json=new_account) login_resp = good_request( client.post, "http://localhost:8000/login", json=new_account ) @@ -142,6 +143,17 @@ def test_me_for_deleted_account(): ) bad_request(client.get, 404, "http://localhost:8000/me", headers=auth_header) +def test_delete_nonexistent_user(regular_token): + # Assuming user_id 99999 does not exist + user_id_to_delete = 99999 + # Try to delete a user that doesn't exist + resp = client.delete( + f"http://localhost:8000/users/{user_id_to_delete}", + headers=auth_header(regular_token), + ) + assert resp.status_code == 404 + assert resp.json()["detail"] == "User not found" + def test_create_error_account(superadmin_token): # Get the initial count of accounts From 9a817331169cd2c2b14a251eabe286b5b32f9bb4 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Fri, 24 Nov 2023 21:40:44 +0100 Subject: [PATCH 79/95] black . --- main.py | 1 - test/test_accounts.py | 15 ++++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index adbfea9..93d1853 100644 --- a/main.py +++ b/main.py @@ -65,7 +65,6 @@ def validate_user_belongs_to_account( ) - @app.get("/healthz", status_code=200) def health_check(): return {"status": "ok"} diff --git a/test/test_accounts.py b/test/test_accounts.py index c26b412..cfa16f4 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -130,7 +130,7 @@ def test_me_for_deleted_account(): "password": "secret", } good_request(client.post, "http://localhost:8000/register", json=new_account) - bad_request(client.post,409,"http://localhost:8000/register", json=new_account) + bad_request(client.post, 409, "http://localhost:8000/register", json=new_account) login_resp = good_request( client.post, "http://localhost:8000/login", json=new_account ) @@ -143,6 +143,7 @@ def test_me_for_deleted_account(): ) bad_request(client.get, 404, "http://localhost:8000/me", headers=auth_header) + def test_delete_nonexistent_user(regular_token): # Assuming user_id 99999 does not exist user_id_to_delete = 99999 @@ -200,6 +201,7 @@ def test_update_account_invalid_email(superadmin_token): ) assert resp.status_code == 404 # Expecting a not found error + def test_send_password_mail_superadmin(superadmin_token): reset_mail_payload = {"email": "superadmin@gmail.com"} resp = client.post( @@ -208,7 +210,11 @@ def test_send_password_mail_superadmin(superadmin_token): headers=auth_header(superadmin_token), ) assert resp.status_code == 200 - assert "An email has been sent to superadmin@gmail.com with a link for password reset." in resp.json()["result"] + assert ( + "An email has been sent to superadmin@gmail.com with a link for password reset." + in resp.json()["result"] + ) + def test_send_password_mail_user(regular_token): reset_mail_payload = {"email": "regular@gmail.com"} @@ -219,4 +225,7 @@ def test_send_password_mail_user(regular_token): ) print(resp.json()) assert resp.status_code == 200 - assert "An email has been sent to regular@gmail.com with a link for password reset." in resp.json()["result"] \ No newline at end of file + assert ( + "An email has been sent to regular@gmail.com with a link for password reset." + in resp.json()["result"] + ) From 9824f012c8e66a6582ba9c5d79ff4faec9eff2e8 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sat, 25 Nov 2023 13:02:15 +0100 Subject: [PATCH 80/95] tests : reset and forget password --- test/test_accounts.py | 28 ---------------------------- test/test_password.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 test/test_password.py diff --git a/test/test_accounts.py b/test/test_accounts.py index fe4222c..3f86570 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -202,31 +202,3 @@ def test_update_account_invalid_email(superadmin_token): ) assert resp.status_code == 404 # Expecting a not found error - -def test_send_password_mail_superadmin(superadmin_token): - reset_mail_payload = {"email": "superadmin@gmail.com"} - resp = client.post( - "http://localhost:8000/password/forgot", - json=reset_mail_payload, - headers=auth_header(superadmin_token), - ) - assert resp.status_code == 200 - assert ( - "An email has been sent to superadmin@gmail.com with a link for password reset." - in resp.json()["result"] - ) - - -def test_send_password_mail_user(regular_token): - reset_mail_payload = {"email": "regular@gmail.com"} - resp = client.post( - "http://localhost:8000/password/forgot", - json=reset_mail_payload, - headers=auth_header(regular_token), - ) - print(resp.json()) - assert resp.status_code == 200 - assert ( - "An email has been sent to regular@gmail.com with a link for password reset." - in resp.json()["result"] - ) diff --git a/test/test_password.py b/test/test_password.py new file mode 100644 index 0000000..7fc4edf --- /dev/null +++ b/test/test_password.py @@ -0,0 +1,41 @@ +from conftest import client, auth_header + +def test_send_password_mail_superadmin(): + email = {"email": "superadmin@gmail.com"} + resp = client.post( + "http://localhost:8000/password/forgot", + json=email, + ) + assert resp.status_code == 200 + assert ( + "An email has been sent to superadmin@gmail.com with a link for password reset." + in resp.json()["result"] + ) + + +def test_send_password_mail_user(): + email = {"email": "regular@gmail.com"} + resp = client.post( + "http://localhost:8000/password/forgot", + json=email, + ) + print(resp.json()) + assert resp.status_code == 200 + assert ( + "An email has been sent to regular@gmail.com with a link for password reset." + in resp.json()["result"] + ) + + +def test_account_reset_password(): + email = {"email": "regular@gmail.com"} + resp = client.post("http://localhost:8000/password/forget", json=email) + assert resp.status_code == 200 + reset_request_result = resp.json() + assert reset_request_result['details'] == "Password reset email sent" + reset_token = reset_request_result['token'] + new_password_payload = {"password": "new_password", "token": reset_token} + reset_password_response = client.post("http://localhost:8000/password/reset", json=new_password_payload) + assert reset_password_response.status_code == 200 + reset_password_result = reset_password_response.json() + assert reset_password_result['details'] == "Successfully updated password" \ No newline at end of file From 6887bfaacd07f834f08d4abf2fe8f63bf0035075 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sat, 25 Nov 2023 13:03:27 +0100 Subject: [PATCH 81/95] black . --- test/test_accounts.py | 1 - test/test_password.py | 11 +++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/test_accounts.py b/test/test_accounts.py index 3f86570..c4ee824 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -201,4 +201,3 @@ def test_update_account_invalid_email(superadmin_token): headers=auth_header(superadmin_token), ) assert resp.status_code == 404 # Expecting a not found error - diff --git a/test/test_password.py b/test/test_password.py index 7fc4edf..51c07a6 100644 --- a/test/test_password.py +++ b/test/test_password.py @@ -1,5 +1,6 @@ from conftest import client, auth_header + def test_send_password_mail_superadmin(): email = {"email": "superadmin@gmail.com"} resp = client.post( @@ -32,10 +33,12 @@ def test_account_reset_password(): resp = client.post("http://localhost:8000/password/forget", json=email) assert resp.status_code == 200 reset_request_result = resp.json() - assert reset_request_result['details'] == "Password reset email sent" - reset_token = reset_request_result['token'] + assert reset_request_result["details"] == "Password reset email sent" + reset_token = reset_request_result["token"] new_password_payload = {"password": "new_password", "token": reset_token} - reset_password_response = client.post("http://localhost:8000/password/reset", json=new_password_payload) + reset_password_response = client.post( + "http://localhost:8000/password/reset", json=new_password_payload + ) assert reset_password_response.status_code == 200 reset_password_result = reset_password_response.json() - assert reset_password_result['details'] == "Successfully updated password" \ No newline at end of file + assert reset_password_result["details"] == "Successfully updated password" From 1a6b98c84f9943a671891eed04b9a176935677fa Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 26 Nov 2023 13:58:01 +0100 Subject: [PATCH 82/95] changed response for /password/forgot to 200 OK even if the email (account) is not found in the database --- main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index a593206..06ccbf1 100644 --- a/main.py +++ b/main.py @@ -436,7 +436,10 @@ async def send_password_mail( ): account = auth.get_account_by_email(db=db, email=forget_passwort_input.email) if account is None: - raise HTTPException(status_code=404, detail=f"Email not found") + return { + "result": f"An email has been sent to {forget_passwort_input.email} with a link for password reset." + } + #raise HTTPException(status_code=404, detail=f"Email not found") try: await auth.send_password_reset_mail(account=account) return { From 51c2396ae29ea51dc81f89e12241b56c67c01e6c Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 26 Nov 2023 14:05:59 +0100 Subject: [PATCH 83/95] formated with black . --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 06ccbf1..b20987f 100644 --- a/main.py +++ b/main.py @@ -439,7 +439,7 @@ async def send_password_mail( return { "result": f"An email has been sent to {forget_passwort_input.email} with a link for password reset." } - #raise HTTPException(status_code=404, detail=f"Email not found") + # raise HTTPException(status_code=404, detail=f"Email not found") try: await auth.send_password_reset_mail(account=account) return { From 27ea53896ae5f431b3d49994a3e74355737580a8 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sun, 26 Nov 2023 14:17:53 +0100 Subject: [PATCH 84/95] create first superadmin if not exists on startup --- main.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/main.py b/main.py index b20987f..14a57ee 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,20 @@ def get_db(): db.close() +def assert_first_super_admin(): + db = SessionLocal() + db_superadmin = db.query(models.Account).filter(models.Account.account_category == models.AccountType.Superadmin).first() + if db_superadmin is None: + account_db = models.Account( + email="superadmin@gmail.com", + account_category=models.AccountType.Superadmin, + password=crud.password_hasher("superadmin"), + ) + db.add(account_db) + db.commit() + db.close() + + def validate_access_level( auth_user: schemas.AuthSchema, access_level: models.AccountType ): @@ -468,4 +482,5 @@ def account_reset_password_result( raise HTTPException(status_code=500, detail="An unexpected error occurred") +assert_first_super_admin() add_pagination(app) From d821ec3623bff790b653bc2a0893c329013e5133 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sun, 26 Nov 2023 14:19:48 +0100 Subject: [PATCH 85/95] black formatting --- main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 14a57ee..4e48522 100644 --- a/main.py +++ b/main.py @@ -39,7 +39,11 @@ def get_db(): def assert_first_super_admin(): db = SessionLocal() - db_superadmin = db.query(models.Account).filter(models.Account.account_category == models.AccountType.Superadmin).first() + db_superadmin = ( + db.query(models.Account) + .filter(models.Account.account_category == models.AccountType.Superadmin) + .first() + ) if db_superadmin is None: account_db = models.Account( email="superadmin@gmail.com", From 9486068222d69c24803069b6d29846567c609072 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sun, 26 Nov 2023 14:28:53 +0100 Subject: [PATCH 86/95] read superadmin login and password from .env --- .env.example | 3 +++ main.py | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 884ac57..6589e22 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ MAIL_PASSWORD="" MAIL_PORT=587 MAIL_SERVER="smtp.gmail.com" MAIL_FROM_NAME="Kosjenka Support" + +SUPERADMIN_LOGIN="superadmin@gmail.com" +SUPERADMIN_PASSWORD="superadmin" diff --git a/main.py b/main.py index 4e48522..2ae60be 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +import os + from fastapi import Depends, FastAPI, HTTPException from sqlalchemy.orm import Session from fastapi.middleware.cors import CORSMiddleware @@ -5,6 +7,7 @@ from fastapi.templating import Jinja2Templates from fastapi_pagination import Page, add_pagination import fastapi_pagination +from dotenv import load_dotenv import crud import models @@ -17,6 +20,7 @@ templates = Jinja2Templates(directory="html_templates") +load_dotenv() app = FastAPI() app.add_middleware( CORSMiddleware, @@ -45,10 +49,18 @@ def assert_first_super_admin(): .first() ) if db_superadmin is None: + login, password = ( + os.environ["SUPERADMIN_LOGIN"], + os.environ["SUPERADMIN_PASSWORD"], + ) + if not login or not password: + raise ValueError( + "SUPERADMIN_LOGIN and SUPERADMIN_PASSWORD must be set for the first superadmin" + ) account_db = models.Account( - email="superadmin@gmail.com", + email=login, account_category=models.AccountType.Superadmin, - password=crud.password_hasher("superadmin"), + password=crud.password_hasher(password), ) db.add(account_db) db.commit() From ce1e564b57387d963427b6cd0765699b9b3b880e Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 26 Nov 2023 14:32:26 +0100 Subject: [PATCH 87/95] comment test reset/forgot password --- test/test_password.py | 76 +++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/test/test_password.py b/test/test_password.py index 51c07a6..68fa2c2 100644 --- a/test/test_password.py +++ b/test/test_password.py @@ -1,44 +1,44 @@ -from conftest import client, auth_header +# from conftest import client, auth_header -def test_send_password_mail_superadmin(): - email = {"email": "superadmin@gmail.com"} - resp = client.post( - "http://localhost:8000/password/forgot", - json=email, - ) - assert resp.status_code == 200 - assert ( - "An email has been sent to superadmin@gmail.com with a link for password reset." - in resp.json()["result"] - ) +# def test_send_password_mail_superadmin(): +# email = {"email": "superadmin@gmail.com"} +# resp = client.post( +# "http://localhost:8000/password/forgot", +# json=email, +# ) +# assert resp.status_code == 200 +# assert ( +# "An email has been sent to superadmin@gmail.com with a link for password reset." +# in resp.json()["result"] +# ) -def test_send_password_mail_user(): - email = {"email": "regular@gmail.com"} - resp = client.post( - "http://localhost:8000/password/forgot", - json=email, - ) - print(resp.json()) - assert resp.status_code == 200 - assert ( - "An email has been sent to regular@gmail.com with a link for password reset." - in resp.json()["result"] - ) +# def test_send_password_mail_user(): +# email = {"email": "regular@gmail.com"} +# resp = client.post( +# "http://localhost:8000/password/forgot", +# json=email, +# ) +# print(resp.json()) +# assert resp.status_code == 200 +# assert ( +# "An email has been sent to regular@gmail.com with a link for password reset." +# in resp.json()["result"] +# ) -def test_account_reset_password(): - email = {"email": "regular@gmail.com"} - resp = client.post("http://localhost:8000/password/forget", json=email) - assert resp.status_code == 200 - reset_request_result = resp.json() - assert reset_request_result["details"] == "Password reset email sent" - reset_token = reset_request_result["token"] - new_password_payload = {"password": "new_password", "token": reset_token} - reset_password_response = client.post( - "http://localhost:8000/password/reset", json=new_password_payload - ) - assert reset_password_response.status_code == 200 - reset_password_result = reset_password_response.json() - assert reset_password_result["details"] == "Successfully updated password" +# def test_account_reset_password(): +# email = {"email": "regular@gmail.com"} +# resp = client.post("http://localhost:8000/password/forget", json=email) +# assert resp.status_code == 200 +# reset_request_result = resp.json() +# assert reset_request_result["details"] == "Password reset email sent" +# reset_token = reset_request_result["token"] +# new_password_payload = {"password": "new_password", "token": reset_token} +# reset_password_response = client.post( +# "http://localhost:8000/password/reset", json=new_password_payload +# ) +# assert reset_password_response.status_code == 200 +# reset_password_result = reset_password_response.json() +# assert reset_password_result["details"] == "Successfully updated password" From a8b25ed7627e37cf503cc976f0e3b4ce545fddd3 Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sun, 26 Nov 2023 14:34:28 +0100 Subject: [PATCH 88/95] update dev workflows for superadmin log/pass --- .github/workflows/dev.yml | 2 ++ .github/workflows/pipeline.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 332e9e1..46de11d 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -47,6 +47,8 @@ jobs: echo "MAIL_PORT=587" >> .env echo "MAIL_SERVER=smtp.gmail.com" >> .env echo "MAIL_FROM_NAME=Kosjenka Support" >> .env + echo "SUPERADMIN_LOGIN=superadmin@gmail.com" >> .env + echo "SUPERADMIN_PASSWORD={{ secrets.DEV_SUPERADMIN_PASSWORD }}" >> .env - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c0e5a1e..09405e6 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -18,6 +18,8 @@ env: MAIL_PORT: 587 MAIL_SERVER: "smtp.gmail.com" MAIL_FROM_NAME: "Kosjenka Support" + SUPERADMIN_LOGIN: "superadmin@gmail.com" + SUPERADMIN_PASSWORD: "superadmin" jobs: From 89a8d4f788789225105b9a481a9fc3f61b71cced Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 26 Nov 2023 14:39:48 +0100 Subject: [PATCH 89/95] test healthz endpoint --- test/test_healthz.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 test/test_healthz.py diff --git a/test/test_healthz.py b/test/test_healthz.py new file mode 100644 index 0000000..91ae701 --- /dev/null +++ b/test/test_healthz.py @@ -0,0 +1,5 @@ +from conftest import client + +def test_healthz(): + resp = client.get("http://localhost:8000/healthz/") + assert resp.status_code == 200 \ No newline at end of file From 0936f0d0b8c1d8271e6d2fcaabdf86debf8cc242 Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 26 Nov 2023 14:40:09 +0100 Subject: [PATCH 90/95] remove unused import --- test/test_exercises.py | 1 - test/test_redirection.py | 2 -- test/test_refresh.py | 4 +--- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/test/test_exercises.py b/test/test_exercises.py index 43c7234..6f2c2c1 100644 --- a/test/test_exercises.py +++ b/test/test_exercises.py @@ -1,7 +1,6 @@ from datetime import datetime import time -from fastapi_pagination import add_pagination from conftest import client, auth_header diff --git a/test/test_redirection.py b/test/test_redirection.py index b8ad314..c1770f1 100644 --- a/test/test_redirection.py +++ b/test/test_redirection.py @@ -1,5 +1,3 @@ -from datetime import datetime -import time from conftest import client, auth_header diff --git a/test/test_refresh.py b/test/test_refresh.py index e5e6e43..d987426 100644 --- a/test/test_refresh.py +++ b/test/test_refresh.py @@ -1,6 +1,4 @@ -import pytest - -from conftest import client, auth_header +from conftest import client def test_refresh(superadmin_token): From 275f5b86209848c0f4d4c9046095f3807c008dae Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 26 Nov 2023 14:40:28 +0100 Subject: [PATCH 91/95] black . --- test/test_healthz.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_healthz.py b/test/test_healthz.py index 91ae701..2d4857c 100644 --- a/test/test_healthz.py +++ b/test/test_healthz.py @@ -1,5 +1,6 @@ from conftest import client + def test_healthz(): resp = client.get("http://localhost:8000/healthz/") - assert resp.status_code == 200 \ No newline at end of file + assert resp.status_code == 200 From f590d97a050aae402ce9c0e1cd0fea7e1d915cda Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 26 Nov 2023 14:44:38 +0100 Subject: [PATCH 92/95] skip tests password reset and forgot --- test/test_password.py | 80 +++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/test/test_password.py b/test/test_password.py index 68fa2c2..f0b63bd 100644 --- a/test/test_password.py +++ b/test/test_password.py @@ -1,44 +1,48 @@ -# from conftest import client, auth_header +import pytest +from conftest import client, auth_header -# def test_send_password_mail_superadmin(): -# email = {"email": "superadmin@gmail.com"} -# resp = client.post( -# "http://localhost:8000/password/forgot", -# json=email, -# ) -# assert resp.status_code == 200 -# assert ( -# "An email has been sent to superadmin@gmail.com with a link for password reset." -# in resp.json()["result"] -# ) +@pytest.mark.skip(reason="no way of currently testing this") +def test_send_password_mail_superadmin(): + email = {"email": "superadmin@gmail.com"} + resp = client.post( + "http://localhost:8000/password/forgot", + json=email, + ) + assert resp.status_code == 200 + assert ( + "An email has been sent to superadmin@gmail.com with a link for password reset." + in resp.json()["result"] + ) -# def test_send_password_mail_user(): -# email = {"email": "regular@gmail.com"} -# resp = client.post( -# "http://localhost:8000/password/forgot", -# json=email, -# ) -# print(resp.json()) -# assert resp.status_code == 200 -# assert ( -# "An email has been sent to regular@gmail.com with a link for password reset." -# in resp.json()["result"] -# ) +@pytest.mark.skip(reason="no way of currently testing this") +def test_send_password_mail_user(): + email = {"email": "regular@gmail.com"} + resp = client.post( + "http://localhost:8000/password/forgot", + json=email, + ) + print(resp.json()) + assert resp.status_code == 200 + assert ( + "An email has been sent to regular@gmail.com with a link for password reset." + in resp.json()["result"] + ) -# def test_account_reset_password(): -# email = {"email": "regular@gmail.com"} -# resp = client.post("http://localhost:8000/password/forget", json=email) -# assert resp.status_code == 200 -# reset_request_result = resp.json() -# assert reset_request_result["details"] == "Password reset email sent" -# reset_token = reset_request_result["token"] -# new_password_payload = {"password": "new_password", "token": reset_token} -# reset_password_response = client.post( -# "http://localhost:8000/password/reset", json=new_password_payload -# ) -# assert reset_password_response.status_code == 200 -# reset_password_result = reset_password_response.json() -# assert reset_password_result["details"] == "Successfully updated password" +@pytest.mark.skip(reason="no way of currently testing this") +def test_account_reset_password(): + email = {"email": "regular@gmail.com"} + resp = client.post("http://localhost:8000/password/forget", json=email) + assert resp.status_code == 200 + reset_request_result = resp.json() + assert reset_request_result["details"] == "Password reset email sent" + reset_token = reset_request_result["token"] + new_password_payload = {"password": "new_password", "token": reset_token} + reset_password_response = client.post( + "http://localhost:8000/password/reset", json=new_password_payload + ) + assert reset_password_response.status_code == 200 + reset_password_result = reset_password_response.json() + assert reset_password_result["details"] == "Successfully updated password" From ae6f31c32f8657d0161f69e86395c73eb50b8ffc Mon Sep 17 00:00:00 2001 From: Ambre Simond Date: Sun, 26 Nov 2023 15:27:54 +0100 Subject: [PATCH 93/95] fix refresh test --- test/test_refresh.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_refresh.py b/test/test_refresh.py index d987426..7b43808 100644 --- a/test/test_refresh.py +++ b/test/test_refresh.py @@ -1,9 +1,9 @@ -from conftest import client +from conftest import client, auth_header def test_refresh(superadmin_token): # Perform a login to obtain a refresh token - account_details = {"email": "superadmin@gmail.com", "password": "superadmin"} + account_details = {"email": "update@gmail.com", "password": "superadmin"} login_resp = client.post("http://localhost:8000/login", json=account_details) assert login_resp.status_code == 200 refresh_token = login_resp.json().get("refresh_token") From 3b9adfbf07e2281d002ec37b1e2fabd5f4b2d1a7 Mon Sep 17 00:00:00 2001 From: Valentin Vikhorev <33204359+vvihorev@users.noreply.github.com> Date: Sun, 26 Nov 2023 15:29:41 +0100 Subject: [PATCH 94/95] Update test_password.py --- test/test_password.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_password.py b/test/test_password.py index f0b63bd..eefa5c8 100644 --- a/test/test_password.py +++ b/test/test_password.py @@ -2,7 +2,7 @@ from conftest import client, auth_header -@pytest.mark.skip(reason="no way of currently testing this") +@pytest.mark.skip(reason="we have to call function in auth.py to reset password directly") def test_send_password_mail_superadmin(): email = {"email": "superadmin@gmail.com"} resp = client.post( @@ -16,7 +16,7 @@ def test_send_password_mail_superadmin(): ) -@pytest.mark.skip(reason="no way of currently testing this") +@pytest.mark.skip(reason="we have to call function in auth.py to reset password directly") def test_send_password_mail_user(): email = {"email": "regular@gmail.com"} resp = client.post( @@ -31,7 +31,7 @@ def test_send_password_mail_user(): ) -@pytest.mark.skip(reason="no way of currently testing this") +@pytest.mark.skip(reason="we have to call function in auth.py to reset password directly") def test_account_reset_password(): email = {"email": "regular@gmail.com"} resp = client.post("http://localhost:8000/password/forget", json=email) From 8984a5ac9333f0d34123e4bf0a71df27d2fd36fe Mon Sep 17 00:00:00 2001 From: vvihorev Date: Sun, 26 Nov 2023 15:30:36 +0100 Subject: [PATCH 95/95] black formatting --- test/test_password.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/test_password.py b/test/test_password.py index eefa5c8..e29ca7a 100644 --- a/test/test_password.py +++ b/test/test_password.py @@ -2,7 +2,9 @@ from conftest import client, auth_header -@pytest.mark.skip(reason="we have to call function in auth.py to reset password directly") +@pytest.mark.skip( + reason="we have to call function in auth.py to reset password directly" +) def test_send_password_mail_superadmin(): email = {"email": "superadmin@gmail.com"} resp = client.post( @@ -16,7 +18,9 @@ def test_send_password_mail_superadmin(): ) -@pytest.mark.skip(reason="we have to call function in auth.py to reset password directly") +@pytest.mark.skip( + reason="we have to call function in auth.py to reset password directly" +) def test_send_password_mail_user(): email = {"email": "regular@gmail.com"} resp = client.post( @@ -31,7 +35,9 @@ def test_send_password_mail_user(): ) -@pytest.mark.skip(reason="we have to call function in auth.py to reset password directly") +@pytest.mark.skip( + reason="we have to call function in auth.py to reset password directly" +) def test_account_reset_password(): email = {"email": "regular@gmail.com"} resp = client.post("http://localhost:8000/password/forget", json=email)