🎉 First commit, from couchbase generator, basic changes

not tested / updated yet
This commit is contained in:
Sebastián Ramírez
2019-02-09 19:42:36 +04:00
commit 7f8bfc8faa
198 changed files with 21022 additions and 0 deletions
@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints.role import router as roles_router
from app.api.api_v1.endpoints.token import router as token_router
from app.api.api_v1.endpoints.user import router as user_router
from app.api.api_v1.endpoints.utils import router as utils_router
api_router = APIRouter()
api_router.include_router(roles_router)
api_router.include_router(token_router)
api_router.include_router(user_router)
api_router.include_router(utils_router)
@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from starlette.exceptions import HTTPException
from app.core.jwt import get_current_user
from app.crud.user import check_if_user_is_active, check_if_user_is_superuser
from app.crud.utils import ensure_enums_to_strs
from app.models.role import RoleEnum, Roles
from app.models.user import UserInDB
router = APIRouter()
@router.get("/roles/", response_model=Roles)
def route_roles_get(current_user: UserInDB = Depends(get_current_user)):
"""
Retrieve roles
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
elif not (check_if_user_is_superuser(current_user)):
raise HTTPException(
status_code=400, detail="The current user does not have enogh privileges"
)
roles = ensure_enums_to_strs(RoleEnum)
return {"roles": roles}
@@ -0,0 +1,96 @@
from datetime import timedelta
from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm
from starlette.exceptions import HTTPException
from app.core import config
from app.core.jwt import create_access_token, get_current_user
from app.crud.user import (
authenticate_user,
check_if_user_is_active,
check_if_user_is_superuser,
get_user,
update_user,
)
from app.db.database import get_default_bucket
from app.models.msg import Msg
from app.models.token import Token
from app.models.user import User, UserInDB, UserInUpdate
from app.utils import (
generate_password_reset_token,
send_reset_password_email,
verify_password_reset_token,
)
router = APIRouter()
@router.post("/login/access-token", response_model=Token, tags=["login"])
def route_login_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
"""
OAuth2 compatible token login, get an access token for future requests
"""
bucket = get_default_bucket()
user = authenticate_user(bucket, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
elif not check_if_user_is_active(user):
raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
data={"username": form_data.username}, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/login/test-token", tags=["login"], response_model=User)
def route_test_token(current_user: UserInDB = Depends(get_current_user)):
"""
Test access token
"""
return current_user
@router.post("/password-recovery/{username}", tags=["login"], response_model=Msg)
def route_recover_password(username: str):
"""
Password Recovery
"""
bucket = get_default_bucket()
user = get_user(bucket, username)
if not user:
raise HTTPException(
status_code=404,
detail="The user with this username does not exist in the system.",
)
password_reset_token = generate_password_reset_token(username)
send_reset_password_email(
email_to=user.email, username=username, token=password_reset_token
)
return {"msg": "Password recovery email sent"}
@router.post("/reset-password/", tags=["login"], response_model=Msg)
def route_reset_password(token: str, new_password: str):
"""
Reset password
"""
username = verify_password_reset_token(token)
if not username:
raise HTTPException(status_code=400, detail="Invalid token")
bucket = get_default_bucket()
user = get_user(bucket, username)
if not user:
raise HTTPException(
status_code=404,
detail="The user with this username does not exist in the system.",
)
elif not check_if_user_is_active(user):
raise HTTPException(status_code=400, detail="Inactive user")
user_in = UserInUpdate(name=username, password=new_password)
user = update_user(bucket, user_in)
return {"msg": "Password updated successfully"}
@@ -0,0 +1,205 @@
from typing import List
from fastapi import APIRouter, Body, Depends
from pydantic.types import EmailStr
from starlette.exceptions import HTTPException
from app.core import config
from app.core.jwt import get_current_user
from app.crud.user import (
check_if_user_is_active,
check_if_user_is_superuser,
get_user,
get_users,
search_users,
update_user,
upsert_user,
)
from app.db.database import get_default_bucket
from app.models.user import User, UserInCreate, UserInDB, UserInUpdate
from app.utils import send_new_account_email
router = APIRouter()
@router.get("/users/", tags=["users"], response_model=List[User])
def route_users_get(
skip: int = 0, limit: int = 100, current_user: UserInDB = Depends(get_current_user)
):
"""
Retrieve users
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
elif not check_if_user_is_superuser(current_user):
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
bucket = get_default_bucket()
users = get_users(bucket, skip=skip, limit=limit)
return users
@router.get("/users/search/", tags=["users"], response_model=List[User])
def route_search_users(
q: str,
skip: int = 0,
limit: int = 100,
current_user: UserInDB = Depends(get_current_user),
):
"""
Search users, use Bleve Query String syntax: http://blevesearch.com/docs/Query-String-Query/
For typeahead sufix with `*`. For example, a query with: `email:johnd*` will match users with
email `johndoe@example.com`, `johndid@example.net`, etc.
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
elif not check_if_user_is_superuser(current_user):
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
bucket = get_default_bucket()
users = search_users(bucket=bucket, query_string=q, skip=skip, limit=limit)
return users
@router.post("/users/", tags=["users"], response_model=User)
def route_users_post(
*, user_in: UserInCreate, current_user: UserInDB = Depends(get_current_user)
):
"""
Create new user
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
elif not check_if_user_is_superuser(current_user):
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
bucket = get_default_bucket()
user = get_user(bucket, user_in.username)
if user:
raise HTTPException(
status_code=400,
detail="The user with this username already exists in the system.",
)
user = upsert_user(bucket, user_in, persist_to=1)
if config.EMAILS_ENABLED and user_in.email:
send_new_account_email(
email_to=user_in.email, username=user_in.username, password=user_in.password
)
return user
@router.put("/users/me", tags=["users"], response_model=User)
def route_users_me_put(
*,
password: str = None,
full_name: str = None,
email: EmailStr = None,
current_user: UserInDB = Depends(get_current_user),
):
"""
Update own user
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
user_in = UserInUpdate(**current_user.dict())
if password is not None:
user_in.password = password
if full_name is not None:
user_in.full_name = full_name
if email is not None:
user_in.email = email
bucket = get_default_bucket()
user = update_user(bucket, user_in)
return user
@router.get("/users/me", tags=["users"], response_model=User)
def route_users_me_get(current_user: UserInDB = Depends(get_current_user)):
"""
Get current user
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@router.post("/users/open", tags=["users"], response_model=User)
def route_users_post_open(
*,
username: str = Body(...),
password: str = Body(...),
email: EmailStr = Body(None),
full_name: str = Body(None),
):
"""
Create new user without the need to be logged in
"""
if not config.USERS_OPEN_REGISTRATION:
raise HTTPException(
status_code=403,
detail="Open user resgistration is forbidden on this server",
)
bucket = get_default_bucket()
user = get_user(bucket, username)
if user:
raise HTTPException(
status_code=400,
detail="The user with this username already exists in the system",
)
user_in = UserInCreate(
username=username, password=password, email=email, full_name=full_name
)
user = upsert_user(bucket, user_in, persist_to=1)
return user
@router.get("/users/{username}", tags=["users"], response_model=User)
def route_users_id_get(
username: str, current_user: UserInDB = Depends(get_current_user)
):
"""
Get a specific user by username (email)
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
bucket = get_default_bucket()
user = get_user(bucket, username)
if user == current_user:
return user
if not check_if_user_is_superuser(current_user):
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
return user
@router.put("/users/{username}", tags=["users"], response_model=User)
def route_users_put(
*,
username: str,
user_in: UserInUpdate,
current_user: UserInDB = Depends(get_current_user),
):
"""
Update a user
"""
if not check_if_user_is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
elif not check_if_user_is_superuser(current_user):
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
bucket = get_default_bucket()
user = get_user(bucket, username)
if not user:
raise HTTPException(
status_code=404,
detail="The user with this username does not exist in the system",
)
user = update_user(bucket, user_in)
return user
@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends
from pydantic.types import EmailStr
from starlette.exceptions import HTTPException
from app.core.celery_app import celery_app
from app.core.jwt import get_current_user
from app.crud.user import check_if_user_is_superuser
from app.models.msg import Msg
from app.models.user import UserInDB
from app.utils import send_test_email
router = APIRouter()
@router.post("/test-celery/", tags=["utils"], response_model=Msg, status_code=201)
def route_test_celery(msg: Msg, current_user: UserInDB = Depends(get_current_user)):
"""
Test Celery worker
"""
if not check_if_user_is_superuser(current_user):
raise HTTPException(status_code=400, detail="Not a superuser")
celery_app.send_task("app.worker.test_celery", args=[msg.msg])
return {"msg": "Word received"}
@router.post("/test-email/", tags=["utils"], response_model=Msg, status_code=201)
def route_test_email(
email_to: EmailStr, current_user: UserInDB = Depends(get_current_user)
):
"""
Test emails
"""
if not check_if_user_is_superuser(current_user):
raise HTTPException(status_code=400, detail="Not a superuser")
send_test_email(email_to=email_to)
return {"msg": "Test email sent"}
@@ -0,0 +1,32 @@
import logging
from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
from app.db.external_session import db_session
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
max_tries = 60 * 5 # 5 minutes
wait_seconds = 1
@retry(
stop=stop_after_attempt(max_tries),
wait=wait_fixed(wait_seconds),
before=before_log(logger, logging.INFO),
after=after_log(logger, logging.WARN),
)
def init():
# Try to create session to check if DB is awake
db_session.execute("SELECT 1")
def main():
logger.info("Initializing service")
init()
logger.info("Service finished initializing")
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
import logging
from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
from app.db.external_session import db_session
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
max_tries = 60 * 5 # 5 minutes
wait_seconds = 1
@retry(
stop=stop_after_attempt(max_tries),
wait=wait_fixed(wait_seconds),
before=before_log(logger, logging.INFO),
after=after_log(logger, logging.WARN),
)
def init():
# Try to create session to check if DB is awake
db_session.execute("SELECT 1")
def main():
logger.info("Initializing service")
init()
logger.info("Service finished initializing")
if __name__ == "__main__":
main()
@@ -0,0 +1,5 @@
from celery import Celery
celery_app = Celery("worker", broker="amqp://guest@queue//")
celery_app.conf.task_routes = {"app.worker.test_celery": "main-queue"}
@@ -0,0 +1,78 @@
import os
def getenv_boolean(var_name, default_value=False):
result = default_value
env_value = os.getenv(var_name)
if env_value is not None:
result = env_value.upper() in ("TRUE", "1")
return result
API_V1_STR = "/api/v1"
SECRET_KEY = os.getenvb(b"SECRET_KEY")
if not SECRET_KEY:
SECRET_KEY = os.urandom(32)
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 8 # 60 minutes * 24 hours * 8 days = 8 days
SERVER_NAME = os.getenv("SERVER_NAME")
SERVER_HOST = os.getenv("SERVER_HOST")
BACKEND_CORS_ORIGINS = os.getenv(
"BACKEND_CORS_ORIGINS"
) # a string of origins separated by commas, e.g: "http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, http://dev.couchbase-project.com, https://stag.couchbase-project.com, https://couchbase-project.com, http://local.dockertoolbox.tiangolo.com"
PROJECT_NAME = os.getenv("PROJECT_NAME")
SENTRY_DSN = os.getenv("SENTRY_DSN")
# Couchbase server settings
COUCHBASE_MEMORY_QUOTA_MB = os.getenv("COUCHBASE_MEMORY_QUOTA_MB", "256")
COUCHBASE_INDEX_MEMORY_QUOTA_MB = os.getenv("COUCHBASE_INDEX_MEMORY_QUOTA_MB" "256")
COUCHBASE_FTS_MEMORY_QUOTA_MB = os.getenv("COUCHBASE_FTS_MEMORY_QUOTA_MB", "256")
COUCHBASE_HOST = os.getenv("COUCHBASE_HOST", "couchbase")
COUCHBASE_PORT = os.getenv("COUCHBASE_PORT", "8091")
COUCHBASE_FULL_TEXT_PORT = os.getenv("COUCHBASE_FULL_TEXT_PORT", "8094")
COUCHBASE_ENTERPRISE = getenv_boolean("COUCHBASE_ENTERPRISE")
COUCHBASE_USER = os.getenv("COUCHBASE_USER", "Administrator")
COUCHBASE_PASSWORD = os.getenv("COUCHBASE_PASSWORD", "password")
COUCHBASE_BUCKET_NAME = os.getenv("COUCHBASE_BUCKET_NAME", "app")
COUCHBASE_SYNC_GATEWAY_HOST = os.getenv("COUCHBASE_SYNC_GATEWAY_HOST", "sync-gateway")
COUCHBASE_SYNC_GATEWAY_PORT = os.getenv("COUCHBASE_SYNC_GATEWAY_PORT", "4985")
COUCHBASE_SYNC_GATEWAY_USER = os.getenv("COUCHBASE_SYNC_GATEWAY_USER")
COUCHBASE_SYNC_GATEWAY_PASSWORD = os.getenv("COUCHBASE_SYNC_GATEWAY_PASSWORD")
COUCHBASE_SYNC_GATEWAY_DATABASE = os.getenv("COUCHBASE_SYNC_GATEWAY_DATABASE")
# Couchbase query timeouts
COUCHBASE_DURABILITY_TIMEOUT_SECS = 60.0
COUCHBASE_OPERATION_TIMEOUT_SECS = 30.0
COUCHBASE_N1QL_TIMEOUT_SECS = 300.0
# Couchbase Sync Gateway settings
COUCHBASE_CORS_ORIGINS = os.getenv("COUCHBASE_CORS_ORIGINS")
# a string of origins separated by commas, e.g: "http://localhost:5984, http://localhost, http://localhost:4200, http://localhost:3000, http://localhost:8080, http://dev.couchbase-project.com, https://stag.couchbase-project.com, https://db.stag.couchbase-project.com, https://couchbase-project.com, https://db.couchbase-project.com, http://local.dockertoolbox.tiangolo.com, http://local.dockertoolbox.tiangolo.com:5984"
COUCHBASE_AUTH_TIMEOUT = ACCESS_TOKEN_EXPIRE_MINUTES * 60
COUCHBASE_FULL_TEXT_INDEX_DEFINITIONS_DIR = "/app/app/search_index_definitions/"
SMTP_TLS = getenv_boolean("SMTP_TLS", True)
SMTP_PORT = None
_SMTP_PORT = os.getenv("SMTP_PORT")
if _SMTP_PORT is not None:
SMTP_PORT = int(_SMTP_PORT)
SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
EMAILS_FROM_EMAIL = os.getenv("EMAILS_FROM_EMAIL")
EMAILS_FROM_NAME = PROJECT_NAME
EMAIL_RESET_TOKEN_EXPIRE_HOURS = 48
EMAIL_TEMPLATES_DIR = "/app/app/email-templates/build"
EMAILS_ENABLED = SMTP_HOST and SMTP_PORT and EMAILS_FROM_EMAIL
ROLE_SUPERUSER = "superuser"
FIRST_SUPERUSER = os.getenv("FIRST_SUPERUSER")
FIRST_SUPERUSER_PASSWORD = os.getenv("FIRST_SUPERUSER_PASSWORD")
USERS_OPEN_REGISTRATION = getenv_boolean("USERS_OPEN_REGISTRATION")
@@ -0,0 +1,44 @@
from datetime import datetime, timedelta
import jwt
from fastapi import Security
from fastapi.security import OAuth2PasswordBearer
from jwt import PyJWTError
from starlette.exceptions import HTTPException
from starlette.status import HTTP_403_FORBIDDEN
from app.core.config import SECRET_KEY
from app.crud.user import get_user
from app.db.database import get_default_bucket
from app.models.token import TokenPayload
ALGORITHM = "HS256"
access_token_jwt_subject = "access"
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
def get_current_user(token: str = Security(reusable_oauth2)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
token_data = TokenPayload(**payload)
except PyJWTError:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
)
bucket = get_default_bucket()
user = get_user(bucket, username=token_data.username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
def create_access_token(*, data: dict, expires_delta: timedelta = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire, "sub": access_token_jwt_subject})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
@@ -0,0 +1,11 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
@@ -0,0 +1,91 @@
from app.core.security import get_password_hash
from app.models.role import Role
from app.models.user import User
def get_user(username, db_session):
return db_session.query(User).filter(User.id == username).first()
def check_if_user_is_active(user):
return user.is_active
def check_if_user_is_superuser(user):
return user.is_superuser
def check_if_username_is_active(username, db_session):
user = get_user(username, db_session)
return check_if_user_is_active(user)
def get_role_by_name(name, db_session):
role = db_session.query(Role).filter(Role.name == name).first()
return role
def get_role_by_id(role_id, db_session):
role = db_session.query(Role).filter(Role.id == role_id).first()
return role
def create_role(name, db_session):
role = Role(name=name)
db_session.add(role)
db_session.commit()
return role
def get_roles(db_session):
return db_session.query(Role).all()
def get_user_roles(user):
return user.roles
def get_user_by_username(username, db_session) -> User:
user = db_session.query(User).filter(User.email == username).first() # type: User
return user
def get_user_by_id(user_id, db_session):
user = db_session.query(User).filter(User.id == user_id).first() # type: User
return user
def get_user_hashed_password(user):
return user.password
def get_user_id(user):
return user.id
def get_users(db_session):
return db_session.query(User).all()
def create_user(
db_session, username, password, first_name=None, last_name=None, is_superuser=False
):
user = User(
email=username,
password=get_password_hash(password),
first_name=first_name,
last_name=last_name,
is_superuser=is_superuser,
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
def assign_role_to_user(role: Role, user: User, db_session):
user.roles.append(role)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@@ -0,0 +1,214 @@
import uuid
from enum import Enum
from typing import List, Sequence, Type, Union
from pydantic import BaseModel
from pydantic.fields import Field, Shape
from app.core.config import COUCHBASE_BUCKET_NAME
from couchbase.bucket import Bucket
from couchbase.fulltext import MatchAllQuery, QueryStringQuery
from couchbase.n1ql import CONSISTENCY_REQUEST, N1QLQuery
def generate_new_id():
return str(uuid.uuid4())
def ensure_enums_to_strs(items: Union[Sequence[Union[Enum, str]], Type[Enum]]):
str_items = []
for item in items:
if isinstance(item, Enum):
str_items.append(str(item.value))
else:
str_items.append(str(item))
return str_items
def get_all_documents_by_type(bucket: Bucket, *, doc_type: str, skip=0, limit=100):
query_str = f"SELECT *, META().id as id FROM {COUCHBASE_BUCKET_NAME} WHERE type = $type LIMIT $limit OFFSET $skip;"
q = N1QLQuery(
query_str, bucket=COUCHBASE_BUCKET_NAME, type=doc_type, limit=limit, skip=skip
)
q.consistency = CONSISTENCY_REQUEST
result = bucket.n1ql_query(q)
return result
def get_documents_by_keys(
bucket: Bucket, *, keys: List[str], doc_model=Type[BaseModel]
):
results = bucket.get_multi(keys, quiet=True)
docs = []
for result in results.values():
doc = doc_model(**result.value)
docs.append(doc)
return docs
def results_to_model(results_from_couchbase: list, *, doc_model: Type[BaseModel]):
items = []
for doc in results_from_couchbase:
data = doc[COUCHBASE_BUCKET_NAME]
doc = doc_model(**data)
items.append(doc)
return items
def search_results_to_model(
results_from_couchbase: list, *, doc_model: Type[BaseModel]
):
items = []
for doc in results_from_couchbase:
data = doc.get("fields")
if not data:
continue
data_nones = {}
for key, value in data.items():
field: Field = doc_model.__fields__[key]
if not value:
value = None
elif field.shape in {Shape.LIST, Shape.SET, Shape.TUPLE} and not isinstance(
value, list
):
value = [value]
data_nones[key] = value
doc = doc_model(**data_nones)
items.append(doc)
return items
def get_docs(
bucket: Bucket, *, doc_type: str, doc_model=Type[BaseModel], skip=0, limit=100
):
doc_results = get_all_documents_by_type(
bucket, doc_type=doc_type, skip=skip, limit=limit
)
return results_to_model(doc_results, doc_model=doc_model)
def get_doc(bucket: Bucket, *, doc_id: str, doc_model: Type[BaseModel]):
result = bucket.get(doc_id, quiet=True)
if not result.value:
return None
model = doc_model(**result.value)
return model
def search_docs_get_doc_ids(
bucket: Bucket,
*,
query_string: str,
index_name: str,
skip: int = 0,
limit: int = 100,
):
query = QueryStringQuery(query_string)
hits = bucket.search(index_name, query, skip=skip, limit=limit)
doc_ids = []
for hit in hits:
doc_ids.append(hit["id"])
return doc_ids
def search_get_results(
bucket: Bucket,
*,
query_string: str,
index_name: str,
skip: int = 0,
limit: int = 100,
):
if query_string:
query = QueryStringQuery(query_string)
else:
query = MatchAllQuery()
hits = bucket.search(index_name, query, fields=["*"], skip=skip, limit=limit)
docs = []
for hit in hits:
docs.append(hit)
return docs
def search_get_results_by_type(
bucket: Bucket,
*,
query_string: str,
index_name: str,
doc_type: str,
skip: int = 0,
limit: int = 100,
):
type_filter = f"type:{doc_type}"
if not query_string:
query_string = type_filter
if query_string and type_filter not in query_string:
query_string += f" {type_filter}"
query = QueryStringQuery(query_string)
hits = bucket.search(index_name, query, fields=["*"], skip=skip, limit=limit)
docs = []
for hit in hits:
docs.append(hit)
return docs
def search_docs(
bucket: Bucket,
*,
query_string: str,
index_name: str,
doc_model: Type[BaseModel],
skip=0,
limit=100,
):
keys = search_docs_get_doc_ids(
bucket=bucket,
query_string=query_string,
index_name=index_name,
skip=skip,
limit=limit,
)
if not keys:
return []
doc_results = get_documents_by_keys(bucket=bucket, keys=keys, doc_model=doc_model)
return doc_results
def search_results(
bucket: Bucket,
*,
query_string: str,
index_name: str,
doc_model: Type[BaseModel],
skip=0,
limit=100,
):
doc_results = search_get_results(
bucket=bucket,
query_string=query_string,
index_name=index_name,
skip=skip,
limit=limit,
)
return search_results_to_model(doc_results, doc_model=doc_model)
def search_results_by_type(
bucket: Bucket,
*,
query_string: str,
index_name: str,
doc_type: str,
doc_model: Type[BaseModel],
skip=0,
limit=100,
):
doc_results = search_get_results_by_type(
bucket=bucket,
query_string=query_string,
index_name=index_name,
doc_type=doc_type,
skip=skip,
limit=limit,
)
return search_results_to_model(doc_results, doc_model=doc_model)
@@ -0,0 +1,5 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from app.db.base_class import Base # noqa
from app.models.role import Role # noqa
from app.models.user import User # noqa
@@ -0,0 +1,11 @@
from sqlalchemy.ext.declarative import declarative_base, declared_attr
class CustomBase(object):
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls):
return cls.__name__.lower()
Base = declarative_base(cls=CustomBase)
@@ -0,0 +1,8 @@
from app.core import config
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
engine = create_engine(config.SQLALCHEMY_DATABASE_URI, convert_unicode=True)
db_session = scoped_session(
sessionmaker(autocommit=False, autoflush=False, bind=engine)
)
@@ -0,0 +1,106 @@
import json
from pathlib import Path, PurePath
from typing import Any, Dict
import requests
from requests.auth import HTTPBasicAuth
from app.core.config import (
COUCHBASE_FULL_TEXT_INDEX_DEFINITIONS_DIR,
COUCHBASE_PASSWORD,
COUCHBASE_USER,
)
def get_index(
index_name: str,
*,
username: str = COUCHBASE_USER,
password: str = COUCHBASE_PASSWORD,
host="couchbase",
port="8094",
):
full_text_url = f"http://{host}:{port}"
index_url = f"{full_text_url}/api/index/{index_name}"
auth = HTTPBasicAuth(username, password)
response = requests.get(index_url, auth=auth)
if response.status_code == 400:
content = response.json()
error = content.get("error")
if error == "rest_auth: preparePerms, err: index not found":
return None
raise ValueError(error)
elif response.status_code == 200:
content = response.json()
assert (
content.get("status") == "ok"
), "Expected a status OK communicating with Full Text Search"
index_def = content.get("indexDef")
return index_def
raise ValueError(response.text)
def create_index(
index_definition: Dict[str, Any],
*,
reset_uuids=True,
username: str = COUCHBASE_USER,
password: str = COUCHBASE_PASSWORD,
host="couchbase",
port="8094",
):
index_name = index_definition.get("name")
assert index_name, "An index name is required as key in an index definition"
if reset_uuids:
index_definition.update({"uuid": "", "sourceUUID": ""})
full_text_url = f"http://{host}:{port}"
index_url = f"{full_text_url}/api/index/{index_name}"
auth = HTTPBasicAuth(username, password)
response = requests.put(index_url, auth=auth, json=index_definition)
content = response.json()
if response.status_code == 400:
error = content.get("error")
if (
"cannot create index because an index with the same name already exists:"
in error
):
raise ValueError(error)
else:
raise ValueError(error)
elif response.status_code == 200:
assert (
content.get("status") == "ok"
), "Expected a status OK communicating with Full Text Search"
return True
raise ValueError(response.text)
def ensure_create_full_text_indexes(
index_dir=COUCHBASE_FULL_TEXT_INDEX_DEFINITIONS_DIR,
username: str = COUCHBASE_USER,
password: str = COUCHBASE_PASSWORD,
host="couchbase",
port="8094",
):
file_path: PurePath
for file_path in Path(index_dir).iterdir():
if file_path.name.endswith(".json"):
with open(file_path) as f:
index_definition = json.load(f)
name = index_definition.get("name")
assert name, "A full text search index definition must have a name field"
current_index = get_index(
index_name=name,
username=username,
password=password,
host=host,
port=port,
)
if not current_index:
assert create_index(
index_definition=index_definition,
username=username,
password=password,
host=host,
port=port,
), "Full Text Search index could not be created"
@@ -0,0 +1,29 @@
from app.core import config
from app.db.utils import (
assign_role_to_user,
create_role,
create_user,
get_role_by_name,
get_user_by_username,
)
def init_db(db_session):
# Tables should be created with Alembic migrations
# But if you don't want to use migrations, create
# the tables uncommenting the next line
# Base.metadata.create_all(bind=engine)
role = get_role_by_name("default", db_session)
if not role:
role = create_role("default", db_session)
user = get_user_by_username(config.FIRST_SUPERUSER, db_session)
if not user:
user = create_user(
db_session,
config.FIRST_SUPERUSER,
config.FIRST_SUPERUSER_PASSWORD,
is_superuser=True,
)
assign_role_to_user(role, user, db_session)
@@ -0,0 +1,11 @@
# Import installed packages
# Import app code
from app.db.base_class import Base
from sqlalchemy import Column, ForeignKey, Integer, Table
users_roles = Table(
"users_roles",
Base.metadata,
Column("user_id", Integer, ForeignKey("user.id")),
Column("role_id", Integer, ForeignKey("role.id")),
)
@@ -0,0 +1,19 @@
# Import standard library packages
from datetime import datetime
# Import app code
from app.db.base_class import Base
from app.models.base_relations import users_roles
# Import installed packages
from sqlalchemy import Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
class Role(Base):
# Own properties
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow(), index=True)
name = Column(String, index=True)
# Relationships
users = relationship("User", secondary=users_roles, back_populates="roles")
@@ -0,0 +1,29 @@
# Import standard library packages
from datetime import datetime
# Typings, for autocompletion (VS Code with Python plug-in)
from typing import List # noqa
# Import app code
from app.db.base_class import Base
from app.models.base_relations import users_roles
# Import installed packages
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
class User(Base):
# Own properties
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow(), index=True)
first_name = Column(String, index=True)
last_name = Column(String, index=True)
email = Column(String, unique=True, index=True)
password = Column(String)
is_active = Column(Boolean(), default=True)
is_superuser = Column(Boolean(), default=False)
# Relationships
roles = relationship(
"Role", secondary=users_roles, back_populates="users"
) # type: List[role.Role]
@@ -0,0 +1,26 @@
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
.ReadMsgBody { width:100%; }
.ExternalClass { width:100%; }
.ExternalClass * { line-height:100%; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
@-ms-viewport { width:320px; }
@viewport { width:320px; }
}</style><!--<![endif]--><!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]--><!--[if lte mso 11]>
<style type="text/css">
.outlook-group-fix { width:100% !important; }
</style>
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}</style><style type="text/css"></style></head><body style="background-color:#ffffff;"><div style="background-color:#ffffff;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]--></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:helvetica;font-size:20px;line-height:1;text-align:left;color:#555555;">{{ project_name }} - New Account</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">You have a new account:</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">Username: {{ username }}</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">Password: {{ password }}</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:50px 0px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;padding:10px 25px;background:#414141;" valign="middle"><a href="{{ link }}" style="background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Go to Dashboard</a></td></tr></table></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #555555;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #555555;font-size:1;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]--></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
@@ -0,0 +1,26 @@
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
.ReadMsgBody { width:100%; }
.ExternalClass { width:100%; }
.ExternalClass * { line-height:100%; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
@-ms-viewport { width:320px; }
@viewport { width:320px; }
}</style><!--<![endif]--><!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]--><!--[if lte mso 11]>
<style type="text/css">
.outlook-group-fix { width:100% !important; }
</style>
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}</style><style type="text/css"></style></head><body style="background-color:#ffffff;"><div style="background-color:#ffffff;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]--></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:helvetica;font-size:20px;line-height:1;text-align:left;color:#555555;">{{ project_name }} - Password Recovery</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">We received a request to recover the password for user {{ username }} with email {{ email }}</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">Reset your password by clicking the button below:</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:50px 0px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;padding:10px 25px;background:#414141;" valign="middle"><a href="{{ link }}" style="background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Reset Password</a></td></tr></table></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">Or open the following link:</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;"><a href="{{ link }}">{{ link }}</a></div></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #555555;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #555555;font-size:1;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]--></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:14px;line-height:1;text-align:left;color:#555555;">The reset password link / button will expire in {{ valid_hours }} hours.</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:14px;line-height:1;text-align:left;color:#555555;">If you didn't request a password recovery you can disregard this email.</div></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
@@ -0,0 +1,25 @@
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
.ReadMsgBody { width:100%; }
.ExternalClass { width:100%; }
.ExternalClass * { line-height:100%; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
@-ms-viewport { width:320px; }
@viewport { width:320px; }
}</style><!--<![endif]--><!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]--><!--[if lte mso 11]>
<style type="text/css">
.outlook-group-fix { width:100% !important; }
</style>
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
}</style><style type="text/css"></style></head><body style="background-color:#ffffff;"><div style="background-color:#ffffff;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 4px #555555;font-size:1;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]--></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:helvetica;font-size:20px;line-height:1;text-align:left;color:#555555;">{{ project_name }}</div></td></tr><tr><td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#555555;">Test email for: {{ email }}</div></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
@@ -0,0 +1,15 @@
<mjml>
<mj-body background-color="#fff">
<mj-section>
<mj-column>
<mj-divider border-color="#555"></mj-divider>
<mj-text font-size="20px" color="#555" font-family="helvetica">{{ project_name }} - New Account</mj-text>
<mj-text font-size="16px" color="#555">You have a new account:</mj-text>
<mj-text font-size="16px" color="#555">Username: {{ username }}</mj-text>
<mj-text font-size="16px" color="#555">Password: {{ password }}</mj-text>
<mj-button padding="50px 0px" href="{{ link }}">Go to Dashboard</mj-button>
<mj-divider border-color="#555" border-width="2px" />
</mj-column>
</mj-section>
</mj-body>
</mjml>
@@ -0,0 +1,19 @@
<mjml>
<mj-body background-color="#fff">
<mj-section>
<mj-column>
<mj-divider border-color="#555"></mj-divider>
<mj-text font-size="20px" color="#555" font-family="helvetica">{{ project_name }} - Password Recovery</mj-text>
<mj-text font-size="16px" color="#555">We received a request to recover the password for user {{ username }}
with email {{ email }}</mj-text>
<mj-text font-size="16px" color="#555">Reset your password by clicking the button below:</mj-text>
<mj-button padding="50px 0px" href="{{ link }}">Reset Password</mj-button>
<mj-text font-size="16px" color="#555">Or open the following link:</mj-text>
<mj-text font-size="16px" color="#555"><a href="{{ link }}">{{ link }}</a></mj-text>
<mj-divider border-color="#555" border-width="2px" />
<mj-text font-size="14px" color="#555">The reset password link / button will expire in {{ valid_hours }} hours.</mj-text>
<mj-text font-size="14px" color="#555">If you didn't request a password recovery you can disregard this email.</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
@@ -0,0 +1,11 @@
<mjml>
<mj-body background-color="#fff">
<mj-section>
<mj-column>
<mj-divider border-color="#555"></mj-divider>
<mj-text font-size="20px" color="#555" font-family="helvetica">{{ project_name }}</mj-text>
<mj-text font-size="16px" color="#555">Test email for: {{ email }}</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
@@ -0,0 +1,26 @@
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from app.api.api_v1.api import api_router
from app.core.config import API_V1_STR, BACKEND_CORS_ORIGINS, PROJECT_NAME
app = FastAPI(title=PROJECT_NAME, openapi_url="/api/v1/openapi.json")
# CORS
origins = []
# Set all CORS enabled origins
if BACKEND_CORS_ORIGINS:
origins_raw = BACKEND_CORS_ORIGINS.split(",")
for origin in origins_raw:
use_origin = origin.strip()
origins.append(use_origin)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
),
app.include_router(api_router, prefix=API_V1_STR)
@@ -0,0 +1 @@
USERPROFILE_DOC_TYPE = "userprofile"
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class Msg(BaseModel):
msg: str
@@ -0,0 +1,14 @@
from enum import Enum
from typing import List
from pydantic import BaseModel
from app.core.config import ROLE_SUPERUSER
class RoleEnum(Enum):
superuser = ROLE_SUPERUSER
class Roles(BaseModel):
roles: List[RoleEnum]
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
username: str = None
@@ -0,0 +1,48 @@
from typing import List, Optional, Union
from pydantic import BaseModel
from app.models.config import USERPROFILE_DOC_TYPE
from app.models.role import RoleEnum
# Shared properties
class UserBase(BaseModel):
email: Optional[str] = None
admin_roles: Optional[List[Union[str, RoleEnum]]] = None
admin_channels: Optional[List[Union[str, RoleEnum]]] = None
disabled: Optional[bool] = None
class UserBaseInDB(UserBase):
username: str
full_name: Optional[str] = None
# Properties to receive via API on creation
class UserInCreate(UserBaseInDB):
password: str
admin_roles: List[Union[str, RoleEnum]] = []
admin_channels: List[Union[str, RoleEnum]] = []
disabled: bool = False
# Properties to receive via API on update
class UserInUpdate(UserBaseInDB):
password: Optional[str] = None
# Additional properties to return via API
class User(UserBaseInDB):
pass
# Additional properties stored in DB
class UserInDB(UserBaseInDB):
type: str = USERPROFILE_DOC_TYPE
hashed_password: str
class UserSyncIn(UserBase):
name: str
password: Optional[str] = None
@@ -0,0 +1,15 @@
{
"name": "users",
"type": "fulltext-alias",
"params": {
"targets": {
"users_01": {}
}
},
"sourceType": "nil",
"sourceName": "",
"sourceUUID": "",
"sourceParams": null,
"planParams": {},
"uuid": ""
}
@@ -0,0 +1,157 @@
{
"name": "users_01",
"type": "fulltext-index",
"params": {
"doc_config": {
"docid_prefix_delim": "",
"docid_regexp": "",
"mode": "type_field",
"type_field": "type"
},
"mapping": {
"analysis": {
"analyzers": {
"userprofile": {
"token_filters": [
"apostrophe",
"to_lower"
],
"tokenizer": "unicode",
"type": "custom"
}
}
},
"default_analyzer": "standard",
"default_datetime_parser": "dateTimeOptional",
"default_field": "_all",
"default_mapping": {
"dynamic": true,
"enabled": false
},
"default_type": "_default",
"docvalues_dynamic": true,
"index_dynamic": true,
"store_dynamic": false,
"type_field": "_type",
"types": {
"userprofile": {
"dynamic": false,
"enabled": true,
"properties": {
"type": {
"enabled": true,
"dynamic": false,
"fields": [
{
"name": "type",
"type": "text",
"analyzer": "keyword",
"store": false,
"index": true,
"include_term_vectors": false,
"include_in_all": false
}
]
},
"admin_channels": {
"enabled": true,
"dynamic": false,
"fields": [
{
"analyzer": "keyword",
"include_in_all": true,
"include_term_vectors": true,
"index": true,
"name": "admin_channels",
"type": "text"
}
]
},
"admin_roles": {
"enabled": true,
"dynamic": false,
"fields": [
{
"analyzer": "keyword",
"include_in_all": true,
"include_term_vectors": true,
"index": true,
"name": "admin_roles",
"type": "text"
}
]
},
"disabled": {
"enabled": true,
"dynamic": false,
"fields": [
{
"include_in_all": true,
"include_term_vectors": true,
"index": true,
"name": "disabled",
"type": "boolean"
}
]
},
"email": {
"enabled": true,
"dynamic": false,
"fields": [
{
"analyzer": "keyword",
"include_in_all": true,
"include_term_vectors": true,
"index": true,
"name": "email",
"type": "text"
}
]
},
"full_name": {
"enabled": true,
"dynamic": false,
"fields": [
{
"analyzer": "standard",
"include_in_all": true,
"include_term_vectors": true,
"index": true,
"name": "full_name",
"type": "text"
}
]
},
"username": {
"enabled": true,
"dynamic": false,
"fields": [
{
"analyzer": "keyword",
"include_in_all": true,
"include_term_vectors": true,
"index": true,
"name": "username",
"type": "text"
}
]
}
}
}
}
},
"store": {
"indexType": "scorch",
"kvStoreName": ""
}
},
"sourceType": "couchbase",
"sourceName": "app",
"sourceUUID": "",
"sourceParams": {},
"planParams": {
"maxPartitionsPerPIndex": 171,
"numReplicas": 0
},
"uuid": ""
}
@@ -0,0 +1 @@
.cache
@@ -0,0 +1,16 @@
import requests
from app.core import config
from app.tests.utils.utils import get_server_api
def test_celery_worker_test(superuser_token_headers):
server_api = get_server_api()
data = {"msg": "test"}
r = requests.post(
f"{server_api}{config.API_V1_STR}/test-celery/",
json=data,
headers=superuser_token_headers,
)
response = r.json()
assert response["msg"] == "Word received"
@@ -0,0 +1,30 @@
import requests
from app.core import config
from app.tests.utils.utils import get_server_api
def test_get_access_token():
server_api = get_server_api()
login_data = {
"username": config.FIRST_SUPERUSER,
"password": config.FIRST_SUPERUSER_PASSWORD,
}
r = requests.post(
f"{server_api}{config.API_V1_STR}/login/access-token", data=login_data
)
tokens = r.json()
assert r.status_code == 200
assert "access_token" in tokens
assert tokens["access_token"]
def test_use_access_token(superuser_token_headers):
server_api = get_server_api()
r = requests.post(
f"{server_api}{config.API_V1_STR}/login/test-token",
headers=superuser_token_headers,
)
result = r.json()
assert r.status_code == 200
assert "username" in result
@@ -0,0 +1,112 @@
import requests
from app.core import config
from app.crud.user import get_user, upsert_user
from app.db.database import get_default_bucket
from app.models.user import UserInCreate
from app.tests.utils.user import user_authentication_headers
from app.tests.utils.utils import get_server_api, random_lower_string
def test_get_users_superuser_me(superuser_token_headers):
server_api = get_server_api()
r = requests.get(
f"{server_api}{config.API_V1_STR}/users/me", headers=superuser_token_headers
)
current_user = r.json()
assert current_user
assert current_user["disabled"] is False
assert "superuser" in current_user["admin_roles"]
assert current_user["username"] == config.FIRST_SUPERUSER
def test_create_user_new_email(superuser_token_headers):
server_api = get_server_api()
username = random_lower_string()
password = random_lower_string()
data = {"username": username, "password": password}
r = requests.post(
f"{server_api}{config.API_V1_STR}/users/",
headers=superuser_token_headers,
json=data,
)
assert 200 <= r.status_code < 300
created_user = r.json()
bucket = get_default_bucket()
user = get_user(bucket, username)
assert user.username == created_user["username"]
def test_get_existing_user(superuser_token_headers):
server_api = get_server_api()
username = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(username=username, email=username, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
r = requests.get(
f"{server_api}{config.API_V1_STR}/users/{username}",
headers=superuser_token_headers,
)
assert 200 <= r.status_code < 300
api_user = r.json()
user = get_user(bucket, username)
assert user.username == api_user["username"]
def test_create_user_existing_username(superuser_token_headers):
server_api = get_server_api()
username = random_lower_string()
# username = email
password = random_lower_string()
user_in = UserInCreate(username=username, email=username, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
data = {"username": username, "password": password}
r = requests.post(
f"{server_api}{config.API_V1_STR}/users/",
headers=superuser_token_headers,
json=data,
)
created_user = r.json()
assert r.status_code == 400
assert "_id" not in created_user
def test_create_user_by_normal_user():
server_api = get_server_api()
username = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(username=username, email=username, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
user_token_headers = user_authentication_headers(server_api, username, password)
data = {"username": username, "password": password}
r = requests.post(
f"{server_api}{config.API_V1_STR}/users/", headers=user_token_headers, json=data
)
assert r.status_code == 400
def test_retrieve_users(superuser_token_headers):
server_api = get_server_api()
username = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(username=username, email=username, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
username2 = random_lower_string()
password2 = random_lower_string()
user_in2 = UserInCreate(username=username2, email=username2, password=password2)
user2 = upsert_user(bucket, user_in, persist_to=1)
r = requests.get(
f"{server_api}{config.API_V1_STR}/users/", headers=superuser_token_headers
)
all_users = r.json()
assert len(all_users) > 1
for user in all_users:
assert "username" in user
assert "admin_roles" in user
@@ -0,0 +1,13 @@
import pytest
from app.tests.utils.utils import get_server_api, get_superuser_token_headers
@pytest.fixture(scope="module")
def server_api():
return get_server_api()
@pytest.fixture(scope="module")
def superuser_token_headers():
return get_superuser_token_headers()
@@ -0,0 +1,7 @@
from app.crud.user import get_user_doc_id
def test_get_user_id():
username = "johndoe@example.com"
user_id = get_user_doc_id(username)
assert user_id == "userprofile::johndoe@example.com"
@@ -0,0 +1,105 @@
from fastapi.encoders import jsonable_encoder
from app.crud.user import (
authenticate_user,
check_if_user_is_active,
check_if_user_is_superuser,
get_user,
upsert_user,
)
from app.db.database import get_default_bucket
from app.models.role import RoleEnum
from app.models.user import UserInCreate
from app.tests.utils.utils import random_lower_string
def test_create_user():
email = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(username=email, email=email, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
assert hasattr(user, "username")
assert user.username == email
assert hasattr(user, "hashed_password")
assert hasattr(user, "type")
assert user.type == "userprofile"
def test_authenticate_user():
email = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(username=email, email=email, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
authenticated_user = authenticate_user(bucket, email, password)
assert authenticated_user
assert user.username == authenticated_user.username
def test_not_authenticate_user():
email = random_lower_string()
password = random_lower_string()
bucket = get_default_bucket()
user = authenticate_user(bucket, email, password)
assert user is False
def test_check_if_user_is_active():
email = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(username=email, email=email, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
is_active = check_if_user_is_active(user)
assert is_active is True
def test_check_if_user_is_active_inactive():
email = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(
username=email, email=email, password=password, disabled=True
)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
is_active = check_if_user_is_active(user)
assert is_active is False
def test_check_if_user_is_superuser():
email = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(
username=email, email=email, password=password, admin_roles=[RoleEnum.superuser]
)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
is_superuser = check_if_user_is_superuser(user)
assert is_superuser is True
def test_check_if_user_is_superuser_normal_user():
username = random_lower_string()
password = random_lower_string()
user_in = UserInCreate(username=username, email=username, password=password)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
is_superuser = check_if_user_is_superuser(user)
assert is_superuser is False
def test_get_user():
password = random_lower_string()
username = random_lower_string()
user_in = UserInCreate(
username=username,
email=username,
password=password,
admin_roles=[RoleEnum.superuser],
)
bucket = get_default_bucket()
user = upsert_user(bucket, user_in, persist_to=1)
user_2 = get_user(bucket, username)
assert user.username == user_2.username
assert jsonable_encoder(user) == jsonable_encoder(user_2)
@@ -0,0 +1,13 @@
import requests
from app.core import config
def user_authentication_headers(server_api, email, password):
data = {"username": email, "password": password}
r = requests.post(f"{server_api}{config.API_V1_STR}/login/access-token", data=data)
response = r.json()
auth_token = response["access_token"]
headers = {"Authorization": f"Bearer {auth_token}"}
return headers
@@ -0,0 +1,31 @@
import random
import string
import requests
from app.core import config
def random_lower_string():
return "".join(random.choices(string.ascii_lowercase, k=32))
def get_server_api():
server_name = f"http://{config.SERVER_NAME}"
return server_name
def get_superuser_token_headers():
server_api = get_server_api()
login_data = {
"username": config.FIRST_SUPERUSER,
"password": config.FIRST_SUPERUSER_PASSWORD,
}
r = requests.post(
f"{server_api}{config.API_V1_STR}/login/access-token", data=login_data
)
tokens = r.json()
a_token = tokens["access_token"]
headers = {"Authorization": f"Bearer {a_token}"}
# superuser_token_headers = headers
return headers
@@ -0,0 +1,35 @@
import logging
from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed
from app.db.external_session import db_session
from app.tests.api.api_v1.token.test_token import test_get_access_token
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
max_tries = 60 * 5 # 5 minutes
wait_seconds = 1
@retry(
stop=stop_after_attempt(max_tries),
wait=wait_fixed(wait_seconds),
before=before_log(logger, logging.INFO),
after=after_log(logger, logging.WARN),
)
def init():
# Try to create session to check if DB is awake
db_session.execute("SELECT 1")
# Wait for API to be awake, run one simple tests to authenticate
test_get_access_token()
def main():
logger.info("Initializing service")
init()
logger.info("Service finished initializing")
if __name__ == "__main__":
main()
@@ -0,0 +1,126 @@
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Union
import emails
import jwt
from emails.template import JinjaTemplate
from jwt.exceptions import InvalidTokenError
from app.core.config import (
EMAIL_RESET_TOKEN_EXPIRE_HOURS,
EMAIL_TEMPLATES_DIR,
EMAILS_ENABLED,
EMAILS_FROM_EMAIL,
EMAILS_FROM_NAME,
PROJECT_NAME,
SECRET_KEY,
SERVER_HOST,
SMTP_HOST,
SMTP_PASSWORD,
SMTP_PORT,
SMTP_TLS,
SMTP_USER,
)
password_reset_jwt_subject = "preset"
def send_email(email_to: str, subject_template="", html_template="", environment={}):
assert EMAILS_ENABLED, "no provided configuration for email variables"
message = emails.Message(
subject=JinjaTemplate(subject_template),
html=JinjaTemplate(html_template),
mail_from=(EMAILS_FROM_NAME, EMAILS_FROM_EMAIL),
)
smtp_options = {"host": SMTP_HOST, "port": SMTP_PORT}
if SMTP_TLS:
smtp_options["tls"] = True
if SMTP_USER:
smtp_options["user"] = SMTP_USER
if SMTP_PASSWORD:
smtp_options["password"] = SMTP_PASSWORD
response = message.send(to=email_to, render=environment, smtp=smtp_options)
logging.info(f"send email result: {response}")
def send_test_email(email_to: str):
subject = f"{PROJECT_NAME} - Test email"
with open(Path(EMAIL_TEMPLATES_DIR) / "test_email.html") as f:
template_str = f.read()
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={"project_name": PROJECT_NAME, "email": email_to},
)
def send_reset_password_email(email_to: str, username: str, token: str):
subject = f"{PROJECT_NAME} - Password recovery for user {username}"
with open(Path(EMAIL_TEMPLATES_DIR) / "reset_password.html") as f:
template_str = f.read()
if hasattr(token, "decode"):
use_token = token.decode()
else:
use_token = token
link = f"{SERVER_HOST}/reset-password?token={use_token}"
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={
"project_name": PROJECT_NAME,
"username": username,
"email": email_to,
"valid_hours": EMAIL_RESET_TOKEN_EXPIRE_HOURS,
"link": link,
},
)
def send_new_account_email(email_to: str, username: str, password: str):
subject = f"{PROJECT_NAME} - New acccount for user {username}"
with open(Path(EMAIL_TEMPLATES_DIR) / "new_account.html") as f:
template_str = f.read()
link = f"{SERVER_HOST}"
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={
"project_name": PROJECT_NAME,
"username": username,
"password": password,
"email": email_to,
"link": link,
},
)
def generate_password_reset_token(username):
delta = timedelta(hours=EMAIL_RESET_TOKEN_EXPIRE_HOURS)
now = datetime.utcnow()
expires = now + delta
exp = expires.timestamp()
encoded_jwt = jwt.encode(
{
"exp": exp,
"nbf": now,
"sub": password_reset_jwt_subject,
"username": username,
},
SECRET_KEY,
algorithm="HS256",
)
return encoded_jwt
def verify_password_reset_token(token) -> Union[str, bool]:
try:
decoded_token = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
assert decoded_token["sub"] == password_reset_jwt_subject
return decoded_token["username"]
except InvalidTokenError:
return False
@@ -0,0 +1,19 @@
# Import standard library modules
# Import installed packages
from raven import Client
from app.core.celery_app import celery_app
# Import app code
# Absolute imports for Hydrogen (Jupyter Kernel) compatibility
from app.core.config import SENTRY_DSN
client_sentry = Client(SENTRY_DSN)
@celery_app.task(acks_late=True)
def test_celery(word: str):
print("test task")
return f"test task return {word}"