Skip to content

Commit 9dbc61a

Browse files
committed
timetracking / invoicing user test PP
1 parent 4ec3a0e commit 9dbc61a

File tree

5 files changed

+65
-207
lines changed

5 files changed

+65
-207
lines changed

notebooks/walkthrough/04-invoicing.ipynb

Lines changed: 29 additions & 194 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,24 @@
113113
"/Users/cls/miniforge3/envs/tuttle/lib/python3.9/site-packages/sqlmodel/orm/session.py:60: SAWarning: Class SelectOfScalar will not make use of SQL compilation caching as it does not set the 'inherit_cache' attribute to ``True``. This can have significant performance implications including some performance degradations in comparison to prior SQLAlchemy versions. Set this attribute to True if this object can make use of the cache key generated by the superclass. Alternatively, this attribute may be set to False which will disable this warning. (Background on this error at: https://sqlalche.me/e/14/cprf)\n",
114114
" results = super().execute(\n"
115115
]
116+
},
117+
{
118+
"ename": "NoResultFound",
119+
"evalue": "No row was found when one was required",
120+
"output_type": "error",
121+
"traceback": [
122+
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
123+
"\u001b[0;31mNoResultFound\u001b[0m Traceback (most recent call last)",
124+
"\u001b[0;32m/var/folders/pl/9s2ysv_92pn6_2w7j2t40mh00000gn/T/ipykernel_48194/3726912579.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mmy_project\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mapp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget_project\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtitle\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"#HeatingRepair\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
125+
"\u001b[0;32m~/Documents/Work/Projects/PrototypeFund/Dev/tuttle/tuttle/app.py\u001b[0m in \u001b[0;36mget_project\u001b[0;34m(self, title, tag)\u001b[0m\n\u001b[1;32m 86\u001b[0m \u001b[0;34m\"\"\"Get a project by title or tag.\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 87\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mtitle\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 88\u001b[0;31m project = self.db_session.exec(\n\u001b[0m\u001b[1;32m 89\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0msqlmodel\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mselect\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmodel\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mProject\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mwhere\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmodel\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mProject\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtitle\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0mtitle\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 90\u001b[0m ).one()\n",
126+
"\u001b[0;32m~/miniforge3/envs/tuttle/lib/python3.9/site-packages/sqlalchemy/engine/result.py\u001b[0m in \u001b[0;36mone\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 1405\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1406\u001b[0m \"\"\"\n\u001b[0;32m-> 1407\u001b[0;31m return self._only_one_row(\n\u001b[0m\u001b[1;32m 1408\u001b[0m \u001b[0mraise_for_second_row\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mraise_for_none\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mscalar\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mFalse\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1409\u001b[0m )\n",
127+
"\u001b[0;32m~/miniforge3/envs/tuttle/lib/python3.9/site-packages/sqlalchemy/engine/result.py\u001b[0m in \u001b[0;36m_only_one_row\u001b[0;34m(self, raise_for_second_row, raise_for_none, scalar)\u001b[0m\n\u001b[1;32m 559\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mrow\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 560\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mraise_for_none\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 561\u001b[0;31m raise exc.NoResultFound(\n\u001b[0m\u001b[1;32m 562\u001b[0m \u001b[0;34m\"No row was found when one was required\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 563\u001b[0m )\n",
128+
"\u001b[0;31mNoResultFound\u001b[0m: No row was found when one was required"
129+
]
116130
}
117131
],
118132
"source": [
119-
"my_project = app.get_project(title=\"Heating Repair\")"
133+
"my_project = app.get_project(title=\"#HeatingRepair\")"
120134
]
121135
},
122136
{
@@ -145,7 +159,7 @@
145159
},
146160
{
147161
"cell_type": "code",
148-
"execution_count": 5,
162+
"execution_count": null,
149163
"id": "70b0dd5b-e749-4864-a951-0ca3a21b00af",
150164
"metadata": {},
151165
"outputs": [],
@@ -193,19 +207,10 @@
193207
},
194208
{
195209
"cell_type": "code",
196-
"execution_count": 6,
210+
"execution_count": null,
197211
"id": "690a6ac7-f5cd-494f-bb69-dbd1f0bea5f2",
198212
"metadata": {},
199-
"outputs": [
200-
{
201-
"name": "stderr",
202-
"output_type": "stream",
203-
"text": [
204-
"/Users/cls/miniforge3/envs/tuttle/lib/python3.9/site-packages/sqlmodel/orm/session.py:101: SAWarning: Dialect sqlite+pysqlite does *not* support Decimal objects natively, and SQLAlchemy must convert from floating point - rounding errors and other issues may occur. Please consider storing Decimal numbers as strings or integers on this platform for lossless storage.\n",
205-
" return super().execute( # type: ignore\n"
206-
]
207-
}
208-
],
213+
"outputs": [],
209214
"source": [
210215
"my_timesheet = tuttle.timetracking.generate_timesheet(\n",
211216
" source=my_calendar,\n",
@@ -233,7 +238,7 @@
233238
},
234239
{
235240
"cell_type": "code",
236-
"execution_count": 7,
241+
"execution_count": null,
237242
"id": "3bbb30ec-e5dd-4383-960f-5043f947774b",
238243
"metadata": {},
239244
"outputs": [],
@@ -249,42 +254,20 @@
249254
},
250255
{
251256
"cell_type": "code",
252-
"execution_count": 8,
257+
"execution_count": null,
253258
"id": "5eeb012a-ea3b-4502-a968-31faad9422f2",
254259
"metadata": {},
255-
"outputs": [
256-
{
257-
"data": {
258-
"text/plain": [
259-
"'2022-02-23-01'"
260-
]
261-
},
262-
"execution_count": 8,
263-
"metadata": {},
264-
"output_type": "execute_result"
265-
}
266-
],
260+
"outputs": [],
267261
"source": [
268262
"my_invoice.number"
269263
]
270264
},
271265
{
272266
"cell_type": "code",
273-
"execution_count": 9,
267+
"execution_count": null,
274268
"id": "d630bf01-3e04-444c-9cdb-8848ba591a7b",
275269
"metadata": {},
276-
"outputs": [
277-
{
278-
"data": {
279-
"text/plain": [
280-
"Decimal('952.000000000000')"
281-
]
282-
},
283-
"execution_count": 9,
284-
"metadata": {},
285-
"output_type": "execute_result"
286-
}
287-
],
270+
"outputs": [],
288271
"source": [
289272
"my_invoice.total"
290273
]
@@ -323,158 +306,10 @@
323306
},
324307
{
325308
"cell_type": "code",
326-
"execution_count": 10,
309+
"execution_count": null,
327310
"id": "a786c042-0602-4313-9dd8-3ea2cf6c6f1c",
328311
"metadata": {},
329-
"outputs": [
330-
{
331-
"data": {
332-
"text/html": [
333-
"<!doctype html>\n",
334-
"<html class=\"no-js\" lang=\"\">\n",
335-
"\n",
336-
"<head>\n",
337-
" <meta charset=\"utf-8\">\n",
338-
" <title>Invoice No. 2022-02-23-01</title>\n",
339-
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n",
340-
"\n",
341-
" \n",
342-
" <!-- pure HTML -->\n",
343-
" \n",
344-
"</head>\n",
345-
"<body>\n",
346-
"\n",
347-
"<div class=\"web-container\">\n",
348-
"\n",
349-
" <div class=\"page-container\">\n",
350-
" <span class=\"page\"></span>\n",
351-
" <span class=\"pages\"></span>\n",
352-
" </div>\n",
353-
"\n",
354-
" \n",
355-
"\n",
356-
"\n",
357-
" <table class=\"invoice-info-container\">\n",
358-
" <tr>\n",
359-
" <td rowspan=\"2\" class=\"client-name\">\n",
360-
" Sam Lowry<br>\n",
361-
" \n",
362-
"Main Street 9999<br>\n",
363-
"55555 Sao Paolo<br>\n",
364-
"Brazil\n",
365-
"\n",
366-
" </td>\n",
367-
" <td>\n",
368-
" Harry Tuttle<br>\n",
369-
" \n",
370-
"Main Street 450<br>\n",
371-
"555555 Sao Paolo<br>\n",
372-
"Brazil\n",
373-
"\n",
374-
" </td>\n",
375-
" </tr>\n",
376-
" <tr>\n",
377-
" <td>\n",
378-
"\n",
379-
" </td>\n",
380-
" </tr>\n",
381-
" <tr>\n",
382-
" <td>\n",
383-
"\n",
384-
" </td>\n",
385-
" <td>\n",
386-
"\n",
387-
" </td>\n",
388-
" </tr>\n",
389-
" <tr>\n",
390-
" <td>\n",
391-
" Invoice Date: <strong>2022-02-23</strong><br>\n",
392-
" Invoice Number: <strong>2022-02-23-01</strong>\n",
393-
" </td>\n",
394-
" <td>\n",
395-
" </td>\n",
396-
" </tr>\n",
397-
" </table>\n",
398-
"\n",
399-
"\n",
400-
" <table class=\"line-items-container\">\n",
401-
" <thead>\n",
402-
" <tr>\n",
403-
" <th class=\"heading-description\">Date</th>\n",
404-
" <th class=\"heading-description\">Description</th>\n",
405-
" <th class=\"heading-quantity\">Qty</th>\n",
406-
" <th class=\"heading-quantity\">Unit</th>\n",
407-
" <th class=\"heading-price\">Unit Price</th>\n",
408-
" <th class=\"heading-price\">VAT%</th>\n",
409-
" <th class=\"heading-subtotal\">Subtotal</th>\n",
410-
" </tr>\n",
411-
" </thead>\n",
412-
" <tbody>\n",
413-
" \n",
414-
" <tr>\n",
415-
" <td>2022-02-23</td>\n",
416-
" <td>Heating Repair - February 2022</td>\n",
417-
" <td class=\"right\">16</td>\n",
418-
" <td class=\"right\">hour</td>\n",
419-
" <td class=\"right\">€50.00 €</td>\n",
420-
" <td class=\"right\">19.00 %</td>\n",
421-
" <td class=\"right\">€800.00</td>\n",
422-
" </tr>\n",
423-
" \n",
424-
" </tbody>\n",
425-
" </table>\n",
426-
"\n",
427-
"\n",
428-
" <table class=\"line-items-container has-bottom-border\">\n",
429-
" <thead>\n",
430-
" <tr>\n",
431-
" <th>Payment Info</th>\n",
432-
" <th>Due By</th>\n",
433-
" <th>Total VAT</th>\n",
434-
" <th>Total Due</th>\n",
435-
" </tr>\n",
436-
" </thead>\n",
437-
" <tbody>\n",
438-
" <tr>\n",
439-
" <td class=\"payment-info\">\n",
440-
" <div>\n",
441-
" Account No: <strong>BZ99830994950003161565</strong>\n",
442-
" </div>\n",
443-
" </td>\n",
444-
" <td class=\"bold\">2022-03-09</td>\n",
445-
" <td>€152.00</td>\n",
446-
" <td class=\"bold\">€952.00</td>\n",
447-
" </tr>\n",
448-
" </tbody>\n",
449-
" </table>\n",
450-
"\n",
451-
" <div class=\"footer\">\n",
452-
" <div class=\"footer-info\">\n",
453-
" <span></span> |\n",
454-
" <span>+55555555555</span> |\n",
455-
" <span>https://tuttle-dev.github.io/tuttle/</span>\n",
456-
" </div>\n",
457-
" <div class=\"footer-thanks\">\n",
458-
" <span>Thank you!</span>\n",
459-
" </div>\n",
460-
" </div>\n",
461-
"\n",
462-
"\n",
463-
"</div>\n",
464-
"\n",
465-
"\n",
466-
"</body>\n",
467-
"</html>"
468-
],
469-
"text/plain": [
470-
"<IPython.core.display.HTML object>"
471-
]
472-
},
473-
"execution_count": 10,
474-
"metadata": {},
475-
"output_type": "execute_result"
476-
}
477-
],
312+
"outputs": [],
478313
"source": [
479314
"display.HTML(\n",
480315
" tuttle.rendering.render_invoice(\n",
@@ -507,7 +342,7 @@
507342
},
508343
{
509344
"cell_type": "code",
510-
"execution_count": 11,
345+
"execution_count": null,
511346
"id": "f87c6347-8664-483b-9e2f-f5d61f1772ae",
512347
"metadata": {},
513348
"outputs": [],
@@ -517,7 +352,7 @@
517352
},
518353
{
519354
"cell_type": "code",
520-
"execution_count": 12,
355+
"execution_count": null,
521356
"id": "3e6d9815-e474-4f3a-b6df-e803c3cc5bfd",
522357
"metadata": {},
523358
"outputs": [],
@@ -540,7 +375,7 @@
540375
},
541376
{
542377
"cell_type": "code",
543-
"execution_count": 13,
378+
"execution_count": null,
544379
"id": "f7c0a6c9-5a7a-4b3a-9d2f-de0671fac734",
545380
"metadata": {},
546381
"outputs": [],
@@ -574,7 +409,7 @@
574409
},
575410
{
576411
"cell_type": "code",
577-
"execution_count": 14,
412+
"execution_count": null,
578413
"id": "4eeb3a78-64ab-4ebf-b69d-6c5b99285b7b",
579414
"metadata": {},
580415
"outputs": [],

tuttle/app.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,24 @@
1010
class App:
1111
"""The main application class"""
1212

13-
def __init__(self, debug_mode=False, verbose=False):
13+
def __init__(self, debug_mode=False, verbose=False, in_memory=False):
1414
if debug_mode:
1515
self.home = Path("./test_home")
1616
else:
1717
self.home = Path.home() / ".tuttle"
1818
if not os.path.exists(self.home):
1919
os.mkdir(self.home)
20-
self.db_path = self.home / "tuttle.db"
21-
self.db_engine = sqlmodel.create_engine(
22-
f"sqlite:///{self.db_path}",
23-
echo=verbose,
24-
)
20+
if in_memory:
21+
self.db_engine = sqlmodel.create_engine(
22+
f"sqlite:///",
23+
echo=verbose,
24+
)
25+
else:
26+
self.db_path = self.home / "tuttle.db"
27+
self.db_engine = sqlmodel.create_engine(
28+
f"sqlite:///{self.db_path}",
29+
echo=verbose,
30+
)
2531
sqlmodel.SQLModel.metadata.create_all(self.db_engine)
2632
self.db_session = self.get_session()
2733

@@ -78,9 +84,21 @@ def user(self):
7884
user = self.db_session.exec(sqlmodel.select(model.User)).one()
7985
return user
8086

81-
def get_project(self, title: str):
82-
"""Get a project by its title."""
83-
project = self.db_session.exec(
84-
(sqlmodel.select(model.Project).where(model.Project.title == title))
85-
).one()
86-
return project
87+
def get_project(
88+
self,
89+
title: str = None,
90+
tag: str = None,
91+
):
92+
"""Get a project by title or tag."""
93+
if title:
94+
project = self.db_session.exec(
95+
(sqlmodel.select(model.Project).where(model.Project.title == title))
96+
).one()
97+
return project
98+
elif tag:
99+
project = self.db_session.exec(
100+
(sqlmodel.select(model.Project).where(model.Project.tag == tag))
101+
).one()
102+
return project
103+
else:
104+
raise ValueError("either project title or tag required")

tuttle/calendar.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def to_data(self) -> DataFrame:
143143
# TODO: extract tag
144144
"tag": event_data["title"],
145145
"description": event_data["description"],
146+
"all_day": event_data["allDay"],
146147
}
147148
)
148149
# TODO: handle timezones

tuttle/invoicing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def generate_invoice(
3232
quantity=total_hours,
3333
unit="hour",
3434
unit_price=timesheet.project.contract.rate,
35-
VAT_rate=0.19, # TODO: adjustable VAT rate
35+
VAT_rate=contract.VAT_rate,
3636
description=timesheet.title,
3737
)
3838
invoice.generate_number()

tuttle/model.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ class Contract(SQLModel, table=True):
202202
description="Rate of remuneration",
203203
)
204204
currency: str # TODO: currency representation
205+
VAT_rate: Decimal = Field(
206+
description="VAT rate applied to the contractual rate.",
207+
default=0.19, # TODO: configure by country?
208+
)
205209
unit: TimeUnit = Field(
206210
description="Unit of time tracked. The rate applies to this unit.",
207211
sa_column=sqlalchemy.Column(sqlalchemy.Enum(TimeUnit)),

0 commit comments

Comments
 (0)