hh-applicant-tool 1.4.7__py3-none-any.whl → 1.4.12__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.
Files changed (46) hide show
  1. hh_applicant_tool/__main__.py +1 -1
  2. hh_applicant_tool/ai/openai.py +2 -2
  3. hh_applicant_tool/api/__init__.py +4 -2
  4. hh_applicant_tool/api/client.py +23 -12
  5. hh_applicant_tool/{constants.py → api/client_keys.py} +3 -3
  6. hh_applicant_tool/{datatypes.py → api/datatypes.py} +2 -0
  7. hh_applicant_tool/api/errors.py +8 -2
  8. hh_applicant_tool/{utils → api}/user_agent.py +1 -1
  9. hh_applicant_tool/main.py +12 -13
  10. hh_applicant_tool/operations/apply_similar.py +125 -47
  11. hh_applicant_tool/operations/authorize.py +82 -25
  12. hh_applicant_tool/operations/call_api.py +3 -3
  13. hh_applicant_tool/operations/{check_negotiations.py → clear_negotiations.py} +40 -25
  14. hh_applicant_tool/operations/list_resumes.py +5 -7
  15. hh_applicant_tool/operations/query.py +3 -1
  16. hh_applicant_tool/operations/reply_employers.py +80 -40
  17. hh_applicant_tool/operations/settings.py +2 -2
  18. hh_applicant_tool/operations/update_resumes.py +5 -4
  19. hh_applicant_tool/operations/whoami.py +1 -1
  20. hh_applicant_tool/storage/__init__.py +5 -1
  21. hh_applicant_tool/storage/facade.py +2 -2
  22. hh_applicant_tool/storage/models/base.py +4 -4
  23. hh_applicant_tool/storage/models/contacts.py +28 -0
  24. hh_applicant_tool/storage/queries/schema.sql +22 -9
  25. hh_applicant_tool/storage/repositories/base.py +69 -15
  26. hh_applicant_tool/storage/repositories/contacts.py +5 -10
  27. hh_applicant_tool/storage/repositories/employers.py +1 -0
  28. hh_applicant_tool/storage/repositories/errors.py +19 -0
  29. hh_applicant_tool/storage/repositories/negotiations.py +1 -0
  30. hh_applicant_tool/storage/repositories/resumes.py +2 -7
  31. hh_applicant_tool/storage/repositories/settings.py +1 -0
  32. hh_applicant_tool/storage/repositories/vacancies.py +1 -0
  33. hh_applicant_tool/storage/utils.py +6 -15
  34. hh_applicant_tool/utils/__init__.py +3 -3
  35. hh_applicant_tool/utils/config.py +1 -1
  36. hh_applicant_tool/utils/log.py +4 -1
  37. hh_applicant_tool/utils/mixins.py +20 -19
  38. hh_applicant_tool/utils/terminal.py +13 -0
  39. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/METADATA +197 -140
  40. hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
  41. hh_applicant_tool/storage/models/contact.py +0 -16
  42. hh_applicant_tool-1.4.7.dist-info/RECORD +0 -67
  43. /hh_applicant_tool/utils/{dateutil.py → date.py} +0 -0
  44. /hh_applicant_tool/utils/{jsonutil.py → json.py} +0 -0
  45. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/WHEEL +0 -0
  46. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/entry_points.txt +0 -0
@@ -1,4 +1,8 @@
1
1
  from .facade import StorageFacade
2
2
  from .utils import apply_migration, list_migrations
3
3
 
4
- __all__ = ["StorageFacade", "apply_migration", "list_migrations"]
4
+ __all__ = [
5
+ "StorageFacade",
6
+ "apply_migration",
7
+ "list_migrations",
8
+ ]
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import sqlite3
4
4
 
5
- from .repositories.contacts import EmployerContactsRepository
5
+ from .repositories.contacts import VacancyContactsRepository
6
6
  from .repositories.employers import EmployersRepository
7
7
  from .repositories.negotiations import NegotiationRepository
8
8
  from .repositories.resumes import ResumesRepository
@@ -18,7 +18,7 @@ class StorageFacade:
18
18
  init_db(conn)
19
19
  self.employers = EmployersRepository(conn)
20
20
  self.vacancies = VacanciesRepository(conn)
