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.
File without changes
@@ -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)