├── .gitignore ├── .pre-commit-config.yaml ├── README.md ├── backend ├── .dockerignore ├── .env.example ├── Dockerfile ├── alembic.ini ├── alembic │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── b35cc22d1726_create_user_and_blog_table_migrations.py ├── apis │ ├── base.py │ └── v1 │ │ ├── route_blog.py │ │ ├── route_login.py │ │ └── route_user.py ├── apps │ ├── base.py │ └── v1 │ │ ├── route_blog.py │ │ └── route_login.py ├── core │ ├── config.py │ ├── hashing.py │ └── security.py ├── db │ ├── base.py │ ├── base_class.py │ ├── models │ │ ├── blog.py │ │ └── user.py │ ├── repository │ │ ├── blog.py │ │ ├── login.py │ │ └── user.py │ └── session.py ├── docker-compose.yaml ├── main.py ├── requirements.txt ├── schemas │ ├── blog.py │ ├── token.py │ └── user.py ├── static │ └── images │ │ └── shots.png ├── templates │ ├── auth │ │ ├── login.html │ │ └── register.html │ ├── base.html │ ├── blog │ │ ├── create_blog.html │ │ ├── detail.html │ │ └── home.html │ └── components │ │ ├── alert.html │ │ └── navbar.html └── tests │ ├── conftest.py │ ├── test_routes │ ├── test_blog.py │ └── test_user.py │ └── utils │ ├── blog.py │ └── user.py └── setup.cfg /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | env 4 | test_db.db 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | 9 | - repo: https://github.com/psf/black 10 | rev: 22.10.0 11 | hooks: 12 | - id: black 13 | 14 | - repo: https://github.com/PyCQA/flake8 15 | rev: 4.0.1 16 | hooks: 17 | - id: flake8 18 | args: [--max-line-length=88] 19 | #files: ^my_appname/|^test_suite_name/ 20 | 21 | - repo: https://github.com/asottile/reorder_python_imports 22 | rev: v3.10.0 23 | hooks: 24 | - id: reorder-python-imports 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Algoholic 2 | A real world blog built with fastapi 3 | 4 | 5 | 6 | ## Technology Stack: 7 | * FastAPI 8 | * Uvicorn 9 | * Pytest 10 | * Sqlalchemy 11 | * Postgres 12 | 13 | 14 | ## How to start the app ? 15 | ``` 16 | git clone https://github.com/sourabhsinha396/fastapi-blog 17 | cd .\algoholic.io\ 18 | python -m venv env #create a virtual environment 19 | .\env\Scripts\activate #activate your windows virtual environment (Linux/Mac: source env/bin/activate) 20 | cd .\backend\ 21 | pip install -r .\requirements.txt 22 | uvicorn main:app --reload #start server 23 | visit 127.0.0.1:8000/ 24 | ``` 25 | 26 | Features: 27 | - ✔️ Course [FastAPI Course](https://www.fastapitutorial.com/blog/fastapi-course/) 28 | - ✔️ Hello World 29 | - Connecting to Database 30 | - Migration by alembic 31 | - Schemas 32 | - Dependency Injection 33 | - Password Hashing 34 | - Unit Testing (What makes an app stable) 35 | - Authentication login/create user/get token 36 | - Authorization/Permissions 37 | 38 | --------------------------------- Intermediate Stuffs -------------------------------- 39 | - Caching 40 | - Deployment on Linux Server 41 | - Webapp (Monolithic) 42 | 43 | --------------------------------- Advanced Stuffs ------------------------------------ 44 | - 🚧 Load Testing 45 | - 🚧 Fully Asyc 46 | - 🚧 Dockerization 47 | - 🚧 Creating a frontend using React 48 | - 🚧 CI and CD 49 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | env/ 2 | env -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=blogdb 2 | POSTGRES_USER=yourusername 3 | POSTGRES_PASSWORD=supersecret 4 | POSTGRES_SERVER=localhost 5 | POSTGRES_PORT=5432 6 | SECRET_KEY=supersecretljfdl283894839jlddfk 7 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONUNBUFFERED 1 5 | 6 | WORKDIR /app 7 | 8 | COPY requirements.txt /app/ 9 | RUN pip install --no-cache-dir -r requirements.txt 10 | 11 | COPY . /app/ 12 | 13 | EXPOSE 8000 14 | 15 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /backend/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | sqlalchemy.url = postgresql://nofoobar:supersecret@localhost:5432/blogdb 64 | 65 | 66 | [post_write_hooks] 67 | # post_write_hooks defines scripts or Python functions that are run 68 | # on newly generated revision scripts. See the documentation for further 69 | # detail and examples 70 | 71 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 72 | # hooks = black 73 | # black.type = console_scripts 74 | # black.entrypoint = black 75 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 76 | 77 | # Logging configuration 78 | [loggers] 79 | keys = root,sqlalchemy,alembic 80 | 81 | [handlers] 82 | keys = console 83 | 84 | [formatters] 85 | keys = generic 86 | 87 | [logger_root] 88 | level = WARN 89 | handlers = console 90 | qualname = 91 | 92 | [logger_sqlalchemy] 93 | level = WARN 94 | handlers = 95 | qualname = sqlalchemy.engine 96 | 97 | [logger_alembic] 98 | level = INFO 99 | handlers = 100 | qualname = alembic 101 | 102 | [handler_console] 103 | class = StreamHandler 104 | args = (sys.stderr,) 105 | level = NOTSET 106 | formatter = generic 107 | 108 | [formatter_generic] 109 | format = %(levelname)-5.5s [%(name)s] %(message)s 110 | datefmt = %H:%M:%S 111 | -------------------------------------------------------------------------------- /backend/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /backend/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from db.base import Base 5 | from sqlalchemy import engine_from_config 6 | from sqlalchemy import pool 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | if config.config_file_name is not None: 15 | fileConfig(config.config_file_name) 16 | 17 | # add your model's MetaData object here 18 | # for 'autogenerate' support 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | target_metadata = Base.metadata 22 | 23 | # other values from the config, defined by the needs of env.py, 24 | # can be acquired: 25 | # my_important_option = config.get_main_option("my_important_option") 26 | # ... etc. 27 | 28 | 29 | def run_migrations_offline() -> None: 30 | """Run migrations in 'offline' mode. 31 | 32 | This configures the context with just a URL 33 | and not an Engine, though an Engine is acceptable 34 | here as well. By skipping the Engine creation 35 | we don't even need a DBAPI to be available. 36 | 37 | Calls to context.execute() here emit the given string to the 38 | script output. 39 | 40 | """ 41 | url = config.get_main_option("sqlalchemy.url") 42 | context.configure( 43 | url=url, 44 | target_metadata=target_metadata, 45 | literal_binds=True, 46 | dialect_opts={"paramstyle": "named"}, 47 | ) 48 | 49 | with context.begin_transaction(): 50 | context.run_migrations() 51 | 52 | 53 | def run_migrations_online() -> None: 54 | """Run migrations in 'online' mode. 55 | 56 | In this scenario we need to create an Engine 57 | and associate a connection with the context. 58 | 59 | """ 60 | connectable = engine_from_config( 61 | config.get_section(config.config_ini_section, {}), 62 | prefix="sqlalchemy.", 63 | poolclass=pool.NullPool, 64 | ) 65 | 66 | with connectable.connect() as connection: 67 | context.configure(connection=connection, target_metadata=target_metadata) 68 | 69 | with context.begin_transaction(): 70 | context.run_migrations() 71 | 72 | 73 | if context.is_offline_mode(): 74 | run_migrations_offline() 75 | else: 76 | run_migrations_online() 77 | -------------------------------------------------------------------------------- /backend/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /backend/alembic/versions/b35cc22d1726_create_user_and_blog_table_migrations.py: -------------------------------------------------------------------------------- 1 | """create user and blog table migrations 2 | 3 | Revision ID: b35cc22d1726 4 | Revises: 5 | Create Date: 2023-06-12 18:14:16.390175 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.dialects import postgresql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "b35cc22d1726" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "user", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("email", sa.String(), nullable=False), 25 | sa.Column("password", sa.String(), nullable=False), 26 | sa.Column("is_superuser", sa.Boolean(), nullable=True), 27 | sa.Column("is_active", sa.Boolean(), nullable=True), 28 | sa.PrimaryKeyConstraint("id"), 29 | ) 30 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) 31 | op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) 32 | op.create_table( 33 | "blog", 34 | sa.Column("id", sa.Integer(), nullable=False), 35 | sa.Column("title", sa.String(), nullable=False), 36 | sa.Column("slug", sa.String(), nullable=False), 37 | sa.Column("content", sa.Text(), nullable=True), 38 | sa.Column("author_id", sa.Integer(), nullable=True), 39 | sa.Column("created_at", sa.DateTime(), nullable=True), 40 | sa.Column("is_active", sa.Boolean(), nullable=True), 41 | sa.ForeignKeyConstraint( 42 | ["author_id"], 43 | ["user.id"], 44 | ), 45 | sa.PrimaryKeyConstraint("id"), 46 | ) 47 | # ### end Alembic commands ### 48 | 49 | 50 | def downgrade() -> None: 51 | # ### commands auto generated by Alembic - please adjust! ### 52 | op.drop_table("blog") 53 | op.drop_index(op.f("ix_user_id"), table_name="user") 54 | op.drop_index(op.f("ix_user_email"), table_name="user") 55 | op.drop_table("user") 56 | # ### end Alembic commands ### 57 | -------------------------------------------------------------------------------- /backend/apis/base.py: -------------------------------------------------------------------------------- 1 | from apis.v1 import route_blog 2 | from apis.v1 import route_login 3 | from apis.v1 import route_user 4 | from fastapi import APIRouter 5 | 6 | 7 | api_router = APIRouter() 8 | api_router.include_router(route_user.router, prefix="", tags=["users"]) 9 | api_router.include_router(route_blog.router, prefix="", tags=["blogs"]) 10 | api_router.include_router(route_login.router, prefix="", tags=["login"]) 11 | -------------------------------------------------------------------------------- /backend/apis/v1/route_blog.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from apis.v1.route_login import get_current_user 4 | from db.models.user import User 5 | from db.repository.blog import create_new_blog 6 | from db.repository.blog import delete_blog 7 | from db.repository.blog import list_blogs 8 | from db.repository.blog import retreive_blog 9 | from db.repository.blog import update_blog 10 | from db.session import get_db 11 | from fastapi import APIRouter 12 | from fastapi import Depends 13 | from fastapi import HTTPException 14 | from fastapi import status 15 | from schemas.blog import CreateBlog 16 | from schemas.blog import ShowBlog 17 | from schemas.blog import UpdateBlog 18 | from sqlalchemy.orm import Session 19 | 20 | router = APIRouter() 21 | 22 | 23 | @router.post("/blogs", response_model=ShowBlog, status_code=status.HTTP_201_CREATED) 24 | def create_blog( 25 | blog: CreateBlog, 26 | db: Session = Depends(get_db), 27 | current_user: User = Depends(get_current_user), 28 | ): 29 | blog = create_new_blog(blog=blog, db=db, author_id=current_user.id) 30 | return blog 31 | 32 | 33 | @router.get("/blog/{id}", response_model=ShowBlog) 34 | def get_blog(id: int, db: Session = Depends(get_db)): 35 | blog = retreive_blog(id=id, db=db) 36 | if not blog: 37 | raise HTTPException( 38 | detail=f"Blog with ID {id} does not exist.", 39 | status_code=status.HTTP_404_NOT_FOUND, 40 | ) 41 | return blog 42 | 43 | 44 | @router.get("/blogs", response_model=List[ShowBlog]) 45 | def get_all_blogs(db: Session = Depends(get_db)): 46 | blogs = list_blogs(db=db) 47 | return blogs 48 | 49 | 50 | @router.put("/blog/{id}", response_model=ShowBlog) 51 | def update_a_blog( 52 | id: int, 53 | blog: UpdateBlog, 54 | db: Session = Depends(get_db), 55 | current_user: User = Depends(get_current_user), 56 | ): 57 | blog = update_blog(id=id, blog=blog, author_id=current_user.id, db=db) 58 | if isinstance(blog, dict): 59 | raise HTTPException( 60 | detail=blog.get("error"), 61 | status_code=status.HTTP_404_NOT_FOUND, 62 | ) 63 | return blog 64 | 65 | 66 | @router.delete("/delete/{id}") 67 | def delete_a_blog( 68 | id: int, 69 | db: Session = Depends(get_db), 70 | current_user: User = Depends(get_current_user), 71 | ): 72 | message = delete_blog(id=id, author_id=current_user.id, db=db) 73 | if message.get("error"): 74 | raise HTTPException( 75 | detail=message.get("error"), status_code=status.HTTP_400_BAD_REQUEST 76 | ) 77 | return {"msg": f"Successfully deleted blog with id {id}"} 78 | -------------------------------------------------------------------------------- /backend/apis/v1/route_login.py: -------------------------------------------------------------------------------- 1 | from core.config import settings 2 | from core.hashing import Hasher 3 | from core.security import create_access_token 4 | from db.repository.login import get_user 5 | from db.session import get_db 6 | from fastapi import APIRouter 7 | from fastapi import Depends 8 | from fastapi import HTTPException 9 | from fastapi import status 10 | from fastapi.security import OAuth2PasswordBearer 11 | from fastapi.security import OAuth2PasswordRequestForm 12 | from jose import jwt 13 | from jose import JWTError 14 | from schemas.token import Token 15 | from sqlalchemy.orm import Session 16 | 17 | 18 | router = APIRouter() 19 | 20 | 21 | def authenticate_user(email: str, password: str, db: Session): 22 | user = get_user(email=email, db=db) 23 | print(user) 24 | if not user: 25 | return False 26 | if not Hasher.verify_password(password, user.password): 27 | return False 28 | return user 29 | 30 | 31 | @router.post("/token", response_model=Token) 32 | def login_for_access_token( 33 | form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) 34 | ): 35 | user = authenticate_user(form_data.username, form_data.password, db) 36 | if not user: 37 | raise HTTPException( 38 | status_code=status.HTTP_401_UNAUTHORIZED, 39 | detail="Incorrect username or password", 40 | ) 41 | access_token = create_access_token(data={"sub": user.email}) 42 | return {"access_token": access_token, "token_type": "bearer"} 43 | 44 | 45 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") 46 | 47 | 48 | def get_current_user( 49 | token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) 50 | ): 51 | credentials_exception = HTTPException( 52 | status_code=status.HTTP_401_UNAUTHORIZED, 53 | detail="Could not validate credentials", 54 | ) 55 | 56 | try: 57 | payload = jwt.decode( 58 | token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] 59 | ) 60 | username: str = payload.get("sub") 61 | if username is None: 62 | raise credentials_exception 63 | except JWTError: 64 | raise credentials_exception 65 | user = get_user(email=username, db=db) 66 | if user is None: 67 | raise credentials_exception 68 | return user 69 | -------------------------------------------------------------------------------- /backend/apis/v1/route_user.py: -------------------------------------------------------------------------------- 1 | from db.repository.user import create_new_user 2 | from db.session import get_db 3 | from fastapi import APIRouter 4 | from fastapi import Depends 5 | from fastapi import status 6 | from schemas.user import ShowUser 7 | from schemas.user import UserCreate 8 | from sqlalchemy.orm import Session 9 | 10 | router = APIRouter() 11 | 12 | 13 | @router.post("/users", response_model=ShowUser, status_code=status.HTTP_201_CREATED) 14 | def create_user(user: UserCreate, db: Session = Depends(get_db)): 15 | user = create_new_user(user=user, db=db) 16 | return user 17 | -------------------------------------------------------------------------------- /backend/apps/base.py: -------------------------------------------------------------------------------- 1 | from apps.v1 import route_blog 2 | from apps.v1 import route_login 3 | from fastapi import APIRouter 4 | 5 | app_router = APIRouter() 6 | 7 | 8 | app_router.include_router( 9 | route_blog.router, prefix="", tags=[""], include_in_schema=False 10 | ) 11 | 12 | app_router.include_router( 13 | route_login.router, prefix="/auth", tags=[""], include_in_schema=False 14 | ) 15 | -------------------------------------------------------------------------------- /backend/apps/v1/route_blog.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from apis.v1.route_login import get_current_user 4 | from db.repository.blog import create_new_blog 5 | from db.repository.blog import delete_blog 6 | from db.repository.blog import list_blogs 7 | from db.repository.blog import retreive_blog 8 | from db.session import get_db 9 | from fastapi import APIRouter 10 | from fastapi import Depends 11 | from fastapi import Form 12 | from fastapi import Request 13 | from fastapi import responses 14 | from fastapi import status 15 | from fastapi.security.utils import get_authorization_scheme_param 16 | from fastapi.templating import Jinja2Templates 17 | from schemas.blog import CreateBlog 18 | from sqlalchemy.orm import Session 19 | 20 | 21 | templates = Jinja2Templates(directory="templates") 22 | router = APIRouter() 23 | 24 | 25 | @router.get("/") 26 | def home(request: Request, alert: Optional[str] = None, db: Session = Depends(get_db)): 27 | blogs = list_blogs(db=db) 28 | return templates.TemplateResponse( 29 | "blog/home.html", {"request": request, "blogs": blogs, "alert": alert} 30 | ) 31 | 32 | 33 | @router.get("/app/blog/{id}") 34 | def blog_detail(request: Request, id: int, db: Session = Depends(get_db)): 35 | blog = retreive_blog(id=id, db=db) 36 | return templates.TemplateResponse( 37 | "blog/detail.html", {"request": request, "blog": blog} 38 | ) 39 | 40 | 41 | @router.get("/app/create-new-blog") 42 | def create_blog(request: Request): 43 | return templates.TemplateResponse("blog/create_blog.html", {"request": request}) 44 | 45 | 46 | @router.post("/app/create-new-blog") 47 | def create_blog( 48 | request: Request, 49 | title: str = Form(...), 50 | content: str = Form(...), 51 | db: Session = Depends(get_db), 52 | ): 53 | token = request.cookies.get("access_token") 54 | _, token = get_authorization_scheme_param(token) 55 | try: 56 | author = get_current_user(token=token, db=db) 57 | blog = CreateBlog(title=title, content=content) 58 | blog = create_new_blog(blog=blog, db=db, author_id=author.id) 59 | return responses.RedirectResponse( 60 | "/?alert=Blog Submitted for Review", status_code=status.HTTP_302_FOUND 61 | ) 62 | except Exception as e: 63 | errors = ["Please log in to create blog"] 64 | print("Exception raised", e) 65 | return templates.TemplateResponse( 66 | "blog/create_blog.html", 67 | {"request": request, "errors": errors, "title": title, "content": content}, 68 | ) 69 | 70 | 71 | @router.get("/delete/{id}") 72 | def delete_a_blog(request: Request, id: int, db: Session = Depends(get_db)): 73 | token = request.cookies.get("access_token") 74 | _, token = get_authorization_scheme_param(token) 75 | try: 76 | author = get_current_user(token=token, db=db) 77 | msg = delete_blog(id=id, author_id=author.id, db=db) 78 | alert = msg.get("error") or msg.get("msg") 79 | return responses.RedirectResponse( 80 | f"/?alert={alert}", status_code=status.HTTP_302_FOUND 81 | ) 82 | except Exception as e: 83 | print(f"Exception raised while deleting {e}") 84 | blog = retreive_blog(id=id, db=db) 85 | return templates.TemplateResponse( 86 | "blog/detail.html", 87 | {"request": request, "alert": "Please Login Again", "blog": blog}, 88 | ) 89 | -------------------------------------------------------------------------------- /backend/apps/v1/route_login.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from apis.v1.route_login import authenticate_user 4 | from core.security import create_access_token 5 | from db.repository.user import create_new_user 6 | from db.session import get_db 7 | from fastapi import APIRouter 8 | from fastapi import Depends 9 | from fastapi import Form 10 | from fastapi import Request 11 | from fastapi import responses 12 | from fastapi import status 13 | from fastapi.templating import Jinja2Templates 14 | from pydantic.error_wrappers import ValidationError 15 | from schemas.user import UserCreate 16 | from sqlalchemy.orm import Session 17 | 18 | 19 | templates = Jinja2Templates(directory="templates") 20 | router = APIRouter() 21 | 22 | 23 | @router.get("/register") 24 | def register(request: Request): 25 | return templates.TemplateResponse("auth/register.html", {"request": request}) 26 | 27 | 28 | @router.post("/register") 29 | def register( 30 | request: Request, 31 | email: str = Form(...), 32 | password: str = Form(...), 33 | db: Session = Depends(get_db), 34 | ): 35 | errors = [] 36 | try: 37 | user = UserCreate(email=email, password=password) 38 | create_new_user(user=user, db=db) 39 | return responses.RedirectResponse( 40 | "/?alert=Successfully%20Registered", status_code=status.HTTP_302_FOUND 41 | ) 42 | except ValidationError as e: 43 | errors_list = json.loads(e.json()) 44 | for item in errors_list: 45 | errors.append(item.get("loc")[0] + ": " + item.get("msg")) 46 | return templates.TemplateResponse( 47 | "auth/register.html", {"request": request, "errors": errors} 48 | ) 49 | 50 | 51 | @router.get("/login") 52 | def login(request: Request): 53 | return templates.TemplateResponse("auth/login.html", {"request": request}) 54 | 55 | 56 | @router.post("/login") 57 | def login( 58 | request: Request, 59 | email: str = Form(...), 60 | password: str = Form(...), 61 | db: Session = Depends(get_db), 62 | ): 63 | errors = [] 64 | user = authenticate_user(email=email, password=password, db=db) 65 | if not user: 66 | errors.append("Incorrect email or password") 67 | return templates.TemplateResponse( 68 | "auth/login.html", {"request": request, "errors": errors} 69 | ) 70 | access_token = create_access_token(data={"sub": email}) 71 | response = responses.RedirectResponse( 72 | "/?alert=Successfully Logged In", status_code=status.HTTP_302_FOUND 73 | ) 74 | response.set_cookie( 75 | key="access_token", value=f"Bearer {access_token}", httponly=True 76 | ) 77 | return response 78 | -------------------------------------------------------------------------------- /backend/core/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | 7 | 8 | class Settings: 9 | PROJECT_NAME: str = "Algoholic 🔥" 10 | PROJECT_VERSION: str = "1.0.0" 11 | 12 | POSTGRES_USER: str = os.getenv("POSTGRES_USER") 13 | POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD") 14 | POSTGRES_SERVER: str = os.getenv("POSTGRES_SERVER", "localhost") 15 | POSTGRES_PORT: str = os.getenv("POSTGRES_PORT", 5432) 16 | POSTGRES_DB: str = os.getenv("POSTGRES_DB", "tdd") 17 | DATABASE_URL = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}:{POSTGRES_PORT}/{POSTGRES_DB}" 18 | SECRET_KEY: str = os.getenv("SECRET_KEY") 19 | ALGORITHM = "HS256" 20 | ACCESS_TOKEN_EXPIRE_MINUTES = 30 21 | 22 | 23 | settings = Settings() 24 | -------------------------------------------------------------------------------- /backend/core/hashing.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 4 | 5 | 6 | class Hasher: 7 | @staticmethod 8 | def verify_password(plain_password, hashed_password): 9 | return pwd_context.verify(plain_password, hashed_password) 10 | 11 | @staticmethod 12 | def get_password_hash(password): 13 | return pwd_context.hash(password) 14 | -------------------------------------------------------------------------------- /backend/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from datetime import timedelta 3 | from typing import Optional 4 | 5 | from core.config import settings 6 | from jose import jwt 7 | 8 | 9 | def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): 10 | to_encode = data.copy() 11 | if expires_delta: 12 | expire = datetime.utcnow() + expires_delta 13 | else: 14 | expire = datetime.utcnow() + timedelta( 15 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES 16 | ) 17 | to_encode.update({"exp": expire}) 18 | encoded_jwt = jwt.encode( 19 | to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM 20 | ) 21 | return encoded_jwt 22 | -------------------------------------------------------------------------------- /backend/db/base.py: -------------------------------------------------------------------------------- 1 | from db.base_class import Base 2 | from db.models.blog import Blog 3 | from db.models.user import User 4 | -------------------------------------------------------------------------------- /backend/db/base_class.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.ext.declarative import declared_attr 4 | from sqlalchemy.orm import as_declarative 5 | 6 | 7 | @as_declarative() 8 | class Base: 9 | id: Any 10 | __name__: str 11 | 12 | # to generate tablename from classname 13 | @declared_attr 14 | def __tablename__(cls) -> str: 15 | return cls.__name__.lower() 16 | -------------------------------------------------------------------------------- /backend/db/models/blog.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from db.base_class import Base 4 | from sqlalchemy import Boolean 5 | from sqlalchemy import Column 6 | from sqlalchemy import DateTime 7 | from sqlalchemy import ForeignKey 8 | from sqlalchemy import Integer 9 | from sqlalchemy import String 10 | from sqlalchemy import Text 11 | from sqlalchemy.orm import relationship 12 | 13 | 14 | class Blog(Base): 15 | id = Column(Integer, primary_key=True) 16 | title = Column(String, nullable=False) 17 | slug = Column(String, nullable=False, unique=True) 18 | content = Column(Text, nullable=True) 19 | author_id = Column(Integer, ForeignKey("user.id")) 20 | author = relationship("User", back_populates="blogs") 21 | created_at = Column(DateTime, default=datetime.now) 22 | is_active = Column(Boolean, default=False) 23 | -------------------------------------------------------------------------------- /backend/db/models/user.py: -------------------------------------------------------------------------------- 1 | from db.base_class import Base 2 | from sqlalchemy import Boolean 3 | from sqlalchemy import Column 4 | from sqlalchemy import Integer 5 | from sqlalchemy import String 6 | from sqlalchemy.orm import relationship 7 | 8 | 9 | class User(Base): 10 | id = Column(Integer, primary_key=True, index=True) 11 | email = Column(String, nullable=False, unique=True, index=True) 12 | password = Column(String, nullable=False) 13 | is_active = Column(Boolean(), default=True) 14 | blogs = relationship("Blog", back_populates="author") 15 | -------------------------------------------------------------------------------- /backend/db/repository/blog.py: -------------------------------------------------------------------------------- 1 | from db.models.blog import Blog 2 | from schemas.blog import CreateBlog 3 | from schemas.blog import UpdateBlog 4 | from sqlalchemy.orm import Session 5 | 6 | 7 | def create_new_blog(blog: CreateBlog, db: Session, author_id: int = 1): 8 | blog = Blog(**blog.dict(), author_id=author_id) 9 | db.add(blog) 10 | db.commit() 11 | db.refresh(blog) 12 | return blog 13 | 14 | 15 | def retreive_blog(id: int, db: Session): 16 | blog = db.query(Blog).filter(Blog.id == id).first() 17 | return blog 18 | 19 | 20 | def list_blogs(db: Session): 21 | blogs = db.query(Blog).filter(Blog.is_active == True).all() 22 | return blogs 23 | 24 | 25 | def update_blog(id: int, blog: UpdateBlog, author_id: int, db: Session): 26 | blog_in_db = db.query(Blog).filter(Blog.id == id).first() 27 | if not blog_in_db: 28 | return {"error": f"Blog with id {id} does not exist"} 29 | if not blog_in_db.author_id == author_id: 30 | return {"error": "Only the author can modify the blog"} 31 | blog_in_db.title = blog.title 32 | blog_in_db.content = blog.content 33 | db.add(blog_in_db) 34 | db.commit() 35 | return blog_in_db 36 | 37 | 38 | def delete_blog(id: int, author_id: int, db: Session): 39 | blog_in_db = db.query(Blog).filter(Blog.id == id) 40 | if not blog_in_db.first(): 41 | return {"error": f"Could not find blog with id {id}"} 42 | if not blog_in_db.first().author_id == author_id: 43 | return {"error": "Only the author can delete a blog"} 44 | blog_in_db.delete() 45 | db.commit() 46 | return {"msg": f"Deleted blog with id {id}"} 47 | -------------------------------------------------------------------------------- /backend/db/repository/login.py: -------------------------------------------------------------------------------- 1 | from db.models.user import User 2 | from sqlalchemy.orm import Session 3 | 4 | 5 | def get_user(email: str, db: Session): 6 | user = db.query(User).filter(User.email == email).first() 7 | return user 8 | -------------------------------------------------------------------------------- /backend/db/repository/user.py: -------------------------------------------------------------------------------- 1 | from core.hashing import Hasher 2 | from db.models.user import User 3 | from schemas.user import UserCreate 4 | from sqlalchemy.orm import Session 5 | 6 | 7 | def create_new_user(user: UserCreate, db: Session): 8 | user = User( 9 | email=user.email, 10 | password=Hasher.get_password_hash(user.password), 11 | is_active=True, 12 | ) 13 | db.add(user) 14 | db.commit() 15 | db.refresh(user) 16 | return user 17 | -------------------------------------------------------------------------------- /backend/db/session.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | from core.config import settings 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | 8 | SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL 9 | print("Database URL is ", SQLALCHEMY_DATABASE_URL) 10 | engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) 11 | 12 | 13 | # if you don't want to install postgres or any database, use sqlite, a file system based database, 14 | # uncomment below lines if you would like to use sqlite and comment above 2 lines of SQLALCHEMY_DATABASE_URL AND engine 15 | 16 | # SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" 17 | # engine = create_engine( 18 | # SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} 19 | # ) 20 | 21 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 22 | 23 | 24 | def get_db() -> Generator: 25 | try: 26 | db = SessionLocal() 27 | yield db 28 | finally: 29 | db.close() 30 | -------------------------------------------------------------------------------- /backend/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | web: 5 | build: 6 | context: . 7 | ports: 8 | - "8000:8000" 9 | depends_on: 10 | - db 11 | volumes: 12 | - .:/app 13 | command: bash -c "uvicorn main:app --host 0.0.0.0 --port 8000 --reload" 14 | 15 | db: 16 | image: postgres:16 17 | environment: 18 | POSTGRES_DB: ${POSTGRES_DB} 19 | POSTGRES_USER: ${POSTGRES_USER} 20 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 21 | volumes: 22 | - postgres_data:/var/lib/postgresql/data 23 | 24 | pgadmin: 25 | image: dpage/pgadmin4:8 26 | environment: 27 | PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} 28 | PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} 29 | ports: 30 | - "5050:80" 31 | volumes: 32 | - pgadmin_data:/var/lib/pgadmin 33 | 34 | 35 | volumes: 36 | postgres_data: 37 | pgadmin_data: 38 | -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | from apis.base import api_router 2 | from apps.base import app_router 3 | from core.config import settings 4 | from db.base import Base 5 | from db.session import engine 6 | from fastapi import FastAPI 7 | from fastapi.staticfiles import StaticFiles 8 | 9 | 10 | def create_tables(): 11 | Base.metadata.create_all(bind=engine) 12 | 13 | 14 | def include_router(app): 15 | app.include_router(api_router) 16 | app.include_router(app_router) 17 | 18 | 19 | def configure_staticfiles(app): 20 | app.mount("/static", StaticFiles(directory="static"), name="static") 21 | 22 | 23 | def start_application(): 24 | app = FastAPI(title=settings.PROJECT_NAME, version=settings.PROJECT_VERSION) 25 | create_tables() 26 | include_router(app) 27 | configure_staticfiles(app) 28 | return app 29 | 30 | 31 | app = start_application() 32 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.95.1 2 | uvicorn==0.22.0 3 | 4 | #new 5 | SQLAlchemy==2.0.13 6 | psycopg2-binary==2.9.6 7 | 8 | python-dotenv==1.0.0 9 | alembic==1.11.1 10 | pydantic[email]==1.10.9 11 | passlib[bcrypt]==1.7.4 12 | 13 | 14 | pytest==7.4.0 15 | httpx==0.24.1 16 | 17 | pre-commit==3.3.3 18 | python-jose==3.3.0 19 | python-multipart==0.0.6 20 | 21 | Jinja2==3.1.2 22 | aiofiles==23.1.0 23 | -------------------------------------------------------------------------------- /backend/schemas/blog.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | from pydantic import root_validator 6 | 7 | 8 | class CreateBlog(BaseModel): 9 | title: str 10 | slug: Optional[str] 11 | content: Optional[str] = None 12 | 13 | @root_validator(pre=True) 14 | def generate_slug(cls, values): 15 | if "title" in values: 16 | values["slug"] = values.get("title").replace(" ", "-").lower() 17 | return values 18 | 19 | 20 | class UpdateBlog(CreateBlog): 21 | pass 22 | 23 | 24 | class ShowBlog(BaseModel): 25 | title: str 26 | content: Optional[str] 27 | created_at: date 28 | 29 | class Config: 30 | orm_mode = True 31 | -------------------------------------------------------------------------------- /backend/schemas/token.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Token(BaseModel): 5 | access_token: str 6 | token_type: str 7 | -------------------------------------------------------------------------------- /backend/schemas/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from pydantic import EmailStr 3 | from pydantic import Field 4 | 5 | 6 | # properties required during user creation 7 | class UserCreate(BaseModel): 8 | email: EmailStr 9 | password: str = Field(..., min_length=4) 10 | 11 | 12 | class ShowUser(BaseModel): 13 | id: int 14 | email: EmailStr 15 | is_active: bool 16 | 17 | class Config: # tells pydantic to convert even non dict obj to json 18 | orm_mode = True 19 | -------------------------------------------------------------------------------- /backend/static/images/shots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sourabhsinha396/fastapi-blog/dbb37709bad5fe37b8f62bd036be8adcc816f8c4/backend/static/images/shots.png -------------------------------------------------------------------------------- /backend/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block title %} 5 | Login 6 | {% endblock %} 7 | 8 | 9 | {% block content %} 10 | 11 | {% for error in errors %} 12 |
13 |
  • {{error}}
  • 14 |
    15 | {% endfor %} 16 | 17 |
    18 |
    Login
    19 |
    20 |
    21 |
    22 |
    23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
    31 |
    32 |
    33 |
    34 |
    35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /backend/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block title %} 5 | Sign Up 6 | {% endblock %} 7 | 8 | 9 | {% block content %} 10 | 11 | {% for error in errors %} 12 |
    13 |
  • {{error}}
  • 14 |
    15 | {% endfor %} 16 | 17 |
    18 |
    Register
    19 |
    20 |
    21 |
    22 |
    23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
    31 |
    32 |
    33 |
    34 |
    35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /backend/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %} 7 | {% endblock %} 8 | 9 | 10 | 11 | {% include "components/navbar.html" %} 12 | {% with alert=alert %} 13 | {% include "components/alert.html" %} 14 | {% endwith %} 15 | {% block content %} 16 | {% endblock %} 17 | 18 | 19 | {% block scripts %} 20 | {% endblock %} 21 | 22 | 23 | -------------------------------------------------------------------------------- /backend/templates/blog/create_blog.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block title %} 5 | Write a Blog 6 | {% endblock %} 7 | 8 | 9 | {% block content %} 10 | 11 | {% for error in errors %} 12 |
    13 |
  • {{error}}
  • 14 |
    15 | {% endfor %} 16 | 17 |
    18 |
    Write a Blog
    19 |
    20 |
    21 |
    22 |
    23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
    31 |
    32 |
    33 |
    34 |
    35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /backend/templates/blog/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block title %} 5 | {{blog.title}} 6 | {% endblock %} 7 | 8 | 9 | {% block content %} 10 |
    11 |
    12 |

    {{blog.title}}

    13 | Publish Date: {{blog.created_at.strftime('%Y-%m-%d')}} 14 |

    {{blog.content}}

    15 | Delete 16 |
    17 |
    18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /backend/templates/blog/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% block title %} 5 | Algoholic 6 | {% endblock %} 7 | 8 | 9 | {% block content %} 10 |
    11 | 12 |
    13 | 14 | {% for blog in blogs %} 15 |
    16 |
    17 |
    18 |
    {{ blog.title }}
    19 |
    20 |
    21 |

    First Published : {{blog.created_at.strftime('%Y-%m-%d')}}

    22 | Content : {{blog.content[:60]}}... 23 |
    24 | Read more 25 |
    26 |
    27 |
    28 | {% endfor %} 29 | 30 |
    31 |
    32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /backend/templates/components/alert.html: -------------------------------------------------------------------------------- 1 | {% if alert %} 2 | 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /backend/templates/components/navbar.html: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /backend/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Any 4 | from typing import Generator 5 | 6 | import pytest 7 | from fastapi import FastAPI 8 | from fastapi.testclient import TestClient 9 | from sqlalchemy import create_engine 10 | from sqlalchemy.orm import sessionmaker 11 | 12 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 13 | # this is to include backend dir in sys.path so that we can import from db,main.py 14 | 15 | from db.base import Base 16 | from db.session import get_db 17 | from apis.base import api_router 18 | 19 | 20 | def start_application(): 21 | app = FastAPI() 22 | app.include_router(api_router) 23 | return app 24 | 25 | 26 | SQLALCHEMY_DATABASE_URL = "sqlite:///./test_db.db" 27 | engine = create_engine( 28 | SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} 29 | ) 30 | # Use connect_args parameter only with sqlite 31 | SessionTesting = sessionmaker(autocommit=False, autoflush=False, bind=engine) 32 | 33 | 34 | @pytest.fixture(scope="function") 35 | def app() -> Generator[FastAPI, Any, None]: 36 | """ 37 | Create a fresh database on each test case. 38 | """ 39 | Base.metadata.create_all(engine) # Create the tables. 40 | _app = start_application() 41 | yield _app 42 | Base.metadata.drop_all(engine) 43 | 44 | 45 | @pytest.fixture(scope="function") 46 | def db_session(app: FastAPI) -> Generator[SessionTesting, Any, None]: 47 | connection = engine.connect() 48 | transaction = connection.begin() 49 | session = SessionTesting(bind=connection) 50 | yield session # use the session in tests. 51 | session.close() 52 | transaction.rollback() 53 | connection.close() 54 | 55 | 56 | @pytest.fixture(scope="function") 57 | def client( 58 | app: FastAPI, db_session: SessionTesting 59 | ) -> Generator[TestClient, Any, None]: 60 | """ 61 | Create a new FastAPI TestClient that uses the `db_session` fixture to override 62 | the `get_db` dependency that is injected into routes. 63 | """ 64 | 65 | def _get_test_db(): 66 | try: 67 | yield db_session 68 | finally: 69 | pass 70 | 71 | app.dependency_overrides[get_db] = _get_test_db 72 | with TestClient(app) as client: 73 | yield client 74 | -------------------------------------------------------------------------------- /backend/tests/test_routes/test_blog.py: -------------------------------------------------------------------------------- 1 | from tests.utils.blog import create_random_blog 2 | 3 | 4 | def test_should_fetch_blog_created(client, db_session): 5 | blog = create_random_blog(db=db_session) 6 | # print(blog.__dict__) 7 | response = client.get(f"blog/{blog.id}/") 8 | assert response.status_code == 200 9 | assert response.json()["title"] == blog.title 10 | -------------------------------------------------------------------------------- /backend/tests/test_routes/test_user.py: -------------------------------------------------------------------------------- 1 | def test_create_user(client): 2 | data = {"email": "testuser@nofoobar.com", "password": "testing"} 3 | response = client.post("/users", json=data) 4 | assert response.status_code == 201 5 | assert response.json()["email"] == "testuser@nofoobar.com" 6 | assert response.json()["is_active"] == True 7 | -------------------------------------------------------------------------------- /backend/tests/utils/blog.py: -------------------------------------------------------------------------------- 1 | from db.repository.blog import create_new_blog 2 | from schemas.blog import CreateBlog 3 | from sqlalchemy.orm import Session 4 | from tests.utils.user import create_random_user 5 | 6 | 7 | def create_random_blog(db: Session): 8 | blog = CreateBlog(title="first_blog", content="Tests make the system stable!") 9 | user = create_random_user(db=db) 10 | blog = create_new_blog(blog=blog, db=db, author_id=user.id) 11 | return blog 12 | -------------------------------------------------------------------------------- /backend/tests/utils/user.py: -------------------------------------------------------------------------------- 1 | from db.repository.user import create_new_user 2 | from schemas.user import UserCreate 3 | from sqlalchemy.orm import Session 4 | 5 | 6 | def create_random_user(db: Session): 7 | user = UserCreate(email="ping@fastapitutorial.com", password="Hello!") 8 | user = create_new_user(user=user, db=db) 9 | return user 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, W503, E203, E402, E712 3 | max-line-length = 88 4 | exclude = .git, backend/alembic/versions/* , backend/db/base.py 5 | --------------------------------------------------------------------------------