Skip to content

Commit f5f55cf

Browse files
authored
Merge pull request #207 from tuttle-dev/dev-input-validation
Using the capabilities of `sqlmodel` and `pydantic` to do data validation.
2 parents 83f2755 + cbbc18f commit f5f55cf

File tree

7 files changed

+270
-154
lines changed

7 files changed

+270
-154
lines changed

app/demo.py

Lines changed: 53 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,70 @@
1-
from typing import List, Optional, Callable
1+
from typing import Callable, List, Optional
22

3+
import datetime
34
import random
5+
from datetime import date, timedelta
46
from pathlib import Path
5-
from tuttle.calendar import Calendar, ICSCalendar
7+
from decimal import Decimal
8+
69
import faker
7-
import random
8-
import datetime
9-
from datetime import timedelta, date
1010
import ics
11-
from sqlmodel import Field, SQLModel, create_engine, Session, select
11+
import numpy
1212
import sqlalchemy
1313
from loguru import logger
14-
import numpy
14+
from sqlmodel import Field, Session, SQLModel, create_engine, select
1515

16+
from tuttle import rendering
17+
from tuttle.calendar import Calendar, ICSCalendar
1618
from tuttle.model import (
1719
Address,
18-
Contact,
20+
BankAccount,
1921
Client,
20-
Project,
22+
Contact,
2123
Contract,
22-
TimeUnit,
2324
Cycle,
24-
User,
25-
BankAccount,
2625
Invoice,
2726
InvoiceItem,
27+
Project,
28+
TimeUnit,
29+
User,
2830
)
29-
from tuttle import rendering
3031

3132

3233
def create_fake_contact(
3334
fake: faker.Faker,
3435
):
35-
try:
36-
street_line, city_line = fake.address().splitlines()
37-
a = Address(
38-
id=id,
39-
street=street_line.split(" ")[0],
40-
number=street_line.split(" ")[1],
41-
city=city_line.split(" ")[1],
42-
postal_code=city_line.split(" ")[0],
43-
country=fake.country(),
44-
)
45-
first_name, last_name = fake.name().split(" ", 1)
46-
contact = Contact(
47-
id=id,
48-
first_name=first_name,
49-
last_name=last_name,
50-
email=fake.email(),
51-
company=fake.company(),
52-
address_id=a.id,
53-
address=a,
54-
)
55-
return contact
56-
except Exception as ex:
57-
logger.error(ex)
58-
logger.error(f"Failed to create fake contact, trying again")
59-
return create_fake_contact(fake)
36+
37+
split_address_lines = fake.address().splitlines()
38+
street_line = split_address_lines[0]
39+
city_line = split_address_lines[1]
40+
a = Address(
41+
street=street_line,
42+
number=city_line,
43+
city=city_line.split(" ")[1],
44+
postal_code=city_line.split(" ")[0],
45+
country=fake.country(),
46+
)
47+
first_name, last_name = fake.name().split(" ", 1)
48+
contact = Contact(
49+
first_name=first_name,
50+
last_name=last_name,
51+
email=fake.email(),
52+
company=fake.company(),
53+
address_id=a.id,
54+
address=a,
55+
)
56+
return contact
6057

6158

6259
def create_fake_client(
6360
invoicing_contact: Contact,
6461
fake: faker.Faker,
6562
):
6663
client = Client(
67-
id=id,
6864
name=fake.company(),
6965
invoicing_contact=invoicing_contact,
7066
)
67+
assert client.invoicing_contact is not None
7168
return client
7269

7370

