Skip to content

11 fix delete account flow #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dde68d9
Update user.py
AkanshuS Nov 20, 2024
60468fe
Update user.py
AkanshuS Nov 20, 2024
8a62883
Logs Out
AkanshuS Nov 22, 2024
9beee0e
Customization adjustments
chriscarrollsmith Nov 24, 2024
4ea6509
Don't return users with deleted column set to True
chriscarrollsmith Nov 24, 2024
30551b7
Use a mock to send email in unit test
chriscarrollsmith Nov 24, 2024
e058660
Revert "Use a mock to send email in unit test"
chriscarrollsmith Nov 24, 2024
b603e73
Revert "Don't return users with deleted column set to True"
chriscarrollsmith Nov 24, 2024
fbcb550
Remove deleted attributes in the database models and actually delete …
chriscarrollsmith Nov 24, 2024
99a18ad
Merge branch 'main' of https://github.com/Promptly-Technologies-LLC/f…
chriscarrollsmith Nov 24, 2024
5805368
Merge branch 'main' into 11-fix-delete-account-flow
chriscarrollsmith Nov 24, 2024
9d8c5b3
Added tests for a couple read routes in main.py
chriscarrollsmith Nov 25, 2024
4ed648a
Added request models for user profile update
chriscarrollsmith Nov 25, 2024
7ea6d51
Fixed a SQLAlchemy model problem where cascade was going the wrong way
chriscarrollsmith Nov 25, 2024
3cc67dd
Redirect unauthed users with 303 rather than 307 so POST requests are…
chriscarrollsmith Nov 25, 2024
bf63caf
New authentication error handler in main.py
chriscarrollsmith Nov 25, 2024
2fe1570
Fix misnamed endpoint in profile.html template
chriscarrollsmith Nov 25, 2024
a6fd524
New test fixtures for authed and unauthed clients, tests for user end…
chriscarrollsmith Nov 25, 2024
26073b4
Fixed a type lint error and updated pytest docs
chriscarrollsmith Nov 25, 2024
e1ee3d7
Adjusted password strength regex to allow forward slashes
chriscarrollsmith Nov 25, 2024
b165e0a
Document that we need to use -v flag when running docker compose down
chriscarrollsmith Nov 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/customization.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes
- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state.
- `session`: Provides a session for database operations in tests.
- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables.
- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session.
- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture.
- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture.
- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password.

To run the tests, use these commands:
Expand All @@ -43,7 +44,7 @@ The project uses type annotations and mypy for static type checking. To run mypy
mypy
```

We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it is a lifestyle change!
We find that mypy is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that mypy requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change!

## Project structure

Expand Down Expand Up @@ -80,15 +81,19 @@ We also create POST endpoints, which accept form submissions so the user can cre

#### Routing patterns in this template

In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory. We name our GET routes using the convention `read_<name>`, where `<name>` is the name of the page, to indicate that they are read-only endpoints that do not modify the database.
In this template, GET routes are defined in the main entry point for the application, `main.py`. POST routes are organized into separate modules within the `routers/` directory.

We name our GET routes using the convention `read_<name>`, where `<name>` is the name of the page, to indicate that they are read-only endpoints that do not modify the database.

We divide our GET routes into authenticated and unauthenticated routes, using commented section headers in our code that look like this:

```python
# -- Authenticated Routes --
```

Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes. Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`.
Some of our routes take request parameters, which we pass as keyword arguments to the route handler. These parameters should be type annotated for validation purposes.

Some parameters are shared across all authenticated or unauthenticated routes, so we define them in the `common_authenticated_parameters` and `common_unauthenticated_parameters` dependencies defined in `main.py`.

### HTML templating with Jinja2