21
- self.employer_contacts = EmployerContactsRepository(conn)
21
+ self.vacancy_contacts = VacancyContactsRepository(conn)
22
22
  self.negotiations = NegotiationRepository(conn)
23
23
  self.settings = SettingsRepository(conn)
24
24
  self.resumes = ResumesRepository(conn)
@@ -4,8 +4,8 @@ from datetime import datetime
4
4
  from logging import getLogger
5
5
  from typing import Any, Callable, Mapping, Self, dataclass_transform, get_origin
6
6
 
7
- from hh_applicant_tool.utils import jsonutil
8
- from hh_applicant_tool.utils.dateutil import try_parse_datetime
7
+ from hh_applicant_tool.utils import json
8
+ from hh_applicant_tool.utils.date import try_parse_datetime
9
9
 
10
10
  logger = getLogger(__package__)
11
11
 
@@ -48,7 +48,7 @@ class BaseModel:
48
48
  if value is MISSING:
49
49
  continue
50
50
  if f.metadata.get("store_json"):
51
- value = jsonutil.dumps(value)
51
+ value = json.dumps(value)
52
52
  # Точно не нужно типы приводить перед сохранением
53
53
  # else:
54
54
  # value = self._coerce_type(value, f)
@@ -118,7 +118,7 @@ class BaseModel:
118
118
  continue
119
119
 
120
120
  if f.metadata.get("store_json"):
121
- value = jsonutil.loads(value)
121
+ value = json.loads(value)
122
122
  else:
123
123
  value = cls._coerce_type(value, f)
124
124
 
@@ -0,0 +1,28 @@
1
+ from .base import BaseModel, mapped
2
+
3
+
4
+ # Из вакансии извлекается
5
+ class VacancyContactsModel(BaseModel):
6
+ id: int
7
+ vacancy_id: int = mapped(path="id")
8
+
9
+ vacancy_name: str = mapped(path="name")
10
+ vacancy_alternate_url: str = mapped(path="alternate_url", default=None)
11
+ vacancy_area_id: int = mapped(path="area.id", default=None)
12
+ vacancy_area_name: str = mapped(path="area.name", default=None)
13
+ vacancy_salary_from: int = mapped(path="salary.from", default=0)
14
+ vacancy_salary_to: int = mapped(path="salary.to", default=0)
15
+ vacancy_currency: str = mapped(path="salary.currency", default="RUR")
16
+ vacancy_gross: bool = mapped(path="salary.gross", default=False)
17
+
18
+ employer_id: int = mapped(path="employer.id", default=None)
19
+ employer_name: str = mapped(path="employer.name", default=None)
20
+ email: str = mapped(path="contacts.email")
21
+ name: str = mapped(path="contacts.name", default=None)
22
+ phone_numbers: str = mapped(
23
+ path="contacts.phones",
24
+ transform=lambda phones: ", ".join(
25
+ p["formatted"] for p in phones if p.get("number")
26
+ ),
27
+ default=None,
28
+ )
@@ -14,17 +14,29 @@ CREATE TABLE IF NOT EXISTS employers (
14
14
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
15
15
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
16
16
  );
