├── backend
├── app
│ ├── __init__.py
│ ├── api
│ │ ├── __init__.py
│ │ ├── utils
│ │ │ ├── __init__.py
│ │ │ ├── db.py
│ │ │ └── security.py
│ │ └── api_v1
│ │ │ ├── __init__.py
│ │ │ ├── endpoints
│ │ │ ├── __init__.py
│ │ │ ├── utils.py
│ │ │ ├── notes.py
│ │ │ ├── login.py
│ │ │ └── users.py
│ │ │ └── api.py
│ ├── core
│ │ ├── __init__.py
│ │ ├── celery_app.py
│ │ ├── security.py
│ │ ├── jwt.py
│ │ └── config.py
│ ├── db
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── base_class.py
│ │ ├── session.py
│ │ └── init_db.py
│ ├── models
│ │ ├── __init__.py
│ │ ├── note.py
│ │ └── user.py
│ ├── schemas
│ │ ├── __init__.py
│ │ ├── msg.py
│ │ ├── token.py
│ │ ├── note.py
│ │ └── user.py
│ ├── crud
│ │ ├── __init__.py
│ │ ├── crud_user.py
│ │ ├── crud_notes.py
│ │ └── base.py
│ ├── prestart.sh
│ ├── initial_data.py
│ ├── email-templates
│ │ ├── test_email.mjml
│ │ ├── src
│ │ │ ├── new_account.mjml
│ │ │ └── reset_password.mjml
│ │ └── build
│ │ │ ├── test_email.html
│ │ │ ├── new_account.html
│ │ │ └── reset_password.html
│ ├── backend_pre_start.py
│ ├── main.py
│ └── utils.py
├── alembic
│ ├── README
│ ├── sql
│ │ ├── delete_note.sql
│ │ ├── get_owner.sql
│ │ ├── create_note.sql
│ │ ├── update_note.sql
│ │ ├── get_notes_with_user.sql
│ │ ├── get_by_id.sql
│ │ └── get_notes_by_owner.sql
│ ├── script.py.mako
│ ├── versions
│ │ ├── 2020-05-09-193806_3baeebd703b9_init.py
│ │ └── 2020-05-09-195458_e435422117c3_note.py
│ └── env.py
├── .dockerignore
├── dev-requirements.txt
├── mypy.ini
├── requirements.txt
└── alembic.ini
├── db
└── .dockerignore
├── .dockerignore
├── psql.Dockerfile
├── .whitesource
├── .github
├── workflows
│ └── codesee-arch-diagram.yml
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── sample.env
├── docker-compose.yaml
├── LICENSE
├── app.Dockerfile
├── .gitignore
└── README.md
/backend/app/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/db/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/api/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/models/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/schemas/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/api/api_v1/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app/api/api_v1/endpoints/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/db/.dockerignore:
--------------------------------------------------------------------------------
1 | psql_password.txt
2 | psql_user.txt
--------------------------------------------------------------------------------
/backend/alembic/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
--------------------------------------------------------------------------------
/backend/app/crud/__init__.py:
--------------------------------------------------------------------------------
1 | from .crud_user import user
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | env
2 | .dockerignore
3 | Dockerfile
4 | Dockerfile.prod
5 | .coverage
6 | htmlcov
7 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | env
2 | .dockerignore
3 | Dockerfile
4 | Dockerfile.prod
5 | .coverage
6 | htmlcov
7 |
--------------------------------------------------------------------------------
/backend/app/schemas/msg.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class Msg(BaseModel):
5 | msg: str
6 |
--------------------------------------------------------------------------------
/backend/app/api/utils/db.py:
--------------------------------------------------------------------------------
1 | from starlette.requests import Request
2 |
3 |
4 | def get_db(request: Request):
5 | return request.state.db
6 |
--------------------------------------------------------------------------------
/psql.Dockerfile:
--------------------------------------------------------------------------------
1 | # pull official base image
2 | FROM postgres:12-alpine
3 |
4 | # run create.sql on init
5 | # ADD create.sql /docker-entrypoint-initdb.d
--------------------------------------------------------------------------------
/backend/alembic/sql/delete_note.sql:
--------------------------------------------------------------------------------
1 | CREATE PROCEDURE delete_note(in _id integer)
2 | LANGUAGE SQL
3 | AS $$
4 | DELETE FROM note where id = _id
5 | $$;
--------------------------------------------------------------------------------
/backend/dev-requirements.txt:
--------------------------------------------------------------------------------
1 | black
2 | mypy
3 | flake8
4 | requests
5 | pytest
6 | pytest-cov
7 | isort
8 | autoflake
9 | sqlalchemy-stubs
10 |
--------------------------------------------------------------------------------
/.whitesource:
--------------------------------------------------------------------------------
1 | {
2 | "checkRunSettings": {
3 | "vulnerableCheckRunConclusionLevel": "failure"
4 | },
5 | "issueSettings": {
6 | "minSeverityLevel": "LOW"
7 | }
8 | }
--------------------------------------------------------------------------------
/backend/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | plugins = sqlmypy, pydantic.mypy
3 |
4 | [mypy-jose]
5 | ignore_missing_imports = True
6 |
7 | [mypy-passlib.context]
8 | ignore_missing_imports = True
9 |
--------------------------------------------------------------------------------
/backend/alembic/sql/get_owner.sql:
--------------------------------------------------------------------------------
1 | create or replace function get_owner(in note_id integer, out integer)
2 | language sql
3 | as
4 | $$
5 | SELECT owner_id FROM note WHERE id = note_id;
6 | $$;
--------------------------------------------------------------------------------
/backend/app/core/celery_app.py:
--------------------------------------------------------------------------------
1 | # type: ignore
2 |
3 | from celery import Celery
4 |
5 | celery_app = Celery("worker", broker="amqp://guest@queue//")
6 |
7 | celery_app.conf.task_routes = {"app.worker.test_celery": "main-queue"}
8 |
--------------------------------------------------------------------------------
/backend/alembic/sql/create_note.sql:
--------------------------------------------------------------------------------
1 | CREATE PROCEDURE insert_note(in n_title varchar(50), in n_desc varchar(50), in n_owner integer, inout id integer)
2 | LANGUAGE SQL
3 | AS $$
4 | INSERT INTO note(title, description, owner_id) VALUES (n_title, n_desc, n_owner) RETURNING id
5 | $$;
--------------------------------------------------------------------------------
/backend/app/db/base.py:
--------------------------------------------------------------------------------
1 | # Import all the models, so that Base has them before being
2 | # imported by Alembic
3 | from app.db.base_class import Base # noqa
4 | from app.models.user import User # noqa
5 | from app.models.note import Note # noqa
6 |
7 | # from app.models.item import Item # noqa
8 |
--------------------------------------------------------------------------------
/backend/app/schemas/token.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 | from typing import Optional
3 |
4 |
5 | class Token(BaseModel):
6 | access_token: str
7 | token_type: str
8 |
9 |
10 | class TokenPayload(BaseModel):
11 | # sub: int
12 | user_id: Optional[int] = None
13 |
--------------------------------------------------------------------------------
/backend/alembic/sql/update_note.sql:
--------------------------------------------------------------------------------
1 | CREATE PROCEDURE update_note(in n_title varchar(50), in n_desc varchar(50), in n_changed_by integer,
2 | in _id integer, inout id integer)
3 | LANGUAGE SQL
4 | AS $$
5 | UPDATE note SET title = n_title, description = n_desc, changed_by = n_changed_by, changed_date = now()
6 | WHERE id = _id RETURNING id
7 | $$;
--------------------------------------------------------------------------------
/backend/app/prestart.sh:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env bash
2 |
3 | echo "Waiting for postgres..."
4 |
5 | while ! nc -z psql 5432; do
6 | sleep 0.1
7 | done
8 |
9 | echo "PostgreSQL started"
10 |
11 | exec "$@"
12 |
13 | # Let the DB start
14 | python ./app/backend_pre_start.py
15 |
16 | # Run migrations
17 | alembic upgrade head
18 |
19 | # Create initial data in DB
20 | python ./app/initial_data.py
--------------------------------------------------------------------------------
/backend/app/core/security.py:
--------------------------------------------------------------------------------
1 | from passlib.context import CryptContext
2 |
3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
4 |
5 |
6 | def verify_password(plain_password: str, hashed_password: str):
7 | return pwd_context.verify(plain_password, hashed_password)
8 |
9 |
10 | def get_password_hash(password: str):
11 | return pwd_context.hash(password)
12 |
--------------------------------------------------------------------------------
/backend/app/db/base_class.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from sqlalchemy import Column, DateTime, ForeignKey, Integer
4 | from sqlalchemy.ext.declarative import declarative_base, declared_attr
5 |
6 |
7 | class CustomBase(object):
8 | # Generate __tablename__ automatically
9 | @declared_attr
10 | def __tablename__(cls):
11 | return cls.__name__.lower()
12 |
13 |
14 | Base = declarative_base(cls=CustomBase)
15 |
--------------------------------------------------------------------------------
/backend/alembic/sql/get_notes_with_user.sql:
--------------------------------------------------------------------------------
1 | create function get_notes_with_user()
2 | returns TABLE(id integer, title character varying, description character varying, created_date timestamp without time zone, changed_date timestamp without time zone, owner character varying)
3 | language sql
4 | as
5 | $$
6 | SELECT n.id, n.title, n.description, n.created_date, n.changed_date, "user".full_name
7 | FROM note n JOIN "user" ON "user".id = n.owner_id ORDER BY n.id;
8 | $$;
--------------------------------------------------------------------------------
/backend/app/api/api_v1/api.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 |
3 | from app.api.api_v1.endpoints import login, notes, users, utils
4 |
5 | api_router = APIRouter()
6 | api_router.include_router(login.router, tags=["login"]) # type ignore
7 | api_router.include_router(users.router, prefix="/users", tags=["users"])
8 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
9 | api_router.include_router(notes.router, prefix="/notes", tags=["notes"])
10 |
--------------------------------------------------------------------------------
/backend/app/initial_data.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from app.db.init_db import init_db
4 | from app.db.session import Session
5 |
6 | logging.basicConfig(level=logging.INFO)
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | def init() -> None:
11 | db = Session()
12 | init_db(db)
13 |
14 |
15 | def main() -> None:
16 | logger.info("Creating initial data")
17 | init()
18 | logger.info("Initial data created")
19 |
20 |
21 | if __name__ == "__main__":
22 | main()
23 |
--------------------------------------------------------------------------------
/backend/alembic/sql/get_by_id.sql:
--------------------------------------------------------------------------------
1 | create or replace function get_by_id(note_id integer)
2 | returns TABLE(id integer, title character varying, description character varying, created_date timestamp without time zone, changed_date timestamp without time zone, owner character varying)
3 | language sql
4 | as
5 | $$
6 | SELECT n.id, n.title, n.description, n.created_date, n.changed_date, "user".full_name
7 | FROM note n JOIN "user" ON "user".id = n.owner_id WHERE n.id = note_id ORDER BY n.id;
8 | $$;
9 |
--------------------------------------------------------------------------------
/backend/app/email-templates/test_email.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ project_name }}
9 | Test email for: {{ email }}
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/backend/alembic/sql/get_notes_by_owner.sql:
--------------------------------------------------------------------------------
1 | create or replace function get_notes_by_owner(ownerid int)
2 | returns TABLE (
3 | id int,
4 | title varchar(50),
5 | description varchar(50),
6 | created_date timestamp,
7 | changed_date timestamp,
8 | owner varchar
9 | )
10 | language sql as $$
11 | SELECT n.id, n.title, n.description, n.created_date, n.changed_date, "user".full_name
12 | FROM note n JOIN "user" ON "user".id = n.owner_id where owner_id = ownerid ORDER BY n.id;
13 | $$;
--------------------------------------------------------------------------------
/backend/app/db/session.py:
--------------------------------------------------------------------------------
1 | from databases import Database
2 | from sqlalchemy import MetaData, create_engine
3 | from sqlalchemy.orm import scoped_session, sessionmaker
4 |
5 | from app.core import config
6 |
7 | database = Database(config.SQLALCHEMY_DATABASE_URI)
8 | metadata = MetaData()
9 |
10 | engine = create_engine(config.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True)
11 | db_session = scoped_session(
12 | sessionmaker(autocommit=False, autoflush=False, bind=engine)
13 | )
14 | Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
15 |
--------------------------------------------------------------------------------
/backend/requirements.txt:
--------------------------------------------------------------------------------
1 | aiofiles==0.5.0
2 | alembic==1.4.2
3 | async-exit-stack==1.0.1
4 | async-generator==1.10
5 | asyncio==3.4.3
6 | asyncpg==0.21.0
7 | psycopg2-binary==2.8.5
8 | celery==5.2.2
9 | # databases[postgresql]==0.3.2
10 | email-validator==1.1.1
11 | emails==0.6
12 | fastapi==0.109.1
13 | passlib[bcrypt]==1.7.2
14 | pydantic==1.6.2
15 | pyjwt==2.4.0
16 | python-dotenv==0.13.0
17 | python-jose[cryptography]==3.3.2
18 | python-multipart==0.0.7
19 | raven==6.10.0
20 | requests==2.26.0
21 | sqlalchemy==1.3.17
22 | starlette==0.27.0
23 | tenacity==6.2.0
24 | uvicorn==0.11.7
25 |
--------------------------------------------------------------------------------
/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():
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade():
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/.github/workflows/codesee-arch-diagram.yml:
--------------------------------------------------------------------------------
1 | # This workflow was added by CodeSee. Learn more at https://codesee.io/
2 | # This is v2.0 of this workflow file
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request_target:
8 | types: [opened, synchronize, reopened]
9 |
10 | name: CodeSee
11 |
12 | permissions: read-all
13 |
14 | jobs:
15 | codesee:
16 | runs-on: ubuntu-latest
17 | continue-on-error: true
18 | name: Analyze the repo with CodeSee
19 | steps:
20 | - uses: Codesee-io/codesee-action@v2
21 | with:
22 | codesee-token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }}
23 |
--------------------------------------------------------------------------------
/backend/app/models/note.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, func
3 |
4 | from app.db.base_class import Base
5 |
6 |
7 | class Note(Base):
8 |
9 | id = Column(Integer, primary_key=True, index=True)
10 | title = Column(String(50), index=True)
11 | description = Column(String(50), index=True)
12 | created_date = Column(DateTime, server_default=func.now(), nullable=False)
13 | owner_id = Column(Integer, ForeignKey("user.id"))
14 | changed_date = Column(DateTime, onupdate=datetime.datetime.now)
15 | changed_by = Column(Integer, ForeignKey("user.id"))
16 |
--------------------------------------------------------------------------------
/sample.env:
--------------------------------------------------------------------------------
1 | PYTHONPATH=.
2 | SECRET_KEY=6667ac30bb5b14ee0e7b3aef53f5ecf7b557469145f30bd4aedc01e5959b36a7
3 |
4 | POSTGRES_SERVER=psql:5432
5 | POSTGRES_USER=dbuser
6 | POSTGRES_PASSWORD=QpqQcgD7dxVG
7 | POSTGRES_DB=lrnfast
8 | BACKEND_CORS_ORIGINS=http://localhost,http://localhost:4200,http://localhost:3000,http://localhost:8080
9 | PROJECT_NAME="Learning FastAPI"
10 | FIRST_SUPERUSER=wed@mail.com
11 | FIRST_SUPERUSER_PASSWORD=9das33@54
12 | FIRST_SUPERUSER_FULLNAME=Edward
13 | SMTP_TLS=True
14 | SMTP_PORT=1112
15 | SMTP_HOST=127.0.0.1
16 | SMTP_USER=laadmin
17 | SMTP_PASSWORD=nothere
18 | EMAILS_FROM_EMAIL=no-reply@somewhere.com
19 |
20 | USERS_OPEN_REGISTRATION=True
21 |
22 | SENTRY_DSN=
23 |
--------------------------------------------------------------------------------
/backend/app/core/jwt.py:
--------------------------------------------------------------------------------
1 | # type: ignore
2 |
3 | from datetime import datetime, timedelta
4 |
5 | import jwt
6 |
7 | from app.core import config
8 |
9 | ALGORITHM = "HS256"
10 | access_token_jwt_subject = "access"
11 |
12 |
13 | def create_access_token(*, data: dict, expires_delta: timedelta = None):
14 | to_encode = data.copy()
15 | if expires_delta:
16 | expire = datetime.utcnow() + expires_delta
17 | else:
18 | expire = datetime.utcnow() + timedelta(minutes=15)
19 | to_encode.update({"exp": expire, "sub": access_token_jwt_subject})
20 | encoded_jwt = jwt.encode(to_encode, config.SECRET_KEY, algorithm=ALGORITHM)
21 | return encoded_jwt
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/backend/app/schemas/note.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 | from datetime import datetime
3 | from pydantic import BaseModel, Field
4 |
5 |
6 | class NoteSchema(BaseModel):
7 | title: str = Field(..., min_length=3, max_length=50)
8 | description: str = Field(..., min_length=3, max_length=50)
9 |
10 |
11 | class NoteDB(NoteSchema):
12 | id: int
13 | title: str
14 | description: str
15 | owner_id: int
16 | created_date: datetime
17 | changed_date: Optional[datetime] = None
18 | changed_by: Optional[int] = None
19 |
20 |
21 | class NoteUser(NoteSchema):
22 | id: int
23 | title: str
24 | description: str
25 | created_date: datetime
26 | changed_date: Optional[datetime] = None
27 | owner: str = None
28 |
--------------------------------------------------------------------------------
/backend/app/email-templates/src/new_account.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ project_name }} - New Account
7 | You have a new account:
8 | Username: {{ username }}
9 | Password: {{ password }}
10 | Go to Dashboard
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | psql:
5 | build:
6 | context: ./db
7 | dockerfile: ../psql.Dockerfile
8 | volumes:
9 | - psql-data:/var/lib/postgresql/data
10 | expose:
11 | - 5432
12 | ports:
13 | - 5432:5432
14 | env_file:
15 | - .env
16 | networks:
17 | - backend
18 |
19 | app:
20 | build:
21 | context: ./backend
22 | dockerfile: ../app.Dockerfile
23 | command:
24 | - /bin/bash
25 | - -c
26 | - |
27 | ./app/prestart.sh
28 | uvicorn app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000
29 | volumes:
30 | - ./backend:/usr/src/app
31 | ports:
32 | - 8000:8000
33 | env_file:
34 | - .env
35 | networks:
36 | - backend
37 | - frontend
38 | depends_on:
39 | - psql
40 |
41 | networks:
42 | frontend:
43 | backend:
44 |
45 | volumes:
46 | psql-data:
--------------------------------------------------------------------------------
/backend/app/backend_pre_start.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
4 |
5 | from app.db.session import Session
6 |
7 | logging.basicConfig(level=logging.INFO)
8 | logger = logging.getLogger(__name__)
9 |
10 | max_tries = 60 * 5 # 5 minutes
11 | wait_seconds = 1
12 |
13 |
14 | @retry(
15 | stop=stop_after_attempt(max_tries),
16 | wait=wait_fixed(wait_seconds),
17 | before=before_log(logger, logging.INFO),
18 | after=after_log(logger, logging.WARN),
19 | )
20 | def init() -> None:
21 | try:
22 | db = Session()
23 | # Try to create session to check if DB is awake
24 | db.execute("SELECT 1")
25 | except Exception as e:
26 | logger.error(e)
27 | raise e
28 |
29 |
30 | def main() -> None:
31 | logger.info("Initializing service")
32 | init()
33 | logger.info("Service finished initializing")
34 |
35 |
36 | if __name__ == "__main__":
37 | main()
38 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/backend/app/db/init_db.py:
--------------------------------------------------------------------------------
1 | from app import crud
2 | from app.core import config
3 |
4 | # make sure all SQL Alchemy models are imported before initializing DB
5 | # otherwise, SQL Alchemy might fail to initialize relationships properly
6 | # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28
7 | from app.db import base
8 | from app.schemas.user import UserCreate
9 |
10 |
11 | def init_db(db_session):
12 | # Tables should be created with Alembic migrations
13 | # But if you don't want to use migrations, create
14 | # the tables un-commenting the next line
15 | # Base.metadata.create_all(bind=engine)
16 |
17 | user = crud.user.get_by_email(db_session, email=config.FIRST_SUPERUSER)
18 | if not user:
19 | user_in = UserCreate(
20 | email=config.FIRST_SUPERUSER,
21 | password=config.FIRST_SUPERUSER_PASSWORD,
22 | full_name=config.FIRST_SUPERUSER_FULLNAME,
23 | is_superuser=True,
24 | )
25 | user = crud.user.create(db_session, obj_in=user_in)
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 wedwardbeck
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/backend/app/api/api_v1/endpoints/utils.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends
2 | from pydantic.networks import EmailStr
3 |
4 | from app.api.utils.security import get_current_active_superuser # type: ignore
5 | from app.core.celery_app import celery_app # type: ignore
6 | from app.schemas.msg import Msg
7 | from app.schemas.user import User
8 | from app.models.user import User as DBUser
9 | from app.utils import send_test_email # type: ignore
10 |
11 | router = APIRouter()
12 |
13 |
14 | @router.post("/test-celery/", response_model=Msg, status_code=201)
15 | def test_celery(msg: Msg, current_user: DBUser = Depends(get_current_active_superuser)):
16 | """
17 | Test Celery worker.
18 | """
19 | celery_app.send_task("app.worker.test_celery", args=[msg.msg])
20 | return {"msg": "Word received"}
21 |
22 |
23 | @router.post("/test-email/", response_model=Msg, status_code=201)
24 | def test_email(
25 | email_to: EmailStr, current_user: DBUser = Depends(get_current_active_superuser)
26 | ):
27 | """
28 | Test emails.
29 | """
30 | send_test_email(email_to=email_to)
31 | return {"msg": "Test email sent"}
32 |
--------------------------------------------------------------------------------
/backend/app/schemas/user.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel, EmailStr
4 |
5 |
6 | # Shared properties
7 | class UserBase(BaseModel):
8 | email: Optional[EmailStr] = None
9 | is_active: Optional[bool] = True
10 | is_superuser: Optional[bool] = False
11 | full_name: Optional[str] = None
12 |
13 |
14 | class UserBaseInDB(UserBase):
15 | id: Optional[int] = None
16 |
17 | class Config:
18 | orm_mode = True
19 |
20 |
21 | # Properties to receive via API on creation
22 | class UserCreate(UserBaseInDB):
23 | email: EmailStr
24 | password: str
25 | full_name: str
26 |
27 |
28 | # Properties to receive via API on update
29 | class UserUpdate(UserBaseInDB):
30 | password: Optional[str] = None
31 |
32 |
33 | # Additional properties to return via API
34 | class User(UserBaseInDB):
35 | pass
36 |
37 |
38 | # Additional properties stored in DB
39 | class UserInDB(UserBaseInDB):
40 | hashed_password: str
41 |
42 |
43 | # Additional properties to return via API
44 | class UserShared(BaseModel):
45 | full_name: str
46 |
47 | class Config:
48 | orm_mode = True
49 |
--------------------------------------------------------------------------------
/backend/app/models/user.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Boolean, Column, DateTime, Integer, String, func
2 |
3 | from app.db.base_class import Base
4 |
5 |
6 | class User(Base):
7 | id = Column(Integer, primary_key=True, index=True)
8 | full_name = Column(String, index=True)
9 | email = Column(String, unique=True, index=True)
10 | hashed_password = Column(String)
11 | is_active = Column(Boolean(), default=True)
12 | is_superuser = Column(Boolean(), default=False)
13 | created_date = Column(DateTime, server_default=func.now(), nullable=False)
14 |
15 |
16 | # from sqlalchemy import Boolean, Column, DateTime, Integer, String, Table, func
17 | #
18 | # from app.db.session import metadata
19 | #
20 | # user = Table(
21 | # "user",
22 | # metadata,
23 | # Column("id", Integer, primary_key=True, index=True),
24 | # Column("full_name", String, index=True),
25 | # Column("email", String, unique=True, index=True),
26 | # Column("hashed_password", String),
27 | # Column("is_active", Boolean(), default=True),
28 | # Column("is_superuser", Boolean(), default=False),
29 | # Column("created_date", DateTime, server_default=func.now(), nullable=False),
30 | # )
31 |
--------------------------------------------------------------------------------
/backend/app/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from starlette.middleware.cors import CORSMiddleware
3 | from starlette.requests import Request
4 |
5 | from app.api.api_v1.api import api_router
6 | from app.core import config
7 | from app.db.session import Session, database
8 |
9 | app = FastAPI(title=config.PROJECT_NAME, openapi_url="/api/v1/openapi.json") # type: ignore
10 |
11 |
12 | @app.on_event("startup")
13 | async def startup():
14 | await database.connect()
15 |
16 |
17 | @app.on_event("shutdown")
18 | async def shutdown():
19 | await database.disconnect()
20 |
21 |
22 | # CORS
23 | origins = []
24 |
25 | # Set all CORS enabled origins
26 | if config.BACKEND_CORS_ORIGINS:
27 | origins_raw = config.BACKEND_CORS_ORIGINS.split(",")
28 | for origin in origins_raw:
29 | use_origin = origin.strip()
30 | origins.append(use_origin)
31 | app.add_middleware( # type: ignore
32 | CORSMiddleware,
33 | allow_origins=origins,
34 | allow_credentials=True,
35 | allow_methods=["*"],
36 | allow_headers=["*"],
37 | ),
38 |
39 | app.include_router(api_router, prefix=config.API_V1_STR)
40 |
41 |
42 | @app.middleware("http")
43 | async def db_session_middleware(request: Request, call_next):
44 | request.state.db = Session()
45 | response = await call_next(request)
46 | request.state.db.close()
47 | return response
48 |
--------------------------------------------------------------------------------
/backend/app/email-templates/src/reset_password.mjml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ project_name }} - Password Recovery
9 | We received a request to recover the password for user {{ username }}
11 | with email {{ email }}
13 | Reset your password by clicking the button below:
16 | Reset Password
19 | Or open the following link:
22 | {{ link }}
25 |
26 | The reset password link / button will expire in {{ valid_hours }}
28 | hours.
30 | If you didn't request a password recovery you can disregard this
32 | email.
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/backend/alembic/versions/2020-05-09-193806_3baeebd703b9_init.py:
--------------------------------------------------------------------------------
1 | """Init
2 |
3 | Revision ID: 3baeebd703b9
4 | Revises:
5 | Create Date: 2020-05-09 19:38:06.481746+00:00
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '3baeebd703b9'
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('user',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('full_name', sa.String(), nullable=True),
24 | sa.Column('email', sa.String(), nullable=True),
25 | sa.Column('hashed_password', sa.String(), nullable=True),
26 | sa.Column('is_active', sa.Boolean(), nullable=True),
27 | sa.Column('is_superuser', sa.Boolean(), nullable=True),
28 | sa.Column('created_date', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
29 | sa.PrimaryKeyConstraint('id')
30 | )
31 | op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
32 | op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False)
33 | op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
34 | # ### end Alembic commands ###
35 |
36 |
37 | def downgrade():
38 | # ### commands auto generated by Alembic - please adjust! ###
39 | op.drop_index(op.f('ix_user_id'), table_name='user')
40 | op.drop_index(op.f('ix_user_full_name'), table_name='user')
41 | op.drop_index(op.f('ix_user_email'), table_name='user')
42 | op.drop_table('user')
43 | # ### end Alembic commands ###
44 |
--------------------------------------------------------------------------------
/backend/app/crud/crud_user.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from sqlalchemy.orm import Session
4 |
5 | from app.models.user import User
6 | from app.schemas.user import UserCreate, UserUpdate
7 | from app.core.security import verify_password, get_password_hash
8 | from app.crud.base import CRUDBase
9 |
10 |
11 | class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
12 | # noinspection PyMethodMayBeStatic
13 | def get_by_email(self, db_session: Session, *, email: str) -> Optional[User]:
14 | return db_session.query(User).filter(User.email == email).first()
15 |
16 | def create(self, db_session: Session, *, obj_in: UserCreate) -> User:
17 | db_obj = User( # type: ignore
18 | email=obj_in.email,
19 | hashed_password=get_password_hash(obj_in.password),
20 | full_name=obj_in.full_name,
21 | is_superuser=obj_in.is_superuser,
22 | )
23 | db_session.add(db_obj)
24 | db_session.commit()
25 | db_session.refresh(db_obj)
26 | return db_obj
27 |
28 | def authenticate(
29 | self, db_session: Session, *, email: str, password: str) -> Optional[User]:
30 | user = self.get_by_email(db_session, email=email)
31 | if not user:
32 | return None
33 | if not verify_password(password, user.hashed_password): # type: ignore
34 | return None
35 | return user
36 |
37 | # noinspection PyMethodMayBeStatic
38 | def is_active(self, user: User) -> bool:
39 | return user.is_active
40 |
41 | # noinspection PyMethodMayBeStatic
42 | def is_superuser(self, user: User) -> bool:
43 | return user.is_superuser
44 |
45 |
46 | user = CRUDUser(User)
47 |
--------------------------------------------------------------------------------
/backend/app/api/utils/security.py:
--------------------------------------------------------------------------------
1 | # type: ignore
2 |
3 | import jwt
4 | from fastapi import Depends, HTTPException, Security
5 | from fastapi.security import OAuth2PasswordBearer
6 | from jwt import PyJWTError
7 | from sqlalchemy.orm import Session
8 | from starlette.status import HTTP_403_FORBIDDEN
9 |
10 | from app import crud
11 | from app.api.utils.db import get_db
12 | from app.core import config
13 | from app.core.jwt import ALGORITHM
14 | from app.models.user import User
15 | from app.schemas.token import TokenPayload
16 |
17 | reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
18 |
19 |
20 | def get_current_user(
21 | db: Session = Depends(get_db), token: str = Security(reusable_oauth2)
22 | ):
23 | try:
24 | payload = jwt.decode(token, config.SECRET_KEY, algorithms=[ALGORITHM])
25 | token_data = TokenPayload(**payload)
26 | except PyJWTError:
27 | raise HTTPException(
28 | status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
29 | )
30 | user = crud.user.get(db, id=token_data.user_id)
31 | if not user:
32 | raise HTTPException(status_code=404, detail="User not found")
33 | return user
34 |
35 |
36 | def get_current_active_user(current_user: User = Security(get_current_user)):
37 | if not crud.user.is_active(current_user):
38 | raise HTTPException(status_code=400, detail="Inactive user")
39 | return current_user
40 |
41 |
42 | def get_current_active_superuser(current_user: User = Security(get_current_user)):
43 | if not crud.user.is_superuser(current_user):
44 | raise HTTPException(
45 | status_code=400, detail="The user doesn't have enough privileges"
46 | )
47 | return current_user
48 |
--------------------------------------------------------------------------------
/backend/app/crud/crud_notes.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from app.db.session import database
3 | from app.models.note import Note
4 | from app.schemas.note import NoteSchema
5 | from app.models.user import User
6 |
7 |
8 | async def post(payload: NoteSchema, owner_id: int):
9 | query = "CALL insert_note(:title, :description, :owner_id, 0)" # noinspection
10 | values = {"title": payload.title, "description": payload.description, "owner_id": owner_id}
11 | id = await database.fetch_val(query=query, values=values) # use databases.execute if databases version <=0.3.2
12 | note = await get(id)
13 | return note
14 |
15 |
16 | async def get(id: int):
17 | query = "SELECT * FROM get_by_id(:id)"
18 | return await database.fetch_one(query=query, values={"id": id})
19 |
20 |
21 | async def get_all(*, skip=0, limit=100):
22 | query = "select * from get_notes_with_user() LIMIT :limit OFFSET :skip"
23 | return await database.fetch_all(query=query, values={"skip": skip, "limit": limit})
24 |
25 |
26 | async def get_all_by_owner(*, owner_id: int, skip=0, limit=100):
27 | query = "select * from get_notes_by_owner(:owner_id) LIMIT :limit OFFSET :skip;"
28 | return await database.fetch_all(query=query, values={"skip": skip, "limit": limit, "owner_id": owner_id})
29 |
30 |
31 | async def put(id: int, payload: NoteSchema, current_user: User):
32 | values = {
33 | "title": payload.title,
34 | "description": payload.description,
35 | "changed_by": current_user.id,
36 | "id": id
37 | }
38 | query = "CALL update_note(:title, :description, :changed_by, :id, 0)"
39 | updated: int = await database.execute(query=query, values=values)
40 | note = await get(updated)
41 | return note
42 |
43 |
44 | async def delete(id: int):
45 | query = "CALL delete_note(:id)"
46 | return await database.execute(query=query, values={"id": id})
47 |
48 |
49 | async def get_owner(id: int):
50 | query = "SELECT get_owner(:id)"
51 | return await database.fetch_val(query=query, values={"id": id})
52 |
--------------------------------------------------------------------------------
/app.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM clearlinux:latest AS builder
2 |
3 | ARG swupd_args
4 | # Move to latest Clear Linux release to ensure
5 | # that the swupd command line arguments are
6 | # correct
7 | RUN swupd update --no-boot-update $swupd_args
8 |
9 | # Grab os-release info from the minimal base image so
10 | # that the new content matches the exact OS version
11 | COPY --from=clearlinux/os-core:latest /usr/lib/os-release /
12 |
13 | # Install additional content in a target directory
14 | # using the os version from the minimal base
15 | RUN source /os-release && \
16 | mkdir /install_root \
17 | && swupd os-install -V ${VERSION_ID} \
18 | --path /install_root --statedir /swupd-state \
19 | --bundles=os-core-update,python3-basic,ncat --no-scripts
20 | # --bundles=os-core-update,python3-basic --no-boot-update
21 |
22 | # For some Host OS configuration with redirect_dir on,
23 | # extra data are saved on the upper layer when the same
24 | # file exists on different layers. To minimize docker
25 | # image size, remove the overlapped files before copy.
26 | RUN mkdir /os_core_install
27 | COPY --from=clearlinux/os-core:latest / /os_core_install/
28 | RUN cd / && \
29 | find os_core_install | sed -e 's/os_core_install/install_root/' | xargs rm -d &> /dev/null || true
30 |
31 |
32 | FROM clearlinux/os-core:latest
33 |
34 | COPY --from=builder /install_root /
35 |
36 | #set work directory early so remaining paths can be relative
37 | WORKDIR /usr/src/app
38 |
39 | # Adding requirements file to current directory
40 | # just this file first to cache the pip install step when code changes
41 | COPY requirements.txt .
42 |
43 | # set environment variables
44 | ENV PYTHONDONTWRITEBYTECODE 1
45 | ENV PYTHONUNBUFFERED 1
46 | ENV PIP_DISABLE_PIP_VERSION_CHECK=1
47 | ENV PIP_NO_CACHE_DIR=1
48 |
49 | #install dependencies
50 | RUN pip install cython && pip install -r requirements.txt
51 |
52 | #temp fix for databases - not correct release on PyPi
53 | COPY ./databases ./databases
54 | RUN pip install ./databases && cd ../ && rm -rf /databases
55 |
56 | # expose the port 8000
57 | EXPOSE 8000
58 |
59 | # add app
60 | COPY . .
--------------------------------------------------------------------------------
/backend/alembic/env.py:
--------------------------------------------------------------------------------
1 | from logging.config import fileConfig
2 |
3 | from sqlalchemy import engine_from_config, create_engine
4 | from sqlalchemy import pool
5 |
6 | from alembic import context
7 | from dotenv import load_dotenv
8 |
9 | import os
10 | import sys
11 | load_dotenv()
12 |
13 | sys.path.append(os.getcwd())
14 | from app.db.base import Base # noqa
15 | from app.core.config import SQLALCHEMY_DATABASE_URI
16 |
17 | config = context.config
18 |
19 | fileConfig(config.config_file_name)
20 |
21 | target_metadata = Base.metadata
22 |
23 | def run_migrations_offline():
24 | """Run migrations in 'offline' mode.
25 |
26 | This configures the context with just a URL
27 | and not an Engine, though an Engine is acceptable
28 | here as well. By skipping the Engine creation
29 | we don't even need a DBAPI to be available.
30 |
31 | Calls to context.execute() here emit the given string to the
32 | script output.
33 |
34 | """
35 |
36 | url = SQLALCHEMY_DATABASE_URI
37 | context.configure(
38 | url=url,
39 | target_metadata=target_metadata,
40 | literal_binds=True,
41 | dialect_opts={"paramstyle": "named"},
42 | compare_type=True,
43 | )
44 |
45 | with context.begin_transaction():
46 | context.run_migrations()
47 |
48 |
49 | def run_migrations_online():
50 | """Run migrations in 'online' mode.
51 |
52 | In this scenario we need to create an Engine
53 | and associate a connection with the context.
54 |
55 | """
56 |
57 | url = SQLALCHEMY_DATABASE_URI
58 | if url:
59 | connectable = create_engine(url)
60 | else:
61 | connectable = engine_from_config(
62 | config.get_section(config.config_ini_section),
63 | prefix="sqlalchemy.",
64 | poolclass=pool.NullPool,
65 | )
66 |
67 | with connectable.connect() as connection:
68 | context.configure(
69 | connection=connection, target_metadata=target_metadata
70 | )
71 |
72 | with context.begin_transaction():
73 | context.run_migrations()
74 |
75 |
76 | if context.is_offline_mode():
77 | run_migrations_offline()
78 | else:
79 | run_migrations_online()
80 |
--------------------------------------------------------------------------------
/backend/app/core/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from dotenv import load_dotenv
3 |
4 | load_dotenv()
5 |
6 |
7 | def getenv_boolean(var_name, default_value=False):
8 | result = default_value
9 | env_value = os.getenv(var_name)
10 | if env_value is not None:
11 | result = env_value.upper() in ("TRUE", "1")
12 | return result
13 |
14 |
15 | API_V1_STR = "/api/v1"
16 |
17 | SECRET_KEY = os.getenv("SECRET_KEY")
18 | if not SECRET_KEY:
19 | SECRET_KEY = os.urandom(32) # type: ignore
20 |
21 | ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days
22 |
23 | SERVER_NAME = os.getenv("SERVER_NAME")
24 | SERVER_HOST = os.getenv("SERVER_HOST")
25 | BACKEND_CORS_ORIGINS = os.getenv(
26 | "BACKEND_CORS_ORIGINS"
27 | ) # a string of origins separated by commas, e.g: "http://localhost, http://localhost:4200, http://localhost:3000,
28 | # http://localhost:8080, http://local.dockertoolbox.tiangolo.com"
29 | PROJECT_NAME = os.getenv("PROJECT_NAME")
30 | SENTRY_DSN = os.getenv("SENTRY_DSN")
31 |
32 | POSTGRES_SERVER = os.getenv("POSTGRES_SERVER")
33 | POSTGRES_USER = os.getenv("POSTGRES_USER")
34 | POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
35 | POSTGRES_DB = os.getenv("POSTGRES_DB")
36 | SQLALCHEMY_DATABASE_URI = (
37 | f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}/{POSTGRES_DB}"
38 | )
39 |
40 | SMTP_TLS = getenv_boolean("SMTP_TLS", True)
41 | SMTP_PORT = None
42 | _SMTP_PORT = os.getenv("SMTP_PORT")
43 | if _SMTP_PORT is not None:
44 | SMTP_PORT = int(_SMTP_PORT)
45 | SMTP_HOST = os.getenv("SMTP_HOST")
46 | SMTP_USER = os.getenv("SMTP_USER")
47 | SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
48 | EMAILS_FROM_EMAIL = os.getenv("EMAILS_FROM_EMAIL")
49 | EMAILS_FROM_NAME = PROJECT_NAME
50 | EMAIL_RESET_TOKEN_EXPIRE_HOURS = 48
51 | EMAIL_TEMPLATES_DIR = "/app/email-templates/build"
52 | EMAILS_ENABLED = 0
53 | # SMTP_HOST and SMTP_PORT and EMAILS_FROM_EMAIL
54 |
55 | FIRST_SUPERUSER = os.getenv("FIRST_SUPERUSER")
56 | FIRST_SUPERUSER_PASSWORD = os.getenv("FIRST_SUPERUSER_PASSWORD")
57 | FIRST_SUPERUSER_FULLNAME = os.getenv("FIRST_SUPERUSER_FULLNAME")
58 |
59 | USERS_OPEN_REGISTRATION = getenv_boolean("USERS_OPEN_REGISTRATION")
60 |
61 | EMAIL_TEST_USER = "test@example.com"
62 |
--------------------------------------------------------------------------------
/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 files
8 | file_template = %%(year)d-%%(month).2d-%%(day).2d-%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
9 |
10 | # timezone to use when rendering the date
11 | # within the migration file as well as the filename.
12 | # string value is passed to dateutil.tz.gettz()
13 | # leave blank for localtime
14 | timezone = UTC
15 |
16 | # max length of characters to apply to the
17 | # "slug" field
18 | # truncate_slug_length = 40
19 |
20 | # set to 'true' to run the environment during
21 | # the 'revision' command, regardless of autogenerate
22 | # revision_environment = false
23 |
24 | # set to 'true' to allow .pyc and .pyo files without
25 | # a source .py file to be detected as revisions in the
26 | # versions/ directory
27 | # sourceless = false
28 |
29 | # version location specification; this defaults
30 | # to alembic/versions. When using multiple version
31 | # directories, initial revisions must be specified with --version-path
32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions
33 |
34 | # the output encoding used when revision files
35 | # are written from script.py.mako
36 | # output_encoding = utf-8
37 |
38 | [post_write_hooks]
39 | # post_write_hooks defines scripts or Python functions that are run
40 | # on newly generated revision scripts. See the documentation for further
41 | # detail and examples
42 |
43 | # format using "black" - use the console_scripts runner, against the "black" entrypoint
44 | # hooks=black
45 | # black.type=console_scripts
46 | # black.entrypoint=black
47 | # black.options=-l 79
48 |
49 | # Logging configuration
50 | [loggers]
51 | keys = root,sqlalchemy,alembic
52 |
53 | [handlers]
54 | keys = console
55 |
56 | [formatters]
57 | keys = generic
58 |
59 | [logger_root]
60 | level = WARN
61 | handlers = console
62 | qualname =
63 |
64 | [logger_sqlalchemy]
65 | level = WARN
66 | handlers =
67 | qualname = sqlalchemy.engine
68 |
69 | [logger_alembic]
70 | level = INFO
71 | handlers =
72 | qualname = alembic
73 |
74 | [handler_console]
75 | class = StreamHandler
76 | args = (sys.stderr,)
77 | level = NOTSET
78 | formatter = generic
79 |
80 | [formatter_generic]
81 | format = %(levelname)-5.5s [%(name)s] %(message)s
82 | datefmt = %H:%M:%S
83 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### Python template
3 | # Byte-compiled / optimized / DLL files
4 | __pycache__/
5 | *.py[cod]
6 | *$py.class
7 |
8 | # C extensions
9 | *.so
10 |
11 | # Distribution / packaging
12 | .Python
13 | # build/
14 | develop-eggs/
15 | dist/
16 | downloads/
17 | eggs/
18 | .eggs/
19 | lib/
20 | lib64/
21 | parts/
22 | sdist/
23 | var/
24 | wheels/
25 | pip-wheel-metadata/
26 | share/python-wheels/
27 | *.egg-info/
28 | .installed.cfg
29 | *.egg
30 | MANIFEST
31 |
32 | # PyInstaller
33 | # Usually these files are written by a python script from a template
34 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
35 | *.manifest
36 | *.spec
37 |
38 | # Installer logs
39 | pip-log.txt
40 | pip-delete-this-directory.txt
41 |
42 | # Unit test / coverage reports
43 | htmlcov/
44 | .tox/
45 | .nox/
46 | .coverage
47 | .coverage.*
48 | .cache
49 | nosetests.xml
50 | coverage.xml
51 | *.cover
52 | *.py,cover
53 | .hypothesis/
54 | .pytest_cache/
55 |
56 | # Translations
57 | *.mo
58 | *.pot
59 |
60 | # Django stuff:
61 | *.log
62 | local_settings.py
63 | db.sqlite3
64 | db.sqlite3-journal
65 |
66 | # Flask stuff:
67 | instance/
68 | .webassets-cache
69 |
70 | # Scrapy stuff:
71 | .scrapy
72 |
73 | # Sphinx documentation
74 | docs/_build/
75 |
76 | # PyBuilder
77 | target/
78 |
79 | # Jupyter Notebook
80 | .ipynb_checkpoints
81 |
82 | # IPython
83 | profile_default/
84 | ipython_config.py
85 |
86 | # pyenv
87 | .python-version
88 |
89 | # pipenv
90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
93 | # install all needed dependencies.
94 | #Pipfile.lock
95 |
96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
97 | __pypackages__/
98 |
99 | # Celery stuff
100 | celerybeat-schedule
101 | celerybeat.pid
102 |
103 | # SageMath parsed files
104 | *.sage.py
105 |
106 | # Environments
107 | .env
108 | .venv
109 | env/
110 | venv/
111 | ENV/
112 | env.bak/
113 | venv.bak/
114 |
115 | # Spyder project settings
116 | .spyderproject
117 | .spyproject
118 |
119 | # Rope project settings
120 | .ropeproject
121 |
122 | # mkdocs documentation
123 | /site
124 |
125 | # mypy
126 | .mypy_cache/
127 | .dmypy.json
128 | dmypy.json
129 |
130 | # Pyre type checker
131 | .pyre/
132 |
133 | # Pycharm
134 | .idea
135 |
136 | .vscode
137 | # other
138 | /backend/databases/
--------------------------------------------------------------------------------
/backend/app/crud/base.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from typing import List, Optional, Generic, TypeVar, Type
3 |
4 | from fastapi.encoders import jsonable_encoder
5 | from pydantic import BaseModel
6 | from sqlalchemy.orm import Session
7 |
8 | from app.models.user import User as DBUser
9 | from app.db.base_class import Base
10 |
11 | ModelType = TypeVar("ModelType", bound=Base)
12 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
13 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
14 |
15 |
16 | class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
17 | def __init__(self, model: Type[ModelType]):
18 | """
19 | CRUD object with default methods to Create, Read, Update, Delete (CRUD).
20 |
21 | **Parameters**
22 |
23 | * `model`: A SQLAlchemy model class
24 | * `schema`: A Pydantic model (schema) class
25 | """
26 | self.model = model
27 |
28 | def get(self, db_session: Session, id: int) -> Optional[ModelType]:
29 | return db_session.query(self.model).filter(self.model.id == id).first() # type: ignore
30 |
31 | def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[ModelType]:
32 | return db_session.query(self.model).offset(skip).limit(limit).all()
33 |
34 | def create(self, db_session: Session, *, obj_in: CreateSchemaType) -> ModelType:
35 | obj_in_data = jsonable_encoder(obj_in)
36 | db_obj = self.model(**obj_in_data) # type: ignore
37 | db_session.add(db_obj)
38 | db_session.commit()
39 | db_session.refresh(db_obj)
40 | return db_obj
41 |
42 | def update(self, db_session: Session, *, db_obj: ModelType, obj_in: UpdateSchemaType) -> ModelType:
43 | obj_data = jsonable_encoder(db_obj)
44 | update_data = obj_in.dict(skip_defaults=True)
45 | for field in obj_data:
46 | if field in update_data:
47 | setattr(db_obj, field, update_data[field])
48 | db_session.add(db_obj)
49 | db_session.commit()
50 | db_session.refresh(db_obj)
51 | return db_obj
52 |
53 | def update_with_user(
54 | self, db_session: Session, *, db_obj: ModelType, obj_in: UpdateSchemaType, uid: DBUser
55 | ) -> ModelType:
56 | obj_data = jsonable_encoder(db_obj)
57 | update_data = obj_in.dict(skip_defaults=True)
58 | update_data['changed_by'] = uid.id
59 | for field in obj_data:
60 | if field in update_data:
61 | setattr(db_obj, field, update_data[field])
62 | db_session.add(db_obj)
63 | db_session.commit()
64 | db_session.refresh(db_obj)
65 | return db_obj
66 |
67 | def remove(self, db_session: Session, *, id: int) -> ModelType:
68 | obj = db_session.query(self.model).get(id)
69 | db_session.delete(obj)
70 | db_session.commit()
71 | return obj
72 |
--------------------------------------------------------------------------------
/backend/app/api/api_v1/endpoints/notes.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from fastapi import APIRouter, Depends, HTTPException, Path, Response
4 |
5 | from app.api.utils.security import get_current_active_user # type: ignore
6 | from app import crud
7 | from app.crud import crud_notes
8 | from app.models.user import User # as User
9 | from app.schemas.note import NoteDB, NoteSchema, NoteUser
10 |
11 | router = APIRouter()
12 | ERROR_NOT_FOUND = "Note not found"
13 | ERROR_PERMISSIONS = "Not enough permissions"
14 |
15 |
16 | @router.post("/", response_model=NoteUser, status_code=201)
17 | async def create_note(
18 | payload: NoteSchema,
19 | current_user: User = Depends(get_current_active_user)
20 | ):
21 | """
22 | Create a new note.
23 | :param payload:
24 | :param current_user:
25 | :return:
26 | """
27 | return await crud_notes.post(payload, owner_id=current_user.id)
28 |
29 |
30 | @router.get("/{id}/", response_model=NoteUser)
31 | async def read_note(
32 | id: int = Path(..., gt=0),
33 | current_user: User = Depends(get_current_active_user)
34 | ):
35 | """
36 | Get a note by ID
37 | :param id:
38 | :param current_user:
39 | :return:
40 | """
41 | owner = await crud_notes.get_owner(id)
42 | print("owner is: ", owner) # TODO: Remove this print()
43 | if not owner:
44 | raise HTTPException(status_code=404, detail=ERROR_NOT_FOUND)
45 | if not crud.user.is_superuser(current_user) and (owner != current_user.id):
46 | raise HTTPException(status_code=403, detail=ERROR_PERMISSIONS)
47 | return await crud_notes.get(id)
48 |
49 |
50 | @router.get("/", response_model=List[NoteUser])
51 | async def read_all_notes(
52 | skip: int = 0,
53 | limit: int = 100,
54 | current_user: User = Depends(get_current_active_user),
55 | ):
56 | """
57 | Retrieve notes.
58 | :param skip:
59 | :param limit:
60 | :param current_user:
61 | :return:
62 | """
63 | if crud.user.is_superuser(current_user):
64 | notes = await crud_notes.get_all(skip=skip, limit=limit)
65 | else:
66 | notes = await crud_notes.get_all_by_owner(owner_id=current_user.id, skip=skip, limit=limit)
67 | return notes
68 |
69 |
70 | @router.put("/{id}/", response_model=NoteUser)
71 | async def update_note(
72 | payload: NoteSchema,
73 | id: int = Path(..., gt=0),
74 | current_user: User = Depends(get_current_active_user)
75 | ):
76 | """
77 | Update a note.
78 | :param payload:
79 | :param id:
80 | :param current_user:
81 | :return:
82 | """
83 | owner = await crud_notes.get_owner(id)
84 | if not owner:
85 | raise HTTPException(status_code=404, detail=ERROR_NOT_FOUND)
86 | if not crud.user.is_superuser(current_user) and (owner != current_user.id):
87 | raise HTTPException(status_code=403, detail=ERROR_PERMISSIONS)
88 | note = await crud_notes.put(id, payload, current_user)
89 | return note
90 |
91 |
92 | @router.delete("/{id}/", response_class=Response, status_code=204)
93 | async def delete_note(
94 | id: int = Path(..., gt=0),
95 | current_user: User = Depends(get_current_active_user)
96 | ):
97 | """
98 | Delete a note.
99 | :param id:
100 | :param current_user:
101 | :return:
102 | """
103 | owner = await crud_notes.get_owner(id)
104 | if not owner:
105 | raise HTTPException(status_code=404, detail=ERROR_NOT_FOUND)
106 | if not crud.user.is_superuser(current_user) and (owner != current_user.id):
107 | raise HTTPException(status_code=403, detail=ERROR_PERMISSIONS)
108 | return await crud_notes.delete(id)
109 |
--------------------------------------------------------------------------------
/backend/app/email-templates/build/test_email.html:
--------------------------------------------------------------------------------
1 |
{{ project_name }}
Test email for: {{ email }}
--------------------------------------------------------------------------------
/backend/app/api/api_v1/endpoints/login.py:
--------------------------------------------------------------------------------
1 | # type: ignore
2 |
3 | from datetime import timedelta
4 |
5 | from fastapi import APIRouter, Body, Depends, HTTPException
6 | from fastapi.security import OAuth2PasswordRequestForm
7 | from sqlalchemy.orm import Session
8 |
9 | from app import crud
10 | from app.api.utils.db import get_db
11 | from app.api.utils.security import get_current_user
12 | from app.core import config
13 | from app.core.jwt import create_access_token
14 | from app.core.security import get_password_hash
15 | from app.models.user import User as DBUser
16 | from app.schemas.msg import Msg
17 | from app.schemas.token import Token
18 | from app.schemas.user import User
19 | from app.utils import (
20 | generate_password_reset_token,
21 | send_reset_password_email,
22 | verify_password_reset_token,
23 | )
24 |
25 | router = APIRouter()
26 |
27 |
28 | @router.post("/login/access-token", response_model=Token, tags=["login"])
29 | def login_access_token(
30 | db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
31 | ):
32 | """
33 | OAuth2 compatible token login, get an access token for future requests
34 | """
35 | user = crud.user.authenticate(
36 | db, email=form_data.username, password=form_data.password
37 | )
38 | if not user:
39 | raise HTTPException(status_code=400, detail="Incorrect email or password")
40 | elif not crud.user.is_active(user):
41 | raise HTTPException(status_code=400, detail="Inactive user")
42 | access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
43 | return {
44 | "access_token": create_access_token(
45 | data={"user_id": user.id}, expires_delta=access_token_expires
46 | ),
47 | "token_type": "bearer",
48 | }
49 |
50 |
51 | @router.post("/login/test-token", tags=["login"], response_model=User)
52 | def test_token(current_user: DBUser = Depends(get_current_user)):
53 | """
54 | Test access token
55 | """
56 | return current_user
57 |
58 |
59 | @router.post("/password-recovery/{email}", tags=["login"], response_model=Msg)
60 | def recover_password(email: str, db: Session = Depends(get_db)):
61 | """
62 | Password Recovery
63 | """
64 | user = crud.user.get_by_email(db, email=email)
65 |
66 | if not user:
67 | raise HTTPException(
68 | status_code=404,
69 | detail="The user with this username does not exist in the system.",
70 | )
71 | password_reset_token = generate_password_reset_token(email=email)
72 | send_reset_password_email(
73 | email_to=user.email, email=email, token=password_reset_token
74 | )
75 | return {"msg": "Password recovery email sent"}
76 |
77 |
78 | @router.post("/reset-password/", tags=["login"], response_model=Msg)
79 | def reset_password(
80 | token: str = Body(...), new_password: str = Body(...), db: Session = Depends(get_db)
81 | ):
82 | """
83 | Reset password
84 | """
85 | email = verify_password_reset_token(token)
86 | if not email:
87 | raise HTTPException(status_code=400, detail="Invalid token")
88 | user = crud.user.get_by_email(db, email=email)
89 | if not user:
90 | raise HTTPException(
91 | status_code=404,
92 | detail="The user with this username does not exist in the system.",
93 | )
94 | elif not crud.user.is_active(user):
95 | raise HTTPException(status_code=400, detail="Inactive user")
96 | hashed_password = get_password_hash(new_password)
97 | user.hashed_password = hashed_password
98 | db.add(user)
99 | db.commit()
100 | return {"msg": "Password updated successfully"}
101 |
--------------------------------------------------------------------------------
/backend/app/utils.py:
--------------------------------------------------------------------------------
1 | # type: ignore
2 |
3 | import logging
4 | from datetime import datetime, timedelta
5 | from pathlib import Path
6 | from typing import Optional
7 |
8 | import emails
9 | import jwt
10 | from app.core import config
11 | from emails.template import JinjaTemplate
12 | from jwt.exceptions import InvalidTokenError
13 |
14 | password_reset_jwt_subject = "preset"
15 |
16 |
17 | def send_email(email_to: str, subject_template="", html_template="", environment={}):
18 | assert config.EMAILS_ENABLED, "no provided configuration for email variables"
19 | message = emails.Message(
20 | subject=JinjaTemplate(subject_template),
21 | html=JinjaTemplate(html_template),
22 | mail_from=(config.EMAILS_FROM_NAME, config.EMAILS_FROM_EMAIL),
23 | )
24 | smtp_options = {"host": config.SMTP_HOST, "port": config.SMTP_PORT}
25 | if config.SMTP_TLS:
26 | smtp_options["tls"] = True
27 | if config.SMTP_USER:
28 | smtp_options["user"] = config.SMTP_USER
29 | if config.SMTP_PASSWORD:
30 | smtp_options["password"] = config.SMTP_PASSWORD
31 | response = message.send(to=email_to, render=environment, smtp=smtp_options)
32 | logging.info(f"send email result: {response}")
33 |
34 |
35 | def send_test_email(email_to: str):
36 | project_name = config.PROJECT_NAME
37 | subject = f"{project_name} - Test email"
38 | with open(Path(config.EMAIL_TEMPLATES_DIR) / "test_email.html") as f:
39 | template_str = f.read()
40 | send_email(
41 | email_to=email_to,
42 | subject_template=subject,
43 | html_template=template_str,
44 | environment={"project_name": config.PROJECT_NAME, "email": email_to},
45 | )
46 |
47 |
48 | def send_reset_password_email(email_to: str, email: str, token: str):
49 | project_name = config.PROJECT_NAME
50 | subject = f"{project_name} - Password recovery for user {email}"
51 | with open(Path(config.EMAIL_TEMPLATES_DIR) / "reset_password.html") as f:
52 | template_str = f.read()
53 | if hasattr(token, "decode"):
54 | use_token = token.decode()
55 | else:
56 | use_token = token
57 | server_host = config.SERVER_HOST
58 | link = f"{server_host}/reset-password?token={use_token}"
59 | send_email(
60 | email_to=email_to,
61 | subject_template=subject,
62 | html_template=template_str,
63 | environment={
64 | "project_name": config.PROJECT_NAME,
65 | "username": email,
66 | "email": email_to,
67 | "valid_hours": config.EMAIL_RESET_TOKEN_EXPIRE_HOURS,
68 | "link": link,
69 | },
70 | )
71 |
72 |
73 | def send_new_account_email(email_to: str, username: str, password: str):
74 | project_name = config.PROJECT_NAME
75 | subject = f"{project_name} - New account for user {username}"
76 | with open(Path(config.EMAIL_TEMPLATES_DIR) / "new_account.html") as f:
77 | template_str = f.read()
78 | link = config.SERVER_HOST
79 | send_email(
80 | email_to=email_to,
81 | subject_template=subject,
82 | html_template=template_str,
83 | environment={
84 | "project_name": config.PROJECT_NAME,
85 | "username": username,
86 | "password": password,
87 | "email": email_to,
88 | "link": link,
89 | },
90 | )
91 |
92 |
93 | def generate_password_reset_token(email):
94 | delta = timedelta(hours=config.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
95 | now = datetime.utcnow()
96 | expires = now + delta
97 | exp = expires.timestamp()
98 | encoded_jwt = jwt.encode(
99 | {"exp": exp, "nbf": now, "sub": password_reset_jwt_subject, "email": email},
100 | config.SECRET_KEY,
101 | algorithm="HS256",
102 | )
103 | return encoded_jwt
104 |
105 |
106 | def verify_password_reset_token(token) -> Optional[str]:
107 | try:
108 | decoded_token = jwt.decode(token, config.SECRET_KEY, algorithms=["HS256"])
109 | assert decoded_token["sub"] == password_reset_jwt_subject
110 | return decoded_token["email"]
111 | except InvalidTokenError:
112 | return None
113 |
--------------------------------------------------------------------------------
/backend/alembic/versions/2020-05-09-195458_e435422117c3_note.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | Revision ID: e435422117c3
4 | Revises: 3baeebd703b9
5 | Create Date: 2020-05-09 19:54:58.438807+00:00
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'e435422117c3'
14 | down_revision = '3baeebd703b9'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table('note',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('title', sa.String(length=50), nullable=True),
24 | sa.Column('description', sa.String(length=50), nullable=True),
25 | sa.Column('created_date', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
26 | sa.Column('owner_id', sa.Integer(), nullable=True),
27 | sa.Column('changed_date', sa.DateTime(), nullable=True),
28 | sa.Column('changed_by', sa.Integer(), nullable=True),
29 | sa.ForeignKeyConstraint(['changed_by'], ['user.id'], ),
30 | sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
31 | sa.PrimaryKeyConstraint('id')
32 | )
33 | op.create_index(op.f('ix_note_description'), 'note', ['description'], unique=False)
34 | op.create_index(op.f('ix_note_id'), 'note', ['id'], unique=False)
35 | op.create_index(op.f('ix_note_title'), 'note', ['title'], unique=False)
36 | op.execute(
37 | """
38 | CREATE PROCEDURE insert_note(in n_title varchar(50), in n_desc varchar(50), in n_owner integer, inout id integer)
39 | LANGUAGE SQL
40 | AS $$
41 | INSERT INTO note(title, description, owner_id) VALUES (n_title, n_desc, n_owner) RETURNING id
42 | $$;
43 | """
44 | )
45 | op.execute(
46 | """
47 | CREATE PROCEDURE delete_note(in _id integer)
48 | LANGUAGE SQL
49 | AS $$
50 | DELETE FROM note where id = _id
51 | $$;
52 | """
53 | )
54 | op.execute(
55 | """
56 | create or replace function get_by_id(note_id integer)
57 | returns TABLE(id integer, title character varying, description character varying, created_date timestamp without time zone, changed_date timestamp without time zone, owner character varying)
58 | language sql
59 | AS
60 | $$
61 | SELECT n.id, n.title, n.description, n.created_date, n.changed_date, "user".full_name
62 | FROM note n JOIN "user" ON "user".id = n.owner_id WHERE n.id = note_id ORDER BY n.id;
63 | $$;
64 | """
65 | )
66 | op.execute(
67 | """
68 | create or replace function get_notes_by_owner(ownerid int)
69 | returns TABLE (
70 | id int,
71 | title varchar(50),
72 | description varchar(50),
73 | created_date timestamp,
74 | changed_date timestamp,
75 | owner varchar
76 | )
77 | language sql as $$
78 | SELECT n.id, n.title, n.description, n.created_date, n.changed_date, "user".full_name
79 | FROM note n JOIN "user" ON "user".id = n.owner_id where owner_id = ownerid ORDER BY n.id;
80 | $$;
81 | """
82 | )
83 | op.execute(
84 | """create function get_notes_with_user()
85 | returns TABLE(id integer, title character varying, description character varying, created_date timestamp without time zone, changed_date timestamp without time zone, owner character varying)
86 | language sql
87 | as
88 | $$
89 | SELECT n.id, n.title, n.description, n.created_date, n.changed_date, "user".full_name
90 | FROM note n JOIN "user" ON "user".id = n.owner_id ORDER BY n.id;
91 | $$;
92 | """
93 | )
94 | op.execute(
95 | """create or replace function get_owner(in note_id integer, out integer)
96 | language sql
97 | as
98 | $$
99 | SELECT owner_id FROM note WHERE id = note_id;
100 | $$;
101 | """
102 | )
103 | op.execute(
104 | """
105 | CREATE PROCEDURE update_note(in n_title varchar(50), in n_desc varchar(50), in n_changed_by integer,
106 | in _id integer, inout id integer)
107 | LANGUAGE SQL
108 | AS $$
109 | UPDATE note SET title = n_title, description = n_desc, changed_by = n_changed_by, changed_date = now()
110 | WHERE id = _id RETURNING id
111 | $$;
112 | """
113 | )
114 | # ### end Alembic commands ###
115 |
116 |
117 | def downgrade():
118 | # ### commands auto generated by Alembic - please adjust! ###
119 | op.drop_index(op.f('ix_note_title'), table_name='note')
120 | op.drop_index(op.f('ix_note_id'), table_name='note')
121 | op.drop_index(op.f('ix_note_description'), table_name='note')
122 | op.drop_table('note')
123 | # ### end Alembic commands ###
124 |
--------------------------------------------------------------------------------
/backend/app/api/api_v1/endpoints/users.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from fastapi import APIRouter, Body, Depends, HTTPException
4 | from fastapi.encoders import jsonable_encoder
5 | from pydantic.networks import EmailStr
6 | from sqlalchemy.orm import Session
7 |
8 | from app import crud
9 | from app.api.utils.db import get_db
10 | from app.api.utils.security import get_current_active_superuser, get_current_active_user # type: ignore
11 | from app.core import config
12 | from app.models.user import User as DBUser
13 | from app.schemas.user import User, UserCreate, UserUpdate
14 | from app.utils import send_new_account_email # type: ignore
15 | from app.db.session import database
16 |
17 | router = APIRouter()
18 |
19 |
20 | @router.get("/", response_model=List[User])
21 | async def read_users(
22 | db: Session = Depends(get_db),
23 | skip: int = 0,
24 | limit: int = 100,
25 | current_user: DBUser = Depends(get_current_active_superuser),
26 | ):
27 | """
28 | Retrieve users.
29 | """
30 | users = crud.user.get_multi(db, skip=skip, limit=limit)
31 | return users
32 |
33 |
34 | @router.post("/", response_model=User)
35 | def create_user(
36 | *,
37 | db: Session = Depends(get_db),
38 | user_in: UserCreate,
39 | current_user: DBUser = Depends(get_current_active_superuser),
40 | ):
41 | """
42 | Create new user.
43 | """
44 | user = crud.user.get_by_email(db, email=user_in.email)
45 | if user:
46 | raise HTTPException(
47 | status_code=400,
48 | detail="The user with this username already exists in the system.",
49 | )
50 | user = crud.user.create(db, obj_in=user_in)
51 | if config.EMAILS_ENABLED and user_in.email:
52 | send_new_account_email(
53 | email_to=user_in.email, username=user_in.email, password=user_in.password
54 | )
55 | return user
56 |
57 |
58 | @router.put("/me", response_model=User)
59 | def update_user_me(
60 | *,
61 | db: Session = Depends(get_db),
62 | password: str = Body(None),
63 | full_name: str = Body(None),
64 | email: EmailStr = Body(None),
65 | current_user: DBUser = Depends(get_current_active_user),
66 | ):
67 | """
68 | Update own user.
69 | """
70 | current_user_data = jsonable_encoder(current_user)
71 | user_in = UserUpdate(**current_user_data)
72 | if password is not None:
73 | user_in.password = password
74 | if full_name is not None:
75 | user_in.full_name = full_name
76 | if email is not None:
77 | user_in.email = email
78 | user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
79 | return user
80 |
81 |
82 | @router.get("/me", response_model=User)
83 | def read_user_me(
84 | db: Session = Depends(get_db),
85 | current_user: DBUser = Depends(get_current_active_user),
86 | ):
87 | """
88 | Get current user.
89 | """
90 | return current_user
91 |
92 |
93 | @router.post("/open", response_model=User)
94 | def create_user_open(
95 | *,
96 | db: Session = Depends(get_db),
97 | password: str = Body(...),
98 | email: EmailStr = Body(...),
99 | full_name: str = Body(None),
100 | ):
101 | """
102 | Create new user without the need to be logged in.
103 | """
104 | if not config.USERS_OPEN_REGISTRATION:
105 | raise HTTPException(
106 | status_code=403,
107 | detail="Open user registration is forbidden on this server",
108 | )
109 | user = crud.user.get_by_email(db, email=email)
110 | if user:
111 | raise HTTPException(
112 | status_code=400,
113 | detail="The user with this username already exists in the system",
114 | )
115 | user_in = UserCreate(password=password, email=email, full_name=full_name)
116 | user = crud.user.create(db, obj_in=user_in)
117 | return user
118 |
119 |
120 | @router.get("/{user_id}", response_model=User)
121 | def read_user_by_id(
122 | user_id: int,
123 | current_user: DBUser = Depends(get_current_active_user),
124 | db: Session = Depends(get_db),
125 | ):
126 | """
127 | Get a specific user by id.
128 | """
129 | user = crud.user.get(db, id=user_id)
130 | if user == current_user:
131 | return user
132 | if not crud.user.is_superuser(current_user):
133 | raise HTTPException(
134 | status_code=400, detail="The user doesn't have enough privileges"
135 | )
136 | return user
137 |
138 |
139 | @router.put("/{user_id}", response_model=User)
140 | def update_user(
141 | *,
142 | db: Session = Depends(get_db),
143 | user_id: int,
144 | user_in: UserUpdate,
145 | current_user: DBUser = Depends(get_current_active_superuser),
146 | ):
147 | """
148 | Update a user.
149 | """
150 | user = crud.user.get(db, id=user_id)
151 | if not user:
152 | raise HTTPException(
153 | status_code=404,
154 | detail="The user with this username does not exist in the system",
155 | )
156 | user = crud.user.update(db, db_obj=user, obj_in=user_in)
157 | return user
158 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FastAPI CRUD using Async, Alembic & Postgresql
2 |
3 |
4 |
5 |
6 |
7 | > Example Async CRUD API using FastAPI
8 |
9 | This repository is the result of wading through how to use async CRUD operations
10 | using the backend core from the excellent generator template by Sebastián Ramírez,
11 | [Full Stack FastAPI and PostgreSQL - Base Project Generator][tiangolo/fullstack].
12 |
13 | In reviewing [the FastAPI docs][fastapidocs], the [encode/databases docs][databases-queries]
14 | on queries, and the gitter channels, there seemed to be only the basic
15 | information on using databases with SQL Alchemy and data table
16 | definitions that would not work well with Alembic autogenerate - as least in
17 | my case using the generator template. This repository is the working result
18 | of my tests and learning to use FastAPU with async, asycnpg, databases, and Alembic
19 | autogenerated migrations with declarative base model. Hopefully it will be useful
20 | to others looking for some examples of using databases, asyncpg and Alembic with FastAPI.
21 |
22 | ## Updates
23 | **Version 0.3.0** Dockerfiles & Docker-Compose ready containing two services, PSQL & APP.
24 | * **PSQL** - Postgresql 12-alpine image.
25 | * **APP** - ClearLinux/Python3.8 image with custom build.
26 | * **Auto Migrations** - Migrations including creation of stored procedures in APP image.
27 | * **.env Sample** - Change to .env and provide desired values.
28 |
29 | ## Details
30 |
31 | * **Uses version 0.4.0 of the [Fullstack generator][tiangolo/fullstack-v4]**:
32 | before current changes in structure of the project were made.
33 | * **[encode/databases][databases-queries]**: To simplify managing connections, pooling, etc.
34 | * **asyncpg with Posgresql**: Built using a Postgresql 12 database, but can be altered to use SQLite.
35 | * **[Alembic][alembic]**: Using autogenerate to create migrations.
36 | * **Version 0.1.0**: Uses raw SQL in CRUD functions. See tag v0.1.0.
37 | * **Version 0.2.0**: Uses functions (reads) and procedures (write). ***Procedures require Posgresql Version 11 or higher.***
38 | * **Version 0.3.0**: Uses Docker-Compose to spin up services and run migrations.
39 |
40 | ### Critical Notes
41 | * PyCharm (or other IDE) may complain about the PSQL Procedures using "CALL" - I just injected SQL lanuage on the query text to eliminate the errors.
42 | * Some CRUD functions utilize `databases.fetch_val()` and require using the latest master branch of databases from the repo. The version on PyPi is not correct, and is missing the April 30 commit with the required changes to use this. See [this commit](https://github.com/encode/databases/commit/25e65edc369f6f016fab9e4156bdbf628a107fa7).
43 | * Clone the [encode/databases] repo into the "backend" folder before running docker-compose up --build.
44 | * If not patching or using the current repo from PyPi (says 0.3.2 but really is <=0.3.1)- `databases.fetch_one()` needs to be used or the get by ID and create routes will fail.
45 |
46 | ## Installation Instructions
47 | * Clone the repo.
48 | * Clone the [encode/databases] repo into the "backend" folder.
49 | * Change the sample.env file to .env and enter your preferred values.
50 | * Run Docker Compose (with -d for detached, or omit to run in foreground):
51 | ```
52 | $ docker-compose up --build
53 | ```
54 | * In the browser, go to localhost:8000/docs
55 | * Login using the super user email and password supplied in the .env file.
56 | * Test routes.
57 | * When done:
58 | ```
59 | $ docker-compose down
60 | ```
61 | You can rerun the docker-compose up and the datbase data will be persistent.
62 |
63 | You can also remove the named volume where the database data resides by adding the -v flag:
64 | ```
65 | $ docker-compoise down -v
66 | ```
67 |
68 | ## To Do
69 |
70 | Tests, deployment via docker, and other changes to come, time permitting.
71 | Want to see these sooner - see below in [Contributing][learnfast-contrib].
72 |
73 | ## Contributing
74 |
75 | Contributions, issues and feature requests are welcome! Feel free to check [issues page](https://github.com/wedwardbeck/lrnfast/issues).
76 | 1. Fork the Project
77 | 2. Create your Feature Branch (`git checkout -b feature/YourFeature`)
78 | 3. Commit your Changes (`git commit -m 'Add My Feature'`)
79 | 4. Push to the Branch (`git push origin feature/YourFeature`)
80 | 5. Open a Pull Request
81 |
82 | ## Contact
83 |
84 | #### Edward Beck
85 |
86 | - Twitter: [@wedwardbeck](https://twitter.com/wedwardbeck)
87 | - Github: [@wedwardbeck](https://github.com/wedwardbeck)
88 |
89 | ## License
90 |
91 | This project is licensed under the terms of the MIT license.
92 |
93 | ---
94 | [tiangolo/fullstack]: https://github.com/tiangolo/full-stack-fastapi-postgresql
95 | [tiangolo/fullstack-v4]: https://github.com/tiangolo/full-stack-fastapi-postgresql/tree/0.4.0
96 | [fastapidocs]: https://fastapi.tiangolo.com/
97 | [databases-queries]: https://www.encode.io/databases/database_queries/#queries
98 | [databases]: https://www.encode.io/databases/database_queries/#queries
99 | [alembic]: https://github.com/sqlalchemy/alembic
100 | [learnfast]: https://github.com/wedwardbeck/lrnfast
101 | [learnfast-contrib]: https://github.com/wedwardbeck/lrnfast#contributing
102 |
--------------------------------------------------------------------------------
/backend/app/email-templates/build/new_account.html:
--------------------------------------------------------------------------------
1 |