Expand All @@ -109,7 +114,7 @@ async def welcome(request: Request):
)
```

In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{ username }}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML.
In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML.

#### Form validation strategy

Expand Down
21 changes: 20 additions & 1 deletion docs/installation.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ If you use VSCode with Docker to develop in a container, the following VSCode De

Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from View > Command Palette.

*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.*

## Install development dependencies manually

### Python and Docker
Expand Down Expand Up @@ -103,15 +105,32 @@ Set your desired database name, username, and password in the .env file.

To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file.

If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.)

## Start development database

To start the development database, run the following command in your terminal from the root directory:

``` bash
docker compose up -d
```

If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*:

``` bash
# Don't forget the -v flag to tear down the volume!
docker compose down -v
```

You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt:

``` bash
docker compose up -d --force-recreate --build
```

## Run the development server

Make sure the development database is running and tables and default permissions/roles are created first.
Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory:

``` bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
Expand Down
2 changes: 2 additions & 0 deletions index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ To use password recovery, register a [Resend](https://resend.com/) account, veri

### Start development database

To start the development database, run the following command in your terminal from the root directory:

``` bash
docker compose up -d
```
Expand Down
15 changes: 10 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException
from sqlmodel import Session
from routers import authentication, organization, role, user
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
from utils.models import User
from utils.db import get_session, set_up_db

Expand Down Expand Up @@ -37,6 +37,15 @@ async def lifespan(app: FastAPI):
# -- Exception Handling Middlewares --


# Handle AuthenticationError by redirecting to login page
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError):
return RedirectResponse(
url="/login",
status_code=status.HTTP_303_SEE_OTHER
)


# Handle NeedsNewTokens by setting new tokens and redirecting to same page
@app.exception_handler(NeedsNewTokens)
async def needs_new_tokens_handler(request: Request, exc: NeedsNewTokens):
Expand Down Expand Up @@ -104,10 +113,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
# Handle StarletteHTTPException (including 404, 405, etc.) by rendering the error page
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
# Don't handle redirects
if exc.status_code in [301, 302, 303, 307, 308]:
raise exc

