Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e956e10
added: user model structure, migrations and settings
bereoff Mar 12, 2023
5e0ba05
added: basic cli structure config
bereoff Mar 12, 2023
4550faf
updated files
bereoff Apr 17, 2023
2016a1d
added: create_user function
bereoff Apr 18, 2023
a248617
added: UserRequest and UserResponse serializers
bereoff Apr 18, 2023
d01917b
fixes
bereoff Apr 21, 2023
f6003b1
added: get session dependency
bereoff Apr 21, 2023
d8adf28
update: new version
Aug 16, 2023
eea8f74
updated files
bereoff Apr 21, 2023
8c81ce9
updated files
bereoff Apr 21, 2023
443aeaf
added auth flux
bereoff Jun 23, 2023
18f14e1
added auth flux
bereoff Jun 23, 2023
3a48cbd
added protection to get users and create users
bereoff Jun 30, 2023
7c3a66a
fix: get_current_super_user func
bereoff Jun 30, 2023
c3ace15
added: integrety error treatment
bereoff Jun 30, 2023
d8d6bec
added: change password flux
bereoff Jun 30, 2023
b8bee7f
update: new version
bereoff Aug 19, 2023
975a64e
update: features
bereoff Aug 19, 2023
570b987
added: queue email flux
bereoff Aug 20, 2023
bbc70dc
added: transaction and balance structures; added: migrations
bereoff Aug 23, 2023
bb145a7
added: add_transaction func into task module
bereoff Aug 23, 2023
4b39448
added: data migration to a new app deploy flux
bereoff Aug 27, 2023
a9863d0
added: add_transaction from admin to users command to CLI
bereoff Aug 27, 2023
49acc7f
added: transaction router to module __init__
bereoff Aug 27, 2023
39e1681
added: transaction api route
bereoff Aug 27, 2023
9bd68d7
added: transaction serializer
bereoff Aug 28, 2023
381fad3
added: transaction list to api
bereoff Aug 28, 2023
78facc8
removed: extra fields from TransactionSerializer
bereoff Aug 28, 2023
2a92771
added: dependencie to show balance info if it exists
bereoff Aug 28, 2023
b454d9a
added: UserResponseWithBalance serializer
bereoff Aug 28, 2023
70e3035
added: condional balance info serializer into list_users and get_user…
bereoff Aug 28, 2023
4651232
added: SQLModel import to ensure metadata from table in order to exec…
bereoff Aug 28, 2023
7a566b9
added: reset_db command to ensure the expected test behavior
bereoff Aug 28, 2023
16885e3
fix: UserProfilePatchRequest root_validator
bereoff Aug 28, 2023
61cc2dd
fix: docker compose command
bereoff Aug 28, 2023
db3bcac
added: tests pre configuration
bereoff Aug 28, 2023
b216555
added: test functions
bereoff Aug 28, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ __pycache__
**/**/**/__pycache__
.secrets.toml
docs/book
.idea/
14 changes: 14 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.autopep8",
"editor.formatOnSave": true
},
"python.formatting.provider": "none",
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.linting.enabled": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.autoImportCompletions": true,
"python.analysis.autoImportUserSymbols": true,
"python.analysis.completeFunctionParens": true
}
105 changes: 105 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# A generic, single database configuration.

[alembic]
# path to migration scripts
script_location = magrations

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .

# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =

# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false

# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false

# version location specification; This defaults
# to magrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:magrations/versions

# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8

sqlalchemy.url = driver://user:pass@localhost/dbname


