From b98690907a3f98baded1b3715af576c9193a5505 Mon Sep 17 00:00:00 2001 From: Victor Magueta <56882461+vmagueta@users.noreply.github.com> Date: Mon, 1 Jul 2024 10:18:00 -0300 Subject: [PATCH] Implemented JSON database and Add Commands (#16) * Implemented JSON database closes #1 * Add commands - show - add - remove closes #2 closes #3 --- .github/workflows/main.yml | 2 +- Makefile | 4 +- assets/database.example.json | 28 ++++++++ assets/database.json | 128 +++++++++++++++++++++++++++++++++++ assets/people.csv | 2 +- conftest.py | 34 ++++++++++ dundie/cli.py | 52 +++++++++++++- dundie/core.py | 67 ++++++++++++++++-- dundie/database.py | 71 +++++++++++++++++++ dundie/settings.py | 11 +++ dundie/utils/email.py | 33 +++++++++ dundie/utils/user.py | 10 +++ integration/conftest.py | 14 ---- integration/constants.py | 5 +- requirements.test.txt | 1 + tests/conftest.py | 12 ---- tests/constants.py | 5 +- tests/test_add.py | 28 ++++++++ tests/test_database.py | 65 ++++++++++++++++++ tests/test_load.py | 2 +- tests/test_read.py | 32 +++++++++ tests/test_utils.py | 33 +++++++++ 22 files changed, 600 insertions(+), 39 deletions(-) create mode 100644 assets/database.example.json create mode 100644 assets/database.json create mode 100644 conftest.py create mode 100644 dundie/database.py create mode 100644 dundie/settings.py create mode 100644 dundie/utils/email.py create mode 100644 dundie/utils/user.py delete mode 100644 integration/conftest.py delete mode 100644 tests/conftest.py create mode 100644 tests/test_add.py create mode 100644 tests/test_database.py create mode 100644 tests/test_read.py create mode 100644 tests/test_utils.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 887e695..19872e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,7 +52,7 @@ jobs: - name: Install Project run: pip install '.[test]' - name: Run tests - run: pytest -v --junitxml=test-result.xml + run: pytest -v --forked --junitxml=test-result.xml - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2.16.1 if: always() diff --git a/Makefile b/Makefile index d8b8a81..47b03a0 100644 --- a/Makefile +++ b/Makefile @@ -24,12 +24,12 @@ lint: test: - @.venv/bin/pytest -s + @.venv/bin/pytest -s --forked watch: # @.venv/bin/ptw - @ls **/*.py | entr pytest + @ls **/*.py | entr pytest --forked clean: ## Clean unused files. diff --git a/assets/database.example.json b/assets/database.example.json new file mode 100644 index 0000000..a87cded --- /dev/null +++ b/assets/database.example.json @@ -0,0 +1,28 @@ +{ + "people": { + "joe@doe.com": { + "role": "Salesman", + "dept": "Sales", + "active": true, + "name": "Joe Doe" + } + }, + "balance": { + "joe@doe.com": 500 + }, + "movement": { + "joe@doe.com":[ + { + "date": "", + "value": 0, + "actor": "system" + } + ] + }, + "user": { + "joe@doe.com": { + "password": "sdjfbsdjhfbsjhd", + "is_admin": false + } + } +} diff --git a/assets/database.json b/assets/database.json new file mode 100644 index 0000000..95d7365 --- /dev/null +++ b/assets/database.json @@ -0,0 +1,128 @@ +{ + "people": { + "jim@dundermifflin.com": { + "name": "Jim Halpert", + "dept": "Sales", + "role": "Salesman" + }, + "schrute@dundermifflin.com": { + "name": "Dwight Schrute", + "dept": "Sales", + "role": "Manager" + }, + "glewis@dundermifflin.com": { + "name": "Gabe Lewis", + "dept": "C-Level", + "role": "CEO" + } + }, + "balance": { + "jim@dundermifflin.com": 810, + "schrute@dundermifflin.com": 410, + "glewis@dundermifflin.com": 100 + }, + "movement": { + "jim@dundermifflin.com": [ + { + "date": "2024-06-30T13:17:00.085814", + "actor": "system", + "value": 500 + }, + { + "date": "2024-07-01T09:38:03.015041", + "actor": "solermvictor", + "value": 30 + }, + { + "date": "2024-07-01T09:38:15.965008", + "actor": "solermvictor", + "value": 332 + }, + { + "date": "2024-07-01T09:43:45.392936", + "actor": "solermvictor", + "value": -62 + }, + { + "date": "2024-07-01T09:45:45.790470", + "actor": "solermvictor", + "value": 62 + }, + { + "date": "2024-07-01T09:45:52.961517", + "actor": "solermvictor", + "value": -62 + }, + { + "date": "2024-07-01T09:47:39.710349", + "actor": "solermvictor", + "value": 5 + }, + { + "date": "2024-07-01T09:47:47.852407", + "actor": "solermvictor", + "value": 5 + } + ], + "schrute@dundermifflin.com": [ + { + "date": "2024-06-30T13:17:00.086117", + "actor": "system", + "value": 100 + }, + { + "date": "2024-07-01T09:38:03.015054", + "actor": "solermvictor", + "value": 30 + }, + { + "date": "2024-07-01T09:38:15.965025", + "actor": "solermvictor", + "value": 332 + }, + { + "date": "2024-07-01T09:43:45.392949", + "actor": "solermvictor", + "value": -62 + }, + { + "date": "2024-07-01T09:45:45.790483", + "actor": "solermvictor", + "value": 62 + }, + { + "date": "2024-07-01T09:45:52.961531", + "actor": "solermvictor", + "value": -62 + }, + { + "date": "2024-07-01T09:47:39.710364", + "actor": "solermvictor", + "value": 5 + }, + { + "date": "2024-07-01T09:47:47.852421", + "actor": "solermvictor", + "value": 5 + } + ], + "glewis@dundermifflin.com": [ + { + "date": "2024-06-30T13:17:00.086294", + "actor": "system", + "value": 100 + } + ] + }, + "users": { + "jim@dundermifflin.com": { + "password": "81sNc0oJ" + }, + "schrute@dundermifflin.com": { + "password": "HcQZ0aGz" + }, + "glewis@dundermifflin.com": { + "password": "SR2jr3iG" + } + } +} \ No newline at end of file diff --git a/assets/people.csv b/assets/people.csv index 7c58e09..1efc0f2 100644 --- a/assets/people.csv +++ b/assets/people.csv @@ -1,3 +1,3 @@ Jim Halpert, Sales, Salesman, jim@dundermifflin.com Dwight Schrute, Sales, Manager, schrute@dundermifflin.com -Gabe Lewis, Directory, Manager, glewis@dundermifflin.com +Gabe Lewis, C-Level, CEO, glewis@dundermifflin.com diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..c5bd8a1 --- /dev/null +++ b/conftest.py @@ -0,0 +1,34 @@ +import pytest +from unittest.mock import patch + + +MARKER = """\ +unit: Mark unit tests +integration: Mark integration tests +high: High priority +medium: Medium priority +low: Low priority +""" + + +def pytest_configure(config): + for line in MARKER.split("\n"): + config.addinivalue_line("markers", line) + + +@pytest.fixture(autouse=True) +def go_to_tmpdir(request): # injeção de dependencias + tmpdir = request.getfixturevalue("tmpdir") + with tmpdir.as_cwd(): + yield # protocolo de generators + + +@pytest.fixture(autouse=True, scope="function") +def setup_testing_database(request): + """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): + yield diff --git a/dundie/cli.py b/dundie/cli.py index f381128..0610fc8 100644 --- a/dundie/cli.py +++ b/dundie/cli.py @@ -1,3 +1,4 @@ +import json from importlib import metadata import rich_click as click @@ -35,13 +36,60 @@ def load(filepath): - Loads to database """ table = Table(title="Dunder Mifflin Associates") - headers = ["name", "dept", "role", "e-mail"] + headers = ["name", "dept", "role", "created", "e-mail"] for header in headers: table.add_column(header, style="italic cyan1") result = core.load(filepath) for person in result: - table.add_row(*[field.strip() for field in person.split(",")]) + table.add_row(*[str(value) for value in person.values()]) console = Console() console.print(table) + + +@main.command() +@click.option("--dept", required=False) +@click.option("--email", required=False) +@click.option("--output", default=None) +def show(output, **query): + """Shows information about users.""" + result = core.read(**query) + if output: + with open(output, "w") as output_file: + output_file.write(json.dumps(result)) + + if not result: + print("Nothing to show") + + table = Table(title="Dunder Mifflin Report") + for key in result[0]: + table.add_column(key.title(), style="italic cyan1") + + for person in result: + table.add_row(*[str(value) for value in person.values()]) + + console = Console() + console.print(table) + + +@main.command() +@click.argument("value", type=click.INT, required=True) +@click.option("--dept", required=False) +@click.option("--email", required=False) +@click.pass_context +def add(ctx, value, **query): + """Add points to the user or dept""" + core.add(value, **query) + ctx.invoke(show, **query) + + +@main.command() +@click.argument("value", type=click.INT, required=True) +@click.option("--dept", required=False) +@click.option("--email", required=False) +@click.pass_context +def remove(ctx, value, **query): + """Remove points to the user or dept""" + core.add(-value, **query) + ctx.invoke(show, **query) diff --git a/dundie/core.py b/dundie/core.py index e0f9510..9f19bda 100644 --- a/dundie/core.py +++ b/dundie/core.py @@ -1,5 +1,9 @@ """Core module of dundie""" +import os +from csv import reader + +from dundie.database import add_movement, add_person, commit, connect from dundie.utils.log import get_logger log = get_logger() @@ -10,12 +14,67 @@ def load(filepath): >>> len(load('assets/people.csv')) 2 - >>> load('assets/people.csv')[0][0] - 'J' """ try: - with open(filepath) as file_: - return [line.strip() for line in file_.readlines()] + csv_data = reader(open(filepath)) except FileNotFoundError as e: log.error(str(e)) raise e + + db = connect() + people = [] + headers = ["name", "dept", "role", "e-mail"] + for line in csv_data: + person_data = dict(zip(headers, [item.strip() for item in line])) + pk = person_data.pop("e-mail") + person, created = add_person(db, pk, person_data) + + return_data = person.copy() + return_data["created"] = created + return_data["email"] = pk + people.append(return_data) + + commit(db) + return people + + +def read(**query): + """Read data from db and filters using query + + read(email="joe@doe.com") + """ + db = connect() + return_data = [] + for pk, data in db["people"].items(): + + dept = query.get("dept") + if dept and dept != data["dept"]: + continue + + # WALRUS / Assignment Expression - a partir do python 3.8 + if (email := query.get("email")) and email != pk: + continue + + return_data.append( + { + "email": pk, + "balance": db["balance"][pk], + "last_movement": db["movement"][pk][-1]["date"], + **data, + } + ) + + return return_data + + +def add(value, **query): + """Add value to each record on query.""" + people = read(**query) + if not people: + raise RuntimeError("Not Found") + + db = connect() + user = os.getenv("USER") + for person in people: + add_movement(db, person["email"], value, user) + commit(db) diff --git a/dundie/database.py b/dundie/database.py new file mode 100644 index 0000000..cfa4e9e --- /dev/null +++ b/dundie/database.py @@ -0,0 +1,71 @@ +import json +from datetime import datetime + +from dundie.settings import DATABASE_PATH, EMAIL_FROM +from dundie.utils.email import check_valid_email, send_email +from dundie.utils.user import generate_simple_password + +EMPTY_DB = {"people": {}, "balance": {}, "movement": {}, "users": {}} + + +def connect() -> dict: + """Connects to the database, returns dict data.""" + try: + with open(DATABASE_PATH, "r") as database_file: + return json.loads(database_file.read()) + except (json.JSONDecodeError, FileNotFoundError): + return EMPTY_DB + + +def commit(db): + """Save db back to the database file.""" + if db.keys() != EMPTY_DB.keys(): + raise RuntimeError("Database Schema is invalid.") + + with open(DATABASE_PATH, "w") as database_file: + database_file.write(json.dumps(db, indent=4)) + + +def add_person(db, pk, data): + """Saves person data to database. + + - E-mail is unique (resolved by dictonary hash table) + - If exists, update, else create + - Set initial balance (managers = 100, others = 500) + - Generate a password if user is new, and send_email + """ + if not check_valid_email(pk): + raise ValueError(f"{pk} is not a valid e-mail.") + + table = db["people"] + person = table.get(pk, {}) + created = not bool(person) + person.update(data) + table[pk] = person + if created: + set_initial_balance(db, pk, person) + password = set_initial_password(db, pk) + send_email(EMAIL_FROM, pk, "Your dundie password", password) + # TODO: Encrypt and send only link,not password + return person, created + + +def set_initial_password(db, pk): + """Generates and saves password.""" + db["users"].setdefault(pk, {}) + db["users"][pk]["password"] = generate_simple_password(8) + return db["users"][pk]["password"] + + +def set_initial_balance(db, pk, person): + """Add movement and set initial balance.""" + value = 100 if person["role"] == "Manager" else 500 + add_movement(db, pk, value) + + +def add_movement(db, pk, value, actor="system"): + movements = db["movement"].setdefault(pk, []) + movements.append( + {"date": datetime.now().isoformat(), "actor": actor, "value": value} + ) + db["balance"][pk] = sum([item["value"] for item in movements]) diff --git a/dundie/settings.py b/dundie/settings.py new file mode 100644 index 0000000..6c5205f --- /dev/null +++ b/dundie/settings.py @@ -0,0 +1,11 @@ +import os + +SMTP_HOST = "Localhost" +SMTP_PORT = 8025 +SMTP_TIMEOUT = 5 + +EMAIL_FROM = "master@dundie.com" + + +ROOT_PATH = os.path.dirname(__file__) +DATABASE_PATH = os.path.join(ROOT_PATH, "..", "assets", "database.json") diff --git a/dundie/utils/email.py b/dundie/utils/email.py new file mode 100644 index 0000000..b3fd929 --- /dev/null +++ b/dundie/utils/email.py @@ -0,0 +1,33 @@ +import re +import smtplib +from email.mime.text import MIMEText + +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() + + +def check_valid_email(address): + """Return True if e-mail is valid.""" + return bool(re.fullmatch(regex, address)) + + +def send_email(from_, to, subject, text): + """Sends an email to the colaborator""" + if not isinstance(to, list): + to = [to] + + try: + 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()) + except Exception: + log.error("Cannot send email to %s", to) diff --git a/dundie/utils/user.py b/dundie/utils/user.py new file mode 100644 index 0000000..90f57c8 --- /dev/null +++ b/dundie/utils/user.py @@ -0,0 +1,10 @@ +from random import sample +from string import ascii_letters, digits + + +def generate_simple_password(size=8): + """Generate a simple random password + [A-Z][a-z][0-9] + """ + password = sample(ascii_letters + digits, size) + return "".join(password) diff --git a/integration/conftest.py b/integration/conftest.py deleted file mode 100644 index 576fc67..0000000 --- a/integration/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -MARKER = """\ -unit: Mark unit tests -integration: Mark integration tests -high: High priority -medium: Medium priority -low: Low priority -""" - - -def pytest_configure(config): - map( - lambda line: config.addinivalue_line("markers", line), - MARKER.split("\n"), - ) diff --git a/integration/constants.py b/integration/constants.py index 7caf9e6..faeee50 100644 --- a/integration/constants.py +++ b/integration/constants.py @@ -1 +1,4 @@ -PEOPLE_FILE = "tests/assets/people.csv" +import os + +TEST_PATH = os.path.dirname(__file__) +PEOPLE_FILE = os.path.join(TEST_PATH, "../tests", "assets/people.csv") diff --git a/requirements.test.txt b/requirements.test.txt index 590665e..41dfd4a 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,4 +1,5 @@ pytest +pytest-forked flake8 pyproject-flake8 black diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 6e0c26f..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -MARKER = """\ -unit: Mark unit tests -integration: Mark integration tests -high: High priority -medium: Medium priority -low: Low priority -""" - - -def pytest_configure(config): - for line in MARKER.split("\n"): - config.addinivalue_line("markers", line) diff --git a/tests/constants.py b/tests/constants.py index 7caf9e6..97b1ee3 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -1 +1,4 @@ -PEOPLE_FILE = "tests/assets/people.csv" +import os + +TEST_PATH = os.path.dirname(__file__) +PEOPLE_FILE = os.path.join(TEST_PATH, "assets/people.csv") diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..5ba5c08 --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,28 @@ +import pytest + +from dundie.core import add +from dundie.database import add_person, commit, connect + + +@pytest.mark.unit +def test_add_movement(): + db = connect() + + pk = "joe@doe.com" + data = {"role": "Salesman", "dept": "Sales", "name": "Joe Doe"} + person, created = add_person(db, pk, data) + assert created is True + + pk = "jim@doe.com" + data = {"role": "Manager", "dept": "Management", "name": "Jim Doe"} + person, created = add_person(db, pk, data) + assert created is True + + commit(db) + + add(-30, email="joe@doe.com") + add(90, dept="Management") + + db = connect() + assert db["balance"]["joe@doe.com"] == 470 + assert db["balance"]["jim@doe.com"] == 190 diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..ffe3e5c --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,65 @@ +import pytest + +from dundie.database import EMPTY_DB, add_movement, add_person, commit, connect + + +@pytest.mark.unit +def test_database_schema(): + db = connect() + assert db.keys() == EMPTY_DB.keys() + + +@pytest.mark.unit +def test_commit_to_database(): + db = connect() + data = {"name": "Joe Doe", "role": "Salesman", "dept": "Sales"} + db["people"]["joe@doe.com"] = data + commit(db) + + db = connect() + assert db["people"]["joe@doe.com"] == data + + +@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() + person, created = add_person(db, pk, data) + assert created is True + commit(db) + + db = connect() + assert db["people"][pk] == data + assert db["balance"][pk] == 500 + assert len(db["movement"][pk]) > 0 + assert db["movement"][pk][0]["value"] == 500 + + +@pytest.mark.unit +def test_negative_add_person_invalid_email(): + with pytest.raises(ValueError): + add_person({}, ".@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, created = add_person(db, pk, data) + assert created is True + commit(db) + + db = connect() + before = db["balance"][pk] + + add_movement(db, pk, -100, "manager") + commit(db) + + db = connect() + after = db["balance"][pk] + + assert after == before - 100 + assert after == 400 + assert before == 500 diff --git a/tests/test_load.py b/tests/test_load.py index cd5b0e5..da0fe23 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -16,4 +16,4 @@ def test_load_positive_has_3_people(request): @pytest.mark.high def test_load_positive_first_name_starts_with_j(request): """Test load function starts with letter J.""" - assert load(PEOPLE_FILE)[0][0] == "J" + assert load(PEOPLE_FILE)[0]["name"] == "Jim Halpert" diff --git a/tests/test_read.py b/tests/test_read.py new file mode 100644 index 0000000..a990f55 --- /dev/null +++ b/tests/test_read.py @@ -0,0 +1,32 @@ +import pytest + +from dundie.core import read +from dundie.database import add_person, commit, connect + + +@pytest.mark.unit +def test_read_with_query(): + db = connect() + + pk = "joe@doe.com" + data = {"role": "Salesman", "dept": "Sales", "name": "Joe Doe"} + person, created = add_person(db, pk, data) + assert created is True + + pk = "jim@doe.com" + data = {"role": "Manager", "dept": "Management", "name": "Jim Doe"} + person, created = add_person(db, pk, data) + assert created is True + + commit(db) + + response = read() + assert len(response) == 2 + + response = read(dept="Management") + assert len(response) == 1 + assert response[0]["name"] == "Jim Doe" + + response = read(email="joe@doe.com") + assert len(response) == 1 + assert response[0]["name"] == "Joe Doe" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..f037ad8 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,33 @@ +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"] +) +def test_positive_check_valid_email(address): + """Ensure e-mail 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 e-mail is valid.""" + 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 i in range(100): + passwords.append(generate_simple_password(8)) + + print(passwords) + assert (len(set(passwords))) == 100