diff --git a/.env.example b/.env.example index 6589e22..51bad83 100644 --- a/.env.example +++ b/.env.example @@ -4,12 +4,16 @@ DATABASE_URL="sqlite:///./db.sqlite" # AUTH SETTINGS / PASSWORT RESET # Important: No Backslash at the end! PASSWORD_RESET_LINK="127.0.0.1" + +ACTIVATE_ACCOUNT_LINK="127.0.0.1" #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 +#In Sec = 10h +JWT_VALID_TIME_ACTIVATE_ACCOUNT=36000 JWT_SECRET="secret" JWT_ALGORITHM="HS256" MAIL_USERNAME="kosjenka.readingapp@gmail.com" diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 14c54a2..d05250d 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -41,6 +41,7 @@ jobs: echo "JWT_VALID_TIME_ACCESS=1200" >> .env echo "JWT_VALID_TIME_REFRESH=604800" >> .env echo "JWT_VALID_TIME_PWD_RESET=600" >> .env + echo "JWT_VALID_TIME_ACTIVATE_ACCOUNT=36000" >> .env echo "JWT_ALGORITHM=HS256" >> .env echo "MAIL_USERNAME=kosjenka.readingapp@gmail.com" >> .env echo "MAIL_PORT=587" >> .env @@ -49,6 +50,7 @@ jobs: echo "SUPERADMIN_LOGIN=superadmin@gmail.com" >> .env echo "SUPERADMIN_PASSWORD={{ secrets.DEV_SUPERADMIN_PASSWORD }}" >> .env echo "PASSWORD_RESET_LINK=https://admin-kosjenka-dev.vercel.app/password/confirm" >> .env + echo "ACTIVATE_ACCOUNT_LINK=https://admin-kosjenka-dev.vercel.app/admins/confirm" >> .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 0c97c37..cdb909d 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -8,9 +8,11 @@ on: env: DATABASE_URL: "sqlite:///./db.sqlite" PASSWORD_RESET_LINK: "127.0.0.1" + ACTIVATE_ACCOUNT_LINK: "127.0.0.1:8000" JWT_VALID_TIME_ACCESS: 1200 JWT_VALID_TIME_REFRESH: 604800 JWT_VALID_TIME_PWD_RESET: 600 + JWT_VALID_TIME_ACTIVATE_ACCOUNT: 36000 JWT_SECRET: "secret" JWT_ALGORITHM: "HS256" MAIL_USERNAME: "kosjenka.readingapp@gmail.com" diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 462edf7..ad652c5 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -13,6 +13,7 @@ env: JWT_VALID_TIME_ACCESS: 1200 JWT_VALID_TIME_REFRESH: 604800 JWT_VALID_TIME_PWD_RESET: 600 + JWT_VALID_TIME_ACTIVATE_ACCOUNT: 36000 JWT_ALGORITHM: "HS256" MAIL_USERNAME: "kosjenka.readingapp@gmail.com" MAIL_PORT: 587 @@ -57,8 +58,10 @@ 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.PROD_SUPERADMIN_PASSWORD }}" >> .env echo "PASSWORD_RESET_LINK=https://admin-kosjenka.vercel.app/password/confirm" >> .env + echo "ACTIVATE_ACCOUNT_LINK=echo "PASSWORD_RESET_LINK=https://admin-kosjenka.vercel.app/admins/confirm" >> .env - name: Build and push Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc diff --git a/auth.py b/auth.py index dfcddc3..88d9d0e 100644 --- a/auth.py +++ b/auth.py @@ -18,6 +18,7 @@ JWT_VALID_TIME_PWD_RESET = int( os.environ["JWT_VALID_TIME_PWD_RESET"] ) # 60 * 10 # 10min +JWT_VALID_TIME_ACTIVATE_ACCOUNT = int(os.environ["JWT_VALID_TIME_ACTIVATE_ACCOUNT"]) JWT_SECRET = os.environ["JWT_SECRET"] # "C0ddVvlcaL4UuChF8ckFQoVCGbtizyvK" JWT_ALGORITHM = os.environ["JWT_ALGORITHM"] # "HS256" @@ -154,3 +155,48 @@ def reset_password(db: Session, new_password: str, token: str): return "SUCCESS" except: return "ERROR" + + +# Admin Password set +def create_account_activation_token( + email: EmailStr, is_superadmin: bool, valid_time: int +): + payload = { + "email": email, + "is_superadmin": is_superadmin, + "expires": time.time() + valid_time, + } + token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + return token + + +async def send_account_password_mail(account: schemas.AccountPostAdminIn): + token = create_account_activation_token( + email=account.email, + is_superadmin=account.is_superadmin, + valid_time=JWT_VALID_TIME_ACTIVATE_ACCOUNT, + ) + link_base = os.environ["ACTIVATE_ACCOUNT_LINK"] + template_body = { + "user": account.email, + "url": f"{link_base}?token={token}", + "expire_in_hours": int(JWT_VALID_TIME_ACTIVATE_ACCOUNT / 60 / 60), + } + message = MessageSchema( + subject="Kosjenka - Account Registration", + recipients=[account.email], + template_body=template_body, + subtype=MessageType.html, + ) + fm = FastMail(conf) + await fm.send_message(message, template_name="activate_account_email.html") + + +def check_account_activation_token(token: str): + try: + decoded_token = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + if decoded_token["expires"] < time.time(): + return None + return decoded_token + except: + return None diff --git a/crud.py b/crud.py index 5f7928f..8b501c1 100644 --- a/crud.py +++ b/crud.py @@ -56,25 +56,13 @@ def get_exercises( user_id: int | None = None, ): exercises = select(models.Exercise) - # first, filter the exercises by complexity, category and title - if complexity: - exercises = exercises.filter(models.Exercise.complexity == complexity) - if categories: - for category in categories: - exercises = exercises.filter(models.Exercise.category.contains(category)) - if title_like: - if case_sensitive: - exercises = exercises.filter(models.Exercise.title.like(f"%{title_like}%")) - else: - exercises = exercises.filter(models.Exercise.title.ilike(f"%{title_like}%")) - # then, sort the exercises + # sort the exercises if order_by: # sort by completion (completion is in DoExercise table) if order_by == schemas.ExerciseOrderBy.completion: - exercises = ( - select(models.Exercise) - .join(models.DoExercise) - .filter(models.DoExercise.user_id == user_id) + # assert user_id + exercises = exercises.join(models.DoExercise, isouter=True).filter( + models.DoExercise.user_id == user_id ) exercises = exercises.order_by( exercise_order_by_column[order_by].desc() @@ -83,11 +71,22 @@ def get_exercises( ) # sort with elements in exercise's table else: + # assert not user_id exercises = exercises.order_by( exercise_order_by_column[order_by].desc() if order == schemas.Order.desc else exercise_order_by_column[order_by] ) + if complexity: + exercises = exercises.filter(models.Exercise.complexity == complexity) + if categories: + for category in categories: + exercises = exercises.filter(models.Exercise.category.contains(category)) + if title_like: + if case_sensitive: + exercises = exercises.filter(models.Exercise.title.like(f"%{title_like}%")) + else: + exercises = exercises.filter(models.Exercise.title.ilike(f"%{title_like}%")) # if the id of a user is given then add the completion of the specific user if user_id: exercises = ( @@ -207,7 +206,7 @@ def get_account(db: Session, auth_user: schemas.AuthSchema, account_id: int): db.query(models.Account) .filter( models.Account.id_account == account_id, - models.Account.account_category == models.AccountType.Admin, + models.Account.account_category != models.AccountType.Regular, ) .first() ) @@ -386,12 +385,25 @@ def delete_category(db: Session, category: str): def update_category(db: Session, old_category: str, new_category: schemas.Category): + stored_new_category = ( + db.query(models.Category) + .filter(models.Category.category == new_category.category) + .first() + ) stored_category = ( db.query(models.Category) .filter(models.Category.category == old_category) .first() ) - setattr(stored_category, "category", new_category.category) + exs_with_old_category = stored_category.exercises + + if not stored_new_category: + setattr(stored_category, "category", new_category.category) + else: + db.delete(stored_category) + db.commit() + + for ex in exs_with_old_category: + ex.category.append(stored_new_category or stored_category) db.commit() - db.refresh(stored_category) return stored_category diff --git a/html_templates/activate_account_email.html b/html_templates/activate_account_email.html new file mode 100644 index 0000000..1cd263d --- /dev/null +++ b/html_templates/activate_account_email.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Activate your account</title> + </head> + <body> + <h2 style="color: #27a9e1;"> Hey!</h2> + <p style="font-size:18px;color: #030303;">It seems a account for you was created!</p> + <p style="font-size:18px;color: #030303;">Follow this link to finish the registration and set a password for your account:</p> + <p><a style="font-size:18px;display: inline-block; padding: 10px 20px; background-color: #27a9e1; color: #ffffff; text-decoration: none; border-radius: 5px;" href="{{url}}">Set password</a></p> + <b style="font-size:18px;color: #ff9999;">Note: This link will expire in {{expire_in_hours}} hours.</b> + <br> + <hr style="border: 1px solid #CCCCCC;"> + <p style="font-size:14px;color: #888888;">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.</p> + </body> +</html> diff --git a/main.py b/main.py index fd5b6b5..3b64171 100644 --- a/main.py +++ b/main.py @@ -224,21 +224,42 @@ def track_exercise_completion( return db_do_exercise -@app.post("/accounts", response_model=schemas.AccountOut) -def create_account( - account_in: schemas.AccountPostAdmin, +@app.post("/accounts") +async def create_account( + account_in: schemas.AccountPostAdminIn, db: Session = Depends(get_db), auth_user: schemas.AuthSchema = Depends(JWTBearer()), ): 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: + try: + await auth.send_account_password_mail(account=account_in) + return { + "result": f"An email has been sent to {account_in.email} with a link for activating the account." + } + except Exception as e: + print(e) + raise HTTPException(status_code=500, detail=f"An unexpected error occurred") + + +@app.post("/accounts/activate", response_model=schemas.AccountOut) +def account_reset_password_result( + input: schemas.ActivateAccountSchema, + db: Session = Depends(get_db), +): + result = auth.check_account_activation_token(input.token) + if result == None: + raise HTTPException(status_code=401, detail="Token is expired or not valid") + if crud.email_is_registered(db, result["email"]): + raise HTTPException(status_code=409, detail="Email already registered") + if result["is_superadmin"]: type_account = models.AccountType.Superadmin else: type_account = models.AccountType.Admin - account_saved = crud.create_account(db, account_in, type_account) + new_account = schemas.AccountPostAdmin + new_account.email = result["email"] + new_account.password = input.password + account_saved = crud.create_account(db, new_account, type_account) return account_saved diff --git a/models.py b/models.py index e08c8b3..8ccfe42 100644 --- a/models.py +++ b/models.py @@ -62,9 +62,7 @@ class Exercise(Base): title = Column(String) complexity = Column(Enum(Complexity), nullable=True) text = Column(String) - category = relationship( - "Category", secondary=exercise_category, back_populates="exercises" - ) + category = relationship("Category", secondary=exercise_category) users = relationship("DoExercise", back_populates="exercise", lazy="dynamic") date = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -94,4 +92,6 @@ class Category(Base): __tablename__ = "category" category = Column(String, primary_key=True) - exercises = relationship("Exercise", secondary=exercise_category) + exercises = relationship( + "Exercise", secondary=exercise_category, back_populates="category" + ) diff --git a/schemas.py b/schemas.py index 9d24cf1..6dd96ce 100644 --- a/schemas.py +++ b/schemas.py @@ -82,6 +82,11 @@ class AccountIn(BaseModel): password: str +class AccountPostAdminIn(BaseModel): + email: EmailStr + is_superadmin: Optional[bool] = False + + class AccountPostAdmin(AccountIn): is_superadmin: Optional[bool] = False @@ -148,3 +153,9 @@ class ResetPasswordSchema(BaseModel): class ResetPasswordResultSchema(BaseModel): details: str + + +# Activate Account +class ActivateAccountSchema(BaseModel): + password: str + token: str diff --git a/test/conftest.py b/test/conftest.py index b5c4875..e00dd67 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -4,6 +4,7 @@ from crud import password_hasher from database import SessionLocal from main import app +from auth import create_account_activation_token import models @@ -26,14 +27,16 @@ def regular_token(): @pytest.fixture(scope="session") def admin_token(superadmin_token): account_details = {"email": "admin@gmail.com", "password": "admin"} + activate = { + "token": create_account_activation_token("admin@gmail.com", False, 60000), + "password": "admin", + } resp = client.post("http://localhost:8000/login", json=account_details).json() if "detail" in resp and resp["detail"] == "Username/Password wrong": resp = client.post( - "http://localhost:8000/accounts", - json=account_details, - headers=auth_header(superadmin_token), + "http://localhost:8000/accounts/activate", json=activate ).json() - resp = client.post("http://localhost:8000/login", json=account_details).json() + resp = client.post("http://localhost:8000/login", json=account_details).json() access_token = resp["access_token"] yield access_token diff --git a/test/test_accounts.py b/test/test_accounts.py index c4ee824..1b6549e 100644 --- a/test/test_accounts.py +++ b/test/test_accounts.py @@ -1,5 +1,7 @@ from conftest import client, auth_header, good_request, bad_request +from auth import createPasswortResetToken, create_account_activation_token + def test_create_account(superadmin_token): accounts = client.get( @@ -13,12 +15,38 @@ def test_create_account(superadmin_token): headers=auth_header(superadmin_token), ) assert resp.status_code == 200 + activate = { + "token": create_account_activation_token("email@gmail.com", False, 6000), + "password": "secret", + } + activate_result = client.post( + "http://localhost:8000/accounts/activate", json=activate + ) + assert activate_result.status_code == 200 accounts = client.get( "http://localhost:8000/accounts", headers=auth_header(superadmin_token) ).json() assert len(accounts["items"]) == account_count + 1 +def test_activate_account(): + account = {"email": "activate@gmail.com", "password": "secret"} + resp = client.post("http://localhost:8000/login", json=account) + # Check if login is not possible + assert resp.status_code == 400 + token = create_account_activation_token("activate@gmail.com", False, 6000) + activate = { + "token": token, + "password": "secret", + } + + resp2 = client.post("http://localhost:8000/accounts/activate", json=activate) + assert resp2.status_code == 200 + # Try to login again + resp3 = client.post("http://localhost:8000/login", json=account) + assert resp3.status_code == 200 + + def test_update_account(superadmin_token): # Get the superadmin accounts = client.get( @@ -192,7 +220,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): +def test_update_non_existent_account(superadmin_token): # Try updating a non-existent account non_existent_account_id = 999999 # Assuming this ID doesn't exist resp = client.patch( @@ -201,3 +229,31 @@ 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_delete_superadmin_account(superadmin_token): + new_admin = { + "email": "new_admin@mail.com", + "is_superadmin": True, + } + new_admin_resp = good_request( + client.post, + "http://localhost:8000/accounts", + headers=auth_header(superadmin_token), + json=new_admin, + ) + activate = { + "token": create_account_activation_token("new_admin@mail.com", True, 6000), + "password": "new_passwrd", + } + activate_result = client.post( + "http://localhost:8000/accounts/activate", json=activate + ) + assert activate_result.status_code == 200 + activate_result_json = activate_result.json() + id_account = activate_result_json["id_account"] + good_request( + client.delete, + f"http://localhost:8000/accounts/{id_account}", + headers=auth_header(superadmin_token), + ) diff --git a/test/test_categories.py b/test/test_categories.py index 2020ed8..40e3589 100644 --- a/test/test_categories.py +++ b/test/test_categories.py @@ -1,6 +1,6 @@ import pytest -from conftest import client, auth_header +from conftest import client, auth_header, good_request @pytest.mark.parametrize("category_name", ["Dogs", "Cats"]) @@ -214,3 +214,38 @@ def test_patch_nonexistent_category(admin_token): ) assert resp.status_code == 404 assert resp.json()["detail"] == "category not found" + + +def test_patch_category_updates_linked_exercises(create_exercise, admin_token): + ex_wood_id = create_exercise(categories=["wood"])["id"] + patch_category = {"category": "stone"} + good_request( + client.patch, + "http://localhost:8000/categories/wood", + headers=auth_header(admin_token), + json=patch_category, + ) + ex = good_request(client.get, f"http://localhost:8000/exercises/{ex_wood_id}") + assert ex["category"][0]["category"] == "stone" + good_request( + client.delete, + "http://localhost:8000/categories/stone", + headers=auth_header(admin_token), + ) + + +def test_patch_category_to_existing_category(create_exercise, admin_token): + create_exercise(categories=["wood"])["id"] + create_exercise(categories=["stone"])["id"] + patch_category = {"category": "stone"} + good_request( + client.patch, + "http://localhost:8000/categories/wood", + headers=auth_header(admin_token), + json=patch_category, + ) + good_request( + client.delete, + "http://localhost:8000/categories/stone", + headers=auth_header(admin_token), + ) diff --git a/test/test_do_exercise.py b/test/test_do_exercise.py index f5f1590..c665d9b 100644 --- a/test/test_do_exercise.py +++ b/test/test_do_exercise.py @@ -217,6 +217,10 @@ def test_sort_completion(regular_token, create_user, create_exercise): exercise_id1 = create_exercise()["id"] exercise_id2 = create_exercise()["id"] exercise_id3 = create_exercise()["id"] + # Create an exercise without adding completion to + # check that an exercise not already started + # returns completion 0 + exercise_id4 = create_exercise()["id"] exercise_completion1 = { "user_id": created_user_id, @@ -232,7 +236,7 @@ def test_sort_completion(regular_token, create_user, create_exercise): } exercise_completion3 = { "user_id": created_user_id, - "completion": 0, + "completion": 5, "time_spent": 100, "position": 29, } @@ -280,3 +284,4 @@ def test_sort_completion(regular_token, create_user, create_exercise): for exercise in exercises: completions.append(exercise["completion"]["completion"]) assert completions == sorted(completions)[::-1] + # assert 0 in completions