Skip to content

Commit 2bd854e

Browse files
2 parents 5522fac + 49b4468 commit 2bd854e

File tree

17 files changed

+348
-123
lines changed

17 files changed

+348
-123
lines changed

.cursorrules

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Project Architecture
2+
- Keep GET routes in main.py and POST routes in routers/ directory
3+
- Name GET routes using read_<name> convention
4+
- Follow Post-Redirect-Get (PRG) pattern for all form submissions
5+
- Use Jinja2 HTML templates for server-side rendering and minimize client-side JavaScript
6+
- Use forms for all POST routes
7+
- Validate form data comprehensively on the client side as first line of defense, with server-side Pydantic validation as fallback
8+
9+
# File Structure
10+
- main.py: Application entry point and GET routes
11+
- routers/: POST route modules
12+
- templates/: Jinja2 templates
13+
- static/: Static assets
14+
- tests/: Unit tests
15+
- utils/: Helper functions and models
16+
- docker-compose.yml: Test database configuration
17+
- .env: Environment variables
18+
19+
# Python/FastAPI Guidelines
20+
- For all POST routes, define request models in a separate section at the top of the router file
21+
- Implement as_form() classmethod for all form-handling request models
22+
- Use Pydantic for request/response models with @field_validator and custom exceptions for custom form validation
23+
- Use middleware defined in main.py for centralized exception handling
24+
- Add type hints to all function signatures and variables
25+
- Follow mypy type checking standards rigorously
26+
27+
# Form Validation Strategy
28+
- Implement thorough client-side validation via HTML pattern attributes where possible and Javascript otherwise
29+
- Use Pydantic models with custom validators as server-side fallback
30+
- Handle validation errors through middleware exception handlers
31+
- Render validation_error.html template for failed server-side validation
32+
33+
# Database Operations
34+
- Use SQLModel for all database interactions
35+
- Use get_session() from utils/db.py for database connections
36+
- Define database relational models explicitly in utils/models.py
37+
- Inject database session as dependency in route handlers
38+
39+
# Authentication System
40+
- JWT-based token authentication with separate access/refresh tokens and bcrypt for password hashing are defined in utils/auth.py
41+
- Password and email reset tokens with expiration and password reset email flow powered by Resend are defined in utils/auth.py
42+
- HTTP-only cookies are implemented with secure flag and SameSite=strict
43+
- Inject common_authenticated_parameters as a dependency in all authenticated GET routes
44+
- Inject common_unauthenticated_parameters as a dependency in all unauthenticated GET routes
45+
- Inject get_session as a dependency in all POST routes
46+
- Handle security-related errors without leaking information
47+
48+
# Testing
49+
- Run mypy type checking before committing code
50+
- Write comprehensive unit tests using pytest
51+
- Test both success and error cases
52+
- Use test fixtures from tests/conftest.py: engine, session, client, test_user
53+
- set_up_database and clean_db fixtures are autoused by pytest to ensure clean database state
54+
55+
# Error Handling
56+
- Use middleware for centralized exception handling
57+
- Define custom exception classes for specific error cases
58+
- Return appropriate HTTP status codes and error messages
59+
- Render error templates with context data
60+
- Log errors with "uvicorn.error" logger
61+
62+
# Template Structure
63+
- Extend base.html for consistent layout
64+
- Use block tags for content sections
65+
- Include reusable components
66+
- Pass request object and context data to all templates
67+
- Keep form validation logic in corresponding templates
68+
- Use Bootstrap for styling
69+
70+
# Contributing Guidelines
71+
- Follow existing code style and patterns
72+
- Preserve existing comments and docstrings
73+
- Ensure all tests pass before submitting PR
74+
- Update .qmd documentation files for significant changes
75+
- Use Poetry for dependency management

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

@@ -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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ class OrganizationRead(BaseModel):
2727
name: str
2828
created_at: datetime
2929
updated_at: datetime
30-
deleted: bool
3130

3231

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

116-
db_org.deleted = True
117-
db_org.updated_at = datetime.utcnow()
118-
session.add(db_org)
115+
session.delete(db_org)
119116
session.commit()
120117

121118
return RedirectResponse(url="/organizations", status_code=303)

routers/role.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ class RoleRead(BaseModel):
3131
name: str
3232
created_at: datetime
3333
updated_at: datetime
34-
deleted: bool
3534
permissions: List[ValidPermissions]
3635

3736

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

8079
permissions = [
@@ -88,7 +87,6 @@ def read_role(role_id: int, session: Session = Depends(get_session)):
8887
name=db_role.name,
8988
created_at=db_role.created_at,
9089
updated_at=db_role.updated_at,
91-
deleted=db_role.deleted,
9290
permissions=permissions
9391
)
9492

@@ -99,7 +97,7 @@ def update_role(
9997
session: Session = Depends(get_session)
10098
) -> RedirectResponse:
10199
db_role: Role | None = session.get(Role, role.id)
102-
if not db_role or not db_role.id or db_role.deleted:
100+
if not db_role or not db_role.id:
103101
raise HTTPException(status_code=404, detail="Role not found")
104102
role_data = role.model_dump(exclude_unset=True)
105103
for key, value in role_data.items():
@@ -131,8 +129,6 @@ def delete_role(
131129
db_role = session.get(Role, role_id)
132130
if not db_role:
133131
raise HTTPException(status_code=404, detail="Role not found")
134-
db_role.deleted = True
135-
db_role.updated_at = utc_time()
136-
session.add(db_role)
132+
session.delete(db_role)
137133
session.commit()
138134
return RedirectResponse(url="/roles", 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">

0 commit comments

Comments
 (0)