Update app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from flask import Flask, render_template_string, request, jsonify,
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import logging
|
|
@@ -13,29 +13,26 @@ import requests
|
|
| 13 |
import uuid
|
| 14 |
import hmac
|
| 15 |
import hashlib
|
| 16 |
-
from urllib.parse import unquote, parse_qs
|
| 17 |
|
| 18 |
load_dotenv()
|
| 19 |
|
| 20 |
app = Flask(__name__)
|
| 21 |
-
app.secret_key = os.getenv("FLASK_SECRET_KEY", '
|
| 22 |
-
|
| 23 |
-
SYNC_FILES = [DATA_FILE]
|
| 24 |
|
| 25 |
-
|
| 26 |
-
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") # Renamed from HF_TOKEN for clarity
|
| 27 |
-
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
| 31 |
|
| 32 |
DOWNLOAD_RETRIES = 3
|
| 33 |
DOWNLOAD_DELAY = 5
|
| 34 |
|
| 35 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 36 |
|
| 37 |
-
# --- Hugging Face Sync Functions (largely unchanged, adapted for
|
| 38 |
-
|
| 39 |
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
|
| 40 |
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
|
| 41 |
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
|
|
@@ -62,15 +59,15 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
|
|
| 62 |
except HfHubHTTPError as e:
|
| 63 |
if e.response.status_code == 404:
|
| 64 |
logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
|
| 65 |
-
if attempt == 0 and not os.path.exists(file_name) and file_name ==
|
| 66 |
try:
|
| 67 |
with open(file_name, 'w', encoding='utf-8') as f:
|
| 68 |
-
json.dump({'resumes':
|
| 69 |
-
logging.info(f"Created empty local file {file_name}
|
| 70 |
except Exception as create_e:
|
| 71 |
logging.error(f"Failed to create empty local file {file_name}: {create_e}")
|
| 72 |
-
success = False #
|
| 73 |
-
break
|
| 74 |
else:
|
| 75 |
logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
|
| 76 |
except requests.exceptions.RequestException as e:
|
|
@@ -81,12 +78,12 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
|
|
| 81 |
if not success:
|
| 82 |
logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
|
| 83 |
all_successful = False
|
| 84 |
-
logging.info(f"Download process finished. Overall success
|
| 85 |
return all_successful
|
| 86 |
|
| 87 |
def upload_db_to_hf(specific_file=None):
|
| 88 |
if not HF_TOKEN_WRITE:
|
| 89 |
-
logging.warning("
|
| 90 |
return
|
| 91 |
try:
|
| 92 |
api = HfApi()
|
|
@@ -118,53 +115,43 @@ def periodic_backup():
|
|
| 118 |
logging.info("Periodic backup finished.")
|
| 119 |
|
| 120 |
# --- Data Loading and Saving Functions ---
|
| 121 |
-
|
| 122 |
def load_data():
|
| 123 |
-
default_data = {'resumes':
|
| 124 |
try:
|
| 125 |
-
with open(
|
| 126 |
data = json.load(file)
|
| 127 |
-
|
| 128 |
-
if not isinstance(data, dict):
|
| 129 |
-
logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
|
| 130 |
-
raise FileNotFoundError # Trigger download
|
| 131 |
for key in default_data:
|
| 132 |
-
if key not in data: data[key] = []
|
|
|
|
|
|
|
| 133 |
return data
|
| 134 |
-
except FileNotFoundError:
|
| 135 |
-
logging.warning(f"
|
| 136 |
-
except json.JSONDecodeError:
|
| 137 |
-
logging.error(f"Error decoding JSON in local {DATA_FILE}. File might be corrupt. Attempting download.")
|
| 138 |
|
| 139 |
-
if download_db_from_hf(specific_file=
|
| 140 |
try:
|
| 141 |
-
with open(
|
| 142 |
data = json.load(file)
|
| 143 |
-
logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
|
| 144 |
if not isinstance(data, dict):
|
| 145 |
-
logging.error(f"Downloaded {
|
| 146 |
return default_data
|
| 147 |
for key in default_data:
|
| 148 |
-
if key not in data: data[key] = []
|
|
|
|
|
|
|
| 149 |
return data
|
| 150 |
-
except
|
| 151 |
-
|
| 152 |
-
return default_data
|
| 153 |
-
except json.JSONDecodeError:
|
| 154 |
-
logging.error(f"Error decoding JSON in downloaded {DATA_FILE}. Using default.")
|
| 155 |
-
return default_data
|
| 156 |
-
except Exception as e:
|
| 157 |
-
logging.error(f"Unknown error loading downloaded {DATA_FILE}: {e}. Using default.", exc_info=True)
|
| 158 |
return default_data
|
| 159 |
else:
|
| 160 |
-
logging.error(f"Failed to download {
|
| 161 |
-
if not os.path.exists(
|
| 162 |
try:
|
| 163 |
-
with open(
|
| 164 |
-
|
| 165 |
-
logging.info(f"Created empty local file {DATA_FILE} after failed download.")
|
| 166 |
except Exception as create_e:
|
| 167 |
-
logging.error(f"Failed to create empty local file {
|
| 168 |
return default_data
|
| 169 |
|
| 170 |
def save_data(data):
|
|
@@ -172,64 +159,41 @@ def save_data(data):
|
|
| 172 |
if not isinstance(data, dict):
|
| 173 |
logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
|
| 174 |
return
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 180 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 181 |
-
logging.info(f"Data successfully saved to {
|
| 182 |
-
upload_db_to_hf(specific_file=
|
| 183 |
except Exception as e:
|
| 184 |
-
logging.error(f"Error saving data to {
|
| 185 |
-
|
| 186 |
-
# --- Telegram InitData Verification ---
|
| 187 |
-
def verify_telegram_init_data(init_data_str):
|
| 188 |
-
if not init_data_str:
|
| 189 |
-
logging.warning("Verification_failed: init_data_str is empty")
|
| 190 |
-
return None
|
| 191 |
-
|
| 192 |
-
parsed_data = parse_qs(init_data_str)
|
| 193 |
-
|
| 194 |
-
received_hash = parsed_data.pop('hash', [None])[0]
|
| 195 |
-
if not received_hash:
|
| 196 |
-
logging.warning("Verification_failed: hash is missing from init_data")
|
| 197 |
-
return None
|
| 198 |
-
|
| 199 |
-
data_check_string_parts = []
|
| 200 |
-
for key in sorted(parsed_data.keys()):
|
| 201 |
-
# Ensure values are strings, especially 'user' which can be JSON
|
| 202 |
-
value = parsed_data[key][0]
|
| 203 |
-
data_check_string_parts.append(f"{key}={value}")
|
| 204 |
-
|
| 205 |
-
data_check_string = "\n".join(data_check_string_parts)
|
| 206 |
|
|
|
|
|
|
|
| 207 |
try:
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
except Exception as e:
|
| 211 |
-
logging.error(f"Error
|
| 212 |
return None
|
| 213 |
|
| 214 |
-
if calculated_hash == received_hash:
|
| 215 |
-
user_data_str = parsed_data.get('user', [None])[0]
|
| 216 |
-
if user_data_str:
|
| 217 |
-
try:
|
| 218 |
-
user_obj = json.loads(unquote(user_data_str))
|
| 219 |
-
logging.info(f"Telegram user verified: ID {user_obj.get('id')}")
|
| 220 |
-
return user_obj
|
| 221 |
-
except json.JSONDecodeError as e:
|
| 222 |
-
logging.error(f"Failed to decode user data JSON: {e} - Data: {user_data_str}")
|
| 223 |
-
return None # Invalid user JSON
|
| 224 |
-
logging.warning("Verification_failed: user data is missing though hash is valid")
|
| 225 |
-
return {} # Valid hash, but no user data (should not happen with standard initData)
|
| 226 |
-
|
| 227 |
-
logging.warning(f"Verification_failed: Hashes do not match. Calculated: {calculated_hash}, Received: {received_hash}")
|
| 228 |
-
logging.debug(f"Data check string for failed hash: {data_check_string}")
|
| 229 |
-
return None
|
| 230 |
-
|
| 231 |
# --- Templates ---
|
| 232 |
-
|
|
|
|
| 233 |
<!DOCTYPE html>
|
| 234 |
<html lang="en">
|
| 235 |
<head>
|
|
@@ -237,705 +201,1020 @@ MAIN_APP_TEMPLATE = """
|
|
| 237 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
| 238 |
<title>TonTalent</title>
|
| 239 |
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
|
|
|
| 240 |
<style>
|
| 241 |
-
:root {
|
| 242 |
-
--tg-theme-bg-color:
|
| 243 |
-
--tg-theme-text-color:
|
| 244 |
-
--tg-theme-hint-color:
|
| 245 |
-
--tg-theme-link-color:
|
| 246 |
-
--tg-theme-button-color:
|
| 247 |
-
--tg-theme-button-text-color:
|
| 248 |
-
--tg-theme-secondary-bg-color:
|
| 249 |
-
--tg-
|
| 250 |
-
--tg-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
--
|
| 254 |
-
|
| 255 |
-
--border-
|
| 256 |
-
--
|
| 257 |
-
--
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
body.dark {
|
| 262 |
-
--border-color: rgba(255, 255, 255, 0.15);
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 266 |
-
body {
|
| 267 |
-
font-family: var(--font-family);
|
| 268 |
-
background-color: var(--tg-theme-bg-color);
|
| 269 |
-
color: var(--tg-theme-text-color);
|
| 270 |
-
overscroll-behavior-y: none;
|
| 271 |
-
display: flex;
|
| 272 |
-
flex-direction: column;
|
| 273 |
-
height: 100vh;
|
| 274 |
margin: 0;
|
| 275 |
-
padding:
|
| 276 |
-
|
| 277 |
-
.app-container {
|
| 278 |
-
display: flex;
|
| 279 |
-
flex-direction: column;
|
| 280 |
-
flex-grow: 1;
|
| 281 |
-
overflow: hidden;
|
| 282 |
-
}
|
| 283 |
-
.main-header {
|
| 284 |
-
background-color: var(--tg-theme-header-bg-color);
|
| 285 |
-
padding: 10px var(--padding-m);
|
| 286 |
-
border-bottom: 1px solid var(--border-color);
|
| 287 |
-
display: flex;
|
| 288 |
-
justify-content: space-between;
|
| 289 |
-
align-items: center;
|
| 290 |
-
position: sticky;
|
| 291 |
-
top: 0;
|
| 292 |
-
z-index: 100;
|
| 293 |
-
}
|
| 294 |
-
.main-header h1 {
|
| 295 |
-
font-size: 1.5em;
|
| 296 |
-
font-weight: 600;
|
| 297 |
color: var(--tg-theme-text-color);
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
}
|
| 308 |
-
.
|
| 309 |
-
|
| 310 |
-
justify-content: space-around;
|
| 311 |
-
background-color: var(--tg-theme-secondary-bg-color);
|
| 312 |
-
border-top: 1px solid var(--border-color);
|
| 313 |
-
padding: var(--padding-s) 0;
|
| 314 |
-
position: sticky;
|
| 315 |
-
bottom: 0;
|
| 316 |
-
z-index: 100;
|
| 317 |
-
}
|
| 318 |
-
.tab-button {
|
| 319 |
-
flex: 1;
|
| 320 |
-
padding: 12px 0;
|
| 321 |
-
background: none;
|
| 322 |
-
border: none;
|
| 323 |
-
color: var(--tg-theme-hint-color);
|
| 324 |
-
font-size: 0.9em;
|
| 325 |
-
font-weight: 500;
|
| 326 |
-
cursor: pointer;
|
| 327 |
-
transition: color 0.2s ease;
|
| 328 |
-
display: flex;
|
| 329 |
-
flex-direction: column;
|
| 330 |
-
align-items: center;
|
| 331 |
-
gap: 4px;
|
| 332 |
-
}
|
| 333 |
-
.tab-button i { font-size: 1.3em; }
|
| 334 |
-
.tab-button.active {
|
| 335 |
-
color: var(--tg-theme-button-color); /* Use button color for active tab */
|
| 336 |
-
}
|
| 337 |
-
.content-area {
|
| 338 |
-
flex-grow: 1;
|
| 339 |
-
padding: var(--padding-m);
|
| 340 |
-
overflow-y: auto;
|
| 341 |
-
-webkit-overflow-scrolling: touch;
|
| 342 |
-
}
|
| 343 |
-
.item-card {
|
| 344 |
background-color: var(--tg-theme-secondary-bg-color);
|
| 345 |
-
border-radius: var(--border-radius
|
| 346 |
-
padding:
|
| 347 |
-
margin-bottom:
|
| 348 |
-
box-shadow:
|
| 349 |
border: 1px solid var(--border-color);
|
| 350 |
-
}
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
}
|
| 354 |
-
.
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
color: var(--tg-theme-
|
| 363 |
-
margin-bottom: 6px;
|
| 364 |
-
line-height: 1.5;
|
| 365 |
-
white-space: pre-wrap;
|
| 366 |
-
word-break: break-word;
|
| 367 |
-
}
|
| 368 |
-
.item-card .meta-info {
|
| 369 |
-
font-size: 0.8em;
|
| 370 |
-
color: var(--tg-theme-hint-color);
|
| 371 |
-
margin-top: var(--padding-s);
|
| 372 |
-
}
|
| 373 |
-
.item-card .meta-info span { margin-right: 10px; }
|
| 374 |
-
.item-card strong { font-weight: 500; }
|
| 375 |
-
|
| 376 |
-
.modal {
|
| 377 |
-
display: none;
|
| 378 |
-
position: fixed;
|
| 379 |
-
z-index: 1000;
|
| 380 |
-
left: 0;
|
| 381 |
-
top: 0;
|
| 382 |
-
width: 100%;
|
| 383 |
-
height: 100%;
|
| 384 |
-
overflow: auto;
|
| 385 |
-
background-color: rgba(0,0,0,0.5);
|
| 386 |
-
backdrop-filter: blur(5px);
|
| 387 |
-
}
|
| 388 |
-
.modal-content {
|
| 389 |
-
background-color: var(--tg-theme-bg-color);
|
| 390 |
-
margin: 10% auto;
|
| 391 |
-
padding: var(--padding-l);
|
| 392 |
-
border-radius: var(--border-radius-m);
|
| 393 |
-
width: 90%;
|
| 394 |
-
max-width: 500px;
|
| 395 |
-
position: relative;
|
| 396 |
-
}
|
| 397 |
-
.modal-header {
|
| 398 |
-
display: flex;
|
| 399 |
-
justify-content: space-between;
|
| 400 |
-
align-items: center;
|
| 401 |
-
margin-bottom: var(--padding-m);
|
| 402 |
-
padding-bottom: var(--padding-s);
|
| 403 |
-
border-bottom: 1px solid var(--border-color);
|
| 404 |
-
}
|
| 405 |
-
.modal-header h2 {
|
| 406 |
-
font-size: 1.3em;
|
| 407 |
-
font-weight: 600;
|
| 408 |
-
}
|
| 409 |
-
.close-button {
|
| 410 |
-
font-size: 1.8em;
|
| 411 |
-
font-weight: 300;
|
| 412 |
-
color: var(--tg-theme-hint-color);
|
| 413 |
-
background: none;
|
| 414 |
border: none;
|
|
|
|
|
|
|
| 415 |
cursor: pointer;
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
}
|
| 421 |
-
.
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
color: var(--tg-theme-text-color);
|
| 427 |
-
}
|
| 428 |
-
.form-group input[type="text"],
|
| 429 |
-
.form-group input[type="email"],
|
| 430 |
-
.form-group input[type="tel"],
|
| 431 |
-
.form-group textarea,
|
| 432 |
-
.form-group select {
|
| 433 |
width: 100%;
|
| 434 |
padding: 12px;
|
|
|
|
| 435 |
border: 1px solid var(--border-color);
|
| 436 |
-
border-radius:
|
| 437 |
-
|
| 438 |
-
font-family: var(--font-family);
|
| 439 |
background-color: var(--tg-theme-secondary-bg-color);
|
| 440 |
color: var(--tg-theme-text-color);
|
| 441 |
-
}
|
| 442 |
-
.form-group input:focus, .form-group textarea:focus
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
.form-group textarea {
|
| 448 |
-
min-height: 100px;
|
| 449 |
-
resize: vertical;
|
| 450 |
-
}
|
| 451 |
-
.submit-button {
|
| 452 |
-
background-color: var(--tg-theme-button-color);
|
| 453 |
-
color: var(--tg-theme-button-text-color);
|
| 454 |
-
border: none;
|
| 455 |
-
padding: 12px 20px;
|
| 456 |
-
border-radius: var(--border-radius-s);
|
| 457 |
-
font-size: 1em;
|
| 458 |
-
font-weight: 500;
|
| 459 |
cursor: pointer;
|
| 460 |
-
width: 100%;
|
| 461 |
-
transition: background-color 0.2s ease;
|
| 462 |
-
}
|
| 463 |
-
.submit-button:hover {
|
| 464 |
-
opacity: 0.9;
|
| 465 |
-
}
|
| 466 |
-
.delete-button {
|
| 467 |
-
background-color: var(--tg-theme-destructive-text-color, #ff4d4d);
|
| 468 |
-
color: var(--tg-theme-button-text-color);
|
| 469 |
border: none;
|
| 470 |
-
|
| 471 |
-
border-radius: var(--border-radius-s);
|
| 472 |
-
font-size: 0.8em;
|
| 473 |
-
cursor: pointer;
|
| 474 |
-
margin-top: 8px;
|
| 475 |
-
float: right;
|
| 476 |
-
}
|
| 477 |
-
.empty-state {
|
| 478 |
-
text-align: center;
|
| 479 |
-
padding: 40px 20px;
|
| 480 |
-
color: var(--tg-theme-hint-color);
|
| 481 |
-
}
|
| 482 |
-
.empty-state p { font-size: 1.1em; margin-bottom: 10px; }
|
| 483 |
-
.loading-spinner {
|
| 484 |
-
border: 4px solid var(--tg-theme-secondary-bg-color);
|
| 485 |
-
border-top: 4px solid var(--tg-theme-button-color);
|
| 486 |
-
border-radius: 50%;
|
| 487 |
-
width: 30px;
|
| 488 |
-
height: 30px;
|
| 489 |
-
animation: spin 1s linear infinite;
|
| 490 |
-
margin: 20px auto;
|
| 491 |
-
}
|
| 492 |
-
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
| 493 |
-
.user-info {
|
| 494 |
-
font-size: 0.8em;
|
| 495 |
color: var(--tg-theme-hint-color);
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
</style>
|
| 502 |
</head>
|
| 503 |
<body>
|
| 504 |
-
<div class="
|
| 505 |
-
<
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
<main class="content-area" id="contentArea">
|
| 513 |
-
<div class="loading-spinner"></div>
|
| 514 |
-
</main>
|
| 515 |
-
|
| 516 |
-
<nav class="tab-navigation">
|
| 517 |
-
<button class="tab-button active" data-tab="resumes"><i class="fas fa-address-card"></i> Resumes</button>
|
| 518 |
-
<button class="tab-button" data-tab="vacancies"><i class="fas fa-briefcase"></i> Vacancies</button>
|
| 519 |
-
<button class="tab-button" data-tab="freelance_offers"><i class="fas fa-handshake"></i> Offers</button>
|
| 520 |
-
</nav>
|
| 521 |
-
</div>
|
| 522 |
-
|
| 523 |
-
<div id="formModal" class="modal">
|
| 524 |
-
<div class="modal-content">
|
| 525 |
-
<div class="modal-header">
|
| 526 |
-
<h2 id="modalTitle">Create New</h2>
|
| 527 |
-
<button class="close-button" id="closeModalBtn">×</button>
|
| 528 |
-
</div>
|
| 529 |
-
<form id="itemForm">
|
| 530 |
-
<input type="hidden" id="formItemType" name="itemType">
|
| 531 |
-
<input type="hidden" id="formItemId" name="itemId"> <!-- For editing -->
|
| 532 |
-
|
| 533 |
-
<div id="commonFields">
|
| 534 |
-
<!-- Fields will be dynamically added here -->
|
| 535 |
-
</div>
|
| 536 |
-
<button type="submit" class="submit-button">Submit</button>
|
| 537 |
-
</form>
|
| 538 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
</div>
|
| 540 |
|
| 541 |
<script>
|
| 542 |
const tg = window.Telegram.WebApp;
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
function applyTheme() {
|
| 575 |
-
tg.ready();
|
| 576 |
-
tg.expand(); // Make the app full height
|
| 577 |
-
document.body.style.backgroundColor = tg.themeParams.bg_color || '#ffffff';
|
| 578 |
-
document.body.style.color = tg.themeParams.text_color || '#000000';
|
| 579 |
-
if (tg.colorScheme === 'dark') {
|
| 580 |
document.body.classList.add('dark');
|
| 581 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
document.body.classList.remove('dark');
|
| 583 |
-
}
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
html += `<p><strong>${escapeHtml(field.label.replace('(optional)','').replace('(comma-separated)','').replace(' (e.g., Telegram @username, email)',''))}:</strong> ${escapeHtml(String(item[field.name]))}</p>`;
|
| 636 |
-
}
|
| 637 |
-
});
|
| 638 |
-
|
| 639 |
-
html += `<div class="meta-info">`;
|
| 640 |
-
html += `<span>Posted by: ${escapeHtml(item.user_display_name || 'Unknown User')}</span>`;
|
| 641 |
-
html += `<span>On: ${new Date(item.timestamp).toLocaleDateString()}</span>`;
|
| 642 |
-
if (currentUser && item.user_id === currentUser.id) {
|
| 643 |
-
html += `<button class="delete-button" onclick="deleteItem('${type}', '${item.id}')">Delete</button>`;
|
| 644 |
-
}
|
| 645 |
-
html += `</div></div>`;
|
| 646 |
-
});
|
| 647 |
-
contentArea.innerHTML = html;
|
| 648 |
-
}
|
| 649 |
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
}
|
| 659 |
-
|
| 660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
if (event.target == modal) closeModal();
|
| 707 |
-
});
|
| 708 |
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
}
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
if
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
const response = await fetch(`/api/validate_user?initData=${encodeURIComponent(tg.initData)}`);
|
| 799 |
-
if (response.ok) {
|
| 800 |
-
currentUser = await response.json();
|
| 801 |
-
} else {
|
| 802 |
-
console.warn("Server-side user validation failed or user not found in initData.");
|
| 803 |
-
}
|
| 804 |
-
} catch (e) {
|
| 805 |
-
console.error("Error during server-side user validation:", e);
|
| 806 |
-
}
|
| 807 |
-
}
|
| 808 |
-
if (!currentUser) {
|
| 809 |
-
console.warn('Telegram user data not available or validation failed. Proceeding with limited functionality.');
|
| 810 |
-
tg.showAlert('Could not identify user. Some features might be limited.');
|
| 811 |
-
}
|
| 812 |
-
} else {
|
| 813 |
-
currentUser = tg.initDataUnsafe.user;
|
| 814 |
-
}
|
| 815 |
-
|
| 816 |
-
if (currentUser) {
|
| 817 |
-
console.log("Current User:", currentUser);
|
| 818 |
-
}
|
| 819 |
-
displayUserInfo();
|
| 820 |
-
|
| 821 |
-
setupTabs();
|
| 822 |
-
setupModal();
|
| 823 |
-
fetchAndRenderData(currentTab); // Initial data load
|
| 824 |
-
}
|
| 825 |
-
|
| 826 |
-
document.addEventListener('DOMContentLoaded', initApp);
|
| 827 |
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
|
|
|
|
|
|
|
|
|
| 832 |
|
| 833 |
# --- Flask Routes ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
@app.route('/')
|
| 835 |
def index():
|
| 836 |
-
return render_template_string(MAIN_APP_TEMPLATE)
|
| 837 |
-
|
| 838 |
-
@app.route('/api/validate_user', methods=['GET'])
|
| 839 |
-
def validate_user_route():
|
| 840 |
-
init_data_str = request.args.get('initData')
|
| 841 |
-
user = verify_telegram_init_data(init_data_str)
|
| 842 |
-
if user:
|
| 843 |
-
return jsonify(user), 200
|
| 844 |
-
else:
|
| 845 |
-
return jsonify({"detail": "Invalid or missing Telegram initData"}), 401
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
@app.route('/api/get_data/<item_type>', methods=['GET'])
|
| 849 |
-
def get_data_api(item_type):
|
| 850 |
-
init_data_str = request.args.get('initData')
|
| 851 |
-
# Basic check, though for GET it's less critical than for POST/state-changing operations
|
| 852 |
-
# For sensitive data, GET might also need strict validation
|
| 853 |
-
if not verify_telegram_init_data(init_data_str):
|
| 854 |
-
logging.warning(f"Unauthorized attempt to get_data for {item_type}")
|
| 855 |
-
# Depending on sensitivity, you might allow read without strict auth or deny
|
| 856 |
-
# For now, allowing read but logging it. Stricter apps would return 401.
|
| 857 |
-
# return jsonify({"detail": "Unauthorized: Invalid Telegram data"}), 401
|
| 858 |
-
|
| 859 |
data = load_data()
|
| 860 |
-
if
|
| 861 |
-
|
| 862 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
|
| 864 |
-
@app.route('/api/create_item/<item_type>', methods=['POST'])
|
| 865 |
-
def create_item_api(item_type):
|
| 866 |
-
init_data_str = request.args.get('initData') # Or from request.json.get('initData') if sent in body
|
| 867 |
-
user = verify_telegram_init_data(init_data_str)
|
| 868 |
-
if not user:
|
| 869 |
-
return jsonify({"detail": "Unauthorized: Invalid Telegram data"}), 401
|
| 870 |
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
data = load_data()
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
"
|
| 884 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
|
| 886 |
-
|
| 887 |
-
# Assuming client sends all relevant fields
|
| 888 |
-
for key, value in req_data.items():
|
| 889 |
-
if key not in ['itemType', 'itemId', 'user_id', 'user_display_name', 'timestamp', 'id']: # internal/managed fields
|
| 890 |
-
new_item[key] = value
|
| 891 |
-
|
| 892 |
-
# Basic validation (presence of a title-like field)
|
| 893 |
-
title_field_present = any(k in new_item for k in ['title', 'job_title', 'offer_title'])
|
| 894 |
-
if not title_field_present or not new_item.get(next(k for k in ['title', 'job_title', 'offer_title'] if k in new_item)): # ensure it's not empty
|
| 895 |
-
return jsonify({"detail": "A title field is required and cannot be empty."}), 400
|
| 896 |
-
|
| 897 |
|
| 898 |
-
data[
|
| 899 |
save_data(data)
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
data = load_data()
|
| 911 |
-
if item_type not in data:
|
| 912 |
-
return jsonify({"detail": "Invalid item type"}), 404
|
| 913 |
|
| 914 |
-
|
| 915 |
-
|
| 916 |
|
| 917 |
-
if not item_to_delete:
|
| 918 |
-
return jsonify({"detail": "Item not found"}), 404
|
| 919 |
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
return jsonify({"detail": "Forbidden: You can only delete your own items"}), 403
|
| 925 |
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 931 |
|
|
|
|
| 932 |
if __name__ == '__main__':
|
| 933 |
-
logging.info("Application starting up.
|
| 934 |
-
if not os.path.exists(
|
| 935 |
-
logging.info(f"{
|
| 936 |
-
download_db_from_hf(
|
| 937 |
|
| 938 |
-
load_data() # Load
|
| 939 |
logging.info("Initial data load/check complete.")
|
| 940 |
|
| 941 |
if HF_TOKEN_WRITE:
|
|
@@ -945,6 +1224,6 @@ if __name__ == '__main__':
|
|
| 945 |
else:
|
| 946 |
logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
|
| 947 |
|
| 948 |
-
port = int(os.environ.get('PORT',
|
| 949 |
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
|
| 950 |
app.run(debug=False, host='0.0.0.0', port=port)
|
|
|
|
| 1 |
+
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, session, flash
|
| 2 |
import json
|
| 3 |
import os
|
| 4 |
import logging
|
|
|
|
| 13 |
import uuid
|
| 14 |
import hmac
|
| 15 |
import hashlib
|
|
|
|
| 16 |
|
| 17 |
load_dotenv()
|
| 18 |
|
| 19 |
app = Flask(__name__)
|
| 20 |
+
app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_telegram_app_12345')
|
| 21 |
+
TONTALENT_DATA_FILE = 'tontalent_data.json'
|
|
|
|
| 22 |
|
| 23 |
+
SYNC_FILES = [TONTALENT_DATA_FILE]
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
REPO_ID = "Kgshop/tontalent2"
|
| 26 |
+
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 27 |
+
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 28 |
+
BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8"
|
| 29 |
|
| 30 |
DOWNLOAD_RETRIES = 3
|
| 31 |
DOWNLOAD_DELAY = 5
|
| 32 |
|
| 33 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 34 |
|
| 35 |
+
# --- Hugging Face Sync Functions (largely unchanged, adapted for TONTALENT_DATA_FILE) ---
|
|
|
|
| 36 |
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
|
| 37 |
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
|
| 38 |
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
|
|
|
|
| 59 |
except HfHubHTTPError as e:
|
| 60 |
if e.response.status_code == 404:
|
| 61 |
logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
|
| 62 |
+
if attempt == 0 and not os.path.exists(file_name) and file_name == TONTALENT_DATA_FILE:
|
| 63 |
try:
|
| 64 |
with open(file_name, 'w', encoding='utf-8') as f:
|
| 65 |
+
json.dump({'resumes': {}, 'vacancies': {}, 'freelance_offers': {}}, f)
|
| 66 |
+
logging.info(f"Created empty local file {file_name} because it was not found on HF.")
|
| 67 |
except Exception as create_e:
|
| 68 |
logging.error(f"Failed to create empty local file {file_name}: {create_e}")
|
| 69 |
+
success = False # Should be false if file not found unless it's the initial creation
|
| 70 |
+
break # Don't retry 404
|
| 71 |
else:
|
| 72 |
logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
|
| 73 |
except requests.exceptions.RequestException as e:
|
|
|
|
| 78 |
if not success:
|
| 79 |
logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
|
| 80 |
all_successful = False
|
| 81 |
+
logging.info(f"Download process finished. Overall success: {all_successful}")
|
| 82 |
return all_successful
|
| 83 |
|
| 84 |
def upload_db_to_hf(specific_file=None):
|
| 85 |
if not HF_TOKEN_WRITE:
|
| 86 |
+
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
|
| 87 |
return
|
| 88 |
try:
|
| 89 |
api = HfApi()
|
|
|
|
| 115 |
logging.info("Periodic backup finished.")
|
| 116 |
|
| 117 |
# --- Data Loading and Saving Functions ---
|
|
|
|
| 118 |
def load_data():
|
| 119 |
+
default_data = {'resumes': {}, 'vacancies': {}, 'freelance_offers': {}}
|
| 120 |
try:
|
| 121 |
+
with open(TONTALENT_DATA_FILE, 'r', encoding='utf-8') as file:
|
| 122 |
data = json.load(file)
|
| 123 |
+
if not isinstance(data, dict): raise FileNotFoundError
|
|
|
|
|
|
|
|
|
|
| 124 |
for key in default_data:
|
| 125 |
+
if key not in data: data[key] = default_data[key]
|
| 126 |
+
if not isinstance(data[key], dict): data[key] = default_data[key] # Ensure correct type
|
| 127 |
+
logging.info(f"Local data loaded successfully from {TONTALENT_DATA_FILE}")
|
| 128 |
return data
|
| 129 |
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
| 130 |
+
logging.warning(f"Error loading local file {TONTALENT_DATA_FILE} ({e}). Attempting download from HF.")
|
|
|
|
|
|
|
| 131 |
|
| 132 |
+
if download_db_from_hf(specific_file=TONTALENT_DATA_FILE):
|
| 133 |
try:
|
| 134 |
+
with open(TONTALENT_DATA_FILE, 'r', encoding='utf-8') as file:
|
| 135 |
data = json.load(file)
|
|
|
|
| 136 |
if not isinstance(data, dict):
|
| 137 |
+
logging.error(f"Downloaded {TONTALENT_DATA_FILE} is not a dictionary. Using default.")
|
| 138 |
return default_data
|
| 139 |
for key in default_data:
|
| 140 |
+
if key not in data: data[key] = default_data[key]
|
| 141 |
+
if not isinstance(data[key], dict): data[key] = default_data[key]
|
| 142 |
+
logging.info(f"Data loaded successfully from {TONTALENT_DATA_FILE} after download.")
|
| 143 |
return data
|
| 144 |
+
except Exception as ex:
|
| 145 |
+
logging.error(f"Error loading downloaded {TONTALENT_DATA_FILE}: {ex}. Using default.", exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return default_data
|
| 147 |
else:
|
| 148 |
+
logging.error(f"Failed to download {TONTALENT_DATA_FILE} from HF. Using default data structure.")
|
| 149 |
+
if not os.path.exists(TONTALENT_DATA_FILE):
|
| 150 |
try:
|
| 151 |
+
with open(TONTALENT_DATA_FILE, 'w', encoding='utf-8') as f: json.dump(default_data, f)
|
| 152 |
+
logging.info(f"Created empty local file {TONTALENT_DATA_FILE} after failed download.")
|
|
|
|
| 153 |
except Exception as create_e:
|
| 154 |
+
logging.error(f"Failed to create empty local file {TONTALENT_DATA_FILE}: {create_e}")
|
| 155 |
return default_data
|
| 156 |
|
| 157 |
def save_data(data):
|
|
|
|
| 159 |
if not isinstance(data, dict):
|
| 160 |
logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
|
| 161 |
return
|
| 162 |
+
for key in ['resumes', 'vacancies', 'freelance_offers']: # Ensure keys exist
|
| 163 |
+
if key not in data: data[key] = {}
|
| 164 |
+
|
| 165 |
+
with open(TONTALENT_DATA_FILE, 'w', encoding='utf-8') as file:
|
|
|
|
| 166 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 167 |
+
logging.info(f"Data successfully saved to {TONTALENT_DATA_FILE}")
|
| 168 |
+
upload_db_to_hf(specific_file=TONTALENT_DATA_FILE)
|
| 169 |
except Exception as e:
|
| 170 |
+
logging.error(f"Error saving data to {TONTALENT_DATA_FILE}: {e}", exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
+
# --- Telegram Authentication ---
|
| 173 |
+
def validate_telegram_data(init_data_str):
|
| 174 |
try:
|
| 175 |
+
params = dict(kv.split('=', 1) for kv in init_data_str.split('&'))
|
| 176 |
+
hash_received = params.pop('hash', None)
|
| 177 |
+
if not hash_received: return None
|
| 178 |
+
|
| 179 |
+
data_check_string = "\n".join(sorted([f"{k}={v}" for k, v in params.items()]))
|
| 180 |
+
|
| 181 |
+
secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
|
| 182 |
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
| 183 |
+
|
| 184 |
+
if calculated_hash == hash_received:
|
| 185 |
+
user_data_json = params.get('user')
|
| 186 |
+
if user_data_json:
|
| 187 |
+
return json.loads(requests.utils.unquote(user_data_json)) # Telegram user data needs unquoting
|
| 188 |
+
return {}
|
| 189 |
+
return None
|
| 190 |
except Exception as e:
|
| 191 |
+
logging.error(f"Error validating Telegram data: {e}", exc_info=True)
|
| 192 |
return None
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
# --- Templates ---
|
| 195 |
+
# Base template including Telegram WebApp JS and basic styling
|
| 196 |
+
BASE_TEMPLATE = """
|
| 197 |
<!DOCTYPE html>
|
| 198 |
<html lang="en">
|
| 199 |
<head>
|
|
|
|
| 201 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
| 202 |
<title>TonTalent</title>
|
| 203 |
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
| 204 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 205 |
<style>
|
| 206 |
+
:root {{
|
| 207 |
+
--tg-theme-bg-color: {{ '{background_color}' if '{background_color}' != 'None' else '#ffffff' }};
|
| 208 |
+
--tg-theme-text-color: {{ '{text_color}' if '{text_color}' != 'None' else '#000000' }};
|
| 209 |
+
--tg-theme-hint-color: {{ '{hint_color}' if '{hint_color}' != 'None' else '#999999' }};
|
| 210 |
+
--tg-theme-link-color: {{ '{link_color}' if '{link_color}' != 'None' else '#2481cc' }};
|
| 211 |
+
--tg-theme-button-color: {{ '{button_color}' if '{button_color}' != 'None' else '#2481cc' }};
|
| 212 |
+
--tg-theme-button-text-color: {{ '{button_text_color}' if '{button_text_color}' != 'None' else '#ffffff' }};
|
| 213 |
+
--tg-theme-secondary-bg-color: {{ '{secondary_bg_color}' if '{secondary_bg_color}' != 'None' else '#f0f0f0' }};
|
| 214 |
+
--tg-header-color: {{ '{header_color}' if '{header_color}' != 'None' else '#ffffff' }};
|
| 215 |
+
--tg-accent-text-color: {{ '{accent_text_color}' if '{accent_text_color}' != 'None' else '#2481cc' }};
|
| 216 |
+
--tg-section-bg-color: {{ '{section_bg_color}' if '{section_bg_color}' != 'None' else '#ffffff' }};
|
| 217 |
+
--tg-section-header-text-color: {{ '{section_header_text_color}' if '{section_header_text_color}' != 'None' else '#000000'}};
|
| 218 |
+
--tg-destructive-text-color: {{ '{destructive_text_color}' if '{destructive_text_color}' != 'None' else '#ff3b30' }};
|
| 219 |
+
|
| 220 |
+
--border-color: #e0e0e0;
|
| 221 |
+
--card-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
| 222 |
+
--border-radius: 12px;
|
| 223 |
+
}}
|
| 224 |
+
body {{
|
| 225 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
margin: 0;
|
| 227 |
+
padding: 15px;
|
| 228 |
+
background-color: var(--tg-theme-bg-color);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
color: var(--tg-theme-text-color);
|
| 230 |
+
line-height: 1.5;
|
| 231 |
+
font-size: 16px;
|
| 232 |
+
overscroll-behavior-y: none; /* Prevents pull-to-refresh */
|
| 233 |
+
}}
|
| 234 |
+
.dark {{
|
| 235 |
+
--border-color: #3a3a3c;
|
| 236 |
+
/* Add more dark mode specific overrides if needed */
|
| 237 |
+
}}
|
| 238 |
+
.container {{ max-width: 600px; margin: 0 auto; }}
|
| 239 |
+
.header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }}
|
| 240 |
+
.header h1 {{ font-size: 24px; font-weight: 600; margin: 0; color: var(--tg-theme-text-color); }}
|
| 241 |
+
.card {{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
background-color: var(--tg-theme-secondary-bg-color);
|
| 243 |
+
border-radius: var(--border-radius);
|
| 244 |
+
padding: 15px;
|
| 245 |
+
margin-bottom: 15px;
|
| 246 |
+
box-shadow: var(--card-shadow);
|
| 247 |
border: 1px solid var(--border-color);
|
| 248 |
+
}}
|
| 249 |
+
.card h2 {{ margin-top: 0; font-size: 18px; font-weight: 600; color: var(--tg-theme-text-color); }}
|
| 250 |
+
.card p {{ margin-bottom: 8px; font-size: 15px; color: var(--tg-theme-hint-color); }}
|
| 251 |
+
.card p strong {{ color: var(--tg-theme-text-color); }}
|
| 252 |
+
.card .meta {{ font-size: 13px; color: var(--tg-theme-hint-color); margin-bottom: 10px; }}
|
| 253 |
+
.card .actions {{ margin-top: 15px; display: flex; gap: 10px; }}
|
| 254 |
+
.button, button {{
|
| 255 |
+
display: inline-block;
|
| 256 |
+
padding: 10px 20px;
|
| 257 |
+
font-size: 16px;
|
| 258 |
+
font-weight: 500;
|
| 259 |
+
color: var(--tg-theme-button-text-color);
|
| 260 |
+
background-color: var(--tg-theme-button-color);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
border: none;
|
| 262 |
+
border-radius: 8px;
|
| 263 |
+
text-decoration: none;
|
| 264 |
cursor: pointer;
|
| 265 |
+
text-align: center;
|
| 266 |
+
transition: opacity 0.2s;
|
| 267 |
+
}}
|
| 268 |
+
button:disabled {{ opacity: 0.6; cursor: not-allowed; }}
|
| 269 |
+
.button-secondary {{ background-color: var(--tg-theme-secondary-bg-color); color: var(--tg-theme-link-color); border: 1px solid var(--tg-theme-link-color); }}
|
| 270 |
+
.button-destructive {{ background-color: var(--tg-destructive-text-color); color: #fff; }}
|
| 271 |
+
a {{ color: var(--tg-theme-link-color); text-decoration: none; }}
|
| 272 |
+
.form-group {{ margin-bottom: 15px; }}
|
| 273 |
+
.form-group label {{ display: block; font-weight: 500; margin-bottom: 5px; font-size: 15px; color: var(--tg-theme-text-color); }}
|
| 274 |
+
.form-group input[type="text"], .form-group input[type="number"], .form-group input[type="email"], .form-group input[type="tel"], .form-group textarea, .form-group select {{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
width: 100%;
|
| 276 |
padding: 12px;
|
| 277 |
+
font-size: 16px;
|
| 278 |
border: 1px solid var(--border-color);
|
| 279 |
+
border-radius: 8px;
|
| 280 |
+
box-sizing: border-box;
|
|
|
|
| 281 |
background-color: var(--tg-theme-secondary-bg-color);
|
| 282 |
color: var(--tg-theme-text-color);
|
| 283 |
+
}}
|
| 284 |
+
.form-group input:focus, .form-group textarea:focus {{ border-color: var(--tg-theme-link-color); outline: none; }}
|
| 285 |
+
.form-group textarea {{ min-height: 100px; resize: vertical; }}
|
| 286 |
+
.tabs {{ display: flex; margin-bottom: 20px; border-bottom: 1px solid var(--border-color); }}
|
| 287 |
+
.tab-button {{
|
| 288 |
+
padding: 10px 15px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
cursor: pointer;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
border: none;
|
| 291 |
+
background-color: transparent;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
color: var(--tg-theme-hint-color);
|
| 293 |
+
font-size: 16px;
|
| 294 |
+
font-weight: 500;
|
| 295 |
+
border-bottom: 2px solid transparent;
|
| 296 |
+
}}
|
| 297 |
+
.tab-button.active {{ color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }}
|
| 298 |
+
.tab-content {{ display: none; }}
|
| 299 |
+
.tab-content.active {{ display: block; }}
|
| 300 |
+
.publish-buttons {{ display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }}
|
| 301 |
+
.publish-buttons .button {{ width: 100%; box-sizing: border-box; }}
|
| 302 |
+
.user-info {{ font-size: 13px; color: var(--tg-theme-hint-color); text-align: right; margin-bottom: 10px; }}
|
| 303 |
+
.item-photo {{ max-width: 80px; max-height: 80px; border-radius: 8px; object-fit: cover; margin-right: 15px; float: left; }}
|
| 304 |
+
.flash-messages {{ list-style: none; padding: 0; margin-bottom: 15px; }}
|
| 305 |
+
.flash-messages li {{ padding: 10px; border-radius: 8px; margin-bottom: 10px; }}
|
| 306 |
+
.flash-messages .success {{ background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }}
|
| 307 |
+
.flash-messages .error {{ background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }}
|
| 308 |
+
.no-items {{ text-align: center; color: var(--tg-theme-hint-color); padding: 20px 0; }}
|
| 309 |
+
.item-actions { display: flex; gap: 8px; margin-top: 10px; }
|
| 310 |
+
.item-actions a, .item-actions button { font-size: 14px; padding: 6px 12px; }
|
| 311 |
</style>
|
| 312 |
</head>
|
| 313 |
<body>
|
| 314 |
+
<div class="container">
|
| 315 |
+
<div class="user-info">
|
| 316 |
+
{{% if 'user' in session and session['user'] %}}
|
| 317 |
+
Logged in as: {{ session['user'].get('first_name', '') }} {{ session['user'].get('last_name', '') }} ({{ session['user'].get('username', 'N/A') }})
|
| 318 |
+
{{% else %}}
|
| 319 |
+
Authenticating...
|
| 320 |
+
{{% endif %}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
</div>
|
| 322 |
+
|
| 323 |
+
{{% with messages = get_flashed_messages(with_categories=true) %}}
|
| 324 |
+
{{% if messages %}}
|
| 325 |
+
<ul class="flash-messages">
|
| 326 |
+
{{% for category, message in messages %}}
|
| 327 |
+
<li class="{{ category }}">{{ message }}</li>
|
| 328 |
+
{{% endfor %}}
|
| 329 |
+
</ul>
|
| 330 |
+
{{% endif %}}
|
| 331 |
+
{{% endwith %}}
|
| 332 |
+
|
| 333 |
+
{{% block content %}}{{% endblock %}}
|
| 334 |
</div>
|
| 335 |
|
| 336 |
<script>
|
| 337 |
const tg = window.Telegram.WebApp;
|
| 338 |
+
tg.ready();
|
| 339 |
+
tg.expand();
|
| 340 |
+
|
| 341 |
+
function applyTheme(themeParams) {{
|
| 342 |
+
const root = document.documentElement;
|
| 343 |
+
root.style.setProperty('--tg-theme-bg-color', themeParams.bg_color || '#ffffff');
|
| 344 |
+
root.style.setProperty('--tg-theme-text-color', themeParams.text_color || '#000000');
|
| 345 |
+
root.style.setProperty('--tg-theme-hint-color', themeParams.hint_color || '#999999');
|
| 346 |
+
root.style.setProperty('--tg-theme-link-color', themeParams.link_color || '#2481cc');
|
| 347 |
+
root.style.setProperty('--tg-theme-button-color', themeParams.button_color || '#2481cc');
|
| 348 |
+
root.style.setProperty('--tg-theme-button-text-color', themeParams.button_text_color || '#ffffff');
|
| 349 |
+
root.style.setProperty('--tg-theme-secondary-bg-color', themeParams.secondary_bg_color || '#f0f0f0');
|
| 350 |
+
// Additional properties for more iOS-like theming if available
|
| 351 |
+
if (themeParams.header_bg_color) {{ // Renamed for consistency
|
| 352 |
+
root.style.setProperty('--tg-header-color', themeParams.header_bg_color);
|
| 353 |
+
}}
|
| 354 |
+
if (themeParams.accent_text_color) {{
|
| 355 |
+
root.style.setProperty('--tg-accent-text-color', themeParams.accent_text_color);
|
| 356 |
+
}}
|
| 357 |
+
if (themeParams.section_bg_color) {{
|
| 358 |
+
root.style.setProperty('--tg-section-bg-color', themeParams.section_bg_color);
|
| 359 |
+
}}
|
| 360 |
+
if (themeParams.section_header_text_color) {{
|
| 361 |
+
root.style.setProperty('--tg-section-header-text-color', themeParams.section_header_text_color);
|
| 362 |
+
}}
|
| 363 |
+
if (themeParams.destructive_text_color) {{
|
| 364 |
+
root.style.setProperty('--tg-destructive-text-color', themeParams.destructive_text_color);
|
| 365 |
+
}}
|
| 366 |
+
|
| 367 |
+
if (tg.colorScheme === 'dark') {{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
document.body.classList.add('dark');
|
| 369 |
+
// More specific dark overrides if themeParams aren't enough
|
| 370 |
+
if (!themeParams.bg_color || themeParams.bg_color === '#ffffff') root.style.setProperty('--tg-theme-bg-color', '#1c1c1e');
|
| 371 |
+
if (!themeParams.text_color || themeParams.text_color === '#000000') root.style.setProperty('--tg-theme-text-color', '#ffffff');
|
| 372 |
+
if (!themeParams.secondary_bg_color || themeParams.secondary_bg_color === '#f0f0f0') root.style.setProperty('--tg-theme-secondary-bg-color', '#2c2c2e');
|
| 373 |
+
if (!themeParams.border_color) root.style.setProperty('--border-color', '#3a3a3c');
|
| 374 |
+
}} else {{
|
| 375 |
document.body.classList.remove('dark');
|
| 376 |
+
}}
|
| 377 |
+
}}
|
| 378 |
+
|
| 379 |
+
applyTheme(tg.themeParams);
|
| 380 |
+
tg.onEvent('themeChanged', function() {{
|
| 381 |
+
applyTheme(tg.themeParams);
|
| 382 |
+
}});
|
| 383 |
+
|
| 384 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 385 |
+
if (typeof tg.initDataUnsafe !== 'undefined' && tg.initDataUnsafe.hash) {{
|
| 386 |
+
fetch('{{ url_for("auth_telegram") }}', {{
|
| 387 |
+
method: 'POST',
|
| 388 |
+
headers: {{ 'Content-Type': 'application/json' }},
|
| 389 |
+
body: JSON.stringify({{ init_data: tg.initData }})
|
| 390 |
+
}})
|
| 391 |
+
.then(response => response.json())
|
| 392 |
+
.then(data => {{
|
| 393 |
+
if (data.status === 'success') {{
|
| 394 |
+
console.log('Telegram user authenticated:', data.user);
|
| 395 |
+
if (document.querySelector('.user-info')) {{
|
| 396 |
+
document.querySelector('.user-info').textContent = `Logged in as: ${{data.user.first_name || ''}} ${{data.user.last_name || ''}} (${{data.user.username || 'N/A'}})`;
|
| 397 |
+
}}
|
| 398 |
+
// Optionally reload or update content that depends on auth
|
| 399 |
+
if (window.location.pathname === '{{ url_for("index") }}' && !sessionStorage.getItem('authReloaded')) {{
|
| 400 |
+
sessionStorage.setItem('authReloaded', 'true');
|
| 401 |
+
// window.location.reload(); // Can cause loop if not careful
|
| 402 |
+
}}
|
| 403 |
+
}} else {{
|
| 404 |
+
console.error('Telegram authentication failed:', data.message);
|
| 405 |
+
tg.showAlert(data.message || 'Authentication failed.');
|
| 406 |
+
}}
|
| 407 |
+
}})
|
| 408 |
+
.catch(error => {{
|
| 409 |
+
console.error('Error during Telegram auth request:', error);
|
| 410 |
+
tg.showAlert('Error communicating with server for authentication.');
|
| 411 |
+
}});
|
| 412 |
+
}} else {{
|
| 413 |
+
console.warn("Telegram WebApp.initDataUnsafe is not available. Are you in Telegram?");
|
| 414 |
+
// Potentially redirect or show a message if not in Telegram environment and auth is required
|
| 415 |
+
}}
|
| 416 |
+
}});
|
| 417 |
+
|
| 418 |
+
function confirmDelete(event) {{
|
| 419 |
+
if (!confirm('Are you sure you want to delete this item? This action cannot be undone.')) {{
|
| 420 |
+
event.preventDefault();
|
| 421 |
+
}}
|
| 422 |
+
}}
|
| 423 |
+
{{% block extra_js %}}{{% endblock %}}
|
| 424 |
+
</script>
|
| 425 |
+
</body>
|
| 426 |
+
</html>
|
| 427 |
+
""".format(
|
| 428 |
+
background_color=None, text_color=None, hint_color=None, link_color=None,
|
| 429 |
+
button_color=None, button_text_color=None, secondary_bg_color=None,
|
| 430 |
+
header_color=None, accent_text_color=None, section_bg_color=None,
|
| 431 |
+
section_header_text_color=None, destructive_text_color=None
|
| 432 |
+
) # Placeholders for JS to fill
|
| 433 |
+
|
| 434 |
+
INDEX_TEMPLATE = """
|
| 435 |
+
{{% extends "base_template" %}}
|
| 436 |
+
{{% block content %}}
|
| 437 |
+
<div class="header">
|
| 438 |
+
<h1>TonTalent</h1>
|
| 439 |
+
<a href="{{ url_for('admin_sync') }}" class="button button-secondary" style="font-size:14px; padding: 8px 12px;">Sync Panel</a>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
+
{{% if 'user' in session and session['user'] %}}
|
| 443 |
+
<div class="publish-buttons">
|
| 444 |
+
<a href="{{ url_for('publish_item', item_type='resume') }}" class="button"><i class="fas fa-id-card"></i> Publish Resume</a>
|
| 445 |
+
<a href="{{ url_for('publish_item', item_type='vacancy') }}" class="button"><i class="fas fa-briefcase"></i> Publish Vacancy</a>
|
| 446 |
+
<a href="{{ url_for('publish_item', item_type='freelance_offer') }}" class="button"><i class="fas fa-handshake"></i> Publish Freelance Offer</a>
|
| 447 |
+
<a href="{{ url_for('my_postings') }}" class="button button-secondary"><i class="fas fa-list-alt"></i> My Postings</a>
|
| 448 |
+
</div>
|
| 449 |
+
{{% else %}}
|
| 450 |
+
<p class="no-items">Please wait for authentication to complete to publish or view your items.</p>
|
| 451 |
+
{{% endif %}}
|
| 452 |
+
|
| 453 |
+
<div class="tabs">
|
| 454 |
+
<button class="tab-button active" onclick="openTab(event, 'resumes')">Resumes</button>
|
| 455 |
+
<button class="tab-button" onclick="openTab(event, 'vacancies')">Vacancies</button>
|
| 456 |
+
<button class="tab-button" onclick="openTab(event, 'freelance_offers')">Freelance Offers</button>
|
| 457 |
+
</div>
|
| 458 |
|
| 459 |
+
<div id="resumes" class="tab-content active">
|
| 460 |
+
<h3>Latest Resumes</h3>
|
| 461 |
+
{{% if resumes %}}
|
| 462 |
+
{{% for resume_id, resume in resumes.items()|sort(attribute='1.published_at', reverse=True) %}}
|
| 463 |
+
<div class="card">
|
| 464 |
+
{{% if resume.photo_filename %}}
|
| 465 |
+
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ resume.photo_filename }}" alt="{{ resume.full_name }}" class="item-photo">
|
| 466 |
+
{{% endif %}}
|
| 467 |
+
<h2>{{ resume.full_name }} - {{ resume.title }}</h2>
|
| 468 |
+
<p class="meta">Published: {{ resume.published_at[:10] }}</p>
|
| 469 |
+
<p><strong>Skills:</strong> {{ resume.skills|join(', ') }}</p>
|
| 470 |
+
<a href="{{ url_for('view_item', item_type='resume', item_id=resume_id) }}" class="button button-secondary">View Details</a>
|
| 471 |
+
</div>
|
| 472 |
+
{{% endfor %}}
|
| 473 |
+
{{% else %}}
|
| 474 |
+
<p class="no-items">No resumes published yet.</p>
|
| 475 |
+
{{% endif %}}
|
| 476 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
+
<div id="vacancies" class="tab-content">
|
| 479 |
+
<h3>Latest Vacancies</h3>
|
| 480 |
+
{{% if vacancies %}}
|
| 481 |
+
{{% for vacancy_id, vacancy in vacancies.items()|sort(attribute='1.published_at', reverse=True) %}}
|
| 482 |
+
<div class="card">
|
| 483 |
+
{{% if vacancy.company_logo_filename %}}
|
| 484 |
+
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ vacancy.company_logo_filename }}" alt="{{ vacancy.company_name }}" class="item-photo">
|
| 485 |
+
{{% endif %}}
|
| 486 |
+
<h2>{{ vacancy.job_title }} at {{ vacancy.company_name }}</h2>
|
| 487 |
+
<p class="meta">Published: {{ vacancy.published_at[:10] }}</p>
|
| 488 |
+
<p><strong>Location:</strong> {{ vacancy.location }}</p>
|
| 489 |
+
<a href="{{ url_for('view_item', item_type='vacancy', item_id=vacancy_id) }}" class="button button-secondary">View Details</a>
|
| 490 |
+
</div>
|
| 491 |
+
{{% endfor %}}
|
| 492 |
+
{{% else %}}
|
| 493 |
+
<p class="no-items">No vacancies published yet.</p>
|
| 494 |
+
{{% endif %}}
|
| 495 |
+
</div>
|
| 496 |
|
| 497 |
+
<div id="freelance_offers" class="tab-content">
|
| 498 |
+
<h3>Latest Freelance Offers</h3>
|
| 499 |
+
{{% if freelance_offers %}}
|
| 500 |
+
{{% for offer_id, offer in freelance_offers.items()|sort(attribute='1.published_at', reverse=True) %}}
|
| 501 |
+
<div class="card">
|
| 502 |
+
<h2>{{ offer.title }}</h2>
|
| 503 |
+
<p class="meta">Published: {{ offer.published_at[:10] }}</p>
|
| 504 |
+
<p><strong>Budget:</strong> {{ offer.budget }}</p>
|
| 505 |
+
<a href="{{ url_for('view_item', item_type='freelance_offer', item_id=offer_id) }}" class="button button-secondary">View Details</a>
|
| 506 |
+
</div>
|
| 507 |
+
{{% endfor %}}
|
| 508 |
+
{{% else %}}
|
| 509 |
+
<p class="no-items">No freelance offers published yet.</p>
|
| 510 |
+
{{% endif %}}
|
| 511 |
+
</div>
|
| 512 |
+
{{% endblock %}}
|
| 513 |
+
|
| 514 |
+
{{% block extra_js %}}
|
| 515 |
+
<script>
|
| 516 |
+
function openTab(evt, tabName) {{
|
| 517 |
+
var i, tabcontent, tablinks;
|
| 518 |
+
tabcontent = document.getElementsByClassName("tab-content");
|
| 519 |
+
for (i = 0; i < tabcontent.length; i++) {{
|
| 520 |
+
tabcontent[i].style.display = "none";
|
| 521 |
+
tabcontent[i].classList.remove("active");
|
| 522 |
+
}}
|
| 523 |
+
tablinks = document.getElementsByClassName("tab-button");
|
| 524 |
+
for (i = 0; i < tablinks.length; i++) {{
|
| 525 |
+
tablinks[i].classList.remove("active");
|
| 526 |
+
}}
|
| 527 |
+
document.getElementById(tabName).style.display = "block";
|
| 528 |
+
document.getElementById(tabName).classList.add("active");
|
| 529 |
+
evt.currentTarget.classList.add("active");
|
| 530 |
+
}}
|
| 531 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 532 |
+
tg.MainButton.hide();
|
| 533 |
+
const firstTab = document.querySelector('.tab-button');
|
| 534 |
+
if(firstTab) {{
|
| 535 |
+
// firstTab.click(); // Already active by default
|
| 536 |
+
}}
|
| 537 |
+
}});
|
| 538 |
+
</script>
|
| 539 |
+
{{% endblock %}}
|
| 540 |
+
"""
|
|
|
|
|
|
|
| 541 |
|
| 542 |
+
PUBLISH_ITEM_TEMPLATE = """
|
| 543 |
+
{{% extends "base_template" %}}
|
| 544 |
+
{{% block content %}}
|
| 545 |
+
<h2>{{ "Edit" if item_data else "Publish" }} {{ item_type_display_name }}</h2>
|
| 546 |
+
<form method="POST" enctype="multipart/form-data">
|
| 547 |
+
{{% if item_type == 'resume' %}}
|
| 548 |
+
<div class="form-group">
|
| 549 |
+
<label for="full_name">Full Name *</label>
|
| 550 |
+
<input type="text" id="full_name" name="full_name" value="{{ item_data.full_name if item_data else '' }}" required>
|
| 551 |
+
</div>
|
| 552 |
+
<div class="form-group">
|
| 553 |
+
<label for="title">Job Title / Headline *</label>
|
| 554 |
+
<input type="text" id="title" name="title" value="{{ item_data.title if item_data else '' }}" required>
|
| 555 |
+
</div>
|
| 556 |
+
<div class="form-group">
|
| 557 |
+
<label for="skills">Skills (comma-separated) *</label>
|
| 558 |
+
<input type="text" id="skills" name="skills" value="{{ (item_data.skills|join(', ')) if item_data and item_data.skills else '' }}" required>
|
| 559 |
+
</div>
|
| 560 |
+
<div class="form-group">
|
| 561 |
+
<label for="experience">Experience (one entry per line: Company;Role;Duration;Description)</label>
|
| 562 |
+
<textarea id="experience" name="experience" rows="5" placeholder="e.g., Acme Corp;Software Engineer;2020-2023;Developed cool stuff.">{{ (item_data.experience_str if item_data else '') }}</textarea>
|
| 563 |
+
</div>
|
| 564 |
+
<div class="form-group">
|
| 565 |
+
<label for="education">Education (one entry per line: Institution;Degree;Duration)</label>
|
| 566 |
+
<textarea id="education" name="education" rows="3" placeholder="e.g., State University;B.S. Computer Science;2016-2020">{{ (item_data.education_str if item_data else '') }}</textarea>
|
| 567 |
+
</div>
|
| 568 |
+
<div class="form-group">
|
| 569 |
+
<label for="contact_info">Contact (e.g., Telegram @username, email) *</label>
|
| 570 |
+
<input type="text" id="contact_info" name="contact_info" value="{{ item_data.contact_info if item_data else (session['user'].username if session.get('user') else '') }}" required>
|
| 571 |
+
</div>
|
| 572 |
+
<div class="form-group">
|
| 573 |
+
<label for="portfolio_links">Portfolio Links (comma-separated)</label>
|
| 574 |
+
<input type="text" id="portfolio_links" name="portfolio_links" value="{{ (item_data.portfolio_links|join(', ')) if item_data and item_data.portfolio_links else '' }}">
|
| 575 |
+
</div>
|
| 576 |
+
<div class="form-group">
|
| 577 |
+
<label for="photo">Profile Photo (Optional, replaces existing if editing)</label>
|
| 578 |
+
<input type="file" id="photo" name="photo" accept="image/*">
|
| 579 |
+
{{% if item_data and item_data.photo_filename %}}
|
| 580 |
+
<p>Current photo: <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item_data.photo_filename }}" alt="Current Photo" style="max-width:50px; max-height:50px; vertical-align:middle; margin-left:10px;"></p>
|
| 581 |
+
{{% endif %}}
|
| 582 |
+
</div>
|
| 583 |
+
{{% elif item_type == 'vacancy' %}}
|
| 584 |
+
<div class="form-group">
|
| 585 |
+
<label for="company_name">Company Name *</label>
|
| 586 |
+
<input type="text" id="company_name" name="company_name" value="{{ item_data.company_name if item_data else '' }}" required>
|
| 587 |
+
</div>
|
| 588 |
+
<div class="form-group">
|
| 589 |
+
<label for="job_title">Job Title *</label>
|
| 590 |
+
<input type="text" id="job_title" name="job_title" value="{{ item_data.job_title if item_data else '' }}" required>
|
| 591 |
+
</div>
|
| 592 |
+
<div class="form-group">
|
| 593 |
+
<label for="description">Job Description *</label>
|
| 594 |
+
<textarea id="description" name="description" required>{{ item_data.description if item_data else '' }}</textarea>
|
| 595 |
+
</div>
|
| 596 |
+
<div class="form-group">
|
| 597 |
+
<label for="requirements">Requirements (comma-separated)</label>
|
| 598 |
+
<input type="text" id="requirements" name="requirements" value="{{ (item_data.requirements|join(', ')) if item_data and item_data.requirements else '' }}">
|
| 599 |
+
</div>
|
| 600 |
+
<div class="form-group">
|
| 601 |
+
<label for="location">Location *</label>
|
| 602 |
+
<input type="text" id="location" name="location" value="{{ item_data.location if item_data else '' }}" required>
|
| 603 |
+
</div>
|
| 604 |
+
<div class="form-group">
|
| 605 |
+
<label for="salary_range">Salary Range (e.g., $50k-$70k, Negotiable)</label>
|
| 606 |
+
<input type="text" id="salary_range" name="salary_range" value="{{ item_data.salary_range if item_data else '' }}">
|
| 607 |
+
</div>
|
| 608 |
+
<div class="form-group">
|
| 609 |
+
<label for="employment_type">Employment Type</label>
|
| 610 |
+
<select id="employment_type" name="employment_type">
|
| 611 |
+
<option value="Full-time" {{ "selected" if item_data and item_data.employment_type == "Full-time" }}>Full-time</option>
|
| 612 |
+
<option value="Part-time" {{ "selected" if item_data and item_data.employment_type == "Part-time" }}>Part-time</option>
|
| 613 |
+
<option value="Contract" {{ "selected" if item_data and item_data.employment_type == "Contract" }}>Contract</option>
|
| 614 |
+
<option value="Internship" {{ "selected" if item_data and item_data.employment_type == "Internship" }}>Internship</option>
|
| 615 |
+
</select>
|
| 616 |
+
</div>
|
| 617 |
+
<div class="form-group">
|
| 618 |
+
<label for="contact_info">Contact Info (for applications) *</label>
|
| 619 |
+
<input type="text" id="contact_info" name="contact_info" value="{{ item_data.contact_info if item_data else '' }}" required>
|
| 620 |
+
</div>
|
| 621 |
+
<div class="form-group">
|
| 622 |
+
<label for="photo">Company Logo (Optional)</label>
|
| 623 |
+
<input type="file" id="photo" name="photo" accept="image/*">
|
| 624 |
+
{{% if item_data and item_data.company_logo_filename %}}
|
| 625 |
+
<p>Current logo: <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item_data.company_logo_filename }}" alt="Current Logo" style="max-width:50px; max-height:50px; vertical-align:middle; margin-left:10px;"></p>
|
| 626 |
+
{{% endif %}}
|
| 627 |
+
</div>
|
| 628 |
+
{{% elif item_type == 'freelance_offer' %}}
|
| 629 |
+
<div class="form-group">
|
| 630 |
+
<label for="title">Offer Title *</label>
|
| 631 |
+
<input type="text" id="title" name="title" value="{{ item_data.title if item_data else '' }}" required>
|
| 632 |
+
</div>
|
| 633 |
+
<div class="form-group">
|
| 634 |
+
<label for="description">Offer Description *</label>
|
| 635 |
+
<textarea id="description" name="description" required>{{ item_data.description if item_data else '' }}</textarea>
|
| 636 |
+
</div>
|
| 637 |
+
<div class="form-group">
|
| 638 |
+
<label for="skills_required">Skills Required (comma-separated) *</label>
|
| 639 |
+
<input type="text" id="skills_required" name="skills_required" value="{{ (item_data.skills_required|join(', ')) if item_data and item_data.skills_required else '' }}" required>
|
| 640 |
+
</div>
|
| 641 |
+
<div class="form-group">
|
| 642 |
+
<label for="budget">Budget (e.g., $500, Negotiable) *</label>
|
| 643 |
+
<input type="text" id="budget" name="budget" value="{{ item_data.budget if item_data else '' }}" required>
|
| 644 |
+
</div>
|
| 645 |
+
<div class="form-group">
|
| 646 |
+
<label for="deadline">Deadline (e.g., 2 weeks, YYYY-MM-DD)</label>
|
| 647 |
+
<input type="text" id="deadline" name="deadline" value="{{ item_data.deadline if item_data else '' }}">
|
| 648 |
+
</div>
|
| 649 |
+
<div class="form-group">
|
| 650 |
+
<label for="contact_info">Contact Info *</label>
|
| 651 |
+
<input type="text" id="contact_info" name="contact_info" value="{{ item_data.contact_info if item_data else '' }}" required>
|
| 652 |
+
</div>
|
| 653 |
+
{{% endif %}}
|
| 654 |
+
<button type="submit" id="main-submit-button">{{ "Save Changes" if item_data else "Publish" }}</button>
|
| 655 |
+
</form>
|
| 656 |
+
{{% endblock %}}
|
| 657 |
+
{{% block extra_js %}}
|
| 658 |
+
<script>
|
| 659 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 660 |
+
tg.MainButton.setText('{{ "Save Changes" if item_data else "Publish" }}');
|
| 661 |
+
tg.MainButton.show();
|
| 662 |
+
tg.MainButton.onClick(function() {{
|
| 663 |
+
document.getElementById('main-submit-button').click();
|
| 664 |
+
}});
|
| 665 |
+
tg.BackButton.show();
|
| 666 |
+
tg.onEvent('backButtonClicked', function() {{
|
| 667 |
+
window.history.back();
|
| 668 |
+
}});
|
| 669 |
+
}});
|
| 670 |
+
// Cleanup MainButton when navigating away from this page
|
| 671 |
+
window.addEventListener('beforeunload', function() {{
|
| 672 |
+
tg.MainButton.hide();
|
| 673 |
+
tg.MainButton.offClick(); // Remove previous handler
|
| 674 |
+
tg.BackButton.hide();
|
| 675 |
+
tg.offEvent('backButtonClicked');
|
| 676 |
+
}});
|
| 677 |
+
</script>
|
| 678 |
+
{{% endblock %}}
|
| 679 |
+
"""
|
| 680 |
|
| 681 |
+
VIEW_ITEM_TEMPLATE = """
|
| 682 |
+
{{% extends "base_template" %}}
|
| 683 |
+
{{% block content %}}
|
| 684 |
+
<div class="card">
|
| 685 |
+
{{% if item_type == 'resume' %}}
|
| 686 |
+
{{% if item.photo_filename %}}
|
| 687 |
+
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item.photo_filename }}" alt="{{ item.full_name }}" class="item-photo" style="max-width:100px; max-height:100px; margin-bottom:15px;">
|
| 688 |
+
{{% endif %}}
|
| 689 |
+
<h2>{{ item.full_name }}</h2>
|
| 690 |
+
<h3>{{ item.title }}</h3>
|
| 691 |
+
<p class="meta">Published: {{ item.published_at[:10] }} by {{ item.user_display_name }}</p>
|
| 692 |
+
<p><strong>Skills:</strong> {{ item.skills|join(', ') }}</p>
|
| 693 |
+
<h4>Experience:</h4>
|
| 694 |
+
{{% if item.experience %}}
|
| 695 |
+
<ul>{{% for exp in item.experience %}}<li><strong>{{ exp.role }}</strong> at {{ exp.company }} ({{ exp.duration }})<br>{{ exp.description }}</li>{{% endfor %}}</ul>
|
| 696 |
+
{{% else %}}<p>N/A</p>{{% endif %}}
|
| 697 |
+
<h4>Education:</h4>
|
| 698 |
+
{{% if item.education %}}
|
| 699 |
+
<ul>{{% for edu in item.education %}}<li><strong>{{ edu.degree }}</strong>, {{ edu.institution }} ({{ edu.duration }})</li>{{% endfor %}}</ul>
|
| 700 |
+
{{% else %}}<p>N/A</p>{{% endif %}}
|
| 701 |
+
<p><strong>Contact:</strong> {{ item.contact_info }}</p>
|
| 702 |
+
{{% if item.portfolio_links %}}<p><strong>Portfolio:</strong> {{ item.portfolio_links|map('urlize')|join(', ')|safe }}</p>{{% endif %}}
|
| 703 |
+
|
| 704 |
+
{{% elif item_type == 'vacancy' %}}
|
| 705 |
+
{{% if item.company_logo_filename %}}
|
| 706 |
+
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item.company_logo_filename }}" alt="{{ item.company_name }}" class="item-photo" style="max-width:100px; max-height:100px; margin-bottom:15px;">
|
| 707 |
+
{{% endif %}}
|
| 708 |
+
<h2>{{ item.job_title }}</h2>
|
| 709 |
+
<h3>at {{ item.company_name }}</h3>
|
| 710 |
+
<p class="meta">Published: {{ item.published_at[:10] }} by {{ item.user_display_name }}</p>
|
| 711 |
+
<p><strong>Location:</strong> {{ item.location }}</p>
|
| 712 |
+
<p><strong>Description:</strong><br>{{ item.description|replace('\\n', '<br>')|safe }}</p>
|
| 713 |
+
{{% if item.requirements %}}<p><strong>Requirements:</strong> {{ item.requirements|join(', ') }}</p>{{% endif %}}
|
| 714 |
+
{{% if item.salary_range %}}<p><strong>Salary:</strong> {{ item.salary_range }}</p>{{% endif %}}
|
| 715 |
+
<p><strong>Type:</strong> {{ item.employment_type }}</p>
|
| 716 |
+
<p><strong>Contact for Applications:</strong> {{ item.contact_info }}</p>
|
| 717 |
+
|
| 718 |
+
{{% elif item_type == 'freelance_offer' %}}
|
| 719 |
+
<h2>{{ item.title }}</h2>
|
| 720 |
+
<p class="meta">Published: {{ item.published_at[:10] }} by {{ item.user_display_name }}</p>
|
| 721 |
+
<p><strong>Description:</strong><br>{{ item.description|replace('\\n', '<br>')|safe }}</p>
|
| 722 |
+
<p><strong>Skills Required:</strong> {{ item.skills_required|join(', ') }}</p>
|
| 723 |
+
<p><strong>Budget:</strong> {{ item.budget }}</p>
|
| 724 |
+
{{% if item.deadline %}}<p><strong>Deadline:</strong> {{ item.deadline }}</p>{{% endif %}}
|
| 725 |
+
<p><strong>Contact:</strong> {{ item.contact_info }}</p>
|
| 726 |
+
{{% endif %}}
|
| 727 |
+
|
| 728 |
+
{{% if 'user' in session and session['user'] and item.user_id == session['user'].id %}}
|
| 729 |
+
<div class="item-actions">
|
| 730 |
+
<a href="{{ url_for('edit_item', item_type=item_type, item_id=item_id) }}" class="button">Edit</a>
|
| 731 |
+
<form method="POST" action="{{ url_for('delete_item', item_type=item_type, item_id=item_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
|
| 732 |
+
<button type="submit" class="button button-destructive">Delete</button>
|
| 733 |
+
</form>
|
| 734 |
+
</div>
|
| 735 |
+
{{% endif %}}
|
| 736 |
+
</div>
|
| 737 |
+
<a href="{{ url_for('index') }}" class="button button-secondary" style="margin-top:20px;">Back to Listings</a>
|
| 738 |
+
{{% endblock %}}
|
| 739 |
+
{{% block extra_js %}}
|
| 740 |
+
<script>
|
| 741 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 742 |
+
tg.BackButton.show();
|
| 743 |
+
tg.onEvent('backButtonClicked', function() {{
|
| 744 |
+
window.location.href = '{{ url_for("index") }}';
|
| 745 |
+
}});
|
| 746 |
+
}});
|
| 747 |
+
window.addEventListener('beforeunload', function() {{
|
| 748 |
+
tg.BackButton.hide();
|
| 749 |
+
tg.offEvent('backButtonClicked');
|
| 750 |
+
}});
|
| 751 |
+
</script>
|
| 752 |
+
{{% endblock %}}
|
| 753 |
+
"""
|
| 754 |
|
| 755 |
+
MY_POSTINGS_TEMPLATE = """
|
| 756 |
+
{{% extends "base_template" %}}
|
| 757 |
+
{{% block content %}}
|
| 758 |
+
<h2>My Postings</h2>
|
| 759 |
+
|
| 760 |
+
{{% if not (my_resumes or my_vacancies or my_freelance_offers) %}}
|
| 761 |
+
<p class="no-items">You haven't published anything yet.</p>
|
| 762 |
+
<a href="{{ url_for('index') }}" class="button">Publish Something</a>
|
| 763 |
+
{{% endif %}}
|
| 764 |
+
|
| 765 |
+
{{% if my_resumes %}}
|
| 766 |
+
<h3>My Resumes</h3>
|
| 767 |
+
{{% for resume_id, resume in my_resumes.items() %}}
|
| 768 |
+
<div class="card">
|
| 769 |
+
{{% if resume.photo_filename %}}
|
| 770 |
+
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ resume.photo_filename }}" alt="{{ resume.full_name }}" class="item-photo">
|
| 771 |
+
{{% endif %}}
|
| 772 |
+
<h4>{{ resume.full_name }} - {{ resume.title }}</h4>
|
| 773 |
+
<p class="meta">Published: {{ resume.published_at[:10] }}</p>
|
| 774 |
+
<div class="item-actions">
|
| 775 |
+
<a href="{{ url_for('view_item', item_type='resume', item_id=resume_id) }}" class="button button-secondary">View</a>
|
| 776 |
+
<a href="{{ url_for('edit_item', item_type='resume', item_id=resume_id) }}" class="button">Edit</a>
|
| 777 |
+
<form method="POST" action="{{ url_for('delete_item', item_type='resume', item_id=resume_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
|
| 778 |
+
<button type="submit" class="button button-destructive">Delete</button>
|
| 779 |
+
</form>
|
| 780 |
+
</div>
|
| 781 |
+
</div>
|
| 782 |
+
{{% endfor %}}
|
| 783 |
+
{{% endif %}}
|
| 784 |
+
|
| 785 |
+
{{% if my_vacancies %}}
|
| 786 |
+
<h3>My Vacancies</h3>
|
| 787 |
+
{{% for vacancy_id, vacancy in my_vacancies.items() %}}
|
| 788 |
+
<div class="card">
|
| 789 |
+
{{% if vacancy.company_logo_filename %}}
|
| 790 |
+
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ vacancy.company_logo_filename }}" alt="{{ vacancy.company_name }}" class="item-photo">
|
| 791 |
+
{{% endif %}}
|
| 792 |
+
<h4>{{ vacancy.job_title }} at {{ vacancy.company_name }}</h4>
|
| 793 |
+
<p class="meta">Published: {{ vacancy.published_at[:10] }}</p>
|
| 794 |
+
<div class="item-actions">
|
| 795 |
+
<a href="{{ url_for('view_item', item_type='vacancy', item_id=vacancy_id) }}" class="button button-secondary">View</a>
|
| 796 |
+
<a href="{{ url_for('edit_item', item_type='vacancy', item_id=vacancy_id) }}" class="button">Edit</a>
|
| 797 |
+
<form method="POST" action="{{ url_for('delete_item', item_type='vacancy', item_id=vacancy_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
|
| 798 |
+
<button type="submit" class="button button-destructive">Delete</button>
|
| 799 |
+
</form>
|
| 800 |
+
</div>
|
| 801 |
+
</div>
|
| 802 |
+
{{% endfor %}}
|
| 803 |
+
{{% endif %}}
|
| 804 |
+
|
| 805 |
+
{{% if my_freelance_offers %}}
|
| 806 |
+
<h3>My Freelance Offers</h3>
|
| 807 |
+
{{% for offer_id, offer in my_freelance_offers.items() %}}
|
| 808 |
+
<div class="card">
|
| 809 |
+
<h4>{{ offer.title }}</h4>
|
| 810 |
+
<p class="meta">Published: {{ offer.published_at[:10] }}</p>
|
| 811 |
+
<div class="item-actions">
|
| 812 |
+
<a href="{{ url_for('view_item', item_type='freelance_offer', item_id=offer_id) }}" class="button button-secondary">View</a>
|
| 813 |
+
<a href="{{ url_for('edit_item', item_type='freelance_offer', item_id=offer_id) }}" class="button">Edit</a>
|
| 814 |
+
<form method="POST" action="{{ url_for('delete_item', item_type='freelance_offer', item_id=offer_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
|
| 815 |
+
<button type="submit" class="button button-destructive">Delete</button>
|
| 816 |
+
</form>
|
| 817 |
+
</div>
|
| 818 |
+
</div>
|
| 819 |
+
{{% endfor %}}
|
| 820 |
+
{{% endif %}}
|
| 821 |
+
|
| 822 |
+
<a href="{{ url_for('index') }}" class="button button-secondary" style="margin-top:20px;">Back to Main Page</a>
|
| 823 |
+
{{% endblock %}}
|
| 824 |
+
{{% block extra_js %}}
|
| 825 |
+
<script>
|
| 826 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 827 |
+
tg.BackButton.show();
|
| 828 |
+
tg.onEvent('backButtonClicked', function() {{
|
| 829 |
+
window.location.href = '{{ url_for("index") }}';
|
| 830 |
+
}});
|
| 831 |
+
}});
|
| 832 |
+
window.addEventListener('beforeunload', function() {{
|
| 833 |
+
tg.BackButton.hide();
|
| 834 |
+
tg.offEvent('backButtonClicked');
|
| 835 |
+
}});
|
| 836 |
+
</script>
|
| 837 |
+
{{% endblock %}}
|
| 838 |
+
"""
|
| 839 |
|
| 840 |
+
ADMIN_SYNC_TEMPLATE = """
|
| 841 |
+
{{% extends "base_template" %}}
|
| 842 |
+
{{% block content %}}
|
| 843 |
+
<div class="header">
|
| 844 |
+
<h1>Admin Sync Panel</h1>
|
| 845 |
+
</div>
|
| 846 |
+
<div class="card">
|
| 847 |
+
<h2><i class="fas fa-sync-alt"></i> Sync with Data Store</h2>
|
| 848 |
+
<div style="display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap;">
|
| 849 |
+
<form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to force upload local data? This will overwrite server data.');">
|
| 850 |
+
<button type="submit" class="button"><i class="fas fa-upload"></i> Upload DB</button>
|
| 851 |
+
</form>
|
| 852 |
+
<form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to force download data? This will overwrite local files.');">
|
| 853 |
+
<button type="submit" class="button button-secondary"><i class="fas fa-download"></i> Download DB</button>
|
| 854 |
+
</form>
|
| 855 |
+
</div>
|
| 856 |
+
<p style="font-size: 14px; color: var(--tg-theme-hint-color);">Backup occurs automatically every 30 minutes and after each save. Use these buttons for immediate sync.</p>
|
| 857 |
+
</div>
|
| 858 |
+
<div class="card">
|
| 859 |
+
<h2>Data Overview</h2>
|
| 860 |
+
<p>Total Resumes: {{ data_counts.resumes }}</p>
|
| 861 |
+
<p>Total Vacancies: {{ data_counts.vacancies }}</p>
|
| 862 |
+
<p>Total Freelance Offers: {{ data_counts.freelance_offers }}</p>
|
| 863 |
+
</div>
|
| 864 |
+
<a href="{{ url_for('index') }}" class="button button-secondary" style="margin-top:20px;">Back to Main Page</a>
|
| 865 |
+
{{% endblock %}}
|
| 866 |
+
{{% block extra_js %}}
|
| 867 |
+
<script>
|
| 868 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 869 |
+
tg.BackButton.show();
|
| 870 |
+
tg.onEvent('backButtonClicked', function() {{
|
| 871 |
+
window.location.href = '{{ url_for("index") }}';
|
| 872 |
+
}});
|
| 873 |
+
}});
|
| 874 |
+
window.addEventListener('beforeunload', function() {{
|
| 875 |
+
tg.BackButton.hide();
|
| 876 |
+
tg.offEvent('backButtonClicked');
|
| 877 |
+
}});
|
| 878 |
+
</script>
|
| 879 |
+
{{% endblock %}}
|
| 880 |
+
"""
|
| 881 |
|
| 882 |
+
# --- Helper function to get item type display name ---
|
| 883 |
+
def get_item_type_display_name(item_type_slug):
|
| 884 |
+
names = {
|
| 885 |
+
'resume': 'Resume',
|
| 886 |
+
'vacancy': 'Vacancy',
|
| 887 |
+
'freelance_offer': 'Freelance Offer'
|
| 888 |
+
}
|
| 889 |
+
return names.get(item_type_slug, 'Item')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
|
| 891 |
+
def get_item_collection_name(item_type_slug):
|
| 892 |
+
names = {
|
| 893 |
+
'resume': 'resumes',
|
| 894 |
+
'vacancy': 'vacancies',
|
| 895 |
+
'freelance_offer': 'freelance_offers'
|
| 896 |
+
}
|
| 897 |
+
return names.get(item_type_slug)
|
| 898 |
|
| 899 |
# --- Flask Routes ---
|
| 900 |
+
@app.before_request
|
| 901 |
+
def check_auth_for_publish():
|
| 902 |
+
if request.endpoint in ['publish_item', 'edit_item', 'delete_item', 'my_postings']:
|
| 903 |
+
if 'user' not in session or not session['user']:
|
| 904 |
+
flash("You need to be authenticated to access this page. Please ensure you are opening this app through Telegram.", "error")
|
| 905 |
+
return redirect(url_for('index'))
|
| 906 |
+
if not session['user'].get('id'):
|
| 907 |
+
flash("Authentication error: User ID missing.", "error")
|
| 908 |
+
session.pop('user', None)
|
| 909 |
+
return redirect(url_for('index'))
|
| 910 |
+
|
| 911 |
+
@app.route('/auth_telegram', methods=['POST'])
|
| 912 |
+
def auth_telegram():
|
| 913 |
+
data = request.get_json()
|
| 914 |
+
init_data_str = data.get('init_data')
|
| 915 |
+
if not init_data_str:
|
| 916 |
+
return jsonify({"status": "error", "message": "No initData received"}), 400
|
| 917 |
+
|
| 918 |
+
user_info = validate_telegram_data(init_data_str)
|
| 919 |
+
|
| 920 |
+
if user_info and user_info.get('id'):
|
| 921 |
+
session['user'] = user_info
|
| 922 |
+
logging.info(f"User {user_info.get('id')} authenticated via Telegram.")
|
| 923 |
+
return jsonify({"status": "success", "user": user_info})
|
| 924 |
+
else:
|
| 925 |
+
logging.warning(f"Telegram authentication failed for initData: {init_data_str[:100]}...")
|
| 926 |
+
session.pop('user', None)
|
| 927 |
+
return jsonify({"status": "error", "message": "Invalid Telegram data or hash mismatch"}), 403
|
| 928 |
+
|
| 929 |
@app.route('/')
|
| 930 |
def index():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 931 |
data = load_data()
|
| 932 |
+
# Apply theme params from session if available, otherwise use defaults
|
| 933 |
+
theme_params = session.get('themeParams', {})
|
| 934 |
+
return render_template_string(
|
| 935 |
+
BASE_TEMPLATE.format(
|
| 936 |
+
background_color=theme_params.get('bg_color'),
|
| 937 |
+
text_color=theme_params.get('text_color'),
|
| 938 |
+
hint_color=theme_params.get('hint_color'),
|
| 939 |
+
link_color=theme_params.get('link_color'),
|
| 940 |
+
button_color=theme_params.get('button_color'),
|
| 941 |
+
button_text_color=theme_params.get('button_text_color'),
|
| 942 |
+
secondary_bg_color=theme_params.get('secondary_bg_color'),
|
| 943 |
+
header_color=theme_params.get('header_bg_color'),
|
| 944 |
+
accent_text_color=theme_params.get('accent_text_color'),
|
| 945 |
+
section_bg_color=theme_params.get('section_bg_color'),
|
| 946 |
+
section_header_text_color=theme_params.get('section_header_text_color'),
|
| 947 |
+
destructive_text_color=theme_params.get('destructive_text_color')
|
| 948 |
+
) + INDEX_TEMPLATE,
|
| 949 |
+
resumes=data.get('resumes', {}),
|
| 950 |
+
vacancies=data.get('vacancies', {}),
|
| 951 |
+
freelance_offers=data.get('freelance_offers', {}),
|
| 952 |
+
repo_id=REPO_ID
|
| 953 |
+
)
|
| 954 |
+
|
| 955 |
+
@app.route('/publish/<item_type>', methods=['GET', 'POST'])
|
| 956 |
+
@app.route('/edit/<item_type>/<item_id>', methods=['GET', 'POST'])
|
| 957 |
+
def publish_item(item_type, item_id=None):
|
| 958 |
+
data = load_data()
|
| 959 |
+
collection_name = get_item_collection_name(item_type)
|
| 960 |
+
if not collection_name:
|
| 961 |
+
flash("Invalid item type.", "error")
|
| 962 |
+
return redirect(url_for('index'))
|
| 963 |
+
|
| 964 |
+
item_data_to_edit = None
|
| 965 |
+
if item_id:
|
| 966 |
+
item_data_to_edit = data.get(collection_name, {}).get(item_id)
|
| 967 |
+
if not item_data_to_edit or item_data_to_edit.get('user_id') != session['user']['id']:
|
| 968 |
+
flash("Item not found or you don't have permission to edit it.", "error")
|
| 969 |
+
return redirect(url_for('index'))
|
| 970 |
+
# Prepare complex fields for form display
|
| 971 |
+
if item_type == 'resume':
|
| 972 |
+
if item_data_to_edit.get('experience'):
|
| 973 |
+
item_data_to_edit['experience_str'] = "\n".join([f"{e['company']};{e['role']};{e['duration']};{e['description']}" for e in item_data_to_edit['experience']])
|
| 974 |
+
if item_data_to_edit.get('education'):
|
| 975 |
+
item_data_to_edit['education_str'] = "\n".join([f"{e['institution']};{e['degree']};{e['duration']}" for e in item_data_to_edit['education']])
|
| 976 |
+
|
| 977 |
+
|
| 978 |
+
if request.method == 'POST':
|
| 979 |
+
new_item_id = item_id or str(uuid.uuid4())
|
| 980 |
+
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 981 |
+
|
| 982 |
+
current_photo_filename = None
|
| 983 |
+
if item_data_to_edit:
|
| 984 |
+
if item_type == 'resume': current_photo_filename = item_data_to_edit.get('photo_filename')
|
| 985 |
+
elif item_type == 'vacancy': current_photo_filename = item_data_to_edit.get('company_logo_filename')
|
| 986 |
+
|
| 987 |
+
photo_file = request.files.get('photo')
|
| 988 |
+
uploaded_photo_filename = None
|
| 989 |
+
|
| 990 |
+
if photo_file and photo_file.filename:
|
| 991 |
+
if not HF_TOKEN_WRITE:
|
| 992 |
+
flash("Cannot upload photo: Hugging Face Write Token is not configured.", "warning")
|
| 993 |
+
else:
|
| 994 |
+
try:
|
| 995 |
+
uploads_dir = 'uploads_temp'
|
| 996 |
+
os.makedirs(uploads_dir, exist_ok=True)
|
| 997 |
+
api = HfApi()
|
| 998 |
+
|
| 999 |
+
safe_name_prefix = secure_filename(request.form.get('full_name', request.form.get('company_name', request.form.get('title', 'item'))).replace(' ', '_'))[:20]
|
| 1000 |
+
ext = os.path.splitext(photo_file.filename)[1].lower()
|
| 1001 |
+
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
|
| 1002 |
+
flash(f"File {photo_file.filename} is not a supported image format and was skipped.", "warning")
|
| 1003 |
+
else:
|
| 1004 |
+
new_photo_filename_base = f"{safe_name_prefix}_{new_item_id[:8]}_{timestamp.replace(':','-').replace(' ','_')}{ext}"
|
| 1005 |
+
temp_path = os.path.join(uploads_dir, new_photo_filename_base)
|
| 1006 |
+
photo_file.save(temp_path)
|
| 1007 |
+
|
| 1008 |
+
api.upload_file(
|
| 1009 |
+
path_or_fileobj=temp_path,
|
| 1010 |
+
path_in_repo=f"photos/{new_photo_filename_base}",
|
| 1011 |
+
repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
|
| 1012 |
+
commit_message=f"Upload photo for {item_type} {new_item_id}"
|
| 1013 |
+
)
|
| 1014 |
+
uploaded_photo_filename = new_photo_filename_base
|
| 1015 |
+
logging.info(f"Photo {uploaded_photo_filename} uploaded for {item_type} {new_item_id}.")
|
| 1016 |
+
os.remove(temp_path)
|
| 1017 |
+
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir)
|
| 1018 |
+
|
| 1019 |
+
# Delete old photo if a new one is uploaded and an old one existed
|
| 1020 |
+
if current_photo_filename and uploaded_photo_filename != current_photo_filename:
|
| 1021 |
+
try:
|
| 1022 |
+
api.delete_file(repo_id=REPO_ID, path_in_repo=f"photos/{current_photo_filename}", repo_type="dataset", token=HF_TOKEN_WRITE)
|
| 1023 |
+
logging.info(f"Old photo {current_photo_filename} deleted from HF.")
|
| 1024 |
+
except Exception as e_del:
|
| 1025 |
+
logging.error(f"Failed to delete old photo {current_photo_filename}: {e_del}")
|
| 1026 |
+
except Exception as e:
|
| 1027 |
+
logging.error(f"Error uploading photo: {e}", exc_info=True)
|
| 1028 |
+
flash("Error uploading photo.", "error")
|
| 1029 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
|
| 1031 |
+
item_details = {
|
| 1032 |
+
'id': new_item_id,
|
| 1033 |
+
'user_id': session['user']['id'],
|
| 1034 |
+
'user_display_name': f"{session['user'].get('first_name','')} {session['user'].get('last_name','')} ({session['user'].get('username','N/A')})".strip(),
|
| 1035 |
+
'published_at': item_data_to_edit.get('published_at', timestamp) if item_id else timestamp,
|
| 1036 |
+
'updated_at': timestamp
|
| 1037 |
+
}
|
| 1038 |
|
| 1039 |
+
if item_type == 'resume':
|
| 1040 |
+
item_details.update({
|
| 1041 |
+
'full_name': request.form.get('full_name'),
|
| 1042 |
+
'title': request.form.get('title'),
|
| 1043 |
+
'skills': [s.strip() for s in request.form.get('skills', '').split(',') if s.strip()],
|
| 1044 |
+
'contact_info': request.form.get('contact_info'),
|
| 1045 |
+
'portfolio_links': [l.strip() for l in request.form.get('portfolio_links', '').split(',') if l.strip()],
|
| 1046 |
+
'photo_filename': uploaded_photo_filename or current_photo_filename
|
| 1047 |
+
})
|
| 1048 |
+
exp_text = request.form.get('experience', '')
|
| 1049 |
+
item_details['experience'] = []
|
| 1050 |
+
for line in exp_text.splitlines():
|
| 1051 |
+
parts = [p.strip() for p in line.split(';')]
|
| 1052 |
+
if len(parts) == 4: item_details['experience'].append({'company': parts[0], 'role': parts[1], 'duration': parts[2], 'description': parts[3]})
|
| 1053 |
+
|
| 1054 |
+
edu_text = request.form.get('education', '')
|
| 1055 |
+
item_details['education'] = []
|
| 1056 |
+
for line in edu_text.splitlines():
|
| 1057 |
+
parts = [p.strip() for p in line.split(';')]
|
| 1058 |
+
if len(parts) == 3: item_details['education'].append({'institution': parts[0], 'degree': parts[1], 'duration': parts[2]})
|
| 1059 |
+
|
| 1060 |
+
elif item_type == 'vacancy':
|
| 1061 |
+
item_details.update({
|
| 1062 |
+
'company_name': request.form.get('company_name'),
|
| 1063 |
+
'job_title': request.form.get('job_title'),
|
| 1064 |
+
'description': request.form.get('description'),
|
| 1065 |
+
'requirements': [r.strip() for r in request.form.get('requirements', '').split(',') if r.strip()],
|
| 1066 |
+
'location': request.form.get('location'),
|
| 1067 |
+
'salary_range': request.form.get('salary_range'),
|
| 1068 |
+
'employment_type': request.form.get('employment_type'),
|
| 1069 |
+
'contact_info': request.form.get('contact_info'),
|
| 1070 |
+
'company_logo_filename': uploaded_photo_filename or current_photo_filename
|
| 1071 |
+
})
|
| 1072 |
+
elif item_type == 'freelance_offer':
|
| 1073 |
+
item_details.update({
|
| 1074 |
+
'title': request.form.get('title'),
|
| 1075 |
+
'description': request.form.get('description'),
|
| 1076 |
+
'skills_required': [s.strip() for s in request.form.get('skills_required', '').split(',') if s.strip()],
|
| 1077 |
+
'budget': request.form.get('budget'),
|
| 1078 |
+
'deadline': request.form.get('deadline'),
|
| 1079 |
+
'contact_info': request.form.get('contact_info')
|
| 1080 |
+
})
|
| 1081 |
+
|
| 1082 |
+
data.setdefault(collection_name, {})[new_item_id] = item_details
|
| 1083 |
+
save_data(data)
|
| 1084 |
+
flash(f"{get_item_type_display_name(item_type)} {'updated' if item_id else 'published'} successfully!", "success")
|
| 1085 |
+
return redirect(url_for('view_item', item_type=item_type, item_id=new_item_id))
|
| 1086 |
+
|
| 1087 |
+
return render_template_string(
|
| 1088 |
+
BASE_TEMPLATE.format(**session.get('themeParams', {})) + PUBLISH_ITEM_TEMPLATE, # Pass empty dict if no themeParams
|
| 1089 |
+
item_type=item_type,
|
| 1090 |
+
item_type_display_name=get_item_type_display_name(item_type),
|
| 1091 |
+
item_data=item_data_to_edit,
|
| 1092 |
+
repo_id=REPO_ID
|
| 1093 |
+
)
|
| 1094 |
+
|
| 1095 |
+
@app.route('/view/<item_type>/<item_id>')
|
| 1096 |
+
def view_item(item_type, item_id):
|
| 1097 |
data = load_data()
|
| 1098 |
+
collection_name = get_item_collection_name(item_type)
|
| 1099 |
+
if not collection_name:
|
| 1100 |
+
flash("Invalid item type.", "error")
|
| 1101 |
+
return redirect(url_for('index'))
|
| 1102 |
+
|
| 1103 |
+
item = data.get(collection_name, {}).get(item_id)
|
| 1104 |
+
if not item:
|
| 1105 |
+
flash("Item not found.", "error")
|
| 1106 |
+
return redirect(url_for('index'))
|
| 1107 |
+
|
| 1108 |
+
return render_template_string(
|
| 1109 |
+
BASE_TEMPLATE.format(**session.get('themeParams', {})) + VIEW_ITEM_TEMPLATE,
|
| 1110 |
+
item_type=item_type,
|
| 1111 |
+
item_id=item_id,
|
| 1112 |
+
item=item,
|
| 1113 |
+
repo_id=REPO_ID
|
| 1114 |
+
)
|
| 1115 |
+
|
| 1116 |
+
@app.route('/delete/<item_type>/<item_id>', methods=['POST'])
|
| 1117 |
+
def delete_item(item_type, item_id):
|
| 1118 |
+
data = load_data()
|
| 1119 |
+
collection_name = get_item_collection_name(item_type)
|
| 1120 |
+
if not collection_name:
|
| 1121 |
+
flash("Invalid item type.", "error")
|
| 1122 |
+
return redirect(url_for('index'))
|
| 1123 |
+
|
| 1124 |
+
item_to_delete = data.get(collection_name, {}).get(item_id)
|
| 1125 |
+
if not item_to_delete or item_to_delete.get('user_id') != session['user']['id']:
|
| 1126 |
+
flash("Item not found or you don't have permission to delete it.", "error")
|
| 1127 |
+
return redirect(url_for('index'))
|
| 1128 |
+
|
| 1129 |
+
photo_filename_key = None
|
| 1130 |
+
if item_type == 'resume': photo_filename_key = 'photo_filename'
|
| 1131 |
+
elif item_type == 'vacancy': photo_filename_key = 'company_logo_filename'
|
| 1132 |
|
| 1133 |
+
old_photo_to_delete = item_to_delete.get(photo_filename_key) if photo_filename_key else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1134 |
|
| 1135 |
+
del data[collection_name][item_id]
|
| 1136 |
save_data(data)
|
| 1137 |
+
|
| 1138 |
+
if old_photo_to_delete and HF_TOKEN_WRITE:
|
| 1139 |
+
try:
|
| 1140 |
+
api = HfApi()
|
| 1141 |
+
api.delete_file(repo_id=REPO_ID, path_in_repo=f"photos/{old_photo_to_delete}", repo_type="dataset", token=HF_TOKEN_WRITE)
|
| 1142 |
+
logging.info(f"Photo {old_photo_to_delete} deleted from HF for deleted {item_type} {item_id}.")
|
| 1143 |
+
except Exception as e:
|
| 1144 |
+
logging.error(f"Error deleting photo {old_photo_to_delete} from HF: {e}", exc_info=True)
|
| 1145 |
+
flash("Item deleted, but failed to delete associated photo from storage.", "warning")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1146 |
|
| 1147 |
+
flash(f"{get_item_type_display_name(item_type)} deleted successfully.", "success")
|
| 1148 |
+
return redirect(url_for('my_postings'))
|
| 1149 |
|
|
|
|
|
|
|
| 1150 |
|
| 1151 |
+
@app.route('/my_postings')
|
| 1152 |
+
def my_postings():
|
| 1153 |
+
data = load_data()
|
| 1154 |
+
user_id = session['user']['id']
|
|
|
|
| 1155 |
|
| 1156 |
+
my_resumes = {k: v for k, v in data.get('resumes', {}).items() if v.get('user_id') == user_id}
|
| 1157 |
+
my_vacancies = {k: v for k, v in data.get('vacancies', {}).items() if v.get('user_id') == user_id}
|
| 1158 |
+
my_freelance_offers = {k: v for k, v in data.get('freelance_offers', {}).items() if v.get('user_id') == user_id}
|
| 1159 |
+
|
| 1160 |
+
return render_template_string(
|
| 1161 |
+
BASE_TEMPLATE.format(**session.get('themeParams', {})) + MY_POSTINGS_TEMPLATE,
|
| 1162 |
+
my_resumes=my_resumes,
|
| 1163 |
+
my_vacancies=my_vacancies,
|
| 1164 |
+
my_freelance_offers=my_freelance_offers,
|
| 1165 |
+
repo_id=REPO_ID
|
| 1166 |
+
)
|
| 1167 |
+
|
| 1168 |
+
@app.route('/admin_sync', methods=['GET'])
|
| 1169 |
+
def admin_sync():
|
| 1170 |
+
# Basic protection: only allow if user is authenticated (any Telegram user)
|
| 1171 |
+
# For real admin, you'd check against a list of admin user IDs
|
| 1172 |
+
if 'user' not in session or not session['user']:
|
| 1173 |
+
flash("You must be authenticated to access the sync panel.", "error")
|
| 1174 |
+
return redirect(url_for('index'))
|
| 1175 |
+
|
| 1176 |
+
data = load_data()
|
| 1177 |
+
data_counts = {
|
| 1178 |
+
"resumes": len(data.get('resumes', {})),
|
| 1179 |
+
"vacancies": len(data.get('vacancies', {})),
|
| 1180 |
+
"freelance_offers": len(data.get('freelance_offers', {}))
|
| 1181 |
+
}
|
| 1182 |
+
return render_template_string(
|
| 1183 |
+
BASE_TEMPLATE.format(**session.get('themeParams', {})) + ADMIN_SYNC_TEMPLATE,
|
| 1184 |
+
data_counts=data_counts
|
| 1185 |
+
)
|
| 1186 |
+
|
| 1187 |
+
@app.route('/force_upload_sync', methods=['POST']) # Renamed route to avoid conflict if old code is around
|
| 1188 |
+
def force_upload():
|
| 1189 |
+
if 'user' not in session or not session['user']: # Basic protection
|
| 1190 |
+
flash("Authentication required.", "error")
|
| 1191 |
+
return redirect(url_for('index'))
|
| 1192 |
+
logging.info("Forcing upload to Hugging Face...")
|
| 1193 |
+
upload_db_to_hf()
|
| 1194 |
+
flash("Data successfully uploaded to Hugging Face.", 'success')
|
| 1195 |
+
return redirect(url_for('admin_sync'))
|
| 1196 |
+
|
| 1197 |
+
@app.route('/force_download_sync', methods=['POST']) # Renamed route
|
| 1198 |
+
def force_download():
|
| 1199 |
+
if 'user' not in session or not session['user']: # Basic protection
|
| 1200 |
+
flash("Authentication required.", "error")
|
| 1201 |
+
return redirect(url_for('index'))
|
| 1202 |
+
logging.info("Forcing download from Hugging Face...")
|
| 1203 |
+
if download_db_from_hf():
|
| 1204 |
+
flash("Data successfully downloaded. Local files updated.", 'success')
|
| 1205 |
+
load_data() # Reload data in memory
|
| 1206 |
+
else:
|
| 1207 |
+
flash("Failed to download data. Check logs.", 'error')
|
| 1208 |
+
return redirect(url_for('admin_sync'))
|
| 1209 |
|
| 1210 |
+
# --- App Initialization ---
|
| 1211 |
if __name__ == '__main__':
|
| 1212 |
+
logging.info("TonTalent Application starting up...")
|
| 1213 |
+
if not os.path.exists(TONTALENT_DATA_FILE):
|
| 1214 |
+
logging.info(f"{TONTALENT_DATA_FILE} not found locally, attempting initial download/creation.")
|
| 1215 |
+
download_db_from_hf() # This will create an empty file if not on HF and not local
|
| 1216 |
|
| 1217 |
+
load_data() # Load initial data
|
| 1218 |
logging.info("Initial data load/check complete.")
|
| 1219 |
|
| 1220 |
if HF_TOKEN_WRITE:
|
|
|
|
| 1224 |
else:
|
| 1225 |
logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
|
| 1226 |
|
| 1227 |
+
port = int(os.environ.get('PORT', 7861)) # Different port from original app
|
| 1228 |
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
|
| 1229 |
app.run(debug=False, host='0.0.0.0', port=port)
|