studystreak 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- studystreak/__init__.py +0 -0
- studystreak/accounts.py +192 -0
- studystreak/api_client.py +292 -0
- studystreak/app.tcss +1560 -0
- studystreak/assets/sounds/achievement.wav +0 -0
- studystreak/assets/sounds/focus_complete.wav +0 -0
- studystreak/assets/sounds/streak_protected.wav +0 -0
- studystreak/assets/sounds/ui.wav +0 -0
- studystreak/auth_cache.py +48 -0
- studystreak/cli.py +209 -0
- studystreak/notification.py +78 -0
- studystreak/profile_sync.py +38 -0
- studystreak/security.py +58 -0
- studystreak/session.py +93 -0
- studystreak/storage.py +647 -0
- studystreak/ui.py +2839 -0
- studystreak-0.1.0.dist-info/METADATA +29 -0
- studystreak-0.1.0.dist-info/RECORD +21 -0
- studystreak-0.1.0.dist-info/WHEEL +5 -0
- studystreak-0.1.0.dist-info/entry_points.txt +2 -0
- studystreak-0.1.0.dist-info/top_level.txt +1 -0
studystreak/__init__.py
ADDED
|
File without changes
|
studystreak/accounts.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from cryptography.fernet import InvalidToken
|
|
7
|
+
|
|
8
|
+
from studystreak.security import (
|
|
9
|
+
hash_password,
|
|
10
|
+
verify_password,
|
|
11
|
+
generate_salt,
|
|
12
|
+
encrypt_text,
|
|
13
|
+
decrypt_text,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ACCOUNTS_FILE = Path("accounts.json")
|
|
18
|
+
|
|
19
|
+
def get_empty_private_data() -> dict[str, Any]:
|
|
20
|
+
return{
|
|
21
|
+
"sessions": [],
|
|
22
|
+
"weekly_goal": 300,
|
|
23
|
+
"subjects": [],
|
|
24
|
+
"subject_websites": {},
|
|
25
|
+
"leaderboard_opt_in": False,
|
|
26
|
+
"public_summary": {
|
|
27
|
+
"weekly_minutes": 0,
|
|
28
|
+
"current_streak": 0,
|
|
29
|
+
"average_focsu_score": 0,
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def get_default_accounts_data() -> dict[str, Any]:
|
|
34
|
+
return {
|
|
35
|
+
"current_user": None,
|
|
36
|
+
"users": {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def load_account_data() -> dict[str, Any]:
|
|
40
|
+
if not ACCOUNTS_FILE.exists():
|
|
41
|
+
return get_default_accounts_data()
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
with open(ACCOUNTS_FILE, "r", encoding="utf-8") as file:
|
|
45
|
+
data = json.load(file)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
return get_default_accounts_data()
|
|
48
|
+
|
|
49
|
+
if "current_user" not in data:
|
|
50
|
+
data["current_user"] = None
|
|
51
|
+
|
|
52
|
+
if "users" not in data:
|
|
53
|
+
data["users"] = {}
|
|
54
|
+
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
def save_accounts_data(data: dict[str, Any]) -> None:
|
|
58
|
+
with open(ACCOUNTS_FILE, "w", encoding="utf-8") as file:
|
|
59
|
+
json.dump(data, file, indent=4)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def normalise_username(username: str) -> str:
|
|
63
|
+
return username.strip().lower()
|
|
64
|
+
|
|
65
|
+
def validate_username(username: str) -> None:
|
|
66
|
+
if username == "":
|
|
67
|
+
raise ValueError("Username cannot be empty")
|
|
68
|
+
|
|
69
|
+
if len(username) < 3:
|
|
70
|
+
raise ValueError("Username must be at least 3 characters long.")
|
|
71
|
+
|
|
72
|
+
if len(username) > 24:
|
|
73
|
+
raise ValueError("Username must be 24 characters or fewer.")
|
|
74
|
+
|
|
75
|
+
if not re.fullmatch(r"[a-zA-Z0-9_-]+", username):
|
|
76
|
+
raise ValueError("Username can only contain letters, numbers, underscores and hyphens.")
|
|
77
|
+
|
|
78
|
+
def validate_password(password: str) -> None:
|
|
79
|
+
if len(password) < 8:
|
|
80
|
+
raise ValueError("Password must be at least 8 characters long.")
|
|
81
|
+
if len(password) > 128:
|
|
82
|
+
raise ValueError("Password must be 128 characters or fewer.")
|
|
83
|
+
|
|
84
|
+
if password.strip() == "":
|
|
85
|
+
raise ValueError("Password cannot be blank.")
|
|
86
|
+
|
|
87
|
+
def create_account(username: str, password: str, display_name: str | None = None) -> None:
|
|
88
|
+
username = normalise_username(username)
|
|
89
|
+
validate_username(username)
|
|
90
|
+
validate_password(password)
|
|
91
|
+
|
|
92
|
+
data = load_account_data()
|
|
93
|
+
|
|
94
|
+
if username in data["users"]:
|
|
95
|
+
raise ValueError("That username already exist.")
|
|
96
|
+
|
|
97
|
+
encryption_salt = generate_salt()
|
|
98
|
+
private_data = get_empty_private_data()
|
|
99
|
+
private_data_json = json.dumps(private_data)
|
|
100
|
+
|
|
101
|
+
encrypted_private_data = encrypt_text(
|
|
102
|
+
text=private_data_json,
|
|
103
|
+
password=password,
|
|
104
|
+
salt=encryption_salt,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
data["users"][username] = {
|
|
108
|
+
"display_name": display_name.strip() if display_name else username,
|
|
109
|
+
"password_hash": hash_password(password),
|
|
110
|
+
"encryption_salt": encryption_salt,
|
|
111
|
+
"encrypted_private_data": encrypted_private_data,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if data["current_user"] is None:
|
|
115
|
+
data["current_user"] = username
|
|
116
|
+
|
|
117
|
+
save_accounts_data(data)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def login_account(username: str, password: str) -> dict[str, Any]:
|
|
121
|
+
username = normalise_username(username)
|
|
122
|
+
data = load_account_data()
|
|
123
|
+
|
|
124
|
+
if username not in data["users"]:
|
|
125
|
+
raise ValueError("Username or password is incorrect.")
|
|
126
|
+
|
|
127
|
+
user_record = data["users"][username]
|
|
128
|
+
|
|
129
|
+
password_is_valid = verify_password(
|
|
130
|
+
password_hash=user_record["password_hash"],
|
|
131
|
+
password=password,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not password_is_valid:
|
|
135
|
+
raise ValueError("Username or password is incorrect.")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
decrypt_json = decrypt_text(
|
|
139
|
+
encrypted_text=user_record["encrypted_private_data"],
|
|
140
|
+
password=password,
|
|
141
|
+
salt=user_record["encryption_salt"],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
except InvalidToken:
|
|
145
|
+
raise ValueError("Could not decrypt user data. The password may be incorrect.")
|
|
146
|
+
|
|
147
|
+
private_data = json.loads(decrypt_json)
|
|
148
|
+
|
|
149
|
+
data["current_user"] = username
|
|
150
|
+
save_accounts_data(data)
|
|
151
|
+
|
|
152
|
+
return private_data
|
|
153
|
+
|
|
154
|
+
def save_user_private_data(username: str, password: str, private_data: dict[str, Any]) -> None:
|
|
155
|
+
username = normalise_username(username)
|
|
156
|
+
data = load_account_data()
|
|
157
|
+
|
|
158
|
+
if username not in data["users"]:
|
|
159
|
+
raise ValueError("User does not exist")
|
|
160
|
+
|
|
161
|
+
user_record = data["users"][username]
|
|
162
|
+
|
|
163
|
+
if not verify_password(user_record["password_hash"], password):
|
|
164
|
+
raise ValueError("Password is incorrect")
|
|
165
|
+
|
|
166
|
+
private_data_json = json.dumps(private_data)
|
|
167
|
+
|
|
168
|
+
encrypted_private_data = encrypt_text(
|
|
169
|
+
text=private_data_json,
|
|
170
|
+
password=password,
|
|
171
|
+
salt=user_record["encryption_salt"],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
user_record["encrypted_private_data"] = encrypted_private_data
|
|
175
|
+
save_accounts_data(data)
|
|
176
|
+
|
|
177
|
+
def get_current_user() -> str | None:
|
|
178
|
+
data = load_account_data()
|
|
179
|
+
return data["current_user"]
|
|
180
|
+
|
|
181
|
+
def list_accounts() -> list[str]:
|
|
182
|
+
data = load_account_data()
|
|
183
|
+
return sorted(data["users"].keys())
|
|
184
|
+
|
|
185
|
+
def logout_account() -> None:
|
|
186
|
+
data = load_account_data()
|
|
187
|
+
data["current_user"] = None
|
|
188
|
+
save_accounts_data(data)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
load_dotenv(PROJECT_ROOT / ".env")
|
|
10
|
+
|
|
11
|
+
BASE_URL = os.getenv("STUDYSTREAK_API_URL", "http://127.0.0.1:8000")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_error_detail(response: requests.Response) -> str:
|
|
15
|
+
#return the useful FastAPI error instead of hiding it behind a generic message
|
|
16
|
+
try:
|
|
17
|
+
data = response.json()
|
|
18
|
+
except ValueError:
|
|
19
|
+
return response.text.strip() or response.reason
|
|
20
|
+
|
|
21
|
+
detail = data.get("detail")
|
|
22
|
+
|
|
23
|
+
if isinstance(detail, str):
|
|
24
|
+
return detail
|
|
25
|
+
|
|
26
|
+
if isinstance(detail, list):
|
|
27
|
+
messages = []
|
|
28
|
+
|
|
29
|
+
for item in detail:
|
|
30
|
+
if isinstance(item, dict):
|
|
31
|
+
message = item.get("msg", str(item))
|
|
32
|
+
location = item.get("loc", [])
|
|
33
|
+
|
|
34
|
+
if location:
|
|
35
|
+
messages.append(f"{'.'.join(str(part) for part in location)}: {message}")
|
|
36
|
+
else:
|
|
37
|
+
messages.append(message)
|
|
38
|
+
else:
|
|
39
|
+
messages.append(str(item))
|
|
40
|
+
|
|
41
|
+
return "; ".join(messages)
|
|
42
|
+
|
|
43
|
+
if detail is not None:
|
|
44
|
+
return str(detail)
|
|
45
|
+
|
|
46
|
+
return response.text.strip() or response.reason
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def raise_server_error(action: str, response: requests.Response) -> None:
|
|
50
|
+
detail = get_error_detail(response)
|
|
51
|
+
raise ValueError(f"{action} failed ({response.status_code}): {detail}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def login_to_server(username: str, password: str) -> str:
|
|
55
|
+
#login to backend server
|
|
56
|
+
try:
|
|
57
|
+
response = requests.post(
|
|
58
|
+
f"{BASE_URL}/login",
|
|
59
|
+
json={
|
|
60
|
+
"username": username,
|
|
61
|
+
"password": password,
|
|
62
|
+
},
|
|
63
|
+
timeout=10,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
except requests.RequestException as error:
|
|
67
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
68
|
+
|
|
69
|
+
if response.status_code != 200:
|
|
70
|
+
raise_server_error("Server login", response)
|
|
71
|
+
|
|
72
|
+
data = response.json()
|
|
73
|
+
return data["access_token"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def signup_to_server(username: str, password: str, display_name: str | None = None) -> None:
|
|
77
|
+
#create backend server account
|
|
78
|
+
try:
|
|
79
|
+
response = requests.post(
|
|
80
|
+
f"{BASE_URL}/signup",
|
|
81
|
+
json={
|
|
82
|
+
"username": username,
|
|
83
|
+
"password": password,
|
|
84
|
+
"display_name": display_name,
|
|
85
|
+
},
|
|
86
|
+
timeout=10,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
except requests.RequestException as error:
|
|
90
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
91
|
+
|
|
92
|
+
if response.status_code != 200:
|
|
93
|
+
raise_server_error("Server signup", response)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def upload_focus_session(token: str, subject: str, minutes: int, website: str | None) -> None:
|
|
97
|
+
#upload completed focus session
|
|
98
|
+
try:
|
|
99
|
+
response = requests.post(
|
|
100
|
+
f"{BASE_URL}/focus-sessions",
|
|
101
|
+
headers={
|
|
102
|
+
"Authorization": f"Bearer {token}",
|
|
103
|
+
},
|
|
104
|
+
json={
|
|
105
|
+
"subject": subject,
|
|
106
|
+
"minutes": minutes,
|
|
107
|
+
"website": website,
|
|
108
|
+
"completed": True,
|
|
109
|
+
"source": "focus_cli",
|
|
110
|
+
},
|
|
111
|
+
timeout=10,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
except requests.RequestException as error:
|
|
115
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
116
|
+
|
|
117
|
+
if response.status_code != 200:
|
|
118
|
+
raise_server_error("Focus upload", response)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_leaderboard(period="all") -> list[dict]:
|
|
122
|
+
#get server leaderboard
|
|
123
|
+
try:
|
|
124
|
+
response = requests.get(
|
|
125
|
+
f"{BASE_URL}/leaderboard",
|
|
126
|
+
params={"period": period},
|
|
127
|
+
timeout=10,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
except requests.RequestException as error:
|
|
131
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
132
|
+
|
|
133
|
+
if response.status_code != 200:
|
|
134
|
+
raise_server_error("Leaderboard load", response)
|
|
135
|
+
|
|
136
|
+
return response.json()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_server_status() -> bool:
|
|
140
|
+
#check if backend server is online
|
|
141
|
+
try:
|
|
142
|
+
response = requests.get(
|
|
143
|
+
f"{BASE_URL}/",
|
|
144
|
+
timeout=5,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return response.status_code == 200
|
|
148
|
+
|
|
149
|
+
except requests.RequestException:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def get_profile_data(token: str) -> str | None:
|
|
153
|
+
#get encrypted profile data from server
|
|
154
|
+
try:
|
|
155
|
+
response = requests.get(
|
|
156
|
+
f"{BASE_URL}/profile-data",
|
|
157
|
+
headers={
|
|
158
|
+
"Authorization": f"Bearer {token}",
|
|
159
|
+
},
|
|
160
|
+
timeout=10,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
except requests.RequestException as error:
|
|
164
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
165
|
+
|
|
166
|
+
if response.status_code != 200:
|
|
167
|
+
raise_server_error("Profile load", response)
|
|
168
|
+
|
|
169
|
+
data = response.json()
|
|
170
|
+
return data["encrypted_profile_data"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def upload_profile_data(token: str, encrypted_profile_data: str) -> None:
|
|
174
|
+
#upload encrypted profile data to server
|
|
175
|
+
try:
|
|
176
|
+
response = requests.put(
|
|
177
|
+
f"{BASE_URL}/profile-data",
|
|
178
|
+
headers={
|
|
179
|
+
"Authorization": f"Bearer {token}",
|
|
180
|
+
},
|
|
181
|
+
json={
|
|
182
|
+
"encrypted_profile_data": encrypted_profile_data,
|
|
183
|
+
},
|
|
184
|
+
timeout=10,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
except requests.RequestException as error:
|
|
188
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
189
|
+
|
|
190
|
+
if response.status_code != 200:
|
|
191
|
+
raise_server_error("Profile upload", response)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def upload_subjects(token: str, subjects: list[str]) -> None:
|
|
195
|
+
#upload subject list for the Chrome extension
|
|
196
|
+
try:
|
|
197
|
+
response = requests.put(
|
|
198
|
+
f"{BASE_URL}/subjects",
|
|
199
|
+
headers={
|
|
200
|
+
"Authorization": f"Bearer {token}",
|
|
201
|
+
},
|
|
202
|
+
json={
|
|
203
|
+
"subjects": subjects,
|
|
204
|
+
},
|
|
205
|
+
timeout=10,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
except requests.RequestException as error:
|
|
209
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
210
|
+
|
|
211
|
+
if response.status_code != 200:
|
|
212
|
+
raise_server_error("Subject sync", response)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def upload_subject_websites(token: str, subject_websites: dict[str, list[str]]) -> None:
|
|
216
|
+
#upload subject website lists for the Chrome extension
|
|
217
|
+
try:
|
|
218
|
+
response = requests.put(
|
|
219
|
+
f"{BASE_URL}/subject-websites",
|
|
220
|
+
headers={
|
|
221
|
+
"Authorization": f"Bearer {token}",
|
|
222
|
+
},
|
|
223
|
+
json={
|
|
224
|
+
"subject_websites": subject_websites,
|
|
225
|
+
},
|
|
226
|
+
timeout=10,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
except requests.RequestException as error:
|
|
230
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
231
|
+
|
|
232
|
+
if response.status_code != 200:
|
|
233
|
+
raise_server_error("Subject website sync", response)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def upload_timetable(token: str, timetable: list[dict]) -> None:
|
|
237
|
+
#upload timetable list for Chrome extension reminders
|
|
238
|
+
try:
|
|
239
|
+
response = requests.put(
|
|
240
|
+
f"{BASE_URL}/timetable",
|
|
241
|
+
headers={
|
|
242
|
+
"Authorization": f"Bearer {token}",
|
|
243
|
+
},
|
|
244
|
+
json={
|
|
245
|
+
"timetable": timetable,
|
|
246
|
+
},
|
|
247
|
+
timeout=10,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
except requests.RequestException as error:
|
|
251
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
252
|
+
|
|
253
|
+
if response.status_code != 200:
|
|
254
|
+
raise_server_error("Timetable sync", response)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_focus_quality_sessions(token: str) -> list[dict]:
|
|
258
|
+
#download rich Chrome focus-quality summaries for the logged-in user
|
|
259
|
+
try:
|
|
260
|
+
response = requests.get(
|
|
261
|
+
f"{BASE_URL}/focus-quality-sessions",
|
|
262
|
+
headers={
|
|
263
|
+
"Authorization": f"Bearer {token}",
|
|
264
|
+
},
|
|
265
|
+
timeout=10,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
except requests.RequestException as error:
|
|
269
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
270
|
+
|
|
271
|
+
if response.status_code != 200:
|
|
272
|
+
raise_server_error("Focus quality sync", response)
|
|
273
|
+
|
|
274
|
+
return response.json()
|
|
275
|
+
|
|
276
|
+
def upload_streak(token: str, current_streak: int) -> None:
|
|
277
|
+
try:
|
|
278
|
+
response = requests.put(
|
|
279
|
+
f"{BASE_URL}/streak",
|
|
280
|
+
headers={
|
|
281
|
+
"Authorization": f"Bearer {token}",
|
|
282
|
+
},
|
|
283
|
+
json={
|
|
284
|
+
"current_streak": current_streak,
|
|
285
|
+
},
|
|
286
|
+
timeout=10,
|
|
287
|
+
)
|
|
288
|
+
except requests.RequestException as error:
|
|
289
|
+
raise ValueError(f"Could not connect to server at {BASE_URL}: {error}") from error
|
|
290
|
+
|
|
291
|
+
if response.status_code !=200:
|
|
292
|
+
raise_server_error("Streak sync", response)
|