From e956e10b5127afb92c4a9f7ab9c7459f3b1adbc2 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 12 Mar 2023 15:21:40 -0300 Subject: [PATCH 01/37] added: user model structure, migrations and settings --- .vscode/settings.json | 14 +++ alembic.ini | 105 ++++++++++++++++++++ dundie/VERSION.txt | 2 +- dundie/app.py | 7 ++ dundie/db.py | 10 ++ dundie/models/__init__.py | 5 + dundie/models/user.py | 23 +++++ magrations/README | 1 + magrations/env.py | 74 ++++++++++++++ magrations/script.py.mako | 25 +++++ magrations/versions/3b6b0d4bc044_initial.py | 42 ++++++++ 11 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json create mode 100644 alembic.ini create mode 100644 magrations/README create mode 100644 magrations/env.py create mode 100644 magrations/script.py.mako create mode 100644 magrations/versions/3b6b0d4bc044_initial.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0c29199 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "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/dundie/VERSION.txt b/dundie/VERSION.txt index 9082c78..aa0161a 100644 --- a/dundie/VERSION.txt +++ b/dundie/VERSION.txt @@ -1 +1 @@ -0.1.dev0 \ No newline at end of file +0.1.dev20+g91f7712.d20230312 \ No newline at end of file diff --git a/dundie/app.py b/dundie/app.py index e69de29..f72d07a 100644 --- a/dundie/app.py +++ b/dundie/app.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +app = FastAPI( + title="dundie", + version="0.1.0", + description="dundie is a rewards API", +) \ No newline at end of file diff --git a/dundie/db.py b/dundie/db.py index e69de29..63aa5da 100644 --- a/dundie/db.py +++ b/dundie/db.py @@ -0,0 +1,10 @@ +"""Database connection""" +from sqlmodel import 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 +) diff --git a/dundie/models/__init__.py b/dundie/models/__init__.py index e69de29..5ac4e63 100644 --- a/dundie/models/__init__.py +++ b/dundie/models/__init__.py @@ -0,0 +1,5 @@ +from sqlmodel import SQLModel + +from .user import User + +__all__ = ["User", "SQLModel"] diff --git a/dundie/models/user.py b/dundie/models/user.py index e69de29..f51f573 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -0,0 +1,23 @@ +"""User related data models""" +from typing import Optional + +from sqlmodel import Field, SQLModel + + +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: str = Field(nullable=False) + name: str = Field(nullable=False) + dept: str = Field(nullable=False) + currency: str = Field(nullable=False) + + @property + def superuser(self) -> bool: + """Users belonging to management dept are admins.""" + return self.dept == "management" 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/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 ### From 5e0ba051ed73a3ecf850523cb02cab4d4c044fea Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 12 Mar 2023 18:14:31 -0300 Subject: [PATCH 02/37] added: basic cli structure config --- dundie/cli.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/dundie/cli.py b/dundie/cli.py index e69de29..b72367d 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -0,0 +1,47 @@ +import typer +from rich.console import Console +from rich.table import Table +from sqlmodel import Session, select + +from .config import settings +from .db import engine +from .models import User + +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, + } + 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) From 4550fafe0bd004fedbb73c7523b6247e49bda6d2 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Mon, 17 Apr 2023 18:46:47 -0300 Subject: [PATCH 03/37] updated files --- dundie/config.py | 8 +++++++- dundie/default.toml | 7 +++++++ dundie/models/user.py | 12 +++++++++++- dundie/security.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/dundie/config.py b/dundie/config.py index 6fb5a5b..0439a79 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( + Validator("security.SECRET_KEY", must_exist=True, is_type_of=str), +) # type: ignore + +settings.validators.validate() # type: ignore diff --git a/dundie/default.toml b/dundie/default.toml index 0949a4c..1accb0d 100644 --- a/dundie/default.toml +++ b/dundie/default.toml @@ -4,3 +4,10 @@ uri = "" 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_EXPIRES_MINUTES = 600 \ No newline at end of file diff --git a/dundie/models/user.py b/dundie/models/user.py index f51f573..7cfc188 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -3,6 +3,8 @@ from sqlmodel import Field, SQLModel +from dundie.security import HashedPassword + class User(SQLModel, table=True): """Represents the User Model""" @@ -12,7 +14,7 @@ class User(SQLModel, table=True): username: str = Field(unique=True, nullable=False) avatar: Optional[str] = None bio: Optional[str] = None - password: str = Field(nullable=False) + password: HashedPassword name: str = Field(nullable=False) dept: str = Field(nullable=False) currency: str = Field(nullable=False) @@ -21,3 +23,11 @@ class User(SQLModel, table=True): 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(" ", "-") 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) From 2016a1d1c5bdbd9a5d03077a7cd0b820938472d3 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Tue, 18 Apr 2023 20:17:39 -0300 Subject: [PATCH 04/37] added: create_user function --- dundie/cli.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/dundie/cli.py b/dundie/cli.py index b72367d..a2eb613 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -6,6 +6,7 @@ from .config import settings from .db import engine from .models import User +from .models.user import generate_username main = typer.Typer(name="dundie CLI", add_completion=False) @@ -45,3 +46,29 @@ def user_list() -> None: # pyright: ignore 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 From a248617c3ebacf093eb859f1001911fbe75b407e Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Tue, 18 Apr 2023 20:57:56 -0300 Subject: [PATCH 05/37] added: UserRequest and UserResponse serializers --- dundie/models/user.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/dundie/models/user.py b/dundie/models/user.py index 7cfc188..3ef0ca4 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -1,6 +1,7 @@ """User related data models""" from typing import Optional +from pydantic import BaseModel, root_validator from sqlmodel import Field, SQLModel from dundie.security import HashedPassword @@ -31,3 +32,34 @@ def generate_username(name: str) -> str: """ 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 UserRequest(BaseModel): + """Serializer for User request payload""" + + name: str + email: str + dept: str + password: str + currency: str = "USD" + username: Optional[str] = None + avatar: Optional[str] = None + bio: Optional[str] = None + + @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 From d01917bcf7f1b0f106fb0ab38b81d2cff9deffb3 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 21 Apr 2023 11:55:20 -0300 Subject: [PATCH 06/37] fixes --- dundie/VERSION.txt | 2 +- dundie/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dundie/VERSION.txt b/dundie/VERSION.txt index aa0161a..a344ed5 100644 --- a/dundie/VERSION.txt +++ b/dundie/VERSION.txt @@ -1 +1 @@ -0.1.dev20+g91f7712.d20230312 \ No newline at end of file +0.1.dev25+ga248617.d20230421 \ No newline at end of file diff --git a/dundie/config.py b/dundie/config.py index 0439a79..5c67a1f 100644 --- a/dundie/config.py +++ b/dundie/config.py @@ -14,7 +14,7 @@ load_dotenv=False, ) -settings.validators.register( +settings.validators.register( # pyright: ignore Validator("security.SECRET_KEY", must_exist=True, is_type_of=str), ) # type: ignore From f6003b178e59744a2833e7d8c560e2be247f0771 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 21 Apr 2023 11:57:06 -0300 Subject: [PATCH 07/37] added: get session dependency --- dundie/db.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/dundie/db.py b/dundie/db.py index 63aa5da..1d66d5d 100644 --- a/dundie/db.py +++ b/dundie/db.py @@ -1,5 +1,6 @@ """Database connection""" -from sqlmodel import create_engine +from fastapi import Depends +from sqlmodel import Session, create_engine from .config import settings @@ -8,3 +9,11 @@ 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) From d8adf2860bc470b0cd4773893aabd0f3764e7dfe Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Wed, 16 Aug 2023 16:54:48 -0300 Subject: [PATCH 08/37] update: new version --- dundie/VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dundie/VERSION.txt b/dundie/VERSION.txt index a344ed5..38f29d6 100644 --- a/dundie/VERSION.txt +++ b/dundie/VERSION.txt @@ -1 +1 @@ -0.1.dev25+ga248617.d20230421 \ No newline at end of file +0.1.dev27+gf6003b1.d20230816 \ No newline at end of file From eea8f74662607aba55125f11af48c50e566194f3 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 21 Apr 2023 15:11:16 -0300 Subject: [PATCH 09/37] updated files --- dundie/app.py | 6 +++++- dundie/routes/user.py | 17 ++++++++++++++++ requirements.txt | 45 ++++++++++++++++++++++--------------------- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/dundie/app.py b/dundie/app.py index f72d07a..944b074 100644 --- a/dundie/app.py +++ b/dundie/app.py @@ -1,7 +1,11 @@ from fastapi import FastAPI +from dundie.routes.user import router as user_router + app = FastAPI( title="dundie", version="0.1.0", description="dundie is a rewards API", -) \ No newline at end of file +) + +app.include_router(router=user_router, prefix="/user", tags=['user']) diff --git a/dundie/routes/user.py b/dundie/routes/user.py index e69de29..1df7016 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -0,0 +1,17 @@ +from typing import List + +from fastapi import APIRouter +from fastapi.exceptions import HTTPException +from sqlmodel import Session, select + +from dundie.db import ActiveSession +from dundie.models.user import User, UserRequest, UserResponse + +router = APIRouter() + + +@router.get("/", response_model=List[UserResponse]) +async def list_users(*, session: Session = ActiveSession): + """List all users.""" + users = session.exec(select(User)).all() + return users diff --git a/requirements.txt b/requirements.txt index 2681f3c..68515a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile requirements.in # -alembic==1.8.1 +alembic==1.10.3 # via -r requirements.in anyio==3.6.2 # via starlette @@ -16,21 +16,19 @@ click==8.1.3 # via # 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 +38,59 @@ 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 -rich==12.6.0 +rich==13.3.4 # 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 From 8c81ce96de6d563f4b8ef0d37c390bf885a39913 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 21 Apr 2023 16:27:56 -0300 Subject: [PATCH 10/37] updated files --- dundie/app.py | 4 ++-- dundie/models/user.py | 2 +- dundie/routes/__init__.py | 7 +++++++ dundie/routes/user.py | 18 ++++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/dundie/app.py b/dundie/app.py index 944b074..c0a8d82 100644 --- a/dundie/app.py +++ b/dundie/app.py @@ -1,6 +1,6 @@ from fastapi import FastAPI -from dundie.routes.user import router as user_router +from dundie.routes import main_router app = FastAPI( title="dundie", @@ -8,4 +8,4 @@ description="dundie is a rewards API", ) -app.include_router(router=user_router, prefix="/user", tags=['user']) +app.include_router(main_router) diff --git a/dundie/models/user.py b/dundie/models/user.py index 3ef0ca4..3b119e2 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -52,10 +52,10 @@ class UserRequest(BaseModel): email: str dept: str password: str - currency: str = "USD" 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]: diff --git a/dundie/routes/__init__.py b/dundie/routes/__init__.py index e69de29..9e9e1ff 100644 --- a/dundie/routes/__init__.py +++ b/dundie/routes/__init__.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from .user import router as user_router + +main_router = APIRouter() + +main_router.include_router(user_router, prefix="/user", tags=["user"]) diff --git a/dundie/routes/user.py b/dundie/routes/user.py index 1df7016..eb09855 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -15,3 +15,21 @@ async def list_users(*, session: Session = ActiveSession): """List all users.""" users = session.exec(select(User)).all() return users + + +@router.get("/{username}/", response_model=UserResponse) +async def get_user_by_username(*, session: Session = ActiveSession, username: str): + """Get single user by username""" + query = select(User).where(User.username == username) + user = session.exec(query).first() + return user + + +@router.post("/", response_model=UserResponse, status_code=201) +async def create_user(*, session: Session = ActiveSession, user: UserRequest) -> User: + """Creates a new user""" + db_user = User.from_orm(user) + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user From 443aeaf3a6a5db449928c696e182fdd23a6a8820 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 23 Jun 2023 16:20:24 -0300 Subject: [PATCH 11/37] added auth flux --- dundie/auth.py | 120 ++++++++++++++++++++++++++++++++++++++ dundie/default.toml | 2 +- dundie/routes/__init__.py | 2 + dundie/routes/auth.py | 73 +++++++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) diff --git a/dundie/auth.py b/dundie/auth.py index e69de29..2d35351 100644 --- a/dundie/auth.py +++ b/dundie/auth.py @@ -0,0 +1,120 @@ +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(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 validate_token(token: str = Depends(oauth2_scheme)) -> User: + """Validates user token""" + user = get_current_user(token=token) + return user diff --git a/dundie/default.toml b/dundie/default.toml index 1accb0d..a758238 100644 --- a/dundie/default.toml +++ b/dundie/default.toml @@ -10,4 +10,4 @@ echo = false # SECRET_KEY = " " ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 -REFRESH_TOKEN_EXPIRES_MINUTES = 600 \ No newline at end of file +REFRESH_TOKEN_EXPIRE_MINUTES = 600 \ No newline at end of file diff --git a/dundie/routes/__init__.py b/dundie/routes/__init__.py index 9e9e1ff..124eee2 100644 --- a/dundie/routes/__init__.py +++ b/dundie/routes/__init__.py @@ -1,7 +1,9 @@ from fastapi import APIRouter +from .auth import router as auth_router from .user import router as user_router main_router = APIRouter() main_router.include_router(user_router, prefix="/user", tags=["user"]) +main_router.include_router(auth_router, tags=["auth"]) diff --git a/dundie/routes/auth.py b/dundie/routes/auth.py index e69de29..9535a6d 100644 --- a/dundie/routes/auth.py +++ b/dundie/routes/auth.py @@ -0,0 +1,73 @@ +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_EXPIRES_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", + } From 18f14e14a2e8f12a0d18c29d01fc5be2d6616d9b Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 23 Jun 2023 16:21:06 -0300 Subject: [PATCH 12/37] added auth flux --- dundie/routes/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dundie/routes/auth.py b/dundie/routes/auth.py index 9535a6d..1439661 100644 --- a/dundie/routes/auth.py +++ b/dundie/routes/auth.py @@ -16,7 +16,8 @@ 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_EXPIRES_MINUTES # pyright: ignore +REFRESH_TOKEN_EXPIRE_MINUTES = settings.security.refresh_token_expire_minutes # pyright: ignore + router = APIRouter() From 3a48cbd95b51da1220c15bb003ad50cc2192ad5b Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 30 Jun 2023 14:08:17 -0300 Subject: [PATCH 13/37] added protection to get users and create users --- dundie/auth.py | 12 +++++++++++- dundie/routes/user.py | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/dundie/auth.py b/dundie/auth.py index 2d35351..dd4f3d0 100644 --- a/dundie/auth.py +++ b/dundie/auth.py @@ -65,7 +65,7 @@ def authenticate_user( def get_user(username: str) -> Optional[User]: # TODO: move to utils module - query = select(User).where(username == username) + query = select(User).where(User.username == username) with Session(engine) as session: return session.exec(query).first() @@ -114,6 +114,16 @@ async def get_current_active_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""" + return current_user + + +SuperUser = Depends(get_current_active_user) + + async def validate_token(token: str = Depends(oauth2_scheme)) -> User: """Validates user token""" user = get_current_user(token=token) diff --git a/dundie/routes/user.py b/dundie/routes/user.py index eb09855..0576b52 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -4,13 +4,14 @@ from fastapi.exceptions import HTTPException from sqlmodel import Session, select +from dundie.auth import AuthenticatedUser, SuperUser from dundie.db import ActiveSession from dundie.models.user import User, UserRequest, UserResponse router = APIRouter() -@router.get("/", response_model=List[UserResponse]) +@router.get("/", response_model=List[UserResponse], dependencies=[AuthenticatedUser]) async def list_users(*, session: Session = ActiveSession): """List all users.""" users = session.exec(select(User)).all() @@ -25,7 +26,7 @@ async def get_user_by_username(*, session: Session = ActiveSession, username: st return user -@router.post("/", response_model=UserResponse, status_code=201) +@router.post("/", response_model=UserResponse, status_code=201, dependencies=[SuperUser]) async def create_user(*, session: Session = ActiveSession, user: UserRequest) -> User: """Creates a new user""" db_user = User.from_orm(user) From 7c3a66a38700c212aa46950cfff7f7b70ce77d0d Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 30 Jun 2023 14:16:28 -0300 Subject: [PATCH 14/37] fix: get_current_super_user func --- dundie/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dundie/auth.py b/dundie/auth.py index dd4f3d0..7b47d7f 100644 --- a/dundie/auth.py +++ b/dundie/auth.py @@ -118,10 +118,12 @@ 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_active_user) +SuperUser = Depends(get_current_super_user) async def validate_token(token: str = Depends(oauth2_scheme)) -> User: From c3ace15930cf56ff863d4b4815391498e4f95dc4 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 30 Jun 2023 14:44:09 -0300 Subject: [PATCH 15/37] added: integrety error treatment --- dundie/routes/user.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dundie/routes/user.py b/dundie/routes/user.py index 0576b52..a8547b3 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from fastapi.exceptions import HTTPException +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from dundie.auth import AuthenticatedUser, SuperUser @@ -29,8 +30,14 @@ async def get_user_by_username(*, session: Session = ActiveSession, username: st @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) - session.commit() + try: + session.commit() + except IntegrityError: + raise HTTPException(status_code=500, detail="Database IntegrityError") session.refresh(db_user) return db_user From d8d6becb0418c46a7a044da0f3cbee3fe5518d87 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Fri, 30 Jun 2023 16:30:02 -0300 Subject: [PATCH 16/37] added: change password flux --- dundie/auth.py | 43 +++++++++++++++++++++++++++++++++++++ dundie/models/user.py | 34 ++++++++++++++++++++++++++++- dundie/routes/user.py | 50 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/dundie/auth.py b/dundie/auth.py index 7b47d7f..9a77a40 100644 --- a/dundie/auth.py +++ b/dundie/auth.py @@ -130,3 +130,46 @@ 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) diff --git a/dundie/models/user.py b/dundie/models/user.py index 3b119e2..66ca120 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -1,10 +1,11 @@ """User related data models""" from typing import Optional +from fastapi import HTTPException, status from pydantic import BaseModel, root_validator from sqlmodel import Field, SQLModel -from dundie.security import HashedPassword +from dundie.security import HashedPassword, get_password_hash class User(SQLModel, table=True): @@ -63,3 +64,34 @@ def generate_username_if_not_set(cls, values: dict[str, str]) -> dict[str, str]: 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=404, 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/routes/user.py b/dundie/routes/user.py index a8547b3..2b36a4b 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -5,9 +5,15 @@ from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select -from dundie.auth import AuthenticatedUser, SuperUser +from dundie.auth import AuthenticatedUser, CanChangeUserPassword, SuperUser from dundie.db import ActiveSession -from dundie.models.user import User, UserRequest, UserResponse +from dundie.models.user import ( + User, + UserPasswordPatchRequest, + UserProfilePatchRequest, + UserRequest, + UserResponse, +) router = APIRouter() @@ -41,3 +47,43 @@ async def create_user(*, session: Session = ActiveSession, user: UserRequest) -> 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 From b8bee7f337dcf7726bd57e1755a3149391098519 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sat, 19 Aug 2023 09:01:11 -0300 Subject: [PATCH 17/37] update: new version --- dundie/VERSION.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dundie/VERSION.txt b/dundie/VERSION.txt index 38f29d6..a04f310 100644 --- a/dundie/VERSION.txt +++ b/dundie/VERSION.txt @@ -1 +1 @@ -0.1.dev27+gf6003b1.d20230816 \ No newline at end of file +0.1.dev28+gd8adf28.d20230817 \ No newline at end of file From 975a64ec4e186d010d7a21a93eec894173780edc Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sat, 19 Aug 2023 10:40:52 -0300 Subject: [PATCH 18/37] update: features --- .vscode/settings.json | 2 +- dundie/default.toml | 12 ++++++- dundie/routes/user.py | 15 ++++++++- dundie/tasks/user.py | 78 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0c29199..5e20450 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", + "editor.defaultFormatter": "ms-python.autopep8", "editor.formatOnSave": true }, "python.formatting.provider": "none", diff --git a/dundie/default.toml b/dundie/default.toml index a758238..d50ae6a 100644 --- a/dundie/default.toml +++ b/dundie/default.toml @@ -10,4 +10,14 @@ echo = false # SECRET_KEY = " " ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 -REFRESH_TOKEN_EXPIRE_MINUTES = 600 \ No newline at end of file +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 = "" \ No newline at end of file diff --git a/dundie/routes/user.py b/dundie/routes/user.py index 2b36a4b..0db6872 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -1,11 +1,12 @@ from typing import List -from fastapi import APIRouter +from fastapi import APIRouter, Body, BackgroundTasks from fastapi.exceptions import HTTPException from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from dundie.auth import AuthenticatedUser, CanChangeUserPassword, SuperUser +from dundie.tasks.user import try_to_send_pwd_reset_email from dundie.db import ActiveSession from dundie.models.user import ( User, @@ -87,3 +88,15 @@ async def change_password( 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 + return { + "message": "If we found a user with that email, we sent a password reset token to it." + } 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, + ), + ) From 570b98771163d0bd196b786add4890b8af2f7511 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 20 Aug 2023 15:12:32 -0300 Subject: [PATCH 19/37] added: queue email flux --- .gitignore | 1 + docker-compose.yaml | 26 +++++++++++++++++++++++++- dundie/default.toml | 8 ++++++-- dundie/queue.py | 11 +++++++++++ dundie/routes/user.py | 28 +++++++++++++++------------- email.log | 11 +++++++++++ requirements.in | 1 + requirements.txt | 7 +++++++ 8 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 dundie/queue.py create mode 100644 email.log 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/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/default.toml b/dundie/default.toml index d50ae6a..1defdd3 100644 --- a/dundie/default.toml +++ b/dundie/default.toml @@ -2,7 +2,7 @@ [default.db] uri = "" -connect_args = {check_same_thread=false} +connect_args = { check_same_thread = false } echo = false [default.security] @@ -20,4 +20,8 @@ smtp_sender = "no-reply@dm.com" smtp_server = "localhost" smtp_port = 1025 smtp_user = "" -smtp_password = "" \ No newline at end of file +smtp_password = "" + +[default.redis] +host = "redis" +port = 6379 \ No newline at end of file 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/user.py b/dundie/routes/user.py index 0db6872..d8be405 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -6,7 +6,6 @@ from sqlmodel import Session, select from dundie.auth import AuthenticatedUser, CanChangeUserPassword, SuperUser -from dundie.tasks.user import try_to_send_pwd_reset_email from dundie.db import ActiveSession from dundie.models.user import ( User, @@ -15,6 +14,8 @@ UserRequest, UserResponse, ) +from dundie.queue import queue +from dundie.tasks.user import try_to_send_pwd_reset_email router = APIRouter() @@ -52,11 +53,11 @@ async def create_user(*, session: Session = ActiveSession, user: UserRequest) -> @router.patch("/{username}/", response_model=UserResponse) async def update_user( - *, - session: Session = ActiveSession, - patch_data: UserProfilePatchRequest, - current_user: User = AuthenticatedUser, - username: str, + *, + 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: @@ -78,10 +79,10 @@ async def update_user( @router.post("/{username}/password", response_model=UserResponse) async def change_password( - *, - session: Session = ActiveSession, - patch_data: UserPasswordPatchRequest, - user: User = CanChangeUserPassword, + *, + session: Session = ActiveSession, + patch_data: UserPasswordPatchRequest, + user: User = CanChangeUserPassword, ) -> User: user.password = patch_data.hashed_password # pyright: ignore session.add(user) @@ -92,11 +93,12 @@ async def change_password( @router.post("/pwd_reset_token/") async def send_password_reset_token( - *, email: str = Body(embed=True), - background_tasks: BackgroundTasks, + *, 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 + # 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/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/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 68515a5..72e7dcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,12 +8,15 @@ 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 cryptography==40.0.2 @@ -67,8 +70,12 @@ python-jose[cryptography]==3.3.0 # via -r requirements.in python-multipart==0.0.6 # via -r requirements.in +redis==5.0.0 + # via rq rich==13.3.4 # via -r requirements.in +rq==1.15.1 + # via -r requirements.in rsa==4.9 # via python-jose six==1.16.0 From bbc70dce615ec85f15b3b04bc11e279ddf7d8fa2 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Wed, 23 Aug 2023 20:24:47 -0300 Subject: [PATCH 20/37] added: transaction and balance structures; added: migrations --- dundie/models/__init__.py | 3 +- dundie/models/transaction.py | 48 +++++++++++++++++++ dundie/models/user.py | 29 ++++++++++- .../versions/1b27082555a1_transaction.py | 47 ++++++++++++++++++ 4 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 magrations/versions/1b27082555a1_transaction.py diff --git a/dundie/models/__init__.py b/dundie/models/__init__.py index 5ac4e63..4ca419b 100644 --- a/dundie/models/__init__.py +++ b/dundie/models/__init__.py @@ -1,5 +1,6 @@ from sqlmodel import SQLModel +from .transaction import Transaction, Balance from .user import User -__all__ = ["User", "SQLModel"] +__all__ = ["User", "SQLModel", "Transaction", "Balance"] 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 66ca120..fb91b1e 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -1,12 +1,15 @@ """User related data models""" -from typing import Optional +from typing import Optional, TYPE_CHECKING from fastapi import HTTPException, status from pydantic import BaseModel, root_validator -from sqlmodel import Field, SQLModel +from sqlmodel import Field, SQLModel, Relationship from dundie.security import HashedPassword, get_password_hash +if TYPE_CHECKING: + from dundie.models.transaction import Transaction, Balance + class User(SQLModel, table=True): """Represents the User Model""" @@ -21,6 +24,28 @@ class User(SQLModel, table=True): 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.""" 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 ### From bb145a7b742e5a086fd4a2c7bed17bbe6fc45bdc Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Wed, 23 Aug 2023 20:51:42 -0300 Subject: [PATCH 21/37] added: add_transaction func into task module --- dundie/tasks/transaction.py | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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() From 4b39448cc487e9527d79940bca6aee33023b8d9e Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 20:10:35 -0300 Subject: [PATCH 22/37] added: data migration to a new app deploy flux --- .../11acfd7547d8_ensure_admin_user.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 magrations/versions/11acfd7547d8_ensure_admin_user.py 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 From a9863d0d30db3d5e56821e4bf372426e8e50108c Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 20:24:37 -0300 Subject: [PATCH 23/37] added: add_transaction from admin to users command to CLI --- dundie/cli.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/dundie/cli.py b/dundie/cli.py index a2eb613..85a67e2 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -7,6 +7,8 @@ from .db import engine from .models import User from .models.user import generate_username +from .tasks.transaction import add_transaction +from .models.transaction import Transaction, Balance main = typer.Typer(name="dundie CLI", add_completion=False) @@ -20,6 +22,9 @@ def shell() -> None: # pyright: ignore "select": select, "session": Session(engine), "User": User, + "Transaction": Transaction, + "Balance": Balance, + "add_transaction": add_transaction, } typer.echo(f"Auto imports: {list(_vars.keys())}") try: @@ -72,3 +77,34 @@ def create_user( 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) From 49acc7f3ea1a52376a9e55fc56de20d46feebaa1 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 20:39:33 -0300 Subject: [PATCH 24/37] added: transaction router to module __init__ --- dundie/routes/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dundie/routes/__init__.py b/dundie/routes/__init__.py index 124eee2..e746c52 100644 --- a/dundie/routes/__init__.py +++ b/dundie/routes/__init__.py @@ -2,8 +2,10 @@ 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"]) From 39e1681da0bcab2e1dce8f9955503108a0a45509 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 20:40:08 -0300 Subject: [PATCH 25/37] added: transaction api route --- dundie/routes/transaction.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/dundie/routes/transaction.py b/dundie/routes/transaction.py index e69de29..8d283a2 100644 --- a/dundie/routes/transaction.py +++ b/dundie/routes/transaction.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Body, HTTPException +from dundie.auth import AuthenticatedUser +from dundie.db import ActiveSession +from dundie.models import User +from dundie.tasks.transaction import add_transaction, TransactionError +from sqlmodel import select, Session + +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"} From 9bd68d77b5199a83e8a541154fb2c9c939bc2173 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 21:31:11 -0300 Subject: [PATCH 26/37] added: transaction serializer --- dundie/models/serializers.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 dundie/models/serializers.py diff --git a/dundie/models/serializers.py b/dundie/models/serializers.py new file mode 100644 index 0000000..19ecc1c --- /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"): + 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 From 381fad36f2d4743b80a5f3ca6cc647656a7f9931 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 21:31:55 -0300 Subject: [PATCH 27/37] added: transaction list to api --- dundie/routes/transaction.py | 56 +++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/dundie/routes/transaction.py b/dundie/routes/transaction.py index 8d283a2..643798e 100644 --- a/dundie/routes/transaction.py +++ b/dundie/routes/transaction.py @@ -1,9 +1,16 @@ -from fastapi import APIRouter, Body, HTTPException +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 User -from dundie.tasks.transaction import add_transaction, TransactionError -from sqlmodel import select, Session +from dundie.models import Transaction, User +from dundie.models.serializers import TransactionResponse +from dundie.tasks.transaction import TransactionError, add_transaction router = APIRouter() @@ -28,3 +35,44 @@ async def create_transaction( # 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) From 78facc88d349c28fc3eb6e5970bad2e3ae0274e8 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 21:54:33 -0300 Subject: [PATCH 28/37] removed: extra fields from TransactionSerializer --- dundie/models/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dundie/models/serializers.py b/dundie/models/serializers.py index 19ecc1c..4eaf1a6 100644 --- a/dundie/models/serializers.py +++ b/dundie/models/serializers.py @@ -9,7 +9,7 @@ from .user import User -class TransactionResponse(BaseModel, extra="allow"): +class TransactionResponse(BaseModel): # , extra="allow" retorna camopos extras id: int value: int date: datetime From 2a9277122903b8930a230cc17e7e35be509ef462 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 22:55:21 -0300 Subject: [PATCH 29/37] added: dependencie to show balance info if it exists --- dundie/auth.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/dundie/auth.py b/dundie/auth.py index 9a77a40..44e37ed 100644 --- a/dundie/auth.py +++ b/dundie/auth.py @@ -173,3 +173,37 @@ async def get_user_if_change_password_is_allowed( 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) From b454d9a107428e7fe30d00a3bb5d655fd5a84131 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 22:56:12 -0300 Subject: [PATCH 30/37] added: UserResponseWithBalance serializer --- dundie/models/user.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/dundie/models/user.py b/dundie/models/user.py index fb91b1e..5ca541f 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -1,14 +1,14 @@ """User related data models""" -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from fastapi import HTTPException, status from pydantic import BaseModel, root_validator -from sqlmodel import Field, SQLModel, Relationship +from sqlmodel import Field, Relationship, SQLModel from dundie.security import HashedPassword, get_password_hash if TYPE_CHECKING: - from dundie.models.transaction import Transaction, Balance + from dundie.models.transaction import Balance, Transaction class User(SQLModel, table=True): @@ -71,6 +71,17 @@ class UserResponse(BaseModel): 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""" From 70e30354ae9833914a91cbc4b3ff7074ff100bd8 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 22:57:39 -0300 Subject: [PATCH 31/37] added: condional balance info serializer into list_users and get_user endpoints --- dundie/routes/user.py | 46 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/dundie/routes/user.py b/dundie/routes/user.py index d8be405..ef5cec6 100644 --- a/dundie/routes/user.py +++ b/dundie/routes/user.py @@ -1,11 +1,14 @@ from typing import List -from fastapi import APIRouter, Body, BackgroundTasks +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, SuperUser +from dundie.auth import AuthenticatedUser, CanChangeUserPassword, ShowBalanceField, SuperUser from dundie.db import ActiveSession from dundie.models.user import ( User, @@ -13,6 +16,7 @@ UserProfilePatchRequest, UserRequest, UserResponse, + UserResponseWithBalance, ) from dundie.queue import queue from dundie.tasks.user import try_to_send_pwd_reset_email @@ -20,18 +24,48 @@ router = APIRouter() -@router.get("/", response_model=List[UserResponse], dependencies=[AuthenticatedUser]) -async def list_users(*, session: Session = ActiveSession): +@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) -async def get_user_by_username(*, session: Session = ActiveSession, username: str): +@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 From 465123271224395c2543d9749f5a4fbc5e5b8534 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 23:12:23 -0300 Subject: [PATCH 32/37] added: SQLModel import to ensure metadata from table in order to execute the pipeline test --- dundie/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dundie/db.py b/dundie/db.py index 1d66d5d..ef38f00 100644 --- a/dundie/db.py +++ b/dundie/db.py @@ -1,6 +1,6 @@ """Database connection""" from fastapi import Depends -from sqlmodel import Session, create_engine +from sqlmodel import Session, SQLModel, create_engine from .config import settings From 7a566b9203c609298d3a5006afc597ebad056077 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Sun, 27 Aug 2023 23:14:01 -0300 Subject: [PATCH 33/37] added: reset_db command to ensure the expected test behavior --- dundie/cli.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/dundie/cli.py b/dundie/cli.py index 85a67e2..28e46e7 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -4,11 +4,11 @@ from sqlmodel import Session, select from .config import settings -from .db import engine +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 -from .models.transaction import Transaction, Balance main = typer.Typer(name="dundie CLI", add_completion=False) @@ -108,3 +108,15 @@ def transaction( 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) From 16885e3c37784dc973035650f972f3fcd0f421df Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Mon, 28 Aug 2023 00:06:15 -0300 Subject: [PATCH 34/37] fix: UserProfilePatchRequest root_validator --- dundie/models/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dundie/models/user.py b/dundie/models/user.py index 5ca541f..8fe8687 100644 --- a/dundie/models/user.py +++ b/dundie/models/user.py @@ -111,7 +111,7 @@ class UserProfilePatchRequest(BaseModel): @root_validator(pre=True) def ensure_values(cls, values): if not values: - raise HTTPException(status_code=404, detail="Bad request, no data informed") + raise HTTPException(status_code=400, detail="Bad request, no data informed") return values From 61cc2dd1282849debc953a7a72aebc4ad51e5bbf Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Mon, 28 Aug 2023 00:06:47 -0300 Subject: [PATCH 35/37] fix: docker compose command --- test.sh | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 From db3bcace4df58d0406c6d4d5b4af79feb0f77959 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Mon, 28 Aug 2023 00:07:22 -0300 Subject: [PATCH 36/37] added: tests pre configuration --- tests/conftest.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) 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") From b216555ecf7f0907db54fcc07cc7a005a095ab08 Mon Sep 17 00:00:00 2001 From: Bruno Bereoff Date: Mon, 28 Aug 2023 00:07:50 -0300 Subject: [PATCH 37/37] added: test functions --- tests/test_api.py | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) 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"