diff --git a/.dependabot/config.yml b/.dependabot/config.yml new file mode 100644 index 0000000..4fd947e --- /dev/null +++ b/.dependabot/config.yml @@ -0,0 +1,11 @@ +version: 1 +update_configs: + # Keep package.json (& lockfiles) up to date as soon as + # new versions are published to the npm registry + - package_manager: 'javascript' + directory: '{{cookiecutter.project_slug}}/frontend' + update_schedule: 'monthly' + # Keep Dockerfile up to date, batching pull requests weekly + - package_manager: 'python' + directory: '{{cookiecutter.project_slug}}/backend' + update_schedule: 'monthly' diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml new file mode 100644 index 0000000..16ea6a0 --- /dev/null +++ b/.github/workflows/config.yml @@ -0,0 +1,33 @@ +name: build + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + sudo python -m pip install --upgrade pip + sudo pip install cookiecutter + - name: Prettify code + uses: creyD/prettier_action@v2.2 + with: + prettier_options: --write **/*.{ts,tsx,md} + - name: Black Code Formatter + uses: lgeiger/black-action@master + with: + args: '{{cookiecutter.project_slug}}/backend --check --line-length 79' + - name: Run tests + run: | + sudo ./scripts/test.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..022d478 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +fastapi-react +node_modules/ +package-lock.json \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..37ea7ee --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing + +## Development + +### Helper Scripts + +You can use the helper script `scripts/dev-project.sh` to create a cookiecutter +project to test locally. Do this from outside of the root directory to avoid +accidentally commiting test builds. For example: + +```bash +./fastapi-react/scripts/dev-project.sh +``` + +This will then create a `dev-fastapi-react` directory. + +```bash +cd dev-fastapi-react +docker-compose up -d +``` + +When developing locally, there is also a helper script that will create a cookiecutter directory, build containers, and run tests all from within the root project directory. This can be kind of a tedious process with cookiecutter so this makes it somewhat less painful. From the root `fastapi-react` directory, simply run: + +```bash +./scripts/test_local.sh +``` + +## Pull Requests + +Use the general [feature branch +workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/feature-branch-workflow) +for development. After a feature is complete, make a pull request and wait for 1 +approval before merging. + +Try to keep PRs as small and focused as possible. If you are making a big +breaking change in production and don't want to expose half finished +functionality to users, you can use [feature +flags](https://www.martinfowler.com/articles/feature-toggles.html) to work on +this incrementally. A big PR is much less likely to be approved + +## Linting + +Please run Black code formatter on the backend code and Prettier on the frontend +code. Take a look at [the Github action](.github/workflows/config.yml) for an example of this. + +## Where to Start + +Start by browsing through the [list of issues](https://github.com/Buuntu/fastapi-react/issues), particularly those flagged as [help wanted](https://github.com/Buuntu/fastapi-react/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad76fb3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Gabriel Abud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7ac228f..f31b5e7 100644 --- a/README.md +++ b/README.md @@ -1 +1,383 @@ -# fastapi-react-slim \ No newline at end of file +# FastAPI + React · ![build](https://github.com/Buuntu/fastapi-react/workflows/build/badge.svg) [![license](https://img.shields.io/github/license/peaceiris/actions-gh-pages.svg)](LICENSE) [![Dependabot Status](https://img.shields.io/badge/Dependabot-active-brightgreen.svg)](https://dependabot.com) + +
+fastapi-logo react-logo     react-admin     react-logo     +react-logo sql-alchemy +
+ +A cookiecutter template for bootstrapping a FastAPI and React project using a +modern stack. + +--- + +## Features + +- **[FastAPI](https://fastapi.tiangolo.com/)** (Python 3.8) + - JWT authentication using [OAuth2 "password + flow"](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/) and + PyJWT +- **[React](https://reactjs.org/)** (with Typescript) + - [react-router v5](https://reacttraining.com/react-router/) to handle routing + - [Utility functions](#Frontend-Utilities) and [higher-order + components](#Higher-Order-Components) for handling authentication +- **[PostgreSQL](https://www.postgresql.org/)** for the database +- **[SqlAlchemy](https://www.sqlalchemy.org/)** for ORM +- **[Celery](http://www.celeryproject.org/)** for [background + tasks](#background-tasks) and [Redis](https://redis.io/) as a message broker + - Includes [Flower](https://flower.readthedocs.io/en/latest/) for task + monitoring +- **[Alembic](https://alembic.sqlalchemy.org/en/latest/)** for database + migrations +- **[Pytest](https://docs.pytest.org/en/latest/)** for backend tests + - Includes test database, transaction rollbacks after each test, and reusable + [Pytest fixtures](#fixtures). +- **[Prettier](https://prettier.io/)**/**[ESLint](https://eslint.org/)** (Airbnb + style guide) +- **[Docker Compose](https://docs.docker.com/compose/)** for development +- **[Nginx](https://www.nginx.com/)** as a reverse proxy to allow + backend/frontend on the same port +- **[MaterialUI](https://material-ui.com/)** using recommended + [CSS-in-JS](https://material-ui.com/styles/basics/) styling. +- **[react-admin](https://github.com/marmelab/react-admin)** for the admin + dashboard + - Using the same token based authentication as FastAPI backend (JWT) + +## Table of Contents + +- [Background](#background) +- [Quick Start](#quick-start) +- [Develop](#develop) +- [Admin Dashboard](#admin-dashboard) +- [Security](#security) +- [Testing](#testing) + - [Fixtures](#fixtures) + - [test_db](#test_db) + - [test_user](#test_user) + - [test_superuser](#test_superuser) + - [client](#client) + - [user_token_headers](#user_token_headers) + - [superuser_token_headers](#superuser_token_headers) +- [Background Tasks](#background-tasks) + - [Flower](#flower) +- [Frontend Utilities](#frontend-utilities) + - [Utility Functions](#utility-functions) + - [login](#login) + - [logout](#logout) + - [isAuthenticated](#isauthenticated) + - [Routes](#routes) + - [Higher Order Components](#higher-order-components) + - [PrivateRoute](#privateroute) +- [Deployment](#deployment) +- [Contributing](#contributing) + +## Background + +It is often laborsome to start a new project. 90% of the time you have to decide +how to handle authentication, reverse proxies, docker containers, testing, +server-side validation, linting, etc. before you can even get started. + +**FastAPI-React** serves to streamline and give you that functionality out of +the box. + +It is meant as a lightweight/React alternative to [FastAPI's official fullstack +project](https://github.com/tiangolo/full-stack-fastapi-postgresql). If you want +a more comprehensive project in Vue, I would suggest you start there. A lot of +the backend code is taken from that project or the [FastAPI official +docs](https://fastapi.tiangolo.com/). + +## Quick Start + +First, install cookiecutter if you don't already have it: + +```bash +pip3 install cookiecutter +``` + +Second, install docker-compose if you don't already have it: + +[docker-compose installation official +docs](https://docs.docker.com/compose/install/). + +Then, in the directory you want your project to live: + +```bash +cookiecutter gh:Buuntu/fastapi-react +``` + +You will need to put in a few variables and it will create a project directory +(called whatever you set for `project_slug`). + +
Input Variables + +- project_name [default fastapi-react-project] +- project_slug [default fastapi-react-project] - this is your project directory +- port [default 8000] +- postgres_user [default postgres] +- postgres_password [default password] +- postgres_database [default app] +- superuser_email [default admin@fastapi-react-project.com] +- superuser_password [default password] +- secret_key [default super_secret] + +
+ +## Develop + +Change into your project directory and run: + +```bash +chmod +x scripts/build.sh +./scripts/build.sh +``` + +This will build and run the docker containers, run the alembic migrations, and +load the initial data (a test user). + +It may take a while to build the first time it's run since it needs to fetch all +the docker images. + +Once you've built the images once, you can simply use regular `docker-compose` +commands to manage your development environment, for example to start your +containers: + +```bash +docker-compose up -d +``` + +Once this finishes you can navigate to the port set during setup (default is +`localhost:8000`), you should see the slightly modified create-react-app page: + +![default create-react-app](assets/create-react-app.png) + +_Note: If you see an Nginx error at first with a `502: Bad Gateway` page, you +may have to wait for webpack to build the development server (the nginx +container builds much more quickly)._ + +Login screen: ![regular login](assets/regular-login.png) + +The backend docs will be at `http://localhost:8000/api/docs`. ![API +Docs](assets/api-docs.png) + +## Admin Dashboard + +This project uses [react-admin](https://marmelab.com/react-admin/) for a highly +configurable admin dashboard. + +After starting the project, navigate to `http://localhost:8000/admin`. You +should see a login screen. Use the username/password you set for the superuser +on project setup. + +_NOTE: regular users will not be able to access the admin dashboard_ + +![React Adming Login](assets/login-screen.png) + +You should now see a list of users which you can edit, add, and delete. The +table is configured with the REST endpoints to the FastAPI `/users` routes in +the backend. + +![React Admin Dashboard](assets/admin-dashboard.png) + +The admin dashboard is kept in the `frontend/src/admin` directory to keep it +separate from the regular frontend. + +## Security + +To generate a secure key used for encrypting/decrypting the JSON Web Tokens, you +can run this command: + +```bash +openssl rand -hex 32 +``` + +The default is fine for development but you will want something more secure for +production. + +You can either set this on project setup as `secret_key` or manually edit the +Python `SECRET_KEY` variable in `backend/app/core/security.py`. + +## Testing + +This project comes with Pytest and a few Pytest fixtures for easier mocking. The +fixtures are all located in `backend/conftest.py` within your project directory. + +All tests are configured to run on a test database using [SQLAlchemy +transactions](https://docs.sqlalchemy.org/en/13/orm/session_transaction.html) to +reset the testing state on each function. This is to avoid a database call +affecting the state of a different test. + +### Fixtures + +These fixtures are included in `backend/conftest.py` and are automatically +imported into any test files that being with `test_`. + +#### test_db + +The `test_db` fixture is an empty test database and an instance of a SQLAlchemy +Session class. + +```python +def test_user(test_db): + assert test_db.query(models.User).all() +``` + +#### test_user + +```python +def test_user_exists(test_user): + assert test_user.email == "admin@example.com" +``` + +#### test_superuser + +```python +def test_superuser(client, test_superuser): + assert test_superuser.is_superuser +``` + +#### client + +To use an unauthenticated test client, use `client`: + +```python +def test_get_users(client): + client.get("/api/v1/users") + assert response.status_code == 200 +``` + +#### user_token_headers + +If you need an authenticated client using OAuth2 and JWTs: + +```python +def test_user_me(client, user_token_headers): + response = client.get( + "/api/v1/users/me", + headers=user_token_headers, + ) + assert response.status_code == 200 +``` + +Since OAuth2 expects the access token in the headers, you will need to pass in +`user_token_headers` as the `headers` argument in any client request that +requires authentication. + +#### superuser_token_headers + +```python +def test_user_me(client, superuser_token_headers): + response = client.get( + "/api/v1/users", + headers=superuser_token_headers, + ) + assert response.status_code == 200 +``` + +## Background Tasks + +This template comes with Celery and Redis Docker containers pre-configured for +you. For any long running processes, it's recommended that you handle these +using a task queue like Celery to avoid making the client wait for a request to +finish. Some examples of this might be sending emails, uploading large files, or +any long running, resource intensive tasks. + +There is an example task in `backend/app/tasks.py` and an example Celery test in +`backend/app/tests/test_tasks.py`. This test runs synchronously, which is what +Celery docs recommend. + +If you are not happy with Celery or Redis, it should be easy to swap these +containers out with your favorite tools. Some suggested alternatives might be +[Huey](https://github.com/coleifer/huey) as the task queue and +[RabbitMQ](https://www.rabbitmq.com/) for the message broker. + +### Flower + +You can monitor tasks using Flower by going to http://localhost:5555 + +## Frontend Utilities + +There are a few helper methods to handle authentication in `frontend/src/utils`. +These store and access the JWT returned by FastAPI in local storage. Even though +this doesn't add any security, we prevent loading routes that might be protected +on the frontend, which results in a better UX experience. + +### Utility Functions + +#### login + +```typescript +// in src/utils/auth.ts + +/** + * Handles authentication with backend and stores in JWT in local storage + **/ +const login = (email: string, password: string) => boolean; +``` + +#### logout + +```typescript +// in src/utils/auth.ts + +// clears token from local storage +const logout = (email: string, password: string) => void; +``` + +#### isAuthenticated + +```typescript +// Checks authenticated state from JWT tokens +const isAuthenticated = () => boolean; +``` + +### Routes + +Some basic routes are included (and handled in `frontend/Routes.tsx`). + +- `/login` - Login screen +- `/logout` - Logout +- `/` - Home +- `/protected` - Example of protected route + +### Higher Order Components + +#### PrivateRoute + +This handles routes that require authentication. It will automatically check +whether the correct token with the "user" permissions is present or redirect to +the home page. + +```JSX +// in src/Routes.tsx +import { Switch } from 'react-router-dom'; + +// Replace this with your component +import { ProtectedComponent } from 'components'; + +const Routes = () => ( + + + +); +``` + + + +## Deployment + +This stack can be adjusted and used with several deployment options that are +compatible with Docker Compose, but it may be easiest to use Docker in Swarm +Mode with an Nginx main load balancer proxy handling automatic HTTPS +certificates, using the ideas from DockerSwarm.rocks. + +Please refer to DockerSwarm.rocks to see how to deploy such a cluster easily. +You will have to change the Traefik examples to Nginx or update your +docker-compose file. + +## Contributing + +Contributing is more than welcome. Please read the [Contributing +doc](CONTRIBUTING.md) to find out more. diff --git a/assets/admin-dashboard.png b/assets/admin-dashboard.png new file mode 100644 index 0000000..6e279f5 Binary files /dev/null and b/assets/admin-dashboard.png differ diff --git a/assets/api-docs.png b/assets/api-docs.png new file mode 100644 index 0000000..6ee3b09 Binary files /dev/null and b/assets/api-docs.png differ diff --git a/assets/create-react-app.png b/assets/create-react-app.png new file mode 100644 index 0000000..a2df4b9 Binary files /dev/null and b/assets/create-react-app.png differ diff --git a/assets/fastapi-logo.png b/assets/fastapi-logo.png new file mode 100644 index 0000000..57d9eec Binary files /dev/null and b/assets/fastapi-logo.png differ diff --git a/assets/login-screen.png b/assets/login-screen.png new file mode 100644 index 0000000..8855163 Binary files /dev/null and b/assets/login-screen.png differ diff --git a/assets/postgres.png b/assets/postgres.png new file mode 100644 index 0000000..897ddd9 Binary files /dev/null and b/assets/postgres.png differ diff --git a/assets/react-admin.png b/assets/react-admin.png new file mode 100644 index 0000000..58ec366 Binary files /dev/null and b/assets/react-admin.png differ diff --git a/assets/react-logo.png b/assets/react-logo.png new file mode 100644 index 0000000..af9faae Binary files /dev/null and b/assets/react-logo.png differ diff --git a/assets/regular-login.png b/assets/regular-login.png new file mode 100644 index 0000000..f2c2880 Binary files /dev/null and b/assets/regular-login.png differ diff --git a/assets/sql-alchemy.png b/assets/sql-alchemy.png new file mode 100644 index 0000000..b39d0ad Binary files /dev/null and b/assets/sql-alchemy.png differ diff --git a/assets/typescript.png b/assets/typescript.png new file mode 100644 index 0000000..4500d29 Binary files /dev/null and b/assets/typescript.png differ diff --git a/cookiecutter.json b/cookiecutter.json new file mode 100644 index 0000000..d7b3507 --- /dev/null +++ b/cookiecutter.json @@ -0,0 +1,11 @@ +{ + "project_slug": "fastapi-react-project", + "project_name": "{{ cookiecutter.project_slug }}", + "port": "8000", + "postgres_user": "postgres", + "postgres_password": "password", + "postgres_database": "app", + "superuser_email": "admin@{{cookiecutter.project_name}}.com", + "superuser_password": "password", + "secret_key": "super_secret" +} diff --git a/scripts/dev-project.sh b/scripts/dev-project.sh new file mode 100755 index 0000000..659c4db --- /dev/null +++ b/scripts/dev-project.sh @@ -0,0 +1,14 @@ + +#!/bin/bash + +# Exit in case of error +set -e + +if [ ! -d ./fastapi-react ] ; then + echo "Run this script from outside the project, to generate a sibling dev project" + exit 1 +fi + +rm -rf ./dev-fastapi-react + +python -m cookiecutter --no-input -f ./fastapi-react project_slug="dev-fastapi-react" \ No newline at end of file diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..7656db4 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,28 @@ +#! /usr/bin/env bash + +# Exit in case of error +set -e + +# Run this from the root of the project +cookiecutter --no-input -f ./ project_slug="testing-project" + +cd testing-project + +docker-compose build +docker-compose down -v --remove-orphans +docker-compose up -d +# Run migrations first +docker-compose run --rm backend alembic upgrade head + +# Backend/frontend tests +./scripts/test.sh + +# Cleanup +docker-compose down -v --remove-orphans + +# only remove directory if running locally +if [[ -z "$CIRCLE_CI_ENV" ]]; then + echo "empty" + cd .. + rm -rf testing-project +fi diff --git a/scripts/test_local.sh b/scripts/test_local.sh new file mode 100755 index 0000000..26ca710 --- /dev/null +++ b/scripts/test_local.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Exit in case of error +set -e + +# Move out of top-level dir +cd .. + +# Generate local cookiecutter project, bring up in docker and test +./fastapi-react/scripts/dev-project.sh +cd dev-fastapi-react +docker-compose down -v --remove-orphans +./scripts/build.sh +./scripts/test.sh \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/.prettierignore b/{{cookiecutter.project_slug}}/.prettierignore new file mode 100644 index 0000000..412c257 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.prettierignore @@ -0,0 +1 @@ +docker-compose.yml \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md new file mode 100644 index 0000000..0c77e44 --- /dev/null +++ b/{{cookiecutter.project_slug}}/README.md @@ -0,0 +1,159 @@ +# {{cookiecutter.project_name}} + +## Features + +- **FastAPI** with Python 3.8 +- **React 16** with Typescript, Redux, and react-router +- Postgres +- SqlAlchemy with Alembic for migrations +- Pytest for backend tests +- Jest for frontend tests +- Perttier/Eslint (with Airbnb style guide) +- Docker compose for easier development +- Nginx as a reverse proxy to allow backend and frontend on the same port + +## Development + +The only dependencies for this project should be docker and docker-compose. + +### Quick Start + +Starting the project with hot-reloading enabled +(the first time it will take a while): + +```bash +docker-compose up -d +``` + +To run the alembic migrations (for the users table): + +```bash +docker-compose run --rm backend alembic upgrade head +``` + +And navigate to http://localhost:{{cookiecutter.port}} + +_Note: If you see an Nginx error at first with a `502: Bad Gateway` page, you may have to wait for webpack to build the development server (the nginx container builds much more quickly)._ + +Auto-generated docs will be at +http://localhost:{{cookiecutter.port}}/api/docs + +### Rebuilding containers: + +``` +docker-compose build +``` + +### Restarting containers: + +``` +docker-compose restart +``` + +### Bringing containers down: + +``` +docker-compose down +``` + +### Frontend Development + +Alternatively to running inside docker, it can sometimes be easier +to use npm directly for quicker reloading. To run using npm: + +``` +cd frontend +npm install +npm start +``` + +This should redirect you to http://localhost:3000 + +### Frontend Tests + +``` +cd frontend +npm install +npm test +``` + +## Migrations + +Migrations are run using alembic. To run all migrations: + +``` +docker-compose run --rm backend alembic upgrade head +``` + +To create a new migration: + +``` +alembic revision -m "create users table" +``` + +And fill in `upgrade` and `downgrade` methods. For more information see +[Alembic's official documentation](https://alembic.sqlalchemy.org/en/latest/tutorial.html#create-a-migration-script). + +## Testing + +There is a helper script for both frontend and backend tests: + +``` +./scripts/test.sh +``` + +### Backend Tests + +``` +docker-compose run backend pytest +``` + +any arguments to pytest can also be passed after this command + +### Frontend Tests + +``` +docker-compose run frontend test +``` + +This is the same as running npm test from within the frontend directory + +## Logging + +``` +docker-compose logs +``` + +Or for a specific service: + +``` +docker-compose logs -f name_of_service # frontend|backend|db +``` + +## Project Layout + +``` +backend +└── app + ├── alembic + │ └── versions # where migrations are located + ├── api + │ └── api_v1 + │ └── endpoints + ├── core # config + ├── db # db models + ├── tests # pytest + └── main.py # entrypoint to backend + +frontend +└── public +└── src + ├── components + │ └── Home.tsx + ├── config + │ └── index.tsx # constants + ├── __tests__ + │ └── test_home.tsx + ├── index.tsx # entrypoint + └── App.tsx # handles routing +``` diff --git a/{{cookiecutter.project_slug}}/backend/Dockerfile b/{{cookiecutter.project_slug}}/backend/Dockerfile new file mode 100644 index 0000000..10aef33 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/Dockerfile @@ -0,0 +1,13 @@ + +FROM python:3.8 + +RUN mkdir /app +WORKDIR /app + +RUN apt update && \ + apt install -y postgresql-client + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/backend/alembic.ini b/{{cookiecutter.project_slug}}/backend/alembic.ini new file mode 100644 index 0000000..53e6f13 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/alembic.ini @@ -0,0 +1,82 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = app/alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +timezone = America/Los_Angeles + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/backend/app/__init__.py b/{{cookiecutter.project_slug}}/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic.ini b/{{cookiecutter.project_slug}}/backend/app/alembic.ini new file mode 100644 index 0000000..bfcc3c7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/README b/{{cookiecutter.project_slug}}/backend/app/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/__init__.py b/{{cookiecutter.project_slug}}/backend/app/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/env.py b/{{cookiecutter.project_slug}}/backend/app/alembic/env.py new file mode 100644 index 0000000..5cbeca0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/env.py @@ -0,0 +1,81 @@ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from app.db.models import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_url(): + return os.getenv("DATABASE_URL") + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + Calls to context.execute() here emit the given string to the + script output. + """ + # url = config.get_main_option("sqlalchemy.url") + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + In this scenario we need to create an Engine + and associate a connection with the context. + """ + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/script.py.mako b/{{cookiecutter.project_slug}}/backend/app/alembic/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/{{cookiecutter.project_slug}}/backend/app/alembic/versions/91979b40eb38_create_users_table.py b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/91979b40eb38_create_users_table.py new file mode 100644 index 0000000..af661b4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/alembic/versions/91979b40eb38_create_users_table.py @@ -0,0 +1,34 @@ +"""create users table + +Revision ID: 91979b40eb38 +Revises: +Create Date: 2020-03-23 14:53:53.101322 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "91979b40eb38" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "user", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("email", sa.String(50), nullable=False), + sa.Column("first_name", sa.String(100)), + sa.Column("last_name", sa.String(100)), + sa.Column("address", sa.String(100)), + sa.Column("hashed_password", sa.String(100), nullable=False), + sa.Column("is_active", sa.Boolean, nullable=False), + sa.Column("is_superuser", sa.Boolean, nullable=False), + ) + + +def downgrade(): + op.drop_table("user") diff --git a/{{cookiecutter.project_slug}}/backend/app/api/__init__.py b/{{cookiecutter.project_slug}}/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/api/api_v1/__init__.py b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/__init__.py b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/auth.py b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/auth.py new file mode 100644 index 0000000..05247f5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/auth.py @@ -0,0 +1,63 @@ +from fastapi.security import OAuth2PasswordRequestForm +from fastapi import APIRouter, Depends, HTTPException, status +from datetime import timedelta + +from app.db.session import get_db +from app.core import security +from app.core.auth import authenticate_user, sign_up_new_user + +auth_router = r = APIRouter() + + +@r.post("/token") +async def login( + db=Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() +): + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta( + minutes=security.ACCESS_TOKEN_EXPIRE_MINUTES + ) + if user.is_superuser: + permissions = "admin" + else: + permissions = "user" + access_token = security.create_access_token( + data={"sub": user.email, "permissions": permissions}, + expires_delta=access_token_expires, + ) + + return {"access_token": access_token, "token_type": "bearer"} + + +@r.post("/signup") +async def signup( + db=Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() +): + user = sign_up_new_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Account already exists", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta( + minutes=security.ACCESS_TOKEN_EXPIRE_MINUTES + ) + if user.is_superuser: + permissions = "admin" + else: + permissions = "user" + access_token = security.create_access_token( + data={"sub": user.email, "permissions": permissions}, + expires_delta=access_token_expires, + ) + + return {"access_token": access_token, "token_type": "bearer"} diff --git a/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/__init__.py b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/test_auth.py b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/test_auth.py new file mode 100644 index 0000000..3e73790 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/test_auth.py @@ -0,0 +1,66 @@ +from app.core import security + +# Monkey patch function we can use to shave a second off our tests by skipping the password hashing check +def verify_password_mock(first: str, second: str): + return True + + +def test_login(client, test_user, monkeypatch): + # Patch the test to skip password hashing check for speed + monkeypatch.setattr(security, "verify_password", verify_password_mock) + + response = client.post( + "/api/token", + data={"username": test_user.email, "password": "nottheactualpass"}, + ) + assert response.status_code == 200 + + +def test_signup(client, monkeypatch): + def get_password_hash_mock(first: str, second: str): + return True + + monkeypatch.setattr(security, "get_password_hash", get_password_hash_mock) + + response = client.post( + "/api/signup", + data={"username": "some@email.com", "password": "randompassword"}, + ) + assert response.status_code == 200 + + +def test_resignup(client, test_user, monkeypatch): + # Patch the test to skip password hashing check for speed + monkeypatch.setattr(security, "verify_password", verify_password_mock) + + response = client.post( + "/api/signup", + data={ + "username": test_user.email, + "password": "password_hashing_is_skipped_via_monkey_patch", + }, + ) + assert response.status_code == 409 + + +def test_wrong_password( + client, test_db, test_user, test_password, monkeypatch +): + def verify_password_failed_mock(first: str, second: str): + return False + + monkeypatch.setattr( + security, "verify_password", verify_password_failed_mock + ) + + response = client.post( + "/api/token", data={"username": test_user.email, "password": "wrong"} + ) + assert response.status_code == 401 + + +def test_wrong_login(client, test_db, test_user, test_password): + response = client.post( + "/api/token", data={"username": "fakeuser", "password": test_password} + ) + assert response.status_code == 401 diff --git a/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/test_users.py b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/test_users.py new file mode 100644 index 0000000..5f285b0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/tests/test_users.py @@ -0,0 +1,110 @@ +from app.db import models + + +def test_get_users(client, test_superuser, superuser_token_headers): + response = client.get("/api/v1/users", headers=superuser_token_headers) + assert response.status_code == 200 + assert response.json() == [ + { + "id": test_superuser.id, + "email": test_superuser.email, + "is_active": test_superuser.is_active, + "is_superuser": test_superuser.is_superuser, + } + ] + + +def test_delete_user(client, test_superuser, test_db, superuser_token_headers): + response = client.delete( + f"/api/v1/users/{test_superuser.id}", headers=superuser_token_headers + ) + assert response.status_code == 200 + assert test_db.query(models.User).all() == [] + + +def test_delete_user_not_found(client, superuser_token_headers): + response = client.delete( + "/api/v1/users/4321", headers=superuser_token_headers + ) + assert response.status_code == 404 + + +def test_edit_user(client, test_superuser, superuser_token_headers): + new_user = { + "email": "newemail@email.com", + "is_active": False, + "is_superuser": True, + "first_name": "Joe", + "last_name": "Smith", + "password": "new_password", + } + + response = client.put( + f"/api/v1/users/{test_superuser.id}", + json=new_user, + headers=superuser_token_headers, + ) + assert response.status_code == 200 + new_user["id"] = test_superuser.id + new_user.pop("password") + assert response.json() == new_user + + +def test_edit_user_not_found(client, test_db, superuser_token_headers): + new_user = { + "email": "newemail@email.com", + "is_active": False, + "is_superuser": False, + "password": "new_password", + } + response = client.put( + "/api/v1/users/1234", json=new_user, headers=superuser_token_headers + ) + assert response.status_code == 404 + + +def test_get_user( + client, + test_user, + superuser_token_headers, +): + response = client.get( + f"/api/v1/users/{test_user.id}", headers=superuser_token_headers + ) + assert response.status_code == 200 + assert response.json() == { + "id": test_user.id, + "email": test_user.email, + "is_active": bool(test_user.is_active), + "is_superuser": test_user.is_superuser, + } + + +def test_user_not_found(client, superuser_token_headers): + response = client.get("/api/v1/users/123", headers=superuser_token_headers) + assert response.status_code == 404 + + +def test_authenticated_user_me(client, user_token_headers): + response = client.get("/api/v1/users/me", headers=user_token_headers) + assert response.status_code == 200 + + +def test_unauthenticated_routes(client): + response = client.get("/api/v1/users/me") + assert response.status_code == 401 + response = client.get("/api/v1/users") + assert response.status_code == 401 + response = client.get("/api/v1/users/123") + assert response.status_code == 401 + response = client.put("/api/v1/users/123") + assert response.status_code == 401 + response = client.delete("/api/v1/users/123") + assert response.status_code == 401 + + +def test_unauthorized_routes(client, user_token_headers): + response = client.get("/api/v1/users", headers=user_token_headers) + assert response.status_code == 403 + response = client.get("/api/v1/users/123", headers=user_token_headers) + assert response.status_code == 403 diff --git a/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/users.py b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/users.py new file mode 100644 index 0000000..06167e2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/api/api_v1/routers/users.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter, Request, Depends, Response, encoders +import typing as t + +from app.db.session import get_db +from app.db.crud import ( + get_users, + get_user, + create_user, + delete_user, + edit_user, +) +from app.db.schemas import UserCreate, UserEdit, User, UserOut +from app.core.auth import get_current_active_user, get_current_active_superuser + +users_router = r = APIRouter() + + +@r.get( + "/users", + response_model=t.List[User], + response_model_exclude_none=True, +) +async def users_list( + response: Response, + db=Depends(get_db), + current_user=Depends(get_current_active_superuser), +): + """ + Get all users + """ + users = get_users(db) + # This is necessary for react-admin to work + response.headers["Content-Range"] = f"0-9/{len(users)}" + return users + + +@r.get("/users/me", response_model=User, response_model_exclude_none=True) +async def user_me(current_user=Depends(get_current_active_user)): + """ + Get own user + """ + return current_user + + +@r.get( + "/users/{user_id}", + response_model=User, + response_model_exclude_none=True, +) +async def user_details( + request: Request, + user_id: int, + db=Depends(get_db), + current_user=Depends(get_current_active_superuser), +): + """ + Get any user details + """ + user = get_user(db, user_id) + return user + # return encoders.jsonable_encoder( + # user, skip_defaults=True, exclude_none=True, + # ) + + +@r.post("/users", response_model=User, response_model_exclude_none=True) +async def user_create( + request: Request, + user: UserCreate, + db=Depends(get_db), + current_user=Depends(get_current_active_superuser), +): + """ + Create a new user + """ + return create_user(db, user) + + +@r.put( + "/users/{user_id}", response_model=User, response_model_exclude_none=True +) +async def user_edit( + request: Request, + user_id: int, + user: UserEdit, + db=Depends(get_db), + current_user=Depends(get_current_active_superuser), +): + """ + Update existing user + """ + return edit_user(db, user_id, user) + + +@r.delete( + "/users/{user_id}", response_model=User, response_model_exclude_none=True +) +async def user_delete( + request: Request, + user_id: int, + db=Depends(get_db), + current_user=Depends(get_current_active_superuser), +): + """ + Delete existing user + """ + return delete_user(db, user_id) diff --git a/{{cookiecutter.project_slug}}/backend/app/api/dependencies/__init__.py b/{{cookiecutter.project_slug}}/backend/app/api/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/core/__init__.py b/{{cookiecutter.project_slug}}/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/core/auth.py b/{{cookiecutter.project_slug}}/backend/app/core/auth.py new file mode 100644 index 0000000..0b404b2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/core/auth.py @@ -0,0 +1,75 @@ +import jwt +from fastapi import Depends, HTTPException, status +from jwt import PyJWTError + +from app.db import models, schemas, session +from app.db.crud import get_user_by_email, create_user +from app.core import security + + +async def get_current_user( + db=Depends(session.get_db), token: str = Depends(security.oauth2_scheme) +): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + token, security.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + permissions: str = payload.get("permissions") + token_data = schemas.TokenData(email=email, permissions=permissions) + except PyJWTError: + raise credentials_exception + user = get_user_by_email(db, token_data.email) + if user is None: + raise credentials_exception + return user + + +async def get_current_active_user( + current_user: models.User = Depends(get_current_user), +): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +async def get_current_active_superuser( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not current_user.is_superuser: + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user + + +def authenticate_user(db, email: str, password: str): + user = get_user_by_email(db, email) + if not user: + return False + if not security.verify_password(password, user.hashed_password): + return False + return user + + +def sign_up_new_user(db, email: str, password: str): + user = get_user_by_email(db, email) + if user: + return False # User already exists + new_user = create_user( + db, + schemas.UserCreate( + email=email, + password=password, + is_active=True, + is_superuser=False, + ), + ) + return new_user diff --git a/{{cookiecutter.project_slug}}/backend/app/core/celery_app.py b/{{cookiecutter.project_slug}}/backend/app/core/celery_app.py new file mode 100644 index 0000000..8355ef0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/core/celery_app.py @@ -0,0 +1,5 @@ +from celery import Celery + +celery_app = Celery("worker", broker="redis://redis:6379/0") + +celery_app.conf.task_routes = {"app.tasks.*": "main-queue"} diff --git a/{{cookiecutter.project_slug}}/backend/app/core/config.py b/{{cookiecutter.project_slug}}/backend/app/core/config.py new file mode 100644 index 0000000..7674990 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/core/config.py @@ -0,0 +1,7 @@ +import os + +PROJECT_NAME = "{{cookiecutter.project_name}}" + +SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") + +API_V1_STR = "/api/v1" diff --git a/{{cookiecutter.project_slug}}/backend/app/core/security.py b/{{cookiecutter.project_slug}}/backend/app/core/security.py new file mode 100644 index 0000000..70f99f3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/core/security.py @@ -0,0 +1,31 @@ +import jwt +from fastapi.security import OAuth2PasswordBearer +from passlib.context import CryptContext +from datetime import datetime, timedelta + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token") + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +SECRET_KEY = "{{cookiecutter.secret_key}}" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(*, data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt diff --git a/{{cookiecutter.project_slug}}/backend/app/db/__init__.py b/{{cookiecutter.project_slug}}/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/db/crud.py b/{{cookiecutter.project_slug}}/backend/app/db/crud.py new file mode 100644 index 0000000..f11b363 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/db/crud.py @@ -0,0 +1,69 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session +import typing as t + +from . import models, schemas +from app.core.security import get_password_hash + + +def get_user(db: Session, user_id: int): + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +def get_user_by_email(db: Session, email: str) -> schemas.UserBase: + return db.query(models.User).filter(models.User.email == email).first() + + +def get_users( + db: Session, skip: int = 0, limit: int = 100 +) -> t.List[schemas.UserOut]: + return db.query(models.User).offset(skip).limit(limit).all() + + +def create_user(db: Session, user: schemas.UserCreate): + hashed_password = get_password_hash(user.password) + db_user = models.User( + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + is_active=user.is_active, + is_superuser=user.is_superuser, + hashed_password=hashed_password, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def delete_user(db: Session, user_id: int): + user = get_user(db, user_id) + if not user: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") + db.delete(user) + db.commit() + return user + + +def edit_user( + db: Session, user_id: int, user: schemas.UserEdit +) -> schemas.User: + db_user = get_user(db, user_id) + if not db_user: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") + update_data = user.dict(exclude_unset=True) + + if "password" in update_data: + update_data["hashed_password"] = get_password_hash(user.password) + del update_data["password"] + + for key, value in update_data.items(): + setattr(db_user, key, value) + + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user diff --git a/{{cookiecutter.project_slug}}/backend/app/db/models.py b/{{cookiecutter.project_slug}}/backend/app/db/models.py new file mode 100644 index 0000000..4744d4c --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/db/models.py @@ -0,0 +1,15 @@ +from sqlalchemy import Boolean, Column, Integer, String + +from .session import Base + + +class User(Base): + __tablename__ = "user" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + first_name = Column(String) + last_name = Column(String) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) diff --git a/{{cookiecutter.project_slug}}/backend/app/db/schemas.py b/{{cookiecutter.project_slug}}/backend/app/db/schemas.py new file mode 100644 index 0000000..0dfcc45 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/db/schemas.py @@ -0,0 +1,45 @@ +from pydantic import BaseModel +import typing as t + + +class UserBase(BaseModel): + email: str + is_active: bool = True + is_superuser: bool = False + first_name: str = None + last_name: str = None + + +class UserOut(UserBase): + pass + + +class UserCreate(UserBase): + password: str + + class Config: + orm_mode = True + + +class UserEdit(UserBase): + password: t.Optional[str] = None + + class Config: + orm_mode = True + + +class User(UserBase): + id: int + + class Config: + orm_mode = True + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + email: str = None + permissions: str = "user" diff --git a/{{cookiecutter.project_slug}}/backend/app/db/session.py b/{{cookiecutter.project_slug}}/backend/app/db/session.py new file mode 100644 index 0000000..d7e2f6c --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/db/session.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from app.core import config + +engine = create_engine( + config.SQLALCHEMY_DATABASE_URI, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/{{cookiecutter.project_slug}}/backend/app/initial_data.py b/{{cookiecutter.project_slug}}/backend/app/initial_data.py new file mode 100644 index 0000000..b41a523 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/initial_data.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +from app.db.session import get_db +from app.db.crud import create_user +from app.db.schemas import UserCreate +from app.db.session import SessionLocal + + +def init() -> None: + db = SessionLocal() + + create_user( + db, + UserCreate( + email="{{cookiecutter.superuser_email}}", + password="{{cookiecutter.superuser_password}}", + is_active=True, + is_superuser=True, + ), + ) + + +if __name__ == "__main__": + print("Creating superuser {{cookiecutter.superuser_email}}") + init() + print("Superuser created") diff --git a/{{cookiecutter.project_slug}}/backend/app/main.py b/{{cookiecutter.project_slug}}/backend/app/main.py new file mode 100644 index 0000000..b3f1c58 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI, Depends +from starlette.requests import Request +import uvicorn + +from app.api.api_v1.routers.users import users_router +from app.api.api_v1.routers.auth import auth_router +from app.core import config +from app.db.session import SessionLocal +from app.core.auth import get_current_active_user +from app.core.celery_app import celery_app +from app import tasks + + +app = FastAPI( + title=config.PROJECT_NAME, docs_url="/api/docs", openapi_url="/api" +) + + +@app.middleware("http") +async def db_session_middleware(request: Request, call_next): + request.state.db = SessionLocal() + response = await call_next(request) + request.state.db.close() + return response + + +@app.get("/api/v1") +async def root(): + return {"message": "Hello World"} + + +@app.get("/api/v1/task") +async def example_task(): + celery_app.send_task("app.tasks.example_task", args=["Hello World"]) + + return {"message": "success"} + + +# Routers +app.include_router( + users_router, + prefix="/api/v1", + tags=["users"], + dependencies=[Depends(get_current_active_user)], +) +app.include_router(auth_router, prefix="/api", tags=["auth"]) + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", reload=True, port=8888) diff --git a/{{cookiecutter.project_slug}}/backend/app/tasks.py b/{{cookiecutter.project_slug}}/backend/app/tasks.py new file mode 100644 index 0000000..c17d506 --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/tasks.py @@ -0,0 +1,6 @@ +from app.core.celery_app import celery_app + + +@celery_app.task(acks_late=True) +def example_task(word: str) -> str: + return f"test task returns {word}" diff --git a/{{cookiecutter.project_slug}}/backend/app/tests/__init__.py b/{{cookiecutter.project_slug}}/backend/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_slug}}/backend/app/tests/test_main.py b/{{cookiecutter.project_slug}}/backend/app/tests/test_main.py new file mode 100644 index 0000000..024cf9e --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/tests/test_main.py @@ -0,0 +1,4 @@ +def test_read_main(client): + response = client.get("/api/v1") + assert response.status_code == 200 + assert response.json() == {"message": "Hello World"} diff --git a/{{cookiecutter.project_slug}}/backend/app/tests/test_tasks.py b/{{cookiecutter.project_slug}}/backend/app/tests/test_tasks.py new file mode 100644 index 0000000..7f49f1c --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/app/tests/test_tasks.py @@ -0,0 +1,6 @@ +from app import tasks + + +def test_example_task(): + task_output = tasks.example_task("Hello World") + assert task_output == "test task returns Hello World" diff --git a/{{cookiecutter.project_slug}}/backend/conftest.py b/{{cookiecutter.project_slug}}/backend/conftest.py new file mode 100644 index 0000000..ecd831d --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/conftest.py @@ -0,0 +1,169 @@ +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker +from sqlalchemy_utils import database_exists, create_database, drop_database +from fastapi.testclient import TestClient +import typing as t + +from app.core import config, security +from app.db.session import Base, get_db +from app.db import models +from app.main import app + + +def get_test_db_url() -> str: + return f"{config.SQLALCHEMY_DATABASE_URI}_test" + + +@pytest.fixture +def test_db(): + """ + Modify the db session to automatically roll back after each test. + This is to avoid tests affecting the database state of other tests. + """ + # Connect to the test database + engine = create_engine( + get_test_db_url(), + ) + + connection = engine.connect() + trans = connection.begin() + + # Run a parent transaction that can roll back all changes + test_session_maker = sessionmaker( + autocommit=False, autoflush=False, bind=engine + ) + test_session = test_session_maker() + test_session.begin_nested() + + @event.listens_for(test_session, "after_transaction_end") + def restart_savepoint(s, transaction): + if transaction.nested and not transaction._parent.nested: + s.expire_all() + s.begin_nested() + + yield test_session + + # Roll back the parent transaction after the test is complete + test_session.close() + trans.rollback() + connection.close() + + +@pytest.fixture(scope="session", autouse=True) +def create_test_db(): + """ + Create a test database and use it for the whole test session. + """ + + test_db_url = get_test_db_url() + + # Create the test database + assert not database_exists( + test_db_url + ), "Test database already exists. Aborting tests." + create_database(test_db_url) + test_engine = create_engine(test_db_url) + Base.metadata.create_all(test_engine) + + # Run the tests + yield + + # Drop the test database + drop_database(test_db_url) + + +@pytest.fixture +def client(test_db): + """ + Get a TestClient instance that reads/write to the test database. + """ + + def get_test_db(): + yield test_db + + app.dependency_overrides[get_db] = get_test_db + + yield TestClient(app) + + +@pytest.fixture +def test_password() -> str: + return "securepassword" + + +def get_password_hash() -> str: + """ + Password hashing can be expensive so a mock will be much faster + """ + return "supersecrethash" + + +@pytest.fixture +def test_user(test_db) -> models.User: + """ + Make a test user in the database + """ + + user = models.User( + email="fake@email.com", + hashed_password=get_password_hash(), + is_active=True, + ) + test_db.add(user) + test_db.commit() + return user + + +@pytest.fixture +def test_superuser(test_db) -> models.User: + """ + Superuser for testing + """ + + user = models.User( + email="fakeadmin@email.com", + hashed_password=get_password_hash(), + is_superuser=True, + ) + test_db.add(user) + test_db.commit() + return user + + +def verify_password_mock(first: str, second: str) -> bool: + return True + + +@pytest.fixture +def user_token_headers( + client: TestClient, test_user, test_password, monkeypatch +) -> t.Dict[str, str]: + monkeypatch.setattr(security, "verify_password", verify_password_mock) + + login_data = { + "username": test_user.email, + "password": test_password, + } + r = client.post("/api/token", data=login_data) + tokens = r.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + return headers + + +@pytest.fixture +def superuser_token_headers( + client: TestClient, test_superuser, test_password, monkeypatch +) -> t.Dict[str, str]: + monkeypatch.setattr(security, "verify_password", verify_password_mock) + + login_data = { + "username": test_superuser.email, + "password": test_password, + } + r = client.post("/api/token", data=login_data) + tokens = r.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + return headers diff --git a/{{cookiecutter.project_slug}}/backend/pyproject.toml b/{{cookiecutter.project_slug}}/backend/pyproject.toml new file mode 100644 index 0000000..627a23c --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 80 \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/backend/requirements.txt b/{{cookiecutter.project_slug}}/backend/requirements.txt new file mode 100644 index 0000000..7f8389d --- /dev/null +++ b/{{cookiecutter.project_slug}}/backend/requirements.txt @@ -0,0 +1,19 @@ +alembic==1.4.3 +Authlib==0.14.3 +fastapi==0.65.2 +celery==5.0.0 +redis==3.5.3 +httpx==0.15.5 +ipython==7.31.1 +itsdangerous==1.1.0 +Jinja2==2.11.3 +psycopg2==2.8.6 +pytest==6.1.0 +requests==2.24.0 +SQLAlchemy==1.3.19 +uvicorn==0.12.1 +passlib==1.7.2 +bcrypt==3.2.0 +sqlalchemy-utils==0.36.8 +python-multipart==0.0.5 +pyjwt==1.7.1 \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/docker-compose.yml b/{{cookiecutter.project_slug}}/docker-compose.yml new file mode 100644 index 0000000..3209ed8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/docker-compose.yml @@ -0,0 +1,71 @@ +version: '3.7' +services: + nginx: + image: nginx:1.17 + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf + ports: + - {{cookiecutter.port}}:80 + depends_on: + - backend + - frontend + + redis: + image: redis + ports: + - 6379:6379 + + postgres: + image: postgres:12 + restart: always + environment: + POSTGRES_USER: {{cookiecutter.postgres_user}} + POSTGRES_PASSWORD: {{cookiecutter.postgres_password}} + ports: + - '5432:5432' + volumes: + - db-data:/var/lib/postgresql/data:cached + + worker: + build: + context: backend + dockerfile: Dockerfile + command: celery --app app.tasks worker --loglevel=DEBUG -Q main-queue -c 1 + + flower: + image: mher/flower + command: celery flower --broker=redis://redis:6379/0 --port=5555 + ports: + - 5555:5555 + depends_on: + - "redis" + + backend: + build: + context: backend + dockerfile: Dockerfile + command: python app/main.py + tty: true + volumes: + - ./backend:/app/:cached + - ./.docker/.ipython:/root/.ipython:cached + environment: + PYTHONPATH: . + DATABASE_URL: 'postgresql://{{cookiecutter.postgres_user}}:{{cookiecutter.postgres_password}}@postgres:5432/{{cookiecutter.postgres_user}}' + depends_on: + - "postgres" + + frontend: + build: + context: frontend + dockerfile: Dockerfile + stdin_open: true + volumes: + - './frontend:/app:cached' + - './frontend/node_modules:/app/node_modules:cached' + environment: + - NODE_ENV=development + + +volumes: + db-data: diff --git a/{{cookiecutter.project_slug}}/frontend/.dockerignore b/{{cookiecutter.project_slug}}/frontend/.dockerignore new file mode 100644 index 0000000..25c8fdb --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/frontend/.eslintrc.js b/{{cookiecutter.project_slug}}/frontend/.eslintrc.js new file mode 100644 index 0000000..428e422 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/.eslintrc.js @@ -0,0 +1,51 @@ +let rules = { + 'max-len': ['error', 80, 2, { ignoreUrls: true }], + 'no-console': [0], + 'no-restricted-syntax': 'off', + 'no-continue': 'off', + 'no-underscore-dangle': 'off', + 'import/extensions': 'off', + 'import/no-unresolved': 'off', + 'operator-linebreak': 'off', + 'implicit-arrow-linebreak': 'off', + 'react/destructuring-assignment': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', + 'react/jsx-one-expression-per-line': 'off', + 'react/jsx-filename-extension': [2, { extensions: ['.ts', '.tsx'] }], + 'lines-between-class-members': [ + 'error', + 'always', + { exceptAfterSingleLine: true }, + ], +}; + +module.exports = { + extends: ['airbnb', 'plugin:prettier/recommended', 'prettier/react'], + parser: 'babel-eslint', + rules, + env: { + browser: true, + commonjs: true, + node: true, + jest: true, + es6: true, + }, + plugins: ['react', 'react-hooks', 'jsx-a11y'], + settings: { + ecmascript: 6, + jsx: true, + 'import/resolver': { + node: { + paths: ['src'], + }, + }, + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + react: { + pragma: 'React', + version: '16.8', + }, + }, +}; diff --git a/{{cookiecutter.project_slug}}/frontend/.prettierrc.js b/{{cookiecutter.project_slug}}/frontend/.prettierrc.js new file mode 100644 index 0000000..158883b --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + printWidth: 80, + singleQuote: true, + trailingComma: 'es5', +}; diff --git a/{{cookiecutter.project_slug}}/frontend/Dockerfile b/{{cookiecutter.project_slug}}/frontend/Dockerfile new file mode 100644 index 0000000..d398ff4 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:12 + +ADD package.json /package.json + +ENV NODE_PATH=/node_modules +ENV PATH=$PATH:/node_modules/.bin +RUN npm install + +WORKDIR /app +ADD . /app + +EXPOSE 8000 +EXPOSE 35729 + +ENTRYPOINT ["/bin/bash", "/app/run.sh"] +CMD ["start"] diff --git a/{{cookiecutter.project_slug}}/frontend/README.md b/{{cookiecutter.project_slug}}/frontend/README.md new file mode 100644 index 0000000..54ef094 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/README.md @@ -0,0 +1,68 @@ +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +## Available Scripts + +In the project directory, you can run: + +### `npm start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+You will also see any lint errors in the console. + +### `npm test` + +Launches the test runner in the interactive watch mode.
+See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `npm run build` + +Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `npm run eject` + +**Note: this is a one-way operation. Once you `eject`, you can’t go back!** + +If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. + +You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). + +### Code Splitting + +This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting + +### Analyzing the Bundle Size + +This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size + +### Making a Progressive Web App + +This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app + +### Advanced Configuration + +This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration + +### Deployment + +This section has moved here: https://facebook.github.io/create-react-app/docs/deployment + +### `npm run build` fails to minify + +This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify diff --git a/{{cookiecutter.project_slug}}/frontend/package.json b/{{cookiecutter.project_slug}}/frontend/package.json new file mode 100644 index 0000000..14dd1f8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/package.json @@ -0,0 +1,62 @@ +{ + "name": "fastapi-react", + "version": "0.1.0", + "private": true, + "dependencies": { + "ra-data-json-server": "^3.5.2", + "ra-data-simple-rest": "^3.3.2", + "react": "^16.13.1", + "react-admin": "^3.5.2", + "react-dom": "^16.13.1", + "react-router-dom": "^5.1.2", + "react-scripts": "3.4.3", + "react-truncate": "^2.4.0", + "standard": "^14.3.3", + "jwt-decode": "^3.0.0", + "@material-ui/lab": "^4.0.0-alpha.54" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "CI=true react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "airbnb" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "typescript": "^4.0.2", + "@testing-library/jest-dom": "^5.11.1", + "@testing-library/react": "^11.0.4", + "@typescript-eslint/eslint-plugin": "^2.24.0", + "@typescript-eslint/parser": "^2.24.0", + "@testing-library/user-event": "^12.0.11", + "@types/jest": "^26.0.3", + "@types/node": "^14.0.1", + "@types/react": "^16.9.19", + "@types/react-dom": "^16.9.5", + "@types/react-router-dom": "^5.1.3", + "@types/jwt-decode": "^2.2.1", + "eslint-config-airbnb": "^18.1.0", + "eslint-config-react-app": "^5.2.1", + "eslint-plugin-flowtype": "^4.6.0", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-jsx-a11y": "^6.2.3", + "eslint-plugin-react": "^7.19.0", + "eslint-plugin-react-hooks": "^2.5.1", + "prettier": "^2.0.5", + "react-test-renderer": "^16.13.1" + } +} diff --git a/{{cookiecutter.project_slug}}/frontend/public/favicon.ico b/{{cookiecutter.project_slug}}/frontend/public/favicon.ico new file mode 100644 index 0000000..bcd5dfd Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/favicon.ico differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/index.html b/{{cookiecutter.project_slug}}/frontend/public/index.html new file mode 100644 index 0000000..aa069f2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/{{cookiecutter.project_slug}}/frontend/public/logo192.png b/{{cookiecutter.project_slug}}/frontend/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/logo192.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/logo512.png b/{{cookiecutter.project_slug}}/frontend/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/{{cookiecutter.project_slug}}/frontend/public/logo512.png differ diff --git a/{{cookiecutter.project_slug}}/frontend/public/manifest.json b/{{cookiecutter.project_slug}}/frontend/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/{{cookiecutter.project_slug}}/frontend/public/robots.txt b/{{cookiecutter.project_slug}}/frontend/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/{{cookiecutter.project_slug}}/frontend/run.sh b/{{cookiecutter.project_slug}}/frontend/run.sh new file mode 100644 index 0000000..5d2b843 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +case $1 in + start) + # The '| cat' is to trick Node that this is an non-TTY terminal + # then react-scripts won't clear the console. + npm start | cat + ;; + build) + npm build + ;; + test) + npm test $@ + ;; + *) + npm "$@" + ;; +esac \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/frontend/src/App.tsx b/{{cookiecutter.project_slug}}/frontend/src/App.tsx new file mode 100644 index 0000000..f41354b --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/App.tsx @@ -0,0 +1,6 @@ +import React, { FC } from 'react'; +import { Routes } from './Routes'; + +const App: FC = () => ; + +export default App; diff --git a/{{cookiecutter.project_slug}}/frontend/src/Routes.tsx b/{{cookiecutter.project_slug}}/frontend/src/Routes.tsx new file mode 100644 index 0000000..44cf96d --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/Routes.tsx @@ -0,0 +1,54 @@ +import React, { FC } from 'react'; +import { Switch, Route } from 'react-router-dom'; +import { useHistory } from 'react-router'; +import { makeStyles } from '@material-ui/core/styles'; + +import { Home, Login, SignUp, Protected, PrivateRoute } from './views'; +import { Admin } from './admin'; +import { logout } from './utils/auth'; + +const useStyles = makeStyles((theme) => ({ + app: { + textAlign: 'center', + }, + header: { + backgroundColor: '#282c34', + minHeight: '100vh', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + fontSize: 'calc(10px + 2vmin)', + color: 'white', + }, +})); + +export const Routes: FC = () => { + const classes = useStyles(); + const history = useHistory(); + + return ( + + + + + +
+
+ + + { + logout(); + history.push('/'); + return null; + }} + /> + + +
+
+
+ ); +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/__tests__/home.test.tsx b/{{cookiecutter.project_slug}}/frontend/src/__tests__/home.test.tsx new file mode 100644 index 0000000..ef03bde --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/__tests__/home.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { Home } from '../views/Home'; + +it('Home renders correctly', () => { + const home = render(); + expect(home.getByText('Admin Dashboard')).toBeInTheDocument(); + expect(home.getByText('Protected Route')).toBeInTheDocument(); + expect(home.getByText('Login')).toBeInTheDocument(); + expect(home.getByText('Sign Up')).toBeInTheDocument(); +}); diff --git a/{{cookiecutter.project_slug}}/frontend/src/__tests__/login.test.tsx b/{{cookiecutter.project_slug}}/frontend/src/__tests__/login.test.tsx new file mode 100644 index 0000000..1dae198 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/__tests__/login.test.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { Login } from '../views'; + +it('Login renders correctly', () => { + const login = render(); + expect(login.getByText('Email')).toBeInTheDocument(); + expect(login.getByText('Password')).toBeInTheDocument(); + expect(login.getByText('Login')).toBeInTheDocument(); +}); diff --git a/{{cookiecutter.project_slug}}/frontend/src/admin/Admin.tsx b/{{cookiecutter.project_slug}}/frontend/src/admin/Admin.tsx new file mode 100644 index 0000000..daa1218 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/admin/Admin.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; +import { fetchUtils, Admin as ReactAdmin, Resource } from 'react-admin'; +import simpleRestProvider from 'ra-data-simple-rest'; +import authProvider from './authProvider'; + +import { UserList, UserEdit, UserCreate } from './Users'; + +const httpClient = (url: any, options: any) => { + if (!options) { + options = {}; + } + if (!options.headers) { + options.headers = new Headers({ Accept: 'application/json' }); + } + const token = localStorage.getItem('token'); + options.headers.set('Authorization', `Bearer ${token}`); + return fetchUtils.fetchJson(url, options); +}; + +const dataProvider = simpleRestProvider('api/v1', httpClient); + +export const Admin: FC = () => { + return ( + + {(permissions: 'admin' | 'user') => [ + permissions === 'admin' ? ( + + ) : null, + ]} + + ); +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserCreate.tsx b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserCreate.tsx new file mode 100644 index 0000000..466a518 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserCreate.tsx @@ -0,0 +1,21 @@ +import React, { FC } from 'react'; +import { + Create, + SimpleForm, + TextInput, + PasswordInput, + BooleanInput, +} from 'react-admin'; + +export const UserCreate: FC = (props) => ( + + + + + + + + + + +); diff --git a/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserEdit.tsx b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserEdit.tsx new file mode 100644 index 0000000..7925d19 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserEdit.tsx @@ -0,0 +1,22 @@ +import React, { FC } from 'react'; +import { + Edit, + SimpleForm, + TextInput, + PasswordInput, + BooleanInput, +} from 'react-admin'; + +export const UserEdit: FC = (props) => ( + + + + + + + + + + + +); diff --git a/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserList.tsx b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserList.tsx new file mode 100644 index 0000000..dce27f7 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/UserList.tsx @@ -0,0 +1,24 @@ +// in src/users.js +import React, { FC } from 'react'; +import { + List, + Datagrid, + TextField, + BooleanField, + EmailField, + EditButton, +} from 'react-admin'; + +export const UserList: FC = (props) => ( + + + + + + + + + + + +); diff --git a/{{cookiecutter.project_slug}}/frontend/src/admin/Users/index.ts b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/index.ts new file mode 100644 index 0000000..999f7e0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/admin/Users/index.ts @@ -0,0 +1,3 @@ +export * from './UserEdit'; +export * from './UserList'; +export * from './UserCreate'; diff --git a/{{cookiecutter.project_slug}}/frontend/src/admin/authProvider.ts b/{{cookiecutter.project_slug}}/frontend/src/admin/authProvider.ts new file mode 100644 index 0000000..1e0fe3a --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/admin/authProvider.ts @@ -0,0 +1,55 @@ +import decodeJwt from 'jwt-decode'; + +type loginFormType = { + username: string; + password: string; +}; + +const authProvider = { + login: ({ username, password }: loginFormType) => { + let formData = new FormData(); + formData.append('username', username); + formData.append('password', password); + const request = new Request('/api/token', { + method: 'POST', + body: formData, + }); + return fetch(request) + .then((response) => { + if (response.status < 200 || response.status >= 300) { + throw new Error(response.statusText); + } + return response.json(); + }) + .then(({ access_token }) => { + const decodedToken: any = decodeJwt(access_token); + if (decodedToken.permissions !== 'admin') { + throw new Error('Forbidden'); + } + localStorage.setItem('token', access_token); + localStorage.setItem('permissions', decodedToken.permissions); + }); + }, + logout: () => { + localStorage.removeItem('token'); + localStorage.removeItem('permissions'); + return Promise.resolve(); + }, + checkError: (error: { status: number }) => { + const status = error.status; + if (status === 401 || status === 403) { + localStorage.removeItem('token'); + return Promise.reject(); + } + return Promise.resolve(); + }, + checkAuth: () => + localStorage.getItem('token') ? Promise.resolve() : Promise.reject(), + getPermissions: () => { + const role = localStorage.getItem('permissions'); + return role ? Promise.resolve(role) : Promise.reject(); + // localStorage.getItem('token') ? Promise.resolve() : Promise.reject(), + }, +}; + +export default authProvider; diff --git a/{{cookiecutter.project_slug}}/frontend/src/admin/index.ts b/{{cookiecutter.project_slug}}/frontend/src/admin/index.ts new file mode 100644 index 0000000..c956a8f --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/admin/index.ts @@ -0,0 +1 @@ +export * from './Admin'; diff --git a/{{cookiecutter.project_slug}}/frontend/src/config/index.tsx b/{{cookiecutter.project_slug}}/frontend/src/config/index.tsx new file mode 100644 index 0000000..ccf59e2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/config/index.tsx @@ -0,0 +1,3 @@ +export const BASE_URL: string = 'http://localhost:{{cookiecutter.port}}'; +export const BACKEND_URL: string = + 'http://localhost:{{cookiecutter.port}}/api/v1'; diff --git a/{{cookiecutter.project_slug}}/frontend/src/decs.d.ts b/{{cookiecutter.project_slug}}/frontend/src/decs.d.ts new file mode 100644 index 0000000..5557bb8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/decs.d.ts @@ -0,0 +1 @@ +declare module 'react-admin'; diff --git a/{{cookiecutter.project_slug}}/frontend/src/index.css b/{{cookiecutter.project_slug}}/frontend/src/index.css new file mode 100644 index 0000000..ec2585e --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/{{cookiecutter.project_slug}}/frontend/src/index.tsx b/{{cookiecutter.project_slug}}/frontend/src/index.tsx new file mode 100644 index 0000000..9a6816b --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter as Router } from 'react-router-dom'; +import './index.css'; +import App from './App'; + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/{{cookiecutter.project_slug}}/frontend/src/logo.svg b/{{cookiecutter.project_slug}}/frontend/src/logo.svg new file mode 100644 index 0000000..6b60c10 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/{{cookiecutter.project_slug}}/frontend/src/react-app-env.d.ts b/{{cookiecutter.project_slug}}/frontend/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/{{cookiecutter.project_slug}}/frontend/src/utils/api.ts b/{{cookiecutter.project_slug}}/frontend/src/utils/api.ts new file mode 100644 index 0000000..6b7e2f0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/utils/api.ts @@ -0,0 +1,13 @@ +import { BACKEND_URL } from '../config'; + +export const getMessage = async () => { + const response = await fetch(BACKEND_URL); + + const data = await response.json(); + + if (data.message) { + return data.message; + } + + return Promise.reject('Failed to get message from backend'); +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/utils/auth.ts b/{{cookiecutter.project_slug}}/frontend/src/utils/auth.ts new file mode 100644 index 0000000..bbcf39e --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/utils/auth.ts @@ -0,0 +1,118 @@ +import decodeJwt from 'jwt-decode'; + +export const isAuthenticated = () => { + const permissions = localStorage.getItem('permissions'); + if (!permissions) { + return false; + } + return permissions === 'user' || permissions === 'admin' ? true : false; +}; + +/** + * Login to backend and store JSON web token on success + * + * @param email + * @param password + * @returns JSON data containing access token on success + * @throws Error on http errors or failed attempts + */ +export const login = async (email: string, password: string) => { + // Assert email or password is not empty + if (!(email.length > 0) || !(password.length > 0)) { + throw new Error('Email or password was not provided'); + } + const formData = new FormData(); + // OAuth2 expects form data, not JSON data + formData.append('username', email); + formData.append('password', password); + + const request = new Request('/api/token', { + method: 'POST', + body: formData, + }); + + const response = await fetch(request); + + if (response.status === 500) { + throw new Error('Internal server error'); + } + + const data = await response.json(); + + if (response.status > 400 && response.status < 500) { + if (data.detail) { + throw data.detail; + } + throw data; + } + + if ('access_token' in data) { + const decodedToken: any = decodeJwt(data['access_token']); + localStorage.setItem('token', data['access_token']); + localStorage.setItem('permissions', decodedToken.permissions); + } + + return data; +}; + +/** + * Sign up via backend and store JSON web token on success + * + * @param email + * @param password + * @returns JSON data containing access token on success + * @throws Error on http errors or failed attempts + */ +export const signUp = async ( + email: string, + password: string, + passwordConfirmation: string +) => { + // Assert email or password or password confirmation is not empty + if (!(email.length > 0)) { + throw new Error('Email was not provided'); + } + if (!(password.length > 0)) { + throw new Error('Password was not provided'); + } + if (!(passwordConfirmation.length > 0)) { + throw new Error('Password confirmation was not provided'); + } + + const formData = new FormData(); + // OAuth2 expects form data, not JSON data + formData.append('username', email); + formData.append('password', password); + + const request = new Request('/api/signup', { + method: 'POST', + body: formData, + }); + + const response = await fetch(request); + + if (response.status === 500) { + throw new Error('Internal server error'); + } + + const data = await response.json(); + if (response.status > 400 && response.status < 500) { + if (data.detail) { + throw data.detail; + } + throw data; + } + + if ('access_token' in data) { + const decodedToken: any = decodeJwt(data['access_token']); + localStorage.setItem('token', data['access_token']); + localStorage.setItem('permissions', decodedToken.permissions); + } + + return data; +}; + +export const logout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('permissions'); +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/utils/index.ts b/{{cookiecutter.project_slug}}/frontend/src/utils/index.ts new file mode 100644 index 0000000..abb0c9d --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './api'; diff --git a/{{cookiecutter.project_slug}}/frontend/src/views/Home.tsx b/{{cookiecutter.project_slug}}/frontend/src/views/Home.tsx new file mode 100644 index 0000000..2a23974 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/views/Home.tsx @@ -0,0 +1,66 @@ +import React, { FC, useState } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; + +import { getMessage } from '../utils/api'; +import { isAuthenticated } from '../utils/auth'; + +const useStyles = makeStyles((theme) => ({ + link: { + color: '#61dafb', + }, +})); + +export const Home: FC = () => { + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const classes = useStyles(); + + const queryBackend = async () => { + try { + const message = await getMessage(); + setMessage(message); + } catch (err) { + setError(String(err)); + } + }; + + return ( + <> + {!message && !error && ( + queryBackend()}> + Click to make request to backend + + )} + {message && ( +

+ {message} +

+ )} + {error && ( +

+ Error: {error} +

+ )} + + Admin Dashboard + + + Protected Route + + {isAuthenticated() ? ( + + Logout + + ) : ( + <> + + Login + + + Sign Up + + + )} + + ); +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/views/Login.tsx b/{{cookiecutter.project_slug}}/frontend/src/views/Login.tsx new file mode 100644 index 0000000..26298c8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/views/Login.tsx @@ -0,0 +1,151 @@ +import React, { FC, useState } from 'react'; +import { + Paper, + Grid, + TextField, + Button, + FormControlLabel, + Checkbox, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Face, Fingerprint } from '@material-ui/icons'; +import { Alert } from '@material-ui/lab'; +import { Redirect } from 'react-router-dom'; +import { useHistory } from 'react-router'; + +import { login, isAuthenticated } from '../utils/auth'; + +const useStyles = makeStyles((theme) => ({ + margin: { + margin: theme.spacing(2), + }, + padding: { + padding: theme.spacing(1), + }, + button: { + textTransform: 'none', + }, + marginTop: { + marginTop: 10, + }, +})); + +export const Login: FC = () => { + const classes = useStyles(); + const history = useHistory(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (_: React.MouseEvent) => { + setError(''); + try { + const data = await login(email, password); + + if (data) { + history.push('/'); + } + } catch (err) { + if (err instanceof Error) { + // handle errors thrown from frontend + setError(err.message); + } else { + // handle errors thrown from backend + setError(String(err)); + } + } + }; + + return isAuthenticated() ? ( + + ) : ( + +
+ + + + + + ) => + setEmail(e.currentTarget.value) + } + fullWidth + autoFocus + required + /> + + + + + + + + ) => + setPassword(e.currentTarget.value) + } + fullWidth + required + /> + + +
+ + {error && ( + + {error} + + )} + + + + } + label="Remember me" + /> + + + + + + + {' '} + {' '} +   + + +
+
+ ); +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/views/PrivateRoute.tsx b/{{cookiecutter.project_slug}}/frontend/src/views/PrivateRoute.tsx new file mode 100644 index 0000000..285fc5f --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/views/PrivateRoute.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +import { isAuthenticated } from '../utils/auth'; + +type PrivateRouteType = { + component: React.ComponentType; + path?: string | string[]; +}; + +export const PrivateRoute: FC = ({ + component, + ...rest +}: any) => ( + + isAuthenticated() === true ? ( + React.createElement(component, props) + ) : ( + + ) + } + /> +); diff --git a/{{cookiecutter.project_slug}}/frontend/src/views/Protected.tsx b/{{cookiecutter.project_slug}}/frontend/src/views/Protected.tsx new file mode 100644 index 0000000..078414a --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/views/Protected.tsx @@ -0,0 +1,5 @@ +import React, { FC } from 'react'; + +export const Protected: FC = () => { + return

This component is protected

; +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/views/SignUp.tsx b/{{cookiecutter.project_slug}}/frontend/src/views/SignUp.tsx new file mode 100644 index 0000000..0ede11e --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/views/SignUp.tsx @@ -0,0 +1,138 @@ +import React, { FC, useState } from 'react'; +import { Paper, Grid, TextField, Button } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Face, Fingerprint } from '@material-ui/icons'; +import { Alert } from '@material-ui/lab'; +import { Redirect } from 'react-router-dom'; +import { useHistory } from 'react-router'; + +import { signUp, isAuthenticated } from '../utils/auth'; + +const useStyles = makeStyles((theme) => ({ + margin: { + margin: theme.spacing(2), + }, + padding: { + padding: theme.spacing(1), + }, + button: { + textTransform: 'none', + }, + marginTop: { + marginTop: 10, + }, +})); + +export const SignUp: FC = () => { + const classes = useStyles(); + const history = useHistory(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirmation, setPasswordConfirmation] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (_: React.MouseEvent) => { + // Password confirmation validation + if (password !== passwordConfirmation) setError('Passwords do not match'); + else { + setError(''); + try { + const data = await signUp(email, password, passwordConfirmation); + + if (data) { + history.push('/'); + } + } catch (err) { + if (err instanceof Error) { + // handle errors thrown from frontend + setError(err.message); + } else { + // handle errors thrown from backend + setError(String(err)); + } + } + } + }; + + return isAuthenticated() ? ( + + ) : ( + +
+ + + + + + ) => + setEmail(e.currentTarget.value) + } + fullWidth + autoFocus + required + /> + + + + + + + + ) => + setPassword(e.currentTarget.value) + } + fullWidth + required + /> + + + + + + + + ) => + setPasswordConfirmation(e.currentTarget.value) + } + fullWidth + required + /> + + +
+ + {error && ( + + {error} + + )} + + + + +
+
+ ); +}; diff --git a/{{cookiecutter.project_slug}}/frontend/src/views/index.ts b/{{cookiecutter.project_slug}}/frontend/src/views/index.ts new file mode 100644 index 0000000..797586c --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/src/views/index.ts @@ -0,0 +1,5 @@ +export * from './Home'; +export * from './Login'; +export * from './SignUp'; +export * from './Protected'; +export * from './PrivateRoute'; diff --git a/{{cookiecutter.project_slug}}/frontend/tsconfig.json b/{{cookiecutter.project_slug}}/frontend/tsconfig.json new file mode 100644 index 0000000..4a41017 --- /dev/null +++ b/{{cookiecutter.project_slug}}/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": ["src", "decs.d.ts"] +} diff --git a/{{cookiecutter.project_slug}}/nginx/nginx.conf b/{{cookiecutter.project_slug}}/nginx/nginx.conf new file mode 100644 index 0000000..853b5c0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/nginx/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name {{cookiecutter.project_slug}}; + + location / { + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_pass http://frontend:3000; + + proxy_redirect off; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /api { + proxy_pass http://backend:8888/api; + } +} diff --git a/{{cookiecutter.project_slug}}/scripts/build.sh b/{{cookiecutter.project_slug}}/scripts/build.sh new file mode 100755 index 0000000..77a3645 --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/build.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Exit in case of error +set -e + +# Build and run containers +docker-compose up -d + +# Hack to wait for postgres container to be up before running alembic migrations +sleep 5; + +# Run migrations +docker-compose run --rm backend alembic upgrade head + +# Create initial data +docker-compose run --rm backend python3 app/initial_data.py \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/scripts/test.sh b/{{cookiecutter.project_slug}}/scripts/test.sh new file mode 100755 index 0000000..9f1d0d3 --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/test.sh @@ -0,0 +1,7 @@ +#! /usr/bin/env bash + +# Exit in case of error +set -e + +docker-compose run backend pytest +docker-compose run frontend test \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/scripts/test_backend.sh b/{{cookiecutter.project_slug}}/scripts/test_backend.sh new file mode 100644 index 0000000..b5fb2c2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/scripts/test_backend.sh @@ -0,0 +1,6 @@ +#! /usr/bin/env bash + +# Exit in case of error +set -e + +docker-compose run backend pytest $@ \ No newline at end of file