Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using the capabilities of sqlmodel and pydantic to do data validation. #207

Merged
merged 8 commits into from
Jan 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 53 additions & 62 deletions app/demo.py
Original file line number Diff line number Diff line change
@@ -1,73 +1,70 @@
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(
invoicing_contact: Contact,
fake: faker.Faker,
):
client = Client(
id=id,
name=fake.company(),
invoicing_contact=invoicing_contact,
)
assert client.invoicing_contact is not None
return client


Expand All @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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(),
Expand All @@ -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":
Expand All @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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()

Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion app/projects/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
2 changes: 1 addition & 1 deletion tuttle/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
69 changes: 46 additions & 23 deletions tuttle/model.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -393,13 +404,24 @@ class Project(SQLModel, table=True):
sa_relationship_kwargs={"lazy": "subquery"},
)

# PROPERTIES
@property
def client(self) -> Optional[Client]:
if self.contract:
return self.contract.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
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions tuttle/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading