diff --git a/fastapi-react-project/.idea/.gitignore b/fastapi-react-project/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/fastapi-react-project/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/fastapi-react-project/.idea/fastapi-react-project.iml b/fastapi-react-project/.idea/fastapi-react-project.iml new file mode 100644 index 00000000..d0876a78 --- /dev/null +++ b/fastapi-react-project/.idea/fastapi-react-project.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/fastapi-react-project/.idea/inspectionProfiles/profiles_settings.xml b/fastapi-react-project/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 00000000..105ce2da --- /dev/null +++ b/fastapi-react-project/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/fastapi-react-project/.idea/modules.xml b/fastapi-react-project/.idea/modules.xml new file mode 100644 index 00000000..59819c27 --- /dev/null +++ b/fastapi-react-project/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/fastapi-react-project/.idea/vcs.xml b/fastapi-react-project/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/fastapi-react-project/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/fastapi-react-project/.prettierignore b/fastapi-react-project/.prettierignore new file mode 100644 index 00000000..412c2574 --- /dev/null +++ b/fastapi-react-project/.prettierignore @@ -0,0 +1 @@ +docker-compose.yml \ No newline at end of file diff --git a/fastapi-react-project/README.md b/fastapi-react-project/README.md new file mode 100644 index 00000000..42406d8d --- /dev/null +++ b/fastapi-react-project/README.md @@ -0,0 +1,159 @@ +# fastapi-react-project + +## 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:8000 + +_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:8000/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/fastapi-react-project/backend/Dockerfile b/fastapi-react-project/backend/Dockerfile new file mode 100644 index 00000000..10aef33a --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/alembic.ini b/fastapi-react-project/backend/alembic.ini new file mode 100644 index 00000000..53e6f13f --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/__init__.py b/fastapi-react-project/backend/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/__pycache__/__init__.cpython-38.pyc b/fastapi-react-project/backend/app/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 00000000..39e0fa85 Binary files /dev/null and b/fastapi-react-project/backend/app/__pycache__/__init__.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/__pycache__/main.cpython-38.pyc b/fastapi-react-project/backend/app/__pycache__/main.cpython-38.pyc new file mode 100644 index 00000000..1d6efc4d Binary files /dev/null and b/fastapi-react-project/backend/app/__pycache__/main.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/__pycache__/tasks.cpython-38.pyc b/fastapi-react-project/backend/app/__pycache__/tasks.cpython-38.pyc new file mode 100644 index 00000000..ac700ff3 Binary files /dev/null and b/fastapi-react-project/backend/app/__pycache__/tasks.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/alembic.ini b/fastapi-react-project/backend/app/alembic.ini new file mode 100644 index 00000000..bfcc3c7f --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/alembic/README b/fastapi-react-project/backend/app/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/fastapi-react-project/backend/app/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/fastapi-react-project/backend/app/alembic/__init__.py b/fastapi-react-project/backend/app/alembic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/alembic/__pycache__/env.cpython-38.pyc b/fastapi-react-project/backend/app/alembic/__pycache__/env.cpython-38.pyc new file mode 100644 index 00000000..cb3a419b Binary files /dev/null and b/fastapi-react-project/backend/app/alembic/__pycache__/env.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/alembic/env.py b/fastapi-react-project/backend/app/alembic/env.py new file mode 100644 index 00000000..5cbeca06 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/alembic/script.py.mako b/fastapi-react-project/backend/app/alembic/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/alembic/versions/0078de844505_removed_updated_at_column_from_note.py b/fastapi-react-project/backend/app/alembic/versions/0078de844505_removed_updated_at_column_from_note.py new file mode 100644 index 00000000..44fba5f5 --- /dev/null +++ b/fastapi-react-project/backend/app/alembic/versions/0078de844505_removed_updated_at_column_from_note.py @@ -0,0 +1,28 @@ +"""Removed updated_at column from Note + +Revision ID: 0078de844505 +Revises: d3fd14d7c620 +Create Date: 2023-06-12 04:41:52.475432-07:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '0078de844505' +down_revision = 'd3fd14d7c620' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('notes', 'updated_at') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('notes', sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/fastapi-react-project/backend/app/alembic/versions/6405440953df_create_notes_table.py b/fastapi-react-project/backend/app/alembic/versions/6405440953df_create_notes_table.py new file mode 100644 index 00000000..4103ea0d --- /dev/null +++ b/fastapi-react-project/backend/app/alembic/versions/6405440953df_create_notes_table.py @@ -0,0 +1,32 @@ +"""create notes table + +Revision ID: 6405440953df +Revises: 91979b40eb38 +Create Date: 2023-06-09 09:53:27.798080 + +""" +from alembic import op +import sqlalchemy as sa +import datetime # Add this line + + +# revision identifiers, used by Alembic. +revision = '6405440953df' +down_revision = '91979b40eb38' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'notes', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('title', sa.String(200), nullable=False), + sa.Column('description', sa.String(500)), + sa.Column('created_at', sa.DateTime, default=datetime.datetime.utcnow), + sa.Column('user_id', sa.Integer, sa.ForeignKey('user.id')) + ) + + +def downgrade(): + op.drop_table('notes') diff --git a/fastapi-react-project/backend/app/alembic/versions/91979b40eb38_create_users_table.py b/fastapi-react-project/backend/app/alembic/versions/91979b40eb38_create_users_table.py new file mode 100644 index 00000000..af661b44 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/alembic/versions/__pycache__/0078de844505_removed_updated_at_column_from_note.cpython-38.pyc b/fastapi-react-project/backend/app/alembic/versions/__pycache__/0078de844505_removed_updated_at_column_from_note.cpython-38.pyc new file mode 100644 index 00000000..4df763ed Binary files /dev/null and b/fastapi-react-project/backend/app/alembic/versions/__pycache__/0078de844505_removed_updated_at_column_from_note.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/alembic/versions/__pycache__/6405440953df_create_notes_table.cpython-38.pyc b/fastapi-react-project/backend/app/alembic/versions/__pycache__/6405440953df_create_notes_table.cpython-38.pyc new file mode 100644 index 00000000..67fe8596 Binary files /dev/null and b/fastapi-react-project/backend/app/alembic/versions/__pycache__/6405440953df_create_notes_table.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/alembic/versions/__pycache__/91979b40eb38_create_users_table.cpython-38.pyc b/fastapi-react-project/backend/app/alembic/versions/__pycache__/91979b40eb38_create_users_table.cpython-38.pyc new file mode 100644 index 00000000..81e69554 Binary files /dev/null and b/fastapi-react-project/backend/app/alembic/versions/__pycache__/91979b40eb38_create_users_table.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/alembic/versions/__pycache__/d3fd14d7c620_updated_note_to_notes.cpython-38.pyc b/fastapi-react-project/backend/app/alembic/versions/__pycache__/d3fd14d7c620_updated_note_to_notes.cpython-38.pyc new file mode 100644 index 00000000..4f49eaca Binary files /dev/null and b/fastapi-react-project/backend/app/alembic/versions/__pycache__/d3fd14d7c620_updated_note_to_notes.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/alembic/versions/d3fd14d7c620_updated_note_to_notes.py b/fastapi-react-project/backend/app/alembic/versions/d3fd14d7c620_updated_note_to_notes.py new file mode 100644 index 00000000..b710bce0 --- /dev/null +++ b/fastapi-react-project/backend/app/alembic/versions/d3fd14d7c620_updated_note_to_notes.py @@ -0,0 +1,46 @@ +"""updated note to notes + +Revision ID: d3fd14d7c620 +Revises: 6405440953df +Create Date: 2023-06-12 04:04:15.064014-07:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd3fd14d7c620' +down_revision = '6405440953df' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('notes', sa.Column('updated_at', sa.DateTime(), nullable=True)) + op.alter_column('user', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=True) + op.alter_column('user', 'is_superuser', + existing_type=sa.BOOLEAN(), + nullable=True) + op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) + op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) + op.drop_column('user', 'address') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('address', sa.VARCHAR(length=100), autoincrement=False, nullable=True)) + op.drop_index(op.f('ix_user_id'), table_name='user') + op.drop_index(op.f('ix_user_email'), table_name='user') + op.alter_column('user', 'is_superuser', + existing_type=sa.BOOLEAN(), + nullable=False) + op.alter_column('user', 'is_active', + existing_type=sa.BOOLEAN(), + nullable=False) + op.drop_column('notes', 'updated_at') + # ### end Alembic commands ### diff --git a/fastapi-react-project/backend/app/api/__init__.py b/fastapi-react-project/backend/app/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/api/__pycache__/__init__.cpython-38.pyc b/fastapi-react-project/backend/app/api/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 00000000..8702d96b Binary files /dev/null and b/fastapi-react-project/backend/app/api/__pycache__/__init__.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/api/api_v1/__init__.py b/fastapi-react-project/backend/app/api/api_v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/api/api_v1/__pycache__/__init__.cpython-38.pyc b/fastapi-react-project/backend/app/api/api_v1/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 00000000..8a0bdcec Binary files /dev/null and b/fastapi-react-project/backend/app/api/api_v1/__pycache__/__init__.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/__init__.py b/fastapi-react-project/backend/app/api/api_v1/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/__init__.cpython-38.pyc b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 00000000..0f972deb Binary files /dev/null and b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/__init__.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/auth.cpython-38.pyc b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/auth.cpython-38.pyc new file mode 100644 index 00000000..b32a1c78 Binary files /dev/null and b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/auth.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/notes.cpython-38.pyc b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/notes.cpython-38.pyc new file mode 100644 index 00000000..79bd0f63 Binary files /dev/null and b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/notes.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/users.cpython-38.pyc b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/users.cpython-38.pyc new file mode 100644 index 00000000..0ed4fda4 Binary files /dev/null and b/fastapi-react-project/backend/app/api/api_v1/routers/__pycache__/users.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/auth.py b/fastapi-react-project/backend/app/api/api_v1/routers/auth.py new file mode 100644 index 00000000..05247f58 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/api/api_v1/routers/notes.py b/fastapi-react-project/backend/app/api/api_v1/routers/notes.py new file mode 100644 index 00000000..655559fb --- /dev/null +++ b/fastapi-react-project/backend/app/api/api_v1/routers/notes.py @@ -0,0 +1,93 @@ +from fastapi import APIRouter, Request, Depends, Response +import typing as t + +from app.db.session import get_db +from app.db.crud import ( + get_notes, + get_note, + create_note, + delete_note, + edit_note, +) +from app.db.schemas import NoteCreate, NoteEdit, NoteOut +from app.core.auth import get_current_active_user, get_current_active_superuser + +notes_router = r = APIRouter() + + +@r.get( + "/notes", + response_model=t.List[NoteOut], + response_model_exclude_none=True, +) +async def notes_list( + response: Response, + db=Depends(get_db), + current_user=Depends(get_current_active_user), +): + """ + Get all notes + """ + notes = get_notes(db) + # This is necessary for react-admin to work + response.headers["Content-Range"] = f"0-9/{len(notes)}" + return notes + + +@r.get( + "/notes/{note_id}", + response_model=NoteOut, + response_model_exclude_none=True, +) +async def note_details( + request: Request, + note_id: int, + db=Depends(get_db), + current_user=Depends(get_current_active_user), +): + """ + Get any note details + """ + note = get_note(db, note_id) + return note + +@r.post("/notes", response_model=NoteOut, response_model_exclude_none=True) +async def note_create( + request: Request, + note: NoteCreate, + db=Depends(get_db), + current_user=Depends(get_current_active_user), +): + """ + Create a new note + """ + return create_note(db, note, current_user.id) + +@r.put( + "/notes/{note_id}", response_model=NoteOut, response_model_exclude_none=True +) +async def note_edit( + request: Request, + note_id: int, + note: NoteEdit, + db=Depends(get_db), + current_user=Depends(get_current_active_user), +): + """ + Update existing note + """ + return edit_note(db, note_id, note) + +@r.delete( + "/notes/{note_id}", response_model=NoteOut, response_model_exclude_none=True +) +async def note_delete( + request: Request, + note_id: int, + db=Depends(get_db), + current_user=Depends(get_current_active_user), +): + """ + Delete existing note + """ + return delete_note(db, note_id) diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/tests/__init__.py b/fastapi-react-project/backend/app/api/api_v1/routers/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/api/api_v1/routers/tests/test_auth.py b/fastapi-react-project/backend/app/api/api_v1/routers/tests/test_auth.py new file mode 100644 index 00000000..3e73790a --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/api/api_v1/routers/tests/test_users.py b/fastapi-react-project/backend/app/api/api_v1/routers/tests/test_users.py new file mode 100644 index 00000000..5f285b03 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/api/api_v1/routers/users.py b/fastapi-react-project/backend/app/api/api_v1/routers/users.py new file mode 100644 index 00000000..06167e27 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/api/dependencies/__init__.py b/fastapi-react-project/backend/app/api/dependencies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/core/__init__.py b/fastapi-react-project/backend/app/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/core/__pycache__/__init__.cpython-38.pyc b/fastapi-react-project/backend/app/core/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 00000000..cbca0766 Binary files /dev/null and b/fastapi-react-project/backend/app/core/__pycache__/__init__.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/core/__pycache__/auth.cpython-38.pyc b/fastapi-react-project/backend/app/core/__pycache__/auth.cpython-38.pyc new file mode 100644 index 00000000..31cc6347 Binary files /dev/null and b/fastapi-react-project/backend/app/core/__pycache__/auth.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/core/__pycache__/celery_app.cpython-38.pyc b/fastapi-react-project/backend/app/core/__pycache__/celery_app.cpython-38.pyc new file mode 100644 index 00000000..cb9f38d6 Binary files /dev/null and b/fastapi-react-project/backend/app/core/__pycache__/celery_app.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/core/__pycache__/config.cpython-38.pyc b/fastapi-react-project/backend/app/core/__pycache__/config.cpython-38.pyc new file mode 100644 index 00000000..d3ee944b Binary files /dev/null and b/fastapi-react-project/backend/app/core/__pycache__/config.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/core/__pycache__/security.cpython-38.pyc b/fastapi-react-project/backend/app/core/__pycache__/security.cpython-38.pyc new file mode 100644 index 00000000..4213edd3 Binary files /dev/null and b/fastapi-react-project/backend/app/core/__pycache__/security.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/core/auth.py b/fastapi-react-project/backend/app/core/auth.py new file mode 100644 index 00000000..0b404b2f --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/core/celery_app.py b/fastapi-react-project/backend/app/core/celery_app.py new file mode 100644 index 00000000..8355ef0d --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/core/config.py b/fastapi-react-project/backend/app/core/config.py new file mode 100644 index 00000000..3a6f2852 --- /dev/null +++ b/fastapi-react-project/backend/app/core/config.py @@ -0,0 +1,7 @@ +import os + +PROJECT_NAME = "fastapi-react-project" + +SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") + +API_V1_STR = "/api/v1" diff --git a/fastapi-react-project/backend/app/core/security.py b/fastapi-react-project/backend/app/core/security.py new file mode 100644 index 00000000..eb0cffce --- /dev/null +++ b/fastapi-react-project/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 = "super_secret" +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/fastapi-react-project/backend/app/db/__init__.py b/fastapi-react-project/backend/app/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/db/__pycache__/__init__.cpython-38.pyc b/fastapi-react-project/backend/app/db/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 00000000..cbdf6264 Binary files /dev/null and b/fastapi-react-project/backend/app/db/__pycache__/__init__.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/db/__pycache__/crud.cpython-38.pyc b/fastapi-react-project/backend/app/db/__pycache__/crud.cpython-38.pyc new file mode 100644 index 00000000..e561f9e2 Binary files /dev/null and b/fastapi-react-project/backend/app/db/__pycache__/crud.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/db/__pycache__/models.cpython-38.pyc b/fastapi-react-project/backend/app/db/__pycache__/models.cpython-38.pyc new file mode 100644 index 00000000..6997e373 Binary files /dev/null and b/fastapi-react-project/backend/app/db/__pycache__/models.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/db/__pycache__/schemas.cpython-38.pyc b/fastapi-react-project/backend/app/db/__pycache__/schemas.cpython-38.pyc new file mode 100644 index 00000000..5df526d8 Binary files /dev/null and b/fastapi-react-project/backend/app/db/__pycache__/schemas.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/db/__pycache__/session.cpython-38.pyc b/fastapi-react-project/backend/app/db/__pycache__/session.cpython-38.pyc new file mode 100644 index 00000000..084c804f Binary files /dev/null and b/fastapi-react-project/backend/app/db/__pycache__/session.cpython-38.pyc differ diff --git a/fastapi-react-project/backend/app/db/crud.py b/fastapi-react-project/backend/app/db/crud.py new file mode 100644 index 00000000..06641af3 --- /dev/null +++ b/fastapi-react-project/backend/app/db/crud.py @@ -0,0 +1,119 @@ +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 + +def get_note(db: Session, note_id: int): + note = db.query(models.Note).filter(models.Note.id == note_id).first() + if not note: + raise HTTPException(status_code=404, detail="Note not found") + return note + + +def get_notes( + db: Session, skip: int = 0, limit: int = 100 +) -> t.List[schemas.NoteOut]: + return db.query(models.Note).offset(skip).limit(limit).all() + + +def create_note(db: Session, note: schemas.NoteCreate, user_id: int): + db_note = models.Note( + title=note.title, + description=note.description, + user_id=user_id, + ) + db.add(db_note) + db.commit() + db.refresh(db_note) + return db_note + + +def delete_note(db: Session, note_id: int): + note = get_note(db, note_id) + if not note: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Note not found") + db.delete(note) + db.commit() + return note + + +def edit_note( + db: Session, note_id: int, note: schemas.NoteEdit +) -> schemas.Note: + db_note = get_note(db, note_id) + if not db_note: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Note not found") + update_data = note.dict(exclude_unset=True) + + for key, value in update_data.items(): + setattr(db_note, key, value) + + db.add(db_note) + db.commit() + db.refresh(db_note) + return db_note diff --git a/fastapi-react-project/backend/app/db/models.py b/fastapi-react-project/backend/app/db/models.py new file mode 100644 index 00000000..103cff01 --- /dev/null +++ b/fastapi-react-project/backend/app/db/models.py @@ -0,0 +1,28 @@ +from sqlalchemy import Boolean, Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from .session import Base +import datetime + +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) + notes = relationship("Note", back_populates="user") + + +class Note(Base): + __tablename__ = "notes" # Change this line + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + description = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + user_id = Column(Integer, ForeignKey("user.id")) + + user = relationship("User", back_populates="notes") diff --git a/fastapi-react-project/backend/app/db/schemas.py b/fastapi-react-project/backend/app/db/schemas.py new file mode 100644 index 00000000..ab1a0999 --- /dev/null +++ b/fastapi-react-project/backend/app/db/schemas.py @@ -0,0 +1,72 @@ +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" + + +class NoteBase(BaseModel): + title: str + description: t.Optional[str] = None + + +class NoteCreate(NoteBase): + pass + + +class NoteEdit(NoteBase): + pass + + +class NoteOut(NoteBase): + id: int + + class Config: + orm_mode = True + + +class Note(NoteBase): + id: int + + class Config: + orm_mode = True diff --git a/fastapi-react-project/backend/app/db/session.py b/fastapi-react-project/backend/app/db/session.py new file mode 100644 index 00000000..d7e2f6c5 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/initial_data.py b/fastapi-react-project/backend/app/initial_data.py new file mode 100644 index 00000000..dc781698 --- /dev/null +++ b/fastapi-react-project/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="admin@fastapi-react-project.com", + password="password", + is_active=True, + is_superuser=True, + ), + ) + + +if __name__ == "__main__": + print("Creating superuser admin@fastapi-react-project.com") + init() + print("Superuser created") diff --git a/fastapi-react-project/backend/app/main.py b/fastapi-react-project/backend/app/main.py new file mode 100644 index 00000000..1ef77754 --- /dev/null +++ b/fastapi-react-project/backend/app/main.py @@ -0,0 +1,51 @@ +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.api.api_v1.routers.notes import notes_router # new import +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"]) +app.include_router(notes_router, prefix="/api/v1", tags=["notes"]) # new router + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", reload=True, port=8888) diff --git a/fastapi-react-project/backend/app/tasks.py b/fastapi-react-project/backend/app/tasks.py new file mode 100644 index 00000000..c17d5063 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/tests/__init__.py b/fastapi-react-project/backend/app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi-react-project/backend/app/tests/test_main.py b/fastapi-react-project/backend/app/tests/test_main.py new file mode 100644 index 00000000..024cf9e5 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/app/tests/test_tasks.py b/fastapi-react-project/backend/app/tests/test_tasks.py new file mode 100644 index 00000000..7f49f1ca --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/conftest.py b/fastapi-react-project/backend/conftest.py new file mode 100644 index 00000000..ecd831dc --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/backend/pyproject.toml b/fastapi-react-project/backend/pyproject.toml new file mode 100644 index 00000000..627a23c9 --- /dev/null +++ b/fastapi-react-project/backend/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 80 \ No newline at end of file diff --git a/fastapi-react-project/backend/requirements.txt b/fastapi-react-project/backend/requirements.txt new file mode 100644 index 00000000..7f8389d3 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/docker-compose.yml b/fastapi-react-project/docker-compose.yml new file mode 100644 index 00000000..b1a41e33 --- /dev/null +++ b/fastapi-react-project/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: + - 8000:80 + depends_on: + - backend + - frontend + + redis: + image: redis + ports: + - 6379:6379 + + postgres: + image: postgres:12 + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 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://postgres:password@postgres:5432/postgres' + 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/fastapi-react-project/frontend/.dockerignore b/fastapi-react-project/frontend/.dockerignore new file mode 100644 index 00000000..25c8fdba --- /dev/null +++ b/fastapi-react-project/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json \ No newline at end of file diff --git a/fastapi-react-project/frontend/.eslintrc.js b/fastapi-react-project/frontend/.eslintrc.js new file mode 100644 index 00000000..428e4222 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/.prettierrc.js b/fastapi-react-project/frontend/.prettierrc.js new file mode 100644 index 00000000..158883bf --- /dev/null +++ b/fastapi-react-project/frontend/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + printWidth: 80, + singleQuote: true, + trailingComma: 'es5', +}; diff --git a/fastapi-react-project/frontend/Dockerfile b/fastapi-react-project/frontend/Dockerfile new file mode 100644 index 00000000..d398ff4f --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/README.md b/fastapi-react-project/frontend/README.md new file mode 100644 index 00000000..54ef0943 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/package.json b/fastapi-react-project/frontend/package.json new file mode 100644 index 00000000..fa17afa2 --- /dev/null +++ b/fastapi-react-project/frontend/package.json @@ -0,0 +1,62 @@ +{ + "name": "fastapi-react", + "version": "0.1.0", + "private": true, + "dependencies": { + "@material-ui/lab": "^4.0.0-alpha.54", + "jwt-decode": "^3.0.0", + "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" + }, + "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": { + "@testing-library/jest-dom": "^5.11.1", + "@testing-library/react": "^11.0.4", + "@testing-library/user-event": "^12.0.11", + "@types/jest": "^26.0.3", + "@types/jwt-decode": "^2.2.1", + "@types/node": "^14.0.1", + "@types/react": "^16.9.19", + "@types/react-dom": "^16.9.5", + "@types/react-router-dom": "^5.1.3", + "@typescript-eslint/eslint-plugin": "^2.24.0", + "@typescript-eslint/parser": "^2.24.0", + "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", + "typescript": "^4.0.2" + } +} diff --git a/fastapi-react-project/frontend/public/favicon.ico b/fastapi-react-project/frontend/public/favicon.ico new file mode 100644 index 00000000..bcd5dfd6 Binary files /dev/null and b/fastapi-react-project/frontend/public/favicon.ico differ diff --git a/fastapi-react-project/frontend/public/index.html b/fastapi-react-project/frontend/public/index.html new file mode 100644 index 00000000..aa069f27 --- /dev/null +++ b/fastapi-react-project/frontend/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/fastapi-react-project/frontend/public/logo192.png b/fastapi-react-project/frontend/public/logo192.png new file mode 100644 index 00000000..fc44b0a3 Binary files /dev/null and b/fastapi-react-project/frontend/public/logo192.png differ diff --git a/fastapi-react-project/frontend/public/logo512.png b/fastapi-react-project/frontend/public/logo512.png new file mode 100644 index 00000000..a4e47a65 Binary files /dev/null and b/fastapi-react-project/frontend/public/logo512.png differ diff --git a/fastapi-react-project/frontend/public/manifest.json b/fastapi-react-project/frontend/public/manifest.json new file mode 100644 index 00000000..080d6c77 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/public/robots.txt b/fastapi-react-project/frontend/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/fastapi-react-project/frontend/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/fastapi-react-project/frontend/run.sh b/fastapi-react-project/frontend/run.sh new file mode 100644 index 00000000..5d2b8435 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/App.tsx b/fastapi-react-project/frontend/src/App.tsx new file mode 100644 index 00000000..f41354b7 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/Routes.tsx b/fastapi-react-project/frontend/src/Routes.tsx new file mode 100644 index 00000000..44cf96d2 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/__tests__/home.test.tsx b/fastapi-react-project/frontend/src/__tests__/home.test.tsx new file mode 100644 index 00000000..ef03bdef --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/__tests__/login.test.tsx b/fastapi-react-project/frontend/src/__tests__/login.test.tsx new file mode 100644 index 00000000..1dae198d --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/admin/Admin.tsx b/fastapi-react-project/frontend/src/admin/Admin.tsx new file mode 100644 index 00000000..4540d4dc --- /dev/null +++ b/fastapi-react-project/frontend/src/admin/Admin.tsx @@ -0,0 +1,41 @@ +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'; +import { NoteList, NoteEdit, NoteCreate } from './Notes'; + +const httpClient = (url: any, options: any = {}) => { + 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' && ( + + ), + , + ]} + + ); +}; diff --git a/fastapi-react-project/frontend/src/admin/Notes/NoteCreate.tsx b/fastapi-react-project/frontend/src/admin/Notes/NoteCreate.tsx new file mode 100644 index 00000000..4fe2af26 --- /dev/null +++ b/fastapi-react-project/frontend/src/admin/Notes/NoteCreate.tsx @@ -0,0 +1,15 @@ +import React, { FC } from 'react'; +import { + Create, + SimpleForm, + TextInput, +} from 'react-admin'; + +export const NoteCreate: FC = (props) => ( + + + + + + +); diff --git a/fastapi-react-project/frontend/src/admin/Notes/NoteEdit.tsx b/fastapi-react-project/frontend/src/admin/Notes/NoteEdit.tsx new file mode 100644 index 00000000..9b2b4eab --- /dev/null +++ b/fastapi-react-project/frontend/src/admin/Notes/NoteEdit.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; +import { + Edit, + SimpleForm, + TextInput, +} from 'react-admin'; + +export const NoteEdit: FC = (props) => ( + + + + + + + +); diff --git a/fastapi-react-project/frontend/src/admin/Notes/NoteList.tsx b/fastapi-react-project/frontend/src/admin/Notes/NoteList.tsx new file mode 100644 index 00000000..db42daa3 --- /dev/null +++ b/fastapi-react-project/frontend/src/admin/Notes/NoteList.tsx @@ -0,0 +1,18 @@ +import React, { FC } from 'react'; +import { + List, + Datagrid, + TextField, + EditButton, +} from 'react-admin'; + +export const NoteList: FC = (props) => ( + + + + + + + + +); diff --git a/fastapi-react-project/frontend/src/admin/Notes/index.ts b/fastapi-react-project/frontend/src/admin/Notes/index.ts new file mode 100644 index 00000000..a03df243 --- /dev/null +++ b/fastapi-react-project/frontend/src/admin/Notes/index.ts @@ -0,0 +1,3 @@ +export * from './NoteEdit'; +export * from './NoteList'; +export * from './NoteCreate'; diff --git a/fastapi-react-project/frontend/src/admin/Users/UserCreate.tsx b/fastapi-react-project/frontend/src/admin/Users/UserCreate.tsx new file mode 100644 index 00000000..466a5182 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/admin/Users/UserEdit.tsx b/fastapi-react-project/frontend/src/admin/Users/UserEdit.tsx new file mode 100644 index 00000000..7925d192 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/admin/Users/UserList.tsx b/fastapi-react-project/frontend/src/admin/Users/UserList.tsx new file mode 100644 index 00000000..dce27f7e --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/admin/Users/index.ts b/fastapi-react-project/frontend/src/admin/Users/index.ts new file mode 100644 index 00000000..999f7e00 --- /dev/null +++ b/fastapi-react-project/frontend/src/admin/Users/index.ts @@ -0,0 +1,3 @@ +export * from './UserEdit'; +export * from './UserList'; +export * from './UserCreate'; diff --git a/fastapi-react-project/frontend/src/admin/authProvider.ts b/fastapi-react-project/frontend/src/admin/authProvider.ts new file mode 100644 index 00000000..1e0fe3ae --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/admin/index.ts b/fastapi-react-project/frontend/src/admin/index.ts new file mode 100644 index 00000000..c956a8fd --- /dev/null +++ b/fastapi-react-project/frontend/src/admin/index.ts @@ -0,0 +1 @@ +export * from './Admin'; diff --git a/fastapi-react-project/frontend/src/config/index.tsx b/fastapi-react-project/frontend/src/config/index.tsx new file mode 100644 index 00000000..31dc509f --- /dev/null +++ b/fastapi-react-project/frontend/src/config/index.tsx @@ -0,0 +1,3 @@ +export const BASE_URL: string = 'http://localhost:8000'; +export const BACKEND_URL: string = + 'http://localhost:8000/api/v1'; diff --git a/fastapi-react-project/frontend/src/decs.d.ts b/fastapi-react-project/frontend/src/decs.d.ts new file mode 100644 index 00000000..5557bb84 --- /dev/null +++ b/fastapi-react-project/frontend/src/decs.d.ts @@ -0,0 +1 @@ +declare module 'react-admin'; diff --git a/fastapi-react-project/frontend/src/index.css b/fastapi-react-project/frontend/src/index.css new file mode 100644 index 00000000..ec2585e8 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/index.tsx b/fastapi-react-project/frontend/src/index.tsx new file mode 100644 index 00000000..9a6816b7 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/logo.svg b/fastapi-react-project/frontend/src/logo.svg new file mode 100644 index 00000000..6b60c104 --- /dev/null +++ b/fastapi-react-project/frontend/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/fastapi-react-project/frontend/src/react-app-env.d.ts b/fastapi-react-project/frontend/src/react-app-env.d.ts new file mode 100644 index 00000000..6431bc5f --- /dev/null +++ b/fastapi-react-project/frontend/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/fastapi-react-project/frontend/src/utils/api.ts b/fastapi-react-project/frontend/src/utils/api.ts new file mode 100644 index 00000000..6b7e2f0a --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/utils/auth.ts b/fastapi-react-project/frontend/src/utils/auth.ts new file mode 100644 index 00000000..bbcf39eb --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/utils/index.ts b/fastapi-react-project/frontend/src/utils/index.ts new file mode 100644 index 00000000..abb0c9d6 --- /dev/null +++ b/fastapi-react-project/frontend/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './api'; diff --git a/fastapi-react-project/frontend/src/views/Home.tsx b/fastapi-react-project/frontend/src/views/Home.tsx new file mode 100644 index 00000000..2a239743 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/views/Login.tsx b/fastapi-react-project/frontend/src/views/Login.tsx new file mode 100644 index 00000000..26298c8a --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/views/PrivateRoute.tsx b/fastapi-react-project/frontend/src/views/PrivateRoute.tsx new file mode 100644 index 00000000..285fc5fd --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/views/Protected.tsx b/fastapi-react-project/frontend/src/views/Protected.tsx new file mode 100644 index 00000000..078414ad --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/views/SignUp.tsx b/fastapi-react-project/frontend/src/views/SignUp.tsx new file mode 100644 index 00000000..0ede11ee --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/src/views/index.ts b/fastapi-react-project/frontend/src/views/index.ts new file mode 100644 index 00000000..797586c5 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/frontend/tsconfig.json b/fastapi-react-project/frontend/tsconfig.json new file mode 100644 index 00000000..4a41017b --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/nginx/nginx.conf b/fastapi-react-project/nginx/nginx.conf new file mode 100644 index 00000000..10a3d32d --- /dev/null +++ b/fastapi-react-project/nginx/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name fastapi-react-project; + + 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/fastapi-react-project/scripts/build.sh b/fastapi-react-project/scripts/build.sh new file mode 100755 index 00000000..77a36455 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/scripts/test.sh b/fastapi-react-project/scripts/test.sh new file mode 100755 index 00000000..9f1d0d33 --- /dev/null +++ b/fastapi-react-project/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/fastapi-react-project/scripts/test_backend.sh b/fastapi-react-project/scripts/test_backend.sh new file mode 100644 index 00000000..b5fb2c2e --- /dev/null +++ b/fastapi-react-project/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 diff --git a/pyramid_scaffold/.coveragerc b/pyramid_scaffold/.coveragerc new file mode 100644 index 00000000..12edc761 --- /dev/null +++ b/pyramid_scaffold/.coveragerc @@ -0,0 +1,2 @@ +[run] +source = pyramid_scaffold diff --git a/pyramid_scaffold/.gitignore b/pyramid_scaffold/.gitignore new file mode 100644 index 00000000..e9336274 --- /dev/null +++ b/pyramid_scaffold/.gitignore @@ -0,0 +1,22 @@ +*.egg +*.egg-info +*.pyc +*$py.class +*~ +.coverage +coverage.xml +build/ +dist/ +.tox/ +nosetests.xml +env*/ +tmp/ +Data*.fs* +*.sublime-project +*.sublime-workspace +.*.sw? +.sw? +.DS_Store +coverage +test +*.sqlite diff --git a/pyramid_scaffold/CHANGES.txt b/pyramid_scaffold/CHANGES.txt new file mode 100644 index 00000000..14b902fd --- /dev/null +++ b/pyramid_scaffold/CHANGES.txt @@ -0,0 +1,4 @@ +0.0 +--- + +- Initial version. diff --git a/pyramid_scaffold/MANIFEST.in b/pyramid_scaffold/MANIFEST.in new file mode 100644 index 00000000..1687278c --- /dev/null +++ b/pyramid_scaffold/MANIFEST.in @@ -0,0 +1,5 @@ +include *.txt *.ini *.cfg *.rst +recursive-include pyramid_scaffold *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2 +recursive-include tests * +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/pyramid_scaffold/README.txt b/pyramid_scaffold/README.txt new file mode 100644 index 00000000..2fcd679c --- /dev/null +++ b/pyramid_scaffold/README.txt @@ -0,0 +1,30 @@ +Pyramid Scaffold +================ + +Getting Started +--------------- + +- Change directory into your newly created project if not already there. Your + current directory should be the same as this README.txt file and setup.py. + + cd pyramid_scaffold + +- Create a Python virtual environment, if not already created. + + python3 -m venv env + +- Upgrade packaging tools, if necessary. + + env/bin/pip install --upgrade pip setuptools + +- Install the project in editable mode with its testing requirements. + + env/bin/pip install -e ".[testing]" + +- Run your project's tests. + + env/bin/pytest + +- Run your project. + + env/bin/pserve development.ini diff --git a/pyramid_scaffold/development.ini b/pyramid_scaffold/development.ini new file mode 100644 index 00000000..c69db2ca --- /dev/null +++ b/pyramid_scaffold/development.ini @@ -0,0 +1,59 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:pyramid_scaffold + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, pyramid_scaffold + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_pyramid_scaffold] +level = DEBUG +handlers = +qualname = pyramid_scaffold + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/pyramid_scaffold/production.ini b/pyramid_scaffold/production.ini new file mode 100644 index 00000000..c105c815 --- /dev/null +++ b/pyramid_scaffold/production.ini @@ -0,0 +1,53 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:pyramid_scaffold + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, pyramid_scaffold + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_pyramid_scaffold] +level = WARN +handlers = +qualname = pyramid_scaffold + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/pyramid_scaffold/pyramid_scaffold/__init__.py b/pyramid_scaffold/pyramid_scaffold/__init__.py new file mode 100644 index 00000000..a3d5a646 --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/__init__.py @@ -0,0 +1,11 @@ +from pyramid.config import Configurator + + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + with Configurator(settings=settings) as config: + config.include('pyramid_jinja2') + config.include('.routes') + config.scan() + return config.make_wsgi_app() diff --git a/pyramid_scaffold/pyramid_scaffold/routes.py b/pyramid_scaffold/pyramid_scaffold/routes.py new file mode 100644 index 00000000..25504ad4 --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/routes.py @@ -0,0 +1,3 @@ +def includeme(config): + config.add_static_view('static', 'static', cache_max_age=3600) + config.add_route('home', '/') diff --git a/pyramid_scaffold/pyramid_scaffold/static/pyramid-16x16.png b/pyramid_scaffold/pyramid_scaffold/static/pyramid-16x16.png new file mode 100644 index 00000000..97920311 Binary files /dev/null and b/pyramid_scaffold/pyramid_scaffold/static/pyramid-16x16.png differ diff --git a/pyramid_scaffold/pyramid_scaffold/static/pyramid.png b/pyramid_scaffold/pyramid_scaffold/static/pyramid.png new file mode 100644 index 00000000..4ab837be Binary files /dev/null and b/pyramid_scaffold/pyramid_scaffold/static/pyramid.png differ diff --git a/pyramid_scaffold/pyramid_scaffold/static/theme.css b/pyramid_scaffold/pyramid_scaffold/static/theme.css new file mode 100644 index 00000000..a70ee557 --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/static/theme.css @@ -0,0 +1,157 @@ +@import url(//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700); +body { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; + color: #ffffff; + background: #bc2131; +} +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 300; +} +p { + font-weight: 300; +} +button, input, optgroup, select, textarea { + color: black; +} +.font-normal { + font-weight: 400; +} +.font-semi-bold { + font-weight: 600; +} +.font-bold { + font-weight: 700; +} +.starter-template { + margin-top: 250px; +} +.starter-template .content { + margin-left: 10px; +} +.starter-template .content h1 { + margin-top: 10px; + font-size: 60px; +} +.starter-template .content h1 .smaller { + font-size: 40px; + color: #f2b7bd; +} +.starter-template .content .lead { + font-size: 25px; + color: #f2b7bd; +} +.starter-template .content .lead .font-normal { + color: #ffffff; +} +.starter-template .links { + float: right; + right: 0; + margin-top: 125px; +} +.starter-template .links ul { + display: block; + padding: 0; + margin: 0; +} +.starter-template .links ul li { + list-style: none; + display: inline; + margin: 0 10px; +} +.starter-template .links ul li:first-child { + margin-left: 0; +} +.starter-template .links ul li:last-child { + margin-right: 0; +} +.starter-template .links ul li.current-version { + color: #f2b7bd; + font-weight: 400; +} +.starter-template .links ul li a, a { + color: #f2b7bd; + text-decoration: underline; +} +.starter-template .links ul li a:hover, a:hover { + color: #ffffff; + text-decoration: underline; +} +.starter-template .links ul li .icon-muted { + color: #eb8b95; + margin-right: 5px; +} +.starter-template .links ul li:hover .icon-muted { + color: #ffffff; +} +.starter-template .copyright { + margin-top: 10px; + font-size: 0.9em; + color: #f2b7bd; + text-transform: lowercase; + float: right; + right: 0; +} +@media (max-width: 1199px) { + .starter-template .content h1 { + font-size: 45px; + } + .starter-template .content h1 .smaller { + font-size: 30px; + } + .starter-template .content .lead { + font-size: 20px; + } +} +@media (max-width: 991px) { + .starter-template { + margin-top: 0; + } + .starter-template .logo { + margin: 40px auto; + } + .starter-template .content { + margin-left: 0; + text-align: center; + } + .starter-template .content h1 { + margin-bottom: 20px; + } + .starter-template .links { + float: none; + text-align: center; + margin-top: 60px; + } + .starter-template .copyright { + float: none; + text-align: center; + } +} +@media (max-width: 767px) { + .starter-template .content h1 .smaller { + font-size: 25px; + display: block; + } + .starter-template .content .lead { + font-size: 16px; + } + .starter-template .links { + margin-top: 40px; + } + .starter-template .links ul li { + display: block; + margin: 0; + } + .starter-template .links ul li .icon-muted { + display: none; + } + .starter-template .copyright { + margin-top: 20px; + } +} diff --git a/pyramid_scaffold/pyramid_scaffold/templates/404.jinja2 b/pyramid_scaffold/pyramid_scaffold/templates/404.jinja2 new file mode 100644 index 00000000..aaf12413 --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/templates/404.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} + +{% block content %} +
+

