tontalent / app.py
Shveiauto's picture
Update app.py
090c458 verified
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash
import json
import os
import logging
import threading
import time
from datetime import datetime, timedelta
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
import uuid
import hmac
import hashlib
import urllib.parse
import urllib.request
load_dotenv()
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
DATA_FILE = 'tontalent_data.json'
SYNC_FILES = [DATA_FILE]
REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8")
TON_RECIPIENT_WALLET = os.getenv("TON_RECIPIENT_WALLET", "UQDCf710_mgpep8YCkJgBrdyhs7Tomr_ywq6VrzPCHez4Tvl")
PIN_PRICE_TON = os.getenv("PIN_PRICE_TON", "1.0")
PIN_DURATION_DAYS = 7
DOWNLOAD_RETRIES = 3
DOWNLOAD_DELAY = 5
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
files_to_download = [specific_file] if specific_file else SYNC_FILES
logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
all_successful = True
for file_name in files_to_download:
success = False
for attempt in range(retries + 1):
try:
logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...")
local_path = hf_hub_download(
repo_id=REPO_ID, filename=file_name, repo_type="dataset",
token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
force_download=True, resume_download=False
)
logging.info(f"Successfully downloaded {file_name} to {local_path}.")
success = True
break
except RepositoryNotFoundError:
logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.")
return False
except HfHubHTTPError as e:
if e.response.status_code == 404:
logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
if attempt == 0 and not os.path.exists(file_name):
try:
if file_name == DATA_FILE:
with open(file_name, 'w', encoding='utf-8') as f:
json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
logging.info(f"Created empty local file {file_name} because it was not found on HF.")
except Exception as create_e:
logging.error(f"Failed to create empty local file {file_name}: {create_e}")
success = True
break
else:
logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
except Exception as e:
logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
if attempt < retries: time.sleep(delay)
if not success:
logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
all_successful = False
logging.info(f"Download process finished. Overall success: {all_successful}")
return all_successful
def upload_db_to_hf(specific_file=None):
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
return
try:
api = HfApi()
files_to_upload = [specific_file] if specific_file else SYNC_FILES
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
for file_name in files_to_upload:
if os.path.exists(file_name):
try:
api.upload_file(
path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
repo_type="dataset", token=HF_TOKEN_WRITE,
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
except Exception as e:
logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
else:
logging.warning(f"File {file_name} not found locally, skipping upload.")
logging.info("Finished uploading files to HF.")
except Exception as e:
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
def periodic_backup():
backup_interval = 1800
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
while True:
time.sleep(backup_interval)
logging.info("Starting periodic backup...")
upload_db_to_hf()
logging.info("Periodic backup finished.")
def load_data():
default_data = {'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info(f"Local data loaded successfully from {DATA_FILE}")
if not isinstance(data, dict):
logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
raise FileNotFoundError
for key in default_data:
if key not in data: data[key] = default_data[key]
return data
except (FileNotFoundError, json.JSONDecodeError) as e:
logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
if not isinstance(data, dict):
logging.error(f"Downloaded {DATA_FILE} is not a dictionary. Using default.")
return default_data
for key in default_data:
if key not in data: data[key] = default_data[key]
return data
except Exception as load_e:
logging.error(f"Error loading downloaded {DATA_FILE}: {load_e}. Using default.", exc_info=True)
return default_data
else:
logging.error(f"Failed to download {DATA_FILE} from HF. Using empty default data structure.")
if not os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump(default_data, f)
logging.info(f"Created empty local file {DATA_FILE} after failed download.")
except Exception as create_e:
logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
return default_data
def save_data(data):
try:
if not isinstance(data, dict):
logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
return
default_keys = {'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}
for key in default_keys:
if key not in data: data[key] = default_keys[key]
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
logging.info(f"Data successfully saved to {DATA_FILE}")
upload_db_to_hf(specific_file=DATA_FILE)
except Exception as e:
logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
def verify_telegram_auth_data(auth_data_str, bot_token):
if not auth_data_str:
return False, None
params = dict(urllib.parse.parse_qsl(auth_data_str))
if 'hash' not in params:
return False, None
received_hash = params.pop('hash')
sorted_params = sorted(params.items())
data_check_string_parts = []
for key, value in sorted_params:
data_check_string_parts.append(f"{key}={value}")
data_check_string = "\n".join(data_check_string_parts)
secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest()
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if calculated_hash == received_hash:
try:
user_data = json.loads(params.get('user', '{}'))
return True, user_data
except json.JSONDecodeError:
return False, None
return False, None
MAIN_APP_TEMPLATE = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>TonTalent</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://telegram.org/js/telegram-analytics.js"></script>
<script src="https://unpkg.com/@tonconnect/ui@latest/dist/tonconnect-ui.min.js"></script>
<style>
:root {
--tg-theme-bg-color: #ffffff;
--tg-theme-text-color: #000000;
--tg-theme-hint-color: #999999;
--tg-theme-link-color: #007aff;
--tg-theme-button-color: #007aff;
--tg-theme-button-text-color: #ffffff;
--tg-theme-secondary-bg-color: #f0f0f0;
--tg-theme-header-bg-color: #efeff4;
--tg-theme-section-bg-color: #ffffff;
--tg-theme-section-header-text-color: #8e8e93;
--tg-theme-destructive-text-color: #ff3b30;
--tg-theme-accent-text-color: #007aff;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
background-color: var(--tg-theme-bg-color);
color: var(--tg-theme-text-color);
overscroll-behavior-y: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.4;
}
.app-container { display: flex; flex-direction: column; min-height: 100vh; min-height: -webkit-fill-available; }
.header {
background-color: var(--tg-theme-header-bg-color);
padding: 12px 15px;
text-align: center;
font-weight: 600;
font-size: 17px;
border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
position: sticky;
top: 0;
z-index: 100;
}
.user-info-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 15px;
background-color: var(--tg-theme-section-bg-color);
border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
}
.user-details {
display: flex;
align-items: center;
cursor: pointer;
transition: background-color 0.2s ease;
flex-grow: 1;
min-width: 0;
}
.user-info-bar img {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
object-fit: cover;
background-color: var(--tg-theme-secondary-bg-color);
}
.user-info-bar span {
font-size: 15px;
font-weight: 500;
color: var(--tg-theme-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ton-connect-button-container {
margin-left: 10px;
}
.tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; }
.tab-button {
flex: 1;
padding: 12px 10px;
text-align: center;
cursor: pointer;
background: none;
border: none;
color: var(--tg-theme-hint-color);
font-size: 15px;
font-weight: 500;
border-bottom: 2.5px solid transparent;
transition: color 0.2s ease, border-bottom-color 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }
.content { flex-grow: 1; padding: 0; overflow-x: hidden; transition: opacity 0.2s ease-out; }
.list-item {
background-color: var(--tg-theme-section-bg-color);
padding: 12px 15px;
margin: 10px 15px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
cursor: pointer;
transition: transform 0.1s ease-out, background-color 0.1s ease;
position: relative;
}
.list-item.pinned {
background-color: color-mix(in srgb, var(--tg-theme-accent-text-color) 8%, var(--tg-theme-section-bg-color));
border: 1px solid color-mix(in srgb, var(--tg-theme-accent-text-color) 20%, transparent);
}
.pin-icon {
position: absolute;
top: 10px;
right: 10px;
font-size: 16px;
color: var(--tg-theme-accent-text-color);
}
.list-item:active { transform: scale(0.98); background-color: var(--tg-theme-secondary-bg-color); }
.list-item h3 { margin: 0 0 6px 0; font-size: 17px; font-weight: 600; color: var(--tg-theme-text-color); }
.list-item p { margin: 0 0 4px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
.list-item .meta { font-size: 13px; color: var(--tg-theme-hint-color); margin-top: 8px; }
.form-container, .detail-view { padding: 20px 15px; background-color: var(--tg-theme-section-bg-color); min-height: calc(100vh - 180px); }
.form-group { margin-bottom: 18px; }
.form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 6px; font-weight: 500; }
.form-group input, .form-group textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--tg-theme-secondary-bg-color);
border-radius: 8px;
font-size: 16px;
background-color: var(--tg-theme-bg-color);
color: var(--tg-theme-text-color);
box-sizing: border-box;
transition: border-color 0.2s ease;
}
.form-group input:focus, .form-group textarea:focus { border-color: var(--tg-theme-link-color); outline: none; }
.form-group textarea { min-height: 100px; resize: vertical; }
.fab {
position: fixed;
bottom: 25px;
right: 25px;
width: 56px;
height: 56px;
background-color: var(--tg-theme-button-color);
color: var(--tg-theme-button-text-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
cursor: pointer;
z-index: 1000;
border: none;
transition: transform 0.15s ease-out;
}
.fab:active { transform: scale(0.92); }
.detail-view h2 { margin-top: 0; font-size: 22px; font-weight: 600; color: var(--tg-theme-text-color); margin-bottom: 15px; }
.detail-view p { margin-bottom: 10px; line-height: 1.6; font-size: 16px; }
.detail-view strong { font-weight: 500; color: var(--tg-theme-text-color); }
.detail-view .meta-detail { font-size: 13px; color: var(--tg-theme-hint-color); margin-top: 20px; }
.detail-view a { color: var(--tg-theme-link-color); text-decoration: none; }
.detail-view a:hover { text-decoration: underline; }
.loading, .empty-state { text-align: center; padding: 50px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
.action-button {
display: block;
width: 100%;
padding: 12px 15px;
margin-top: 15px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
text-align: center;
transition: background-color 0.2s ease;
}
.button-primary {
background-color: var(--tg-theme-button-color);
color: var(--tg-theme-button-text-color);
}
.button-primary:active {
background-color: color-mix(in srgb, var(--tg-theme-button-color) 80%, black);
}
.button-destructive {
background-color: var(--tg-theme-destructive-text-color);
color: #ffffff;
}
.button-destructive:active {
background-color: color-mix(in srgb, var(--tg-theme-destructive-text-color) 80%, black);
}
.error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 10px; text-align: center; }
</style>
</head>
<body>
<div class="app-container">
<div class="header" id="appHeader">TonTalent</div>
<div class="user-info-bar">
<div class="user-details" id="userDetails">
<img id="userAvatar" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="Avatar">
<span id="userInfoText">Loading user...</span>
</div>
<div id="tonConnectButton" class="ton-connect-button-container"></div>
</div>
<div class="tabs">
<button class="tab-button active" data-tab="resumes">Resumes</button>
<button class="tab-button" data-tab="vacancies">Vacancies</button>
<button class="tab-button" data-tab="freelance_offers">Freelance</button>
</div>
<div class="content" id="mainContent">
<div class="loading">Loading content...</div>
</div>
<button class="fab" id="fabButton" title="Add New Item">+</button>
</div>
<script>
const tg = window.Telegram.WebApp;
let currentUser = null;
let currentView = 'resumes';
let currentItem = null;
let previousViewBeforeMyPosts = 'resumes';
const mainContent = document.getElementById('mainContent');
const fabButton = document.getElementById('fabButton');
const appHeader = document.getElementById('appHeader');
const tabOrder = ['resumes', 'vacancies', 'freelance_offers'];
const PIN_PRICE_TON = "{{ PIN_PRICE_TON }}";
const PIN_DURATION_DAYS = "{{ PIN_DURATION_DAYS }}";
const tonConnectUI = new TON_CONNECT_UI.TonConnectUI({
manifestUrl: `${window.location.origin}/tonconnect-manifest.json`,
buttonRootId: 'tonConnectButton'
});
function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
function applyThemeParams() {
const rootStyle = document.documentElement.style;
rootStyle.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
rootStyle.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
rootStyle.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
rootStyle.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
rootStyle.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
rootStyle.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff');
rootStyle.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
rootStyle.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
rootStyle.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
rootStyle.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
rootStyle.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
rootStyle.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
}
async function apiCall(endpoint, method = 'GET', body = null) {
const headers = { 'Content-Type': 'application/json' };
if (tg.initData) {
headers['X-Telegram-Auth'] = tg.initData;
}
const options = { method, headers };
if (body) options.body = JSON.stringify(body);
try {
const response = await fetch(endpoint, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Request failed without JSON body' }));
throw new Error(errorData.error || `HTTP error ${response.status}`);
}
return response.json();
} catch (error) {
console.error('API Call Error:', error);
tg.showAlert(error.message || 'An API error occurred.');
throw error;
}
}
function renderList(items, type) {
mainContent.style.opacity = 0;
if (!items || items.length === 0) {
mainContent.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
} else {
mainContent.innerHTML = items.map(item => `
<div class="list-item ${item.pinned_until && new Date(item.pinned_until) > new Date() ? 'pinned' : ''}" onclick="handleItemClick('${type}', '${item.id}')">
${item.pinned_until && new Date(item.pinned_until) > new Date() ? '<div class="pin-icon">📌</div>' : ''}
<h3>${item.title || item.name || 'Untitled'}</h3>
${type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''}
${type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''}
<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
</div>
`).join('');
}
setTimeout(() => { mainContent.style.opacity = 1; }, 50);
}
function handleItemClick(type, id) {
tg.HapticFeedback.impactOccurred('light');
showDetailView(type, id);
}
function formatContactField(contactValue, userTelegramUsername) {
let displayContact = contactValue;
if (!displayContact && userTelegramUsername) {
return `<a href="tg://resolve?domain=${userTelegramUsername}" target="_blank" rel="noopener noreferrer">@${userTelegramUsername}</a>`;
}
if (displayContact) {
if (displayContact.startsWith('@')) {
const username = displayContact.substring(1);
return `<a href="tg://resolve?domain=${username}" target="_blank" rel="noopener noreferrer">${displayContact}</a>`;
} else if (displayContact.startsWith('http://') || displayContact.startsWith('https://')) {
return `<a href="${displayContact}" target="_blank" rel="noopener noreferrer">${displayContact}</a>`;
} else if (displayContact.includes('@') && displayContact.includes('.') && !displayContact.startsWith('http')) {
return `<a href="mailto:${displayContact}">${displayContact}</a>`;
}
}
return displayContact || 'N/A';
}
function showDetailView(type, id) {
mainContent.style.opacity = 0;
tg.BackButton.show();
tg.BackButton.onClick(() => {
tg.HapticFeedback.impactOccurred('light');
if (currentView === 'my_posts') {
showMyPostsView();
} else {
loadView(type);
}
});
tg.MainButton.hide();
fabButton.style.display = 'none';
apiCall(`/api/${type}/${id}`)
.then(item => {
currentItem = item;
let detailsHtml = `<div class="detail-view"><h2>${item.title || item.name}</h2>`;
const itemContact = formatContactField(item.contact, item.user_telegram_username);
const postedByLink = item.user_telegram_username
? `<a href="tg://resolve?domain=${item.user_telegram_username}" target="_blank" rel="noopener noreferrer">@${item.user_telegram_username}</a>`
: 'anonymous';
const isPinned = item.pinned_until && new Date(item.pinned_until) > new Date();
if (type === 'resumes') {
detailsHtml += `<p><strong>Skills:</strong> ${item.skills || 'N/A'}</p><p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Contact:</strong> ${itemContact}</p>${item.portfolio_link ? `<p><strong>Portfolio:</strong> <a href="${item.portfolio_link}" target="_blank" rel="noopener noreferrer">${item.portfolio_link}</a></p>` : ''}`;
} else if (type === 'vacancies') {
detailsHtml += `<p><strong>Company:</strong> ${item.company_name || 'N/A'}</p><p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Requirements:</strong><br>${item.requirements ? item.requirements.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Salary:</strong> ${item.salary || 'N/A'}</p><p><strong>Location:</strong> ${item.location || 'N/A'}</p><p><strong>Contact/Apply:</strong> ${itemContact}</p>`;
} else if (type === 'freelance_offers') {
detailsHtml += `<p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Budget:</strong> ${item.budget || 'N/A'}</p><p><strong>Deadline:</strong> ${item.deadline || 'N/A'}</p><p><strong>Skills Needed:</strong> ${item.skills_needed || 'N/A'}</p><p><strong>Contact:</strong> ${itemContact}</p>`;
}
detailsHtml += `<p class="meta-detail">Posted by: ${postedByLink} on ${new Date(item.timestamp).toLocaleDateString()}</p>`;
if (isPinned) {
detailsHtml += `<p class="meta-detail" style="color: var(--tg-theme-accent-text-color);">📌 Pinned until ${new Date(item.pinned_until).toLocaleDateString()}</p>`;
}
if (currentUser && item.user_id === String(currentUser.id)) {
if (!isPinned) {
detailsHtml += `<button id="pinItemButton" class="action-button button-primary" style="margin-top: 25px;">📌 Pin for ${PIN_PRICE_TON} TON</button>`;
}
detailsHtml += `<button id="editItemButton" class="action-button" style="background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color);">Edit Post</button>`;
detailsHtml += `<button id="deleteItemButton" class="action-button button-destructive">Delete Post</button>`;
}
detailsHtml += `</div>`;
mainContent.innerHTML = detailsHtml;
if (currentUser && item.user_id === String(currentUser.id)) {
document.getElementById('pinItemButton')?.addEventListener('click', () => {
tg.HapticFeedback.impactOccurred('light');
handlePinItem(type, item.id);
});
document.getElementById('editItemButton')?.addEventListener('click', () => {
tg.HapticFeedback.impactOccurred('light');
showForm(type, item);
});
document.getElementById('deleteItemButton')?.addEventListener('click', () => {
tg.HapticFeedback.impactOccurred('light');
handleDeleteItem(type, item.id);
});
}
setTimeout(() => { mainContent.style.opacity = 1; }, 50);
})
.catch(err => {
mainContent.innerHTML = `<div class="empty-state">Error loading details.</div>`;
setTimeout(() => { mainContent.style.opacity = 1; }, 50);
});
}
async function handlePinItem(type, itemId) {
if (!tonConnectUI.connected) {
tg.showAlert('Please connect your TON wallet first.');
return;
}
const nanoTON = (parseFloat(PIN_PRICE_TON) * 1_000_000_000).toString();
const transaction = {
validUntil: Math.floor(Date.now() / 1000) + 600,
messages: [
{
address: "{{ TON_RECIPIENT_WALLET }}",
amount: nanoTON
}
]
};
try {
tg.showPopup({
title: 'Confirm Payment',
message: `You are about to pay ${PIN_PRICE_TON} TON to pin this post for ${PIN_DURATION_DAYS} days.`,
buttons: [ {id: 'ok', type: 'default', text: 'Proceed'}, {type: 'cancel'} ]
}, async (buttonId) => {
if (buttonId === 'ok') {
try {
tg.MainButton.showProgress();
const result = await tonConnectUI.sendTransaction(transaction);
await apiCall(`/api/pin/${type}/${itemId}`, 'POST', { boc: result.boc });
tg.HapticFeedback.notificationOccurred('success');
tg.showAlert('Payment successful! Your post has been pinned.');
showDetailView(type, itemId);
} catch (error) {
tg.HapticFeedback.notificationOccurred('error');
tg.showAlert(error?.message || 'Transaction failed or was rejected.');
console.error(error);
} finally {
tg.MainButton.hideProgress();
}
}
});
} catch (error) {
console.error('Error initiating transaction:', error);
tg.showAlert('Could not initiate transaction.');
}
}
function showForm(type, itemToEdit = null) {
mainContent.style.opacity = 0;
currentItem = itemToEdit;
tg.BackButton.show();
tg.BackButton.onClick(() => {
tg.HapticFeedback.impactOccurred('light');
if (itemToEdit) showDetailView(type, itemToEdit.id);
else if (currentView === 'my_posts') showMyPostsView();
else loadView(type);
});
fabButton.style.display = 'none';
let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1)}</h2>`;
if (type === 'resumes') {
formHtml += `<div class="form-group"><label for="name">Full Name *</label><input type="text" id="name" value="${itemToEdit?.name || ''}" required></div><div class="form-group"><label for="title">Job Title / Desired Position *</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div><div class="form-group"><label for="skills">Skills (comma separated)</label><textarea id="skills">${itemToEdit?.skills || ''}</textarea></div><div class="form-group"><label for="experience">Experience</label><textarea id="experience">${itemToEdit?.experience || ''}</textarea></div><div class="form-group"><label for="education">Education</label><textarea id="education">${itemToEdit?.education || ''}</textarea></div><div class="form-group"><label for="contact">Contact Info (e.g., email, or leave blank to use Telegram)</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div><div class="form-group"><label for="portfolio_link">Portfolio Link (optional)</label><input type="url" id="portfolio_link" value="${itemToEdit?.portfolio_link || ''}"></div>`;
} else if (type === 'vacancies') {
formHtml += `<div class="form-group"><label for="company_name">Company Name *</label><input type="text" id="company_name" value="${itemToEdit?.company_name || ''}" required></div><div class="form-group"><label for="title">Job Title *</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div><div class="form-group"><label for="description">Description</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div><div class="form-group"><label for="requirements">Requirements</label><textarea id="requirements">${itemToEdit?.requirements || ''}</textarea></div><div class="form-group"><label for="salary">Salary/Compensation</label><input type="text" id="salary" value="${itemToEdit?.salary || ''}"></div><div class="form-group"><label for="location">Location (e.g., Remote, City)</label><input type="text" id="location" value="${itemToEdit?.location || ''}"></div><div class="form-group"><label for="contact">Contact Info / How to Apply</label><textarea id="contact">${itemToEdit?.contact || ''}</textarea></div>`;
} else if (type === 'freelance_offers') {
formHtml += `<div class="form-group"><label for="title">Project Title *</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div><div class="form-group"><label for="description">Description of Work</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div><div class="form-group"><label for="budget">Budget</label><input type="text" id="budget" value="${itemToEdit?.budget || ''}"></div><div class="form-group"><label for="deadline">Expected Deadline</label><input type="text" id="deadline" value="${itemToEdit?.deadline || ''}"></div><div class="form-group"><label for="skills_needed">Skills Needed (comma separated)</label><textarea id="skills_needed">${itemToEdit?.skills_needed || ''}</textarea></div><div class="form-group"><label for="contact">Contact Info (or leave blank to use Telegram)</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>`;
}
formHtml += `<div id="formError" class="error-message"></div></div>`;
mainContent.innerHTML = formHtml;
setTimeout(() => { mainContent.style.opacity = 1; }, 50);
tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
tg.MainButton.show();
tg.MainButton.onClick(() => handleSubmit(type, itemToEdit ? itemToEdit.id : null));
}
function handleSubmit(type, itemId = null) {
const payload = {};
let isValid = true;
document.getElementById('formError').textContent = '';
if (type === 'resumes') {
payload.name = document.getElementById('name').value.trim();
payload.title = document.getElementById('title').value.trim();
payload.skills = document.getElementById('skills').value.trim();
payload.experience = document.getElementById('experience').value.trim();
payload.education = document.getElementById('education').value.trim();
payload.contact = document.getElementById('contact').value.trim();
payload.portfolio_link = document.getElementById('portfolio_link').value.trim();
if (!payload.name || !payload.title) isValid = false;
} else if (type === 'vacancies') {
payload.company_name = document.getElementById('company_name').value.trim();
payload.title = document.getElementById('title').value.trim();
payload.description = document.getElementById('description').value.trim();
payload.requirements = document.getElementById('requirements').value.trim();
payload.salary = document.getElementById('salary').value.trim();
payload.location = document.getElementById('location').value.trim();
payload.contact = document.getElementById('contact').value.trim();
if (!payload.company_name || !payload.title) isValid = false;
} else if (type === 'freelance_offers') {
payload.title = document.getElementById('title').value.trim();
payload.description = document.getElementById('description').value.trim();
payload.budget = document.getElementById('budget').value.trim();
payload.deadline = document.getElementById('deadline').value.trim();
payload.skills_needed = document.getElementById('skills_needed').value.trim();
payload.contact = document.getElementById('contact').value.trim();
if (!payload.title) isValid = false;
}
if (!isValid) {
document.getElementById('formError').textContent = 'Please fill in all required fields (*).';
tg.HapticFeedback.notificationOccurred('error');
return;
}
tg.MainButton.showProgress();
tg.HapticFeedback.impactOccurred('light');
const method = itemId ? 'PUT' : 'POST';
const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
apiCall(endpoint, method, payload)
.then(response => {
tg.HapticFeedback.notificationOccurred('success');
tg.MainButton.hideProgress();
tg.MainButton.hide();
if (currentView === 'my_posts') showMyPostsView();
else loadView(type);
})
.catch(err => {
tg.HapticFeedback.notificationOccurred('error');
tg.MainButton.hideProgress();
document.getElementById('formError').textContent = err.message || 'Failed to submit.';
});
}
function handleDeleteItem(type, itemId) {
tg.showConfirm('Are you sure you want to delete this post?', (confirmed) => {
if (confirmed) {
tg.HapticFeedback.impactOccurred('medium');
apiCall(`/api/${type}/${itemId}`, 'DELETE')
.then(() => {
tg.HapticFeedback.notificationOccurred('success');
tg.showAlert('Post deleted successfully.');
if (currentView === 'my_posts') showMyPostsView();
else loadView(type);
})
.catch(err => {
tg.HapticFeedback.notificationOccurred('error');
tg.showAlert(err.message || 'Failed to delete post.');
});
} else {
tg.HapticFeedback.impactOccurred('light');
}
});
}
function loadView(tabName, fromSwipe = false) {
if (!fromSwipe && currentView !== tabName) tg.HapticFeedback.impactOccurred('light');
if (currentView === 'my_posts' && tabName !== 'my_posts') {
appHeader.textContent = 'TonTalent';
}
currentView = tabName;
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelector(`.tab-button[data-tab="${tabName}"]`).classList.add('active');
mainContent.style.opacity = 0;
mainContent.innerHTML = `<div class="loading">Loading ${tabName}...</div>`;
tg.BackButton.hide();
tg.MainButton.hide();
fabButton.style.display = 'block';
apiCall(`/api/${tabName}`)
.then(data => renderList(data, tabName))
.catch(err => {
mainContent.innerHTML = `<div class="empty-state">Error loading ${tabName}.</div>`;
setTimeout(() => { mainContent.style.opacity = 1; }, 50);
});
}
function renderMyPostsList(items) {
mainContent.style.opacity = 0;
if (!items || items.length === 0) {
mainContent.innerHTML = `<div class="empty-state">You haven't posted anything yet.</div>`;
} else {
mainContent.innerHTML = items.map(item => `
<div class="list-item ${item.pinned_until && new Date(item.pinned_until) > new Date() ? 'pinned' : ''}" onclick="handleItemClick('${item.type}', '${item.id}')">
${item.pinned_until && new Date(item.pinned_until) > new Date() ? '<div class="pin-icon">📌</div>' : ''}
<h3>${item.title || item.name || 'Untitled'} <span style="font-weight:normal; font-size: 0.8em; color: var(--tg-theme-hint-color);">(${capitalizeFirstLetter(item.type.slice(0,-1))})</span></h3>
${item.type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''}
${item.type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''}
<p class="meta">Posted on ${new Date(item.timestamp).toLocaleDateString()}${item.updated_timestamp ? ' (Edited: ' + new Date(item.updated_timestamp).toLocaleDateString() + ')' : ''}</p>
</div>
`).join('');
}
setTimeout(() => { mainContent.style.opacity = 1; }, 50);
}
function showMyPostsView() {
tg.HapticFeedback.impactOccurred('light');
if (currentView !== 'my_posts') {
previousViewBeforeMyPosts = currentView;
}
currentView = 'my_posts';
appHeader.textContent = 'My Posts';
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
fabButton.style.display = 'none';
tg.MainButton.hide();
tg.BackButton.show();
tg.BackButton.onClick(() => {
tg.HapticFeedback.impactOccurred('light');
appHeader.textContent = 'TonTalent';
loadView(previousViewBeforeMyPosts);
});
mainContent.style.opacity = 0;
mainContent.innerHTML = '<div class="loading">Loading your posts...</div>';
Promise.all([
apiCall('/api/resumes'),
apiCall('/api/vacancies'),
apiCall('/api/freelance_offers')
]).then(([resumes, vacancies, freelanceOffers]) => {
const myResumes = resumes.filter(item => String(item.user_id) === String(currentUser.id));
const myVacancies = vacancies.filter(item => String(item.user_id) === String(currentUser.id));
const myFreelanceOffers = freelanceOffers.filter(item => String(item.user_id) === String(currentUser.id));
const allMyPosts = [
...myResumes.map(item => ({ ...item, type: 'resumes' })),
...myVacancies.map(item => ({ ...item, type: 'vacancies' })),
...myFreelanceOffers.map(item => ({ ...item, type: 'freelance_offers' }))
].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
const now = new Date();
const pinnedPosts = allMyPosts.filter(p => p.pinned_until && new Date(p.pinned_until) > now);
const regularPosts = allMyPosts.filter(p => !p.pinned_until || new Date(p.pinned_until) <= now);
renderMyPostsList([...pinnedPosts, ...regularPosts]);
}).catch(err => {
mainContent.innerHTML = '<div class="empty-state">Error loading your posts.</div>';
setTimeout(() => { mainContent.style.opacity = 1; }, 50);
});
}
let touchstartX = 0;
let touchendX = 0;
const swipeThreshold = 70;
mainContent.addEventListener('touchstart', e => {
if (currentView === 'my_posts' || document.querySelector('.detail-view') || document.querySelector('.form-container')) return;
touchstartX = e.changedTouches[0].screenX;
}, { passive: true });
mainContent.addEventListener('touchend', e => {
if (currentView === 'my_posts' || document.querySelector('.detail-view') || document.querySelector('.form-container')) return;
touchendX = e.changedTouches[0].screenX;
handleSwipeGesture();
});
function handleSwipeGesture() {
const swipeLength = touchendX - touchstartX;
if (Math.abs(swipeLength) < swipeThreshold) return;
let currentIndex = tabOrder.indexOf(currentView);
if (currentIndex === -1) return;
let newIndex;
if (touchendX < touchstartX) {
newIndex = (currentIndex + 1) % tabOrder.length;
} else {
newIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
}
if (newIndex !== currentIndex) {
tg.HapticFeedback.impactOccurred('light');
loadView(tabOrder[newIndex], true);
}
}
async function init() {
tg.ready();
applyThemeParams();
tg.expand();
tg.enableClosingConfirmation();
tg.onEvent('themeChanged', applyThemeParams);
const userInfoText = document.getElementById('userInfoText');
const userAvatar = document.getElementById('userAvatar');
const userDetails = document.getElementById('userDetails');
userInfoText.textContent = `Welcome, ${tg.initDataUnsafe.user?.first_name || 'User'}!`;
if (tg.initDataUnsafe.user?.username) {
userInfoText.textContent += ` (@${tg.initDataUnsafe.user.username})`;
}
try {
const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
currentUser = authResponse.user;
if (currentUser) {
userInfoText.textContent = `${currentUser.first_name || ''} ${currentUser.last_name || ''}`.trim();
if (currentUser.username) userInfoText.textContent += ` (@${currentUser.username})`;
else userInfoText.textContent += ` (ID: ${currentUser.id})`;
if (currentUser.photo_url) {
userAvatar.src = currentUser.photo_url;
}
userDetails.addEventListener('click', () => {
if(currentUser) showMyPostsView();
});
}
} catch (error) {
console.error("Auth error:", error);
userInfoText.textContent = `Auth failed. Please restart the app.`;
}
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', () => loadView(button.dataset.tab));
});
fabButton.addEventListener('click', () => {
tg.HapticFeedback.impactOccurred('medium');
showForm(currentView);
});
loadView('resumes');
}
init();
</script>
</body>
</html>
'''
ADMIN_TEMPLATE = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TonTalent Admin</title>
<style>
body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
.container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
h1, h2 { color: #333; }
.section { margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9;}
.item { border-bottom: 1px solid #eee; padding: 10px 0; }
.item:last-child { border-bottom: none; }
.item h3 { margin: 0 0 5px 0; }
.item p { margin: 3px 0; font-size: 0.9em; color: #555; }
.item .pinned-info { color: #007bff; font-weight: bold; }
.button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
.button-primary { background-color: #007bff; color: white; }
.button-danger { background-color: #dc3545; color: white; }
.button-secondary { background-color: #6c757d; color: white; }
.message { padding: 10px; margin-bottom: 15px; border-radius: 4px; }
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
.sync-buttons form { display: inline-block; margin-right: 10px; }
</style>
</head>
<body>
<div class="container">
<h1>TonTalent Admin Panel</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="message {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="section">
<h2>Data Synchronization with Hugging Face</h2>
<div class="sync-buttons">
<form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
<button type="submit" class="button button-primary">Upload DB to HF</button>
</form>
<form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
<button type="submit" class="button button-secondary">Download DB from HF</button>
</form>
</div>
<p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
</div>
<div class="section">
<h2>Resumes ({{ resumes|length }})</h2>
{% for resume in resumes %}
<div class="item">
<h3>{{ resume.name }} - {{ resume.title }}</h3>
<p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
<p>Posted: {{ resume.timestamp }}</p>
{% if resume.pinned_until and resume.pinned_until > now_iso %}
<p class="pinned-info">📌 Pinned until: {{ resume.pinned_until }}</p>
{% endif %}
<form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');">
<input type="hidden" name="item_type" value="resumes">
<input type="hidden" name="item_id" value="{{ resume.id }}">
<button type="submit" class="button button-danger">Delete</button>
</form>
</div>
{% else %}
<p>No resumes found.</p>
{% endfor %}
</div>
<div class="section">
<h2>Vacancies ({{ vacancies|length }})</h2>
{% for vacancy in vacancies %}
<div class="item">
<h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
<p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
<p>Posted: {{ vacancy.timestamp }}</p>
{% if vacancy.pinned_until and vacancy.pinned_until > now_iso %}
<p class="pinned-info">📌 Pinned until: {{ vacancy.pinned_until }}</p>
{% endif %}
<form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');">
<input type="hidden" name="item_type" value="vacancies">
<input type="hidden" name="item_id" value="{{ vacancy.id }}">
<button type="submit" class="button button-danger">Delete</button>
</form>
</div>
{% else %}
<p>No vacancies found.</p>
{% endfor %}
</div>
<div class="section">
<h2>Freelance Offers ({{ freelance_offers|length }})</h2>
{% for offer in freelance_offers %}
<div class="item">
<h3>{{ offer.title }}</h3>
<p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
<p>Budget: {{ offer.budget }}</p>
<p>Posted: {{ offer.timestamp }}</p>
{% if offer.pinned_until and offer.pinned_until > now_iso %}
<p class="pinned-info">📌 Pinned until: {{ offer.pinned_until }}</p>
{% endif %}
<form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this freelance offer?');">
<input type="hidden" name="item_type" value="freelance_offers">
<input type="hidden" name="item_id" value="{{ offer.id }}">
<button type="submit" class="button button-danger">Delete</button>
</form>
</div>
{% else %}
<p>No freelance offers found.</p>
{% endfor %}
</div>
</div>
</body>
</html>
'''
@app.route('/')
def main_app_view():
return render_template_string(
MAIN_APP_TEMPLATE,
TON_RECIPIENT_WALLET=TON_RECIPIENT_WALLET,
PIN_PRICE_TON=PIN_PRICE_TON,
PIN_DURATION_DAYS=PIN_DURATION_DAYS
)
@app.route('/tonconnect-manifest.json')
def tonconnect_manifest():
return jsonify({
"url": url_for('main_app_view', _external=True),
"name": "TonTalent",
"iconUrl": "https://i.ibb.co/6yP0wHz/DALLE-2024-05-18-14-38-16-A-sleek-professional-logo-for-Ton-Talent-a-job-search-and-freelanc.png"
})
@app.route('/api/auth_user', methods=['POST'])
def auth_user():
auth_data_str = request.headers.get('X-Telegram-Auth')
if not auth_data_str:
init_data_payload = request.json.get('init_data')
if init_data_payload:
auth_data_str = init_data_payload
else:
return jsonify({"error": "Authentication data not provided"}), 401
is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
if not is_valid or not user_data_from_auth:
return jsonify({"error": "Invalid authentication data"}), 403
data = load_data()
users = data.get('users', {})
user_id_str = str(user_data_from_auth.get('id'))
if user_id_str not in users:
users[user_id_str] = {
'id': user_data_from_auth.get('id'),
'first_name': user_data_from_auth.get('first_name'),
'last_name': user_data_from_auth.get('last_name'),
'username': user_data_from_auth.get('username'),
'language_code': user_data_from_auth.get('language_code'),
'photo_url': user_data_from_auth.get('photo_url'),
'first_seen': datetime.now().isoformat()
}
users[user_id_str]['last_seen'] = datetime.now().isoformat()
if user_data_from_auth.get('photo_url'):
users[user_id_str]['photo_url'] = user_data_from_auth.get('photo_url')
data['users'] = users
save_data(data)
return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
def get_authenticated_user_details(request_headers):
auth_data_str = request_headers.get('X-Telegram-Auth')
if not auth_data_str:
return None
is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
if is_valid and user_data_from_auth:
data = load_data()
user_id_str = str(user_data_from_auth.get('id'))
return data.get('users', {}).get(user_id_str)
return None
@app.route('/api/<item_type>', methods=['GET'])
def get_items(item_type):
if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
return jsonify({"error": "Invalid item type"}), 400
data = load_data()
items = data.get(item_type, [])
now = datetime.now()
pinned_items = sorted(
[i for i in items if i.get('pinned_until') and datetime.fromisoformat(i['pinned_until']) > now],
key=lambda x: x.get('timestamp', ''), reverse=True
)
regular_items = sorted(
[i for i in items if not i.get('pinned_until') or datetime.fromisoformat(i['pinned_until']) <= now],
key=lambda x: x.get('timestamp', ''), reverse=True
)
return jsonify(pinned_items + regular_items), 200
@app.route('/api/<item_type>/<item_id>', methods=['GET'])
def get_item(item_type, item_id):
if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
return jsonify({"error": "Invalid item type"}), 400
data = load_data()
item = next((i for i in data.get(item_type, []) if i['id'] == item_id), None)
if item:
return jsonify(item), 200
return jsonify({"error": "Item not found"}), 404
@app.route('/api/<item_type>', methods=['POST'])
def create_item(item_type):
user = get_authenticated_user_details(request.headers)
if not user:
return jsonify({"error": "Authentication required or user not found in DB"}), 401
if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
return jsonify({"error": "Invalid item type"}), 400
req_data = request.json
if not req_data:
return jsonify({"error": "No data provided"}), 400
new_item = {
"id": str(uuid.uuid4()),
"user_id": str(user.get('id')),
"user_telegram_username": user.get('username', 'unknown'),
"timestamp": datetime.now().isoformat(),
}
if item_type == 'resumes':
required_fields = ['name', 'title']
for field in required_fields:
if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
new_item.update({
"name": req_data.get('name'), "title": req_data.get('title'),
"skills": req_data.get('skills', ''), "experience": req_data.get('experience', ''),
"education": req_data.get('education', ''), "contact": req_data.get('contact', ''),
"portfolio_link": req_data.get('portfolio_link', '')
})
elif item_type == 'vacancies':
required_fields = ['company_name', 'title']
for field in required_fields:
if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
new_item.update({
"company_name": req_data.get('company_name'), "title": req_data.get('title'),
"description": req_data.get('description', ''), "requirements": req_data.get('requirements', ''),
"salary": req_data.get('salary', ''), "location": req_data.get('location', ''),
"contact": req_data.get('contact', '')
})
elif item_type == 'freelance_offers':
required_fields = ['title']
for field in required_fields:
if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
new_item.update({
"title": req_data.get('title'), "description": req_data.get('description', ''),
"budget": req_data.get('budget', ''), "deadline": req_data.get('deadline', ''),
"skills_needed": req_data.get('skills_needed', ''), "contact": req_data.get('contact', '')
})
data = load_data()
data[item_type].append(new_item)
save_data(data)
return jsonify(new_item), 201
@app.route('/api/<item_type>/<item_id>', methods=['PUT'])
def update_item(item_type, item_id):
user = get_authenticated_user_details(request.headers)
if not user: return jsonify({"error": "Authentication required or user not found in DB"}), 401
if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
return jsonify({"error": "Invalid item type"}), 400
req_data = request.json
if not req_data: return jsonify({"error": "No data provided"}), 400
data = load_data()
items_list = data.get(item_type, [])
item_index = -1
for idx, i in enumerate(items_list):
if i['id'] == item_id:
item_index = idx
break
if item_index == -1: return jsonify({"error": "Item not found"}), 404
original_item = items_list[item_index]
if str(original_item.get('user_id')) != str(user.get('id')):
return jsonify({"error": "Forbidden: You can only edit your own items"}), 403
updated_item = original_item.copy()
updated_item['updated_timestamp'] = datetime.now().isoformat()
if item_type == 'resumes':
updated_item.update({
"name": req_data.get('name', original_item.get('name')),
"title": req_data.get('title', original_item.get('title')),
"skills": req_data.get('skills', original_item.get('skills')),
"experience": req_data.get('experience', original_item.get('experience')),
"education": req_data.get('education', original_item.get('education')),
"contact": req_data.get('contact', original_item.get('contact')),
"portfolio_link": req_data.get('portfolio_link', original_item.get('portfolio_link'))
})
elif item_type == 'vacancies':
updated_item.update({
"company_name": req_data.get('company_name', original_item.get('company_name')),
"title": req_data.get('title', original_item.get('title')),
"description": req_data.get('description', original_item.get('description')),
"requirements": req_data.get('requirements', original_item.get('requirements')),
"salary": req_data.get('salary', original_item.get('salary')),
"location": req_data.get('location', original_item.get('location')),
"contact": req_data.get('contact', original_item.get('contact'))
})
elif item_type == 'freelance_offers':
updated_item.update({
"title": req_data.get('title', original_item.get('title')),
"description": req_data.get('description', original_item.get('description')),
"budget": req_data.get('budget', original_item.get('budget')),
"deadline": req_data.get('deadline', original_item.get('deadline')),
"skills_needed": req_data.get('skills_needed', original_item.get('skills_needed')),
"contact": req_data.get('contact', original_item.get('contact'))
})
data[item_type][item_index] = updated_item
save_data(data)
return jsonify(updated_item), 200
@app.route('/api/<item_type>/<item_id>', methods=['DELETE'])
def delete_item(item_type, item_id):
user = get_authenticated_user_details(request.headers)
if not user: return jsonify({"error": "Authentication required or user not found in DB"}), 401
if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
return jsonify({"error": "Invalid item type"}), 400
data = load_data()
items_list = data.get(item_type, [])
original_length = len(items_list)
item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
if not item_to_delete: return jsonify({"error": "Item not found"}), 404
if str(item_to_delete.get('user_id')) != str(user.get('id')):
return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
data[item_type] = [i for i in items_list if i['id'] != item_id]
if len(data[item_type]) < original_length:
save_data(data)
return jsonify({"message": "Item deleted successfully"}), 200
return jsonify({"error": "Item not found or deletion failed"}), 404
@app.route('/api/pin/<item_type>/<item_id>', methods=['POST'])
def pin_item(item_type, item_id):
user = get_authenticated_user_details(request.headers)
if not user: return jsonify({"error": "Authentication required"}), 401
if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
return jsonify({"error": "Invalid item type"}), 400
req_data = request.json
if not req_data or 'boc' not in req_data:
return jsonify({"error": "Transaction data (boc) not provided"}), 400
data = load_data()
items_list = data.get(item_type, [])
item_index = -1
for idx, i in enumerate(items_list):
if i['id'] == item_id:
item_index = idx
break
if item_index == -1: return jsonify({"error": "Item not found"}), 404
item = items_list[item_index]
if str(item.get('user_id')) != str(user.get('id')):
return jsonify({"error": "Forbidden: You can only pin your own items"}), 403
# Basic server-side validation is needed here.
# A full validation requires a TON library to parse the BoC, which is complex.
# A simpler, but less secure method, relies on trusting the client or using a public API.
# For now, we will assume the client-side interaction is sufficient for this implementation.
# In a production app, you MUST implement robust server-side validation of the transaction.
item['pinned_until'] = (datetime.now() + timedelta(days=PIN_DURATION_DAYS)).isoformat()
data[item_type][item_index] = item
save_data(data)
return jsonify({"message": "Item pinned successfully", "pinned_until": item['pinned_until']}), 200
@app.route('/admin', methods=['GET'])
def admin_panel():
data = load_data()
return render_template_string(ADMIN_TEMPLATE,
now_iso=datetime.now().isoformat(),
resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True),
vacancies=sorted(data.get('vacancies', []), key=lambda x: x.get('timestamp', ''), reverse=True),
freelance_offers=sorted(data.get('freelance_offers', []), key=lambda x: x.get('timestamp', ''), reverse=True))
@app.route('/admin/delete', methods=['POST'])
def admin_delete_item():
item_type = request.form.get('item_type')
item_id = request.form.get('item_id')
if not item_type or not item_id or item_type not in ['resumes', 'vacancies', 'freelance_offers']:
flash('Invalid item type or ID for deletion.', 'error')
return redirect(url_for('admin_panel'))
data = load_data()
items_list = data.get(item_type, [])
original_length = len(items_list)
data[item_type] = [i for i in items_list if i['id'] != item_id]
if len(data[item_type]) < original_length:
save_data(data)
flash(f'{item_type.capitalize()[:-1]} deleted successfully.', 'success')
else:
flash('Item not found or already deleted.', 'warning')
return redirect(url_for('admin_panel'))
@app.route('/admin/force_upload', methods=['POST'])
def force_upload_admin():
logging.info("Admin forcing upload to Hugging Face...")
try:
upload_db_to_hf()
flash("Data successfully uploaded to Hugging Face.", 'success')
except Exception as e:
logging.error(f"Error during forced upload: {e}", exc_info=True)
flash(f"Error uploading to Hugging Face: {e}", 'error')
return redirect(url_for('admin_panel'))
@app.route('/admin/force_download', methods=['POST'])
def force_download_admin():
logging.info("Admin forcing download from Hugging Face...")
try:
if download_db_from_hf():
flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success')
load_data()
else:
flash("Failed to download data from Hugging Face. Check logs.", 'error')
except Exception as e:
logging.error(f"Error during forced download: {e}", exc_info=True)
flash(f"Error downloading from Hugging Face: {e}", 'error')
return redirect(url_for('admin_panel'))
if __name__ == '__main__':
logging.info("Application starting up. Performing initial data load/download...")
download_db_from_hf()
load_data()
logging.info("Initial data load complete.")
if HF_TOKEN_WRITE:
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
logging.info("Periodic backup thread started.")
else:
logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
port = int(os.environ.get('PORT', 7860))
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
app.run(debug=False, host='0.0.0.0', port=port)