[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples

# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME

# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
26 changes: 25 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

version: '3.9'

services:
Expand Down Expand Up @@ -29,3 +28,28 @@ services:
- POSTGRES_DBS=dundie, dundie_test
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
redis:
image: redis:6.2.5-alpine
restart: always
ports:
- "6379:6379"
volumes:
- $HOME/.redis/dundie_redis/data:/data
worker:
build:
context: .
dockerfile: Dockerfile.dev
environment:
DUNDIE_DB__uri: "postgresql://postgres:postgres@db:5432/${DUNDIE_DB:-dundie}"
DUNDIE_DB__connect_args: "{}"
volumes:
- .:/home/app/api
depends_on:
- db
- redis
stdin_open: true
tty: true
command: rq worker --with-scheduler --url redis://redis:6379



2 changes: 1 addition & 1 deletion dundie/VERSION.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.dev0
0.1.dev28+gd8adf28.d20230817
11 changes: 11 additions & 0 deletions dundie/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import FastAPI

from dundie.routes import main_router

app = FastAPI(
title="dundie",
version="0.1.0",
description="dundie is a rewards API",
)

app.include_router(main_router)
209 changes: 209 additions & 0 deletions dundie/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
from datetime import date, datetime, timedelta
from functools import partial
from typing import Callable, Optional, Union

from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlmodel import Session, select

from dundie.config import settings
from dundie.db import engine
from dundie.models.user import User
from dundie.security import verify_password

ALGORITHM = settings.security.ALGORITHM # pyright: ignore

SECRET_KEY = settings.security.SECRET_KEY # pyright: ignore
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str


class RefreshToken(BaseModel):
refresh_token: str


class TokenData(BaseModel):
username: Optional[str] = None


def create_access_token(
data: dict, expires_delta: Optional[timedelta] = None, scope: str = "access_token"
) -> str:
""" "Creates a JWT token"""
to_encode = data.copy()
expires_delta = expires_delta or timedelta(minutes=15)
expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire, "scope": scope})
enconded_jwt = jwt.encode(to_encode, SECRET_KEY, ALGORITHM) # pyright: ignore

return enconded_jwt


create_refresh_token = partial(create_access_token, scope="refresh_token")


def authenticate_user(
get_user: Callable,
username: str,
password: str,
) -> Union[User, bool]:
"""Authenticate a user"""
user = get_user(username)
if not user:
return False
if not verify_password(password, user.password):
return False
return user


def get_user(username: str) -> Optional[User]:
# TODO: move to utils module
query = select(User).where(User.username == username)
with Session(engine) as session:
return session.exec(query).first()


def get_current_user(
token: str = Depends(oauth2_scheme), request: Request = None, fresh=False # pyright: ignore
) -> User:
"""Get the current user authenticated"""
credential_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if request:
if authorization := request.headers.get("authorization"):
try:
token = authorization.split(" ")[1]
except IndexError:
raise credential_exception
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) # pyright: ignore
username = payload.get("sub")
if username is None:
raise credential_exception
token_data = TokenData(username=username)

except JWTError:
raise credential_exception

user = get_user(username=token_data.username) # pyright: ignore
if user is None:
raise credential_exception
if fresh and (not payload["fresh"]) and not user.superuser:
raise credential_exception

return user


async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Wraps the sync get_active_user for sync calls"""
return current_user


AuthenticatedUser = Depends(get_current_active_user)


async def get_current_super_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Wraps the sync get_active_user for sync calls for superuser"""
if not current_user.superuser:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a superuser")
return current_user


SuperUser = Depends(get_current_super_user)


async def validate_token(token: str = Depends(oauth2_scheme)) -> User:
"""Validates user token"""
user = get_current_user(token=token)
return user


async def get_user_if_change_password_is_allowed(
*,
request: Request,
pwd_reset_token: Optional[str] = None, # from path?pwd_reset_token=xxxx
username: str, # from /path/{username}
) -> User:
"""Returns User if one of the conditions is met.
1. There is a pwd_reset_token passed as query parameter and it is valid OR
2. authenticated_user is supersuser OR
3. authenticated_user is User
"""
target_user = get_user(username) # The user we want to change the password
if not target_user:
raise HTTPException(status_code=404, detail="User not found")

try:
valid_pwd_reset_token = get_current_user(token=pwd_reset_token or "") == target_user
except HTTPException:
valid_pwd_reset_token = False

try:
authenticated_user = get_current_user(token="", request=request)
except HTTPException:
authenticated_user = None

if any(
[
valid_pwd_reset_token,
authenticated_user and authenticated_user.superuser,
authenticated_user and authenticated_user.id == target_user.id,
]
):
return target_user

raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not allowed to change this user's password",
)


CanChangeUserPassword = Depends(get_user_if_change_password_is_allowed)


async def show_balance_field(
*,
request: Request,
show_balance: Optional[bool] = False, # from /user/?show_balance=true
) -> bool:
"""Returns True if one of the conditions is met.
1. show_balance is True AND
2. authenticated_user.superuser OR
3. authenticated_user.username == username
"""
if not show_balance:
return False

username = request.path_params.get("username")

try:
authenticated_user = get_current_user(token="", request=request)
except HTTPException:
authenticated_user = None

if any(
[
authenticated_user and authenticated_user.superuser,
authenticated_user and authenticated_user.username == username,
]
):
return True

return False


ShowBalanceField = Depends(show_balance_field)
Loading