diff --git a/server.py b/server.py index e20a35f..96aebb6 100644 --- a/server.py +++ b/server.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, ConfigDict from typing import Optional, List from datetime import datetime, timedelta, timezone from jose import jwt @@ -318,9 +318,10 @@ class TokenResponse(BaseModel): class Tag(BaseModel): - id: Optional[int] = None + model_config = ConfigDict(from_attributes=True) + id: int name: str - category: str = "skill" + category: Optional[str] = None class VacancyCreate(BaseModel): @@ -383,7 +384,7 @@ class ResumeResponse(BaseModel): desired_salary: Optional[str] work_experience: List[WorkExperience] education: List[Education] - tags: List[Tag] = [] + tags: List[Tag] views: int updated_at: str full_name: Optional[str] = None @@ -1426,99 +1427,121 @@ async def update_vacancy( vacancy_update: VacancyUpdate, user_id: int = Depends(get_current_user) ): - """Обновление существующей вакансии""" - with get_db() as conn: - cursor = conn.cursor() + """Обновление существующей вакансии (для админа и владельца)""" + try: + print(f"📝 Обновление вакансии {vacancy_id} пользователем {user_id}") - # Проверяем, что вакансия принадлежит пользователю - cursor.execute(""" - SELECT v.*, u.role - FROM vacancies v - JOIN users u ON v.user_id = u.id - WHERE v.id = ? AND v.user_id = ? - """, (vacancy_id, user_id)) + with get_db() as conn: + cursor = conn.cursor() - vacancy = cursor.fetchone() - if not vacancy: - raise HTTPException(status_code=404, detail="Вакансия не найдена") + # Проверяем существование вакансии + cursor.execute("SELECT * FROM vacancies WHERE id = ?", (vacancy_id,)) + vacancy = cursor.fetchone() - # Проверяем роль (только работодатель может редактировать) - if vacancy["role"] != "employer": - raise HTTPException(status_code=403, detail="Только работодатели могут редактировать вакансии") + if not vacancy: + raise HTTPException(status_code=404, detail="Вакансия не найдена") - # Обновляем основные поля - update_fields = [] - params = [] + # Проверяем права (админ или владелец) + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + current_user = cursor.fetchone() + is_admin = current_user and current_user["is_admin"] == 1 - if vacancy_update.title is not None: - update_fields.append("title = ?") - params.append(vacancy_update.title) + # Если не админ и не владелец - запрещаем + if not is_admin and vacancy["user_id"] != user_id: + print( + f"❌ Доступ запрещен: пользователь {user_id} не является админом или владельцем вакансии {vacancy_id}") + raise HTTPException(status_code=403, detail="Нет прав на редактирование") - if vacancy_update.salary is not None: - update_fields.append("salary = ?") - params.append(vacancy_update.salary) + print(f"✅ Права проверены: is_admin={is_admin}, is_owner={vacancy['user_id'] == user_id}") - if vacancy_update.description is not None: - update_fields.append("description = ?") - params.append(vacancy_update.description) + # Обновляем основные поля + update_fields = [] + params = [] - if vacancy_update.contact is not None: - update_fields.append("contact = ?") - params.append(vacancy_update.contact) + if vacancy_update.title is not None: + update_fields.append("title = ?") + params.append(vacancy_update.title) - if vacancy_update.is_active is not None: - update_fields.append("is_active = ?") - params.append(1 if vacancy_update.is_active else 0) + if vacancy_update.salary is not None: + update_fields.append("salary = ?") + params.append(vacancy_update.salary) - if update_fields: - query = f"UPDATE vacancies SET {', '.join(update_fields)} WHERE id = ?" - params.append(vacancy_id) - cursor.execute(query, params) + if vacancy_update.description is not None: + update_fields.append("description = ?") + params.append(vacancy_update.description) - # Обновляем теги - if vacancy_update.tags is not None: - # Удаляем старые теги - cursor.execute("DELETE FROM vacancy_tags WHERE vacancy_id = ?", (vacancy_id,)) + if vacancy_update.contact is not None: + update_fields.append("contact = ?") + params.append(vacancy_update.contact) - # Добавляем новые теги - for tag_name in vacancy_update.tags: - # Ищем или создаем тег - cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) - tag = cursor.fetchone() + if vacancy_update.is_active is not None: + update_fields.append("is_active = ?") + params.append(1 if vacancy_update.is_active else 0) - if tag: - tag_id = tag["id"] - else: - cursor.execute("INSERT INTO tags (name) VALUES (?)", (tag_name,)) - tag_id = cursor.lastrowid + if update_fields: + query = f"UPDATE vacancies SET {', '.join(update_fields)} WHERE id = ?" + params.append(vacancy_id) + cursor.execute(query, params) + print(f"✅ Основные поля вакансии {vacancy_id} обновлены") - cursor.execute(""" - INSERT INTO vacancy_tags (vacancy_id, tag_id) - VALUES (?, ?) - """, (vacancy_id, tag_id)) + # Обновляем теги + if vacancy_update.tags is not None: + # Удаляем старые теги + cursor.execute("DELETE FROM vacancy_tags WHERE vacancy_id = ?", (vacancy_id,)) - conn.commit() + # Добавляем новые теги + for tag_name in vacancy_update.tags: + if tag_name and tag_name.strip(): + tag_name = tag_name.strip() + # Ищем или создаем тег + cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) + tag = cursor.fetchone() - # Возвращаем обновленную вакансию - cursor.execute(""" - SELECT v.*, COALESCE(c.name, u.full_name) as company_name - FROM vacancies v - JOIN users u ON v.user_id = u.id - LEFT JOIN companies c ON v.user_id = c.user_id - WHERE v.id = ? - """, (vacancy_id,)) + if tag: + tag_id = tag["id"] + else: + cursor.execute("INSERT INTO tags (name) VALUES (?)", (tag_name,)) + tag_id = cursor.lastrowid - updated_vacancy = dict(cursor.fetchone()) + cursor.execute(""" + INSERT INTO vacancy_tags (vacancy_id, tag_id) + VALUES (?, ?) + """, (vacancy_id, tag_id)) - # Добавляем теги - cursor.execute(""" - SELECT t.* FROM tags t - JOIN vacancy_tags vt ON t.id = vt.tag_id - WHERE vt.vacancy_id = ? - """, (vacancy_id,)) - updated_vacancy["tags"] = [dict(tag) for tag in cursor.fetchall()] + print(f"✅ Теги вакансии {vacancy_id} обновлены") - return updated_vacancy + conn.commit() + + # Возвращаем обновленную вакансию + cursor.execute(""" + SELECT v.*, + COALESCE(c.name, u.full_name) as company_name, + u.full_name as user_name + FROM vacancies v + JOIN users u ON v.user_id = u.id + LEFT JOIN companies c ON v.user_id = c.user_id + WHERE v.id = ? + """, (vacancy_id,)) + + updated_vacancy = dict(cursor.fetchone()) + + # Добавляем теги + cursor.execute(""" + SELECT t.name FROM tags t + JOIN vacancy_tags vt ON t.id = vt.tag_id + WHERE vt.vacancy_id = ? + """, (vacancy_id,)) + updated_vacancy["tags"] = [tag["name"] for tag in cursor.fetchall()] + + print(f"✅ Вакансия {vacancy_id} успешно обновлена") + return updated_vacancy + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при обновлении вакансии {vacancy_id}: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") @app.post("/api/vacancies/{vacancy_id}/apply") @@ -1558,102 +1581,136 @@ async def create_or_update_resume( resume: ResumeCreate, user_id: int = Depends(get_current_user) ): - """Создание или обновление резюме""" - with get_db() as conn: - cursor = conn.cursor() + """Создание или обновление резюме (для админа и владельца)""" + try: + print(f"📝 Сохранение резюме для пользователя {user_id}") - # Проверка роли - cursor.execute("SELECT role FROM users WHERE id = ?", (user_id,)) - user = cursor.fetchone() - if not user or user["role"] != "employee": - raise HTTPException(status_code=403, detail="Только соискатели могут создавать резюме") + with get_db() as conn: + cursor = conn.cursor() - # Проверка существования резюме - cursor.execute("SELECT id FROM resumes WHERE user_id = ?", (user_id,)) - existing = cursor.fetchone() + # Получаем информацию о текущем пользователе + cursor.execute("SELECT role, is_admin FROM users WHERE id = ?", (user_id,)) + current_user = cursor.fetchone() - if existing: - # Обновление существующего резюме - cursor.execute(""" - UPDATE resumes - SET desired_position = ?, about_me = ?, desired_salary = ?, updated_at = CURRENT_TIMESTAMP - WHERE user_id = ? - """, (resume.desired_position, resume.about_me, resume.desired_salary, user_id)) + if not current_user: + raise HTTPException(status_code=404, detail="Пользователь не найден") - resume_id = existing["id"] + is_admin = current_user["is_admin"] == 1 + is_employee = current_user["role"] == "employee" - # Удаление старых записей - cursor.execute("DELETE FROM work_experience WHERE resume_id = ?", (resume_id,)) - cursor.execute("DELETE FROM education WHERE resume_id = ?", (resume_id,)) - cursor.execute("DELETE FROM resume_tags WHERE resume_id = ?", (resume_id,)) - else: - # Создание нового резюме - cursor.execute(""" - INSERT INTO resumes (user_id, desired_position, about_me, desired_salary) - VALUES (?, ?, ?, ?) - """, (user_id, resume.desired_position, resume.about_me, resume.desired_salary)) + # Если пользователь не админ и не соискатель + if not is_admin and not is_employee: + raise HTTPException(status_code=403, detail="Только соискатели и администраторы могут создавать резюме") - resume_id = cursor.lastrowid - - # Добавление опыта работы (ОБНОВЛЕНО - добавили description) - for exp in resume.work_experience: - cursor.execute(""" - INSERT INTO work_experience (resume_id, position, company, period, description) - VALUES (?, ?, ?, ?, ?) - """, (resume_id, exp.position, exp.company, exp.period, exp.description)) - - # Добавление образования - for edu in resume.education: - cursor.execute(""" - INSERT INTO education (resume_id, institution, specialty, graduation_year) - VALUES (?, ?, ?, ?) - """, (resume_id, edu.institution, edu.specialty, edu.graduation_year)) - - # Добавление тегов - if resume.tags: - for tag_name in resume.tags: - cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) - tag = cursor.fetchone() - - if tag: - tag_id = tag["id"] - else: - cursor.execute("INSERT INTO tags (name) VALUES (?)", (tag_name,)) - tag_id = cursor.lastrowid + # Проверяем существование резюме + cursor.execute("SELECT id FROM resumes WHERE user_id = ?", (user_id,)) + existing = cursor.fetchone() + if existing: + # Обновление существующего резюме cursor.execute(""" - INSERT OR IGNORE INTO resume_tags (resume_id, tag_id) - VALUES (?, ?) - """, (resume_id, tag_id)) + UPDATE resumes + SET desired_position = ?, about_me = ?, desired_salary = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + """, (resume.desired_position, resume.about_me, resume.desired_salary, user_id)) - conn.commit() + resume_id = existing["id"] - # Получение обновленного резюме - cursor.execute("SELECT * FROM resumes WHERE id = ?", (resume_id,)) - resume_data = dict(cursor.fetchone()) + # Удаление старых записей + cursor.execute("DELETE FROM work_experience WHERE resume_id = ?", (resume_id,)) + cursor.execute("DELETE FROM education WHERE resume_id = ?", (resume_id,)) + cursor.execute("DELETE FROM resume_tags WHERE resume_id = ?", (resume_id,)) - cursor.execute(""" - SELECT position, company, period, description - FROM work_experience - WHERE resume_id = ? - """, (resume_id,)) - resume_data["work_experience"] = [dict(exp) for exp in cursor.fetchall()] + print(f"✅ Обновлено существующее резюме ID {resume_id} для пользователя {user_id}") + else: + # Создание нового резюме + cursor.execute(""" + INSERT INTO resumes (user_id, desired_position, about_me, desired_salary) + VALUES (?, ?, ?, ?) + """, (user_id, resume.desired_position, resume.about_me, resume.desired_salary)) - cursor.execute(""" - SELECT institution, specialty, graduation_year - FROM education - WHERE resume_id = ? - """, (resume_id,)) - resume_data["education"] = [dict(edu) for edu in cursor.fetchall()] + resume_id = cursor.lastrowid + print(f"✅ Создано новое резюме ID {resume_id} для пользователя {user_id}") - cursor.execute(""" - SELECT t.* FROM tags t - JOIN resume_tags rt ON t.id = rt.tag_id - WHERE rt.resume_id = ? - """, (resume_id,)) - resume_data["tags"] = [dict(tag) for tag in cursor.fetchall()] + # Добавление опыта работы + for exp in resume.work_experience: + cursor.execute(""" + INSERT INTO work_experience (resume_id, position, company, period, description) + VALUES (?, ?, ?, ?, ?) + """, (resume_id, exp.position, exp.company, exp.period, exp.description)) - return resume_data + # Добавление образования + for edu in resume.education: + cursor.execute(""" + INSERT INTO education (resume_id, institution, specialty, graduation_year) + VALUES (?, ?, ?, ?) + """, (resume_id, edu.institution, edu.specialty, edu.graduation_year)) + + # Добавление тегов + if resume.tags: + for tag_name in resume.tags: + if tag_name and tag_name.strip(): + tag_name = tag_name.strip() + # Ищем или создаем тег + cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) + tag = cursor.fetchone() + + if tag: + tag_id = tag["id"] + else: + cursor.execute("INSERT INTO tags (name) VALUES (?)", (tag_name,)) + tag_id = cursor.lastrowid + + cursor.execute(""" + INSERT OR IGNORE INTO resume_tags (resume_id, tag_id) + VALUES (?, ?) + """, (resume_id, tag_id)) + + conn.commit() + + # Получение обновленного резюме - ВАЖНО: возвращаем теги в правильном формате + cursor.execute("SELECT * FROM resumes WHERE id = ?", (resume_id,)) + resume_data = dict(cursor.fetchone()) + + # Получаем имя пользователя + cursor.execute("SELECT full_name FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + resume_data["full_name"] = user["full_name"] if user else None + + cursor.execute(""" + SELECT position, company, period, description + FROM work_experience + WHERE resume_id = ? + ORDER BY period DESC + """, (resume_id,)) + resume_data["work_experience"] = [dict(exp) for exp in cursor.fetchall()] + + cursor.execute(""" + SELECT institution, specialty, graduation_year + FROM education + WHERE resume_id = ? + ORDER BY graduation_year DESC + """, (resume_id,)) + resume_data["education"] = [dict(edu) for edu in cursor.fetchall()] + + # ВАЖНО: теги возвращаем в формате списка объектов с полями id, name, category + cursor.execute(""" + SELECT t.id, t.name, t.category FROM tags t + JOIN resume_tags rt ON t.id = rt.tag_id + WHERE rt.resume_id = ? + """, (resume_id,)) + resume_data["tags"] = [dict(tag) for tag in cursor.fetchall()] + + print(f"✅ Резюме {resume_id} успешно сохранено") + print(f"📌 Теги в ответе: {resume_data['tags']}") + return resume_data + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при сохранении резюме: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") @app.get("/api/resume", response_model=ResumeResponse) @@ -1662,6 +1719,14 @@ async def get_my_resume(user_id: int = Depends(get_current_user)): with get_db() as conn: cursor = conn.cursor() + # Получаем информацию о пользователе + cursor.execute("SELECT role, is_admin FROM users WHERE id = ?", (user_id,)) + current_user = cursor.fetchone() + + if not current_user: + raise HTTPException(status_code=404, detail="Пользователь не найден") + + # Если админ, он может видеть свое резюме, если оно есть cursor.execute("SELECT * FROM resumes WHERE user_id = ?", (user_id,)) resume = cursor.fetchone() @@ -1670,21 +1735,14 @@ async def get_my_resume(user_id: int = Depends(get_current_user)): resume_data = dict(resume) - # Получаем опыт работы (ОБНОВЛЕНО - добавили поле description) cursor.execute(""" SELECT position, company, period, description FROM work_experience WHERE resume_id = ? - ORDER BY - CASE - WHEN period IS NULL THEN 1 - ELSE 0 - END, - period DESC + ORDER BY period DESC """, (resume["id"],)) resume_data["work_experience"] = [dict(exp) for exp in cursor.fetchall()] - # Получаем образование cursor.execute(""" SELECT institution, specialty, graduation_year FROM education @@ -1693,13 +1751,12 @@ async def get_my_resume(user_id: int = Depends(get_current_user)): """, (resume["id"],)) resume_data["education"] = [dict(edu) for edu in cursor.fetchall()] - # Получаем теги cursor.execute(""" - SELECT t.* FROM tags t + SELECT t.name FROM tags t JOIN resume_tags rt ON t.id = rt.tag_id WHERE rt.resume_id = ? """, (resume["id"],)) - resume_data["tags"] = [dict(tag) for tag in cursor.fetchall()] + resume_data["tags"] = [tag["name"] for tag in cursor.fetchall()] return resume_data @@ -1780,9 +1837,9 @@ async def get_all_resumes_admin( limit: int = 10, search: str = None ): - """Получение всех резюме для админки с пагинацией и поиском""" + """Получение всех резюме для админки""" try: - print(f"👑 Админ запрашивает резюме: страница {page}, поиск '{search}'") + print(f"👑 Админ {user_id} запрашивает список резюме") with get_db() as conn: cursor = conn.cursor() @@ -1791,19 +1848,9 @@ async def get_all_resumes_admin( cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() if not user or not user["is_admin"]: - print(f"❌ Доступ запрещен для пользователя {user_id}") raise HTTPException(status_code=403, detail="Доступ запрещен") - # Базовый запрос для подсчета общего количества - count_query = """ - SELECT COUNT(*) - FROM resumes r - JOIN users u ON r.user_id = u.id - WHERE 1=1 - """ - count_params = [] - - # Базовый запрос для получения данных + # Базовый запрос query = """ SELECT r.id, @@ -1823,21 +1870,11 @@ async def get_all_resumes_admin( """ params = [] - # Добавляем поиск, если есть + # Поиск if search: + query += " AND (u.full_name LIKE ? OR u.email LIKE ? OR r.desired_position LIKE ?)" search_term = f"%{search}%" - search_condition = """ AND ( - u.full_name LIKE ? OR - u.email LIKE ? OR - r.desired_position LIKE ? OR - r.about_me LIKE ? - )""" - query += search_condition - count_query += search_condition - - search_params = [search_term, search_term, search_term, search_term] - params.extend(search_params) - count_params.extend(search_params) + params.extend([search_term, search_term, search_term]) # Сортировка query += " ORDER BY r.updated_at DESC" @@ -1847,38 +1884,36 @@ async def get_all_resumes_admin( query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) - # Получаем общее количество - cursor.execute(count_query, count_params) - total = cursor.fetchone()[0] - - # Получаем резюме cursor.execute(query, params) resumes = cursor.fetchall() + # Получаем общее количество + count_query = "SELECT COUNT(*) FROM resumes" + if search: + count_query += " WHERE user_id IN (SELECT id FROM users WHERE full_name LIKE ? OR email LIKE ?)" + cursor.execute(count_query, [search_term, search_term]) + else: + cursor.execute(count_query) + total = cursor.fetchone()[0] + result = [] for r in resumes: resume_dict = dict(r) - # Получаем теги для резюме + # Получаем теги cursor.execute(""" - SELECT t.name, t.category - FROM tags t + SELECT t.name FROM tags t JOIN resume_tags rt ON t.id = rt.tag_id WHERE rt.resume_id = ? """, (resume_dict["id"],)) - resume_dict["tags"] = [dict(tag) for tag in cursor.fetchall()] + resume_dict["tags"] = [tag["name"] for tag in cursor.fetchall()] # Получаем опыт работы cursor.execute(""" SELECT position, company, period, description FROM work_experience WHERE resume_id = ? - ORDER BY - CASE - WHEN period IS NULL THEN 1 - ELSE 0 - END, - period DESC + ORDER BY period DESC """, (resume_dict["id"],)) resume_dict["work_experience"] = [dict(exp) for exp in cursor.fetchall()] @@ -1891,9 +1926,6 @@ async def get_all_resumes_admin( """, (resume_dict["id"],)) resume_dict["education"] = [dict(edu) for edu in cursor.fetchall()] - # Количество опыта для быстрого отображения - resume_dict["experience_count"] = len(resume_dict["work_experience"]) - result.append(resume_dict) print(f"✅ Загружено {len(result)} резюме из {total}") @@ -1919,27 +1951,22 @@ async def get_resume_admin( resume_id: int, user_id: int = Depends(get_current_user) ): - """Получение детальной информации о резюме для админки""" + """Получение резюме для админки (с полными данными)""" try: - print(f"👑 Админ {user_id} запрашивает детали резюме {resume_id}") + print(f"👑 Админ {user_id} запрашивает резюме {resume_id}") with get_db() as conn: cursor = conn.cursor() # Проверка прав администратора cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) - user = cursor.fetchone() - if not user or not user["is_admin"]: + admin = cursor.fetchone() + if not admin or not admin["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен") - # Получаем основную информацию о резюме + # Получаем резюме cursor.execute(""" - SELECT - r.*, - u.full_name, - u.email, - u.phone, - u.telegram + SELECT r.*, u.full_name, u.email, u.phone, u.telegram FROM resumes r JOIN users u ON r.user_id = u.id WHERE r.id = ? @@ -1949,43 +1976,40 @@ async def get_resume_admin( if not resume: raise HTTPException(status_code=404, detail="Резюме не найдено") - resume_dict = dict(resume) - - # Получаем теги - cursor.execute(""" - SELECT t.* FROM tags t - JOIN resume_tags rt ON t.id = rt.tag_id - WHERE rt.resume_id = ? - """, (resume_id,)) - resume_dict["tags"] = [dict(tag) for tag in cursor.fetchall()] + resume_data = dict(resume) # Получаем опыт работы cursor.execute(""" - SELECT * FROM work_experience + SELECT position, company, period, description + FROM work_experience WHERE resume_id = ? - ORDER BY - CASE - WHEN period IS NULL THEN 1 - ELSE 0 - END, - period DESC + ORDER BY period DESC """, (resume_id,)) - resume_dict["work_experience"] = [dict(exp) for exp in cursor.fetchall()] + resume_data["work_experience"] = [dict(exp) for exp in cursor.fetchall()] # Получаем образование cursor.execute(""" - SELECT * FROM education + SELECT institution, specialty, graduation_year + FROM education WHERE resume_id = ? ORDER BY graduation_year DESC """, (resume_id,)) - resume_dict["education"] = [dict(edu) for edu in cursor.fetchall()] + resume_data["education"] = [dict(edu) for edu in cursor.fetchall()] - return resume_dict + # Получаем теги + cursor.execute(""" + SELECT t.id, t.name, t.category FROM tags t + JOIN resume_tags rt ON t.id = rt.tag_id + WHERE rt.resume_id = ? + """, (resume_id,)) + resume_data["tags"] = [dict(tag) for tag in cursor.fetchall()] + + return resume_data except HTTPException: raise except Exception as e: - print(f"❌ Ошибка при загрузке деталей резюме {resume_id}: {e}") + print(f"❌ Ошибка при загрузке резюме {resume_id}: {e}") traceback.print_exc() raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") @@ -2006,16 +2030,14 @@ async def delete_resume_admin( cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() if not user or not user["is_admin"]: - print(f"❌ Доступ запрещен для пользователя {user_id}") raise HTTPException(status_code=403, detail="Доступ запрещен") # Проверяем существование резюме cursor.execute("SELECT id FROM resumes WHERE id = ?", (resume_id,)) - resume = cursor.fetchone() - if not resume: + if not cursor.fetchone(): raise HTTPException(status_code=404, detail="Резюме не найдено") - # Удаляем связанные записи (каскадно должно работать, но на всякий случай) + # Удаляем связанные записи cursor.execute("DELETE FROM resume_tags WHERE resume_id = ?", (resume_id,)) cursor.execute("DELETE FROM work_experience WHERE resume_id = ?", (resume_id,)) cursor.execute("DELETE FROM education WHERE resume_id = ?", (resume_id,)) @@ -2042,7 +2064,6 @@ async def get_resume(resume_id: int): with get_db() as conn: cursor = conn.cursor() - # Увеличиваем счетчик просмотров cursor.execute("UPDATE resumes SET views = views + 1 WHERE id = ?", (resume_id,)) cursor.execute(""" @@ -2058,17 +2079,12 @@ async def get_resume(resume_id: int): resume_data = dict(resume) - # Получаем опыт работы (ОБНОВЛЕНО - добавили поле description) + # Получаем опыт работы cursor.execute(""" SELECT position, company, period, description FROM work_experience WHERE resume_id = ? - ORDER BY - CASE - WHEN period IS NULL THEN 1 - ELSE 0 - END, - period DESC + ORDER BY period DESC """, (resume_id,)) resume_data["work_experience"] = [dict(exp) for exp in cursor.fetchall()] @@ -2081,13 +2097,13 @@ async def get_resume(resume_id: int): """, (resume_id,)) resume_data["education"] = [dict(edu) for edu in cursor.fetchall()] - # Получаем теги + # Получаем теги - ВАЖНО: возвращаем массив строк cursor.execute(""" - SELECT t.* FROM tags t + SELECT t.name FROM tags t JOIN resume_tags rt ON t.id = rt.tag_id WHERE rt.resume_id = ? """, (resume_id,)) - resume_data["tags"] = [dict(tag) for tag in cursor.fetchall()] + resume_data["tags"] = [tag["name"] for tag in cursor.fetchall()] conn.commit() return resume_data @@ -3201,19 +3217,169 @@ async def get_all_vacancies_admin(user_id: int = Depends(get_current_user)): @app.delete("/api/admin/vacancies/{vacancy_id}") -async def delete_vacancy_admin(vacancy_id: int, user_id: int = Depends(get_current_user)): - """Удаление вакансии (для админа)""" - with get_db() as conn: - cursor = conn.cursor() +async def delete_vacancy_admin( + vacancy_id: int, + user_id: int = Depends(get_current_user) +): + """Удаление вакансии (только для админа)""" + try: + print(f"👑 Админ {user_id} пытается удалить вакансию {vacancy_id}") - cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) - user = cursor.fetchone() - if not user or not user["is_admin"]: - raise HTTPException(status_code=403, detail="Доступ запрещен") + with get_db() as conn: + cursor = conn.cursor() - cursor.execute("DELETE FROM vacancies WHERE id = ?", (vacancy_id,)) - conn.commit() - return {"message": "Вакансия удалена"} + # Проверка прав администратора + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + user = cursor.fetchone() + if not user or not user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен") + + # Проверяем существование вакансии + cursor.execute("SELECT id FROM vacancies WHERE id = ?", (vacancy_id,)) + if not cursor.fetchone(): + raise HTTPException(status_code=404, detail="Вакансия не найдена") + + # Удаляем связанные теги + cursor.execute("DELETE FROM vacancy_tags WHERE vacancy_id = ?", (vacancy_id,)) + + # Удаляем отклики на эту вакансию + cursor.execute("DELETE FROM applications WHERE vacancy_id = ?", (vacancy_id,)) + + # Удаляем вакансию + cursor.execute("DELETE FROM vacancies WHERE id = ?", (vacancy_id,)) + + conn.commit() + print(f"✅ Вакансия {vacancy_id} успешно удалена") + + return {"message": "Вакансия успешно удалена"} + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при удалении вакансии {vacancy_id}: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") + + +@app.put("/api/admin/resumes/{resume_id}") +async def update_resume_admin( + resume_id: int, + resume_update: ResumeCreate, + user_id: int = Depends(get_current_user) +): + """Обновление резюме администратором (по ID резюме)""" + try: + print(f"👑 Админ {user_id} редактирует резюме {resume_id}") + + with get_db() as conn: + cursor = conn.cursor() + + # Проверка прав администратора + cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,)) + admin = cursor.fetchone() + if not admin or not admin["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен") + + # Проверяем существование резюме + cursor.execute("SELECT user_id FROM resumes WHERE id = ?", (resume_id,)) + resume = cursor.fetchone() + if not resume: + raise HTTPException(status_code=404, detail="Резюме не найдено") + + owner_id = resume["user_id"] + + # Обновляем резюме для правильного владельца + cursor.execute(""" + UPDATE resumes + SET desired_position = ?, about_me = ?, desired_salary = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (resume_update.desired_position, resume_update.about_me, resume_update.desired_salary, resume_id)) + + # Удаляем старые записи + cursor.execute("DELETE FROM work_experience WHERE resume_id = ?", (resume_id,)) + cursor.execute("DELETE FROM education WHERE resume_id = ?", (resume_id,)) + cursor.execute("DELETE FROM resume_tags WHERE resume_id = ?", (resume_id,)) + + # Добавляем опыт работы + for exp in resume_update.work_experience: + cursor.execute(""" + INSERT INTO work_experience (resume_id, position, company, period, description) + VALUES (?, ?, ?, ?, ?) + """, (resume_id, exp.position, exp.company, exp.period, exp.description)) + + # Добавляем образование + for edu in resume_update.education: + cursor.execute(""" + INSERT INTO education (resume_id, institution, specialty, graduation_year) + VALUES (?, ?, ?, ?) + """, (resume_id, edu.institution, edu.specialty, edu.graduation_year)) + + # Добавляем теги + if resume_update.tags: + for tag_name in resume_update.tags: + if tag_name and tag_name.strip(): + tag_name = tag_name.strip() + cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) + tag = cursor.fetchone() + + if tag: + tag_id = tag["id"] + else: + cursor.execute("INSERT INTO tags (name) VALUES (?)", (tag_name,)) + tag_id = cursor.lastrowid + + cursor.execute(""" + INSERT OR IGNORE INTO resume_tags (resume_id, tag_id) + VALUES (?, ?) + """, (resume_id, tag_id)) + + conn.commit() + + # Получаем обновленное резюме + cursor.execute(""" + SELECT r.*, u.full_name, u.email, u.phone, u.telegram + FROM resumes r + JOIN users u ON r.user_id = u.id + WHERE r.id = ? + """, (resume_id,)) + + updated_resume = dict(cursor.fetchone()) + + # Получаем опыт работы + cursor.execute(""" + SELECT position, company, period, description + FROM work_experience + WHERE resume_id = ? + ORDER BY period DESC + """, (resume_id,)) + updated_resume["work_experience"] = [dict(exp) for exp in cursor.fetchall()] + + # Получаем образование + cursor.execute(""" + SELECT institution, specialty, graduation_year + FROM education + WHERE resume_id = ? + ORDER BY graduation_year DESC + """, (resume_id,)) + updated_resume["education"] = [dict(edu) for edu in cursor.fetchall()] + + # Получаем теги + cursor.execute(""" + SELECT t.id, t.name, t.category FROM tags t + JOIN resume_tags rt ON t.id = rt.tag_id + WHERE rt.resume_id = ? + """, (resume_id,)) + updated_resume["tags"] = [dict(tag) for tag in cursor.fetchall()] + + print(f"✅ Резюме {resume_id} обновлено администратором для пользователя {owner_id}") + return updated_resume + + except HTTPException: + raise + except Exception as e: + print(f"❌ Ошибка при обновлении резюме {resume_id}: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}") # ========== ПОЛЬЗОВАТЕЛЬСКИЕ ЭНДПОИНТЫ ========== diff --git a/templates/admin.html b/templates/admin.html index 59b1f81..2175eb6 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -462,6 +462,172 @@ grid-template-columns: 1fr; } } + + /* Стили для форм редактирования */ + .edit-form { + margin-top: 20px; + } + + .form-group { + margin-bottom: 20px; + } + + .form-group label { + display: block; + font-weight: 600; + color: #1f3f60; + margin-bottom: 8px; + } + + .form-group input, + .form-group textarea, + .form-group select { + width: 100%; + padding: 12px 16px; + border: 2px solid #dee9f5; + border-radius: 30px; + font-size: 14px; + transition: 0.2s; + } + + .form-group input:focus, + .form-group textarea:focus, + .form-group select:focus { + border-color: #3b82f6; + outline: none; + } + + .form-group textarea { + min-height: 100px; + resize: vertical; + } + + .form-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 15px; + } + + .checkbox-group { + display: flex; + align-items: center; + gap: 10px; + } + + .checkbox-group input { + width: auto; + margin-right: 5px; + } + + .tags-input { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px; + border: 2px solid #dee9f5; + border-radius: 30px; + background: white; + min-height: 50px; + } + + .tag-input-item { + background: #eef4fa; + padding: 4px 12px; + border-radius: 30px; + font-size: 13px; + color: #1f3f60; + display: inline-flex; + align-items: center; + gap: 5px; + } + + .tag-input-item .remove { + cursor: pointer; + color: #ef4444; + font-size: 12px; + } + + .tag-input-field { + border: none; + outline: none; + padding: 4px 8px; + flex: 1; + min-width: 100px; + } + + .btn-save { + background: #10b981; + color: white; + padding: 12px 24px; + border-radius: 40px; + border: none; + font-weight: 600; + cursor: pointer; + margin-right: 10px; + } + + .btn-save:hover { + background: #059669; + } + + .btn-cancel { + background: #eef4fa; + color: #1f3f60; + padding: 12px 24px; + border-radius: 40px; + border: none; + font-weight: 600; + cursor: pointer; + } + + .btn-cancel:hover { + background: #dbeafe; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 30px; + } + + .resume-experience-item, + .resume-education-item { + background: #f9fcff; + border-radius: 20px; + padding: 15px; + margin-bottom: 15px; + position: relative; + } + + .remove-item-btn { + position: absolute; + top: 10px; + right: 10px; + background: #fee2e2; + border: none; + width: 30px; + height: 30px; + border-radius: 15px; + cursor: pointer; + color: #b91c1c; + } + + .add-item-btn { + background: #eef4fa; + border: none; + padding: 10px 20px; + border-radius: 30px; + cursor: pointer; + margin-top: 10px; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .add-item-btn:hover { + background: #dbeafe; + } @@ -660,6 +826,116 @@ + + + + + + + + +
@@ -726,6 +1011,12 @@ let searchTimeouts = {}; + // Переменные для редактирования + let editVacancyTags = []; + let editResumeTags = []; + let editResumeExperience = []; + let editResumeEducation = []; + // ========== УВЕДОМЛЕНИЯ ========== function showNotification(message, type = 'success') { const notification = document.getElementById('notification'); @@ -896,6 +1187,9 @@ ${new Date(v.created_at).toLocaleDateString()}
+ @@ -943,31 +1237,35 @@ table.innerHTML = resumes.map(r => { const expCount = r.work_experience ? r.work_experience.length : (r.experience_count || 0); return ` - - ${r.id} - ${escapeHtml(r.full_name || '—')} - ${escapeHtml(r.desired_position || '—')} - ${escapeHtml(r.desired_salary || '—')} - ${expCount} ${getDeclension(expCount, ['место', 'места', 'мест'])} - - ${(r.tags || []).map(t => - `${escapeHtml(t.name || t)}` - ).join('')} - - ${r.views || 0} - ${r.updated_at ? new Date(r.updated_at).toLocaleDateString() : '—'} - -
- - -
- - - `}).join(''); + + ${r.id} + ${escapeHtml(r.full_name || '—')} + ${escapeHtml(r.desired_position || '—')} + ${escapeHtml(r.desired_salary || '—')} + ${expCount} ${getDeclension(expCount, ['место', 'места', 'мест'])} + + ${(r.tags || []).map(t => + `${escapeHtml(t.name || t)}` + ).join('')} + + ${r.views || 0} + ${r.updated_at ? new Date(r.updated_at).toLocaleDateString() : '—'} + +
+ + + +
+ + + `; + }).join(''); renderPagination('resumes', totalPages, page); @@ -1053,95 +1351,137 @@ // ========== ПРОСМОТР РЕЗЮМЕ ========== async function viewResume(resumeId) { try { + console.log('📥 Загрузка деталей резюме ID:', resumeId); + const response = await fetch(`${API_BASE_URL}/resumes/${resumeId}`); if (!response.ok) throw new Error('Ошибка загрузки резюме'); const resume = await response.json(); + console.log('✅ Данные резюме:', resume); - document.getElementById('resumeModalName').textContent = escapeHtml(resume.full_name || 'Резюме'); + // Проверяем существование элемента + const modalNameElement = document.getElementById('resumeModalName'); + const modalContentElement = document.getElementById('resumeModalContent'); + if (!modalNameElement) { + console.error('❌ Элемент resumeModalName не найден в DOM'); + showNotification('Ошибка: не найден элемент для отображения', 'error'); + return; + } + + if (!modalContentElement) { + console.error('❌ Элемент resumeModalContent не найден в DOM'); + showNotification('Ошибка: не найден элемент для содержимого', 'error'); + return; + } + + modalNameElement.textContent = escapeHtml(resume.full_name || 'Резюме'); + + // Формируем HTML для опыта работы const experienceHtml = resume.work_experience && resume.work_experience.length > 0 ? resume.work_experience.map(exp => ` -
- ${escapeHtml(exp.position)} ${escapeHtml(exp.period || '')}
- ${escapeHtml(exp.company)} - ${exp.description ? `

${escapeHtml(exp.description.substring(0, 100))}...

` : ''} +
+
+ ${escapeHtml(exp.position)} + ${escapeHtml(exp.period || '')} +
+
${escapeHtml(exp.company)}
+ ${exp.description ? `

${escapeHtml(exp.description.substring(0, 150))}${exp.description.length > 150 ? '...' : ''}

` : ''}
`).join('') - : '

Опыт работы не указан

'; + : '

Опыт работы не указан

'; + // Формируем HTML для образования const educationHtml = resume.education && resume.education.length > 0 ? resume.education.map(edu => ` -
- ${escapeHtml(edu.institution)} ${escapeHtml(edu.graduation_year || '')}
- ${escapeHtml(edu.specialty || '')} +
+
+ ${escapeHtml(edu.institution)} + ${escapeHtml(edu.graduation_year || '')} +
+
${escapeHtml(edu.specialty || '')}
`).join('') - : '

Образование не указано

'; + : '

Образование не указано

'; - document.getElementById('resumeModalContent').innerHTML = ` -
-

