From 66e6c2f2d38523533b42a239161521e58c0d5b29 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 29 Jan 2023 13:34:42 +0100 Subject: [PATCH 1/6] checkpoint: validation for Project.tag --- app/demo.py | 5 ++-- tuttle/model.py | 47 +++++++++++++++++++++++--------------- tuttle_tests/test_model.py | 3 ++- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/app/demo.py b/app/demo.py index da0bf9c9..fa32c5d6 100644 --- a/app/demo.py +++ b/app/demo.py @@ -106,11 +106,12 @@ def create_fake_project( fake: faker.Faker, ): project_title = fake.bs() + project_tag = f"#{'-'.join(project_title.split(' ')[:2]).lower()}" + project = Project( title=project_title, - tag="-".join(project_title.split(" ")[:2]).lower(), + tag=project_tag, description=fake.paragraph(nb_sentences=2), - unique_tag=project_title.split(" ")[0].lower(), is_completed=fake.pybool(), start_date=datetime.date.today(), end_date=datetime.date.today() + datetime.timedelta(days=80), diff --git a/tuttle/model.py b/tuttle/model.py index 70fd8983..c97b6389 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -1,30 +1,27 @@ """Object model.""" -import email -from typing import Optional, List, Dict, Type -from pydantic import constr, BaseModel, condecimal -from enum import Enum +from typing import Dict, List, Optional, Type + +import re import datetime +import decimal +import email import hashlib -import uuid +import string import textwrap +import uuid +from decimal import Decimal +from enum import Enum +import pandas import sqlalchemy -from sqlmodel import ( - SQLModel, - Field, - Relationship, -) # from pydantic import str -import decimal -from decimal import Decimal -import pandas - - -from .time import Cycle, TimeUnit +from pydantic import BaseModel, condecimal, constr, validator +from sqlmodel import Field, Relationship, SQLModel, Constraint from .dev import deprecated +from .time import Cycle, TimeUnit def help(model_class): @@ -369,8 +366,10 @@ class Project(SQLModel, table=True): description: str = Field( description="A longer description of the project", default="" ) - # TODO: tag: constr(regex=r"#\S+") - tag: str = Field(description="A unique tag", sa_column_kwargs={"unique": True}) + tag: str = Field( + description="A unique tag, starting with a # symbol", + sa_column_kwargs={"unique": True}, + ) start_date: datetime.date end_date: datetime.date is_completed: bool = Field( @@ -393,6 +392,7 @@ class Project(SQLModel, table=True): sa_relationship_kwargs={"lazy": "subquery"}, ) + # PROPERTIES @property def client(self) -> Optional[Client]: if self.contract: @@ -400,6 +400,16 @@ def client(self) -> Optional[Client]: else: return None + # VALIDATORS + @validator("tag") + def validate_tag(cls, v): + if not re.match(r"^#\S+$", v): + raise ValueError( + "Tag must start with a # symbol and not contain any punctuation or whitespace." + ) + return v + + @deprecated def get_brief_description(self): if len(self.description) <= 108: return self.description @@ -420,6 +430,7 @@ def is_upcoming(self) -> bool: today = datetime.date.today() return self.start_date > today + # FIXME: replace string literals with enum def get_status(self, default: str = "") -> str: if self.is_active(): return "Active" diff --git a/tuttle_tests/test_model.py b/tuttle_tests/test_model.py index 956c0358..5f8bb33b 100644 --- a/tuttle_tests/test_model.py +++ b/tuttle_tests/test_model.py @@ -72,7 +72,7 @@ def test_user(): def test_project(): project = model.Project( title="Heating Repair", - tag="#heating", + tag="#heating-repair", start_date=datetime.date.today(), end_date=datetime.date.today() + datetime.timedelta(days=80), ) @@ -86,6 +86,7 @@ def test_contract(): invoicing_contact=model.Contact( first_name="Central", last_name="Services", + company="Central Services", address=model.Address( street="Down the Road", number="55", From 7669664e0453c45661e5391bbf53d1b8d5618f01 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 29 Jan 2023 23:56:27 +0100 Subject: [PATCH 2/6] checkpoint: model validation approaches tested --- app/demo.py | 110 +++++++++++++++++-------------------- app/projects/view.py | 2 +- tuttle/calendar.py | 2 +- tuttle/model.py | 26 ++++++--- tuttle/rendering.py | 4 +- tuttle_tests/test_model.py | 89 ++++++++++++++++++++++++++++-- 6 files changed, 156 insertions(+), 77 deletions(-) diff --git a/app/demo.py b/app/demo.py index fa32c5d6..8ca61e89 100644 --- a/app/demo.py +++ b/app/demo.py @@ -1,62 +1,59 @@ -from typing import List, Optional, Callable +from typing import Callable, List, Optional +import datetime import random +from datetime import date, timedelta from pathlib import Path -from tuttle.calendar import Calendar, ICSCalendar +from decimal import Decimal + import faker -import random -import datetime -from datetime import timedelta, date import ics -from sqlmodel import Field, SQLModel, create_engine, Session, select +import numpy import sqlalchemy from loguru import logger -import numpy +from sqlmodel import Field, Session, SQLModel, create_engine, select +from tuttle import rendering +from tuttle.calendar import Calendar, ICSCalendar from tuttle.model import ( Address, - Contact, + BankAccount, Client, - Project, + Contact, Contract, - TimeUnit, Cycle, - User, - BankAccount, Invoice, InvoiceItem, + Project, + TimeUnit, + User, ) -from tuttle import rendering def create_fake_contact( fake: faker.Faker, ): - try: - street_line, city_line = fake.address().splitlines() - a = Address( - id=id, - street=street_line.split(" ")[0], - number=street_line.split(" ")[1], - city=city_line.split(" ")[1], - postal_code=city_line.split(" ")[0], - country=fake.country(), - ) - first_name, last_name = fake.name().split(" ", 1) - contact = Contact( - id=id, - first_name=first_name, - last_name=last_name, - email=fake.email(), - company=fake.company(), - address_id=a.id, - address=a, - ) - return contact - except Exception as ex: - logger.error(ex) - logger.error(f"Failed to create fake contact, trying again") - return create_fake_contact(fake) + + split_address_lines = fake.address().splitlines() + street_line = split_address_lines[0] + city_line = split_address_lines[1] + a = Address( + street=street_line, + number=city_line, + city=city_line.split(" ")[1], + postal_code=city_line.split(" ")[0], + country=fake.country(), + ) + first_name, last_name = fake.name().split(" ", 1) + contact = Contact( + first_name=first_name, + last_name=last_name, + email=fake.email(), + company=fake.company(), + address_id=a.id, + address=a, + ) + return contact def create_fake_client( @@ -64,10 +61,10 @@ def create_fake_client( fake: faker.Faker, ): client = Client( - id=id, name=fake.company(), invoicing_contact=invoicing_contact, ) + assert client.invoicing_contact is not None return client @@ -92,7 +89,7 @@ def create_fake_contract( start_date=fake.date_this_year(after_today=True), rate=rate, currency="EUR", # TODO: Use actual currency - VAT_rate=round(random.uniform(0.05, 0.2), 2), + VAT_rate=Decimal(round(random.uniform(0.05, 0.2), 2)), unit=unit, units_per_workday=random.randint(1, 12), volume=fake.random_int(1, 1000), @@ -147,7 +144,7 @@ def create_fake_invoice( """ invoice_number = next(invoice_number_counter) invoice = Invoice( - number=invoice_number, + number=str(invoice_number), date=datetime.date.today(), sent=fake.pybool(), paid=fake.pybool(), @@ -159,6 +156,7 @@ def create_fake_invoice( number_of_items = fake.random_int(min=1, max=5) for _ in range(number_of_items): unit = fake.random_element(elements=("hours", "days")) + unit_price = 0 if unit == "hours": unit_price = abs(round(numpy.random.normal(50, 20), 2)) elif unit == "days": @@ -169,12 +167,11 @@ def create_fake_invoice( end_date=fake.date_this_decade(), quantity=fake.random_int(min=1, max=10), unit=unit, - unit_price=unit_price, + unit_price=Decimal(unit_price), description=fake.sentence(), - VAT_rate=vat_rate, + VAT_rate=Decimal(vat_rate), invoice=invoice, ) - assert invoice_item.invoice == invoice try: rendering.render_invoice( @@ -231,7 +228,6 @@ def create_demo_user() -> User: phone_number="+55555555555", VAT_number="27B-6", address=Address( - name="Harry Tuttle", street="Main Street", number="450", city="Somewhere", @@ -248,6 +244,14 @@ def create_demo_user() -> User: def create_fake_calendar(project_list: List[Project]) -> ics.Calendar: + def random_datetime(start, end): + return start + timedelta( + seconds=random.randint(0, int((end - start).total_seconds())) + ) + + def random_duration(): + return timedelta(hours=random.randint(1, 8)) + # create a new calendar calendar = ics.Calendar() @@ -262,7 +266,7 @@ def create_fake_calendar(project_list: List[Project]) -> ics.Calendar: for _ in range(random.randint(1, 5)): # create a new event event = ics.Event() - event.name = f"Meeting for #{project.tag}" + event.name = f"Meeting for {project.tag}" # set the event's begin and end datetime event.begin = random_datetime(month_ago, now) @@ -273,16 +277,6 @@ def create_fake_calendar(project_list: List[Project]) -> ics.Calendar: return calendar -def random_datetime(start, end): - return start + timedelta( - seconds=random.randint(0, int((end - start).total_seconds())) - ) - - -def random_duration(): - return timedelta(hours=random.randint(1, 8)) - - def install_demo_data( n_projects: int, db_path: str, @@ -336,7 +330,3 @@ def install_demo_data( for project in projects: session.add(project) session.commit() - - -if __name__ == "__main__": - install_demo_data(n_projects=10) diff --git a/app/projects/view.py b/app/projects/view.py index e0fab420..4f296839 100644 --- a/app/projects/view.py +++ b/app/projects/view.py @@ -62,7 +62,7 @@ def build(self): ), title=views.StdBodyText(self.project.title), subtitle=views.StdBodyText( - f"#{self.project.tag}", + f"{self.project.tag}", color=colors.GRAY_COLOR, weight=FontWeight.BOLD, ), diff --git a/tuttle/calendar.py b/tuttle/calendar.py index d5816bb6..a634e6a8 100644 --- a/tuttle/calendar.py +++ b/tuttle/calendar.py @@ -21,7 +21,7 @@ def extract_hashtag(string) -> str: """Extract the first hashtag from a string.""" - match = re.search(r"#(\S+)", string) + match = re.search(r"(#\S+)", string) if match: return match.group(1) else: diff --git a/tuttle/model.py b/tuttle/model.py index c97b6389..462ed7c8 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -18,13 +18,14 @@ # from pydantic import str from pydantic import BaseModel, condecimal, constr, validator -from sqlmodel import Field, Relationship, SQLModel, Constraint +from sqlmodel import SQLModel, Field, Relationship, Constraint + from .dev import deprecated from .time import Cycle, TimeUnit -def help(model_class): +def help(model_class: Type[BaseModel]): return pandas.DataFrame( ( (field_name, field.field_info.description) @@ -125,7 +126,7 @@ class User(SQLModel, table=True): back_populates="users", sa_relationship_kwargs={"lazy": "subquery"}, ) - VAT_number: str = Field( + VAT_number: Optional[str] = Field( description="Value Added Tax number of the user, legally required for invoices.", ) # User 1:1* ICloudAccount @@ -146,7 +147,7 @@ class User(SQLModel, table=True): sa_relationship_kwargs={"lazy": "subquery"}, ) # TODO: path to logo image - logo: Optional[str] + # logo: Optional[str] = Field(default=None) @property def bank_account_not_set(self) -> bool: @@ -207,6 +208,14 @@ class Contact(SQLModel, table=True): ) # post address + # VALIDATORS + @validator("email") + def email_validator(cls, v): + """Validate email address format.""" + if not re.match(r"[^@]+@[^@]+\.[^@]+", v): + raise ValueError("Not a valid email address") + return v + @property def name(self): if self.first_name and self.last_name: @@ -248,7 +257,9 @@ class Client(SQLModel, table=True): """A client the freelancer has contracted with.""" id: Optional[int] = Field(default=None, primary_key=True) - name: str = Field(default="") + name: str = Field( + description="Name of the client.", + ) # Client 1:1 invoicing Contact invoicing_contact_id: int = Field(default=None, foreign_key="contact.id") invoicing_contact: Contact = Relationship( @@ -361,10 +372,11 @@ class Project(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str = Field( - description="A short, unique title", sa_column_kwargs={"unique": True} + description="A short, unique title", + sa_column_kwargs={"unique": True}, ) description: str = Field( - description="A longer description of the project", default="" + description="A longer description of the project", ) tag: str = Field( description="A unique tag, starting with a # symbol", diff --git a/tuttle/rendering.py b/tuttle/rendering.py index 0799f142..e28df137 100644 --- a/tuttle/rendering.py +++ b/tuttle/rendering.py @@ -118,10 +118,10 @@ def render_invoice( user: User, invoice: Invoice, document_format: str = "pdf", - out_dir: str = None, + out_dir=None, style: str = "anvil", only_final: bool = False, -) -> str: +): """Render an Invoice using an HTML template. Args: diff --git a/tuttle_tests/test_model.py b/tuttle_tests/test_model.py index 5f8bb33b..4cf80375 100644 --- a/tuttle_tests/test_model.py +++ b/tuttle_tests/test_model.py @@ -1,16 +1,27 @@ -#!/usr/bin/env python - """Tests for the database model.""" +import datetime +import os +import sqlite3 from pathlib import Path from tracemalloc import stop + +import pytest from loguru import logger -from sqlmodel import create_engine, SQLModel, Session, select -import sqlite3 -import os -import datetime +from pydantic import EmailStr, ValidationError +from sqlmodel import Session, SQLModel, create_engine, select from tuttle import model, time +from tuttle.model import ( + Address, + Client, + Contact, + Contract, + Project, + User, + TimeUnit, + Cycle, +) def store_and_retrieve(model_object): @@ -112,3 +123,69 @@ def test_contract(): units_per_workday=8, ) assert store_and_retrieve(the_contract) + + +class TestContact: + def test_valid_contact_instantiation(self): + contact = Contact( + first_name="Sam", + last_name="Lowry", + email="sam.lowry@miniinf.gov", + company="Ministry of Information", + ) + assert store_and_retrieve(contact) + + def test_invalid_email_instantiation(self): + with pytest.raises(ValidationError): + Contact.validate( + dict( + first_name="Sam", + last_name="Lowry", + email="27B-", + company="Ministry of Information", + ) + ) + + +class TestClient: + def test_valid_instantiation(self): + invoicing_contact = Contact( + first_name="Sam", + last_name="Lowry", + email="sam.lowry@miniinf.gov", + company="Ministry of Information", + ) + client = Client( + name="Ministry of Information", invoicing_contact=invoicing_contact + ) + assert store_and_retrieve(client) + + def test_missing_fields_instantiation(self): + with pytest.raises(ValidationError): + Client() # type: ignore + + +class TestContract: + def test_valid_instantiation(self): + client = Client(name="Ministry of Information") + contract = Contract( + title="Project X Contract", + client=client, + signature_date=datetime.date(2022, 10, 1), + start_date=datetime.date(2022, 10, 2), + end_date=datetime.date(2022, 12, 31), + rate=100, + is_completed=False, + currency="USD", + VAT_rate=0.19, + unit=TimeUnit.hour, + units_per_workday=8, + volume=100, + term_of_payment=31, + billing_cycle=Cycle.monthly, + ) + assert store_and_retrieve(contract) + + def test_missing_fields_instantiation(self): + with pytest.raises(ValidationError): + Contract() # type: ignore From 67effab9572ac5ac07c2ca5591074967c246deb7 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Mon, 30 Jan 2023 00:27:27 +0100 Subject: [PATCH 3/6] test: model unit tests updated --- tuttle_tests/test_model.py | 114 +++++++++++++++++++++---------------- 1 file changed, 64 insertions(+), 50 deletions(-) diff --git a/tuttle_tests/test_model.py b/tuttle_tests/test_model.py index 4cf80375..af460d9c 100644 --- a/tuttle_tests/test_model.py +++ b/tuttle_tests/test_model.py @@ -80,51 +80,6 @@ def test_user(): assert icloud_account.user.name == "Archibald Tuttle" -def test_project(): - project = model.Project( - title="Heating Repair", - tag="#heating-repair", - start_date=datetime.date.today(), - end_date=datetime.date.today() + datetime.timedelta(days=80), - ) - assert store_and_retrieve(project) - - -def test_contract(): - - the_client = model.Client( - name="Central Services", - invoicing_contact=model.Contact( - first_name="Central", - last_name="Services", - company="Central Services", - address=model.Address( - street="Down the Road", - number="55", - city="Somewhere", - postal_code="99999", - country="Brazil", - ), - email="mail@centralservices.com", - ), - ) - - the_contract = model.Contract( - title="CS Q1 2022", - client=the_client, - start_date=datetime.date(2022, 1, 1), - end_date=datetime.date(2022, 3, 31), - signature_date=datetime.date(2021, 10, 31), - rate=100, - unit=time.TimeUnit.hour, - currency="EUR", - billing_cycle=time.Cycle.monthly, - volume=3 * 8 * 8, - units_per_workday=8, - ) - assert store_and_retrieve(the_contract) - - class TestContact: def test_valid_contact_instantiation(self): contact = Contact( @@ -148,6 +103,8 @@ def test_invalid_email_instantiation(self): class TestClient: + """Tests for the Client model.""" + def test_valid_instantiation(self): invoicing_contact = Contact( first_name="Sam", @@ -155,17 +112,52 @@ def test_valid_instantiation(self): email="sam.lowry@miniinf.gov", company="Ministry of Information", ) - client = Client( - name="Ministry of Information", invoicing_contact=invoicing_contact + client = Client.validate( + dict( + name="Ministry of Information", + invoicing_contact=invoicing_contact, + ) ) assert store_and_retrieve(client) def test_missing_fields_instantiation(self): with pytest.raises(ValidationError): - Client() # type: ignore + Client.validate(dict()) class TestContract: + """Tests for the Contract model.""" + + def test_valid_instantiation(self): + client = Client(name="Ministry of Information") + contract = Contract.validate( + dict( + title="Project X Contract", + client=client, + signature_date=datetime.date(2022, 10, 1), + start_date=datetime.date(2022, 10, 2), + end_date=datetime.date(2022, 12, 31), + rate=100, + is_completed=False, + currency="USD", + VAT_rate=0.19, + unit=TimeUnit.hour, + units_per_workday=8, + volume=100, + term_of_payment=31, + billing_cycle=Cycle.monthly, + ) + ) + assert store_and_retrieve(contract) + + def test_missing_fields_instantiation(self): + with pytest.raises(ValidationError): + Contract.validate(dict()) + + +class TestProject: + """Tests for the Project model.""" + def test_valid_instantiation(self): client = Client(name="Ministry of Information") contract = Contract( @@ -184,8 +176,30 @@ def test_valid_instantiation(self): term_of_payment=31, billing_cycle=Cycle.monthly, ) - assert store_and_retrieve(contract) + project = Project.validate( + dict( + title="Project X", + description="The description of Project X", + tag="#project_x", + start_date=datetime.date(2022, 10, 2), + end_date=datetime.date(2022, 12, 31), + contract=contract, + ) + ) + assert store_and_retrieve(project) def test_missing_fields_instantiation(self): with pytest.raises(ValidationError): - Contract() # type: ignore + Project.validate(dict()) + + def test_invalid_tag_instantiation(self): + with pytest.raises(ValidationError): + Project.validate( + dict( + title="Project X", + description="The description of Project X", + tag="project_x", + start_date=datetime.date(2022, 10, 2), + end_date=datetime.date(2022, 12, 31), + ) + ) From bbdca62aaa17a4465faad5468bbe123b6e69a426 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Mon, 30 Jan 2023 01:58:09 +0100 Subject: [PATCH 4/6] checkpoint: contact unit test --- tuttle_tests/test_model.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tuttle_tests/test_model.py b/tuttle_tests/test_model.py index af460d9c..f9922311 100644 --- a/tuttle_tests/test_model.py +++ b/tuttle_tests/test_model.py @@ -81,16 +81,18 @@ def test_user(): class TestContact: - def test_valid_contact_instantiation(self): - contact = Contact( - first_name="Sam", - last_name="Lowry", - email="sam.lowry@miniinf.gov", - company="Ministry of Information", + def test_valid_instantiation(self): + contact = Contact.validate( + dict( + first_name="Sam", + last_name="Lowry", + email="sam.lowry@miniinf.gov", + company="Ministry of Information", + ) ) assert store_and_retrieve(contact) - def test_invalid_email_instantiation(self): + def test_invalid_email(self): with pytest.raises(ValidationError): Contact.validate( dict( From d2acf20dbf78227d981e79dbf260aee5e2b14ba6 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Mon, 30 Jan 2023 09:52:38 +0100 Subject: [PATCH 5/6] test: ValidationError handling --- tuttle_tests/test_model.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tuttle_tests/test_model.py b/tuttle_tests/test_model.py index f9922311..1622d78c 100644 --- a/tuttle_tests/test_model.py +++ b/tuttle_tests/test_model.py @@ -122,6 +122,19 @@ def test_valid_instantiation(self): ) assert store_and_retrieve(client) + def test_missing_name(self): + """Test that a ValidationError is raised when the name is missing.""" + with pytest.raises(ValidationError): + Client.validate(dict()) + + try: + client = Client.validate(dict()) + except ValidationError as ve: + for error in ve.errors(): + field_name = error.get("loc")[0] + error_message = error.get("msg") + assert field_name == "name" + def test_missing_fields_instantiation(self): with pytest.raises(ValidationError): Client.validate(dict()) From 35b4954502123bdb8457ab77100a3b1b0f47e40c Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Mon, 30 Jan 2023 10:00:02 +0100 Subject: [PATCH 6/6] test: delete old unit tests --- tuttle_tests/test_controller.py | 18 ------------------ tuttle_tests/test_model.py | 13 +++++++++++++ 2 files changed, 13 insertions(+), 18 deletions(-) delete mode 100644 tuttle_tests/test_controller.py diff --git a/tuttle_tests/test_controller.py b/tuttle_tests/test_controller.py deleted file mode 100644 index c51c1321..00000000 --- a/tuttle_tests/test_controller.py +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env python - -"""Tests for `tuttle` package.""" - -import pytest - -from tuttle import controller, preferences - - -def test_eval_time_planning(): - # TODO: - pass - - -def test_instantiate_controller(): - """Test that the controller can be instantiated.""" - con = controller.Controller(in_memory=True, preferences=preferences.Preferences()) - assert con is not None diff --git a/tuttle_tests/test_model.py b/tuttle_tests/test_model.py index 1622d78c..11224dc8 100644 --- a/tuttle_tests/test_model.py +++ b/tuttle_tests/test_model.py @@ -80,6 +80,19 @@ def test_user(): assert icloud_account.user.name == "Archibald Tuttle" +class TestUser: + """Tests for the User model.""" + + def test_valid_instantiation(self): + user = User.validate( + dict( + name="Harry Tuttle", + subtitle="Heating Engineer", + email="harry@tuttle.com", + ) + ) + + class TestContact: def test_valid_instantiation(self): contact = Contact.validate(