hh-applicant-tool 0.7.10__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 (76) hide show
  1. hh_applicant_tool/__init__.py +1 -0
  2. hh_applicant_tool/__main__.py +1 -1
  3. hh_applicant_tool/ai/base.py +2 -0
  4. hh_applicant_tool/ai/openai.py +25 -35
  5. hh_applicant_tool/api/__init__.py +4 -2
  6. hh_applicant_tool/api/client.py +65 -68
  7. hh_applicant_tool/{constants.py → api/client_keys.py} +3 -6
  8. hh_applicant_tool/api/datatypes.py +293 -0
  9. hh_applicant_tool/api/errors.py +57 -7
  10. hh_applicant_tool/api/user_agent.py +17 -0
  11. hh_applicant_tool/main.py +234 -113
  12. hh_applicant_tool/operations/apply_similar.py +353 -371
  13. hh_applicant_tool/operations/authorize.py +313 -120
  14. hh_applicant_tool/operations/call_api.py +18 -8
  15. hh_applicant_tool/operations/check_proxy.py +30 -0
  16. hh_applicant_tool/operations/clear_negotiations.py +90 -82
  17. hh_applicant_tool/operations/config.py +119 -16
  18. hh_applicant_tool/operations/install.py +34 -0
  19. hh_applicant_tool/operations/list_resumes.py +23 -11
  20. hh_applicant_tool/operations/log.py +77 -0
  21. hh_applicant_tool/operations/migrate_db.py +65 -0
  22. hh_applicant_tool/operations/query.py +122 -0
  23. hh_applicant_tool/operations/refresh_token.py +14 -13
  24. hh_applicant_tool/operations/reply_employers.py +201 -180
  25. hh_applicant_tool/operations/settings.py +95 -0
  26. hh_applicant_tool/operations/uninstall.py +26 -0
  27. hh_applicant_tool/operations/update_resumes.py +23 -11
  28. hh_applicant_tool/operations/whoami.py +40 -7
  29. hh_applicant_tool/storage/__init__.py +8 -0
  30. hh_applicant_tool/storage/facade.py +24 -0
  31. hh_applicant_tool/storage/models/__init__.py +0 -0
  32. hh_applicant_tool/storage/models/base.py +169 -0
  33. hh_applicant_tool/storage/models/contacts.py +28 -0
  34. hh_applicant_tool/storage/models/employer.py +12 -0
  35. hh_applicant_tool/storage/models/negotiation.py +16 -0
  36. hh_applicant_tool/storage/models/resume.py +19 -0
  37. hh_applicant_tool/storage/models/setting.py +6 -0
  38. hh_applicant_tool/storage/models/vacancy.py +36 -0
  39. hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
  40. hh_applicant_tool/storage/queries/schema.sql +132 -0
  41. hh_applicant_tool/storage/repositories/__init__.py +0 -0
  42. hh_applicant_tool/storage/repositories/base.py +230 -0
  43. hh_applicant_tool/storage/repositories/contacts.py +14 -0
  44. hh_applicant_tool/storage/repositories/employers.py +14 -0
  45. hh_applicant_tool/storage/repositories/errors.py +19 -0
  46. hh_applicant_tool/storage/repositories/negotiations.py +13 -0
  47. hh_applicant_tool/storage/repositories/resumes.py +9 -0
  48. hh_applicant_tool/storage/repositories/settings.py +35 -0
  49. hh_applicant_tool/storage/repositories/vacancies.py +9 -0
  50. hh_applicant_tool/storage/utils.py +40 -0
  51. hh_applicant_tool/utils/__init__.py +31 -0
  52. hh_applicant_tool/utils/attrdict.py +6 -0
  53. hh_applicant_tool/utils/binpack.py +167 -0
  54. hh_applicant_tool/utils/config.py +55 -0
  55. hh_applicant_tool/utils/date.py +19 -0
  56. hh_applicant_tool/utils/json.py +61 -0
  57. hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
  58. hh_applicant_tool/utils/log.py +147 -0
  59. hh_applicant_tool/utils/misc.py +12 -0
  60. hh_applicant_tool/utils/mixins.py +221 -0
  61. hh_applicant_tool/utils/string.py +27 -0
  62. hh_applicant_tool/utils/terminal.py +32 -0
  63. hh_applicant_tool-1.4.12.dist-info/METADATA +685 -0
  64. hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
  65. hh_applicant_tool/ai/blackbox.py +0 -55
  66. hh_applicant_tool/color_log.py +0 -47
  67. hh_applicant_tool/mixins.py +0 -13
  68. hh_applicant_tool/operations/delete_telemetry.py +0 -30
  69. hh_applicant_tool/operations/get_employer_contacts.py +0 -348
  70. hh_applicant_tool/telemetry_client.py +0 -106
  71. hh_applicant_tool/types.py +0 -45
  72. hh_applicant_tool/utils.py +0 -119
  73. hh_applicant_tool-0.7.10.dist-info/METADATA +0 -452
  74. hh_applicant_tool-0.7.10.dist-info/RECORD +0 -33
  75. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.12.dist-info}/WHEEL +0 -0
  76. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.12.dist-info}/entry_points.txt +0 -0