@@ -92,7 +89,7 @@ def create_fake_contract(
9289
start_date=fake.date_this_year(after_today=True),
9390
rate=rate,
9491
currency="EUR", # TODO: Use actual currency
95-
VAT_rate=round(random.uniform(0.05, 0.2), 2),
92+
VAT_rate=Decimal(round(random.uniform(0.05, 0.2), 2)),
9693
unit=unit,
9794
units_per_workday=random.randint(1, 12),
9895
volume=fake.random_int(1, 1000),
@@ -106,11 +103,12 @@ def create_fake_project(
106103
fake: faker.Faker,
107104
):
108105
project_title = fake.bs()
106+
project_tag = f"#{'-'.join(project_title.split(' ')[:2]).lower()}"
107+
109108
project = Project(
110109
title=project_title,
111-
tag="-".join(project_title.split(" ")[:2]).lower(),
110+
tag=project_tag,
112111
description=fake.paragraph(nb_sentences=2),
113-
unique_tag=project_title.split(" ")[0].lower(),
114112
is_completed=fake.pybool(),
115113
start_date=datetime.date.today(),
116114
end_date=datetime.date.today() + datetime.timedelta(days=80),
@@ -146,7 +144,7 @@ def create_fake_invoice(
146144
"""
147145
invoice_number = next(invoice_number_counter)
148146
invoice = Invoice(
149-
number=invoice_number,
147+
number=str(invoice_number),
150148
date=datetime.date.today(),
151149
sent=fake.pybool(),
152150
paid=fake.pybool(),
@@ -158,6 +156,7 @@ def create_fake_invoice(
158156
number_of_items = fake.random_int(min=1, max=5)
159157
for _ in range(number_of_items):
160158
unit = fake.random_element(elements=("hours", "days"))
159+
unit_price = 0
161160
if unit == "hours":
162161
unit_price = abs(round(numpy.random.normal(50, 20), 2))
163162
elif unit == "days":
@@ -168,12 +167,11 @@ def create_fake_invoice(
168167
end_date=fake.date_this_decade(),
169168
quantity=fake.random_int(min=1, max=10),
170169
unit=unit,
171-
unit_price=unit_price,
170+
unit_price=Decimal(unit_price),
172171
description=fake.sentence(),
173-
VAT_rate=vat_rate,
172+
VAT_rate=Decimal(vat_rate),
174173
invoice=invoice,
175174
)
176-
assert invoice_item.invoice == invoice
177175

178176
try:
179177
rendering.render_invoice(
@@ -230,7 +228,6 @@ def create_demo_user() -> User:
230228
phone_number="+55555555555",
231229
VAT_number="27B-6",
232230
address=Address(
233-
name="Harry Tuttle",
234231
street="Main Street",
235232
number="450",
236233
city="Somewhere",
@@ -247,6 +244,14 @@ def create_demo_user() -> User:
247244

248245

249246
def create_fake_calendar(project_list: List[Project]) -> ics.Calendar:
247+
def random_datetime(start, end):
248+
return start + timedelta(
249+
seconds=random.randint(0, int((end - start).total_seconds()))
250+
)
251+
252+
def random_duration():
253+
return timedelta(hours=random.randint(1, 8))
254+
250255
# create a new calendar
251256
calendar = ics.Calendar()
252257

@@ -261,7 +266,7 @@ def create_fake_calendar(project_list: List[Project]) -> ics.Calendar:
261266
for _ in range(random.randint(1, 5)):
262267
# create a new event
263268
event = ics.Event()
264-
event.name = f"Meeting for #{project.tag}"
269+
event.name = f"Meeting for {project.tag}"
265270

266271
# set the event's begin and end datetime
267272
event.begin = random_datetime(month_ago, now)
@@ -272,16 +277,6 @@ def create_fake_calendar(project_list: List[Project]) -> ics.Calendar:
272277
return calendar
273278

274279

275-
def random_datetime(start, end):
276-
return start + timedelta(
277-
seconds=random.randint(0, int((end - start).total_seconds()))
278-
)
279-
280-
281-
def random_duration():
282-
return timedelta(hours=random.randint(1, 8))
283-
284-
285280
def install_demo_data(
286281
n_projects: int,
287282
db_path: str,
@@ -335,7 +330,3 @@ def install_demo_data(
335330
for project in projects:
336331
session.add(project)
337332
session.commit()
338-
339-
340-
if __name__ == "__main__":
341-
install_demo_data(n_projects=10)

app/projects/view.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def build(self):
6262
),
6363
title=views.TBodyText(self.project.title),
6464
subtitle=views.TBodyText(
65-
f"#{self.project.tag}",
65+
f"{self.project.tag}",
6666
color=colors.GRAY_COLOR,
6767
weight=FontWeight.BOLD,
6868
),

tuttle/calendar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
def extract_hashtag(string) -> str:
2323
"""Extract the first hashtag from a string."""
24-
match = re.search(r"#(\S+)", string)
24+
match = re.search(r"(#\S+)", string)
2525
if match:
2626
return match.group(1)
2727
else:

tuttle/model.py

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,31 @@
11
"""Object model."""
22

3-
import email
4-
from typing import Optional, List, Dict, Type
5-
from pydantic import constr, BaseModel, condecimal
6-
from enum import Enum
3+
from typing import Dict, List, Optional, Type
4+
5+
import re
76
import datetime
7+
import decimal
8+
import email
89
import hashlib
9-
import uuid
10+
import string
1011
import textwrap
12+
import uuid
13+
from decimal import Decimal
14+
from enum import Enum
1115

16+
import pandas
1217
import sqlalchemy
13-
from sqlmodel import (
14-
SQLModel,
15-
Field,
16-
Relationship,
17-
)
1818

1919
# from pydantic import str
20-
import decimal
21-
from decimal import Decimal
22-
import pandas
23-
20+
from pydantic import BaseModel, condecimal, constr, validator
21+
from sqlmodel import SQLModel, Field, Relationship, Constraint
2422

25-
from .time import Cycle, TimeUnit
2623

2724
from .dev import deprecated
25+
from .time import Cycle, TimeUnit
2826

2927

30-
def help(model_class):
28+
def help(model_class: Type[BaseModel]):
3129
return pandas.DataFrame(
3230
(
3331
(field_name, field.field_info.description)
@@ -128,7 +126,7 @@ class User(SQLModel, table=True):
128126
back_populates="users",
129127
sa_relationship_kwargs={"lazy": "subquery"},
130128
)
131-
VAT_number: str = Field(
129+
VAT_number: Optional[str] = Field(
132130
description="Value Added Tax number of the user, legally required for invoices.",
133131
)
134132
# User 1:1* ICloudAccount
@@ -149,7 +147,7 @@ class User(SQLModel, table=True):
149147
sa_relationship_kwargs={"lazy": "subquery"},
150148
)
151149
# TODO: path to logo image
152-
logo: Optional[str]
150+
# logo: Optional[str] = Field(default=None)
153151

154152
@property
155153
def bank_account_not_set(self) -> bool:
@@ -210,6 +208,14 @@ class Contact(SQLModel, table=True):
210208
)
211209
# post address
212210

211+
# VALIDATORS
212+
@validator("email")
213+
def email_validator(cls, v):
214+
"""Validate email address format."""
215+
if not re.match(r"[^@]+@[^@]+\.[^@]+", v):
216+
raise ValueError("Not a valid email address")
217+
return v
218+
213219
@property
214220
def name(self):
215221
if self.first_name and self.last_name:
@@ -251,7 +257,9 @@ class Client(SQLModel, table=True):
251257
"""A client the freelancer has contracted with."""
252258

253259
id: Optional[int] = Field(default=None, primary_key=True)
254-
name: str = Field(default="")
260+
name: str = Field(
261+
description="Name of the client.",
262+
)
255263
# Client 1:1 invoicing Contact
256264
invoicing_contact_id: int = Field(default=None, foreign_key="contact.id")
257265
invoicing_contact: Contact = Relationship(
@@ -364,13 +372,16 @@ class Project(SQLModel, table=True):
364372

365373
id: Optional[int] = Field(default=None, primary_key=True)
366374
title: str = Field(
367-
description="A short, unique title", sa_column_kwargs={"unique": True}
375+
description="A short, unique title",
376+
sa_column_kwargs={"unique": True},
368377
)
369378
description: str = Field(
370-
description="A longer description of the project", default=""
379+
description="A longer description of the project",
380+
)
381+
tag: str = Field(
382+
description="A unique tag, starting with a # symbol",
383+
sa_column_kwargs={"unique": True},
371384
)
372-
# TODO: tag: constr(regex=r"#\S+")
373-
tag: str = Field(description="A unique tag", sa_column_kwargs={"unique": True})
374385
start_date: datetime.date
375386
end_date: datetime.date
376387
is_completed: bool = Field(
@@ -393,13 +404,24 @@ class Project(SQLModel, table=True):
393404
sa_relationship_kwargs={"lazy": "subquery"},
394405
)
395406

407+
# PROPERTIES
396408
@property
397409
def client(self) -> Optional[Client]:
398410
if self.contract:
399411
return self.contract.client
400412
else:
401413
return None
402414

415+
# VALIDATORS
416+
@validator("tag")
417+
def validate_tag(cls, v):
418+
if not re.match(r"^#\S+$", v):
419+
raise ValueError(
420+
"Tag must start with a # symbol and not contain any punctuation or whitespace."
421+
)
422+
return v
423+
424+
@deprecated
403425
def get_brief_description(self):
404426
if len(self.description) <= 108:
405427
return self.description
@@ -420,6 +442,7 @@ def is_upcoming(self) -> bool:
420442
today = datetime.date.today()
421443
return self.start_date > today
422444

445+
# FIXME: replace string literals with enum
423446
def get_status(self, default: str = "") -> str:
424447
if self.is_active():
425448
return "Active"

tuttle/rendering.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,10 @@ def render_invoice(
118118
user: User,
119119
invoice: Invoice,
120120
document_format: str = "pdf",
121-
out_dir: str = None,
121+
out_dir=None,
122122
style: str = "anvil",
123123
only_final: bool = False,
124-
) -> str:
124+
):
125125
"""Render an Invoice using an HTML template.
126126
127127
Args:

0 commit comments

Comments
 (0)