Pyramid Starter project

+

404 Page Not Found

+
+{% endblock content %} diff --git a/pyramid_scaffold/pyramid_scaffold/templates/layout.jinja2 b/pyramid_scaffold/pyramid_scaffold/templates/layout.jinja2 new file mode 100644 index 00000000..860ceabe --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/templates/layout.jinja2 @@ -0,0 +1,64 @@ + + + + + + + + + + + Cookiecutter Starter project for the Pyramid Web Framework + + + + + + + + + + + + + +
+
+
+
+ +
+
+ {% block content %} +

No content

+ {% endblock content %} +
+
+ +
+ +
+
+
+ + + + + + + + diff --git a/pyramid_scaffold/pyramid_scaffold/templates/mytemplate.jinja2 b/pyramid_scaffold/pyramid_scaffold/templates/mytemplate.jinja2 new file mode 100644 index 00000000..f2e7283f --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/templates/mytemplate.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} + +{% block content %} +
+

Pyramid Starter project

+

Welcome to {{project}}, a Pyramid application generated by
Cookiecutter.

+
+{% endblock content %} diff --git a/pyramid_scaffold/pyramid_scaffold/views/__init__.py b/pyramid_scaffold/pyramid_scaffold/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyramid_scaffold/pyramid_scaffold/views/default.py b/pyramid_scaffold/pyramid_scaffold/views/default.py new file mode 100644 index 00000000..35ad5774 --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/views/default.py @@ -0,0 +1,6 @@ +from pyramid.view import view_config + + +@view_config(route_name='home', renderer='pyramid_scaffold:templates/mytemplate.jinja2') +def my_view(request): + return {'project': 'Pyramid Scaffold'} diff --git a/pyramid_scaffold/pyramid_scaffold/views/notfound.py b/pyramid_scaffold/pyramid_scaffold/views/notfound.py new file mode 100644 index 00000000..0f6a35b1 --- /dev/null +++ b/pyramid_scaffold/pyramid_scaffold/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='pyramid_scaffold:templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/pyramid_scaffold/pytest.ini b/pyramid_scaffold/pytest.ini new file mode 100644 index 00000000..6e3b7498 --- /dev/null +++ b/pyramid_scaffold/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +addopts = --strict-markers + +testpaths = + pyramid_scaffold + tests diff --git a/pyramid_scaffold/setup.py b/pyramid_scaffold/setup.py new file mode 100644 index 00000000..aef7c6b9 --- /dev/null +++ b/pyramid_scaffold/setup.py @@ -0,0 +1,52 @@ +import os + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, 'README.txt')) as f: + README = f.read() +with open(os.path.join(here, 'CHANGES.txt')) as f: + CHANGES = f.read() + +requires = [ + 'plaster_pastedeploy', + 'pyramid', + 'pyramid_jinja2', + 'pyramid_debugtoolbar', + 'waitress', +] + +tests_require = [ + 'WebTest', + 'pytest', + 'pytest-cov', +] + +setup( + name='pyramid_scaffold', + version='0.0', + description='Pyramid Scaffold', + long_description=README + '\n\n' + CHANGES, + classifiers=[ + 'Programming Language :: Python', + 'Framework :: Pyramid', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', + ], + author='', + author_email='', + url='', + keywords='web pyramid pylons', + packages=find_packages(exclude=['tests']), + include_package_data=True, + zip_safe=False, + extras_require={ + 'testing': tests_require, + }, + install_requires=requires, + entry_points={ + 'paste.app_factory': [ + 'main = pyramid_scaffold:main', + ], + }, +) diff --git a/pyramid_scaffold/testing.ini b/pyramid_scaffold/testing.ini new file mode 100644 index 00000000..f5107c59 --- /dev/null +++ b/pyramid_scaffold/testing.ini @@ -0,0 +1,53 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:pyramid_scaffold + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, pyramid_scaffold + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_pyramid_scaffold] +level = DEBUG +handlers = +qualname = pyramid_scaffold + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/pyramid_scaffold/tests/__init__.py b/pyramid_scaffold/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyramid_scaffold/tests/conftest.py b/pyramid_scaffold/tests/conftest.py new file mode 100644 index 00000000..ebc06407 --- /dev/null +++ b/pyramid_scaffold/tests/conftest.py @@ -0,0 +1,76 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest, testConfig +import pytest +import webtest + +from pyramid_scaffold import main + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def app(app_settings): + return main({}, **app_settings) + +@pytest.fixture +def testapp(app): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + }) + + return testapp + +@pytest.fixture +def app_request(app): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + with prepare(registry=app.registry) as env: + request = env['request'] + request.host = 'example.com' + yield request + +@pytest.fixture +def dummy_request(): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the request + itself is not a large focus in the call-stack. It is much easier to mock + and control side-effects using this object, however: + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.host = 'example.com' + + return request + +@pytest.fixture +def dummy_config(dummy_request): + """ + A dummy :class:`pyramid.config.Configurator` object. This allows for + mock configuration, including configuration for ``dummy_request``, as well + as pushing the appropriate threadlocals. + + """ + with testConfig(request=dummy_request) as config: + yield config diff --git a/pyramid_scaffold/tests/test_functional.py b/pyramid_scaffold/tests/test_functional.py new file mode 100644 index 00000000..bac5d63f --- /dev/null +++ b/pyramid_scaffold/tests/test_functional.py @@ -0,0 +1,7 @@ +def test_root(testapp): + res = testapp.get('/', status=200) + assert b'Pyramid' in res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/pyramid_scaffold/tests/test_views.py b/pyramid_scaffold/tests/test_views.py new file mode 100644 index 00000000..1cfaa9ac --- /dev/null +++ b/pyramid_scaffold/tests/test_views.py @@ -0,0 +1,13 @@ +from pyramid_scaffold.views.default import my_view +from pyramid_scaffold.views.notfound import notfound_view + + +def test_my_view(app_request): + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['project'] == 'Pyramid Scaffold' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {}