From ebc7b2c37ad6ce7ebe6cfe6dbf6cf0d83bd8bf15 Mon Sep 17 00:00:00 2001 From: vmagueta Date: Thu, 1 Aug 2024 20:04:43 -0300 Subject: [PATCH] Add migrations with alembic + API call --- alembic.ini | 116 ++++++++++++++++++ assets/database.db | Bin 49152 -> 57344 bytes assets/people.csv | 9 +- dundie/cli.py | 4 +- dundie/core.py | 9 +- dundie/models.py | 1 + dundie/settings.py | 1 + dundie/utils/db.py | 1 + dundie/utils/exchange.py | 30 +++++ migrations/README | 1 + migrations/env.py | 79 ++++++++++++ migrations/script.py.mako | 27 ++++ ...3_adicionado_o_campo_currency_em_person.py | 33 +++++ migrations/versions/863e6e940c0a_initial.py | 27 ++++ requirements.txt | 2 + 15 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 alembic.ini create mode 100644 dundie/utils/exchange.py create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/63699c03d253_adicionado_o_campo_currency_em_person.py create mode 100644 migrations/versions/863e6e940c0a_initial.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..feca94c --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# 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>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# 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 migrations/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:migrations/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. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///assets/database.db + + +[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 + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix 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/assets/database.db b/assets/database.db index bc9716c80cb46973911e77c1793bf7fc2bd20a60..2a3a7a704410bd9ef35ca795ef3cfffad91024fb 100644 GIT binary patch delta 1865 zcma)6ZA@EL7(Tb{ZRu_Ad0PrxXqcM?k6I~36iJ0gTqb}V?f-%_=(U9ODga!R!G%mqx(V#)MADVbBYzb7xxXFET z?sJ~=yze>pJ@3SXdg7XT%4W7AgfMyUKWx5EJZMF(vu9Xl8nGqIsAL)cfiK{b>=b*% z7&iQ6m|=ck-qU}?bYIMOlrWQBl#Z*cq|Uu8x5BI`n$2Y5spvR=A{1$PCKQp)xGcvw zCLKl{S6fS+(~tBIB;)Y#}JJ#BYYXI!y9lM|6VYgX0hH+>5%_%?tn%9uFBca+kHf!b*O=}ah8Eh zHkE!VmL=(Ah7yB=$wbN(O%F9I^wD@Kp6O2>&Gu(v&5ri90&0>*1?|ejlrK(IJ8&x{g$hzAV|2!4hYSb#h5 zTfxj<1*1W2qwYnc<&{H+Fby{m`~!c(I^2Sr1+#kzw6vO{Xj;D2WF^z12u9%(Sc7R; zARaMz)TVBr9R-3^@!%BGR)I4tA-F+OoP-l_5Z}Sy;CJz99Ka>)Dtmz) zXOFQ~<89*?#N1rFxf75CyN_RZg6VT(g%xCj^Po zueeCAvaJQGy%rF?;PQ&1;PW^w`^iU<3+{kVa+U64pZos?+Pl*>f;p4gPHnHCiejR?m?$eIEX71=F=5_G z_|h5EP9~j!VgeTvtla+oRnlCpAovr0fn}J7E95jk1YngWaezJ@K06qCZcLKrKWo~v sN2RS=iu4;;C#~yy$ak?;OsVWX(N|v|b&D}U5|tLRQ(^+OUB6xO9|@ZEZU6uP delta 500 zcmZWlziU%b6h8Oe>&wf1@7>R4FildMyjGDyV@o<%2aSU+La2jS2XS(65Yo9Ir2Yfu z^$tQ16ibIfp~FQg5hR0A#2bvdkuXM=P8cmJ;3mJJgf9G0dbSA|?U%@XNM zhTWRiU2jFt`>B+i7;FoS5niH>D;P6TAq8(%xO5`Q9n}}}r9_FlbSlc}IA8QmIxdwb z(mDNlE~9tzS#O3}b5-n7bP2yK?i0Ee`39wg5X2n8`UyU0IP1HPZJ2Y-WrC6p z>;FBy??N>@5`-PXd#qy#mvBt|RL|8NbzRLWFYH)%9)+fJbJ7_J(ER@%KB9{b-r^OW zp@pV3r2*Z%ecqxfgr_igfEYDYaTOIS=`(h$;;2xBuH4(SQrm=Y*u^`vO*H)wy1h}} UZ@{4goG~E=mGeP$9L|%;KbMk-R{#J2 diff --git a/assets/people.csv b/assets/people.csv index abf2d0b..164bced 100644 --- a/assets/people.csv +++ b/assets/people.csv @@ -1,4 +1,5 @@ -Jim Halpert, Sales, Salesman, jim@dundermifflin.com -Dwight Schrute, Sales, Manager, schrute@dundermifflin.com -Gabe Lewis, C-Level, CEO, glewis@dundermifflin.com -Pam Beasly, General, Recepcionist, pam@dundermifflin.com +Jim Halpert, Sales, Salesman, jim@dundermifflin.com, USD +Dwight Schrute, Sales, Manager, schrute@dundermifflin.com, EUR +Gabe Lewis, C-Level, CEO, glewis@dundermifflin.com, BRL +Pam Beasly, General, Recepcionist, pam@dundermifflin.com, BRL +Bruno, General, Guard, bruno@dundermifflin.com, BRL diff --git a/dundie/cli.py b/dundie/cli.py index 541ea3d..76c0243 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -40,7 +40,7 @@ def load(filepath): - Loads to database """ table = Table(title="Dunder Mifflin Associates") - headers = ["email", "name", "dept", "role", "created"] + headers = ["email", "name", "dept", "role", "currency", "created"] for header in headers: table.add_column(header, style="magenta") @@ -72,6 +72,8 @@ def show(output, **query): table.add_column(key.title().replace("_", " "), style="magenta") for person in result: + person["value"] = f"{person['value']:.2f}" + person["balance"] = f"{person['balance']:.2f}" table.add_row(*[str(value) for value in person.values()]) console = Console() diff --git a/dundie/core.py b/dundie/core.py index d7a29bd..d6c2919 100644 --- a/dundie/core.py +++ b/dundie/core.py @@ -10,6 +10,7 @@ from dundie.models import Person from dundie.settings import DATEFMT from dundie.utils.db import add_movement, add_person +from dundie.utils.exchange import get_rates from dundie.utils.log import get_logger log = get_logger() @@ -30,7 +31,7 @@ def load(filepath: str) -> ResultDict: raise e people = [] - headers = ["name", "dept", "role", "email"] + headers = ["name", "dept", "role", "email", "currency"] with get_session() as session: for line in csv_data: @@ -64,8 +65,13 @@ def read(**query: Query) -> ResultDict: sql = sql.where(*query_statements) # WHERE ... with get_session() as session: + currencies = session.exec( + select(Person.currency).distinct(Person.currency) + ) + rates = get_rates(currencies) results = session.exec(sql) for person in results: + total = rates[person.currency].value * person.balance[0].value return_data.append( { "email": person.email, @@ -74,6 +80,7 @@ def read(**query: Query) -> ResultDict: DATEFMT ), **person.dict(exclude={"id"}), + **{"value": total}, } ) return return_data diff --git a/dundie/models.py b/dundie/models.py index 8120b09..7d5f26e 100644 --- a/dundie/models.py +++ b/dundie/models.py @@ -20,6 +20,7 @@ class Person(SQLModel, table=True): name: str = Field(nullable=False) dept: str = Field(nullable=False, index=True) role: str = Field(nullable=False) + currency: str = Field(default="USD") balance: "Balance" = Relationship(back_populates="person") movement: "Movement" = Relationship(back_populates="person") diff --git a/dundie/settings.py b/dundie/settings.py index a31fbcb..e34f641 100644 --- a/dundie/settings.py +++ b/dundie/settings.py @@ -6,6 +6,7 @@ EMAIL_FROM: str = "master@dundie.com" DATEFMT: str = "%d/%m/%Y %H:%M:%S" +API_BASE_URL = "https://economia.awesomeapi.com.br/json/last/USD-{currency}" ROOT_PATH: str = os.path.dirname(__file__) DATABASE_PATH: str = os.path.join(ROOT_PATH, "..", "assets", "database.db") diff --git a/dundie/utils/db.py b/dundie/utils/db.py index 7a292ee..31ecfdf 100644 --- a/dundie/utils/db.py +++ b/dundie/utils/db.py @@ -30,6 +30,7 @@ def add_person(session: Session, instance: Person): else: existing.dept = instance.dept existing.role = instance.role + existing.currency = instance.currency session.add(existing) return instance, created diff --git a/dundie/utils/exchange.py b/dundie/utils/exchange.py new file mode 100644 index 0000000..4deb1b3 --- /dev/null +++ b/dundie/utils/exchange.py @@ -0,0 +1,30 @@ +from decimal import Decimal + +import httpx +from pydantic import BaseModel, Field + +from dundie.settings import API_BASE_URL + + +class USDRate(BaseModel): + code: str = Field(default="USD") + codein: str = Field(default="USD") + name: str = Field(default="Dolar/Dolar") + value: Decimal = Field(alias="high") + + +def get_rates(currencies: list[str]) -> dict[str, USDRate]: + """Get current rate for USD vc Currency.""" + return_data = {} + for currency in currencies: + if currency == "USD": + return_data[currency] = USDRate(high=1) + else: + response = httpx.get(API_BASE_URL.format(currency=currency)) + if response.status_code == 200: + data = response.json()["USD" + currency] + return_data[currency] = USDRate(**data) + else: + return_data[currency] = USDRate(name="api/error", high=0) + + return return_data diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..15d95e4 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,79 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from dundie import models + +# 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 = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + 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_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + 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/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..6ce3351 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/63699c03d253_adicionado_o_campo_currency_em_person.py b/migrations/versions/63699c03d253_adicionado_o_campo_currency_em_person.py new file mode 100644 index 0000000..aa66550 --- /dev/null +++ b/migrations/versions/63699c03d253_adicionado_o_campo_currency_em_person.py @@ -0,0 +1,33 @@ +"""Adicionado o campo currency em person + +Revision ID: 63699c03d253 +Revises: 863e6e940c0a +Create Date: 2024-08-01 15:25:47.385323 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '63699c03d253' +down_revision: Union[str, None] = '863e6e940c0a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('person', sa.Column( + 'currency', sqlmodel.sql.sqltypes.AutoString(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('person', 'currency') + # ### end Alembic commands ### diff --git a/migrations/versions/863e6e940c0a_initial.py b/migrations/versions/863e6e940c0a_initial.py new file mode 100644 index 0000000..657f12c --- /dev/null +++ b/migrations/versions/863e6e940c0a_initial.py @@ -0,0 +1,27 @@ +"""initial + +Revision ID: 863e6e940c0a +Revises: +Create Date: 2024-08-01 15:22:23.235789 + +""" +from typing import Sequence, Union + + +# revision identifiers, used by Alembic. +revision: str = '863e6e940c0a' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 35617de..1a713e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ rich-click sqlalchemy==1.4.35 pydantic<2.0 sqlmodel +alembic +httpx