Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ cython_debug/

# VS Code
.vscode/
.claude/

.envrc
serverless.yaml
Expand Down
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

**JavaScript, Vite, Reactjs, Redux Toolkit, Redux Sagas, Python, FastAPI, GraphQL, AsyncPG, Postgres**

[![Application Unit Tests](https://github.com/WillSams/example-js-react-with-python/actions/workflows/pr-validate.yml/badge.svg)](https://github.com/WillSams/eexample-js-react-with-python/actions/workflows/pr-validate.yml)
[![Application Unit Tests](https://github.com/WillSams/example-js-react-with-python/actions/workflows/pr-validate.yml/badge.svg)](https://github.com/WillSams/example-js-react-with-python/actions/workflows/pr-validate.yml)

This example contains a frontend and backend:

- The frontend is a [React](https://react.dev) application using [Bootstrap4](https://getbootstrap.com/docs/4.6/getting-started/introduction/) for view designs.
- The backend is a [GraphQL API](https://graphql.org) providing the ability to create, delete, and list reservatios plus available rooms for a given date range.
- The backend is a [GraphQL API](https://graphql.org) providing the ability to create, delete, and list reservations plus available rooms for a given date range.

React [Typescript](https://github.com/WillSams/example-ts-react-with-python) and [Express MVC](https://github.com/WillSams/example-mvc-expressjs-with-python) versions of this same idea are available.

Expand All @@ -17,7 +17,7 @@ An [abandoned](https://github.com/WillSams/example-mvc-expressjs-with-python/tre

- When a room is reserved, it cannot be reserved by another guest on overlapping dates.
- Whenever there are multiple available rooms for a request, the room with the lower final price is assigned.
- Whenever a request is made for a single room, a double bed room may be assigned (if no single is available?).
- Whenever a request is made for a single room, a double bed room may be assigned if no single is available.
- Smokers are not placed in non-smoking rooms.
- Non-smokers are not placed in allowed smoking rooms.
- Final price for reservations are determined by daily price * num of days requested, plus the cleaning fee.
Expand Down Expand Up @@ -77,11 +77,12 @@ The below are optional but highly recommended:

- [nvm](https://github.com/nvm-sh/nvm) - Used to manage NodeJS versions.
- [Direnv](https://direnv.net/) - Used to manage environment variables.
- Install [direnv](https://direnv.net) for persisting environment variables needed for development.

## Getting Started

First, we'll need to set up our environment variables. You can do this by either any of the methods mentioned in [/tools/ENV.md](./tools/ENV.md) but I recommend using [Direnv](https://direnv.net/).
First, we'll need to set up our environment variables. You can do this by any of the methods mentioned in [/tools/ENV.md](./tools/ENV.md) but I recommend using [Direnv](https://direnv.net/).

Key backend variables include `SECRET_KEY`, `REFRESH_SECRET_KEY`, `PG_URL`, and `ALLOWED_ORIGINS` (a comma-separated list of allowed frontend origins, e.g. `http://localhost:3000`).

### Install Python Packages

Expand All @@ -99,11 +100,11 @@ pip install -r requirements.txt
Execute the following within your terminal:

```bash
nvm use # To eliminate any issues, install/use the version listed in .nvmrc.
npm i # install the packages needed for project
cd ../frontend && npm i # install the packages needed for the frontend
cd ../db && npm i # install the packages needed for database migrations
cd .. # navigate back to the root of the repostiory
nvm use # To eliminate any issues, install/use the version listed in .nvmrc.
npm i # install the packages needed for project
cd frontend && npm i # install the packages needed for the frontend
cd ../db && npm i # install the packages needed for database migrations
cd .. # navigate back to the root of the repository
```

### Create the database
Expand All @@ -112,10 +113,11 @@ Finally, let's create and seed the databases and our Reservations and Rooms tabl

```bash
# Create the databases and seed them
NODE_ENV=development | npm run refresh && npm run seed
cd db
NODE_ENV=development npm run refresh && NODE_ENV=development npm run seed
```

During development, you can just execute `npm run dev:db-baseline` to refresh the database back to the original seed data.
During development, you can just execute `npm run dev:db-baseline` in the root of the project to refresh the database back to the original seed data.

## Development

Expand All @@ -126,17 +128,17 @@ docker-compose up -d # runs the database in the background
npm run dev
```

Also, you just execute the backend via `npm run dev:backend`. to verify the backend is working:
Also, you can just execute the backend via `npm run dev:backend`. To verify the backend is working:

```bash
curl http://localhost:$RESERVATION_PORT/$ENV/about
```

You can also acces the Ariadne GraphiQL (interactive test playground) instance at [http://localhost:$RESERVATION_PORT/$ENV/graphql](http://localhost:$PLAYGROUND_PORT/$ENV/graphql).
You can also access the Ariadne GraphiQL (interactive test playground) instance at [http://localhost:$RESERVATION_PORT/$ENV/graphql](http://localhost:$RESERVATION_PORT/$ENV/graphql).

## Testing

The backend uses [Pytest](https://docs.pytest.org) and the frontend uses [Jest](https://jestjs.io/). To run these tests, simply execute `npm run test:backend` or `npm run test:frontend', respectively.
The backend uses [Pytest](https://docs.pytest.org) and the frontend uses [Jest](https://jestjs.io/). To run these tests, simply execute `npm run test:backend` or `npm run test:frontend`, respectively.

## Containerization

Expand Down
3 changes: 2 additions & 1 deletion backend/specs/resolvers/when_querying_for_available_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ async def should_get_available_rooms_all_available(self, mocker):
mocker.AsyncMock(return_value=mock_pool),
)
mocker.patch(
"api.resolvers.data.fetch_all_rows", mocker.AsyncMock(return_value=rooms)
"api.resolvers.data.fetch_all_rows",
mocker.AsyncMock(return_value={"success": True, "rooms": rooms}),
)
mocker.patch(
"api.resolvers.data.fetch_room",
Expand Down
3 changes: 3 additions & 0 deletions backend/src/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ class DummyPool:
async def close(self):
pass

async def execute(self, *args, **kwargs):
pass

async def fetch(self, *args, **kwargs):
pass

Expand Down
16 changes: 8 additions & 8 deletions backend/src/api/resolvers/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def create_reservation(
return reservations


async def delete_reservation(db, reservation_id: str) -> Dict[str, Any]:
async def delete_reservation(db, reservation_id: int) -> Dict[str, Any]:
reservation = await fetch_reservation(db, reservation_id)
if reservation:
await db.execute("DELETE FROM reservations WHERE id = $1", reservation_id)
Expand Down Expand Up @@ -80,11 +80,8 @@ async def fetch_all_rows(db, entity_type) -> Dict[str, Any]:
table_name = entity_type.__name__.lower() + "s"
query = f"SELECT * FROM {table_name}"
rows = await db.fetch(query)
if rows:
entities = [entity_type(**dict(row)) for row in rows]
return {"success": True, f"{table_name}": entities}
else:
raise ValueError("No reserved rooms found")
entities = [entity_type(**dict(row)) for row in rows] if rows else []
return {"success": True, f"{table_name}": entities}


async def fetch_by_id(db, entity_type, id) -> Dict[str, Any]:
Expand All @@ -99,11 +96,14 @@ async def fetch_by_id(db, entity_type, id) -> Dict[str, Any]:


async def fetch_available_rooms(db, checkin_date, checkout_date) -> Dict[str, Any]:
rooms = await fetch_all_rows(db, Room)
result = await fetch_all_rows(db, Room)
rooms = result.get("rooms", [])
available_rooms = [
room
for room in rooms
if await is_room_available(db, room.id, checkin_date, checkout_date)
if (await is_room_available(db, room.id, checkin_date, checkout_date))[
"success"
]
]
return {"success": True, "rooms": available_rooms}

Expand Down
8 changes: 5 additions & 3 deletions backend/src/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone

from jose import jwt

Expand All @@ -12,9 +12,11 @@

def create_token(subject: str, secret_key: str, expires_delta: timedelta) -> str:
if expires_delta is not None:
expires_at = datetime.utcnow() + expires_delta
expires_at = datetime.now(timezone.utc) + expires_delta
else:
expires_at = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
expires_at = datetime.now(timezone.utc) + timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
)

to_encode = {"exp": expires_at, "sub": subject}
encoded_jwt = jwt.encode(to_encode, secret_key, ALGORITHM)
Expand Down
11 changes: 4 additions & 7 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@
from routes import create_app
from routes.about import AboutRoute
from routes.graphql import GraphqlRoute
from settings import ACCESS_TOKEN_EXPIRE_MINUTES
from settings import ACCESS_TOKEN_EXPIRE_MINUTES, ALLOWED_ORIGINS

app = create_app(AboutRoute(), GraphqlRoute())

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=ALLOWED_ORIGINS,
allow_methods=["*"],
allow_headers=["*"],
)
Expand Down Expand Up @@ -62,11 +61,9 @@ def main() -> None:
port = int(utils.api_port())
if utils.is_debug():
uvicorn.run("main:app", host=host, port=port, reload=True)
else: # note: file logging occurs when not in debug mode
uvicorn.run(app, host=host, port=port)

if utils.is_debug():
utils.logger_exit_message()
else:
uvicorn.run(app, host=host, port=port)


if __name__ == "__main__":
Expand Down
7 changes: 2 additions & 5 deletions backend/src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@
API_PORT = getenv("RESERVATION_PORT") or 80
ENV = getenv("ENV")
DB_URL = getenv("PG_URL")
ALLOWED_ORIGINS = getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")

IS_DEBUG = bool(int(getenv("IS_DEBUG", "0"))) or False

TOKEN_EXPIRATION_IN_MINUTES = 30
SECRET_KEY = getenv("SECRET_KEY")
ALGORITHM = "HS256"

ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 30 minutes
ACCESS_TOKEN_EXPIRE_MINUTES = 60
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
ALGORITHM = "HS256"

SECRET_KEY = getenv("SECRET_KEY")
REFRESH_SECRET_KEY = getenv("REFRESH_SECRET_KEY")
2 changes: 1 addition & 1 deletion frontend/src/screens/home/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const screen = connectComponent(HomeComponent, {
load: {
reservations: () => ({ type: actionTypes.GET_RESERVATIONS }),
},
actionCreators: (dispatch) => ({
mapDispatchToProps: (dispatch) => ({
handleCloseAlert: () => dispatch({ type: actionTypes.CLEAR_ALERT }),
handleConfirmAction: () =>
dispatch({ type: actionTypes.CONFIRM_CONFIRMATION_MODAL }),
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/screens/home/sagas/cancelReservation.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { fetchQuery, deleteReservationMutation } from '@/shared/graphql';
export function* confirmation(reservationId) {
yield put({
type: actionTypes.OPEN_CONFIRMATION_MODAL,
title: 'Are you sure you?',
message: `You will not be able to reverse cancellation (id: ${reservationId}).`,
title: 'Are you sure?',
message: `You will not be able to reverse cancellation (id: ${reservationId}).`,
cancellationText: 'Cancel',
buttonStyle: 'danger',
});
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/screens/reservations/new.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const screen = connectComponent(NewReservationComponent, {
load: {
roomIds: () => ({ type: actionTypes.GET_ROOM_IDS }),
},
actionCreators: (dispatch) => ({
mapDispatchToProps: (dispatch) => ({
createReservation: (formData) =>
dispatch({ type: actionTypes.CREATE_RESERVATION, ...formData }),
handleCloseAlert: () => dispatch({ type: actionTypes.CLEAR_ALERT }),
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/shared/base/actionTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ export const actionTypes = {
// Site Components - Invalid Route Component Actions
LOAD_HOME_COMPONENT: 'site/home/LOAD_HOME_COMPONENT',
LOAD_HOME_COMPONENT_SUCCESS: 'site/home/LOAD_HOME_COMPONENT_SUCCESS',
LOAD_HOME_COMPONENT_FAILED: 'site/home/LOAD_HOME_COMPONENT_SUCCESS',
LOAD_HOME_COMPONENT_FAILED: 'site/home/LOAD_HOME_COMPONENT_FAILED',

UNLOAD_HOME_COMPONENT: 'site/home/UNLOAD_HOME_COMPONENT',
UNLOAD_HOME_COMPONENT_SUCCESS: 'site/home/UNLOAD_HOME_COMPONENT_SUCCESS',
UNLOAD_HOME_COMPONENT_FAILED: 'site/home/UNLOAD_HOME_COMPONENT_SUCCESS',
UNLOAD_HOME_COMPONENT_FAILED: 'site/home/UNLOAD_HOME_COMPONENT_FAILED',

HOME_COMPONENT: 'site/home/HOME_COMPONENT',
GET_RESERVATIONS: 'site/home/GET_RESERVATIONS',
Expand All @@ -57,14 +57,14 @@ export const actionTypes = {
LOAD_NEW_RESERVATION_COMPONENT_SUCCESS:
'site/reservations/LOAD_NEW_RESERVATION_COMPONENT_SUCCESS',
LOAD_NEW_RESERVATION_COMPONENT_FAILED:
'site/reservations/LOAD_NEW_RESERVATION_COMPONENT_SUCCESS',
'site/reservations/LOAD_NEW_RESERVATION_COMPONENT_FAILED',

UNLOAD_NEW_RESERVATION_COMPONENT:
'site/reservations/UNLOAD_NEW_RESERVATION_COMPONENT',
UNLOAD_NEW_RESERVATION_COMPONENT_SUCCESS:
'site/reservations/UNLOAD_NEW_RESERVATION_COMPONENT_SUCCESS',
UNLOAD_NEW_RESERVATION_COMPONENT_FAILED:
'site/reservations/UNLOAD_NEW_RESERVATION_COMPONENT_SUCCESS',
'site/reservations/UNLOAD_NEW_RESERVATION_COMPONENT_FAILED',

NEW_RESERVATION_COMPONENT: 'site/reservations/NEW_RESERVATION_COMPONENT',
GET_ROOM_IDS: 'site/reservations/GET_ROOM_IDS',
Expand All @@ -86,14 +86,14 @@ export const actionTypes = {
LOAD_EDIT_RESERVATION_COMPONENT_SUCCESS:
'site/reservations/edit/LOAD_EDIT_RESERVATION_COMPONENT_SUCCESS',
LOAD_EDIT_RESERVATION_COMPONENT_FAILED:
'site/reservations/edit/LOAD_EDIT_RESERVATION_COMPONENT_SUCCESS',
'site/reservations/edit/LOAD_EDIT_RESERVATION_COMPONENT_FAILED',

UNLOAD_EDIT_RESERVATION_COMPONENT:
'site/reservations/edit/UNLOAD_EDIT_RESERVATION_COMPONENT',
UNLOAD_EDIT_RESERVATION_COMPONENT_SUCCESS:
'site/reservations/edit/UNLOAD_EDIT_RESERVATION_COMPONENT_SUCCESS',
UNLOAD_EDIT_RESERVATION_COMPONENT_FAILED:
'site/reservations/edit/UNLOAD_EDIT_RESERVATION_COMPONENT_SUCCESS',
'site/reservations/edit/UNLOAD_EDIT_RESERVATION_COMPONENT_FAILED',

EDIT_RESERVATION_COMPONENT: 'site/reservations/update/NEW_RESERVATION',

Expand All @@ -108,14 +108,14 @@ export const actionTypes = {
LOAD_SHOW_RESERVATION_COMPONENT_SUCCESS:
'site/reservations/show/LOAD_SHOW_RESERVATION_COMPONENT_SUCCESS',
LOAD_SHOW_RESERVATION_COMPONENT_FAILED:
'site/reservations/show/LOAD_SHOW_RESERVATION_COMPONENT_SUCCESS',
'site/reservations/show/LOAD_SHOW_RESERVATION_COMPONENT_FAILED',

UNLOAD_SHOW_RESERVATION_COMPONENT:
'site/reservations/show/UNLOAD_SHOW_RESERVATION_COMPONENT',
UNLOAD_SHOW_RESERVATION_COMPONENT_SUCCESS:
'site/reservations/show/UNLOAD_SHOW_RESERVATION_COMPONENT_SUCCESS',
UNLOAD_SHOW_RESERVATION_COMPONENT_FAILED:
'site/reservations/show/UNLOAD_SHOW_RESERVATION_COMPONENT_SUCCESS',
'site/reservations/show/UNLOAD_SHOW_RESERVATION_COMPONENT_FAILED',

SHOW_RESERVATION_COMPONENT: 'site/reservations/show/RESERVATION',
GET_RESERVATION: 'site/reservations/show/GET_RESERVATION',
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/shared/base/baseApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ const handleResponse = (response, store) => {

const handleResponseError = (error, store) => {
const { message, name } = error;
store.dispatch({ type: actionTypes.API_REQUEST_DONE });
store.dispatch({
type: actionTypes.API_REQUEST_ERROR,
error: { message, name },
Expand Down Expand Up @@ -71,8 +70,7 @@ export const createBaseApi = async (url, store) => {

return instance;
} catch (error) {
//console.error('Error fetching token:', error);
return error;
throw new Error(`Failed to initialize API: ${error.message}`);
}
};

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/shared/base/connectComponent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useLoadComponent } from '@/shared/hooks';
/*
type Config = {
state: (state: any, ownProps: any) => object;
actionCreators?: (dispatch: Redux.Dispatch) => object;
mapDispatchToProps?: (dispatch: Redux.Dispatch) => object;
componentName: string;
load?: object;
};
Expand Down Expand Up @@ -35,8 +35,8 @@ export const connectComponent = (WrappedComponent, config) => {
};
};
const mapDispatchToProps = (dispatch) => {
const dispatchFromConfig = config.actionCreators
? config.actionCreators(dispatch)
const dispatchFromConfig = config.mapDispatchToProps
? config.mapDispatchToProps(dispatch)
: {};

dispatchFromConfig.getDispatch = () => dispatch;
Expand Down
Loading
Loading