Skip to content

Commit 5763268

Browse files
committed
checkpoint: demo and rendering unit tests
1 parent e3c3978 commit 5763268

File tree

9 files changed

+249
-110
lines changed

9 files changed

+249
-110
lines changed

Diff for: app/auth/intent.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def get_user_if_exists(self) -> IntentResult[Optional[User]]:
9494
"""
9595
result = self._data_source.get_user_()
9696
if not result.was_intent_successful:
97-
result.error_msg = "Checking auth status failed! Please restart the app"
97+
result.error_msg = "No user data found."
9898
result.log_message_if_any()
9999
return result
100100

Diff for: app/invoicing/intent.py

+17-7
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,6 @@ def __init__(self, client_storage: ClientStorage):
4545
self._user_data_source = UserDataSource()
4646
self._auth_intent = AuthIntent()
4747

48-
def get_user(self) -> Optional[User]:
49-
"""Get the current user."""
50-
return self._auth_intent.get_user_if_exists()
51-
5248
def get_active_projects_as_map(self) -> Mapping[int, Project]:
5349
return self._projects_intent.get_active_projects_as_map()
5450

@@ -95,11 +91,12 @@ def create_invoice(
9591
render: bool = True,
9692
) -> IntentResult[Invoice]:
9793
"""Create a new invoice from time tracking data."""
98-
94+
user = self._user_data_source.get_user()
9995
try:
10096
# get the time tracking data
10197
timetracking_data = self._timetracking_data_source.get_data_frame()
102-
timesheet: Timesheet = timetracking.create_timesheet(
98+
# generate timesheet
99+
timesheet: Timesheet = timetracking.generate_timesheet(
103100
timetracking_data,
104101
project,
105102
from_date,
@@ -116,7 +113,20 @@ def create_invoice(
116113
)
117114

118115
if render:
119-
# TODO: render timesheet
116+
# render timesheet
117+
try:
118+
rendering.render_timesheet(
119+
user=user,
120+
timesheet=timesheet,
121+
out_dir=Path.home() / ".tuttle" / "Timesheets",
122+
only_final=True,
123+
)
124+
logger.info(f"✅ rendered timesheet for {project.title}")
125+
except Exception as ex:
126+
logger.error(
127+
f"❌ Error rendering timesheet for {project.title}: {ex}"
128+
)
129+
logger.exception(ex)
120130
# render invoice
121131
try:
122132
rendering.render_invoice(

Diff for: app/demo.py renamed to tuttle/demo.py

+49-20
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,24 @@
3232
)
3333

3434

35+
def create_fake_user(
36+
fake: faker.Faker,
37+
) -> User:
38+
"""
39+
Create a fake user.
40+
"""
41+
user = User(
42+
name=fake.name(),
43+
email=fake.email(),
44+
subtitle=fake.job(),
45+
VAT_number=fake.ean8(),
46+
)
47+
return user
48+
49+
3550
def create_fake_contact(
3651
fake: faker.Faker,
37-
):
52+
) -> Contact:
3853

3954
split_address_lines = fake.address().splitlines()
4055
street_line = split_address_lines[0]
@@ -59,9 +74,11 @@ def create_fake_contact(
5974

6075

6176
def create_fake_client(
62-
invoicing_contact: Contact,
6377
fake: faker.Faker,
64-
):
78+
invoicing_contact: Optional[Contact] = None,
79+
) -> Client:
80+
if invoicing_contact is None:
81+
invoicing_contact = create_fake_contact(fake)
6582
client = Client(
6683
name=fake.company(),
6784
invoicing_contact=invoicing_contact,
@@ -71,12 +88,14 @@ def create_fake_client(
7188

7289

7390
def create_fake_contract(
74-
client: Client,
7591
fake: faker.Faker,
92+
client: Optional[Client] = None,
7693
) -> Contract:
7794
"""
7895
Create a fake contract for the given client.
7996
"""
97+
if client is None:
98+
client = create_fake_client(fake)
8099
unit = random.choice(list(TimeUnit))
81100
if unit == TimeUnit.day:
82101
rate = fake.random_int(200, 1000) # realistic distribution for day rates
@@ -101,9 +120,12 @@ def create_fake_contract(
101120

102121

103122
def create_fake_project(
104-
contract: Contract,
105123
fake: faker.Faker,
106-
):
124+
contract: Optional[Contract] = None,
125+
) -> Project:
126+
if contract is None:
127+
contract = create_fake_contract(fake)
128+
107129
project_title = fake.bs()
108130
project_tag = f"#{'-'.join(project_title.split(' ')[:2]).lower()}"
109131

@@ -130,8 +152,8 @@ def invoice_number_counting():
130152

131153

132154
def create_fake_timesheet(
133-
project: Project,
134155
fake: faker.Faker,
156+
project: Optional[Project] = None,
135157
) -> Timesheet:
136158
"""
137159
Create a fake timesheet object with random values.
@@ -143,6 +165,8 @@ def create_fake_timesheet(
143165
Returns:
144166
Timesheet: A fake timesheet object.
145167
"""
168+
if project is None:
169+
project = create_fake_project(fake)
146170
timesheet = Timesheet(
147171
title=fake.bs(),
148172
comment=fake.paragraph(nb_sentences=2),
@@ -170,9 +194,10 @@ def create_fake_timesheet(
170194

171195

172196
def create_fake_invoice(
173-
project: Project,
174-
user: User,
175197
fake: faker.Faker,
198+
project: Optional[Project] = None,
199+
user: Optional[User] = None,
200+
render: bool = True,
176201
) -> Invoice:
177202
"""
178203
Create a fake invoice object with random values.
@@ -184,6 +209,9 @@ def create_fake_invoice(
184209
Returns:
185210
Invoice: A fake invoice object.
186211
"""
212+
if project is None:
213+
project = create_fake_project(fake)
214+
187215
invoice_number = next(invoice_number_counter)
188216
invoice = Invoice(
189217
number=str(invoice_number),
@@ -215,17 +243,18 @@ def create_fake_invoice(
215243
invoice=invoice,
216244
)
217245

218-
try:
219-
rendering.render_invoice(
220-
user=user,
221-
invoice=invoice,
222-
out_dir=Path.home() / ".tuttle" / "Invoices",
223-
only_final=True,
224-
)
225-
logger.info(f"✅ rendered invoice for {project.title}")
226-
except Exception as ex:
227-
logger.error(f"❌ Error rendering invoice for {project.title}: {ex}")
228-
logger.exception(ex)
246+
if render:
247+
try:
248+
rendering.render_invoice(
249+
user=user,
250+
invoice=invoice,
251+
out_dir=Path.home() / ".tuttle" / "Invoices",
252+
only_final=True,
253+
)
254+
logger.info(f"✅ rendered invoice for {project.title}")
255+
except Exception as ex:
256+
logger.error(f"❌ Error rendering invoice for {project.title}: {ex}")
257+
logger.exception(ex)
229258

230259
return invoice
231260

Diff for: tuttle/model.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ def prefix(self):
614614
"""A string that can be used as the prefix of a file name, or a folder name."""
615615
client_suffix = ""
616616
if self.client:
617-
client_suffix = self.client.name.lower().split()[0]
617+
client_suffix = "-".join(self.client.name.lower().split())
618618
prefix = f"{self.number}-{client_suffix}"
619619
return prefix
620620

Diff for: tuttle/rendering.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,6 @@ def as_percentage(number):
203203
invoice_dir / Path(f"{invoice.prefix}.html"), final_output_path
204204
)
205205
shutil.rmtree(invoice_dir)
206-
# finally set the rendered flag
207-
invoice.rendered = True
208206
# finally set the rendered flag
209207
invoice.rendered = True
210208

@@ -274,6 +272,15 @@ def render_timesheet(
274272
css_paths=css_paths,
275273
out_path=timesheet_dir / Path(f"{prefix}.pdf"),
276274
)
275+
if only_final:
276+
final_output_path = out_dir / Path(f"{prefix}.{document_format}")
277+
if document_format == "pdf":
278+
shutil.move(timesheet_dir / Path(f"{prefix}.pdf"), final_output_path)
279+
else:
280+
shutil.move(timesheet_dir / Path(f"{prefix}.html"), final_output_path)
281+
shutil.rmtree(timesheet_dir)
282+
# finally set the rendered flag
283+
timesheet.rendered = True
277284

278285

279286
def generate_document_thumbnail(pdf_path: str, thumbnail_width: int) -> str:

Diff for: tuttle/timetracking.py

+1-78
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .model import Project, Timesheet, TimeTrackingItem, User
1616

1717

18-
def create_timesheet(
18+
def generate_timesheet(
1919
timetracking_data: DataFrame,
2020
project: Project,
2121
period_start: datetime.date,
@@ -63,83 +63,6 @@ def create_timesheet(
6363
return ts
6464

6565

66-
@deprecated
67-
def generate_timesheet(
68-
source,
69-
project: Project,
70-
period_start: str,
71-
period_end: str = None,
72-
date: datetime.date = datetime.date.today(),
73-
comment: str = "",
74-
group_by: str = None,
75-
item_description: str = None,
76-
as_dataframe: bool = False,
77-
) -> Timesheet:
78-
if period_end:
79-
period = (period_start, period_end)
80-
period_str = f"{period_start} - {period_end}"
81-
else:
82-
period = period_start
83-
period_str = f"{period_start}"
84-
# convert cal to data
85-
timetracking_data = None
86-
if issubclass(type(source), Calendar):
87-
cal = source
88-
timetracking_data = cal.to_data()
89-
elif isinstance(source, pandas.DataFrame):
90-
timetracking_data = source
91-
schema.time_tracking.validate(timetracking_data)
92-
else:
93-
raise ValueError(f"unknown source: {source}")
94-
tag_query = f"tag == '{project.tag}'"
95-
if period_end:
96-
ts_table = (
97-
timetracking_data.loc[period_start:period_end].query(tag_query).sort_index()
98-
)
99-
else:
100-
ts_table = timetracking_data.loc[period_start].query(tag_query).sort_index()
101-
# convert all-day entries
102-
ts_table.loc[ts_table["all_day"], "duration"] = (
103-
project.contract.unit.to_timedelta() * project.contract.units_per_workday
104-
)
105-
if item_description:
106-
# TODO: extract item description from calendar
107-
ts_table["description"] = item_description
108-
# assert not ts_table.empty
109-
if as_dataframe:
110-
return ts_table
111-
112-
# TODO: grouping
113-
if group_by is None:
114-
pass
115-
elif group_by == "day":
116-
ts_table = ts_table.reset_index()
117-
ts_table = ts_table.groupby(by=ts_table["begin"].dt.date).agg(
118-
{
119-
"title": "first",
120-
"tag": "first",
121-
"description": "first",
122-
"duration": "sum",
123-
}
124-
)
125-
elif group_by == "week":
126-
raise NotImplementedError("TODO")
127-
else:
128-
raise ValueError(f"unknown group_by argument: {group_by}")
129-
130-
ts = Timesheet(
131-
title=f"{project.title} - {period_str}",
132-
period=period,
133-
project=project,
134-
comment=comment,
135-
date=date,
136-
)
137-
for record in ts_table.reset_index().to_dict("records"):
138-
ts.items.append(TimeTrackingItem(**record))
139-
140-
return ts
141-
142-
14366
def export_timesheet(
14467
timesheet: Timesheet,
14568
path: str,

Diff for: tuttle_tests/test_demo.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import faker
2+
import pytest
3+
4+
from tuttle.model import Contact, Client, Contract, Project, User
5+
from tuttle import demo
6+
7+
8+
@pytest.fixture
9+
def fake():
10+
return faker.Faker()
11+
12+
13+
def test_create_fake_user(fake):
14+
user = demo.create_fake_user(fake)
15+
assert user.name is not None
16+
assert user.email is not None
17+
assert user.subtitle is not None
18+
assert user.VAT_number is not None
19+
20+
21+
def test_create_fake_contact(fake):
22+
contact = demo.create_fake_contact(fake)
23+
assert isinstance(contact, Contact)
24+
assert contact.first_name is not None
25+
assert contact.last_name is not None
26+
assert contact.email is not None
27+
assert contact.company is not None
28+
assert contact.address is not None
29+
30+
31+
def test_create_fake_client(fake):
32+
client = demo.create_fake_client(fake)
33+
assert isinstance(client, Client)
34+
assert client.name is not None
35+
assert client.invoicing_contact is not None
36+
37+
38+
def test_create_fake_contract(fake):
39+
contract = demo.create_fake_contract(fake)
40+
assert isinstance(contract, Contract)
41+
assert contract.title is not None
42+
assert contract.client is not None
43+
assert contract.signature_date is not None
44+
assert contract.start_date is not None
45+
assert contract.rate is not None
46+
assert contract.currency is not None
47+
assert contract.VAT_rate is not None
48+
assert contract.unit is not None
49+
assert contract.units_per_workday is not None
50+
assert contract.volume is not None
51+
assert contract.term_of_payment is not None
52+
assert contract.billing_cycle is not None
53+
54+
55+
def test_create_fake_project(fake):
56+
project = demo.create_fake_project(fake)
57+
assert isinstance(project, Project)
58+
assert project.title is not None
59+
assert project.tag is not None
60+
assert project.description is not None
61+
assert project.is_completed is not None
62+
assert project.start_date is not None
63+
assert project.end_date is not None
64+
assert project.contract is not None

0 commit comments

Comments
 (0)