diff --git a/.gitignore b/.gitignore index e4af269..dd13e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__ **/**/**/__pycache__ .secrets.toml docs/book +.idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5e20450 --- /dev/null +++ b/.vscode/settings.json @@ -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 +} \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..c4c3350 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index b7ab661..ca66166 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,3 @@ - version: '3.9' services: @@ -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 + + + diff --git a/dundie/VERSION.txt b/dundie/VERSION.txt index 9082c78..a04f310 100644 --- a/dundie/VERSION.txt +++ b/dundie/VERSION.txt @@ -1 +1 @@ -0.1.dev0 \ No newline at end of file +0.1.dev28+gd8adf28.d20230817 \ No newline at end of file diff --git a/dundie/app.py b/dundie/app.py index e69de29..c0a8d82 100644 --- a/dundie/app.py +++ b/dundie/app.py @@ -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) diff --git a/dundie/auth.py b/dundie/auth.py index e69de29..44e37ed 100644 --- a/dundie/auth.py +++ b/dundie/auth.py @@ -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) diff --git a/dundie/cli.py b/dundie/cli.py index e69de29..28e46e7 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -0,0 +1,122 @@ +import typer +from rich.console import Console +from rich.table import Table +from sqlmodel import Session, select + +from .config import settings +from .db import SQLModel, engine +from .models import User +from .models.transaction import Balance, Transaction +from .models.user import generate_username +from .tasks.transaction import add_transaction + +main = typer.Typer(name="dundie CLI", add_completion=False) + + +@main.command() +def shell() -> None: # pyright: ignore + """Opens interactive shell""" + _vars = { + "settings": settings, + "engine": engine, + "select": select, + "session": Session(engine), + "User": User, + "Transaction": Transaction, + "Balance": Balance, + "add_transaction": add_transaction, + } + typer.echo(f"Auto imports: {list(_vars.keys())}") + try: + from IPython import start_ipython + + start_ipython(argv=["--ipython-dir=/tmp", "--no-banner"], user_ns=_vars) # type: ignore + except ImportError: + import code + + code.InteractiveConsole(_vars).interact() + + +@main.command() +def user_list() -> None: # pyright: ignore + """Lists all users""" + table = Table(title="dundie users") + fields = ["name", "username", "dept", "email", "currency"] + for header in fields: + table.add_column(header, style="magenta") + + with Session(engine) as session: + users = session.exec(select(User)) + for user in users: + table.add_row(*[getattr(user, field) for field in fields]) + + Console().print(table) + + +@main.command() +def create_user( + name: str, + email: str, + password: str, + dept: str, + username: str | None = None, + currency: str = "USD", +) -> User: + """Create user""" + with Session(engine) as session: + user = User( + name=name, + email=email, + password=password, # pyright: ignore + dept=dept, + username=username or generate_username(name), + currency=currency, + ) + session.add(user) + session.commit() + session.refresh(user) + typer.echo(f"created {user.username} user") + return user # pyright: ignore + + +@main.command() +def transaction( + username: str, + value: int, +): + """Adds specified value to the user""" + + table = Table(title="Transaction") + fields = ["user", "before", "after"] + for header in fields: + table.add_column(header, style="magenta") + + with Session(engine) as session: + from_user = session.exec(select(User).where(User.username == "admin")).first() + if not from_user: + typer.echo("admin user not found") + exit(1) + user = session.exec(select(User).where(User.username == username)).first() + if not user: + typer.echo(f"user {username} not found") + exit(1) + + from_user_before = from_user.balance + user_before = user.balance + add_transaction(user=user, from_user=from_user, session=session, value=value) + table.add_row(from_user.username, str(from_user_before), str(from_user.balance)) + table.add_row(user.username, str(user_before), str(user.balance)) + + Console().print(table) + + +@main.command() +def reset_db( + force: bool = typer.Option( + False, "--force", "-f", help="Run with no confirmation" + ) +): + """Resets the database tables""" + force = force or typer.confirm("Are you sure?") + if force: + SQLModel.metadata.drop_all(engine) diff --git a/dundie/config.py b/dundie/config.py index 6fb5a5b..5c67a1f 100644 --- a/dundie/config.py +++ b/dundie/config.py @@ -1,7 +1,7 @@ """Settings module""" import os -from dynaconf import Dynaconf +from dynaconf import Dynaconf, Validator HERE = os.path.dirname(os.path.abspath(__file__)) @@ -13,3 +13,9 @@ env_switcher="dundie_env", load_dotenv=False, ) + +settings.validators.register( # pyright: ignore + Validator("security.SECRET_KEY", must_exist=True, is_type_of=str), +) # type: ignore + +settings.validators.validate() # type: ignore diff --git a/dundie/db.py b/dundie/db.py index e69de29..ef38f00 100644 --- a/dundie/db.py +++ b/dundie/db.py @@ -0,0 +1,19 @@ +"""Database connection""" +from fastapi import Depends +from sqlmodel import Session, SQLModel, create_engine + +from .config import settings + +engine = create_engine( + settings.db.uri, # pyright: ignore + echo=settings.db.echo, # pyright: ignore + connect_args=settings.db.connect_args, # pyright: ignore +) + + +def get_session(): + with Session(engine) as session: + yield session + + +ActiveSession = Depends(get_session) diff --git a/dundie/default.toml b/dundie/default.toml index 0949a4c..1defdd3 100644 --- a/dundie/default.toml +++ b/dundie/default.toml @@ -2,5 +2,26 @@ [default.db] uri = "" -connect_args = {check_same_thread=false} +connect_args = { check_same_thread = false } echo = false + +[default.security] +# Secret key is set on .secrets.toml or via envvar `DUNDIE_SECRET_KEY` = ... +# SECRET_KEY = " " +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +REFRESH_TOKEN_EXPIRE_MINUTES = 600 +RESET_TOKEN_EXPIRE_MINUTES = 10 +PWD_RESET_URL = "https://dm.com/reset_password" + +[default.email] +debug_mode = true +smtp_sender = "no-reply@dm.com" +smtp_server = "localhost" +smtp_port = 1025 +smtp_user = "" +smtp_password = "" + +[default.redis] +host = "redis" +port = 6379 \ No newline at end of file diff --git a/dundie/models/__init__.py b/dundie/models/__init__.py index e69de29..4ca419b 100644 --- a/dundie/models/__init__.py +++ b/dundie/models/__init__.py @@ -0,0 +1,6 @@ +from sqlmodel import SQLModel + +from .transaction import Transaction, Balance +from .user import User + +__all__ = ["User", "SQLModel", "Transaction", "Balance"] diff --git a/dundie/models/serializers.py b/dundie/models/serializers.py new file mode 100644 index 0000000..4eaf1a6 --- /dev/null +++ b/dundie/models/serializers.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, root_validator +from sqlmodel import Session + +from dundie.db import engine + +from .user import User + + +class TransactionResponse(BaseModel): # , extra="allow" retorna camopos extras + id: int + value: int + date: datetime + + # These 2 fields will be calculated at response time. + user: Optional[str] = None + from_user: Optional[str] = None + + @root_validator(pre=True) + def get_usernames(cls, values: dict): + with Session(engine) as session: + user = session.get(User, values["user_id"]) + values["user"] = user and user.username + from_user = session.get(User, values["from_id"]) + values["from_user"] = from_user and from_user.username + return values diff --git a/dundie/models/transaction.py b/dundie/models/transaction.py index e69de29..43ef3f3 100644 --- a/dundie/models/transaction.py +++ b/dundie/models/transaction.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from dundie.models.user import User + + +class Transaction(SQLModel, table=True): + """Represents the Transaction Model""" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", nullable=False) + from_id: int = Field(foreign_key="user.id", nullable=False) + value: int = Field(nullable=False) + date: datetime = Field(default_factory=datetime.utcnow, nullable=False) + + # Populates a `.incomes` on `User` + user: Optional["User"] = Relationship( + back_populates="incomes", + sa_relationship_kwargs={"primaryjoin": 'Transaction.user_id == User.id'}, + ) + # Populates a `.expenses` on `User` + from_user: Optional["User"] = Relationship( + back_populates="expenses", + sa_relationship_kwargs={"primaryjoin": 'Transaction.from_id == User.id'}, + ) + + +class Balance(SQLModel, table=True): + """Store the balance of a user account""" + + user_id: int = Field( + foreign_key="user.id", + nullable=False, + primary_key=True, + unique=True, + ) + value: int = Field(nullable=False) + updated_at: datetime = Field( + default_factory=datetime.utcnow, + nullable=False, + sa_column_kwargs={"onupdate": datetime.utcnow} + ) + + # Populates a `._balance` on `User` + user: Optional["User"] = Relationship(back_populates="_balance") diff --git a/dundie/models/user.py b/dundie/models/user.py index e69de29..8fe8687 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -0,0 +1,133 @@ +"""User related data models""" +from typing import TYPE_CHECKING, Optional + +from fastapi import HTTPException, status +from pydantic import BaseModel, root_validator +from sqlmodel import Field, Relationship, SQLModel + +from dundie.security import HashedPassword, get_password_hash + +if TYPE_CHECKING: + from dundie.models.transaction import Balance, Transaction + + +class User(SQLModel, table=True): + """Represents the User Model""" + + id: Optional[int] = Field(default=None, primary_key=True) + email: str = Field(unique=True, nullable=False) + username: str = Field(unique=True, nullable=False) + avatar: Optional[str] = None + bio: Optional[str] = None + password: HashedPassword + name: str = Field(nullable=False) + dept: str = Field(nullable=False) + currency: str = Field(nullable=False) + + # Populates a `.user` on `Transaction` + incomes: Optional[list["Transaction"]] = Relationship( + back_populates="user", + sa_relationship_kwargs={"primaryjoin": 'User.id == Transaction.user_id'}, + ) + # Populates a `.from_user` on `Transaction` + expenses: Optional[list["Transaction"]] = Relationship( + back_populates="from_user", + sa_relationship_kwargs={"primaryjoin": 'User.id == Transaction.from_id'}, + ) + # Populates a `.user` on `Balance` + _balance: Optional["Balance"] = Relationship( + back_populates="user", sa_relationship_kwargs={"lazy": "dynamic"} + ) + + @property + def balance(self) -> int: + """Returns the current balance of the user""" + if (user_balance := self._balance.first()) is not None: # pyright: ignore + return user_balance.value + return 0 + + @property + def superuser(self) -> bool: + """Users belonging to management dept are admins.""" + return self.dept == "management" + + +def generate_username(name: str) -> str: + """Generate a slug from user.name. + "Michael Scott" -> "michael-scott" + """ + + return name.lower().replace(" ", "-") + + +class UserResponse(BaseModel): + """Serializer for User Response""" + + name: str + username: str + dept: str + avatar: Optional[str] = None + bio: Optional[str] = None + currency: str + + +class UserResponseWithBalance(UserResponse): + balance: Optional[int] = None + + @root_validator(pre=True) + def set_balance(cls, values: dict): + """Sets the balance of the user""" + instance = values["_sa_instance_state"].object + values["balance"] = instance.balance + return values + + +class UserRequest(BaseModel): + """Serializer for User request payload""" + + name: str + email: str + dept: str + password: str + username: Optional[str] = None + avatar: Optional[str] = None + bio: Optional[str] = None + currency: str = "USD" + + @root_validator(pre=True) + def generate_username_if_not_set(cls, values: dict[str, str]) -> dict[str, str]: + """Generates username if not set""" + if values.get("username") is None: + values["username"] = generate_username(values["name"]) + return values + + +class UserProfilePatchRequest(BaseModel): + """Serializer for when client wants to partilly update a user profile user.""" + + avatar: Optional[str] = None + bio: Optional[str] = None + + @root_validator(pre=True) + def ensure_values(cls, values): + if not values: + raise HTTPException(status_code=400, detail="Bad request, no data informed") + return values + + +class UserPasswordPatchRequest(BaseModel): + password: str + password_confirm: str + + @root_validator(pre=True) + def check_password_match(cls, values): + """Checks if passwords matche""" + if values.get("password") != values.get("password_confirm"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Passwords do not match" + ) + return values + + @property + def hashed_password(self) -> str: + return get_password_hash(self.password) diff --git a/dundie/queue.py b/dundie/queue.py new file mode 100644 index 0000000..931db44 --- /dev/null +++ b/dundie/queue.py @@ -0,0 +1,11 @@ +from redis import Redis +from rq import Queue + +from dundie.config import settings + +redis = Redis( + host=settings.redis.host, + port=settings.redis.port, +) + +queue = Queue(connection=redis) diff --git a/dundie/routes/__init__.py b/dundie/routes/__init__.py index e69de29..e746c52 100644 --- a/dundie/routes/__init__.py +++ b/dundie/routes/__init__.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from .auth import router as auth_router +from .user import router as user_router +from .transaction import router as transaction_router + +main_router = APIRouter() + +main_router.include_router(user_router, prefix="/user", tags=["user"]) +main_router.include_router(transaction_router, prefix="/transaction", tags=["transaction"]) +main_router.include_router(auth_router, tags=["auth"]) diff --git a/dundie/routes/auth.py b/dundie/routes/auth.py index e69de29..1439661 100644 --- a/dundie/routes/auth.py +++ b/dundie/routes/auth.py @@ -0,0 +1,74 @@ +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + +from dundie.auth import ( + RefreshToken, + Token, + User, + authenticate_user, + create_access_token, + create_refresh_token, + get_user, + validate_token, +) +from dundie.config import settings + +ACCESS_TOKEN_EXPIRE_MINUTES = settings.security.access_token_expire_minutes # pyright: ignore +REFRESH_TOKEN_EXPIRE_MINUTES = settings.security.refresh_token_expire_minutes # pyright: ignore + + +router = APIRouter() + + +@router.post("/token", response_model=Token) +async def login_for_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), +): + user = authenticate_user(get_user, form_data.username, form_data.password) + if not user or not isinstance(user, User): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) # pyright: ignore + access_token = create_access_token( + data={"sub": user.username, "fresh": True}, + expires_delta=access_token_expires, + ) + + refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) # pyright: ignore + refresh_token = create_refresh_token( + data={"sub": user.username}, expires_delta=refresh_token_expires + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + + +@router.post("/refresh_token", response_model=Token) +async def refresh_token(form_data: RefreshToken): + user = await validate_token(token=form_data.refresh_token) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) # pyright: ignore + access_token = create_access_token( + data={"sub": user.username, "fresh": False}, + expires_delta=access_token_expires, + ) + + refresh_token_expires = timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES) # pyright: ignore + refresh_token = create_refresh_token( + data={"sub": user.username}, expires_delta=refresh_token_expires + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } diff --git a/dundie/routes/transaction.py b/dundie/routes/transaction.py index e69de29..643798e 100644 --- a/dundie/routes/transaction.py +++ b/dundie/routes/transaction.py @@ -0,0 +1,78 @@ +from typing import Optional + +from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi_pagination import Page, Params +from fastapi_pagination.ext.sqlmodel import paginate +from sqlalchemy.orm import aliased +from sqlmodel import Session, select, text + +from dundie.auth import AuthenticatedUser +from dundie.db import ActiveSession +from dundie.models import Transaction, User +from dundie.models.serializers import TransactionResponse +from dundie.tasks.transaction import TransactionError, add_transaction + +router = APIRouter() + + +@router.post('/{username}/', status_code=201) +async def create_transaction( + *, + username: str, + value: int = Body(embed=True), + current_user: User = AuthenticatedUser, + session: Session = ActiveSession +): + """Adds a new transaction to the specified user.""" + user = session.exec(select(User).where(User.username == username)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + try: + add_transaction(user=user, from_user=current_user, value=value, session=session) + except TransactionError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # At this point there was no error, so we can return + return {"message": "Transaction added"} + + +@router.get("/", response_model=Page[TransactionResponse]) +async def list_transactions( + *, + current_user: User = AuthenticatedUser, + session: Session = ActiveSession, + params: Params = Depends(), + from_user: Optional[str] = None, + user: Optional[str] = None, + order_by: Optional[str] = None, +): + query = select(Transaction) + + # Optional `AND` filters + if user: + query = query.join( + User, Transaction.user_id == User.id + ).where(User.username == user) + if from_user: + FromUser = aliased(User) # aliased needed to desambiguous the join + query = query.join( + FromUser, Transaction.from_id == FromUser.id + ).where(FromUser.username == from_user) + + # Mandatory access filter + # regular users can only see their own transactions + if not current_user.superuser: + query = query.where( + (Transaction.user_id == current_user.id) | (Transaction.from_id == current_user.id) + ) + + # Ordering based on &order_by=date (asc) or -date (desc) + if order_by: + order_text = text( + order_by.replace("-", "") + " " + ("desc" if "-" in order_by else "asc") + ) + query = query.order_by(order_text) + + # wrap response_model in a pagination object {"items": [], total, page, size } + return paginate(query=query, session=session, params=params) diff --git a/dundie/routes/user.py b/dundie/routes/user.py index e69de29..ef5cec6 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -0,0 +1,138 @@ +from typing import List + +from fastapi import APIRouter, BackgroundTasks, Body +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import HTTPException +from fastapi.responses import JSONResponse +from pydantic import parse_obj_as +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from dundie.auth import AuthenticatedUser, CanChangeUserPassword, ShowBalanceField, SuperUser +from dundie.db import ActiveSession +from dundie.models.user import ( + User, + UserPasswordPatchRequest, + UserProfilePatchRequest, + UserRequest, + UserResponse, + UserResponseWithBalance, +) +from dundie.queue import queue +from dundie.tasks.user import try_to_send_pwd_reset_email + +router = APIRouter() + + +@router.get("/", + response_model=List[UserResponse] | List[UserResponseWithBalance], + response_model_exclude_unset=True, + # dependencies=[AuthenticatedUser], + ) +async def list_users( + *, + session: Session = ActiveSession, + show_balance_field: bool = ShowBalanceField, +): + """List all users.""" + users = session.exec(select(User)).all() + # TODO: pagination + + if show_balance_field: + users_with_balance = parse_obj_as(List[UserResponseWithBalance], users) + return JSONResponse(jsonable_encoder(users_with_balance)) + + return users + + +@router.get( + "/{username}/", + response_model=UserResponse | UserResponseWithBalance, + response_model_exclude_unset=True +) +async def get_user_by_username( + *, + session: Session = ActiveSession, + show_balance_field: bool = ShowBalanceField, + username: str +): + """Get single user by username""" + query = select(User).where(User.username == username) + user = session.exec(query).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if show_balance_field: + user_with_balance = parse_obj_as(UserResponseWithBalance, user) + return JSONResponse(jsonable_encoder(user_with_balance)) + return user + + +@router.post("/", response_model=UserResponse, status_code=201, dependencies=[SuperUser]) +async def create_user(*, session: Session = ActiveSession, user: UserRequest) -> User: + """Creates a new user""" + if session.exec(select(User).where(User.email == user.email)).first(): + raise HTTPException(status_code=409, detail="User email already exists.") + + db_user = User.from_orm(user) + session.add(db_user) + try: + session.commit() + except IntegrityError: + raise HTTPException(status_code=500, detail="Database IntegrityError") + session.refresh(db_user) + return db_user + + +@router.patch("/{username}/", response_model=UserResponse) +async def update_user( + *, + session: Session = ActiveSession, + patch_data: UserProfilePatchRequest, + current_user: User = AuthenticatedUser, + username: str, +) -> User: + user = session.exec(select(User).where(User.username == username)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id != current_user.id and not current_user.superuser: + raise HTTPException(status_code=403, detail="You can only update your own profile") + + if patch_data.avatar is not None: + user.avatar = patch_data.avatar + + if patch_data.bio is not None: + user.bio = patch_data.bio + + session.add(user) + session.commit() + session.refresh(user) + return user + + +@router.post("/{username}/password", response_model=UserResponse) +async def change_password( + *, + session: Session = ActiveSession, + patch_data: UserPasswordPatchRequest, + user: User = CanChangeUserPassword, +) -> User: + user.password = patch_data.hashed_password # pyright: ignore + session.add(user) + session.commit() + session.refresh(user) + return user + + +@router.post("/pwd_reset_token/") +async def send_password_reset_token( + *, email: str = Body(embed=True), + background_tasks: BackgroundTasks, +): + """Sends an email with the token to reset password.""" + # background_tasks.add_task(try_to_send_pwd_reset_email, email=email) # NEW + queue.enqueue(try_to_send_pwd_reset_email, email=email) + return { + "message": "If we found a user with that email, we sent a password reset token to it." + } diff --git a/dundie/security.py b/dundie/security.py index e69de29..5050534 100644 --- a/dundie/security.py +++ b/dundie/security.py @@ -0,0 +1,43 @@ +"""Security utilities""" +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password, hashed_password) -> bool: + """Verifies a hash against a password""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password) -> str: + """Generates a hash from plain text""" + return pwd_context.hash(password) + + +class HashedPassword(str): + """Takes a plain text password and hashes it. + use this as a field in your SQLModel + class User(SQLModel, table=True): + username: str + password: HashedPassword + """ + + @classmethod + def __get_validators__(cls): + # one or more validators may be yielded which will be called in the + # order to validate the input, each validator will receive as an input + # the value returned from the previous validator + yield cls.validate + + @classmethod + def validate(cls, v): + """Accepts a plain text password and returns a hashed password.""" + if not isinstance(v, str): + raise TypeError("string required") + + hashed_password = get_password_hash(v) + # you could also return a string here which would mean model.password + # would be a string, pydantic won't care but you could end up with some + # confusion since the value's type won't match the type annotation + # exactly + return cls(hashed_password) diff --git a/dundie/tasks/transaction.py b/dundie/tasks/transaction.py index e69de29..4efa125 100644 --- a/dundie/tasks/transaction.py +++ b/dundie/tasks/transaction.py @@ -0,0 +1,41 @@ +from typing import Optional + +from sqlmodel import Session + +from dundie.db import engine +from dundie.models import User, Transaction, Balance + + +class TransactionError(Exception): + """Can't add transaction""" + + +def add_transaction(*, user: User, from_user: User, value: int, session: Optional[Session] = None): + """Adds a new transaction to the specified user. + + params: + user: The user to add transaction to. + from_user: The user where amount is coming from or superuser + value: The value being added + """ + if not from_user.superuser and from_user.balance < value: + raise TransactionError("Insufficient balance") + + session = session or Session(engine) + + transaction = Transaction(user=user, from_user=from_user, value=value) # pyright: ignore + session.add(transaction) + session.commit() + session.refresh(user) + session.refresh(from_user) + + for holder in (user, from_user): + total_income = sum([t.value for t in holder.incomes]) # pyright: ignore + total_expense = sum([t.value for t in holder.expenses]) # pyright: ignore + balance = session.get(Balance, holder.id) or Balance( + user=holder, value=0 + ) # pyright: ignore + balance.value = total_income - total_expense + session.add(balance) + + session.commit() diff --git a/dundie/tasks/user.py b/dundie/tasks/user.py index e69de29..d04c10b 100644 --- a/dundie/tasks/user.py +++ b/dundie/tasks/user.py @@ -0,0 +1,78 @@ +import smtplib +from datetime import timedelta +from time import sleep + +from sqlmodel import Session, select + +from dundie.auth import create_access_token +from dundie.config import settings +from dundie.db import engine +from dundie.models.user import User + + +def send_email(email: str, message: str): + if settings.email.debug_mode is True: # pyright: ignore + _send_email_debug(email, message) + else: + _send_email_smtp(email, message) + + +def _send_email_debug(email: str, message: str): + """Mock email sending by printing to a file""" + with open("email.log", "a") as f: + sleep(3) # pretend it takes 3 seconds + f.write(f"--- START EMAIL {email} ---\n" f"{message}\n" "--- END OF EMAIL ---\n") + + +def _send_email_smtp(email: str, message: str): + """Connect to SMTP server and send email""" + with smtplib.SMTP_SSL( + settings.email.smtp_server, settings.email.smtp_port # pyright: ignore # pyright: ignore + ) as server: + server.login(settings.email.smtp_user, settings.email.smtp_password) # pyright: ignore + server.sendmail( + settings.email.smtp_sender, # pyright: ignore + email, + message.encode("utf8"), + ) + + +MESSAGE = """\ +From: Dundie <{sender}> +To: {to} +Subject: Password reset for Dundie + +Please use the following link to reset your password: +{url}?pwd_reset_token={pwd_reset_token} + +This link will expire in {expire} minutes.I +""" + + +def try_to_send_pwd_reset_email(email): + """Given an email address sends email if user is found""" + with Session(engine) as session: + user = session.exec(select(User).where(User.email == email)).first() + if not user: + return + + sender = settings.email.smtp_sender # pyright: ignore + url = settings.security.PWD_RESET_URL # pyright: ignore + expire = settings.security.RESET_TOKEN_EXPIRE_MINUTES # pyright: ignore + + pwd_reset_token = create_access_token( + data={"sub": user.username}, + expires_delta=timedelta(minutes=expire), # pyright: ignore + scope="pwd_reset", + ) + + send_email( + email=user.email, + message=MESSAGE.format( + sender=sender, + to=user.email, + url=url, + pwd_reset_token=pwd_reset_token, + expire=expire, + ), + ) diff --git a/email.log b/email.log new file mode 100644 index 0000000..85e8c2c --- /dev/null +++ b/email.log @@ -0,0 +1,11 @@ +--- START EMAIL bruno@dm.com --- +From: Dundie +To: bruno@dm.com +Subject: Password reset for Dundie + +Please use the following link to reset your password: +https://dm.com/reset_password?pwd_reset_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJicnVuby1iZXJlb2ZmIiwiZXhwIjoxNjkyNTU0OTgzLCJzY29wZSI6InB3ZF9yZXNldCJ9.3l3z2wzut6AN8ZQJSNt9jQi8WLgBMr7WEVSOuD3goG4 + +This link will expire in 10 minutes.I + +--- END OF EMAIL --- diff --git a/magrations/README b/magrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/magrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/magrations/env.py b/magrations/env.py new file mode 100644 index 0000000..2fb116e --- /dev/null +++ b/magrations/env.py @@ -0,0 +1,74 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from dundie import models +from dundie.config import settings +from dundie.db import engine + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = models.SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = settings.db.uri # pyright: ignore + context.configure( + url=url, # pyright: ignore + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/magrations/script.py.mako b/magrations/script.py.mako new file mode 100644 index 0000000..3124b62 --- /dev/null +++ b/magrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/magrations/versions/11acfd7547d8_ensure_admin_user.py b/magrations/versions/11acfd7547d8_ensure_admin_user.py new file mode 100644 index 0000000..7d4292a --- /dev/null +++ b/magrations/versions/11acfd7547d8_ensure_admin_user.py @@ -0,0 +1,44 @@ +"""ensure_admin_user + +Revision ID: 11acfd7547d8 +Revises: 1b27082555a1 +Create Date: 2023-08-27 22:57:08.179796 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + +from dundie.models.user import User +from sqlmodel import Session + + +# revision identifiers, used by Alembic. +revision = '11acfd7547d8' +down_revision = '1b27082555a1' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + session = Session(bind=bind) + + admin = User( + name="Admin", + username="admin", + email="admin@dm.com", + dept="management", + password="admin", # envvar/secrests - colocar password em settings + currency="USD" + ) + + try: + session.add(admin) + session.commit() + except sa.exc.IntegrityError: + session.rollback() + + +def downgrade() -> None: + pass diff --git a/magrations/versions/1b27082555a1_transaction.py b/magrations/versions/1b27082555a1_transaction.py new file mode 100644 index 0000000..857871e --- /dev/null +++ b/magrations/versions/1b27082555a1_transaction.py @@ -0,0 +1,47 @@ +"""transaction + +Revision ID: 1b27082555a1 +Revises: 3b6b0d4bc044 +Create Date: 2023-08-23 23:19:17.591241 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision = '1b27082555a1' +down_revision = '3b6b0d4bc044' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('balance', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('value', sa.Integer(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('user_id'), + sa.UniqueConstraint('user_id') + ) + op.create_table('transaction', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('from_id', sa.Integer(), nullable=False), + sa.Column('value', sa.Integer(), nullable=False), + sa.Column('date', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['from_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('transaction') + op.drop_table('balance') + # ### end Alembic commands ### diff --git a/magrations/versions/3b6b0d4bc044_initial.py b/magrations/versions/3b6b0d4bc044_initial.py new file mode 100644 index 0000000..0ddf5e8 --- /dev/null +++ b/magrations/versions/3b6b0d4bc044_initial.py @@ -0,0 +1,42 @@ +"""initial + +Revision ID: 3b6b0d4bc044 +Revises: +Create Date: 2023-03-12 18:10:15.311601 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision = '3b6b0d4bc044' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('avatar', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('dept', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('currency', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user') + # ### end Alembic commands ### diff --git a/requirements.in b/requirements.in index dbf2283..c3aba43 100644 --- a/requirements.in +++ b/requirements.in @@ -11,3 +11,4 @@ psycopg2-binary # Database Driver alembic # Database Migrations rich # Terminal formatting fastapi-pagination # Pagination +rq # Task queue diff --git a/requirements.txt b/requirements.txt index 2681f3c..72e7dcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,33 +4,34 @@ # # pip-compile requirements.in # -alembic==1.8.1 +alembic==1.10.3 # via -r requirements.in anyio==3.6.2 # via starlette +async-timeout==4.0.3 + # via redis bcrypt==4.0.1 # via passlib cffi==1.15.1 # via cryptography click==8.1.3 # via + # rq # typer # uvicorn -commonmark==0.9.1 - # via rich -cryptography==38.0.4 +cryptography==40.0.2 # via python-jose -dynaconf==3.1.11 +dynaconf==3.1.12 # via -r requirements.in ecdsa==0.18.0 # via python-jose -fastapi==0.88.0 +fastapi==0.95.1 # via # -r requirements.in # fastapi-pagination -fastapi-pagination==0.11.2 +fastapi-pagination==0.12.2 # via -r requirements.in -greenlet==2.0.1 +greenlet==2.0.2 # via sqlalchemy h11==0.14.0 # via uvicorn @@ -40,56 +41,63 @@ jinja2==3.1.2 # via -r requirements.in mako==1.2.4 # via alembic -markupsafe==2.1.1 +markdown-it-py==2.2.0 + # via rich +markupsafe==2.1.2 # via # jinja2 # mako +mdurl==0.1.2 + # via markdown-it-py passlib[bcrypt]==1.7.4 # via -r requirements.in -psycopg2-binary==2.9.5 +psycopg2-binary==2.9.6 # via -r requirements.in -pyasn1==0.4.8 +pyasn1==0.5.0 # via # python-jose # rsa pycparser==2.21 # via cffi -pydantic==1.10.2 +pydantic==1.10.7 # via # fastapi # fastapi-pagination # sqlmodel -pygments==2.13.0 +pygments==2.15.1 # via rich python-jose[cryptography]==3.3.0 # via -r requirements.in -python-multipart==0.0.5 +python-multipart==0.0.6 + # via -r requirements.in +redis==5.0.0 + # via rq +rich==13.3.4 # via -r requirements.in -rich==12.6.0 +rq==1.15.1 # via -r requirements.in rsa==4.9 # via python-jose six==1.16.0 - # via - # ecdsa - # python-multipart + # via ecdsa sniffio==1.3.0 # via anyio sqlalchemy==1.4.41 # via # alembic # sqlmodel -sqlalchemy2-stubs==0.0.2a29 +sqlalchemy2-stubs==0.0.2a34 # via sqlmodel sqlmodel==0.0.8 # via -r requirements.in -starlette==0.22.0 +starlette==0.26.1 # via fastapi typer==0.7.0 # via -r requirements.in -typing-extensions==4.4.0 +typing-extensions==4.5.0 # via + # alembic # pydantic # sqlalchemy2-stubs -uvicorn==0.20.0 +uvicorn==0.21.1 # via -r requirements.in diff --git a/test.sh b/test.sh index b031e4b..9b9f6ad 100755 --- a/test.sh +++ b/test.sh @@ -1,20 +1,20 @@ #!/usr/bin/bash -# Start environment with docker-compose -DUNDIE_DB=dundie_test docker-compose up -d +# Start environment with docker compose +DUNDIE_DB=dundie_test docker compose up -d # wait 5 seconds sleep 5 # Ensure database is clean -docker-compose exec api dundie reset-db -f -docker-compose exec api alembic stamp base +docker compose exec api dundie reset-db -f +docker compose exec api alembic stamp base # run migrations -docker-compose exec api alembic upgrade head +docker compose exec api alembic upgrade head # run tests -docker-compose exec api pytest -v -l --tb=short --maxfail=1 tests/ +docker compose exec api pytest -v -l --tb=short --maxfail=1 tests/ # Stop environment -docker-compose down +docker compose down diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..0853c97 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +import os + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import IntegrityError + +from dundie.app import app +from dundie.cli import create_user + +os.environ["DUNDIE_DB__uri"] = "postgresql://postgres:postgres@db:5432/dundie_test" + + +@pytest.fixture(scope="function") +def api_client(): + """Unauthenticated test client""" + return TestClient(app) + + +def create_api_client_authenticated(username, dept="sales", create=True): + """Creates a new api client authenticated for the specified user.""" + if create: + try: + create_user(name=username, email=f"{username}@dm.com", password=username, dept=dept) + except IntegrityError: + pass + + client = TestClient(app) + token = client.post( + "/token", + data={"username": username, "password": username}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ).json()["access_token"] + client.headers["Authorization"] = f"Bearer {token}" + return client + + +@pytest.fixture(scope="function") +def api_client_admin(): + return create_api_client_authenticated("admin", create=False) + + +@pytest.fixture(scope="function") +def api_client_user1(): + return create_api_client_authenticated("user1", dept="management") + + +@pytest.fixture(scope="function") +def api_client_user2(): + return create_api_client_authenticated("user2") + + +@pytest.fixture(scope="function") +def api_client_user3(): + return create_api_client_authenticated("user3") diff --git a/tests/test_api.py b/tests/test_api.py index e69de29..218974d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -0,0 +1,113 @@ +import pytest + +USER_RESPONSE_KEYS = {"name", "username", "dept", "avatar", "bio", "currency"} +USER_RESPONSE_WITH_BALANCE_KEYS = USER_RESPONSE_KEYS | {"balance"} + + +@pytest.mark.order(1) +def test_user_list( + api_client, + api_client_user1, # pyright: ignore + api_client_user2, # pyright: ignore + api_client_user3, # pyright: ignore +): + """Ensure that all needed users are created and showing on the /user/ API + + NOTE: user fixtures are called just to trigger creation of users. + """ + users = api_client.get("/user/").json() + expected_users = ["admin", "user1", "user2", "user3"] + assert len(users) == len(expected_users) + for user in users: + assert user["username"] in expected_users + assert user["dept"] in ["management", "sales"] + assert user["currency"] == "USD" + assert set(user.keys()) == USER_RESPONSE_KEYS + + +@pytest.mark.order(2) +def test_user_detail(api_client): + """Ensure that the /user/{username} API is working""" + user = api_client.get("/user/user1/").json() + assert user["username"] == "user1" + assert set(user.keys()) == USER_RESPONSE_KEYS + + +@pytest.mark.order(3) +def test_update_user_profile_by_admin(api_client_admin): + """Ensure that admin can patch any user data""" + data = {"avatar": "https://example.com/avatar.png", "bio": "I am a user1"} + api_client_admin.patch("/user/user1/", json=data) + user = api_client_admin.get("/user/user1/").json() + assert user["avatar"] == data["avatar"] + assert user["bio"] == data["bio"] + + +@pytest.mark.order(3) +def test_update_user_profile_by_user(api_client_user2): + """Ensure that user can patch their own data""" + data = {"avatar": "https://example.com/avatar.png", "bio": "I am a user2"} + api_client_user2.patch("/user/user2/", json=data) + user = api_client_user2.get("/user/user2/").json() + assert user["avatar"] == data["avatar"] + assert user["bio"] == data["bio"] + + +@pytest.mark.order(3) +def test_fail_update_user_profile_by_other_user(api_client_user2): + """User 2 will attempt to patch User 1 profile and it will fail""" + response = api_client_user2.patch("/user/user1/", json={'bio': 'testing'}) + assert response.status_code == 403 + + +@pytest.mark.order(4) +def test_add_transaction_for_users_from_admin(api_client_admin): + """Admin user adds a transaction for all users""" + usernames = ["user1", "user2", "user3"] + + for username in usernames: + api_client_admin.post(f"/transaction/{username}/", json={"value": 500}) + + for username in usernames: + user = api_client_admin.get(f"/user/{username}/?show_balance=true").json() + assert user["balance"] == 500 + + +@pytest.mark.order(5) +def test_user1_transfer_20_points_to_user2(api_client_user1): + """Ensure that user1 can transfer points to user2""" + api_client_user1.post("/transaction/user2/", json={"value": 20}) + user1 = api_client_user1.get("/user/user1/?show_balance=true").json() + assert user1["balance"] == 480 + + # user1 can see balance of user2 because user1 is a manager + user2 = api_client_user1.get("/user/user2/?show_balance=true").json() + assert user2["balance"] == 520 + + +@pytest.mark.order(6) +def test_user_list_with_balance(api_client_admin): + """Ensure that admin can see user balance""" + users = api_client_admin.get("/user/?show_balance=true").json() + expected_users = ["admin", "user1", "user2", "user3"] + assert len(users) == len(expected_users) + for user in users: + assert user["username"] in expected_users + assert set(user.keys()) == USER_RESPONSE_WITH_BALANCE_KEYS + + +@pytest.mark.order(6) +def test_admin_can_list_all_transactions(api_client_admin): + """Admin can list all transactions""" + transactions = api_client_admin.get("/transaction/").json() + assert transactions["total"] == 4 + + +@pytest.mark.order(6) +def test_regular_user_can_see_only_own_transaction(api_client_user3): + """Regular user can see only own transactions""" + transactions = api_client_user3.get("/transaction/").json() + assert transactions["total"] == 1 + assert transactions["items"][0]["value"] == 500 + assert transactions["items"][0]["user"] == "user3" + assert transactions["items"][0]["from_user"] == "admin"