@@ -1,12 +1,18 @@
1
1
  # Этот модуль можно использовать как образец для других
2
+ from __future__ import annotations
3
+
2
4
  import argparse
3
5
  import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..api import ApiError, datatypes
9
+ from ..main import BaseNamespace, BaseOperation
10
+ from ..utils import print_err
11
+ from ..utils.string import shorten
12
+
13
+ if TYPE_CHECKING:
14
+ from ..main import HHApplicantTool
4
15
 
5
- from ..api import ApiClient, ApiError
6
- from ..main import BaseOperation
7
- from ..main import Namespace as BaseNamespace
8
- from ..types import ApiListResponse
9
- from ..utils import print_err, truncate_string
10
16
 
11
17
  logger = logging.getLogger(__package__)
12
18
 
@@ -18,15 +24,21 @@ class Namespace(BaseNamespace):
18
24
  class Operation(BaseOperation):
19
25
  """Обновить все резюме"""
20
26
 
27
+ __aliases__ = ["update"]
28
+
21
29
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
22
30
  pass
23
31
 
24
- def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
25
- resumes: ApiListResponse = api_client.get("/resumes/mine")
26
- for resume in resumes["items"]:
32
+ def run(self, tool: HHApplicantTool) -> None:
33
+ resumes: list[datatypes.Resume] = tool.get_resumes()
34
+ # Там вызов API меняет поля
35
+ # tool.storage.resumes.save_batch(resumes)
36
+ for resume in resumes:
27
37
  try:
28
- res = api_client.post(f"/resumes/{resume['id']}/publish")
38
+ res = tool.api_client.post(
39
+ f"/resumes/{resume['id']}/publish",
40
+ )
29
41
  assert res == {}
30
- print("✅ Обновлено", truncate_string(resume["title"]))
42
+ print("✅ Обновлено", shorten(resume["title"]))
31
43
  except ApiError as ex:
32
- print_err("❗ Ошибка:", ex)
44
+ print_err("❗", ex)
@@ -1,11 +1,16 @@
1
1
  # Этот модуль можно использовать как образец для других
2
+ from __future__ import annotations
3
+
2
4
  import argparse
3
- import json
4
5
  import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..api import datatypes
9
+ from ..main import BaseNamespace, BaseOperation
10
+
11
+ if TYPE_CHECKING:
12
+ from ..main import HHApplicantTool
5
13
 
6
- from ..api import ApiClient
7
- from ..main import BaseOperation
8
- from ..main import Namespace as BaseNamespace
9
14
 
10
15
  logger = logging.getLogger(__package__)
11
16
 
@@ -14,12 +19,40 @@ class Namespace(BaseNamespace):
14
19
  pass
15
20
 
16
21
 
22
+ def fmt_plus(n: int) -> str:
23
+ assert n >= 0
24
+ return f"+{n}" if n else "0"
25
+
26
+
17
27
  class Operation(BaseOperation):
18
28
  """Выведет текущего пользователя"""
19
29
 
30
+ __aliases__: list[str] = ["id"]
31
+
20
32
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
21
33
  pass
22
34
 