Основная информация

-
-
-
Желаемая должность
-
${escapeHtml(resume.desired_position || '—')}
+ // Формируем HTML для тегов + const tagsHtml = resume.tags && resume.tags.length > 0 + ? resume.tags.map(t => `${escapeHtml(t.name || t)}`).join('') + : ''; + + modalContentElement.innerHTML = ` +
+

Основная информация

+
+
+
Желаемая должность
+
${escapeHtml(resume.desired_position || '—')}
-
-
Зарплата
-
${escapeHtml(resume.desired_salary || '—')}
+
+
Зарплата
+
${escapeHtml(resume.desired_salary || '—')}
-
-
Просмотров
-
${resume.views || 0}
+
+
Просмотров
+
${resume.views || 0}
-
-
Обновлено
-
${resume.updated_at ? new Date(resume.updated_at).toLocaleDateString() : '—'}
+
+
Обновлено
+
${resume.updated_at ? new Date(resume.updated_at).toLocaleDateString() : '—'}
+
+
+
Навыки
+
${tagsHtml}
-
-

О себе

-

${escapeHtml(resume.about_me || 'Информация не заполнена')}

+
+

О себе

+

${escapeHtml(resume.about_me || 'Информация не заполнена')}

-
-

Опыт работы

+
+

Опыт работы