return templates.TemplateResponse(
request,
"errors/error.html",
Expand Down
1 change: 0 additions & 1 deletion routers/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ class UserRead(BaseModel):
organization_id: Optional[int]
created_at: datetime
updated_at: datetime
deleted: bool


# -- Routes --
Expand Down
5 changes: 1 addition & 4 deletions routers/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ class OrganizationRead(BaseModel):
name: str
created_at: datetime
updated_at: datetime
deleted: bool


class OrganizationUpdate(BaseModel):
Expand Down Expand Up @@ -113,9 +112,7 @@ def delete_organization(
if not db_org:
raise HTTPException(status_code=404, detail="Organization not found")

db_org.deleted = True
db_org.updated_at = datetime.utcnow()
session.add(db_org)
session.delete(db_org)
session.commit()

return RedirectResponse(url="/organizations", status_code=303)
10 changes: 3 additions & 7 deletions routers/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ class RoleRead(BaseModel):
name: str
created_at: datetime
updated_at: datetime
deleted: bool
permissions: List[ValidPermissions]


Expand Down Expand Up @@ -74,7 +73,7 @@ def create_role(
@router.get("/{role_id}", response_model=RoleRead)
def read_role(role_id: int, session: Session = Depends(get_session)):
db_role: Role | None = session.get(Role, role_id)
if not db_role or not db_role.id or db_role.deleted:
if not db_role or not db_role.id:
raise HTTPException(status_code=404, detail="Role not found")

permissions = [
Expand All @@ -88,7 +87,6 @@ def read_role(role_id: int, session: Session = Depends(get_session)):
name=db_role.name,
created_at=db_role.created_at,
updated_at=db_role.updated_at,
deleted=db_role.deleted,
permissions=permissions
)

Expand All @@ -99,7 +97,7 @@ def update_role(
session: Session = Depends(get_session)
) -> RedirectResponse:
db_role: Role | None = session.get(Role, role.id)
if not db_role or not db_role.id or db_role.deleted:
if not db_role or not db_role.id:
raise HTTPException(status_code=404, detail="Role not found")
role_data = role.model_dump(exclude_unset=True)
for key, value in role_data.items():
Expand Down Expand Up @@ -131,8 +129,6 @@ def delete_role(
db_role = session.get(Role, role_id)
if not db_role:
raise HTTPException(status_code=404, detail="Role not found")
db_role.deleted = True
db_role.updated_at = utc_time()
session.add(db_role)
session.delete(db_role)
session.commit()
return RedirectResponse(url="/roles", status_code=303)
46 changes: 23 additions & 23 deletions routers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
# -- Server Request and Response Models --


class UserProfile(BaseModel):
class UpdateProfile(BaseModel):
"""Request model for updating user profile information"""
name: str
email: EmailStr
avatar_url: str
Expand Down Expand Up @@ -40,41 +41,40 @@ async def as_form(
# -- Routes --


@router.get("/profile", response_class=RedirectResponse)
async def view_profile(
current_user: User = Depends(get_authenticated_user)
):
# Render the profile page with the current user's data
return {"user": current_user}


@router.post("/edit_profile", response_class=RedirectResponse)
async def edit_profile(
name: str = Form(...),
email: str = Form(...),
avatar_url: str = Form(...),
@router.post("/update_profile", response_class=RedirectResponse)
async def update_profile(
user_profile: UpdateProfile = Depends(UpdateProfile.as_form),
current_user: User = Depends(get_authenticated_user),
session: Session = Depends(get_session)
):
# Update user details
current_user.name = name
current_user.email = email
current_user.avatar_url = avatar_url
current_user.name = user_profile.name
current_user.email = user_profile.email
current_user.avatar_url = user_profile.avatar_url
session.commit()
session.refresh(current_user)
return RedirectResponse(url="/profile", status_code=303)


@router.post("/delete_account", response_class=RedirectResponse)
async def delete_account(
confirm_delete_password: str = Form(...),
user_delete_account: UserDeleteAccount = Depends(
UserDeleteAccount.as_form),
current_user: User = Depends(get_authenticated_user),
session: Session = Depends(get_session)
):
if not verify_password(confirm_delete_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Password is incorrect")
if not verify_password(
user_delete_account.confirm_delete_password,
current_user.hashed_password
):
raise HTTPException(
status_code=400,
detail="Password is incorrect"
)

# Mark the user as deleted
current_user.deleted = True
# Delete the user
session.delete(current_user)
session.commit()
return RedirectResponse(url="/", status_code=303)

# Log out the user
return RedirectResponse(url="/auth/logout", status_code=303)
2 changes: 1 addition & 1 deletion templates/authentication/register.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~]{8,}"
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/]{8,}"
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"
placeholder="Enter your password" required
autocomplete="new-password">
Expand Down
2 changes: 1 addition & 1 deletion templates/users/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ <h1 class="mb-4">User Profile</h1>
Edit Profile
</div>
<div class="card-body">
<form action="{{ url_for('edit_profile') }}" method="post">
<form action="{{ url_for('update_profile') }}" method="post">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">
Expand Down
56 changes: 39 additions & 17 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi.testclient import TestClient
from utils.db import get_connection_url, set_up_db, tear_down_db, get_session
from utils.models import User, PasswordResetToken
from utils.auth import get_password_hash
from utils.auth import get_password_hash, create_access_token, create_refresh_token
from main import app

load_dotenv()
Expand Down Expand Up @@ -54,22 +54,6 @@ def clean_db(session: Session):
session.commit()


# Test client fixture
@pytest.fixture()
def client(session: Session):
"""
Provides a TestClient instance with the session fixture.
Overrides the get_session dependency to use the test session.
"""
def get_session_override():
return session

app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
yield client
app.dependency_overrides.clear()


# Test user fixture
@pytest.fixture()
def test_user(session: Session):
Expand All @@ -85,3 +69,41 @@ def test_user(session: Session):
session.commit()
session.refresh(user)
return user


# Unauthenticated client fixture
@pytest.fixture()
def unauth_client(session: Session):
"""
Provides a TestClient instance without authentication.
"""
def get_session_override():
return session

app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
yield client
app.dependency_overrides.clear()


# Authenticated client fixture
@pytest.fixture()
def auth_client(session: Session, test_user: User):
"""
Provides a TestClient instance with valid authentication tokens.
"""
def get_session_override():
return session

app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)

# Create and set valid tokens
access_token = create_access_token({"sub": test_user.email})
refresh_token = create_refresh_token({"sub": test_user.email})

client.cookies.set("access_token", access_token)
client.cookies.set("refresh_token", refresh_token)

yield client
app.dependency_overrides.clear()
Loading