23
- def run(self, args: Namespace, api_client: ApiClient, _) -> None:
24
- result = api_client.get("/me")
25
- print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
35
+ def run(self, tool: HHApplicantTool) -> None:
36
+ api_client = tool.api_client
37
+ result: datatypes.User = api_client.get("me")
38
+ full_name = " ".join(
39
+ filter(
40
+ None,
41
+ [
42
+ result.get("last_name"),
43
+ result.get("first_name"),
44
+ result.get("middle_name"),
45
+ ],
46
+ )
47
+ )
48
+ with tool.storage.settings as s:
49
+ s.set_value("user.full_name", full_name)
50
+ s.set_value("user.email", result.get("email"))
51
+ s.set_value("user.phone", result.get("phone"))
52
+ counters = result["counters"]
53
+ print(
54
+ f"🆔 {result['id']} {full_name or 'Анонимный аккаунт'} "
55
+ f"[ 📄 {counters['resumes_count']} "
56
+ f"| 👁️ {fmt_plus(counters['new_resume_views'])} "
57
+ f"| ✉️ {fmt_plus(counters['unread_negotiations'])} ]"
58
+ )
@@ -0,0 +1,8 @@
1
+ from .facade import StorageFacade
2
+ from .utils import apply_migration, list_migrations
3
+
4
+ __all__ = [
5
+ "StorageFacade",
6
+ "apply_migration",
7
+ "list_migrations",
8
+ ]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+ from .repositories.contacts import VacancyContactsRepository
6
+ from .repositories.employers import EmployersRepository
7
+ from .repositories.negotiations import NegotiationRepository
8
+ from .repositories.resumes import ResumesRepository
9
+ from .repositories.settings import SettingsRepository
10
+ from .repositories.vacancies import VacanciesRepository
11
+ from .utils import init_db
12
+
13
+
14
+ class StorageFacade:
15
+ """Единая точка доступа к persistence-слою."""
16
+
17
+ def __init__(self, conn: sqlite3.Connection):
18
+ init_db(conn)
19
+ self.employers = EmployersRepository(conn)
20
+ self.vacancies = VacanciesRepository(conn)
21
+ self.vacancy_contacts = VacancyContactsRepository(conn)
22
+ self.negotiations = NegotiationRepository(conn)
23
+ self.settings = SettingsRepository(conn)
24
+ self.resumes = ResumesRepository(conn)
File without changes
@@ -0,0 +1,169 @@
1
+ import builtins
2
+ from dataclasses import Field, asdict, dataclass, field, fields
3
+ from datetime import datetime
4
+ from logging import getLogger
5
+ from typing import Any, Callable, Mapping, Self, dataclass_transform, get_origin
6
+
7
+ from hh_applicant_tool.utils import json
8
+ from hh_applicant_tool.utils.date import try_parse_datetime
9
+
10
+ logger = getLogger(__package__)
11
+
12
+ MISSING = object()
13
+
14
+
15
+ def mapped(
16
+ path: str | None = None,
17
+ transform: Callable[[Any], Any] | None = None,
18
+ store_json: bool = False,
19
+ **kwargs: Any,
20
+ ):
21
+ metadata = kwargs.get("metadata", {})
22
+ metadata.setdefault("path", path)
23
+ metadata.setdefault("transform", transform)
24
+ metadata.setdefault("store_json", store_json)
25
+ return field(metadata=metadata, **kwargs)
26
+
27
+
28
+ @dataclass_transform(field_specifiers=(field, mapped))
29
+ class BaseModel:
30
+ def __init_subclass__(cls, /, **kwargs: Any):
31
+ super().__init_subclass__()
32
+ dataclass(cls, kw_only=True, **kwargs)
33
+
34
+ @classmethod
35
+ def from_db(cls, data: Mapping[str, Any]) -> Self:
36
+ return cls._from_mapping(data)
37
+
38
+ @classmethod
39
+ def from_api(cls, data: Mapping[str, Any]) -> Self:
40
+ return cls._from_mapping(data, from_source=True)
41
+
42
+ def to_db(self) -> dict[str, Any]:
43
+ data = self.to_dict()
44
+ for f in fields(self):
45
+ # Если какого-то значения нет в словаре, то не ставим его или
46
+ # ломается установка дефолтных значений.
47
+ value = data.get(f.name, MISSING)
48
+ if value is MISSING:
49
+ continue
50
+ if f.metadata.get("store_json"):
51
+ value = json.dumps(value)
52
+ # Точно не нужно типы приводить перед сохранением
53
+ # else:
54
+ # value = self._coerce_type(value, f)
55
+ data[f.name] = value
56
+ return data
57
+
58
+ @classmethod
59
+ def _coerce_type(cls, value: Any, f: Field) -> Any:
60
+ # Лишь создатель знает, что с тобой делать
61
+ if get_origin(f.type):
62
+ return value
63
+
64
+ type_name = f.type if isinstance(f.type, str) else f.type.__name__
65
+ if value is not None and type_name in (
66
+ "bool",
67
+ "str",
68
+ "int",
69
+ "float",
70
+ "datetime",
71
+ ):
72
+ if type_name == "datetime":
73
+ return try_parse_datetime(value)
74
+ try:
75
+ t = getattr(builtins, type_name)
76
+ if not isinstance(value, t):
77
+ value = t(value)
78
+ except (TypeError, ValueError):
79
+ pass
80
+ return value
81
+
82
+ @classmethod
83
+ def _from_mapping(
84
+ cls,
85
+ data: Mapping[str, Any],
86
+ /,
87
+ from_source: bool = False,
88
+ ) -> Self:
89
+ kwargs = {}
90
+ for f in fields(cls):
91
+ if from_source:
92
+ if path := f.metadata.get("path"):
93
+ found = True
94
+ v = data
95
+ for key in path.split("."):
96
+ if isinstance(v, Mapping):
97
+ v = v.get(key)
98
+ else:
99
+ found = False
100
+ break
101
+ if not found:
102
+ continue
103
+ value = v
104
+ else:
105
+ value = data.get(f.name, MISSING)
106
+ if value is MISSING:
107
+ continue
108
+
109
+ if value is not None and (t := f.metadata.get("transform")):
110
+ if isinstance(t, str):
111
+ t = getattr(cls, t)
112
+ value = t(value)
113
+
114
+ value = cls._coerce_type(value, f)
115
+ else:
116
+ value = data.get(f.name, MISSING)
117
+ if value is MISSING:
118
+ continue
119
+
120
+ if f.metadata.get("store_json"):
121
+ value = json.loads(value)
122
+ else:
123
+ value = cls._coerce_type(value, f)
124
+
125
+ kwargs[f.name] = value
126
+ return cls(**kwargs)
127
+
128
+ def to_dict(self) -> dict[str, Any]:
129
+ return asdict(self) # pyright: ignore[reportArgumentType]
130
+
131
+ # def to_json(self, **kwargs: Any) -> str:
132
+ # """Serializes the model to a JSON string."""
133
+ # kwargs.setdefault("ensure_ascii", False)
134
+ # return json_utils.dumps(self.to_dict(), **kwargs)
135
+
136
+ # @classmethod
137
+ # def from_json(cls, json_str: str, **kwargs: Any) -> Self:
138
+ # """Deserializes a model from a JSON string."""
139
+ # data = json_utils.loads(json_str, **kwargs)
140
+ # # from_api is probably more appropriate as JSON is a common API format
141
+ # # and it handles nested data sources.
142
+ # return cls.from_api(data)
143
+
144
+
145
+ if __name__ == "__main__":
146
+
147
+ class CompanyModel(BaseModel):
148
+ id: "int"
149
+ name: str
150
+ city_id: int = mapped(path="location.city.id")
151
+ city: str = mapped(path="location.city.name")
152
+ created_at: datetime
153
+
154
+ c = CompanyModel.from_api(
155
+ {
156
+ "id": "42",
157
+ "name": "ACME",
158
+ "location": {
159
+ "city": {
160
+ "id": "1",
161
+ "name": "Moscow",
162
+ },
163
+ },
164
+ "created_at": "2026-01-09T04:12:00.114858",
165
+ }
166
+ )
167
+
168
+ print(c)
169
+ # assert c == CompanyModel(id=42, name="ACME", city_id=1, city="Moscow")
@@ -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
+ )
@@ -0,0 +1,12 @@
1
+ from .base import BaseModel, mapped
2
+
3
+
4
+ class EmployerModel(BaseModel):
5
+ id: int
6
+ name: str
7
+ type: str | None = None
8
+ description: str | None = None
9
+ site_url: str | None = None
10
+ alternate_url: str | None = None
11
+ area_id: int = mapped(path="area.id", default=None)
12
+ area_name: str = mapped(path="area.name", default=None)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from .base import BaseModel, mapped
6
+
7
+
8
+ class NegotiationModel(BaseModel):
9
+ id: int
10
+ chat_id: int
11
+ state: str = mapped(path="state.id")
12
+ vacancy_id: int = mapped(path="vacancy.id")
13
+ employer_id: int = mapped(path="vacancy.employer.id", default=None)
14
+ resume_id: str = mapped(path="resume.id")
15
+ created_at: datetime | None = None
16
+ updated_at: datetime | None = None
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from .base import BaseModel, mapped
6
+
7
+
8
+ class ResumeModel(BaseModel):
9
+ id: str
10
+ title: str
11
+ url: str
12
+ alternate_url: str
13
+ status_id: str = mapped(path="status.id")
14
+ status_name: str = mapped(path="status.name")
15
+ can_publish_or_update: bool = False
16
+ total_views: int = mapped(path="counters.total_views", default=0)
17
+ new_views: int = mapped(path="counters.new_views", default=0)
18
+ created_at: datetime | None = None
19
+ updated_at: datetime | None = None
@@ -0,0 +1,6 @@
1
+ from .base import BaseModel, mapped
2
+
3
+
4
+ class SettingModel(BaseModel):
5
+ key: str
6
+ value: str = mapped(store_json=True)
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from .base import BaseModel, mapped
6
+
7
+
8
+ class VacancyModel(BaseModel):
9
+ id: int
10
+ name: str
11
+ alternate_url: str
12
+ area_id: int = mapped(path="area.id")
13
+ area_name: str = mapped(path="area.name")
14
+ salary_from: int = mapped(path="salary.from", default=None)
15
+ salary_to: int = mapped(path="salary.to", default=None)
16
+ currency: str = mapped(path="salary.currency", default="RUR")
17
+ gross: bool = mapped(path="salary.gross", default=False)
18
+
19
+ remote: bool = mapped(
20
+ path="schedule.id",
21
+ transform=lambda v: v == "remote",
22
+ default=False,
23
+ )
24
+
25
+ experience: str = mapped(path="experience.id", default=None)
26
+ professional_roles: list[dict] = mapped(
27
+ store_json=True, default_factory=list
28
+ )
29
+
30
+ created_at: datetime | None = None
31
+ published_at: datetime | None = None
32
+ updated_at: datetime | None = None
33
+
34
+ def __post_init__(self):
35
+ self.salary_from = self.salary_from or self.salary_to or 0
36
+ self.salary_to = self.salary_to or self.salary_from or 0
File without changes
@@ -0,0 +1,132 @@
1
+ PRAGMA foreign_keys = OFF;
2
+ -- На всякий случай выключаем проверки
3
+ BEGIN;
4
+ /* ===================== employers ===================== */
5
+ CREATE TABLE IF NOT EXISTS employers (
6
+ id INTEGER PRIMARY KEY,
7
+ name TEXT NOT NULL,
8
+ type TEXT,
9
+ description TEXT,
10
+ site_url TEXT,
11
+ area_id INTEGER,
12
+ area_name TEXT,
13
+ alternate_url TEXT,
14
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
15
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
16
+ );
17
+ /* ===================== contacts ===================== */
18
+ CREATE TABLE IF NOT EXISTS vacancy_contacts (
19
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
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
+ --
34
+ name TEXT,
35
+ email TEXT,
36
+ phone_numbers TEXT NOT NULL,
37
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
38
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
39
+ UNIQUE (vacancy_id, email)
40
+ );
41
+ /* ===================== vacancies ===================== */
42
+ CREATE TABLE IF NOT EXISTS vacancies (
43
+ id INTEGER PRIMARY KEY,
44
+ name TEXT NOT NULL,
45
+ area_id INTEGER,
46
+ area_name TEXT,
47
+ salary_from INTEGER,
48
+ salary_to INTEGER,
49
+ currency VARCHAR(3),
50
+ gross BOOLEAN,
51
+ published_at DATETIME,
52
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
53
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
54
+ remote BOOLEAN,
55
+ experience TEXT,
56
+ professional_roles TEXT,
57
+ alternate_url TEXT
58
+ );
59
+ /* ===================== negotiations ===================== */
60
+ CREATE TABLE IF NOT EXISTS negotiations (
61
+ id INTEGER PRIMARY KEY,
62
+ state TEXT NOT NULL,
63
+ vacancy_id INTEGER NOT NULL,
64
+ employer_id INTEGER,
65
+ -- Может обнулиться при блокировке раб-о-тодателя
66
+ chat_id INTEGER NOT NULL,
67
+ resume_id TEXT,
68
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
69
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
70
+ );
71
+ /* ===================== settings ===================== */
72
+ CREATE TABLE IF NOT EXISTS settings (
73
+ key TEXT PRIMARY KEY,
74
+ value TEXT NOT NULL
75
+ );
76
+ /* ===================== resumes ===================== */
77
+ CREATE TABLE IF NOT EXISTS resumes (
78
+ id TEXT PRIMARY KEY,
79
+ title TEXT NOT NULL,
80
+ url TEXT,
81
+ alternate_url TEXT,
82
+ status_id TEXT,
83
+ status_name TEXT,
84
+ can_publish_or_update BOOLEAN,
85
+ total_views INTEGER DEFAULT 0,
86
+ new_views INTEGER DEFAULT 0,
87
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
88
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
89
+ );
90
+ /* ===================== ИНДЕКСЫ ДЛЯ СТАТИСТИКИ ===================== */
91
+ -- Чтобы выборка для отправки на сервер по updated_at не тормозила
92
+ CREATE INDEX IF NOT EXISTS idx_vac_upd ON vacancies(updated_at);
93
+ CREATE INDEX IF NOT EXISTS idx_emp_upd ON employers(updated_at);
94
+ CREATE INDEX IF NOT EXISTS idx_neg_upd ON negotiations(updated_at);
95
+ /* ===================== ТРИГГЕРЫ (Всегда обновляют дату) ===================== */
96
+ -- Убрал условие WHEN. Теперь при любом UPDATE дата актуализируется принудительно.
97
+ CREATE TRIGGER IF NOT EXISTS trg_resumes_updated
98
+ AFTER
99
+ UPDATE ON resumes BEGIN
100
+ UPDATE resumes
101
+ SET updated_at = CURRENT_TIMESTAMP
102
+ WHERE id = OLD.id;
103
+ END;
104
+ CREATE TRIGGER IF NOT EXISTS trg_employers_updated
105
+ AFTER
106
+ UPDATE ON employers BEGIN
107
+ UPDATE employers
108
+ SET updated_at = CURRENT_TIMESTAMP
109
+ WHERE id = OLD.id;
110
+ END;
111
+ CREATE TRIGGER IF NOT EXISTS trg_vacancy_contacts_updated
112
+ AFTER
113
+ UPDATE ON vacancy_contacts BEGIN
114
+ UPDATE vacancy_contacts
115
+ SET updated_at = CURRENT_TIMESTAMP
116
+ WHERE id = OLD.id;
117
+ END;
118
+ CREATE TRIGGER IF NOT EXISTS trg_vacancies_updated
119
+ AFTER
120
+ UPDATE ON vacancies BEGIN
121
+ UPDATE vacancies
122
+ SET updated_at = CURRENT_TIMESTAMP
123
+ WHERE id = OLD.id;
124
+ END;
125
+ CREATE TRIGGER IF NOT EXISTS trg_negotiations_updated
126
+ AFTER
127
+ UPDATE ON negotiations BEGIN
128
+ UPDATE negotiations
129
+ SET updated_at = CURRENT_TIMESTAMP
130
+ WHERE id = OLD.id;
131
+ END;
132
+ COMMIT;
File without changes