Skip to content

Commit ad71d96

Browse files
Merge branch 'main' into 1-finish-implementing-roleorg-system
2 parents 4542f23 + 2bd854e commit ad71d96

File tree

17 files changed

+281
-143
lines changed

17 files changed

+281
-143
lines changed

docs/customization.qmd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes
2525
- `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.
2626
- `session`: Provides a session for database operations in tests.
2727
- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken` and `User` tables.
28-
- `client`: Provides a `TestClient` instance with the session fixture, overriding the `get_session` dependency to use the test session.
28+
- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture.
29+
- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture.
2930
- `test_user`: Creates a test user in the database with a predefined name, email, and hashed password.
3031

3132
To run the tests, use these commands:

docs/installation.qmd

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ If you use VSCode with Docker to develop in a container, the following VSCode De
2020

2121
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.
2222

23+
*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.*
24+
2325
## Install development dependencies manually
2426

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

104106
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.
105107

108+
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.)
109+
106110
## Start development database
107111

112+
To start the development database, run the following command in your terminal from the root directory:
113+
108114
``` bash
109115
docker compose up -d
110116
```
111117

118+
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*:
119+
120+
``` bash
121+
# Don't forget the -v flag to tear down the volume!
122+
docker compose down -v
123+
```
124+
125+
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:
126+
127+
``` bash
128+
docker compose up -d --force-recreate --build
129+
```
130+
112131
## Run the development server
113132

114-
Make sure the development database is running and tables and default permissions/roles are created first.
133+
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:
115134

