diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5b3fb97..3b188a1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,14 +62,14 @@ jobs: - name: Set required env variables run: | - echo "DB_USER=postgres" >> $GITHUB_ENV - echo "DB_PASSWORD=postgres" >> $GITHUB_ENV - echo "DB_HOST=127.0.0.1" >> $GITHUB_ENV - echo "DB_PORT=5432" >> $GITHUB_ENV - echo "DB_NAME=test_db" >> $GITHUB_ENV - echo "SECRET_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - echo "BASE_URL=http://localhost:8000" >> $GITHUB_ENV - echo "RESEND_API_KEY=resend_api_key" >> $GITHUB_ENV + echo "DB_USER=postgres" > _environment + echo "DB_PASSWORD=postgres" >> _environment + echo "DB_HOST=127.0.0.1" >> _environment + echo "DB_PORT=5432" >> _environment + echo "DB_NAME=test_db" >> _environment + echo "SECRET_KEY=$(openssl rand -base64 32)" >> _environment + echo "BASE_URL=http://localhost:8000" >> _environment + echo "RESEND_API_KEY=resend_api_key" >> _environment - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 diff --git a/.gitignore b/.gitignore index 62a605a..eeb38e2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ __pycache__ *.pyc .env +_environment /.quarto/ _docs/ +*.ipynb .pytest_cache/ .mypy_cache/ node_modules @@ -11,3 +13,6 @@ package-lock.json package.json .specstory .cursorrules +.cursor +repomix-output.txt +artifacts/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md index 3295390..27efd63 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Make sure the development database is running and tables and default permissions/roles are created first. ``` bash -uvicorn main:app --host 0.0.0.0 --port 8000 --reload +uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` Navigate to http://localhost:8000/ @@ -172,7 +172,7 @@ Navigate to http://localhost:8000/ ### Lint types with mypy ``` bash -mypy . +uv run mypy . ``` ## Developing with LLMs diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..075b254 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +/.quarto/ diff --git a/docs/architecture.qmd b/docs/architecture.qmd index 1079e53..a5d897b 100644 --- a/docs/architecture.qmd +++ b/docs/architecture.qmd @@ -25,8 +25,8 @@ with dot.subgraph(name='cluster_client') as client: # Create server subgraph below with dot.subgraph(name='cluster_server') as server: server.attr(label='Server') - server.node('C', 'Convert to Pydantic model', fillcolor='lightgreen', style='rounded,filled') - server.node('D', 'Optional custom validation', fillcolor='lightgreen', style='rounded,filled') + server.node('C', 'FastAPI request validation in route signature', fillcolor='lightgreen', style='rounded,filled') + server.node('D', 'Business logic validation in route function body', fillcolor='lightgreen', style='rounded,filled') server.node('E', 'Update database', fillcolor='lightgreen', style='rounded,filled') server.node('F', 'Middleware error handler', fillcolor='lightgreen', style='rounded,filled') server.node('G', 'Render error template', fillcolor='lightgreen', style='rounded,filled') @@ -59,103 +59,6 @@ dot.render('static/data_flow', format='png', cleanup=True) ![Data flow diagram](static/data_flow.png) -The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. +The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. One disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. Another disadvantage is that it if the user makes an invalid form submission, they will see an error page and will have to click the browser's "back" button to get back to the form with their original form inputs. -## Form validation flow - -We've experimented with several approaches to validating form inputs in the FastAPI endpoints. - -### Objectives - -Ideally, on an invalid input, we would redirect the user back to the form, preserving their inputs and displaying an error message about which input was invalid. - -This would keep the error handling consistent with the PRG pattern described in the [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture) section of this documentation. - -To keep the code DRY, we'd also like to handle such validation with Pydantic dependencies, Python exceptions, and exception-handling middleware as much as possible. - -### Obstacles - -One challenge is that if we redirect back to the page with the form, the page is re-rendered with empty form fields. - -This can be overcome by passing the inputs from the request as context variables to the template. - -But that's a bit clunky, because then we have to support form-specific context variables in every form page and corresponding GET endpoint. - -Also, we have to: - -1. access the request object (which is not by default available to our middleware), and -2. extract the form inputs (at least one of which is invalid in this error case), and -3. pass the form inputs to the template (which is a bit challenging to do in a DRY way since there are different sets of form inputs for different forms). - -Solving these challenges is possible, but gets high-complexity pretty quickly. - -### Approaches - -The best solution, I think, is to use really robust client-side form validation to prevent invalid inputs from being sent to the server in the first place. That makes it less important what we do on the server side, although we still need to handle the server-side error case as a backup in the event that something slips past our validation on the client side. - -Here are some patterns we've considered for server-side error handling: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ApproachReturns to same pagePreserves form inputsFollows PRG patternComplexity
Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" buttonNoYesYesLow
Validate in FastAPI endpoint function body, redirect to origin page with error message query paramYesNoYesMedium
Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level JavascriptYesYesNoLow
- -Presently this template primarily uses option 1 but also supports option 2. Ultimately, I think option 5 will be preferable; support for that [is planned](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/issues/5) for a future update or fork of this template. \ No newline at end of file +A future iteration of this application will use HTMX to update the page in place, so that on an invalid submission an error toast is displayed without a page reload (thus preserving the user's scroll position and form inputs). \ No newline at end of file diff --git a/docs/contributing.qmd b/docs/contributing.qmd index 7489aa5..57f30a4 100644 --- a/docs/contributing.qmd +++ b/docs/contributing.qmd @@ -43,13 +43,17 @@ To contribute code to the project: ### Rendering the documentation -The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you ,make changes to the `.qmd` files in the root folder and the `docs` folder, run the following commands to re-render the docs: +The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you make changes to the `.qmd` files in the root folder and the `docs` folder, you will need to re-render the docs with Quarto. + +Quarto expects environment variables to be set in a file called `_environment`, so before running Quarto render commands, you should copy your `.env` file to `_environment`. ``` bash +# To copy the .env file to _environment +cp .env _environment # To render the documentation website -quarto render +uv run quarto render # To render the README -quarto render index.qmd --output-dir . --output README.md --to gfm +uv run quarto render index.qmd --output-dir . --output README.md --to gfm ``` Due to a quirk of Quarto, an unnecessary `index.html` file is created in the root folder when the README is rendered. This file can be safely deleted. @@ -74,5 +78,5 @@ When creating new features, To publish the documentation to GitHub Pages, run the following command: ``` bash -quarto publish gh-pages +uv run quarto publish gh-pages ``` diff --git a/docs/customization.qmd b/docs/customization.qmd index 0f7e7de..d9adf6f 100644 --- a/docs/customization.qmd +++ b/docs/customization.qmd @@ -31,17 +31,19 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes - `engine`: Creates a new SQLModel engine for the test database. - `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. +- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken`, `EmailUpdateToken`, `User`, `Role`, `Organization`, and `Account` tables. +- `test_account`: Creates a test account with a predefined email and hashed password. +- `test_user`: Creates a test user in the database linked to the test account. - `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. +- `test_organization`: Creates a test organization for use in tests. To run the tests, use these commands: -- Run all tests: `pytest` -- Run tests in debug mode (includes logs and print statements in console output): `pytest -s` -- Run particular test files by name: `pytest ` -- Run particular tests by name: `pytest -k ` +- Run all tests: `uv run pytest` +- Run tests in debug mode (includes logs and print statements in console output): `uv run pytest -s` +- Run particular test files by name: `uv run pytest ` +- Run particular tests by name: `uv run pytest -k ` ### Type checking with mypy @@ -71,12 +73,14 @@ We also create POST endpoints, which accept form submissions so the user can cre #### Customizable folders and files -- FastAPI application entry point and GET routes: `main.py` -- FastAPI POST routes: `routers/` - - User authentication endpoints: `authentication.py` +- FastAPI application entry point and homepage GET route: `main.py` +- FastAPI routes: `routers/` + - Account and authentication endpoints: `account.py` - User profile management endpoints: `user.py` - Organization management endpoints: `organization.py` - Role management endpoints: `role.py` + - Dashboard page: `dashboard.py` + - Static pages (e.g., about, privacy policy, terms of service): `static_pages.py` - Jinja2 templates: `templates/` - Static assets: `static/` - Unit tests: `tests/` @@ -84,13 +88,19 @@ We also create POST endpoints, which accept form submissions so the user can cre - Helper functions: `utils/` - Auth helpers: `auth.py` - Database helpers: `db.py` - - Database models: `models.py` + - FastAPI dependencies: `dependencies.py` + - Enums: `enums.py` - Image helpers: `images.py` + - Database models: `models.py` +- Exceptions: `exceptions/` + - HTTP exceptions: `http_exceptions.py` + - Other custom exceptions: `exceptions.py` - Environment variables: `.env.example` - CI/CD configuration: `.github/` - Project configuration: `pyproject.toml` - Quarto documentation: - - Source: `index.qmd` + `docs/` + - README source: `index.qmd` + - Website source: `index.qmd` + `docs/` - Configuration: `_quarto.yml` Most everything else is auto-generated and should not be manually modified. @@ -99,19 +109,11 @@ Most everything else is auto-generated and should not be manually modified. ### Code conventions -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_`, where `` 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 --- -``` +The GET route for the homepage is defined in the main entry point for the application, `main.py`. The entrypoint imports router modules from the `routers/` directory, which contain the other GET and POST routes for the application. In CRUD style, the router modules are named after the resource they manage, e.g., `account.py` for account management. -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. +We name our GET routes using the convention `read_`, where `` is the name of the resource, to indicate that they are read-only endpoints that do not modify the database. In POST routes that modify the database, you can use the `get_session` dependency as an argument to get a database session. -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`. +Routes that require authentication generally take the `get_authenticated_account` dependency as an argument. Unauthenticated GET routes generally take the `get_optional_user` dependency as an argument. If a route should *only* be seen by authenticated users (i.e., a login page), you can redirect to the dashboard if `get_optional_user` returns a `User` object. ### Context variables @@ -121,6 +123,7 @@ Context refers to Python variables passed to a template to populate the HTML. In @app.get("/welcome") async def welcome(request: Request): return templates.TemplateResponse( + request, "welcome.html", {"username": "Alice"} ) @@ -144,74 +147,6 @@ Pydantic is used for data validation and serialization. It ensures that the data If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. -For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler. - -#### Defining request models and custom validators - -For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`: - -```python -class PasswordMismatchError(HTTPException): - def __init__(self, field: str = "confirm_password"): - super().__init__( - status_code=422, - detail={ - "field": field, - "message": "The passwords you entered do not match" - } - ) - -class UserRegister(BaseModel): - name: str - email: EmailStr - password: str - confirm_password: str - - # Custom validators are added as class attributes - @field_validator("confirm_password", check_fields=False) - def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str: - if v != values["password"]: - raise PasswordMismatchError() - return v - # ... -``` - -We then add this request model as a dependency in the signature of our POST route: - -```python -@app.post("/register") -async def register(request: UserRegister = Depends()): - # ... -``` - -When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware. - -(Note that these examples are simplified versions of the actual code.) - -#### Converting form data to request models - -In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example: - -```python -class UserRegister(BaseModel): - # ... - - @classmethod - async def as_form( - cls, - name: str = Form(...), - email: EmailStr = Form(...), - password: str = Form(...), - confirm_password: str = Form(...) - ): - return cls( - name=name, - email=email, - password=password, - confirm_password=confirm_password - ) -``` - ### Middleware exception handling Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. @@ -223,8 +158,8 @@ Middleware functions are decorated with `@app.exception_handler(ExceptionType)` Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details: ```python -@app.exception_handler(PasswordMismatchError) -async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError): +@app.exception_handler(PasswordValidationError) +async def password_validation_exception_handler(request: Request, exc: PasswordValidationError): return templates.TemplateResponse( request, "errors/validation_error.html", @@ -244,12 +179,13 @@ SQLModel is an Object-Relational Mapping (ORM) library that allows us to interac Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: +- `Account`: Represents a user account with email and password hash +- `User`: Represents a user profile with details like name and avatar; the email and password hash are stored in the related `Account` model - `Organization`: Represents a company or team -- `User`: Represents a user account with name, email, and avatar - `Role`: Represents a set of permissions within an organization - `Permission`: Represents specific actions a user can perform (defined by ValidPermissions enum) - `PasswordResetToken`: Manages password reset functionality with expiration -- `UserPassword`: Stores hashed user passwords separately from user data +- `EmailUpdateToken`: Manages email update confirmation functionality with expiration Two additional models are used by SQLModel to manage many-to-many relationships; you generally will not need to interact with them directly: diff --git a/docs/static/data_flow.png b/docs/static/data_flow.png index 5324b86..9fd78ce 100644 Binary files a/docs/static/data_flow.png and b/docs/static/data_flow.png differ diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index 15713bb..aa2c30c 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -82,7 +82,7 @@ Install Docker Desktop and Coker Compose for your operating system by following For Ubuntu/Debian: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev ``` For macOS: @@ -129,7 +129,7 @@ docker compose up -d Make sure the development database is running and tables and default permissions/roles are created first. ``` bash -uvicorn main:app --host 0.0.0.0 --port 8000 --reload +uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` Navigate to http://localhost:8000/ @@ -137,7 +137,7 @@ Navigate to http://localhost:8000/ ### Lint types with mypy ``` bash -mypy . +uv run mypy . ``` ## Developing with LLMs @@ -245,8 +245,8 @@ with dot.subgraph(name='cluster_client') as client: # Create server subgraph below with dot.subgraph(name='cluster_server') as server: server.attr(label='Server') - server.node('C', 'Convert to Pydantic model', fillcolor='lightgreen', style='rounded,filled') - server.node('D', 'Optional custom validation', fillcolor='lightgreen', style='rounded,filled') + server.node('C', 'FastAPI request validation in route signature', fillcolor='lightgreen', style='rounded,filled') + server.node('D', 'Business logic validation in route function body', fillcolor='lightgreen', style='rounded,filled') server.node('E', 'Update database', fillcolor='lightgreen', style='rounded,filled') server.node('F', 'Middleware error handler', fillcolor='lightgreen', style='rounded,filled') server.node('G', 'Render error template', fillcolor='lightgreen', style='rounded,filled') @@ -279,106 +279,9 @@ dot.render('static/data_flow', format='png', cleanup=True) ![Data flow diagram](static/data_flow.png) -The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. The disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. - -## Form validation flow - -We've experimented with several approaches to validating form inputs in the FastAPI endpoints. - -### Objectives - -Ideally, on an invalid input, we would redirect the user back to the form, preserving their inputs and displaying an error message about which input was invalid. - -This would keep the error handling consistent with the PRG pattern described in the [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture) section of this documentation. - -To keep the code DRY, we'd also like to handle such validation with Pydantic dependencies, Python exceptions, and exception-handling middleware as much as possible. - -### Obstacles - -One challenge is that if we redirect back to the page with the form, the page is re-rendered with empty form fields. - -This can be overcome by passing the inputs from the request as context variables to the template. - -But that's a bit clunky, because then we have to support form-specific context variables in every form page and corresponding GET endpoint. - -Also, we have to: - -1. access the request object (which is not by default available to our middleware), and -2. extract the form inputs (at least one of which is invalid in this error case), and -3. pass the form inputs to the template (which is a bit challenging to do in a DRY way since there are different sets of form inputs for different forms). +The advantage of the PRG pattern is that it is very straightforward to implement and keeps most of the rendering logic on the server side. One disadvantage is that it requires an extra round trip to the database to fetch the updated data, and re-rendering the entire page template may be less efficient than a partial page update on the client side. Another disadvantage is that it if the user makes an invalid form submission, they will see an error page and will have to click the browser's "back" button to get back to the form with their original form inputs. -Solving these challenges is possible, but gets high-complexity pretty quickly. - -### Approaches - -The best solution, I think, is to use really robust client-side form validation to prevent invalid inputs from being sent to the server in the first place. That makes it less important what we do on the server side, although we still need to handle the server-side error case as a backup in the event that something slips past our validation on the client side. - -Here are some patterns we've considered for server-side error handling: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ApproachReturns to same pagePreserves form inputsFollows PRG patternComplexity
Validate with Pydantic dependency, catch and redirect from middleware (with exception message as context) to an error page with "go back" buttonNoYesYesLow
Validate in FastAPI endpoint function body, redirect to origin page with error message query paramYesNoYesMedium
Validate in FastAPI endpoint function body, redirect to origin page with error message query param and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
Validate with Pydantic dependency, use session context to get form inputs from request, redirect to origin page from middleware with exception message and form inputs as context so we can re-render page with original form inputsYesYesYesHigh
Validate in either Pydantic dependency or function endpoint body and directly return error message or error toast HTML partial in JSON, then mount error toast with HTMX or some simple layout-level JavascriptYesYesNoLow
- -Presently this template primarily uses option 1 but also supports option 2. Ultimately, I think option 5 will be preferable; support for that [is planned](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/issues/5) for a future update or fork of this template. +A future iteration of this application will use HTMX to update the page in place, so that on an invalid submission an error toast is displayed without a page reload (thus preserving the user's scroll position and form inputs). # Authentication @@ -509,15 +412,15 @@ reset.render('static/reset_flow', format='png', cleanup=True) # Installation -## Install all dependencies in a VSCode Dev Container +## Install all development dependencies in a VSCode Dev Container -If you use VSCode with Docker to develop in a container, the following VSCode Dev Container configuration will install all dependencies: +If you use VSCode with Docker to develop in a container, the following VSCode Dev Container configuration will install all development dependencies: ``` json { "name": "Python 3", "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz && uv venv && uv sync", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz libwebp-dev && npm install bootstrap@5.3.3 && npm install -g sass && npm install -g gulp && uv venv && uv sync", "features": { "ghcr.io/va-h/devcontainers-features/uv:1": { "version": "latest" @@ -568,7 +471,7 @@ Install Docker Desktop and Docker Compose for your operating system by following For Ubuntu/Debian: ``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev +sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev ``` For macOS: @@ -672,7 +575,9 @@ Before running the development server, make sure the development database is run uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` -Navigate to http://localhost:8000/ +Navigate to http://localhost:8000/. + +(Note: If startup fails with a sqlalchemy/psycopg2 connection error, make sure that Docker Desktop and the database service are running and that the environment variables in the `.env` file are correctly populated, and then try again.) ## Lint types with mypy @@ -712,17 +617,19 @@ The following fixtures, defined in `tests/conftest.py`, are available in the tes - `engine`: Creates a new SQLModel engine for the test database. - `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. +- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken`, `EmailUpdateToken`, `User`, `Role`, `Organization`, and `Account` tables. +- `test_account`: Creates a test account with a predefined email and hashed password. +- `test_user`: Creates a test user in the database linked to the test account. - `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. +- `test_organization`: Creates a test organization for use in tests. To run the tests, use these commands: -- Run all tests: `pytest` -- Run tests in debug mode (includes logs and print statements in console output): `pytest -s` -- Run particular test files by name: `pytest ` -- Run particular tests by name: `pytest -k ` +- Run all tests: `uv run pytest` +- Run tests in debug mode (includes logs and print statements in console output): `uv run pytest -s` +- Run particular test files by name: `uv run pytest ` +- Run particular tests by name: `uv run pytest -k ` ### Type checking with mypy @@ -742,16 +649,24 @@ One use case for this file, if using the Cursor IDE, is to rename it to `.cursor We have also exposed the full Markdown-formatted project documentation as a [single text file](static/documentation.txt) for easy downloading and embedding for RAG workflows. -## Project structure +## Application architecture + +### Post-Redirect-Get pattern -### Customizable folders and files +In this template, we use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. + +We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) -- FastAPI application entry point and GET routes: `main.py` -- FastAPI POST routes: `routers/` - - User authentication endpoints: `auth.py` +#### Customizable folders and files + +- FastAPI application entry point and homepage GET route: `main.py` +- FastAPI routes: `routers/` + - Account and authentication endpoints: `account.py` - User profile management endpoints: `user.py` - Organization management endpoints: `organization.py` - Role management endpoints: `role.py` + - Dashboard page: `dashboard.py` + - Static pages (e.g., about, privacy policy, terms of service): `static_pages.py` - Jinja2 templates: `templates/` - Static assets: `static/` - Unit tests: `tests/` @@ -759,45 +674,34 @@ We have also exposed the full Markdown-formatted project documentation as a [sin - Helper functions: `utils/` - Auth helpers: `auth.py` - Database helpers: `db.py` + - FastAPI dependencies: `dependencies.py` + - Enums: `enums.py` + - Image helpers: `images.py` - Database models: `models.py` -- Environment variables: `.env` +- Exceptions: `exceptions/` + - HTTP exceptions: `http_exceptions.py` + - Other custom exceptions: `exceptions.py` +- Environment variables: `.env.example` - CI/CD configuration: `.github/` - Project configuration: `pyproject.toml` - Quarto documentation: - - Source: `index.qmd` + `docs/` + - README source: `index.qmd` + - Website source: `index.qmd` + `docs/` - Configuration: `_quarto.yml` Most everything else is auto-generated and should not be manually modified. -### Defining a web backend with FastAPI - -We use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. - -We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template follows the Post-Redirect-Get (PRG) pattern to handle POST requests. When a form is submitted, the server processes the data and then returns a "redirect" response, which sends the user to a GET endpoint to re-render the page with the updated data. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) - -#### 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_`, where `` 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 --- -``` +## Backend -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. +### Code conventions -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`. +The GET route for the homepage is defined in the main entry point for the application, `main.py`. The entrypoint imports router modules from the `routers/` directory, which contain the other GET and POST routes for the application. In CRUD style, the router modules are named after the resource they manage, e.g., `account.py` for account management. -### HTML templating with Jinja2 +We name our GET routes using the convention `read_`, where `` is the name of the resource, to indicate that they are read-only endpoints that do not modify the database. In POST routes that modify the database, you can use the `get_session` dependency as an argument to get a database session. -To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. +Routes that require authentication generally take the `get_authenticated_account` dependency as an argument. Unauthenticated GET routes generally take the `get_optional_user` dependency as an argument. If a route should *only* be seen by authenticated users (i.e., a login page), you can redirect to the dashboard if `get_optional_user` returns a `User` object. -With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. - -#### Context variables +### Context variables Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example: @@ -805,6 +709,7 @@ Context refers to Python variables passed to a template to populate the HTML. In @app.get("/welcome") async def welcome(request: Request): return templates.TemplateResponse( + request, "welcome.html", {"username": "Alice"} ) @@ -812,19 +717,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. -#### Form validation strategy - -While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. If users ever see the `validation_error.html` template, it indicates that our client-side validation has failed to catch invalid input before it reaches the server. - -Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: -- Provide immediate feedback to users -- Reduce server load -- Improve user experience by avoiding round-trips to the server -- Prevent malformed data from ever reaching the backend - -Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. - -#### Email templating +### Email templating Password reset and other transactional emails are also handled through Jinja2 templates, located in the `templates/emails` directory. The email templates follow the same inheritance pattern as web templates, with `base_email.html` providing the common layout and styling. @@ -834,81 +727,13 @@ Here's how the default password reset email template looks: The email templates use inline CSS styles to ensure consistent rendering across email clients. Like web templates, they can receive context variables from the Python code (such as `reset_url` in the password reset template). -### Writing type annotated code +### Server-side form validation Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. -For other, custom validation logic, we add Pydantic `@field_validator` methods to our Pydantic request models and then add the models as dependencies in the signatures of corresponding POST routes. FastAPI's dependency injection system ensures that dependency logic is executed before the body of the route handler. - -#### Defining request models and custom validators - -For example, in the `UserRegister` request model in `routers/authentication.py`, we add a custom validation method to ensure that the `confirm_password` field matches the `password` field. If not, it raises a custom `PasswordMismatchError`: - -```python -class PasswordMismatchError(HTTPException): - def __init__(self, field: str = "confirm_password"): - super().__init__( - status_code=422, - detail={ - "field": field, - "message": "The passwords you entered do not match" - } - ) - -class UserRegister(BaseModel): - name: str - email: EmailStr - password: str - confirm_password: str - - # Custom validators are added as class attributes - @field_validator("confirm_password", check_fields=False) - def validate_passwords_match(cls, v: str, values: dict[str, Any]) -> str: - if v != values["password"]: - raise PasswordMismatchError() - return v - # ... -``` - -We then add this request model as a dependency in the signature of our POST route: - -```python -@app.post("/register") -async def register(request: UserRegister = Depends()): - # ... -``` - -When the user submits the form, Pydantic will first check that all expected fields are present and match the expected types. If not, it raises a `RequestValidationError`. Then, it runs our custom `field_validator`, `validate_passwords_match`. If it finds that the `confirm_password` field does not match the `password` field, it raises a `PasswordMismatchError`. These exceptions can then be caught and handled by our middleware. - -(Note that these examples are simplified versions of the actual code.) - -#### Converting form data to request models - -In addition to custom validation logic, we also need to define a method on our request models that converts form data into the request model. Here's what that looks like in the `UserRegister` request model from the previous example: - -```python -class UserRegister(BaseModel): - # ... - - @classmethod - async def as_form( - cls, - name: str = Form(...), - email: EmailStr = Form(...), - password: str = Form(...), - confirm_password: str = Form(...) - ): - return cls( - name=name, - email=email, - password=password, - confirm_password=confirm_password - ) -``` - -#### Middleware exception handling +### Middleware exception handling Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. @@ -919,8 +744,8 @@ Middleware functions are decorated with `@app.exception_handler(ExceptionType)` Here's a middleware for handling the `PasswordMismatchError` exception from the previous example, which renders the `errors/validation_error.html` template with the error details: ```python -@app.exception_handler(PasswordMismatchError) -async def password_mismatch_exception_handler(request: Request, exc: PasswordMismatchError): +@app.exception_handler(PasswordValidationError) +async def password_validation_exception_handler(request: Request, exc: PasswordValidationError): return templates.TemplateResponse( request, "errors/validation_error.html", @@ -932,20 +757,21 @@ async def password_mismatch_exception_handler(request: Request, exc: PasswordMis ) ``` -### Database configuration and access with SQLModel +## Database configuration and access with SQLModel SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation. -#### Models and relationships +### Models and relationships Our database models are defined in `utils/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key models are: +- `Account`: Represents a user account with email and password hash +- `User`: Represents a user profile with details like name and avatar; the email and password hash are stored in the related `Account` model - `Organization`: Represents a company or team -- `User`: Represents a user account with name, email, and avatar - `Role`: Represents a set of permissions within an organization - `Permission`: Represents specific actions a user can perform (defined by ValidPermissions enum) - `PasswordResetToken`: Manages password reset functionality with expiration -- `UserPassword`: Stores hashed user passwords separately from user data +- `EmailUpdateToken`: Manages email update confirmation functionality with expiration Two additional models are used by SQLModel to manage many-to-many relationships; you generally will not need to interact with them directly: @@ -981,7 +807,7 @@ graph.write_png('static/schema.png') ![Database Schema](static/schema.png) -#### Database helpers +### Database helpers Database operations are facilitated by helper functions in `utils/db.py`. Key functions include: @@ -1011,7 +837,7 @@ user.has_permission(permission, organization) You should create custom `ValidPermissions` enum values for your application and validate that users have the necessary permissions before allowing them to modify organization data resources. -#### Cascade deletes +### Cascade deletes Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: @@ -1038,6 +864,127 @@ for role in session.exec(select(Role)).all(): This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage. +## Frontend + +### HTML templating with Jinja2 + +To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. + +With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. + +### Custom theming with Bootstrap + +[Install Node.js](https://nodejs.org/en/download/) on your local machine if it is not there already. + +Install `bootstrap`, `sass`, `gulp`, and `gulp-sass` in your project: + +```bash +npm install --save-dev bootstrap sass gulp gulp-cli gulp-sass +``` + +This will create a `node_modules` folder, a `package-lock.json` file, and a `package.json` file in the root directory of the project. + +Create an `scss` folder and a basic `scss/styles.scss` file: + +```bash +mkdir scss +touch scss/styles.scss +``` + +Your custom styles will go in `scss/styles.scss`, along with `@import` statements to include the Bootstrap components you want. + +#### Customizing the Bootstrap SCSS + +The default CSS for the template was compiled from the following `scss/styles.scss` configuration, which imports all of Bootstrap and overrides the `$theme-colors` and `$font-family-base` variables: + +```scss +// styles.scss + +// Include any default variable overrides here (functions won't be available) + +// State colors +$primary: #7464a1; +$secondary: #64a19d; +$success: #67c29c; +$info: #1cabc4; +$warning: #e4c662; +$danger: #a16468; +$light: #f8f9fa; +$dark: #343a40; + +// Bootstrap color map +$theme-colors: ( + "primary": $primary, + "secondary": $secondary, + "success": $success, + "info": $info, + "warning": $warning, + "danger": $danger, + "light": $light, + "dark": $dark +); + +$font-family-base: ( + "Nunito", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji" +); + +// Include all of Bootstrap + +@import "../node_modules/bootstrap/scss/bootstrap"; +``` + +The most common use case for `styles.scss` is to define a custom color scheme and fonts, but it's also possible to customize some other visual details such as border radius and box shadow depth. See the [Bootstrap Sass customization documentation](https://getbootstrap.com/docs/5.3/customize/sass/) and the many free templates available at [Start Bootstrap](https://startbootstrap.com) for examples. + +#### Compiling the SCSS to CSS + +To compile the SCSS files to CSS, we use `gulp`. In the project root directory, create a `gulpfile.js` file with the following content: + +```javascript + const gulp = require('gulp'); + const sass = require('gulp-sass')(require('sass')); + + // Define a task to compile Sass + gulp.task('sass', function() { + return gulp.src('scss/**/*.scss') // Source folder containing Sass files + .pipe(sass().on('error', sass.logError)) + .pipe(gulp.dest('static/css')); // Destination folder for compiled CSS + }); + + // Define a default task + gulp.task('default', gulp.series('sass')); +``` + +To compile the SCSS file to `static/css`, run this command: + +```bash +npx gulp +``` + +Note that this will overwrite the existing `static/css/styles.css` file, so if you want to define any custom CSS styles, you should do so in either the `scss/styles.scss` file or in `static/css/extras.css`. + +### Client-side form validation + +While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. If users ever see the `validation_error.html` template, it indicates that our client-side validation has failed to catch invalid input before it reaches the server. + +Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: + +- Provide immediate feedback to users +- Reduce server load +- Improve user experience by avoiding round-trips to the server +- Prevent malformed data from ever reaching the backend + +Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. # Deployment @@ -1086,13 +1033,17 @@ To contribute code to the project: ### Rendering the documentation -The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you ,make changes to the `.qmd` files in the root folder and the `docs` folder, run the following commands to re-render the docs: +The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you make changes to the `.qmd` files in the root folder and the `docs` folder, you will need to re-render the docs with Quarto. + +Quarto expects environment variables to be set in a file called `_environment`, so before running Quarto render commands, you should copy your `.env` file to `_environment`. ``` bash +# To copy the .env file to _environment +cp .env _environment # To render the documentation website -quarto render +uv run quarto render # To render the README -quarto render index.qmd --output-dir . --output README.md --to gfm +uv run quarto render index.qmd --output-dir . --output README.md --to gfm ``` Due to a quirk of Quarto, an unnecessary `index.html` file is created in the root folder when the README is rendered. This file can be safely deleted. @@ -1117,5 +1068,5 @@ When creating new features, To publish the documentation to GitHub Pages, run the following command: ``` bash -quarto publish gh-pages +uv run quarto publish gh-pages ``` diff --git a/docs/static/schema.png b/docs/static/schema.png index 49cacfe..df86a9b 100644 Binary files a/docs/static/schema.png and b/docs/static/schema.png differ diff --git a/docs/templates.qmd b/docs/templates.qmd new file mode 100644 index 0000000..2eafda8 --- /dev/null +++ b/docs/templates.qmd @@ -0,0 +1,321 @@ +--- +title: "Template Variables Documentation" +--- + +This file documents the required context variables for each template in the application. + +## Table of Contents + +- [authentication](#authentication) +- [components](#components) +- [dashboard](#dashboard) +- [dashboard > organizations](#dashboard-organizations) +- [dashboard > organizations > members](#dashboard-organizations-members) +- [emails](#emails) +- [errors](#errors) +- [root](#root) +- [users](#users) + +## authentication + +### forgot_password.html + +**Path:** `authentication/forgot_password.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `show_form` | | +| `url_for` | | + +### login.html + +**Path:** `authentication/login.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `url_for` | | + +### register.html + +**Path:** `authentication/register.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `password_pattern` | | +| `url_for` | | + +### reset_password.html + +**Path:** `authentication/reset_password.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `email` | | +| `password_pattern` | | +| `token` | | +| `url_for` | | + +## components + +### footer.html + +**Path:** `components/footer.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `url_for` | | + +### header.html + +**Path:** `components/header.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `url_for` | | +| `user` | | + +### logo.html + +**Path:** `components/logo.html` + +**No variables required** + +### nav.html + +**Path:** `components/nav.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `url_for` | | + +### organizations.html + +**Path:** `components/organizations.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `url_for` | | + +### silhouette.html + +**Path:** `components/silhouette.html` + +**No variables required** + +## dashboard + +### index.html + +**Path:** `dashboard/index.html` + +**No variables required** + +### organization_overview.html + +**Path:** `dashboard/organization_overview.html` + +**No variables required** + +## dashboard > organizations + +### create.html + +**Path:** `dashboard/organizations/create.html` + +**No variables required** + +### delete.html + +**Path:** `dashboard/organizations/delete.html` + +**No variables required** + +### detail.html + +**Path:** `dashboard/organizations/detail.html` + +**No variables required** + +### edit.html + +**Path:** `dashboard/organizations/edit.html` + +**No variables required** + +### members.html + +**Path:** `dashboard/organizations/members.html` + +**No variables required** + +## dashboard > organizations > members + +### delete.html + +**Path:** `dashboard/organizations/members/delete.html` + +**No variables required** + +### edit.html + +**Path:** `dashboard/organizations/members/edit.html` + +**No variables required** + +### invite.html + +**Path:** `dashboard/organizations/members/invite.html` + +**No variables required** + +## emails + +### base_email.html + +**Path:** `emails/base_email.html` + +**No variables required** + +### reset_email.html + +**Path:** `emails/reset_email.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `reset_url` | | + +### update_email_email.html + +**Path:** `emails/update_email_email.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `confirmation_url` | | +| `current_email` | | +| `new_email` | | + +## errors + +### error.html + +**Path:** `errors/error.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `detail` | | +| `status_code` | | +| `url_for` | | + +### validation_error.html + +**Path:** `errors/validation_error.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `errors` | | +| `url_for` | | + +## root + +### about.html + +**Path:** `about.html` + +**No variables required** + +### auth_base.html + +**Path:** `auth_base.html` + +**No variables required** + +### base.html + +**Path:** `base.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `url_for` | | + +### index.html + +**Path:** `index.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `render_logo` | | +| `url_for` | | + +### privacy_policy.html + +**Path:** `privacy_policy.html` + +**No variables required** + +### terms_of_service.html + +**Path:** `terms_of_service.html` + +**No variables required** + +## users + +### organization.html + +**Path:** `users/organization.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `organization` | | +| `render_silhouette` | | +| `url_for` | | + +### profile.html + +**Path:** `users/profile.html` + +**Required Variables:** + +| Variable | Description | +| --- | --- | +| `allowed_formats` | | +| `email_update_requested` | | +| `email_updated` | | +| `max_dimension` | | +| `max_file_size_mb` | | +| `min_dimension` | | +| `render_organizations` | | +| `render_silhouette` | | +| `show_form` | | +| `url_for` | | +| `user` | | diff --git a/exceptions/__init__.py b/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exceptions/exceptions.py b/exceptions/exceptions.py new file mode 100644 index 0000000..65637f3 --- /dev/null +++ b/exceptions/exceptions.py @@ -0,0 +1,8 @@ +from utils.models import User + + +class NeedsNewTokens(Exception): + def __init__(self, user: User, access_token: str, refresh_token: str): + self.user = user + self.access_token = access_token + self.refresh_token = refresh_token diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py new file mode 100644 index 0000000..d51d636 --- /dev/null +++ b/exceptions/http_exceptions.py @@ -0,0 +1,123 @@ +from fastapi import HTTPException, status +from utils.enums import ValidPermissions + +class EmailAlreadyRegisteredError(HTTPException): + def __init__(self): + super().__init__( + status_code=409, + detail="This email is already registered" + ) + + +class CredentialsError(HTTPException): + def __init__(self, message: str = "Invalid credentials"): + super().__init__( + status_code=401, + detail=message + ) + + +class AuthenticationError(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"} + ) + + +class PasswordValidationError(HTTPException): + def __init__(self, field: str, message: str): + super().__init__( + status_code=422, + detail={ + "field": field, + "message": message + } + ) + + +class InsufficientPermissionsError(HTTPException): + def __init__(self): + super().__init__( + status_code=403, + detail="You don't have permission to perform this action" + ) + + +# TODO: Consolidate these two into a single validation error +class EmptyOrganizationNameError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="Organization name cannot be empty" + ) + + +class OrganizationNameTakenError(HTTPException): + def __init__(self): + super().__init__( + status_code=400, + detail="Organization name already taken" + ) + + +class OrganizationNotFoundError(HTTPException): + def __init__(self): + super().__init__( + status_code=404, + detail="Organization not found" + ) + + +class InvalidPermissionError(HTTPException): + """Raised when a user attempts to assign an invalid permission to a role""" + + def __init__(self, permission: ValidPermissions): + super().__init__( + status_code=400, + detail=f"Invalid permission: {permission}" + ) + + +class RoleAlreadyExistsError(HTTPException): + """Raised when attempting to create a role with a name that already exists""" + + def __init__(self): + super().__init__(status_code=400, detail="Role already exists") + + +class RoleNotFoundError(HTTPException): + """Raised when a requested role does not exist""" + + def __init__(self): + super().__init__(status_code=404, detail="Role not found") + + +class RoleHasUsersError(HTTPException): + """Raised when a requested role to be deleted has users""" + + def __init__(self): + super().__init__( + status_code=400, + detail="Role cannot be deleted until users with that role are reassigned" + ) + + +class DataIntegrityError(HTTPException): + def __init__( + self, + resource: str = "Database resource" + ): + super().__init__( + status_code=500, + detail=( + f"{resource} is in a broken state; please contact a system administrator" + ) + ) + + +class InvalidImageError(HTTPException): + """Raised when an invalid image is uploaded""" + + def __init__(self, message: str = "Invalid image file"): + super().__init__(status_code=400, detail=message) \ No newline at end of file diff --git a/index.qmd b/index.qmd index 6b51fb7..4c6df13 100644 --- a/index.qmd +++ b/index.qmd @@ -131,7 +131,7 @@ docker compose up -d Make sure the development database is running and tables and default permissions/roles are created first. ``` bash -uvicorn main:app --host 0.0.0.0 --port 8000 --reload +uv run uvicorn main:app --host 0.0.0.0 --port 8000 --reload ``` Navigate to http://localhost:8000/ @@ -139,7 +139,7 @@ Navigate to http://localhost:8000/ ### Lint types with mypy ``` bash -mypy . +uv run mypy . ``` ## Developing with LLMs diff --git a/main.py b/main.py index 78211cf..865d919 100644 --- a/main.py +++ b/main.py @@ -5,21 +5,20 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.exceptions import RequestValidationError, HTTPException, StarletteHTTPException -from sqlmodel import Session -from routers import authentication, organization, role, user -from utils.auth import ( - HTML_PASSWORD_PATTERN, - get_user_with_relations, - get_optional_user, - NeedsNewTokens, - get_user_from_reset_token, - PasswordValidationError, - AuthenticationError +from fastapi.exceptions import RequestValidationError, StarletteHTTPException +from routers import account, dashboard, organization, role, user, static_pages +from utils.dependencies import ( + get_optional_user ) +from exceptions.http_exceptions import ( + AuthenticationError, + PasswordValidationError +) +from exceptions.exceptions import ( + NeedsNewTokens +) +from utils.db import set_up_db from utils.models import User -from utils.db import get_session, set_up_db -from utils.images import MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES logger = logging.getLogger("uvicorn.error") logger.setLevel(logging.DEBUG) @@ -33,15 +32,25 @@ async def lifespan(app: FastAPI): # Optional shutdown logic +# Initialize the FastAPI app app: FastAPI = FastAPI(lifespan=lifespan) -# Mount static files (e.g., CSS, JS) +# Mount static files (e.g., CSS, JS) and initialize Jinja2 templates app.mount("/static", StaticFiles(directory="static"), name="static") - -# Initialize Jinja2 templates templates = Jinja2Templates(directory="templates") +# --- Include Routers --- + + +app.include_router(account.router) +app.include_router(dashboard.router) +app.include_router(organization.router) +app.include_router(role.router) +app.include_router(static_pages.router) +app.include_router(user.router) + + # --- Exception Handling Middlewares --- @@ -49,7 +58,7 @@ async def lifespan(app: FastAPI): @app.exception_handler(AuthenticationError) async def authentication_error_handler(request: Request, exc: AuthenticationError): return RedirectResponse( - url="/login", + url=app.url_path_for("read_login"), status_code=status.HTTP_303_SEE_OTHER ) @@ -94,8 +103,18 @@ async def password_validation_exception_handler(request: Request, exc: PasswordV @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): errors = {} + + # Map error types to user-friendly message templates + error_templates = { + "pattern_mismatch": "this field cannot be empty or contain only whitespace", + "string_too_short": "this field is required", + "missing": "this field is required", + "string_pattern_mismatch": "this field cannot be empty or contain only whitespace", + "enum": "invalid value" + } + for error in exc.errors(): - # Handle different error locations more carefully + # Handle different error locations carefully location = error["loc"] # Skip type errors for the whole body @@ -104,8 +123,21 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE # For form fields, the location might be just (field_name,) # For JSON body, it might be (body, field_name) - field_name = location[-1] # Take the last item in the location tuple - errors[field_name] = error["msg"] + # For array items, it might be (field_name, array_index) + field_name = location[-2] if isinstance(location[-1], int) else location[-1] + + # Format the field name to be more user-friendly + display_name = field_name.replace("_", " ").title() + + # Use mapped message if available, otherwise use FastAPI's message + error_type = error.get("type", "") + message_template = error_templates.get(error_type, error["msg"]) + + # For array items, append the index to the message + if isinstance(location[-1], int): + message_template = f"Item {location[-1] + 1}: {message_template}" + + errors[display_name] = message_template return templates.TemplateResponse( request, @@ -146,160 +178,22 @@ async def general_exception_handler(request: Request, exc: Exception): ) -# --- Unauthenticated Routes --- - - -# Define a dependency for common parameters -async def common_unauthenticated_parameters( - request: Request, - user: Optional[User] = Depends(get_optional_user), - error_message: Optional[str] = None, -) -> dict: - return {"request": request, "user": user, "error_message": error_message} +# --- Home Page --- @app.get("/") async def read_home( - params: dict = Depends(common_unauthenticated_parameters) -): - if params["user"]: - return RedirectResponse(url="/dashboard", status_code=302) - return templates.TemplateResponse(params["request"], "index.html", params) - - -@app.get("/login") -async def read_login( - params: dict = Depends(common_unauthenticated_parameters), - email_updated: Optional[str] = "false" -): - if params["user"]: - return RedirectResponse(url="/dashboard", status_code=302) - params["email_updated"] = email_updated - return templates.TemplateResponse(params["request"], "authentication/login.html", params) - - -@app.get("/register") -async def read_register( - params: dict = Depends(common_unauthenticated_parameters) -): - if params["user"]: - return RedirectResponse(url="/dashboard", status_code=302) - - params["password_pattern"] = HTML_PASSWORD_PATTERN - return templates.TemplateResponse(params["request"], "authentication/register.html", params) - - -@app.get("/forgot_password") -async def read_forgot_password( - params: dict = Depends(common_unauthenticated_parameters), - show_form: Optional[str] = "true", -): - params["show_form"] = show_form == "true" - - return templates.TemplateResponse(params["request"], "authentication/forgot_password.html", params) - - -@app.get("/about") -async def read_about(params: dict = Depends(common_unauthenticated_parameters)): - return templates.TemplateResponse(params["request"], "about.html", params) - - -@app.get("/privacy_policy") -async def read_privacy_policy(params: dict = Depends(common_unauthenticated_parameters)): - return templates.TemplateResponse(params["request"], "privacy_policy.html", params) - - -@app.get("/terms_of_service") -async def read_terms_of_service(params: dict = Depends(common_unauthenticated_parameters)): - return templates.TemplateResponse(params["request"], "terms_of_service.html", params) - - -@app.get("/auth/reset_password") -async def read_reset_password( - email: str, - token: str, - params: dict = Depends(common_unauthenticated_parameters), - session: Session = Depends(get_session) -): - authorized_user, _ = get_user_from_reset_token(email, token, session) - - # Raise informative error to let user know the token is invalid and may have expired - if not authorized_user: - raise HTTPException(status_code=400, detail="Invalid or expired token") - - params["email"] = email - params["token"] = token - params["password_pattern"] = HTML_PASSWORD_PATTERN - - return templates.TemplateResponse(params["request"], "authentication/reset_password.html", params) - - -# --- Authenticated Routes --- - - -# Define a dependency for common parameters -async def common_authenticated_parameters( request: Request, - user: User = Depends(get_user_with_relations), - error_message: Optional[str] = None -) -> dict: - return {"request": request, "user": user, "error_message": error_message} - - -# Redirect to home if user is not authenticated -@app.get("/dashboard") -async def read_dashboard( - params: dict = Depends(common_authenticated_parameters) + user: Optional[User] = Depends(get_optional_user) ): - return templates.TemplateResponse(params["request"], "dashboard/index.html", params) - - -@app.get("/profile") -async def read_profile( - params: dict = Depends(common_authenticated_parameters), - email_update_requested: Optional[str] = "false", - email_updated: Optional[str] = "false" -): - # Add image constraints to the template context - params.update({ - "max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB - "min_dimension": MIN_DIMENSION, - "max_dimension": MAX_DIMENSION, - "allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()), - "email_update_requested": email_update_requested, - "email_updated": email_updated - }) - return templates.TemplateResponse(params["request"], "users/profile.html", params) - - -@app.get("/organizations/{org_id}") -async def read_organization( - org_id: int, - params: dict = Depends(common_authenticated_parameters) -): - # Get the organization only if the user is a member of it - org = next( - (org for org in params["user"].organizations if org.id == org_id), - None + if user: + return RedirectResponse(url="/dashboard", status_code=302) + return templates.TemplateResponse( + request, + "index.html", + {"user": user} ) - if not org: - raise organization.OrganizationNotFoundError() - - # Eagerly load roles and users - org.roles - org.users - params["organization"] = org - return templates.TemplateResponse(params["request"], "users/organization.html", params) - - -# --- Include Routers --- - - -app.include_router(authentication.router) -app.include_router(organization.router) -app.include_router(role.router) -app.include_router(user.router) if __name__ == "__main__": import uvicorn diff --git a/pyproject.toml b/pyproject.toml index d91deab..f7fdb5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ package-mode = false authors = [ {name = "Christopher Carroll Smith", email = "chriscarrollsmith@gmail.com"}, ] -requires-python = "<4.0,>=3.12" +requires-python = "<4.0,>=3.13" dependencies = [ "sqlmodel<1.0.0,>=0.0.22", "pyjwt<3.0.0,>=2.10.1", @@ -32,4 +32,8 @@ dev = [ "notebook<8.0.0,>=7.2.2", "pytest<9.0.0,>=8.3.3", "sqlalchemy-schemadisplay<3.0,>=2.0", + "perplexity-cli", ] + +[tool.uv.sources] +perplexity-cli = { git = "https://github.com/chriscarrollsmith/perplexity-cli.git" } diff --git a/routers/account.py b/routers/account.py new file mode 100644 index 0000000..237f989 --- /dev/null +++ b/routers/account.py @@ -0,0 +1,485 @@ +# auth.py +from logging import getLogger +from typing import Optional, Tuple +from urllib.parse import urlparse +from fastapi import APIRouter, Depends, BackgroundTasks, Form, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from pydantic import EmailStr +from sqlmodel import Session, select +from utils.models import User, DataIntegrityError, Account +from utils.db import get_session +from utils.auth import ( + HTML_PASSWORD_PATTERN, + COMPILED_PASSWORD_PATTERN, + oauth2_scheme_cookie, + get_password_hash, + verify_password, + create_access_token, + create_refresh_token, + validate_token, + send_reset_email, + send_email_update_confirmation +) +from utils.dependencies import ( + get_authenticated_account, + get_optional_user, + get_account_from_reset_token, + get_account_from_email_update_token, + get_account_from_credentials +) +from exceptions.http_exceptions import ( + EmailAlreadyRegisteredError, + CredentialsError, + PasswordValidationError +) + +logger = getLogger("uvicorn.error") + +router = APIRouter(prefix="/account", tags=["account"]) +templates = Jinja2Templates(directory="templates") + + +# --- Route-specific dependencies --- + + +def validate_password_strength_and_match( + password: str = Form(...), + confirm_password: str = Form(...) +) -> str: + """ + Validates password strength and confirms passwords match. + + Args: + password: Password from form + confirm_password: Confirmation password from form + + Raises: + PasswordValidationError: If password is weak or passwords don't match + + Returns: + str: The validated password + """ + # Validate password strength + if not COMPILED_PASSWORD_PATTERN.match(password): + raise PasswordValidationError( + field="password", + message="Password does not satisfy the security policy" + ) + + # Validate passwords match + if password != confirm_password: + raise PasswordValidationError( + field="confirm_password", + message="The passwords you entered do not match" + ) + + return password + + +# --- Routes --- + + +@router.post("/delete", response_class=RedirectResponse) +async def delete_account( + email: EmailStr = Form(...), + password: str = Form(...), + account: Account = Depends(get_authenticated_account), + session: Session = Depends(get_session) +): + """ + Delete a user account after verifying credentials. + """ + # Verify the provided email matches the authenticated user + if email != account.email: + raise CredentialsError(message="Email does not match authenticated account") + + # Verify password + if not verify_password(password, account.hashed_password): + raise PasswordValidationError( + field="password", + message="Password is incorrect" + ) + + # Delete the account and associated user + # Note: The user will be deleted automatically by cascade relationship + session.delete(account) + session.commit() + + # Log out the user + return RedirectResponse(url="/account/logout", status_code=303) + + +@router.get("/login") +async def read_login( + request: Request, + user: Optional[User] = Depends(get_optional_user), + email_updated: Optional[str] = "false" +): + """ + Render login page or redirect to dashboard if already logged in. + """ + if user: + return RedirectResponse(url="/dashboard", status_code=302) + return templates.TemplateResponse( + "authentication/login.html", + {"request": request, "user": user, "email_updated": email_updated} + ) + + +@router.get("/register") +async def read_register( + request: Request, + user: Optional[User] = Depends(get_optional_user) +): + """ + Render registration page or redirect to dashboard if already logged in. + """ + if user: + return RedirectResponse(url="/dashboard", status_code=302) + + return templates.TemplateResponse( + "authentication/register.html", + {"request": request, "user": user, "password_pattern": HTML_PASSWORD_PATTERN} + ) + + +@router.get("/forgot_password") +async def read_forgot_password( + request: Request, + user: Optional[User] = Depends(get_optional_user), + show_form: Optional[str] = "true", +): + """ + Render forgot password page or redirect to dashboard if already logged in. + """ + if user: + return RedirectResponse(url="/dashboard", status_code=302) + + return templates.TemplateResponse( + "authentication/forgot_password.html", + {"request": request, "user": user, "show_form": show_form == "true"} + ) + + +@router.get("/reset_password") +async def read_reset_password( + request: Request, + email: str, + token: str, + user: Optional[User] = Depends(get_optional_user), + session: Session = Depends(get_session) +): + """ + Render reset password page after validating token. + """ + authorized_account, _ = get_account_from_reset_token(email, token, session) + + # Raise informative error to let user know the token is invalid and may have expired + if not authorized_account: + raise CredentialsError(message="Invalid or expired token") + + return templates.TemplateResponse( + "authentication/reset_password.html", + {"request": request, "user": user, "email": email, "token": token, "password_pattern": HTML_PASSWORD_PATTERN} + ) + + +@router.post("/register", response_class=RedirectResponse) +async def register( + name: str = Form(...), + email: EmailStr = Form(...), + session: Session = Depends(get_session), + _: None = Depends(validate_password_strength_and_match), + password: str = Form(...) +) -> RedirectResponse: + """ + Register a new user account. + """ + # Check if the email is already registered + account: Optional[Account] = session.exec(select(Account).where( + Account.email == email)).one_or_none() + + if account: + raise EmailAlreadyRegisteredError() + + # Hash the password + hashed_password = get_password_hash(password) + + # Create the account + account = Account(email=email, hashed_password=hashed_password) + session.add(account) + session.flush() # Flush to get the account ID + + # Create the user + account.user = User(name=name) + session.add(account) + session.commit() + session.refresh(account) + + # Create access token + access_token = create_access_token(data={"sub": email}) + refresh_token = create_refresh_token(data={"sub": email}) + + # Set cookie + response = RedirectResponse(url="/", status_code=303) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=True, + samesite="strict" + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="strict" + ) + + return response + + +@router.post("/login", response_class=RedirectResponse) +async def login( + account_and_session: Tuple[Account, Session] = Depends(get_account_from_credentials) +) -> RedirectResponse: + """ + Log in a user with valid credentials. + """ + account, session = account_and_session + + # Create access token + access_token = create_access_token( + data={"sub": account.email, "fresh": True} + ) + refresh_token = create_refresh_token(data={"sub": account.email}) + + # Set cookie + response = RedirectResponse(url="/", status_code=303) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=True, + samesite="strict", + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="strict", + ) + + return response + + +# Updated refresh_token endpoint +@router.post("/refresh", response_class=RedirectResponse) +async def refresh_token( + tokens: tuple[Optional[str], Optional[str]] = Depends(oauth2_scheme_cookie), + session: Session = Depends(get_session), +) -> RedirectResponse: + """ + Refresh the access token using a valid refresh token. + """ + _, refresh_token = tokens + if not refresh_token: + return RedirectResponse(url="/login", status_code=303) + + decoded_token = validate_token(refresh_token, token_type="refresh") + if not decoded_token: + response = RedirectResponse(url="/login", status_code=303) + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return response + + user_email = decoded_token.get("sub") + account = session.exec(select(Account).where( + Account.email == user_email)).one_or_none() + if not account: + return RedirectResponse(url="/login", status_code=303) + + new_access_token = create_access_token( + data={"sub": account.email, "fresh": False} + ) + new_refresh_token = create_refresh_token(data={"sub": account.email}) + + response = RedirectResponse(url="/", status_code=303) + response.set_cookie( + key="access_token", + value=new_access_token, + httponly=True, + secure=True, + samesite="strict", + ) + response.set_cookie( + key="refresh_token", + value=new_refresh_token, + httponly=True, + secure=True, + samesite="strict", + ) + + return response + + +@router.post("/forgot_password") +async def forgot_password( + background_tasks: BackgroundTasks, + request: Request, + email: EmailStr = Form(...), + session: Session = Depends(get_session) +): + """ + Send a password reset email to the user. + """ + # TODO: Make this a dependency? + account = session.exec(select(Account).where( + Account.email == email)).one_or_none() + + if account: + background_tasks.add_task(send_reset_email, email, session) + + # Get the referer header, default to /forgot_password if not present + referer = request.headers.get("referer", "/forgot_password") + + # Extract the path from the full URL + redirect_path = urlparse(referer).path + + # Add the query parameter to the redirect path + return RedirectResponse(url=f"{redirect_path}?show_form=false", status_code=303) + + +@router.post("/reset_password") +async def reset_password( + email: EmailStr = Form(...), + token: str = Form(...), + new_password: str = Depends(validate_password_strength_and_match), + session: Session = Depends(get_session) +): + """ + Reset a user's password using a valid token. + """ + + # Get account from reset token + authorized_account, reset_token = get_account_from_reset_token( + email, token, session + ) + + if not authorized_account or not reset_token: + raise CredentialsError("Invalid or expired password reset token; please request a new one") + + # Update password and mark token as used + authorized_account.hashed_password = get_password_hash(new_password) + + reset_token.used = True + session.commit() + session.refresh(authorized_account) + + return RedirectResponse(url="/login", status_code=303) + + +@router.get("/logout", response_class=RedirectResponse) +def logout(): + """ + Log out a user by clearing their cookies. + """ + response = RedirectResponse(url="/", status_code=303) + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return response + + +@router.post("/update_email") +async def request_email_update( + email: EmailStr = Form(...), + new_email: EmailStr = Form(...), + account: Account = Depends(get_authenticated_account), + session: Session = Depends(get_session) +): + """ + Request to update a user's email address. + """ + # Verify the provided email matches the authenticated user + if email != account.email: + raise CredentialsError(message="Email does not match authenticated user") + + if email == new_email: + raise CredentialsError(message="New email is the same as the current email") + + # Check if the new email is already registered + existing_user = session.exec( + select(Account.id).where(Account.email == new_email) + ).first() + + if existing_user: + raise EmailAlreadyRegisteredError() + + if not account.id: + raise DataIntegrityError(resource="Account id") + + # Send confirmation email + send_email_update_confirmation( + current_email=email, + new_email=new_email, + account_id=account.id, + session=session + ) + + return RedirectResponse( + url="/profile?email_update_requested=true", + status_code=303 + ) + + +@router.get("/confirm_email_update") +async def confirm_email_update( + account_id: int, + token: str, + new_email: str, + session: Session = Depends(get_session) +): + """ + Confirm an email update using a valid token. + """ + # TODO: Just eager load the update token with the account + account, update_token = get_account_from_email_update_token( + account_id, token, session + ) + + if not account or not update_token: + raise CredentialsError("Invalid or expired email update token; please request a new one") + + account.email = new_email + update_token.used = True + session.commit() + + # Create new tokens with the updated email + access_token = create_access_token(data={"sub": new_email, "fresh": True}) + refresh_token = create_refresh_token(data={"sub": new_email}) + + # Set cookies before redirecting + response = RedirectResponse( + url="/profile?email_updated=true", + status_code=303 + ) + + # Add secure cookie attributes + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=True, + samesite="lax" + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="lax" + ) + return response diff --git a/routers/authentication.py b/routers/authentication.py deleted file mode 100644 index 5880e11..0000000 --- a/routers/authentication.py +++ /dev/null @@ -1,437 +0,0 @@ -# auth.py -from logging import getLogger -from typing import Optional -from urllib.parse import urlparse -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Form, Request -from fastapi.responses import RedirectResponse -from pydantic import BaseModel, EmailStr, ConfigDict -from sqlmodel import Session, select -from utils.models import User, UserPassword, DataIntegrityError -from utils.auth import ( - get_session, - get_user_from_reset_token, - create_password_validator, - create_passwords_match_validator, - oauth2_scheme_cookie, - get_password_hash, - verify_password, - create_access_token, - create_refresh_token, - validate_token, - send_reset_email, - send_email_update_confirmation, - get_user_from_email_update_token, - get_authenticated_user -) - -logger = getLogger("uvicorn.error") - -router = APIRouter(prefix="/auth", tags=["auth"]) - -# --- Custom Exceptions --- - - -class EmailAlreadyRegisteredError(HTTPException): - def __init__(self): - super().__init__( - status_code=409, - detail="This email is already registered" - ) - - -class InvalidCredentialsError(HTTPException): - def __init__(self): - super().__init__( - status_code=401, - detail="Invalid credentials" - ) - - -class InvalidResetTokenError(HTTPException): - def __init__(self): - super().__init__( - status_code=401, - detail="Invalid or expired password reset token; please request a new one" - ) - - -class InvalidEmailUpdateTokenError(HTTPException): - def __init__(self): - super().__init__( - status_code=401, - detail="Invalid or expired email update token; please request a new one" - ) - - -# --- Server Request and Response Models --- - - -class UserRegister(BaseModel): - name: str - email: EmailStr - password: str - confirm_password: str - - validate_password_strength = create_password_validator("password") - validate_passwords_match = create_passwords_match_validator( - "password", "confirm_password") - - @classmethod - async def as_form( - cls, - name: str = Form(...), - email: EmailStr = Form(...), - password: str = Form(...), - confirm_password: str = Form(...) - ): - return cls( - name=name, - email=email, - password=password, - confirm_password=confirm_password - ) - - -class UserLogin(BaseModel): - email: EmailStr - password: str - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...), - password: str = Form(...) - ): - return cls(email=email, password=password) - - -class UserForgotPassword(BaseModel): - email: EmailStr - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...) - ): - return cls(email=email) - - -class UserResetPassword(BaseModel): - email: EmailStr - token: str - new_password: str - confirm_new_password: str - - # Use the factory with a different field name - validate_password_strength = create_password_validator("new_password") - validate_passwords_match = create_passwords_match_validator( - "new_password", "confirm_new_password") - - @classmethod - async def as_form( - cls, - email: EmailStr = Form(...), - token: str = Form(...), - new_password: str = Form(...), - confirm_new_password: str = Form(...) - ): - return cls(email=email, token=token, - new_password=new_password, confirm_new_password=confirm_new_password) - - -class UpdateEmail(BaseModel): - new_email: EmailStr - - @classmethod - async def as_form( - cls, - new_email: EmailStr = Form(...) - ): - return cls(new_email=new_email) - - -# --- DB Request and Response Models --- - - -class UserRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - email: EmailStr - organization_id: Optional[int] - created_at: datetime - updated_at: datetime - - -# --- Routes --- - - -# TODO: Use custom error message in the case where the user is already registered -@router.post("/register", response_class=RedirectResponse) -async def register( - user: UserRegister = Depends(UserRegister.as_form), - session: Session = Depends(get_session), -) -> RedirectResponse: - # Check if the email is already registered - db_user = session.exec(select(User).where( - User.email == user.email)).first() - - if db_user: - raise EmailAlreadyRegisteredError() - - # Hash the password - hashed_password = get_password_hash(user.password) - - # Create the user - db_user = User(name=user.name, email=user.email, - password=UserPassword(hashed_password=hashed_password)) - session.add(db_user) - session.commit() - session.refresh(db_user) - - # Create access token - access_token = create_access_token(data={"sub": db_user.email}) - refresh_token = create_refresh_token(data={"sub": db_user.email}) - # Set cookie - response = RedirectResponse(url="/", status_code=303) - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=True, - samesite="strict" - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=True, - samesite="strict" - ) - - return response - - -@router.post("/login", response_class=RedirectResponse) -async def login( - user: UserLogin = Depends(UserLogin.as_form), - session: Session = Depends(get_session), -) -> RedirectResponse: - # Check if the email is registered - db_user = session.exec(select(User).where( - User.email == user.email)).first() - - if not db_user or not db_user.password or not verify_password(user.password, db_user.password.hashed_password): - raise InvalidCredentialsError() - - # Create access token - access_token = create_access_token( - data={"sub": db_user.email, "fresh": True}) - refresh_token = create_refresh_token(data={"sub": db_user.email}) - - # Set cookie - response = RedirectResponse(url="/", status_code=303) - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=True, - samesite="strict", - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=True, - samesite="strict", - ) - - return response - - -# Updated refresh_token endpoint -@router.post("/refresh", response_class=RedirectResponse) -async def refresh_token( - tokens: tuple[Optional[str], Optional[str] - ] = Depends(oauth2_scheme_cookie), - session: Session = Depends(get_session), -) -> RedirectResponse: - _, refresh_token = tokens - if not refresh_token: - return RedirectResponse(url="/login", status_code=303) - - decoded_token = validate_token(refresh_token, token_type="refresh") - if not decoded_token: - response = RedirectResponse(url="/login", status_code=303) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - return response - - user_email = decoded_token.get("sub") - db_user = session.exec(select(User).where( - User.email == user_email)).first() - if not db_user: - return RedirectResponse(url="/login", status_code=303) - - new_access_token = create_access_token( - data={"sub": db_user.email, "fresh": False}) - new_refresh_token = create_refresh_token(data={"sub": db_user.email}) - - response = RedirectResponse(url="/", status_code=303) - response.set_cookie( - key="access_token", - value=new_access_token, - httponly=True, - secure=True, - samesite="strict", - ) - response.set_cookie( - key="refresh_token", - value=new_refresh_token, - httponly=True, - secure=True, - samesite="strict", - ) - - return response - - -@router.post("/forgot_password") -async def forgot_password( - background_tasks: BackgroundTasks, - request: Request, - user: UserForgotPassword = Depends(UserForgotPassword.as_form), - session: Session = Depends(get_session) -): - db_user = session.exec(select(User).where( - User.email == user.email)).first() - - if db_user: - background_tasks.add_task(send_reset_email, user.email, session) - - # Get the referer header, default to /forgot_password if not present - referer = request.headers.get("referer", "/forgot_password") - - # Extract the path from the full URL - redirect_path = urlparse(referer).path - - # Add the query parameter to the redirect path - return RedirectResponse(url=f"{redirect_path}?show_form=false", status_code=303) - - -@router.post("/reset_password") -async def reset_password( - user: UserResetPassword = Depends(UserResetPassword.as_form), - session: Session = Depends(get_session) -): - authorized_user, reset_token = get_user_from_reset_token( - user.email, user.token, session) - - if not authorized_user or not reset_token: - raise InvalidResetTokenError() - - # Update password and mark token as used - if authorized_user.password: - authorized_user.password.hashed_password = get_password_hash( - user.new_password - ) - else: - logger.warning( - "User password not found during password reset; creating new password for user") - authorized_user.password = UserPassword( - hashed_password=get_password_hash(user.new_password) - ) - - reset_token.used = True - session.commit() - session.refresh(authorized_user) - - return RedirectResponse(url="/login", status_code=303) - - -@router.get("/logout", response_class=RedirectResponse) -def logout(): - response = RedirectResponse(url="/", status_code=303) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") - return response - - -@router.post("/update_email") -async def request_email_update( - update: UpdateEmail = Depends(UpdateEmail.as_form), - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) -): - # Check if the new email is already registered - existing_user = session.exec( - select(User).where(User.email == update.new_email) - ).first() - - if existing_user: - raise EmailAlreadyRegisteredError() - - if not user.id: - raise DataIntegrityError(resource="User id") - - # Send confirmation email - send_email_update_confirmation( - current_email=user.email, - new_email=update.new_email, - user_id=user.id, - session=session - ) - - return RedirectResponse( - url="/profile?email_update_requested=true", - status_code=303 - ) - - -@router.get("/confirm_email_update") -async def confirm_email_update( - user_id: int, - token: str, - new_email: str, - session: Session = Depends(get_session) -): - user, update_token = get_user_from_email_update_token( - user_id, token, session - ) - - if not user or not update_token: - raise InvalidResetTokenError() - - # Update email and mark token as used - user.email = new_email - update_token.used = True - session.commit() - - # Create new tokens with the updated email - access_token = create_access_token(data={"sub": new_email, "fresh": True}) - refresh_token = create_refresh_token(data={"sub": new_email}) - - # Set cookies before redirecting - response = RedirectResponse( - url="/profile?email_updated=true", - status_code=303 - ) - - # Add secure cookie attributes - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=True, - samesite="lax" - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=True, - samesite="lax" - ) - return response diff --git a/routers/dashboard.py b/routers/dashboard.py new file mode 100644 index 0000000..ef60820 --- /dev/null +++ b/routers/dashboard.py @@ -0,0 +1,22 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Request +from fastapi.templating import Jinja2Templates +from utils.dependencies import get_user_with_relations +from utils.models import User + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) +templates = Jinja2Templates(directory="templates") + + +# --- Authenticated Routes --- + + +@router.get("/") +async def read_dashboard( + request: Request, + user: Optional[User] = Depends(get_user_with_relations) +): + return templates.TemplateResponse( + "dashboard/index.html", + {"request": request, "user": user} + ) \ No newline at end of file diff --git a/routers/organization.py b/routers/organization.py index afa6e5f..82e596e 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -1,111 +1,73 @@ from logging import getLogger -from fastapi import APIRouter, Depends, HTTPException, Form +from typing import Annotated +from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import RedirectResponse -from pydantic import BaseModel, ConfigDict, field_validator +from fastapi.templating import Jinja2Templates from sqlmodel import Session, select -from utils.db import get_session -from utils.auth import get_authenticated_user, get_user_with_relations, InsufficientPermissionsError -from utils.models import Organization, User, Role, utc_time, default_roles, ValidPermissions -from datetime import datetime +from utils.db import get_session, default_roles +from utils.dependencies import get_authenticated_user, get_user_with_relations +from utils.models import Organization, User, Role, utc_time +from utils.enums import ValidPermissions +from exceptions.http_exceptions import OrganizationNotFoundError, OrganizationNameTakenError, InsufficientPermissionsError, EmptyOrganizationNameError logger = getLogger("uvicorn.error") router = APIRouter(prefix="/organizations", tags=["organizations"]) +templates = Jinja2Templates(directory="templates") -# --- Custom Exceptions --- +# --- Routes --- -class EmptyOrganizationNameError(HTTPException): - def __init__(self): - super().__init__( - status_code=400, - detail="Organization name cannot be empty" - ) - - -class OrganizationNotFoundError(HTTPException): - def __init__(self): - super().__init__( - status_code=404, - detail="Organization not found" - ) - - -class OrganizationNameTakenError(HTTPException): - def __init__(self): - super().__init__( - status_code=400, - detail="Organization name already taken" - ) - - -# --- Server Request and Response Models --- - - -class OrganizationCreate(BaseModel): - name: str - - @field_validator('name') - @classmethod - def validate_name(cls, name: str) -> str: - if not name.strip(): - raise EmptyOrganizationNameError() - return name.strip() - - @classmethod - async def as_form(cls, name: str = Form(...)): - return cls(name=name) - - -class OrganizationRead(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - created_at: datetime - updated_at: datetime - - -class OrganizationUpdate(BaseModel): - id: int - name: str - - @field_validator('name') - @classmethod - def validate_name(cls, name: str) -> str: - if not name.strip(): - raise EmptyOrganizationNameError() - return name.strip() - @classmethod - async def as_form(cls, id: int = Form(...), name: str = Form(...)): - return cls(id=id, name=name) +@router.get("/{org_id}") +async def read_organization( + org_id: int, + request: Request, + user: User = Depends(get_user_with_relations) +): + # Get the organization only if the user is a member of it + org = next( + (org for org in user.organizations if org.id == org_id), + None + ) + if not org: + raise OrganizationNotFoundError() + + return templates.TemplateResponse( + request, "users/organization.html", {"organization": org} + ) -# --- Routes --- - @router.post("/create", response_class=RedirectResponse) def create_organization( - org: OrganizationCreate = Depends(OrganizationCreate.as_form), + name: Annotated[str, Form( + min_length=1, + strip_whitespace=True, + pattern=r"\S+", + description="Organization name cannot be empty or contain only whitespace", + title="Organization name" + )], user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: + logger.debug(f"Received organization name: '{name}' (length: {len(name)})") + # Check if organization already exists db_org = session.exec(select(Organization).where( - Organization.name == org.name)).first() + Organization.name == name)).first() if db_org: raise OrganizationNameTakenError() # Create organization first - db_org = Organization(name=org.name) + db_org = Organization(name=name) session.add(db_org) # This gets us the org ID without committing session.flush() # Create default roles with organization_id initial_roles = [ - Role(name=name, organization_id=db_org.id) - for name in default_roles + Role(name=role_name, organization_id=db_org.id) + for role_name in default_roles ] session.add_all(initial_roles) session.flush() @@ -125,13 +87,20 @@ def create_organization( @router.post("/update/{org_id}", name="update_organization", response_class=RedirectResponse) def update_organization( - org: OrganizationUpdate = Depends(OrganizationUpdate.as_form), + org_id: int, + name: Annotated[str, Form( + min_length=1, + strip_whitespace=True, + pattern=r"\S+", + description="Organization name cannot be empty or contain only whitespace", + title="Organization name" + )], user: User = Depends(get_user_with_relations), session: Session = Depends(get_session) ) -> RedirectResponse: # This will raise appropriate exceptions if org doesn't exist or user lacks access organization: Organization | None = next( - (org for org in user.organizations if org.id == org.id), None) + (org_item for org_item in user.organizations if org_item.id == org_id), None) # Check if user has permission to edit organization if not organization or not user.has_permission(ValidPermissions.EDIT_ORGANIZATION, organization): @@ -140,14 +109,14 @@ def update_organization( # Check if new name already exists for another organization existing_org = session.exec( select(Organization) - .where(Organization.name == org.name) - .where(Organization.id != org.id) + .where(Organization.name == name) + .where(Organization.id != org_id) ).first() if existing_org: raise OrganizationNameTakenError() # Update organization name - organization.name = org.name + organization.name = name organization.updated_at = utc_time() session.add(organization) session.commit() diff --git a/routers/role.py b/routers/role.py index c79c24b..83ce4de 100644 --- a/routers/role.py +++ b/routers/role.py @@ -2,155 +2,55 @@ # they themselves have. from typing import List, Sequence, Optional from logging import getLogger -from fastapi import APIRouter, Depends, Form, HTTPException +from fastapi import APIRouter, Depends, Form from fastapi.responses import RedirectResponse -from pydantic import BaseModel, ConfigDict, field_validator from sqlmodel import Session, select, col from sqlalchemy.orm import selectinload from utils.db import get_session -from utils.auth import get_authenticated_user, InsufficientPermissionsError +from utils.dependencies import get_authenticated_user from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError +from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError logger = getLogger("uvicorn.error") router = APIRouter(prefix="/roles", tags=["roles"]) - -# --- Custom Exceptions --- - - -class InvalidPermissionError(HTTPException): - """Raised when a user attempts to assign an invalid permission to a role""" - - def __init__(self, permission: ValidPermissions): - super().__init__( - status_code=400, - detail=f"Invalid permission: {permission}" - ) - - -class RoleAlreadyExistsError(HTTPException): - """Raised when attempting to create a role with a name that already exists""" - - def __init__(self): - super().__init__(status_code=400, detail="Role already exists") - - -class RoleNotFoundError(HTTPException): - """Raised when a requested role does not exist""" - - def __init__(self): - super().__init__(status_code=404, detail="Role not found") - - -class RoleHasUsersError(HTTPException): - """Raised when a requested role to be deleted has users""" - - def __init__(self): - super().__init__( - status_code=400, - detail="Role cannot be deleted until users with that role are reassigned" - ) - - -# --- Server Request Models --- - -class RoleCreate(BaseModel): - model_config = ConfigDict(from_attributes=True) - - name: str - organization_id: int - permissions: List[ValidPermissions] - - @classmethod - async def as_form( - cls, - name: str = Form(...), - organization_id: int = Form(...), - permissions: List[ValidPermissions] = Form(...) - ): - # Pass session to validator context - return cls( - name=name, - organization_id=organization_id, - permissions=permissions - ) - - -class RoleUpdate(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - name: str - organization_id: int - permissions: List[ValidPermissions] - - @classmethod - async def as_form( - cls, - id: int = Form(...), - name: str = Form(...), - organization_id: int = Form(...), - permissions: List[ValidPermissions] = Form(...) - ): - return cls( - id=id, - name=name, - organization_id=organization_id, - permissions=permissions - ) - - -class RoleDelete(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - organization_id: int - - @classmethod - async def as_form( - cls, - id: int = Form(...), - organization_id: int = Form(...) - ): - return cls(id=id, organization_id=organization_id) - - # --- Routes --- - @router.post("/create", response_class=RedirectResponse) def create_role( - role: RoleCreate = Depends(RoleCreate.as_form), + name: str = Form(...), + organization_id: int = Form(...), + permissions: List[ValidPermissions] = Form(...), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: # Check that the user-selected role name is unique for the organization if session.exec( select(Role).where( - Role.name == role.name, - Role.organization_id == role.organization_id + Role.name == name, + Role.organization_id == organization_id ) ).first(): raise RoleAlreadyExistsError() # Check that the user is authorized to create roles in the organization - if not user.has_permission(ValidPermissions.CREATE_ROLE, role.organization_id): + if not user.has_permission(ValidPermissions.CREATE_ROLE, organization_id): raise InsufficientPermissionsError() # Create role db_role = Role( - name=role.name, - organization_id=role.organization_id + name=name, + organization_id=organization_id ) session.add(db_role) # Select Permission records corresponding to the user-selected permissions # and associate them with the newly created role - permissions: Sequence[Permission] = session.exec( - select(Permission).where(col(Permission.name).in_(role.permissions)) + db_permissions: Sequence[Permission] = session.exec( + select(Permission).where(col(Permission.name).in_(permissions)) ).all() - db_role.permissions.extend(permissions) + db_role.permissions.extend(db_permissions) # Commit transaction session.commit() @@ -160,17 +60,20 @@ def create_role( @router.post("/update", response_class=RedirectResponse) def update_role( - role: RoleUpdate = Depends(RoleUpdate.as_form), + id: int = Form(...), + name: str = Form(...), + organization_id: int = Form(...), + permissions: List[ValidPermissions] = Form(...), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: # Check that the user is authorized to update the role - if not user.has_permission(ValidPermissions.EDIT_ROLE, role.organization_id): + if not user.has_permission(ValidPermissions.EDIT_ROLE, organization_id): raise InsufficientPermissionsError() # Select db_role to update, along with its permissions, by ID db_role: Optional[Role] = session.exec( - select(Role).where(Role.id == role.id).options( + select(Role).where(Role.id == id).options( selectinload(Role.permissions)) ).first() @@ -178,12 +81,12 @@ def update_role( raise RoleNotFoundError() # If any user-selected permissions are not valid, raise an error - for permission in role.permissions: + for permission in permissions: if permission not in ValidPermissions: raise InvalidPermissionError(permission) # Add any user-selected permissions that are not already associated with the role - for permission in role.permissions: + for permission in permissions: if permission not in [p.name for p in db_role.permissions]: db_permission: Optional[Permission] = session.exec( select(Permission).where(Permission.name == permission) @@ -195,21 +98,21 @@ def update_role( # Remove any permissions that are not user-selected for db_permission in db_role.permissions: - if db_permission.name not in role.permissions: + if db_permission.name not in permissions: db_role.permissions.remove(db_permission) # Check that no existing organization role has the same name but a different ID if session.exec( select(Role).where( - Role.name == role.name, - Role.organization_id == role.organization_id, - Role.id != role.id + Role.name == name, + Role.organization_id == organization_id, + Role.id != id ) ).first(): raise RoleAlreadyExistsError() # Update role name and updated_at timestamp - db_role.name = role.name + db_role.name = name db_role.updated_at = utc_time() session.commit() @@ -219,17 +122,18 @@ def update_role( @router.post("/delete", response_class=RedirectResponse) def delete_role( - role: RoleDelete = Depends(RoleDelete.as_form), + id: int = Form(...), + organization_id: int = Form(...), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ) -> RedirectResponse: # Check that the user is authorized to delete the role - if not user.has_permission(ValidPermissions.DELETE_ROLE, role.organization_id): + if not user.has_permission(ValidPermissions.DELETE_ROLE, organization_id): raise InsufficientPermissionsError() # Select the role to delete by ID, along with its users db_role: Role | None = session.exec( - select(Role).where(Role.id == role.id).options( + select(Role).where(Role.id == id).options( selectinload(Role.users) ) ).first() diff --git a/routers/static_pages.py b/routers/static_pages.py new file mode 100644 index 0000000..03c27c0 --- /dev/null +++ b/routers/static_pages.py @@ -0,0 +1,44 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Request, HTTPException +from fastapi.templating import Jinja2Templates +from utils.dependencies import get_optional_user +from utils.models import User + +router = APIRouter(tags=["static_pages"]) +templates = Jinja2Templates(directory="templates") + +# Define valid static pages to prevent arbitrary template access +VALID_PAGES = { + "about": "about.html", + "privacy-policy": "privacy_policy.html", + "terms-of-service": "terms_of_service.html" +} + +@router.get("/{page_name}", name="read_static_page") +async def read_static_page( + page_name: str, + request: Request, + user: Optional[User] = Depends(get_optional_user) +): + """ + Generic handler for static pages. + + Args: + page_name: The name of the page to render (must be in VALID_PAGES). + request: The FastAPI request object. + user: The optional authenticated user. + + Returns: + TemplateResponse for the requested page. + + Raises: + HTTPException: If the page_name is not in VALID_PAGES. + """ + if page_name not in VALID_PAGES: + raise HTTPException(status_code=404, detail="Page not found") + + return templates.TemplateResponse( + request, + VALID_PAGES[page_name], + {"user": user} + ) \ No newline at end of file diff --git a/routers/user.py b/routers/user.py index 135f355..629df25 100644 --- a/routers/user.py +++ b/routers/user.py @@ -1,118 +1,72 @@ -from fastapi import APIRouter, Depends, Form, UploadFile, File +from fastapi import APIRouter, Depends, Form, UploadFile, File, Request from fastapi.responses import RedirectResponse, Response -from pydantic import BaseModel, EmailStr from sqlmodel import Session from typing import Optional +from fastapi.templating import Jinja2Templates from utils.models import User, DataIntegrityError -from utils.auth import get_session, get_authenticated_user, verify_password, PasswordValidationError -from utils.images import validate_and_process_image +from utils.db import get_session +from utils.dependencies import get_authenticated_user +from utils.images import validate_and_process_image, MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES router = APIRouter(prefix="/user", tags=["user"]) +templates = Jinja2Templates(directory="templates") -# --- Server Request and Response Models --- - - -class UpdateProfile(BaseModel): - """Request model for updating user profile information""" - name: str - avatar_file: Optional[bytes] = None - avatar_content_type: Optional[str] = None - - @classmethod - async def as_form( - cls, - name: str = Form(...), - avatar_file: Optional[UploadFile] = File(None), - ): - avatar_data = None - avatar_content_type = None - - if avatar_file: - avatar_data = await avatar_file.read() - avatar_content_type = avatar_file.content_type - - return cls( - name=name, - avatar_file=avatar_data, - avatar_content_type=avatar_content_type - ) - - -class UserDeleteAccount(BaseModel): - confirm_delete_password: str - - @classmethod - async def as_form( - cls, - confirm_delete_password: str = Form(...), - ): - return cls(confirm_delete_password=confirm_delete_password) +# --- Routes --- -# --- Routes --- +@router.get("/profile") +async def read_profile( + request: Request, + user: User = Depends(get_authenticated_user), + email_update_requested: Optional[str] = "false", + email_updated: Optional[str] = "false" +): + # Add image constraints to the template context + return templates.TemplateResponse( + request, + "users/profile.html", { + "max_file_size_mb": MAX_FILE_SIZE / (1024 * 1024), # Convert bytes to MB + "min_dimension": MIN_DIMENSION, + "max_dimension": MAX_DIMENSION, + "allowed_formats": list(ALLOWED_CONTENT_TYPES.keys()), + "email_update_requested": email_update_requested, + "email_updated": email_updated, + "user": user + } + ) -@router.post("/update_profile", response_class=RedirectResponse) +@router.post("/update", response_class=RedirectResponse) async def update_profile( - user_profile: UpdateProfile = Depends(UpdateProfile.as_form), + name: Optional[str] = Form(None), + avatar_file: Optional[UploadFile] = File(None), user: User = Depends(get_authenticated_user), session: Session = Depends(get_session) ): # Handle avatar update - if user_profile.avatar_file: + if avatar_file: + avatar_data = await avatar_file.read() + avatar_content_type = avatar_file.content_type + processed_image, content_type = validate_and_process_image( - user_profile.avatar_file, - user_profile.avatar_content_type + avatar_data, + avatar_content_type ) - user_profile.avatar_file = processed_image - user_profile.avatar_content_type = content_type + user.avatar_data = processed_image + user.avatar_content_type = content_type # Update user details - user.name = user_profile.name - - if user_profile.avatar_file: - user.avatar_data = user_profile.avatar_file - user.avatar_content_type = user_profile.avatar_content_type + user.name = name session.commit() session.refresh(user) - return RedirectResponse(url="/profile", status_code=303) - - -@router.post("/delete_account", response_class=RedirectResponse) -async def delete_account( - user_delete_account: UserDeleteAccount = Depends( - UserDeleteAccount.as_form), - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) -): - if not user.password: - raise DataIntegrityError( - resource="User password" - ) - - if not verify_password( - user_delete_account.confirm_delete_password, - user.password.hashed_password - ): - raise PasswordValidationError( - field="confirm_delete_password", - message="Password is incorrect" - ) - - # Delete the user - session.delete(user) - session.commit() - - # Log out the user - return RedirectResponse(url="/auth/logout", status_code=303) + return RedirectResponse(url=router.url_path_for("read_profile"), status_code=303) @router.get("/avatar") async def get_avatar( - user: User = Depends(get_authenticated_user), - session: Session = Depends(get_session) + user: User = Depends(get_authenticated_user) ): """Serve avatar image from database""" if not user.avatar_data: diff --git a/templates/authentication/register.html b/templates/authentication/register.html index 6756ada..75c573a 100644 --- a/templates/authentication/register.html +++ b/templates/authentication/register.html @@ -13,14 +13,14 @@ - +
- +
@@ -33,7 +33,7 @@ Must contain at least one number, one uppercase and lowercase letter, one special character, and at least 8 or more characters
- +
@@ -44,13 +44,13 @@ Passwords do not match.
- +
- +

Already have an account? Login here

diff --git a/templates/authentication/reset_password.html b/templates/authentication/reset_password.html index bf6884b..d7fb341 100644 --- a/templates/authentication/reset_password.html +++ b/templates/authentication/reset_password.html @@ -8,15 +8,15 @@
- +
- - New Password + @@ -27,8 +27,8 @@
- - Confirm New Password +
Passwords do not match. @@ -47,8 +47,8 @@