izteamslots 1.1.0

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.
@@ -0,0 +1,448 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import PROJECT_ROOT
12
+
13
+ ACCOUNTS_DIR = PROJECT_ROOT / "accounts"
14
+
15
+
16
+ @dataclass
17
+ class AdminAccount:
18
+ id: str
19
+ email: str
20
+ password: str
21
+ access_token: str | None = None
22
+ workspace_id: str | None = None
23
+ account_id: str | None = None
24
+ workspaces: list[dict] | None = None
25
+ created_at: str | None = None
26
+ last_login: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class WorkerAccount:
31
+ id: str
32
+ email: str
33
+ password: str
34
+ status: str = "created" # created -> invited -> registered -> logged_in
35
+ openai_password: str | None = None
36
+ access_token: str | None = None
37
+ workspace_id: str | None = None
38
+ admin_email: str | None = None
39
+ created_at: str | None = None
40
+
41
+
42
+ class AccountStore:
43
+ """Единый CRUD для admin и worker аккаунтов."""
44
+
45
+ def __init__(self, base_dir: Path = ACCOUNTS_DIR) -> None:
46
+ self.base_dir = base_dir
47
+ self.admin_dir = base_dir / "admin"
48
+ self.worker_dir = base_dir / "worker"
49
+ self.admin_dir.mkdir(parents=True, exist_ok=True)
50
+ self.worker_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ # --- Helpers ---
53
+
54
+ def _read_index(self, path: Path) -> dict:
55
+ index_path = path / "index.json"
56
+ if index_path.exists():
57
+ return json.loads(index_path.read_text())
58
+ return {}
59
+
60
+ def _write_index(self, path: Path, data: dict) -> None:
61
+ index_path = path / "index.json"
62
+ index_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
63
+
64
+ def _read_meta(self, folder: Path) -> dict:
65
+ meta_path = folder / "meta.json"
66
+ if meta_path.exists():
67
+ return json.loads(meta_path.read_text())
68
+ return {}
69
+
70
+ def _write_meta(self, folder: Path, data: dict) -> None:
71
+ folder.mkdir(parents=True, exist_ok=True)
72
+ meta_path = folder / "meta.json"
73
+ meta_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
74
+
75
+ # --- Admin CRUD ---
76
+
77
+ def list_admins(self) -> list[AdminAccount]:
78
+ index = self._read_index(self.admin_dir)
79
+ admins = []
80
+ for email, info in index.items():
81
+ account_dir = self.admin_dir / info["id"]
82
+ meta = self._read_meta(account_dir)
83
+ admins.append(AdminAccount(
84
+ id=info["id"],
85
+ email=email,
86
+ password=meta.get("password", ""),
87
+ access_token=meta.get("access_token"),
88
+ workspace_id=meta.get("workspace_id"),
89
+ account_id=meta.get("account_id"),
90
+ workspaces=meta.get("workspaces"),
91
+ created_at=info.get("created_at"),
92
+ last_login=info.get("last_login"),
93
+ ))
94
+ return admins
95
+
96
+ def get_admin(self, email: str) -> AdminAccount | None:
97
+ index = self._read_index(self.admin_dir)
98
+ info = index.get(email)
99
+ if not info:
100
+ return None
101
+ account_dir = self.admin_dir / info["id"]
102
+ meta = self._read_meta(account_dir)
103
+ return AdminAccount(
104
+ id=info["id"],
105
+ email=email,
106
+ password=meta.get("password", ""),
107
+ access_token=meta.get("access_token"),
108
+ workspace_id=meta.get("workspace_id"),
109
+ account_id=meta.get("account_id"),
110
+ workspaces=meta.get("workspaces"),
111
+ created_at=info.get("created_at"),
112
+ last_login=info.get("last_login"),
113
+ )
114
+
115
+ def add_admin(self, email: str, password: str) -> AdminAccount:
116
+ index = self._read_index(self.admin_dir)
117
+ if email in index:
118
+ raise ValueError(f"Админ {email} уже существует")
119
+ account_id = uuid.uuid4().hex
120
+ now = datetime.now(timezone.utc).isoformat()
121
+ index[email] = {"id": account_id, "created_at": now}
122
+ self._write_index(self.admin_dir, index)
123
+ account_dir = self.admin_dir / account_id
124
+ self._write_meta(account_dir, {"email": email, "password": password})
125
+ return AdminAccount(id=account_id, email=email, password=password, created_at=now)
126
+
127
+ def update_admin(self, account: AdminAccount) -> None:
128
+ index = self._read_index(self.admin_dir)
129
+ if account.email not in index:
130
+ raise ValueError(f"Админ {account.email} не найден")
131
+ info = index[account.email]
132
+ if account.last_login:
133
+ info["last_login"] = account.last_login
134
+ self._write_index(self.admin_dir, index)
135
+ account_dir = self.admin_dir / info["id"]
136
+ meta: dict[str, Any] = {
137
+ "email": account.email,
138
+ "password": account.password,
139
+ }
140
+ if account.access_token is not None:
141
+ meta["access_token"] = account.access_token
142
+ if account.workspace_id is not None:
143
+ meta["workspace_id"] = account.workspace_id
144
+ if account.account_id is not None:
145
+ meta["account_id"] = account.account_id
146
+ if account.workspaces is not None:
147
+ meta["workspaces"] = account.workspaces
148
+ self._write_meta(account_dir, meta)
149
+
150
+ def delete_admin(self, email: str) -> None:
151
+ index = self._read_index(self.admin_dir)
152
+ info = index.pop(email, None)
153
+ if not info:
154
+ return
155
+ self._write_index(self.admin_dir, index)
156
+ account_dir = self.admin_dir / info["id"]
157
+ if account_dir.exists():
158
+ shutil.rmtree(account_dir)
159
+
160
+ def get_admin_profile_dir(self, account: AdminAccount) -> Path:
161
+ profile_dir = self.admin_dir / account.id / "browser_profile"
162
+ profile_dir.mkdir(parents=True, exist_ok=True)
163
+ return profile_dir
164
+
165
+ # --- Worker CRUD ---
166
+
167
+ def list_workers(self) -> list[WorkerAccount]:
168
+ index = self._read_index(self.worker_dir)
169
+ workers = []
170
+ for email, info in index.items():
171
+ account_dir = self.worker_dir / info["id"]
172
+ meta = self._read_meta(account_dir)
173
+ workers.append(WorkerAccount(
174
+ id=info["id"],
175
+ email=email,
176
+ password=meta.get("password", ""),
177
+ status=meta.get("status", info.get("status", "created")),
178
+ openai_password=meta.get("openai_password"),
179
+ access_token=meta.get("access_token"),
180
+ workspace_id=meta.get("workspace_id"),
181
+ admin_email=info.get("admin_email"),
182
+ created_at=info.get("created_at"),
183
+ ))
184
+ return workers
185
+
186
+ def get_worker(self, email: str) -> WorkerAccount | None:
187
+ index = self._read_index(self.worker_dir)
188
+ info = index.get(email)
189
+ if not info:
190
+ return None
191
+ account_dir = self.worker_dir / info["id"]
192
+ meta = self._read_meta(account_dir)
193
+ return WorkerAccount(
194
+ id=info["id"],
195
+ email=email,
196
+ password=meta.get("password", ""),
197
+ status=meta.get("status", info.get("status", "created")),
198
+ openai_password=meta.get("openai_password"),
199
+ access_token=meta.get("access_token"),
200
+ workspace_id=meta.get("workspace_id"),
201
+ admin_email=info.get("admin_email"),
202
+ created_at=info.get("created_at"),
203
+ )
204
+
205
+ def add_worker(self, email: str, password: str, admin_email: str) -> WorkerAccount:
206
+ index = self._read_index(self.worker_dir)
207
+ if email in index:
208
+ raise ValueError(f"Worker {email} уже существует")
209
+ worker_id = uuid.uuid4().hex
210
+ now = datetime.now(timezone.utc).isoformat()
211
+ index[email] = {
212
+ "id": worker_id,
213
+ "status": "created",
214
+ "admin_email": admin_email,
215
+ "created_at": now,
216
+ }
217
+ self._write_index(self.worker_dir, index)
218
+ account_dir = self.worker_dir / worker_id
219
+ self._write_meta(account_dir, {
220
+ "email": email,
221
+ "password": password,
222
+ "status": "created",
223
+ })
224
+ return WorkerAccount(
225
+ id=worker_id, email=email, password=password,
226
+ admin_email=admin_email, created_at=now,
227
+ )
228
+
229
+ def update_worker(self, account: WorkerAccount) -> None:
230
+ index = self._read_index(self.worker_dir)
231
+ if account.email not in index:
232
+ raise ValueError(f"Worker {account.email} не найден")
233
+ info = index[account.email]
234
+ info["status"] = account.status
235
+ if account.admin_email is not None:
236
+ info["admin_email"] = account.admin_email
237
+ else:
238
+ info.pop("admin_email", None)
239
+ self._write_index(self.worker_dir, index)
240
+ account_dir = self.worker_dir / info["id"]
241
+ meta: dict[str, Any] = {
242
+ "email": account.email,
243
+ "password": account.password,
244
+ "status": account.status,
245
+ }
246
+ if account.openai_password is not None:
247
+ meta["openai_password"] = account.openai_password
248
+ if account.access_token is not None:
249
+ meta["access_token"] = account.access_token
250
+ if account.workspace_id is not None:
251
+ meta["workspace_id"] = account.workspace_id
252
+ self._write_meta(account_dir, meta)
253
+
254
+ def delete_worker(self, email: str) -> None:
255
+ index = self._read_index(self.worker_dir)
256
+ info = index.pop(email, None)
257
+ if not info:
258
+ return
259
+ self._write_index(self.worker_dir, index)
260
+ account_dir = self.worker_dir / info["id"]
261
+ if account_dir.exists():
262
+ shutil.rmtree(account_dir)
263
+
264
+ def get_worker_profile_dir(self, account: WorkerAccount) -> Path:
265
+ profile_dir = self.worker_dir / account.id / "browser_profile"
266
+ profile_dir.mkdir(parents=True, exist_ok=True)
267
+ return profile_dir
268
+
269
+ # --- Doctor ---
270
+
271
+ def doctor(self) -> list[str]:
272
+ """Проверяет целостность данных и исправляет проблемы.
273
+ Возвращает список выполненных исправлений."""
274
+ fixes: list[str] = []
275
+
276
+ for role, role_dir in [("admin", self.admin_dir), ("worker", self.worker_dir)]:
277
+ # 1. Проверяем index.json — читаемый ли
278
+ index_path = role_dir / "index.json"
279
+ if index_path.exists():
280
+ try:
281
+ index = json.loads(index_path.read_text())
282
+ except (json.JSONDecodeError, OSError):
283
+ # Битый index — пересобираем из папок
284
+ index = self._rebuild_index(role_dir, role)
285
+ fixes.append(f"{role}: index.json был повреждён — пересобран из папок")
286
+ else:
287
+ index = {}
288
+
289
+ changed = False
290
+ to_remove: list[str] = []
291
+
292
+ for email, info in list(index.items()):
293
+ account_id = info.get("id")
294
+ if not account_id:
295
+ to_remove.append(email)
296
+ fixes.append(f"{role}: {email} — нет id в index, запись удалена")
297
+ continue
298
+
299
+ account_dir = role_dir / account_id
300
+
301
+ # 2. Папка аккаунта не существует — создаём с meta.json
302
+ if not account_dir.exists():
303
+ account_dir.mkdir(parents=True, exist_ok=True)
304
+ meta = {"email": email}
305
+ if "password" not in info:
306
+ meta["password"] = ""
307
+ else:
308
+ meta["password"] = info["password"]
309
+ # Для worker берём данные из index
310
+ if role == "worker":
311
+ meta["status"] = info.get("status", "created")
312
+ self._write_meta(account_dir, meta)
313
+ fixes.append(f"{role}: {email} — папка {account_id} отсутствовала, создана заново")
314
+ continue
315
+
316
+ # 3. meta.json отсутствует или битый
317
+ meta_path = account_dir / "meta.json"
318
+ if not meta_path.exists():
319
+ meta = {"email": email, "password": ""}
320
+ if role == "worker":
321
+ meta["status"] = info.get("status", "created")
322
+ self._write_meta(account_dir, meta)
323
+ fixes.append(f"{role}: {email} — meta.json отсутствовал, создан заново")
324
+ else:
325
+ try:
326
+ json.loads(meta_path.read_text())
327
+ except (json.JSONDecodeError, OSError):
328
+ meta = {"email": email, "password": ""}
329
+ if role == "worker":
330
+ meta["status"] = info.get("status", "created")
331
+ self._write_meta(account_dir, meta)
332
+ fixes.append(f"{role}: {email} — meta.json был повреждён, пересоздан")
333
+
334
+ # 4. browser_profile отсутствует (данные есть, но куки нет)
335
+ for email, info in index.items():
336
+ account_id = info.get("id")
337
+ if not account_id:
338
+ continue
339
+ account_dir = role_dir / account_id
340
+ profile_dir = account_dir / "browser_profile"
341
+ if account_dir.exists() and not profile_dir.exists():
342
+ meta = self._read_meta(account_dir)
343
+ if meta.get("access_token") or meta.get("password"):
344
+ fixes.append(f"{role}: {email} — browser_profile отсутствует, нужен перелогин")
345
+
346
+ for email in to_remove:
347
+ index.pop(email, None)
348
+ changed = True
349
+
350
+ # 5. Сироты — папки есть, но в index их нет
351
+ if role_dir.exists():
352
+ indexed_ids = {info["id"] for info in index.values() if "id" in info}
353
+ for child in role_dir.iterdir():
354
+ if not child.is_dir() or child.name == "__pycache__":
355
+ continue
356
+ if child.name not in indexed_ids:
357
+ # Пробуем восстановить из meta.json
358
+ meta = self._read_meta(child)
359
+ email = meta.get("email")
360
+ if email and email not in index:
361
+ entry: dict[str, Any] = {"id": child.name}
362
+ if role == "worker":
363
+ entry["status"] = meta.get("status", "created")
364
+ entry["admin_email"] = meta.get("admin_email", "")
365
+ index[email] = entry
366
+ changed = True
367
+ fixes.append(f"{role}: папка {child.name} ({email}) — добавлена в index")
368
+ elif not email:
369
+ # Нет meta или нет email — удаляем сироту
370
+ shutil.rmtree(child)
371
+ fixes.append(f"{role}: папка-сирота {child.name} без meta — удалена")
372
+
373
+ if changed or to_remove:
374
+ self._write_index(role_dir, index)
375
+
376
+ return fixes
377
+
378
+ def _rebuild_index(self, role_dir: Path, role: str) -> dict:
379
+ """Пересобрать index.json из существующих папок с meta.json."""
380
+ index: dict[str, Any] = {}
381
+ for child in role_dir.iterdir():
382
+ if not child.is_dir():
383
+ continue
384
+ meta = self._read_meta(child)
385
+ email = meta.get("email")
386
+ if email:
387
+ entry: dict[str, Any] = {"id": child.name}
388
+ if role == "worker":
389
+ entry["status"] = meta.get("status", "created")
390
+ entry["admin_email"] = meta.get("admin_email", "")
391
+ index[email] = entry
392
+ self._write_index(role_dir, index)
393
+ return index
394
+
395
+ # --- Миграция ---
396
+
397
+ def migrate_from_profiles(self) -> int:
398
+ """Импорт из старого profiles.json в новую структуру. Возвращает кол-во мигрированных."""
399
+ profiles_json = PROJECT_ROOT / "profiles.json"
400
+ if not profiles_json.exists():
401
+ return 0
402
+
403
+ data = json.loads(profiles_json.read_text())
404
+ count = 0
405
+
406
+ # Админы — ключи верхнего уровня кроме _slots
407
+ for email, info in data.items():
408
+ if email.startswith("_"):
409
+ continue
410
+ if self.get_admin(email):
411
+ continue
412
+ admin = self.add_admin(email, info.get("password", ""))
413
+ admin.access_token = info.get("access_token")
414
+ admin.workspace_id = info.get("workspace_id")
415
+ admin.account_id = info.get("account_id")
416
+ admin.workspaces = info.get("workspaces")
417
+ self.update_admin(admin)
418
+
419
+ # Копируем browser_profile
420
+ old_profile = info.get("profile_dir")
421
+ if old_profile:
422
+ old_path = Path(old_profile)
423
+ if old_path.exists():
424
+ new_profile = self.admin_dir / admin.id / "browser_profile"
425
+ if not new_profile.exists():
426
+ shutil.copytree(old_path, new_profile)
427
+ count += 1
428
+
429
+ # Workers — из _slots
430
+ slots_data = data.get("_slots", {})
431
+ # Определяем admin_email (первый найденный админ)
432
+ admin_email = ""
433
+ for email in data:
434
+ if not email.startswith("_"):
435
+ admin_email = email
436
+ break
437
+
438
+ for email, info in slots_data.items():
439
+ if self.get_worker(email):
440
+ continue
441
+ worker = self.add_worker(email, info.get("password", ""), admin_email)
442
+ worker.status = info.get("status", "created")
443
+ worker.access_token = info.get("access_token")
444
+ worker.workspace_id = info.get("workspace_id")
445
+ self.update_worker(worker)
446
+ count += 1
447
+
448
+ return count
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .openai_web_auth import Page
7
+
8
+
9
+ class ChatGPTAPIError(Exception):
10
+ def __init__(self, status: int, message: str) -> None:
11
+ self.status = status
12
+ self.message = message
13
+ super().__init__(f"[{status}] {message}")
14
+
15
+
16
+ class ChatGPTWorkspaceAPI:
17
+ """Обёртка над ChatGPT backend-api — запросы выполняются через браузер (page.evaluate)."""
18
+
19
+ def __init__(self, page: Page, account_id: str, access_token: str) -> None:
20
+ self.page = page
21
+ self.account_id = account_id
22
+ self.access_token = access_token
23
+
24
+ def _request(
25
+ self,
26
+ method: str,
27
+ path: str,
28
+ body: dict | None = None,
29
+ ) -> Any:
30
+ """Выполнить fetch() внутри браузера — куки и Cloudflare токены подхватываются автоматически."""
31
+ url = f"https://chatgpt.com{path}"
32
+ js_body = json.dumps(body) if body else "null"
33
+
34
+ result = self.page.evaluate(
35
+ """async ([url, method, body, token, accountId]) => {
36
+ const opts = {
37
+ method: method,
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ "Authorization": "Bearer " + token,
41
+ "chatgpt-account-id": accountId,
42
+ },
43
+ };
44
+ if (body && method !== 'GET' && method !== 'HEAD') opts.body = body;
45
+ const resp = await fetch(url, opts);
46
+ const text = await resp.text();
47
+ return {status: resp.status, body: text};
48
+ }""",
49
+ [url, method, js_body, self.access_token, self.account_id],
50
+ )
51
+
52
+ status = result["status"]
53
+ raw_body = result["body"]
54
+
55
+ if status >= 400:
56
+ short = raw_body[:200] if len(raw_body) > 200 else raw_body
57
+ if status == 403:
58
+ short = "Cloudflare/доступ запрещён — токен протух, перелогинитесь"
59
+ raise ChatGPTAPIError(status, short)
60
+
61
+ return json.loads(raw_body) if raw_body else {}
62
+
63
+ def send_invites(self, emails: list[str]) -> dict:
64
+ """Отправить инвайты в workspace."""
65
+ return self._request(
66
+ "POST",
67
+ f"/backend-api/accounts/{self.account_id}/invites",
68
+ body={
69
+ "email_addresses": emails,
70
+ "role": "standard-user",
71
+ "resend_emails": True,
72
+ },
73
+ )
74
+
75
+ def get_pending_invites(self) -> list[dict]:
76
+ """Получить список ожидающих инвайтов."""
77
+ data = self._request(
78
+ "GET",
79
+ f"/backend-api/accounts/{self.account_id}/invites?offset=0&limit=100",
80
+ )
81
+ return data.get("invites", [])
82
+
83
+ def get_members(self) -> list[dict]:
84
+ """Получить список участников workspace."""
85
+ data = self._request(
86
+ "GET",
87
+ f"/backend-api/accounts/{self.account_id}/users?offset=0&limit=100",
88
+ )
89
+ return data.get("items", data.get("users", []))
90
+
91
+ def delete_member(self, user_id: str) -> dict:
92
+ """Удалить участника из workspace по user_id."""
93
+ return self._request(
94
+ "DELETE",
95
+ f"/backend-api/accounts/{self.account_id}/users/{user_id}",
96
+ )
97
+
98
+ def delete_invite(self, email: str) -> dict:
99
+ """Удалить инвайт."""
100
+ return self._request(
101
+ "DELETE",
102
+ f"/backend-api/accounts/{self.account_id}/invites",
103
+ body={"email_address": email},
104
+ )
package/backend/dto.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ from .account_store import AdminAccount, WorkerAccount
6
+
7
+
8
+ @dataclass
9
+ class AdminRow:
10
+ email: str
11
+ has_access_token: bool
12
+ has_browser_profile: bool
13
+ workspace_id: str | None
14
+ workspace_count: int
15
+ created_at: str | None
16
+ last_login: str | None
17
+ status_label: str
18
+
19
+ @classmethod
20
+ def from_account(
21
+ cls,
22
+ account: AdminAccount,
23
+ *,
24
+ has_browser_profile: bool = False,
25
+ ) -> "AdminRow":
26
+ has_access_token = bool(account.access_token)
27
+ workspace_count = len(account.workspaces or [])
28
+ if has_access_token and has_browser_profile:
29
+ status_label = "Готов"
30
+ elif has_access_token:
31
+ status_label = "Есть токен"
32
+ elif has_browser_profile:
33
+ status_label = "Нужен вход"
34
+ else:
35
+ status_label = "Не настроен"
36
+
37
+ return cls(
38
+ email=account.email,
39
+ has_access_token=has_access_token,
40
+ has_browser_profile=has_browser_profile,
41
+ workspace_id=account.workspace_id,
42
+ workspace_count=workspace_count,
43
+ created_at=account.created_at,
44
+ last_login=account.last_login,
45
+ status_label=status_label,
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class WorkerRow:
51
+ email: str
52
+ status: str
53
+ has_access_token: bool
54
+ has_browser_profile: bool
55
+ workspace_id: str | None
56
+ admin_email: str | None
57
+ has_openai_password: bool
58
+ created_at: str | None
59
+ status_label: str
60
+
61
+ @classmethod
62
+ def from_account(
63
+ cls,
64
+ account: WorkerAccount,
65
+ *,
66
+ has_browser_profile: bool = False,
67
+ ) -> "WorkerRow":
68
+ has_access_token = bool(account.access_token)
69
+ if has_access_token and has_browser_profile:
70
+ status_label = "Готов"
71
+ elif account.status == "registered":
72
+ status_label = "Зарегистрирован"
73
+ elif account.status == "invited":
74
+ status_label = "Инвайт отправлен"
75
+ elif account.status == "created":
76
+ status_label = "Создан"
77
+ else:
78
+ status_label = account.status
79
+
80
+ return cls(
81
+ email=account.email,
82
+ status=account.status,
83
+ has_access_token=has_access_token,
84
+ has_browser_profile=has_browser_profile,
85
+ workspace_id=account.workspace_id,
86
+ admin_email=account.admin_email,
87
+ has_openai_password=bool(account.openai_password),
88
+ created_at=account.created_at,
89
+ status_label=status_label,
90
+ )
91
+
92
+
93
+ @dataclass
94
+ class MailAccountRow:
95
+ kind: str
96
+ email: str
97
+
98
+
99
+ @dataclass
100
+ class AppStateDTO:
101
+ admins: list[AdminRow]
102
+ workers: list[WorkerRow]
103
+ accounts: list[MailAccountRow]
104
+
105
+ def to_dict(self) -> dict:
106
+ return asdict(self)