116135
``` bash
117136
uvicorn main:app --host 0.0.0.0 --port 8000 --reload

index.qmd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ To use password recovery, register a [Resend](https://resend.com/) account, veri
107107

108108
### Start development database
109109

110+
To start the development database, run the following command in your terminal from the root directory:
111+
110112
``` bash
111113
docker compose up -d
112114
```

main.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException
99
from sqlmodel import Session
1010
from routers import authentication, organization, role, user
11-
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError
11+
from utils.auth import get_authenticated_user, get_optional_user, NeedsNewTokens, get_user_from_reset_token, PasswordValidationError, AuthenticationError
1212
from utils.models import User
1313
from utils.db import get_session, set_up_db
1414
from utils.role_org import get_user_organizations, get_organization_roles
@@ -37,6 +37,15 @@ async def lifespan(app: FastAPI):
3737
# -- Exception Handling Middlewares --
3838

3939

40+
# Handle AuthenticationError by redirecting to login page
41+
@app.exception_handler(AuthenticationError)
42+
async def authentication_error_handler(request: Request, exc: AuthenticationError):
43+
return RedirectResponse(
44+
url="/login",
45+
status_code=status.HTTP_303_SEE_OTHER
46+
)
47+
48+
4049
# Handle NeedsNewTokens by setting new tokens and redirecting to same page
4150
@app.exception_handler(NeedsNewTokens)
4251
async def needs_new_tokens_handler(request: Request, exc: NeedsNewTokens):
@@ -104,10 +113,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
104113
# Handle StarletteHTTPException (including 404, 405, etc.) by rendering the error page
105114
@app.exception_handler(StarletteHTTPException)
106115
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
107-
# Don't handle redirects
108-
if exc.status_code in [301, 302, 303, 307, 308]:
109-
raise exc
110-
111116
return templates.TemplateResponse(
112117
request,
113118
"errors/error.html",

routers/authentication.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ class UserRead(BaseModel):
114114
organization_id: Optional[int]
115115
created_at: datetime
116116
updated_at: datetime
117-
deleted: bool
118117

119118

120119
# -- Routes --

routers/organization.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@ class OrganizationRead(BaseModel):
8383
name: str
8484
created_at: datetime
8585
updated_at: datetime
86-
deleted: bool
8786

8887

8988
class OrganizationUpdate(BaseModel):
@@ -188,12 +187,7 @@ def delete_organization(
188187
# This will raise appropriate exceptions if org doesn't exist or user lacks access
189188
organization = get_organization(org_id, user.id, session)
190189

191-
if not check_user_permission(user.id, org_id, ValidPermissions.DELETE_ORGANIZATION, session):
192-
raise InsufficientPermissionsError()
193-
194-
organization.deleted = True
195-
organization.updated_at = utc_time()
196-
session.add(organization)
190+
session.delete(organization)
197191
session.commit()
198192

199193
return RedirectResponse(url="/profile", status_code=303)

routers/role.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self):
2525

2626

2727
class RoleNotFoundError(HTTPException):
28-
"""Raised when a requested role does not exist or is deleted"""
28+
"""Raised when a requested role does not exist"""
2929

3030
def __init__(self):
3131
super().__init__(status_code=404, detail="Role not found")
@@ -70,7 +70,6 @@ class RoleRead(BaseModel):
7070
name: str
7171
created_at: datetime
7272
updated_at: datetime
73-
deleted: bool
7473
permissions: List[ValidPermissions]
7574

7675

@@ -87,7 +86,7 @@ def validate_role_exists(cls, id: int, info):
8786
session = info.context.get("session")
8887
if session:
8988
role = session.get(Role, id)
90-
if not role or not role.id or role.deleted:
89+
if not role or not role.id:
9190
raise RoleNotFoundError()
9291
return id
9392

@@ -141,7 +140,6 @@ def update_role(
141140
session: Session = Depends(get_session)
142141
) -> RedirectResponse:
143142
db_role: Role | None = session.get(Role, role.id)
144-
145143
role_data = role.model_dump(exclude_unset=True)
146144
for key, value in role_data.items():
147145
setattr(db_role, key, value)
@@ -164,6 +162,7 @@ def update_role(
164162
return RedirectResponse(url="/profile", status_code=303)
165163

166164

165+
# TODO: Reject role deletion if anyone in the organization has that role
167166
@router.delete("/{role_id}", response_class=RedirectResponse)
168167
def delete_role(
169168
role_id: int,
@@ -172,10 +171,7 @@ def delete_role(
172171
) -> RedirectResponse:
173172
db_role = session.get(Role, role_id)
174173
if not db_role:
175-
raise RoleNotFoundError()
176-
177-
db_role.deleted = True
178-
db_role.updated_at = utc_time()
179-
session.add(db_role)
174+
raise HTTPException(status_code=404, detail="Role not found")
175+
session.delete(db_role)
180176
session.commit()
181177
return RedirectResponse(url="/profile", status_code=303)

routers/user.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
# -- Server Request and Response Models --
1212

1313

14-
class UserProfile(BaseModel):
14+
class UpdateProfile(BaseModel):
15+
"""Request model for updating user profile information"""
1516
name: str
1617
email: EmailStr
1718
avatar_url: str
@@ -40,41 +41,40 @@ async def as_form(
4041
# -- Routes --
4142

4243

43-
@router.get("/profile", response_class=RedirectResponse)
44-
async def view_profile(
45-
current_user: User = Depends(get_authenticated_user)
46-
):
47-
# Render the profile page with the current user's data
48-
return {"user": current_user}
49-
50-
51-
@router.post("/edit_profile", response_class=RedirectResponse)
52-
async def edit_profile(
53-
name: str = Form(...),
54-
email: str = Form(...),
55-
avatar_url: str = Form(...),
44+
@router.post("/update_profile", response_class=RedirectResponse)
45+
async def update_profile(
46+
user_profile: UpdateProfile = Depends(UpdateProfile.as_form),
5647
current_user: User = Depends(get_authenticated_user),
5748
session: Session = Depends(get_session)
5849
):
5950
# Update user details
60-
current_user.name = name
61-
current_user.email = email
62-
current_user.avatar_url = avatar_url
51+
current_user.name = user_profile.name
52+
current_user.email = user_profile.email
53+
current_user.avatar_url = user_profile.avatar_url
6354
session.commit()
6455
session.refresh(current_user)
6556
return RedirectResponse(url="/profile", status_code=303)
6657

6758

6859
@router.post("/delete_account", response_class=RedirectResponse)
6960
async def delete_account(
70-
confirm_delete_password: str = Form(...),
61+
user_delete_account: UserDeleteAccount = Depends(
62+
UserDeleteAccount.as_form),
7163
current_user: User = Depends(get_authenticated_user),
7264
session: Session = Depends(get_session)
7365
):
74-
if not verify_password(confirm_delete_password, current_user.hashed_password):
75-
raise HTTPException(status_code=400, detail="Password is incorrect")
66+
if not verify_password(
67+
user_delete_account.confirm_delete_password,
68+
current_user.hashed_password
69+
):
70+
raise HTTPException(
71+
status_code=400,
72+
detail="Password is incorrect"
73+
)
7674

77-
# Mark the user as deleted
78-
current_user.deleted = True
75+
# Delete the user
76+
session.delete(current_user)
7977
session.commit()
80-
return RedirectResponse(url="/", status_code=303)
78+
79+
# Log out the user
80+
return RedirectResponse(url="/auth/logout", status_code=303)

templates/authentication/register.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
<div class="mb-3">
2626
<label for="password" class="form-label">Password</label>
2727
<input type="password" class="form-control" id="password" name="password"
28-
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~]{8,}"
28+
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/])[A-Za-z\d@$!%*?&\{\}\<\>\.\,\\\\'#\-_=\+\(\)\[\]:;\|~\/]{8,}"
2929
title="Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters"
3030
placeholder="Enter your password" required
3131
autocomplete="new-password">

templates/users/profile.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ <h1 class="mb-4">User Profile</h1>
3535
Edit Profile
3636
</div>
3737
<div class="card-body">
38-
<form action="{{ url_for('edit_profile') }}" method="post">
38+
<form action="{{ url_for('update_profile') }}" method="post">
3939
<div class="mb-3">
4040
<label for="name" class="form-label">Name</label>
4141
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}">

0 commit comments

Comments
 (0)