diff --git a/Makefile b/Makefile index 1738b61..fb79ddf 100644 --- a/Makefile +++ b/Makefile @@ -3,30 +3,28 @@ install: @echo "Installing for dev environment" - @.venv/bin/python -m pip install -e '.[dev]' + @.venv/bin/python -m pip install -e '.[test,dev]' virtualenv: - @.venv/bin/python -m pip venv .venv + @python -m venv .venv ipython: @.venv/bin/ipython -fmt: - @.venv/bin/isort --profile=black -m 3 dundie tests integration - @.venv/bin/black dundie tests integration - - lint: + #@.venv/bin/mypy --ignore-missing-imports dundie @.venv/bin/pflake8 +fmt: + @.venv/bin/isort --profile=black -m 3 dundie tests integration + @.venv/bin/black dundie tests integration test: @.venv/bin/pytest -s --forked - watch: # @.venv/bin/ptw @ls **/*.py | entr pytest --forked @@ -55,14 +53,11 @@ docs: docs-serve: @mkdocs serve - build: @python setup.py sdist bdist_wheel - publish-test: @twine upload --repository testpypi dist/* - publish: - @twine updload dist/* + @twine upload dist/* diff --git a/assets/database.db b/assets/database.db new file mode 100644 index 0000000..bc9716c Binary files /dev/null and b/assets/database.db differ diff --git a/assets/database.json b/assets/database.json index de5d4ce..c5877dd 100644 --- a/assets/database.json +++ b/assets/database.json @@ -22,173 +22,193 @@ } }, "balance": { - "jim@dundermifflin.com": 653, - "schrute@dundermifflin.com": 253, - "glewis@dundermifflin.com": 200, - "pam@dundermifflin.com": 500 + "jim@dundermifflin.com": "658", + "schrute@dundermifflin.com": "258", + "glewis@dundermifflin.com": "205", + "pam@dundermifflin.com": "505" }, "movement": { "jim@dundermifflin.com": [ { - "date": "2024-06-30T13:17:00.085814", "actor": "system", - "value": 500 + "value": "500", + "date": "2024-06-30T13:17:00.085814" }, { - "date": "2024-07-01T09:38:03.015041", "actor": "solermvictor", - "value": 30 + "value": "30", + "date": "2024-07-01T09:38:03.015041" }, { - "date": "2024-07-01T09:38:15.965008", "actor": "solermvictor", - "value": 332 + "value": "332", + "date": "2024-07-01T09:38:15.965008" }, { - "date": "2024-07-01T09:43:45.392936", "actor": "solermvictor", - "value": -62 + "value": "-62", + "date": "2024-07-01T09:43:45.392936" }, { - "date": "2024-07-01T09:45:45.790470", "actor": "solermvictor", - "value": 62 + "value": "62", + "date": "2024-07-01T09:45:45.790470" }, { - "date": "2024-07-01T09:45:52.961517", "actor": "solermvictor", - "value": -62 + "value": "-62", + "date": "2024-07-01T09:45:52.961517" }, { - "date": "2024-07-01T09:47:39.710349", "actor": "solermvictor", - "value": 5 + "value": "5", + "date": "2024-07-01T09:47:39.710349" }, { - "date": "2024-07-01T09:47:47.852407", "actor": "solermvictor", - "value": 5 + "value": "5", + "date": "2024-07-01T09:47:47.852407" }, { - "date": "2024-07-01T10:28:44.587618", "actor": "solermvictor", - "value": -257 + "value": "-257", + "date": "2024-07-01T10:28:44.587618" }, { - "date": "2024-07-01T10:29:00.532653", "actor": "solermvictor", - "value": 100 + "value": "100", + "date": "2024-07-01T10:29:00.532653" }, { - "date": "2024-07-19T14:41:20.573189", "actor": "solermvictor", - "value": 5 + "value": "5", + "date": "2024-07-19T14:41:20.573189" }, { - "date": "2024-07-19T14:42:12.498918", "actor": "solermvictor", - "value": -5 + "value": "-5", + "date": "2024-07-19T14:42:12.498918" + }, + { + "actor": "solermvictor", + "value": "5", + "date": "2024-07-22T09:53:14.006612" } ], "schrute@dundermifflin.com": [ { - "date": "2024-06-30T13:17:00.086117", "actor": "system", - "value": 100 + "value": "100", + "date": "2024-06-30T13:17:00.086117" + }, + { + "actor": "solermvictor", + "value": "30", + "date": "2024-07-01T09:38:03.015054" }, { - "date": "2024-07-01T09:38:03.015054", "actor": "solermvictor", - "value": 30 + "value": "332", + "date": "2024-07-01T09:38:15.965025" }, { - "date": "2024-07-01T09:38:15.965025", "actor": "solermvictor", - "value": 332 + "value": "-62", + "date": "2024-07-01T09:43:45.392949" }, { - "date": "2024-07-01T09:43:45.392949", "actor": "solermvictor", - "value": -62 + "value": "62", + "date": "2024-07-01T09:45:45.790483" }, { - "date": "2024-07-01T09:45:45.790483", "actor": "solermvictor", - "value": 62 + "value": "-62", + "date": "2024-07-01T09:45:52.961531" }, { - "date": "2024-07-01T09:45:52.961531", "actor": "solermvictor", - "value": -62 + "value": "5", + "date": "2024-07-01T09:47:39.710364" }, { - "date": "2024-07-01T09:47:39.710364", "actor": "solermvictor", - "value": 5 + "value": "5", + "date": "2024-07-01T09:47:47.852421" }, { - "date": "2024-07-01T09:47:47.852421", "actor": "solermvictor", - "value": 5 + "value": "-257", + "date": "2024-07-01T10:28:44.587632" }, { - "date": "2024-07-01T10:28:44.587632", "actor": "solermvictor", - "value": -257 + "value": "100", + "date": "2024-07-01T10:29:00.532668" }, { - "date": "2024-07-01T10:29:00.532668", "actor": "solermvictor", - "value": 100 + "value": "5", + "date": "2024-07-19T14:41:20.573205" }, { - "date": "2024-07-19T14:41:20.573205", "actor": "solermvictor", - "value": 5 + "value": "-5", + "date": "2024-07-19T14:42:12.498933" }, { - "date": "2024-07-19T14:42:12.498933", "actor": "solermvictor", - "value": -5 + "value": "5", + "date": "2024-07-22T09:53:14.006660" } ], "glewis@dundermifflin.com": [ { - "date": "2024-06-30T13:17:00.086294", "actor": "system", - "value": 100 + "value": "100", + "date": "2024-06-30T13:17:00.086294" }, { - "date": "2024-07-01T10:28:22.353956", "actor": "solermvictor", - "value": 100 + "value": "100", + "date": "2024-07-01T10:28:22.353956" }, { - "date": "2024-07-19T14:41:20.573209", "actor": "solermvictor", - "value": 5 + "value": "5", + "date": "2024-07-19T14:41:20.573209" }, { - "date": "2024-07-19T14:42:12.498938", "actor": "solermvictor", - "value": -5 + "value": "-5", + "date": "2024-07-19T14:42:12.498938" + }, + { + "actor": "solermvictor", + "value": "5", + "date": "2024-07-22T09:53:14.006692" } ], "pam@dundermifflin.com": [ { - "date": "2024-07-15T18:20:06.454769", "actor": "system", - "value": 500 + "value": "500", + "date": "2024-07-15T18:20:06.454769" + }, + { + "actor": "solermvictor", + "value": "5", + "date": "2024-07-19T14:41:20.573212" }, { - "date": "2024-07-19T14:41:20.573212", "actor": "solermvictor", - "value": 5 + "value": "-5", + "date": "2024-07-19T14:42:12.498940" }, { - "date": "2024-07-19T14:42:12.498940", "actor": "solermvictor", - "value": -5 + "value": "5", + "date": "2024-07-22T09:53:14.006720" } ] }, diff --git a/conftest.py b/conftest.py index d323961..d8d123c 100644 --- a/conftest.py +++ b/conftest.py @@ -1,27 +1,30 @@ -"""Configuration of Pytest for the tests of dundie functions.""" - +import warnings import pytest from unittest.mock import patch +from sqlmodel import create_engine +from dundie import models +from sqlalchemy.exc import SAWarning + + +warnings.filterwarnings("ignore", category=SAWarning) MARKER = """\ unit: Mark unit tests integration: Mark integration tests -high: High priority -medium: Medium priority -low: Low priority +high: High Priority +medium: Medium Priority +low: Low Priority """ def pytest_configure(config): - """Configure the marker for functions.""" for line in MARKER.split("\n"): config.addinivalue_line("markers", line) @pytest.fixture(autouse=True) def go_to_tmpdir(request): # injeção de dependencias - """Configure to tests run in temporary directory.""" tmpdir = request.getfixturevalue("tmpdir") with tmpdir.as_cwd(): yield # protocolo de generators @@ -29,8 +32,12 @@ def go_to_tmpdir(request): # injeção de dependencias @pytest.fixture(autouse=True, scope="function") def setup_testing_database(request): - """For each test, create a db on tmpdir forcing to use that filepath.""" + """For each test, create a database file on tmpdir + force database.py to use that filepath. + """ tmpdir = request.getfixturevalue("tmpdir") - test_db = str(tmpdir.join("database.test.json")) - with patch("dundie.database.DATABASE_PATH", test_db): + test_db = str(tmpdir.join("database.test.db")) + engine = create_engine(f"sqlite:///{test_db}") + models.SQLModel.metadata.create_all(bind=engine) + with patch("dundie.database.engine", engine): yield diff --git a/dundie/cli.py b/dundie/cli.py index a0b7ca2..f3ba8ba 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -1,8 +1,6 @@ -"""Commandline Interface of Dundie.""" - import json -from importlib import metadata +from importlib import metadata import rich_click as click from rich.console import Console from rich.table import Table @@ -33,7 +31,7 @@ def main(): @main.command() @click.argument("filepath", type=click.Path(exists=True)) def load(filepath): - """Load the file to the database. + """Loads the file to the database. ## Features @@ -42,7 +40,7 @@ def load(filepath): - Loads to database """ table = Table(title="Dunder Mifflin Associates") - headers = ["name", "dept", "role", "created", "e-mail"] + headers = ["email", "name", "dept", "role", "created"] for header in headers: table.add_column(header, style="magenta") @@ -59,14 +57,15 @@ def load(filepath): @click.option("--email", required=False) @click.option("--output", default=None) def show(output, **query): - """Show information about user or dept.""" + """Shows information about user or dept.""" result = core.read(**query) if output: with open(output, "w") as output_file: output_file.write(json.dumps(result)) - if not result: + if len(result) == 0: print("Nothing to show") + return table = Table(title="Dunder Mifflin Report") for key in result[0]: @@ -96,6 +95,6 @@ def add(ctx, value, **query): @click.option("--email", required=False) @click.pass_context def remove(ctx, value, **query): - """Remove points from the user or dept.""" + """Removes points from the user or dept.""" core.add(-value, **query) ctx.invoke(show, **query) diff --git a/dundie/core.py b/dundie/core.py index ec4671e..e7d2750 100644 --- a/dundie/core.py +++ b/dundie/core.py @@ -1,11 +1,14 @@ -"""Core module of dundie.""" - +"""Core module of dundie""" import os from csv import reader from typing import Any, Dict, List -from dundie.database import add_movement, add_person, commit, connect -from dundie.models import Balance, Movement, Person +from sqlmodel import select + +from dundie.database import get_session +from dundie.models import Person +from dundie.settings import DATEFMT +from dundie.utils.db import add_movement, add_person from dundie.utils.log import get_logger log = get_logger() @@ -14,7 +17,7 @@ def load(filepath: str) -> ResultDict: - """Load data from filepath to the database. + """Loads data from filepath to the database. >>> len(load('assets/people.csv')) 2 @@ -25,58 +28,70 @@ def load(filepath: str) -> ResultDict: log.error(str(e)) raise e - db = connect() people = [] headers = ["name", "dept", "role", "email"] - for line in csv_data: - person_data = dict(zip(headers, [item.strip() for item in line])) - instance = Person(pk=person_data.pop("email"), **person_data) - person, created = add_person(db, instance) - return_data = person.dict(exclude={"pk"}) - return_data["created"] = created - return_data["email"] = person.pk - people.append(return_data) - - commit(db) + + with get_session() as session: + for line in csv_data: + person_data = dict(zip(headers, [item.strip() for item in line])) + instance = Person(**person_data) + person, created = add_person(session, instance) + return_data = person.dict(exclude={"id"}) + return_data["created"] = created + people.append(return_data) + + session.commit() + return people def read(**query: Query) -> ResultDict: - """Read data from db and filters using query. + """Read data from db and filters using query read(email="joe@doe.com") """ query = {k: v for k, v in query.items() if v is not None} - db = connect() return_data = [] + + query_statements = [] + if "dept" in query: + query_statements.append(Person.dept == query["dept"]) if "email" in query: - query["pk"] = query.pop("email") - - for person in db[Person].filter(**query): - return_data.append( - { - "email": person.pk, - "balance": db[Balance].get_by_pk(person.pk).value, - "last_movement": db[Movement] - .filter(person__pk=person.pk)[-1] - .date, - **person.dict(exclude={"pk"}), - } - ) + query_statements.append(Person.email == query["email"]) + sql = select(Person) # SELECT FROM PERSON + if query_statements: + sql = sql.where(*query_statements) # WHERE ... + + with get_session() as session: + results = session.exec(sql) + for person in results: + return_data.append( + { + "email": person.email, + "balance": person.balance[0].value, + "last_movement": person.movement[-1].date.strftime( + DATEFMT + ), + **person.dict(exclude={"id"}), + } + ) return return_data def add(value: int, **query: Query): - """Add value to each record on query.""" + """Add value to each record on query""" query = {k: v for k, v in query.items() if v is not None} people = read(**query) if not people: raise RuntimeError("Not Found") - db = connect() - user = os.getenv("USER") - for person in people: - instance = db[Person].get_by_pk(person["email"]) - add_movement(db, instance, value, user) - commit(db) + with get_session() as session: + user = os.getenv("USER") + for person in people: + instance = session.exec( + select(Person).where(Person.email == person["email"]) + ).first() + add_movement(session, instance, value, user) + + session.commit() diff --git a/dundie/database.py b/dundie/database.py index 20fc52c..1faaba7 100644 --- a/dundie/database.py +++ b/dundie/database.py @@ -1,236 +1,25 @@ -"""Functions to access and manipulate the database.""" +import warnings -import importlib -import json -from collections import UserList, defaultdict -from typing import TYPE_CHECKING, Any, Dict, Optional +from sqlalchemy.exc import SAWarning +from sqlmodel import Session, create_engine -if TYPE_CHECKING: - from pydantic import BaseModel +# We have to monkey patch this attributes +# https://github.com/tiangolo/sqlmodel/issues/189 +from sqlmodel.sql.expression import Select, SelectOfScalar -from dundie.settings import DATABASE_PATH, EMAIL_FROM -from dundie.utils.email import send_email +from dundie import models +from dundie.settings import SQL_CON_STRING -EMPTY_DB: Dict[str, Dict[str, Any]] = { - "people": {}, - "balance": {}, - "movement": {}, - "users": {}, -} +# ^ IMPORTANTE importar todos os models para este contexto +warnings.filterwarnings("ignore", category=SAWarning) -DB = Dict["BaseModel", "ResultList"] +SelectOfScalar.inherit_cache = True # type: ignore +Select.inherit_cache = True # type: ignore +engine = create_engine(SQL_CON_STRING, echo=False) +models.SQLModel.metadata.create_all(bind=engine) -class NotFoundError(Exception): - """Not found error.""" - ... - - -class ResultList(UserList): - def first(self) -> Any: - return self[0] - - def last(self) -> Any: - return self[-1] - - def get_by_pk(self, pk: str) -> Any: - if len(self) == 0: - raise NotFoundError(f"{pk} not found") - try: - if hasattr(self[0], "pk"): - return ResultList( - item for item in self if item.pk == pk - ).first() - return ResultList( - item for item in self if item.person.pk == pk - ).first() - except KeyError: - raise NotFoundError(f"{pk} not found") - - def filter(self, **query: Dict[str, Any]) -> "ResultList": - if not query: - return self - return_data = ResultList() - for item in self: - add_item = [] - for q, v in query.items(): - if "__" in q: - sub_model, sub_field = q.split("__") - related = getattr(item, sub_model) - if getattr(related, sub_field) == v: - add_item.append(True) - else: - if getattr(item, q) == v: - add_item.append(True) - else: - add_item.append(False) - if add_item and all(add_item): - return_data.append(item) - return return_data - - -class ORM: - """Mapeamento entre "table" no JSON e classes em models""" - - MAPPING: Dict[str, str] = { - "people": "dundie.models.Person", - "balance": "dundie.models.Balance", - "movement": "dundie.models.Movement", - "users": "dundie.models.User", - } - - @classmethod - def get_model_class(cls, table_name: str) -> Any: - module, obj = cls.MAPPING[table_name].rsplit(".", 1) - return getattr(importlib.import_module(module), obj) - - @classmethod - def get_table_name(cls, model: Any) -> str: - inverted_orm = {v.split(".")[-1]: k for k, v in cls.MAPPING.items()} - return inverted_orm[model.__name__] - - @classmethod - def serialize(cls, db) -> Dict[str, Any]: - """Turns Model instances back to json compatible dict.""" - raw_db: Dict[str, Any] = defaultdict(dict) - for model, instances in db.items(): - table_name = cls.get_table_name(model) - raw_db[table_name] # initialize default dict - for instance in instances: - raw_instance = json.loads(instance.json()) - if table_name == "people": - raw_db[table_name][raw_instance.pop("pk")] = raw_instance - elif table_name == "balance": - raw_db[table_name][instance.person.pk] = raw_instance[ - "value" - ] - elif table_name == "movement": - table = raw_db[table_name] - table.setdefault(instance.person.pk, []) - raw_instance.pop("person") - table[instance.person.pk].append(raw_instance) - else: - raw_instance.pop("person") - raw_db[table_name][instance.person.pk] = raw_instance - return raw_db - - @classmethod - def deserialize(cls, raw_data: Dict[str, Any]) -> Dict[Any, ResultList]: - """Turns JSON in to model isntances""" - results: Dict[Any, ResultList] = defaultdict(ResultList) - indexes = {} - for table_name, data in raw_data.items(): - Model = cls.get_model_class(table_name) - results[Model] # initialize default dict - if table_name == "people": - for pk, person_data in data.items(): - instance = Model(pk=pk, **person_data) - indexes[pk] = instance - results[Model].append(instance) - elif table_name == "balance": - for pk, balance_data in data.items(): - instance = Model(person=indexes[pk], value=balance_data) - results[Model].append(instance) - elif table_name == "users": - for pk, user_data in data.items(): - instance = Model(person=indexes[pk], **user_data) - results[Model].append(instance) - elif table_name == "movement": - for pk, movements in data.items(): - for movement in movements: - instance = Model(person=indexes[pk], **movement) - results[Model].append(instance) - return results - - -def connect() -> Dict[Any, ResultList]: - """Connects to the database, returns dict data""" - try: - with open(DATABASE_PATH, "r") as database_file: - raw_data = json.loads(database_file.read()) - except (json.JSONDecodeError, FileNotFoundError): - raw_data = EMPTY_DB - - # transform raw data from json to model objects / Deserialize - results = ORM.deserialize(raw_data) - return results - - -def commit(db: DB): - """Save db back to the database file.""" - # transform model objects back to json database / Serialize - raw_db = ORM.serialize(db) - - if raw_db.keys() != EMPTY_DB.keys(): - raise RuntimeError(f"Database Schema is invalid. {raw_db.keys()}") - - final_data = json.dumps(raw_db, indent=4) - with open(DATABASE_PATH, "w") as database_file: - database_file.write(final_data) - - -def add_person(db: DB, instance: Any): - """Saves person data to database. - - - If exists, update, else create - - Set initial balance (managers = 100, others = 500) - - Generate a password if user is new and send_email - """ - Person = ORM.get_model_class("people") - table = db[Person] - existing = table.filter(pk=instance.pk) - created = len(existing) == 0 - if created: - table.append(instance) - set_initial_balance(db, instance) - password = set_initial_password(db, instance) - send_email(EMAIL_FROM, instance.pk, "Your dundie password", password) - else: - existing_data = existing.first().dict() - new_data = instance.dict() - existing_data.update(new_data) - table.remove(existing) - table.append(Person(**existing_data)) - return instance, created - - -def set_initial_password(db: DB, instance: Any) -> str: - """Generated and saves password""" - User = ORM.get_model_class("users") - user = User(person=instance) # password generated by model - db[User].append(user) - return user.password - - -def set_initial_balance(db: DB, person: Any): - """Add movement and set initial balance""" - value = 100 if person.role == "Manager" else 500 - add_movement(db, person, value) - - -def add_movement( - db: DB, person: Any, value: int, actor: Optional[str] = "system" -): - """Adds movement to user account. - - Example:: - - add_movement(db, Person(...), 100, "me") - - """ - Movement = ORM.get_model_class("movement") - db[Movement].append(Movement(person=person, value=value, actor=actor)) - movements = [item for item in db[Movement] if item.person.pk == person.pk] - - Balance = ORM.get_model_class("balance") - - # reset balance table for the user - db[Balance] = ResultList( - [item for item in db[Balance] if item.person.pk != person.pk] - ) - - db[Balance].append( - Balance(person=person, value=sum([item.value for item in movements])) - ) +def get_session() -> Session: + return Session(engine) diff --git a/dundie/models.py b/dundie/models.py index 349ca55..549289d 100644 --- a/dundie/models.py +++ b/dundie/models.py @@ -1,25 +1,29 @@ from datetime import datetime -from decimal import Decimal +from typing import Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import condecimal, validator +from sqlmodel import Field, Relationship, SQLModel from dundie.utils.email import check_valid_email from dundie.utils.user import generate_simple_password class InvalidEmailError(Exception): - """Invalid email error.""" - ... -class Person(BaseModel): - pk: str - name: str - dept: str - role: str +class Person(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + email: str = Field(nullable=False, index=True) + name: str = Field(nullable=False) + dept: str = Field(nullable=False, index=True) + role: str = Field(nullable=False) + + balance: "Balance" = Relationship(back_populates="person") + movement: "Movement" = Relationship(back_populates="person") + user: "User" = Relationship(back_populates="person") - @field_validator("pk") + @validator("email") def validate_email(cls, v: str) -> str: if not check_valid_email(v): raise InvalidEmailError(f"Invalid email for {v!r}") @@ -29,48 +33,36 @@ def __str__(self) -> str: return f"{self.name} - {self.role}" -class Balance(BaseModel): - person: Person - value: Decimal +class Balance(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + person_id: int = Field(foreign_key="person.id") + value: condecimal(decimal_places=3) = Field(default=0) + + person: Person = Relationship(back_populates="balance") class ConfigDict: json_encoders = {Person: lambda p: p.pk} -class Movement(BaseModel): - person: Person - actor: str - value: Decimal - date: datetime = Field(default_factory=lambda: datetime.now().isoformat()) +class Movement(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + person_id: int = Field(foreign_key="person.id") + actor: str = Field(nullable=False, index=True) + value: condecimal(decimal_places=3) = Field(default=0) + date: datetime = Field(default_factory=lambda: datetime.now()) + + person: Person = Relationship(back_populates="movement") class ConfigDict: json_encoders = {Person: lambda p: p.pk} -class User(BaseModel): - person: Person +class User(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True, index=True) + person_id: int = Field(foreign_key="person.id") password: str = Field(default_factory=generate_simple_password) + person: Person = Relationship(back_populates="user") + class ConfigDict: json_encoders = {Person: lambda p: p.pk} - - -if __name__ == "__main__": - p = Person(pk="bruno@g.com", name="Bruno", dept="Sales", role="NA") - print(p) - print(p.json()) - - b = Balance(person=p, value=100) - print(b.json(models_as_dict=False)) - - m = Movement(person=p, date=datetime.now(), actor="sys", value=10) - print(m.json(models_as_dict=False)) - - u = User(person=p) - print(u.json(models_as_dict=False)) - - email = "invalid@" - try: - Person(pk=email) - except InvalidEmailError as e: - assert str(e) == f"Invalid email for {email!r}" diff --git a/dundie/settings.py b/dundie/settings.py index 3219a32..a31fbcb 100644 --- a/dundie/settings.py +++ b/dundie/settings.py @@ -1,13 +1,12 @@ -"""Settings for e-mail, SMTP and paths to functions in the module Dundie.""" - import os -SMTP_HOST = "Localhost" -SMTP_PORT = 8025 -SMTP_TIMEOUT = 5 - -EMAIL_FROM = "master@dundie.com" +SMTP_HOST: str = "localhost" +SMTP_PORT: int = 8025 +SMTP_TIMEOUT: int = 5 +EMAIL_FROM: str = "master@dundie.com" +DATEFMT: str = "%d/%m/%Y %H:%M:%S" -ROOT_PATH = os.path.dirname(__file__) -DATABASE_PATH = os.path.join(ROOT_PATH, "..", "assets", "database.json") +ROOT_PATH: str = os.path.dirname(__file__) +DATABASE_PATH: str = os.path.join(ROOT_PATH, "..", "assets", "database.db") +SQL_CON_STRING = f"sqlite:///{DATABASE_PATH}" diff --git a/dundie/utils/db.py b/dundie/utils/db.py new file mode 100644 index 0000000..7a292ee --- /dev/null +++ b/dundie/utils/db.py @@ -0,0 +1,77 @@ +from typing import Optional + +from sqlmodel import Session, select + +from dundie.models import Balance, Movement, Person, User +from dundie.settings import EMAIL_FROM +from dundie.utils.email import send_email + + +def add_person(session: Session, instance: Person): + """Saves person data to database. + + - If exists, update, else create + - Set initial balance (managers = 100, others = 500) + - Generate a password if user is new and send_email + """ + existing = session.exec( + select(Person).where(Person.email == instance.email) + ).first() + created = existing is None + if created: + session.add(instance) + set_initial_balance(session, instance) + password = set_initial_password(session, instance) + # TODO: Usar sistema de filas (conteudo extra) + send_email( + EMAIL_FROM, instance.email, "Your dundie password", password + ) + return instance, created + else: + existing.dept = instance.dept + existing.role = instance.role + session.add(existing) + return instance, created + + +def set_initial_password(session: Session, instance: Person) -> str: + """Generated and saves password""" + user = User(person=instance) # password generated by model + session.add(user) + return user.password + + +def set_initial_balance(session: Session, person: Person): + """Add movement and set initial balance""" + value = 100 if person.role == "Manager" else 500 + add_movement(session, person, value) + + +def add_movement( + session: Session, + person: Person, + value: int, + actor: Optional[str] = "system", +): + """Adds movement to user account. + + Example:: + + add_movement(db, Person(...), 100, "me") + + """ + movement = Movement(person=person, value=value, actor=actor) + session.add(movement) + + movements = session.exec(select(Movement).where(Movement.person == person)) + + total = sum([mov.value for mov in movements]) + + existing_balance = session.exec( + select(Balance).where(Balance.person == person) + ).first() + if existing_balance: + existing_balance.value = total + session.add(existing_balance) + else: + session.add(Balance(person=person, value=total)) diff --git a/dundie/utils/email.py b/dundie/utils/email.py index d81a075..eeee4fb 100644 --- a/dundie/utils/email.py +++ b/dundie/utils/email.py @@ -1,35 +1,34 @@ -"""Functions that check and send an email to the user.""" - import re import smtplib from email.mime.text import MIMEText +from typing import List from dundie.settings import SMTP_HOST, SMTP_PORT, SMTP_TIMEOUT from dundie.utils.log import get_logger -regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" - log = get_logger() +regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" + -def check_valid_email(address): - """Return True if e-mail is valid.""" +def check_valid_email(address: str) -> bool: + """Return True if email is valid""" return bool(re.fullmatch(regex, address)) -def send_email(from_, to, subject, text): - """Send an email to the colaborator.""" +def send_email(from_: str, to: List[str], subject: str, text: str): + # TODO: Encrypt and send only link not password if not isinstance(to, list): to = [to] try: - with smtplib.smtp( + with smtplib.SMTP( host=SMTP_HOST, port=SMTP_PORT, timeout=SMTP_TIMEOUT ) as server: message = MIMEText(text) message["Subject"] = subject message["From"] = from_ message["To"] = ",".join(to) - server.sendemail(from_, to, message.as_string()) + server.sendmail(from_, to, message.as_string()) except Exception: log.error("Cannot send email to %s", to) diff --git a/dundie/utils/log.py b/dundie/utils/log.py index 7c53ce6..547cdbe 100644 --- a/dundie/utils/log.py +++ b/dundie/utils/log.py @@ -1,8 +1,7 @@ -"""Configuration of the log.""" - import logging import os from logging import handlers +from typing import Union LOG_LEVEL = os.getenv("LOG_LEVEL", "WARNING").upper() log = logging.getLogger("dundie") @@ -12,8 +11,10 @@ ) -def get_logger(logfile="dundie.log"): - """Return a configured logger.""" +def get_logger( + logfile: Union[str, os.PathLike[str]] = "dundie.log" +) -> logging.Logger: + """Returns a configured logger.""" # ch = logging.StreamHandler() # Console/terminal/stderr # ch.setLevel(log_level) fh = handlers.RotatingFileHandler( diff --git a/dundie/utils/user.py b/dundie/utils/user.py index 3b39447..6df3410 100644 --- a/dundie/utils/user.py +++ b/dundie/utils/user.py @@ -1,12 +1,9 @@ -"""Function that generates password for the user.""" - from random import sample from string import ascii_letters, digits -def generate_simple_password(size=8): - """Generate a simple random password. - +def generate_simple_password(size: int = 8) -> str: + """Generate a simple random password [A-Z][a-z][0-9] """ password = sample(ascii_letters + digits, size) diff --git a/integration/constants.py b/integration/constants.py index 9500fb7..faeee50 100644 --- a/integration/constants.py +++ b/integration/constants.py @@ -1,5 +1,3 @@ -"""Constants paths used on the dundie's integration tests.""" - import os TEST_PATH = os.path.dirname(__file__) diff --git a/integration/test_load.py b/integration/test_load.py index 9e558fa..dade867 100644 --- a/integration/test_load.py +++ b/integration/test_load.py @@ -1,5 +1,3 @@ -"""Tests the command in terminal as user.""" - import pytest from click.testing import CliRunner @@ -13,7 +11,7 @@ @pytest.mark.integration @pytest.mark.medium def test_load_positive_call_load_command(): - """Test command load.""" + """test command load""" out = cmd.invoke(load, PEOPLE_FILE) assert "Dunder Mifflin Associates" in out.output @@ -22,7 +20,7 @@ def test_load_positive_call_load_command(): @pytest.mark.medium @pytest.mark.parametrize("wrong_command", ["loady", "carrega", "start"]) def test_load_negative_call_load_command_with_wrong_params(wrong_command): - """Test command load.""" + """test command load""" out = cmd.invoke(main, wrong_command, PEOPLE_FILE) assert out.exit_code != 0 - assert f"No such command '{wrong_command}'" in out.output + assert f"No such command '{wrong_command}'." in out.output diff --git a/isort.cfg b/isort.cfg new file mode 100644 index 0000000..10bec97 --- /dev/null +++ b/isort.cfg @@ -0,0 +1,7 @@ +[settings] +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 79 diff --git a/pyproject.toml b/pyproject.toml index 0b0ed25..4f21d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,14 @@ minversion = "6.0" addopts = "-ra -q -vv" testpaths = [ "tests", - "integration" + "integration", ] [tool.flake8] exclude = [".venv", "build"] max-line-length = 79 - +# extend-ignore = "W293," [tool.black] line-length = 79 @@ -27,8 +27,11 @@ exclude = ''' )/ ''' - [tool.isort] profile = "black" src_paths = ["dundie", "tests", "integration"] multi_line_output = 3 # VHI +line_length = 79 +force_grid_wrap = 0 +use_parentheses = true +include_trailing_comma = true diff --git a/requirements.dev.txt b/requirements.dev.txt index e5f2157..91cd799 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,5 +1,5 @@ # dev dependencies -ipython>=8.0.0,<=8.25.0 +ipython>=7.0.0,<=8.0.0 ipdb pudb pytest-watch @@ -16,5 +16,9 @@ mkdocs # build wheel +# typing +mypy +types-setuptools + # Install project as editable -e . diff --git a/requirements.test.txt b/requirements.test.txt index 41dfd4a..9fabd91 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -4,3 +4,5 @@ flake8 pyproject-flake8 black isort +mypy +types-setuptools diff --git a/requirements.txt b/requirements.txt index 2e9dd91..35617de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ click rich rich-click -pydantic +sqlalchemy==1.4.35 +pydantic<2.0 +sqlmodel diff --git a/tests/constants.py b/tests/constants.py index f27ac64..97b1ee3 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -1,5 +1,3 @@ -"""Constants paths used on dundie's unit tests.""" - import os TEST_PATH = os.path.dirname(__file__) diff --git a/tests/test_add.py b/tests/test_add.py index 01c2573..124e428 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -1,35 +1,43 @@ -"""Tests the function add.""" - import pytest from dundie.core import add, load, read -from dundie.database import add_person, commit, connect -from dundie.models import Balance, Person -from tests.constants import PEOPLE_FILE +from dundie.database import get_session +from dundie.models import Person +from dundie.utils.db import add_person + +from .constants import PEOPLE_FILE @pytest.mark.unit def test_add_movement(): - db = connect() - - pk = "joe@doe.com" - data = {"role": "Salesman", "dept": "Sales", "name": "Joe Doe"} - _, created = add_person(db, Person(pk=pk, **data)) - assert created is True - - pk = "jim@doe.com" - data = {"role": "Manager", "dept": "Management", "name": "Jim Doe"} - _, created = add_person(db, Person(pk=pk, **data)) - assert created is True - - commit(db) - - add(-30, email="joe@doe.com") - add(90, dept="Management") - - db = connect() - assert db[Balance].get_by_pk("joe@doe.com").value == 470 - assert db[Balance].get_by_pk("jim@doe.com").value == 190 + with get_session() as session: + data = { + "role": "Salesman", + "dept": "Sales", + "name": "Joe Doe", + "email": "joe@doe.com", + } + joe, created = add_person(session, Person(**data)) + assert created is True + + data = { + "role": "Manager", + "dept": "Management", + "name": "Jim Doe", + "email": "jim@doe.com", + } + jim, created = add_person(session, Person(**data)) + assert created is True + + session.commit() + + add(-30, email="joe@doe.com") + add(90, dept="Management") + session.refresh(joe) + session.refresh(jim) + + assert joe.balance[0].value == 470 + assert jim.balance[0].value == 190 @pytest.mark.unit diff --git a/tests/test_database.py b/tests/test_database.py index 8fe2457..6128f4b 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,83 +1,84 @@ import pytest +from sqlmodel import select -from dundie.models import Balance, InvalidEmailError, Movement, Person - -from dundie.database import ( # isort:skip - EMPTY_DB, - ORM, - add_movement, - add_person, - commit, - connect, -) +from dundie.database import get_session +from dundie.models import InvalidEmailError, Person +from dundie.utils.db import add_movement, add_person @pytest.mark.unit def test_ensure_database_is_test(): - from dundie.database import DATABASE_PATH - - assert "test.json" in DATABASE_PATH - - -@pytest.mark.unit -def test_database_schema(): - db = connect() - db_keys = {ORM.get_table_name(model) for model in db} - assert db_keys == EMPTY_DB.keys() + session = get_session() + assert "test.db" in session.get_bind().engine.url.database @pytest.mark.unit def test_commit_to_database(): - db = connect() - pk = "joe@doe.com" - data = {"name": "Joe Doe", "role": "Salesman", "dept": "Sales"} - db[Person].append(Person(pk=pk, **data)) - commit(db) - - db = connect() - assert db[Person].get_by_pk("joe@doe.com").dict() == {"pk": pk, **data} + session = get_session() + data = { + "name": "Joe Doe", + "role": "Salesman", + "dept": "Sales", + "email": "joe@doe.com", + } + session.add(Person(**data)) + session.commit() + + assert ( + session.exec(select(Person).where(Person.email == data["email"])) + .first() + .email + == data["email"] + ) @pytest.mark.unit def test_add_person_for_the_first_time(): - pk = "joe@doe.com" - data = {"role": "Salesman", "dept": "Sales", "name": "Joe Doe"} - db = connect() - _, created = add_person(db, Person(pk=pk, **data)) + data = { + "role": "Salesman", + "dept": "Sales", + "name": "Joe Doe", + "email": "joe@doe.com", + } + session = get_session() + person, created = add_person(session, Person(**data)) assert created is True - commit(db) + session.commit() + session.refresh(person) - db = connect() - # assert db[Person].get_by_pk(pk) == {"pk": pk, **data} - assert db[Balance].get_by_pk(pk).value == 500 - assert len(db[Movement].filter(person__pk=pk)) > 0 - assert db[Movement].filter(person__pk=pk).first().value == 500 + assert person.email == data["email"] + assert person.balance[0].value == 500 + assert len(person.movement) > 0 + assert person.movement[0].value == 500 @pytest.mark.unit def test_negative_add_person_invalid_email(): with pytest.raises(InvalidEmailError): - add_person({}, Person(pk=".@bla")) + add_person({}, Person(email=".@bla")) @pytest.mark.unit def test_add_or_remove_points_for_person(): - pk = "joe@doe.com" - data = {"role": "Salesman", "dept": "Sales", "name": "Joe Doe"} - db = connect() - person = Person(pk=pk, **data) - _, created = add_person(db, person) + data = { + "role": "Salesman", + "dept": "Sales", + "name": "Joe Doe", + "email": "joe@doe.com", + } + session = get_session() + person, created = add_person(session, Person(**data)) assert created is True - commit(db) + session.commit() - db = connect() - before = db[Balance].get_by_pk(pk).value + session.refresh(person) + before = person.balance[0].value - add_movement(db, person, -100, "manager") - commit(db) + add_movement(session, person, -100, "manager") + session.commit() - db = connect() - after = db[Balance].get_by_pk(pk).value + session.refresh(person) + after = person.balance[0].value assert after == before - 100 assert after == 400 diff --git a/tests/test_load.py b/tests/test_load.py index 82d7314..71d4ef1 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -1,7 +1,6 @@ import pytest from dundie.core import load -from dundie.database import EMPTY_DB, ORM, connect from .constants import PEOPLE_FILE @@ -18,11 +17,3 @@ def test_load_positive_has_2_people(request): def test_load_positive_first_name_starts_with_j(request): """Test function load function.""" assert load(PEOPLE_FILE)[0]["name"] == "Jim Halpert" - - -@pytest.mark.unit -def test_db_schema(): - load(PEOPLE_FILE) - db = connect() - db_keys = {ORM.get_table_name(model) for model in db} - assert db_keys == EMPTY_DB.keys() diff --git a/tests/test_read.py b/tests/test_read.py index 704d9e2..67c58d5 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -1,27 +1,36 @@ import pytest from dundie.core import load, read -from dundie.database import add_person, commit, connect +from dundie.database import get_session from dundie.models import Person +from dundie.utils.db import add_person from .constants import PEOPLE_FILE @pytest.mark.unit def test_read_with_query(): - db = connect() - - pk = "joe@doe.com" - data = {"role": "Salesman", "dept": "Sales", "name": "Joe Doe"} - _, created = add_person(db, Person(pk=pk, **data)) + session = get_session() + + data = { + "role": "Salesman", + "dept": "Sales", + "name": "Joe Doe", + "email": "joe@doe.com", + } + _, created = add_person(session, Person(**data)) assert created is True - pk = "jim@doe.com" - data = {"role": "Manager", "dept": "Management", "name": "Jim Doe"} - _, created = add_person(db, Person(pk=pk, **data)) + data = { + "role": "Manager", + "dept": "Management", + "name": "Jim Doe", + "email": "jim@doe.com", + } + _, created = add_person(session, Person(**data)) assert created is True - commit(db) + session.commit() response = read() assert len(response) == 2 diff --git a/tests/test_utils.py b/tests/test_utils.py index 0ca15bd..958b77a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,6 +4,38 @@ from dundie.utils.user import generate_simple_password +@pytest.mark.unit +@pytest.mark.parametrize( + "address", ["bruno@rocha.com", "joe@doe.com", "a@b.pt"] +) +def test_positive_check_valid_email(address): + """Ensure email is valid.""" + assert check_valid_email(address) is True + + +@pytest.mark.unit +@pytest.mark.parametrize("address", ["bruno@.com", "@doe.com", "a@b"]) +def test_negative_check_valid_email(address): + """Ensure email is invalid.""" + assert check_valid_email(address) is False + + +@pytest.mark.unit +def test_generate_simple_password(): + """Test generation of random simple passwords + TODO: Generate hashed complex passwords, encrypit it + """ + passwords = [] + for _ in range(100): + passwords.append(generate_simple_password(8)) + + assert len(set(passwords)) == 100 +import pytest + +from dundie.utils.email import check_valid_email +from dundie.utils.user import generate_simple_password + + @pytest.mark.unit @pytest.mark.parametrize( "address", ["bruno@rocha.com", "joe@doe.com", "a@b.pt"]