├── 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 | Version 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 |

{{ project_name }} - New Account
You have a new account:
Username: {{ username }}
Password: {{ password }}
Go to Dashboard

-------------------------------------------------------------------------------- /backend/app/email-templates/build/reset_password.html: -------------------------------------------------------------------------------- 1 |

{{ project_name }} - Password Recovery
We received a request to recover the password for user {{ username }} with email {{ email }}
Reset your password by clicking the button below:
Reset Password
Or open the following link:

The reset password link / button will expire in {{ valid_hours }} hours.
If you didn't request a password recovery you can disregard this email.
--------------------------------------------------------------------------------