17
- /* ===================== employer_contacts ===================== */
18
- CREATE TABLE IF NOT EXISTS employer_contacts (
17
+ /* ===================== contacts ===================== */
18
+ CREATE TABLE IF NOT EXISTS vacancy_contacts (
19
19
  id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
20
- employer_id INTEGER NOT NULL,
21
- -- Просто поле, без REFERENCES
20
+ vacancy_id INTEGER NOT NULL,
21
+ -- Все это избыточные поля
22
+ vacancy_alternate_url TEXT,
23
+ vacancy_name TEXT,
24
+ vacancy_area_id INTEGER,
25
+ vacancy_area_name TEXT,
26
+ vacancy_salary_from INTEGER,
27
+ vacancy_salary_to INTEGER,
28
+ vacancy_currency VARCHAR(3),
29
+ vacancy_gross BOOLEAN,
30
+ --
31
+ employer_id INTEGER,
32
+ employer_name TEXT,
33
+ --
22
34
  name TEXT,
23
35
  email TEXT,
24
36
  phone_numbers TEXT NOT NULL,
25
37
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
26
38
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
27
- UNIQUE (employer_id, email)
39
+ UNIQUE (vacancy_id, email)
28
40
  );
29
41
  /* ===================== vacancies ===================== */
30
42
  CREATE TABLE IF NOT EXISTS vacancies (
@@ -49,7 +61,8 @@ CREATE TABLE IF NOT EXISTS negotiations (
49
61
  id INTEGER PRIMARY KEY,
50
62
  state TEXT NOT NULL,
51
63
  vacancy_id INTEGER NOT NULL,
52
- employer_id INTEGER NOT NULL,
64
+ employer_id INTEGER,
65
+ -- Может обнулиться при блокировке раб-о-тодателя
53
66
  chat_id INTEGER NOT NULL,
54
67
  resume_id TEXT,
55
68
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -95,10 +108,10 @@ UPDATE employers
95
108
  SET updated_at = CURRENT_TIMESTAMP
96
109
  WHERE id = OLD.id;
97
110
  END;
98
- CREATE TRIGGER IF NOT EXISTS trg_employer_contacts_updated
111
+ CREATE TRIGGER IF NOT EXISTS trg_vacancy_contacts_updated
99
112
  AFTER
100
- UPDATE ON employer_contacts BEGIN
101
- UPDATE employer_contacts
113
+ UPDATE ON vacancy_contacts BEGIN
114
+ UPDATE vacancy_contacts
102
115
  SET updated_at = CURRENT_TIMESTAMP
103
116
  WHERE id = OLD.id;
104
117
  END;
@@ -4,11 +4,10 @@ import logging
4
4
  import sqlite3
5
5
  from collections.abc import Sequence
6
6
  from dataclasses import dataclass
7
- from functools import cached_property
8
7
  from typing import Any, ClassVar, Iterator, Mapping, Self, Type
9
8
 
10
9
  from ..models.base import BaseModel
11
- from ..utils import model2table
10
+ from .errors import wrap_db_errors
12
11
 
13
12
  DEFAULT_PRIMARY_KEY = "id"
14
13
 
@@ -19,18 +18,23 @@ logger = logging.getLogger(__package__)
19
18
  class BaseRepository:
20
19
  model: ClassVar[Type[BaseModel] | None] = None
21
20
  pkey: ClassVar[str] = DEFAULT_PRIMARY_KEY
21
+ conflict_columns: ClassVar[tuple[str, ...] | None] = None
22
+ update_excludes: ClassVar[tuple[str, ...]] = ("created_at", "updated_at")
23
+ __table__: ClassVar[str | None] = None
22
24
 
23
25
  conn: sqlite3.Connection
24
26
  auto_commit: bool = True
25
27
 
26
- @cached_property
28
+ @property
27
29
  def table_name(self) -> str:
28
- return model2table(self.model)
30
+ return self.__table__ or self.model.__name__
29
31
 
32
+ @wrap_db_errors
30
33
  def commit(self):
31
34
  if self.conn.in_transaction:
32
35
  self.conn.commit()
33
36
 
37
+ @wrap_db_errors
34
38
  def rollback(self):
35
39
  if self.conn.in_transaction:
36
40
  self.conn.rollback()
@@ -53,6 +57,7 @@ class BaseRepository:
53
57
  data = {col[0]: value for col, value in zip(cursor.description, row)} # noqa: B905
54
58
  return self.model.from_db(data)
55
59
 
60
+ @wrap_db_errors
56
61
  def find(self, **kwargs: Any) -> Iterator[BaseModel]:
57
62
  # logger.debug(kwargs)
58
63
  operators = {
@@ -94,25 +99,37 @@ class BaseRepository:
94
99
  if conditions:
95
100
  sql += f" WHERE {' AND '.join(conditions)}"
96
101
  sql += " ORDER BY rowid DESC;"
97
- logger.debug("%.2000s", sql)
98
- cur = self.conn.execute(sql, sql_params)
102
+ try:
103
+ cur = self.conn.execute(sql, sql_params)
104
+ except sqlite3.Error:
105
+ logger.warning("SQL ERROR: %s", sql)
106
+ raise
107
+
99
108
  yield from (self._row_to_model(cur, row) for row in cur.fetchall())
100
109
 
110
+ @wrap_db_errors
101
111
  def get(self, pk: Any) -> BaseModel | None:
102
112
  return next(self.find(**{f"{self.pkey}": pk}), None)
103
113
 
114
+ @wrap_db_errors
104
115
  def count_total(self) -> int:
105
116
  cur = self.conn.execute(f"SELECT count(*) FROM {self.table_name};")
106
117
  return cur.fetchone()[0]
107
118
 
108
- def delete(self, o: BaseModel, /, commit: bool | None = None) -> None:
119
+ @wrap_db_errors
120
+ def delete(self, obj_or_pkey: Any, /, commit: bool | None = None) -> None:
109
121
  sql = f"DELETE FROM {self.table_name} WHERE {self.pkey} = ?"
110
- pk_value = getattr(o, self.pkey)
122
+ pk_value = (
123
+ getattr(obj_or_pkey, self.pkey)
124
+ if isinstance(obj_or_pkey, BaseModel)
125
+ else obj_or_pkey
126
+ )
111
127
  self.conn.execute(sql, (pk_value,))
112
128
  self.maybe_commit(commit=commit)
113
129
 
114
130
  remove = delete
115
131
 
132
+ @wrap_db_errors
116
133
  def clear(self, commit: bool | None = None):
117
134
  self.conn.execute(f"DELETE FROM {self.table_name};")
118
135
  self.maybe_commit(commit)
@@ -121,14 +138,21 @@ class BaseRepository:
121
138
 
122
139
  def _insert(
123
140
  self,
124
- data: Mapping[str, Any],
141
+ data: Mapping[str, Any] | list[Mapping[str, Any]],
125
142
  /,
143
+ batch: bool = False,
126
144
  upsert: bool = True,
127
145
  conflict_columns: Sequence[str] | None = None,
128
- update_excludes: Sequence[str] = ("created_at", "updated_at"),
146
+ update_excludes: Sequence[str] | None = None,
129
147
  commit: bool | None = None,
130
148
  ):
131
- columns = list(data.keys())
149
+ conflict_columns = conflict_columns or self.conflict_columns
150
+ update_excludes = update_excludes or self.update_excludes
151
+
152
+ if batch and not data:
153
+ return
154
+
155
+ columns = list(dict(data[0] if batch else data).keys())
132
156
  sql = (
133
157
  f"INSERT INTO {self.table_name} ({', '.join(columns)})"
134
158
  f" VALUES (:{', :'.join(columns)})"
@@ -151,7 +175,10 @@ class BaseRepository:
151
175
  # 2. Primary key (никогда не меняем)
152
176
  # 3. Технические поля (created_at и т.д.)
153
177
  update_set = (
154
- cols_set - conflict_set - {self.pkey} - set(update_excludes)
178
+ cols_set
179
+ - conflict_set
180
+ - {self.pkey}
181
+ - set(update_excludes or [])
155
182
  )
156
183
 
157
184
  if update_set:
@@ -163,14 +190,41 @@ class BaseRepository:
163
190
  sql += " DO NOTHING"
164
191
 
165
192
  sql += ";"
166
- logger.debug("%.2000s", sql)
167
- self.conn.execute(sql, data)
193
+ # logger.debug("%.2000s", sql)
194
+ try:
195
+ if batch:
196
+ self.conn.executemany(sql, data)
197
+ else:
198
+ self.conn.execute(sql, data)
199
+ except sqlite3.Error:
200
+ logger.warning("SQL ERROR: %s", sql)
201
+
202
+ raise
168
203
  self.maybe_commit(commit)
169
204
 
205
+ @wrap_db_errors
170
206
  def save(
171
- self, obj: BaseModel | Mapping[str, Any], /, **kwargs: Any
207
+ self,
208
+ obj: BaseModel | Mapping[str, Any],
209
+ /,
210
+ **kwargs: Any,
172
211
  ) -> None:
173
212
  if isinstance(obj, Mapping):
174
213
  obj = self.model.from_api(obj)
175
214
  data = obj.to_db()
176
215
  self._insert(data, **kwargs)
216
+
217
+ @wrap_db_errors
218
+ def save_batch(
219
+ self,
220
+ items: list[BaseModel | Mapping[str, Any]],
221
+ /,
222
+ **kwargs: Any,
223
+ ) -> None:
224
+ if not items:
225
+ return
226
+ data = [
227
+ (self.model.from_api(i) if isinstance(i, Mapping) else i).to_db()
228
+ for i in items
229
+ ]
230
+ self._insert(data, batch=True, **kwargs)
@@ -2,18 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
 
5
- from ..models.contact import EmployerContactModel
5
+ from ..models.contacts import VacancyContactsModel
6
6
  from .base import BaseRepository
7
7
 
8
8
  logger = logging.getLogger(__package__)
9
9
 
10
10
 
11
- class EmployerContactsRepository(BaseRepository):
12
- model = EmployerContactModel
13
-
14
- def save(self, contact: EmployerContactModel) -> None:
15
- # logger.debug(contact)
16
- super().save(
17
- contact,
18
- conflict_columns=["employer_id", "email"],
19
- )
11
+ class VacancyContactsRepository(BaseRepository):
12
+ __table__ = "vacancy_contacts"
13
+ model = VacancyContactsModel
14
+ conflict_columns = ("vacancy_id", "email")
@@ -7,6 +7,7 @@ from .base import BaseRepository
7
7
 
8
8
 
9
9
  class EmployersRepository(BaseRepository):
10
+ __table__ = "employers"
10
11
  model = EmployerModel
11
12
 
12
13
  def find(self, **kwargs) -> Iterator[EmployerModel]:
@@ -0,0 +1,19 @@
1
+ import sqlite3
2
+ from functools import wraps
3
+
4
+
5
+ class RepositoryError(sqlite3.Error):
6
+ pass
7
+
8
+
9
+ def wrap_db_errors(func):
10
+ @wraps(func)
11
+ def wrapper(*args, **kwargs):
12
+ try:
13
+ return func(*args, **kwargs)
14
+ except sqlite3.Error as e:
15
+ raise RepositoryError(
16
+ f"Database error in {func.__name__}: {e}"
17
+ ) from e
18
+
19
+ return wrapper
@@ -9,4 +9,5 @@ logger = getLogger(__package__)
9
9
 
10
10
 
11
11
  class NegotiationRepository(BaseRepository):
12
+ __table__ = "negotiations"
12
13
  model = NegotiationModel
@@ -1,14 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sqlite3
4
-
5
3
  from ..models.resume import ResumeModel
6
4
  from .base import BaseRepository
7
5
 
8
6
 
9
7
  class ResumesRepository(BaseRepository):
10
- """Репозиторий для хранения резюме."""
11
-
12
- def __init__(self, conn: sqlite3.Connection):
13
- super().__init__(conn)
14
- self.model = ResumeModel
8
+ __table__ = "resumes"
9
+ model = ResumeModel
@@ -7,6 +7,7 @@ Default = TypeVar("Default")
7
7
 
8
8
 
9
9
  class SettingsRepository(BaseRepository):
10
+ __table__ = "settings"
10
11
  pkey: str = "key"
11
12
  model = SettingModel
12
13
 
@@ -5,4 +5,5 @@ from .base import BaseRepository
5
5
 
6
6
 
7
7
  class VacanciesRepository(BaseRepository):
8
+ __table__ = "vacancies"
8
9
  model = VacancyModel
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- import re
5
4
  import sqlite3
6
5
  from pathlib import Path
7
6
 
@@ -17,7 +16,7 @@ def init_db(conn: sqlite3.Connection) -> None:
17
16
  conn.executescript(
18
17
  (QUERIES_PATH / "schema.sql").read_text(encoding="utf-8")
19
18
  )
20
- logger.debug("Database unitialized")
19
+ logger.debug("Database initialized")
21
20
 
22
21
 
23
22
  def list_migrations() -> list[str]:
@@ -34,16 +33,8 @@ def apply_migration(conn: sqlite3.Connection, name: str) -> None:
34
33
  )
35
34
 
36
35
 
37
- def model2table(o: type) -> str:
38
- name: str = o.__name__
39
- if name.endswith("Model"):
40
- name = name[:-5]
41
- name = re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
42
- # y -> ies (если перед y согласная: vacancy -> vacancies)
43
- if name.endswith("y") and not name.endswith(("ay", "ey", "iy", "oy", "uy")):
44
- return name[:-1] + "ies"
45
- # s, x, z, ch, sh -> +es (bus -> buses, match -> matches)
46
- if name.endswith(("s", "x", "z", "ch", "sh")):
47
- return name + "es"
48
- # Обычный случай
49
- return name + "s"
36
+ # def model2table(o: type) -> str:
37
+ # name: str = o.__name__
38
+ # if name.endswith("Model"):
39
+ # name = name[:-5]
40
+ # return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from ..api.user_agent import generate_android_useragent
3
4
  from .attrdict import AttrDict
4
5
  from .config import Config, get_config_path
5
- from .dateutil import (
6
+ from .date import (
6
7
  DATETIME_FORMAT,
7
8
  parse_api_datetime,
8
9
  try_parse_datetime,
@@ -10,7 +11,6 @@ from .dateutil import (
10
11
  from .misc import calc_hash, print_err
11
12
  from .string import bool2str, list2str, rand_text, shorten
12
13
  from .terminal import setup_terminal
13
- from .user_agent import hh_android_useragent
14
14
 
15
15
  # Add all public symbols to __all__ for consistent import behavior
16
16
  __all__ = [
@@ -25,7 +25,7 @@ __all__ = [
25
25
  "bool2str",
26
26
  "list2str",
27
27
  "calc_hash",
28
- "hh_android_useragent",
28
+ "generate_android_useragent",
29
29
  "setup_terminal",
30
30
  "print_err",
31
31
  ]
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from threading import Lock
8
8
  from typing import Any
9
9
 
10
- from . import jsonutil as json
10
+ from . import json
11
11
 
12
12
 
13
13
  @cache
@@ -38,9 +38,11 @@ class ColorHandler(logging.StreamHandler):
38
38
  def format(self, record: logging.LogRecord) -> str:
39
39
  # Подавляем вывод подробного сообщения об ошибке
40
40
  orig_exc_info = record.exc_info
41
+
41
42
  # Детали ошибки показываем только при отладке
42
43
  if self.level > logging.DEBUG:
43
44
  record.exc_info = None
45
+
44
46
  message = super().format(record)
45
47
  # Обязательно нужно восстановить оригинальное значение или в файловом
46
48
  # логе не будет деталей ошибки
@@ -107,8 +109,9 @@ def setup_logger(
107
109
  ]
108
110
  )
109
111
 
112
+ file_handler.addFilter(redactor)
113
+
110
114
  for h in [color_handler, file_handler]:
111
- h.addFilter(redactor)
112
115
  logger.addHandler(h)
113
116
 
114
117
 
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import platform
4
4
  import socket
5
- from datetime import datetime, timedelta
5
+ from datetime import datetime, timedelta, timezone
6
6
  from functools import cache
7
7
  from importlib.metadata import version
8
8
  from logging import getLogger
@@ -31,7 +31,7 @@ def get_package_version() -> str | None:
31
31
 
32
32
 
33
33
  class ErrorReporter:
34
- def build_report(
34
+ def __build_report(
35
35
  self: HHApplicantTool,
36
36
  last_report: datetime,
37
37
  ) -> dict:
@@ -42,15 +42,16 @@ class ErrorReporter:
42
42
  fp, last_report, maxlen=10000
43
43
  )
44
44
 
45
- # Эти данные нужны для воспроизведения ошибок. Среди них ваших нет
46
- contacts = [
45
+ # Эти данные нужны для воспроизведения ошибок. Среди них ваших
46
+ # персональных данных нет
47
+ vacancy_contacts = [
47
48
  c.to_dict()
48
- for c in self.storage.employer_contacts.find(
49
+ for c in self.storage.vacancy_contacts.find(
49
50
  updated_at__ge=last_report
50
51
  )
51
52
  ][-10000:]
52
53
 
53
- for c in contacts:
54
+ for c in vacancy_contacts:
54
55
  c.pop("id", 0)
55
56
 
56
57
  employers = [
@@ -106,15 +107,15 @@ class ErrorReporter:
106
107
 
107
108
  return dict(
108
109
  error_logs=error_logs,
109
- contacts=contacts,
110
+ vacancy_contacts=vacancy_contacts,
110
111
  employers=employers,
111
112
  vacancies=vacancies,
112
113
  package_version=get_package_version(),
113
114
  system_info=system_info,
114
- report_created=datetime.now(),
115
+ report_created=datetime.now(timezone.utc),
115
116
  )
116
117
 
117
- def send_report(self: HHApplicantTool, data: bytes) -> int:
118
+ def __send_report(self: HHApplicantTool, data: bytes) -> int:
118
119
  try:
119
120
  r = self.session.post(
120
121
  # "http://localhost:8000/report",
@@ -128,7 +129,7 @@ class ErrorReporter:
128
129
  # log.error("Network error: %s", e)
129
130
  return False
130
131
 
131
- def process_reporting(self):
132
+ def _process_reporting(self):
132
133
  # Получаем timestamp последнего репорта
133
134
  last_report = datetime.fromtimestamp(
134
135
  self.storage.settings.get_value("_last_report", 0)
@@ -136,12 +137,12 @@ class ErrorReporter:
136
137
 
137
138
  if datetime.now() >= last_report + timedelta(hours=72):
138
139
  try:
139
- report_dict = self.build_report(last_report)
140
+ report_dict = self.__build_report(last_report)
140
141
  has_data = any(
141
142
  [
142
143
  report_dict.get("error_logs"),
143
144
  report_dict.get("employers"),
144
- report_dict.get("contacts"),
145
+ report_dict.get("vacancy_contacts"),
145
146
  report_dict.get("vacancies"),
146
147
  ]
147
148
  )
@@ -149,7 +150,7 @@ class ErrorReporter:
149
150
  data = binpack.serialize(report_dict)
150
151
  log.debug("Report body size: %d", len(data))
151
152
  # print(binpack.deserialize(data))
152
- if self.send_report(data):
153
+ if self.__send_report(data):
153
154
  log.debug("Report was sent")
154
155
  else:
155
156
  log.debug("Report failed")
@@ -161,7 +162,7 @@ class ErrorReporter:
161
162
 
162
163
 
163
164
  class VersionChecker:
164
- def get_latest_version(self: HHApplicantTool) -> Literal[False] | str:
165
+ def __get_latest_version(self: HHApplicantTool) -> Literal[False] | str:
165
166
  try:
166
167
  response = self.session.get(
167
168
  "https://pypi.org/pypi/hh-applicant-tool/json", timeout=15
@@ -172,11 +173,11 @@ class VersionChecker:
172
173
  except requests.RequestException:
173
174
  return False
174
175
 
175
- def check_version(self: HHApplicantTool) -> bool:
176
+ def _check_version(self: HHApplicantTool) -> bool:
176
177
  if datetime.now().timestamp() >= self.storage.settings.get_value(
177
178
  "_next_version_check", 0
178
179
  ):
179
- if v := self.get_latest_version():
180
+ if v := self.__get_latest_version():
180
181
  self.storage.settings.set_value("_latest_version", v)
181
182
  self.storage.settings.set_value(
182
183
  "_next_version_check", datetime.now() + timedelta(hours=1)
@@ -210,11 +211,11 @@ class ChatOpenAISupport:
210
211
 
211
212
 
212
213
  class MegaTool(ErrorReporter, VersionChecker, ChatOpenAISupport):
213
- def check_system(self: HHApplicantTool):
214
+ def _check_system(self: HHApplicantTool):
214
215
  if not self.storage.settings.get_value("disable_version_check", False):
215
- self.check_version()
216
+ self._check_version()
216
217
 
217
218
  if self.storage.settings.get_value("send_error_reports", True):
218
- self.process_reporting()
219
+ self._process_reporting()
219
220
  else:
220
221
  log.warning("ОТКЛЮЧЕНА ОТПРАВКА СООБЩЕНИЙ ОБ ОШИБКАХ!")
@@ -1,5 +1,7 @@
1
+ import base64
1
2
  import ctypes
2
3
  import platform
4
+ import sys
3
5
 
4
6
 
5
7
  def setup_terminal() -> None:
@@ -17,3 +19,14 @@ def setup_terminal() -> None:
17
19
  # Если что-то пошло не так (старая Windows или нет прав),
18
20
  # просто продолжаем работу без цветов
19
21
  pass
22
+
23
+
24
+ def print_kitty_image(data: bytes) -> None:
25
+ # Кодируем весь файл целиком (он уже сжат в PNG)
26
+ b64data = base64.b64encode(data).decode("ascii")
27
+
28
+ # f=100 говорит терминалу: "это PNG, разберись сам с размерами"
29
+ # Нам больше не нужно указывать s=... и v=...
30
+ sys.stdout.write(f"\033_Ga=T,f=100;{b64data}\033\\")
31
+ sys.stdout.flush()
32
+ print()