${experienceHtml}
-
-

Образование

+
+

Образование

${educationHtml}
-
-

Контакты

-
-
-
Email
-
${escapeHtml(resume.email || '—')}
+
+

Контакты

+
+
+
Email
+
${escapeHtml(resume.email || '—')}
-
-
Телефон
-
${escapeHtml(resume.phone || '—')}
+
+
Телефон
+
${escapeHtml(resume.phone || '—')}
-
-
Telegram
-
${escapeHtml(resume.telegram || '—')}
+
+
Telegram
+
${escapeHtml(resume.telegram || '—')}
`; - document.getElementById('resumeViewModal').classList.add('active'); + const modal = document.getElementById('resumeViewModal'); + if (modal) { + modal.classList.add('active'); + } else { + console.error('❌ Модальное окно resumeViewModal не найдено'); + showNotification('Ошибка: модальное окно не найдено', 'error'); + } } catch (error) { console.error('Error loading resume details:', error); @@ -1150,7 +1490,10 @@ } function closeResumeModal() { - document.getElementById('resumeViewModal').classList.remove('active'); + const modal = document.getElementById('resumeViewModal'); + if (modal) { + modal.classList.remove('active'); + } } // ========== ПРОСМОТР КОМПАНИИ ========== @@ -1335,6 +1678,395 @@ } } + // ========== РЕДАКТИРОВАНИЕ ВАКАНСИЙ ========== + + async function openEditVacancyModal(vacancyId) { + try { + const response = await fetch(`${API_BASE_URL}/vacancies/${vacancyId}`); + if (!response.ok) throw new Error('Ошибка загрузки'); + + const vacancy = await response.json(); + + document.getElementById('editVacancyId').value = vacancy.id; + document.getElementById('editVacancyTitle').value = vacancy.title || ''; + document.getElementById('editVacancySalary').value = vacancy.salary || ''; + document.getElementById('editVacancyStatus').value = vacancy.is_active ? '1' : '0'; + document.getElementById('editVacancyDescription').value = vacancy.description || ''; + document.getElementById('editVacancyContact').value = vacancy.contact || ''; + + editVacancyTags = (vacancy.tags || []).map(t => t.name); + renderVacancyTags(); + + document.getElementById('editVacancyModal').classList.add('active'); + + } catch (error) { + console.error('Error loading vacancy for edit:', error); + showNotification('Ошибка загрузки данных вакансии', 'error'); + } + } + + function closeEditVacancyModal() { + document.getElementById('editVacancyModal').classList.remove('active'); + editVacancyTags = []; + } + + function renderVacancyTags() { + const container = document.getElementById('editVacancyTagsContainer'); + const input = document.getElementById('editVacancyTagInput'); + + container.innerHTML = ''; + + editVacancyTags.forEach(tag => { + const tagSpan = document.createElement('span'); + tagSpan.className = 'tag-input-item'; + tagSpan.innerHTML = `${escapeHtml(tag)} ×`; + container.appendChild(tagSpan); + }); + + container.appendChild(input); + input.value = ''; + input.focus(); + } + + function addVacancyTag() { + const input = document.getElementById('editVacancyTagInput'); + const tag = input.value.trim(); + + if (tag && !editVacancyTags.includes(tag)) { + editVacancyTags.push(tag); + renderVacancyTags(); + } + } + + function removeVacancyTag(tag) { + editVacancyTags = editVacancyTags.filter(t => t !== tag); + renderVacancyTags(); + } + + async function saveVacancyEdit() { + const vacancyId = document.getElementById('editVacancyId').value; + const data = { + title: document.getElementById('editVacancyTitle').value, + salary: document.getElementById('editVacancySalary').value || null, + description: document.getElementById('editVacancyDescription').value || null, + contact: document.getElementById('editVacancyContact').value || null, + tags: editVacancyTags, + is_active: document.getElementById('editVacancyStatus').value === '1' + }; + + if (!data.title) { + showNotification('Введите название вакансии', 'error'); + return; + } + + try { + const response = await fetch(`${API_BASE_URL}/vacancies/${vacancyId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(data) + }); + + if (!response.ok) throw new Error('Ошибка сохранения'); + + showNotification('Вакансия обновлена', 'success'); + closeEditVacancyModal(); + loadVacancies(currentPage.vacancies, searchTerms.vacancies); + + } catch (error) { + console.error('Error saving vacancy:', error); + showNotification(error.message, 'error'); + } + } + + // ========== РЕДАКТИРОВАНИЕ РЕЗЮМЕ ========== + + async function openEditResumeModal(resumeId) { + try { + console.log('📥 Загрузка резюме для редактирования ID:', resumeId); + + // Используем админский эндпоинт для получения данных + const response = await fetch(`${API_BASE_URL}/admin/resumes/${resumeId}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Ошибка загрузки'); + + const resume = await response.json(); + console.log('✅ Данные резюме:', resume); + + document.getElementById('editResumeId').value = resume.id; + document.getElementById('editResumeUserId').value = resume.user_id; + document.getElementById('editResumeName').value = resume.full_name || ''; + document.getElementById('editResumePosition').value = resume.desired_position || ''; + document.getElementById('editResumeSalary').value = resume.desired_salary || ''; + document.getElementById('editResumeAbout').value = resume.about_me || ''; + + // Загружаем теги + editResumeTags = (resume.tags || []).map(t => t.name || t); + console.log('📌 Теги:', editResumeTags); + renderResumeTags(); + + // Загружаем опыт работы + editResumeExperience = (resume.work_experience || []).map(exp => ({ + position: exp.position || '', + company: exp.company || '', + period: exp.period || '', + description: exp.description || '' + })); + renderResumeExperience(); + + // Загружаем образование + editResumeEducation = (resume.education || []).map(edu => ({ + institution: edu.institution || '', + specialty: edu.specialty || '', + graduation_year: edu.graduation_year || '' + })); + renderResumeEducation(); + + document.getElementById('editResumeModal').classList.add('active'); + + } catch (error) { + console.error('Error loading resume for edit:', error); + showNotification('Ошибка загрузки данных резюме', 'error'); + } + } + + function closeEditResumeModal() { + document.getElementById('editResumeModal').classList.remove('active'); + editResumeTags = []; + editResumeExperience = []; + editResumeEducation = []; + } + + function renderResumeTags() { + const container = document.getElementById('editResumeTagsContainer'); + if (!container) return; + + // Сохраняем ссылку на input, если он есть + let existingInput = document.getElementById('editResumeTagInput'); + + // Очищаем контейнер + container.innerHTML = ''; + + // Добавляем теги + if (editResumeTags && editResumeTags.length > 0) { + editResumeTags.forEach(tag => { + if (tag && tag.trim()) { + const tagSpan = document.createElement('span'); + tagSpan.className = 'tag-input-item'; + tagSpan.innerHTML = `${escapeHtml(tag)} ×`; + container.appendChild(tagSpan); + } + }); + } + + // Создаем input для ввода новых тегов + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'tag-input-field'; + input.id = 'editResumeTagInput'; + input.placeholder = 'Введите тег и нажмите Enter'; + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addResumeTag(); + } + }); + + container.appendChild(input); + } + + function addResumeTag() { + const input = document.getElementById('editResumeTagInput'); + if (!input) return; + + const tag = input.value.trim(); + if (tag && !editResumeTags.includes(tag)) { + editResumeTags.push(tag); + renderResumeTags(); + } + input.value = ''; + input.focus(); + } + + function removeResumeTag(tag) { + editResumeTags = editResumeTags.filter(t => t !== tag); + renderResumeTags(); + } + + function renderResumeExperience() { + const container = document.getElementById('editResumeExperienceContainer'); + container.innerHTML = ''; + + editResumeExperience.forEach((exp, index) => { + const div = document.createElement('div'); + div.className = 'resume-experience-item'; + div.innerHTML = ` + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + container.appendChild(div); + }); + + setTimeout(() => { + document.querySelectorAll('.exp-position').forEach((input, idx) => { + input.addEventListener('change', () => updateExperienceItem(idx, 'position', input.value)); + }); + document.querySelectorAll('.exp-company').forEach((input, idx) => { + input.addEventListener('change', () => updateExperienceItem(idx, 'company', input.value)); + }); + document.querySelectorAll('.exp-period').forEach((input, idx) => { + input.addEventListener('change', () => updateExperienceItem(idx, 'period', input.value)); + }); + document.querySelectorAll('.exp-description').forEach((input, idx) => { + input.addEventListener('change', () => updateExperienceItem(idx, 'description', input.value)); + }); + }, 100); + } + + function updateExperienceItem(index, field, value) { + if (editResumeExperience[index]) { + editResumeExperience[index][field] = value; + } + } + + function addExperienceItem() { + editResumeExperience.push({ + position: '', + company: '', + period: '', + description: '' + }); + renderResumeExperience(); + } + + function removeExperienceItem(index) { + editResumeExperience.splice(index, 1); + renderResumeExperience(); + } + + function renderResumeEducation() { + const container = document.getElementById('editResumeEducationContainer'); + container.innerHTML = ''; + + editResumeEducation.forEach((edu, index) => { + const div = document.createElement('div'); + div.className = 'resume-education-item'; + div.innerHTML = ` + +
+ + +
+
+ + +
+
+ + +
+ `; + container.appendChild(div); + }); + + setTimeout(() => { + document.querySelectorAll('.edu-institution').forEach((input, idx) => { + input.addEventListener('change', () => updateEducationItem(idx, 'institution', input.value)); + }); + document.querySelectorAll('.edu-specialty').forEach((input, idx) => { + input.addEventListener('change', () => updateEducationItem(idx, 'specialty', input.value)); + }); + document.querySelectorAll('.edu-year').forEach((input, idx) => { + input.addEventListener('change', () => updateEducationItem(idx, 'graduation_year', input.value)); + }); + }, 100); + } + + function updateEducationItem(index, field, value) { + if (editResumeEducation[index]) { + editResumeEducation[index][field] = value; + } + } + + function addEducationItem() { + editResumeEducation.push({ + institution: '', + specialty: '', + graduation_year: '' + }); + renderResumeEducation(); + } + + function removeEducationItem(index) { + editResumeEducation.splice(index, 1); + renderResumeEducation(); + } + + async function saveResumeEdit() { + const resumeId = document.getElementById('editResumeId').value; + + const data = { + desired_position: document.getElementById('editResumePosition').value || null, + about_me: document.getElementById('editResumeAbout').value || null, + desired_salary: document.getElementById('editResumeSalary').value || null, + tags: editResumeTags, + work_experience: editResumeExperience, + education: editResumeEducation + }; + + console.log('📤 Отправка данных резюме:', JSON.stringify(data, null, 2)); + + try { + // Используем админский эндпоинт для обновления резюме + const response = await fetch(`${API_BASE_URL}/admin/resumes/${resumeId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(data) + }); + + console.log('📥 Статус ответа:', response.status); + + if (!response.ok) { + const error = await response.json(); + console.error('❌ Ошибка:', error); + throw new Error(error.detail || 'Ошибка сохранения'); + } + + const result = await response.json(); + console.log('✅ Результат сохранения:', result); + + showNotification('Резюме обновлено', 'success'); + closeEditResumeModal(); + loadResumes(currentPage.resumes, searchTerms.resumes); + + } catch (error) { + console.error('Error saving resume:', error); + showNotification(error.message, 'error'); + } + } + // ========== ПАГИНАЦИЯ ========== function renderPagination(type, totalPages, currentPageNum) { const container = document.getElementById(`${type}Pagination`); @@ -1460,11 +2192,11 @@ popularTagsTable.innerHTML = tags.slice(0, 10).map(t => ` ${escapeHtml(t.name)} - ${escapeHtml(t.category || '—')} - 0 - 0 - 0 - + ${escapeHtml(t.category || '—')}很少 + 0很少 + 0很少 + 0很少 + `).join(''); } catch (error) { @@ -1550,26 +2282,48 @@ window.location.href = '/'; } + // ========== ИНИЦИАЛИЗАЦИЯ СОБЫТИЙ ========== + function initTagInputs() { + const vacancyTagInput = document.getElementById('editVacancyTagInput'); + if (vacancyTagInput) { + vacancyTagInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addVacancyTag(); + } + }); + } + + const resumeTagInput = document.getElementById('editResumeTagInput'); + if (resumeTagInput) { + resumeTagInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addResumeTag(); + } + }); + } + } + // ========== ЗАКРЫТИЕ МОДАЛОК ПО КЛИКУ ВНЕ ========== window.onclick = function(event) { const tagModal = document.getElementById('tagModal'); const resumeModal = document.getElementById('resumeViewModal'); const companyModal = document.getElementById('companyViewModal'); + const editVacancyModal = document.getElementById('editVacancyModal'); + const editResumeModal = document.getElementById('editResumeModal'); - if (event.target === tagModal) { - tagModal.classList.remove('active'); - } - if (event.target === resumeModal) { - resumeModal.classList.remove('active'); - } - if (event.target === companyModal) { - companyModal.classList.remove('active'); - } + if (event.target === tagModal) tagModal.classList.remove('active'); + if (event.target === resumeModal) resumeModal.classList.remove('active'); + if (event.target === companyModal) companyModal.classList.remove('active'); + if (event.target === editVacancyModal) editVacancyModal.classList.remove('active'); + if (event.target === editResumeModal) editResumeModal.classList.remove('active'); }; // ========== ИНИЦИАЛИЗАЦИЯ ========== window.addEventListener('load', () => { updateNavigation(); + initTagInputs(); loadStats(); loadUsers(); loadCompanies(); diff --git a/templates/resume_detail.html b/templates/resume_detail.html index e22a7fc..9d279c9 100644 --- a/templates/resume_detail.html +++ b/templates/resume_detail.html @@ -1008,9 +1008,19 @@ async function loadResume() { try { const response = await fetch(`${API_BASE_URL}/resumes/${resumeId}`); - if (!response.ok) throw new Error('Резюме не найдено'); + + if (!response.ok) { + throw new Error('Резюме не найдено'); + } + currentResume = await response.json(); + + // Отладочный вывод + console.log('📥 Загружено резюме:', currentResume); + console.log('📌 Теги (сырые):', currentResume.tags); + renderResume(currentResume); + } catch (error) { console.error('Error loading resume:', error); document.getElementById('resumeDetail').innerHTML = ` @@ -1028,19 +1038,46 @@ function renderResume(resume) { const container = document.getElementById('resumeDetail'); const token = localStorage.getItem('accessToken'); + const canContact = token && currentUser && currentUser.role === 'employer'; const decodedName = decodeHtmlEntities(resume.full_name || ''); const decodedPosition = decodeHtmlEntities(resume.desired_position || ''); + // Проверяем и обрабатываем теги (поддержка разных форматов) + let tags = []; + if (resume.tags) { + if (Array.isArray(resume.tags)) { + // Если теги приходят как массив объектов или строк + tags = resume.tags.map(t => { + if (typeof t === 'string') return t; + if (t.name) return t.name; + return t; + }); + } else if (typeof resume.tags === 'string') { + tags = resume.tags.split(',').map(t => t.trim()).filter(t => t); + } + } + + console.log('📌 Теги для отображения:', tags); + const experienceHtml = resume.work_experience && resume.work_experience.length > 0 ? resume.work_experience.map(exp => `
${escapeHtml(exp.position)} - ${escapeHtml(exp.period || 'Период не указан')} + + ${escapeHtml(exp.period || 'Период не указан')} +
-
${escapeHtml(exp.company)}
- ${exp.description ? `
📋 Обязанности и достижения:

${escapeHtml(exp.description).replace(/\n/g, '
')}

` : ''} +
+ ${escapeHtml(exp.company)} +
+ ${exp.description ? ` +
+ 📋 Обязанности и достижения: +

${escapeHtml(exp.description).replace(/\n/g, '
')}

+
+ ` : ''}
`).join('') : '

Опыт работы не указан

'; @@ -1059,7 +1096,9 @@ container.innerHTML = `
-
+
+ +
${escapeHtml(decodedName)}
@@ -1070,7 +1109,9 @@
${escapeHtml(decodedPosition)}
- ${resume.views || 0} просмотров + + ${resume.views || 0} просмотров + Обновлено: ${new Date(resume.updated_at).toLocaleDateString()}
@@ -1078,8 +1119,11 @@
${escapeHtml(resume.desired_salary || 'Зарплатные ожидания не указаны')}
- ${resume.tags && resume.tags.length > 0 ? ` -
${resume.tags.map(t => `${escapeHtml(t.name)}`).join('')}
+ + ${tags && tags.length > 0 ? ` +
+ ${tags.map(t => `${escapeHtml(t)}`).join('')} +
` : ''}

О себе

@@ -1093,25 +1137,50 @@

Контактная информация

+ ${canContact ? `
-
${escapeHtml(resume.email || 'Email не указан')}
-
${escapeHtml(resume.phone || 'Телефон не указан')}
-
${escapeHtml(resume.telegram || 'Telegram не указан')}
+
+ + ${escapeHtml(resume.email || 'Email не указан')} +
+
+ + ${escapeHtml(resume.phone || 'Телефон не указан')} +
+
+ + ${escapeHtml(resume.telegram || 'Telegram не указан')} +
` : `
-

Контактные данные доступны только авторизованным работодателям

- ${!token ? 'Войти как работодатель' : '

Только для работодателей

'} +

+ Контактные данные доступны только авторизованным работодателям +

+ ${!token ? + 'Войти как работодатель' : + '

Только для работодателей

' + }
`}
- ${canContact ? `` : ''} - - + ${canContact ? ` + + ` : ''} + + + +
`; }