diff --git a/app/demo.py b/app/demo.py index da0bf9c9..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), @@ -106,11 +103,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), @@ -146,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(), @@ -158,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": @@ -168,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( @@ -230,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", @@ -247,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() @@ -261,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) @@ -272,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, @@ -335,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 5193ab07..41489cda 100644 --- a/app/projects/view.py +++ b/app/projects/view.py @@ -62,7 +62,7 @@ def build(self): ), title=views.TBodyText(self.project.title), subtitle=views.TBodyText( - 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 70fd8983..462ed7c8 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -1,33 +1,31 @@ """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 pydantic import BaseModel, condecimal, constr, validator +from sqlmodel import SQLModel, Field, Relationship, Constraint -from .time import Cycle, TimeUnit 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) @@ -128,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 @@ -149,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: @@ -210,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: @@ -251,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( @@ -364,13 +372,16 @@ 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", + sa_column_kwargs={"unique": True}, ) - # TODO: tag: constr(regex=r"#\S+") - tag: str = Field(description="A unique tag", sa_column_kwargs={"unique": True}) start_date: datetime.date end_date: datetime.date is_completed: bool = Field( @@ -393,6 +404,7 @@ class Project(SQLModel, table=True): sa_relationship_kwargs={"lazy": "subquery"}, ) + # PROPERTIES @property def client(self) -> Optional[Client]: if self.contract: @@ -400,6 +412,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 +442,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/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_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 956c0358..11224dc8 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): @@ -69,45 +80,154 @@ def test_user(): assert icloud_account.user.name == "Archibald Tuttle" -def test_project(): - project = model.Project( - title="Heating Repair", - tag="#heating", - 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", - address=model.Address( - street="Down the Road", - number="55", - city="Somewhere", - postal_code="99999", - country="Brazil", - ), - email="mail@centralservices.com", - ), - ) +class TestUser: + """Tests for the User model.""" - 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) + 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( + 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(self): + with pytest.raises(ValidationError): + Contact.validate( + dict( + first_name="Sam", + last_name="Lowry", + email="27B-", + company="Ministry of Information", + ) + ) + + +class TestClient: + """Tests for the Client model.""" + + 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.validate( + dict( + name="Ministry of Information", + invoicing_contact=invoicing_contact, + ) + ) + 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()) + + +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( + 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, + ) + 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): + 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), + ) + )