Skip to content

Commit c53d26e

Browse files
authored
Merge pull request #60 from ZFordDev/54-models-repository-api-uiagnostic-crud-layer
Dataclasses | CRUD API
2 parents 06426f5 + c770f2b commit c53d26e

2 files changed

Lines changed: 276 additions & 0 deletions

File tree

src/storage/models.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
models.py
3+
---------
4+
Dataclasses representing core SchedPlus entities.
5+
6+
These are UI-agnostic and used by the repository and logic layer.
7+
"""
8+
9+
from dataclasses import dataclass
10+
from typing import Optional
11+
12+
13+
@dataclass
14+
class Entry:
15+
id: int
16+
title: str
17+
description: Optional[str]
18+
due_date: Optional[str] # ISO 8601 string or None
19+
completed: bool
20+
created_at: str # ISO 8601
21+
updated_at: str # ISO 8601
22+
23+
@classmethod
24+
def from_row(cls, row) -> "Entry":
25+
return cls(
26+
id=row["id"],
27+
title=row["title"],
28+
description=row["description"],
29+
due_date=row["due_date"],
30+
completed=bool(row["completed"]),
31+
created_at=row["created_at"],
32+
updated_at=row["updated_at"],
33+
)
34+
35+
36+
@dataclass
37+
class Comment:
38+
id: int
39+
entry_id: int
40+
text: str
41+
created_at: str # ISO 8601
42+
43+
@classmethod
44+
def from_row(cls, row) -> "Comment":
45+
return cls(
46+
id=row["id"],
47+
entry_id=row["entry_id"],
48+
text=row["text"],
49+
created_at=row["created_at"],
50+
)
51+
52+
53+
@dataclass
54+
class Tag:
55+
id: int
56+
name: str
57+
58+
@classmethod
59+
def from_row(cls, row) -> "Tag":
60+
return cls(
61+
id=row["id"],
62+
name=row["name"],
63+
)
64+
65+
66+
@dataclass
67+
class RecurrenceRule:
68+
id: int
69+
entry_id: int
70+
rule: str
71+
72+
@classmethod
73+
def from_row(cls, row) -> "RecurrenceRule":
74+
return cls(
75+
id=row["id"],
76+
entry_id=row["entry_id"],
77+
rule=row["rule"],
78+
)

src/storage/repository.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""
2+
repository.py
3+
-------------
4+
UI‑agnostic repository layer for SchedPlus.
5+
6+
This module exposes the public CRUD API used by:
7+
- Tkinter UI
8+
- PyQt UI
9+
- RAW/terminal mode (future)
10+
- Automated scripts
11+
- Scheduler logic
12+
13+
It wraps the SQLite engine and returns dataclasses defined in models.py.
14+
"""
15+
16+
import sqlite3
17+
from datetime import datetime
18+
from typing import List, Optional
19+
20+
from storage.db import get_connection
21+
from storage.models import Entry, Comment, Tag, RecurrenceRule
22+
23+
24+
def _now_iso() -> str:
25+
"""Returns current time in ISO 8601 format."""
26+
return datetime.now().isoformat(timespec="seconds")
27+
28+
29+
class Repository:
30+
"""
31+
Repository provides a clean, UI‑agnostic API for all task operations.
32+
"""
33+
34+
def __init__(self, conn: Optional[sqlite3.Connection] = None):
35+
self.conn = conn or get_connection()
36+
37+
# ---------------------------------------------------------
38+
# Entries
39+
# ---------------------------------------------------------
40+
41+
def create_entry(
42+
self,
43+
title: str,
44+
description: Optional[str] = None,
45+
due_date: Optional[str] = None,
46+
) -> Entry:
47+
now = _now_iso()
48+
cursor = self.conn.cursor()
49+
50+
cursor.execute(
51+
"""
52+
INSERT INTO entries (title, description, due_date, completed, created_at, updated_at)
53+
VALUES (?, ?, ?, 0, ?, ?)
54+
""",
55+
(title, description, due_date, now, now),
56+
)
57+
58+
entry_id = cursor.lastrowid
59+
self.conn.commit()
60+
61+
return self.get_entry(entry_id)
62+
63+
def update_entry(
64+
self,
65+
entry_id: int,
66+
title: Optional[str] = None,
67+
description: Optional[str] = None,
68+
due_date: Optional[str] = None,
69+
completed: Optional[bool] = None,
70+
) -> Optional[Entry]:
71+
"""
72+
Updates only the fields provided.
73+
"""
74+
entry = self.get_entry(entry_id)
75+
if entry is None:
76+
return None
77+
78+
new_title = title if title is not None else entry.title
79+
new_desc = description if description is not None else entry.description
80+
new_due = due_date if due_date is not None else entry.due_date
81+
new_completed = int(completed) if completed is not None else int(entry.completed)
82+
83+
cursor = self.conn.cursor()
84+
cursor.execute(
85+
"""
86+
UPDATE entries
87+
SET title = ?, description = ?, due_date = ?, completed = ?, updated_at = ?
88+
WHERE id = ?
89+
""",
90+
(new_title, new_desc, new_due, new_completed, _now_iso(), entry_id),
91+
)
92+
93+
self.conn.commit()
94+
return self.get_entry(entry_id)
95+
96+
def delete_entry(self, entry_id: int) -> None:
97+
cursor = self.conn.cursor()
98+
cursor.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
99+
self.conn.commit()
100+
101+
def get_entry(self, entry_id: int) -> Optional[Entry]:
102+
cursor = self.conn.cursor()
103+
row = cursor.execute(
104+
"SELECT * FROM entries WHERE id = ?", (entry_id,)
105+
).fetchone()
106+
107+
return Entry.from_row(row) if row else None
108+
109+
def list_entries(self) -> List[Entry]:
110+
cursor = self.conn.cursor()
111+
rows = cursor.execute(
112+
"SELECT * FROM entries ORDER BY created_at DESC"
113+
).fetchall()
114+
115+
return [Entry.from_row(r) for r in rows]
116+
117+
# ---------------------------------------------------------
118+
# Comments
119+
# ---------------------------------------------------------
120+
121+
def add_comment(self, entry_id: int, text: str) -> Comment:
122+
now = _now_iso()
123+
cursor = self.conn.cursor()
124+
125+
cursor.execute(
126+
"""
127+
INSERT INTO comments (entry_id, text, created_at)
128+
VALUES (?, ?, ?)
129+
""",
130+
(entry_id, text, now),
131+
)
132+
133+
comment_id = cursor.lastrowid
134+
self.conn.commit()
135+
136+
return self.get_comment(comment_id)
137+
138+
def get_comment(self, comment_id: int) -> Optional[Comment]:
139+
cursor = self.conn.cursor()
140+
row = cursor.execute(
141+
"SELECT * FROM comments WHERE id = ?", (comment_id,)
142+
).fetchone()
143+
144+
return Comment.from_row(row) if row else None
145+
146+
def list_comments(self, entry_id: int) -> List[Comment]:
147+
cursor = self.conn.cursor()
148+
rows = cursor.execute(
149+
"""
150+
SELECT * FROM comments
151+
WHERE entry_id = ?
152+
ORDER BY created_at ASC
153+
""",
154+
(entry_id,),
155+
).fetchall()
156+
157+
return [Comment.from_row(r) for r in rows]
158+
159+
# ---------------------------------------------------------
160+
# Tags
161+
# ---------------------------------------------------------
162+
163+
def get_tags(self) -> List[Tag]:
164+
cursor = self.conn.cursor()
165+
rows = cursor.execute("SELECT * FROM tags ORDER BY name ASC").fetchall()
166+
return [Tag.from_row(r) for r in rows]
167+
168+
def assign_tag(self, entry_id: int, tag_name: str) -> Tag:
169+
"""
170+
Ensures tag exists, then links it to the entry.
171+
"""
172+
cursor = self.conn.cursor()
173+
174+
# 1. Ensure tag exists
175+
row = cursor.execute(
176+
"SELECT * FROM tags WHERE name = ?", (tag_name,)
177+
).fetchone()
178+
179+
if row:
180+
tag = Tag.from_row(row)
181+
else:
182+
cursor.execute(
183+
"INSERT INTO tags (name) VALUES (?)",
184+
(tag_name,),
185+
)
186+
tag = Tag(id=cursor.lastrowid, name=tag_name)
187+
188+
# 2. Link entry <-> tag
189+
cursor.execute(
190+
"""
191+
INSERT OR IGNORE INTO entry_tags (entry_id, tag_id)
192+
VALUES (?, ?)
193+
""",
194+
(entry_id, tag.id),
195+
)
196+
197+
self.conn.commit()
198+
return tag

0 commit comments

Comments
 (0)