389 lines
17 KiB
Python
389 lines
17 KiB
Python
# server.py
|
||
import re
|
||
import json
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from fastapi import FastAPI, Request, Form
|
||
from fastapi.responses import HTMLResponse, JSONResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from typing import Optional
|
||
|
||
# ==================== КОНФИГУРАЦИЯ ====================
|
||
HOST = "0.0.0.0"
|
||
PORT = 8000
|
||
DEBUG = True
|
||
|
||
BASE_DIR = Path(__file__).parent
|
||
TEMPLATES_DIR = BASE_DIR / "templates"
|
||
STATIC_DIR = BASE_DIR / "static"
|
||
DATA_DIR = BASE_DIR / "data"
|
||
|
||
# Создаем необходимые папки
|
||
TEMPLATES_DIR.mkdir(exist_ok=True)
|
||
STATIC_DIR.mkdir(exist_ok=True)
|
||
DATA_DIR.mkdir(exist_ok=True)
|
||
|
||
# Создаем подпапки для статики
|
||
(STATIC_DIR / "css").mkdir(exist_ok=True)
|
||
(STATIC_DIR / "js").mkdir(exist_ok=True)
|
||
|
||
# ==================== ИНИЦИАЛИЗАЦИЯ FASTAPI ====================
|
||
app = FastAPI(title="БС Патриот - Регистрация на круглый стол", debug=DEBUG)
|
||
|
||
# Подключаем статические файлы
|
||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||
|
||
|
||
# ==================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ====================
|
||
def save_registration_to_file(data: dict) -> dict:
|
||
"""Сохраняет заявку в JSON-файл"""
|
||
filepath = DATA_DIR / "registrations.json"
|
||
|
||
registrations = []
|
||
if filepath.exists():
|
||
try:
|
||
with open(filepath, 'r', encoding='utf-8') as f:
|
||
registrations = json.load(f)
|
||
except (json.JSONDecodeError, IOError):
|
||
registrations = []
|
||
|
||
new_record = {
|
||
"id": len(registrations) + 1,
|
||
"created_at": datetime.now().isoformat(),
|
||
"fullname": data.get("fullname"),
|
||
"email": data.get("email"),
|
||
"phone": data.get("phone"),
|
||
"company": data.get("company"),
|
||
"businessSize": data.get("businessSize", ""),
|
||
"message": data.get("message", ""),
|
||
"status": "pending"
|
||
}
|
||
|
||
registrations.append(new_record)
|
||
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
json.dump(registrations, f, ensure_ascii=False, indent=2)
|
||
|
||
return new_record
|
||
|
||
|
||
def get_html_content() -> str:
|
||
"""Читает HTML файл или возвращает fallback"""
|
||
html_path = TEMPLATES_DIR / "index.html"
|
||
if html_path.exists():
|
||
with open(html_path, 'r', encoding='utf-8') as f:
|
||
return f.read()
|
||
else:
|
||
return get_fallback_html()
|
||
|
||
|
||
def get_fallback_html() -> str:
|
||
"""Fallback HTML если файл шаблона не найден"""
|
||
return """<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Круглый стол | БС «Патриот»</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||
<style>
|
||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||
body {{ font-family: 'Inter', Arial, sans-serif; background: #fefcf5; color: #1a1f2b; line-height: 1.6; }}
|
||
.container {{ max-width: 1200px; margin: 0 auto; padding: 40px 20px; text-align: center; }}
|
||
h1 {{ color: #c52a1c; margin-bottom: 20px; font-size: 2.5rem; }}
|
||
.badge {{ background: rgba(197,42,28,0.12); color: #c52a1c; padding: 6px 18px; border-radius: 40px; display: inline-block; margin-bottom: 20px; }}
|
||
.btn {{ display: inline-block; background: #c52a1c; color: white; padding: 12px 30px; text-decoration: none; border-radius: 50px; margin-top: 20px; border: none; cursor: pointer; font-family: inherit; }}
|
||
.form-card {{ background: white; max-width: 500px; margin: 40px auto; padding: 30px; border-radius: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }}
|
||
input, select, textarea {{ width: 100%; padding: 12px; margin: 10px 0; border: 1px solid #ddd; border-radius: 8px; font-family: inherit; }}
|
||
button {{ background: #c52a1c; color: white; padding: 12px 30px; border: none; border-radius: 50px; cursor: pointer; font-size: 16px; font-family: inherit; width: 100%; }}
|
||
.success {{ color: #1f8a4c; background: #e0f2e9; padding: 10px; border-radius: 8px; margin-top: 10px; }}
|
||
.error {{ color: #c52a1c; background: #ffe8e5; padding: 10px; border-radius: 8px; margin-top: 10px; }}
|
||
.stats {{ display: flex; justify-content: center; gap: 30px; margin: 30px 0; flex-wrap: wrap; }}
|
||
.stat {{ text-align: center; }}
|
||
.stat h4 {{ font-size: 2rem; color: #c52a1c; }}
|
||
footer {{ background: #0a1a2f; color: #b9c7d9; padding: 30px; margin-top: 40px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="badge"><i class="fas fa-shield-alt"></i> Закрытый клуб предпринимателей</div>
|
||
<h1>Круглый стол с бизнес-сообществом <span style="color:#c52a1c;">«Патриот»</span></h1>
|
||
<p><i class="far fa-calendar-check"></i> <strong>26 апреля 2026, начало в 11:00</strong></p>
|
||
<p><i class="fas fa-lock"></i> Вход по рекомендации</p>
|
||
|
||
<div class="stats">
|
||
<div class="stat"><h4>557+</h4><p>участников</p></div>
|
||
<div class="stat"><h4>125+</h4><p>выездов в зону СВО</p></div>
|
||
<div class="stat"><h4>30+</h4><p>комитетов</p></div>
|
||
</div>
|
||
|
||
<div class="form-card">
|
||
<h2>Регистрация на мероприятие</h2>
|
||
<form id="registrationForm">
|
||
<input type="text" name="fullname" id="fullname" placeholder="ФИО *" required>
|
||
<input type="email" name="email" id="email" placeholder="E-mail *" required>
|
||
<input type="tel" name="phone" id="phone" placeholder="Телефон *" required>
|
||
<input type="text" name="company" id="company" placeholder="Компания *" required>
|
||
<select name="businessSize" id="businessSize">
|
||
<option value="">Годовой оборот (опционально)</option>
|
||
<option value="до 50 млн руб.">до 50 млн руб.</option>
|
||
<option value="50–500 млн руб.">50–500 млн руб.</option>
|
||
<option value="500 млн – 1 млрд руб.">500 млн – 1 млрд руб.</option>
|
||
<option value="более 1 млрд руб.">более 1 млрд руб.</option>
|
||
</select>
|
||
<textarea name="message" id="message" rows="3" placeholder="Коротко о себе"></textarea>
|
||
<label>
|
||
<input type="checkbox" name="agree" id="agreeCheck" value="true" required>
|
||
Я соглашаюсь с условиями *
|
||
</label>
|
||
<button type="submit">Отправить заявку</button>
|
||
<div id="formFeedback"></div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<footer>
|
||
<p>Бизнес-сообщество «Предприниматели Патриоты»</p>
|
||
<p>© 2026 Круглый стол с БС «Патриот»</p>
|
||
</footer>
|
||
<script>
|
||
const form = document.getElementById('registrationForm');
|
||
form.addEventListener('submit', async (e) => {{
|
||
e.preventDefault();
|
||
const feedback = document.getElementById('formFeedback');
|
||
const formData = new FormData(form);
|
||
const submitBtn = form.querySelector('button[type="submit"]');
|
||
const originalText = submitBtn.innerHTML;
|
||
|
||
submitBtn.disabled = true;
|
||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-pulse"></i> Отправка...';
|
||
|
||
try {{
|
||
const response = await fetch('/api/register', {{
|
||
method: 'POST',
|
||
body: formData
|
||
}});
|
||
const data = await response.json();
|
||
if (data.success) {{
|
||
feedback.innerHTML = '<div class="success">✅ ' + data.message + '</div>';
|
||
form.reset();
|
||
}} else {{
|
||
feedback.innerHTML = '<div class="error">❌ ' + data.message + '</div>';
|
||
}}
|
||
}} catch (err) {{
|
||
console.error('Error:', err);
|
||
feedback.innerHTML = '<div class="error">❌ Ошибка соединения</div>';
|
||
}} finally {{
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalText;
|
||
setTimeout(() => {{ feedback.innerHTML = ''; }}, 5000);
|
||
}}
|
||
}});
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
# ==================== МАРШРУТЫ ====================
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def get_landing_page():
|
||
"""Главная страница - посадочный лендинг мероприятия"""
|
||
return HTMLResponse(content=get_html_content(), status_code=200)
|
||
|
||
|
||
@app.post("/api/register")
|
||
async def register(
|
||
fullname: str = Form(...),
|
||
email: str = Form(...),
|
||
phone: str = Form(...),
|
||
company: str = Form(...),
|
||
businessSize: Optional[str] = Form(None),
|
||
message: Optional[str] = Form(None),
|
||
agree: Optional[str] = Form(None)
|
||
):
|
||
"""API-эндпоинт для регистрации участников"""
|
||
|
||
print(f"Received data: fullname={fullname}, email={email}, phone={phone}, company={company}, agree={agree}")
|
||
|
||
# Валидация
|
||
if not agree or agree != "true":
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"success": False, "message": "Необходимо согласие с условиями"}
|
||
)
|
||
|
||
if not fullname or len(fullname.strip()) < 2:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"success": False, "message": "Введите корректное ФИО"}
|
||
)
|
||
|
||
email_pattern = r'^[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,}$'
|
||
if not re.match(email_pattern, email):
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"success": False, "message": "Введите корректный email"}
|
||
)
|
||
|
||
digits_only = re.sub(r'\D', '', phone)
|
||
if len(digits_only) < 10:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"success": False, "message": "Введите корректный номер телефона (минимум 10 цифр)"}
|
||
)
|
||
|
||
if not company or len(company.strip()) < 1:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"success": False, "message": "Укажите название компании"}
|
||
)
|
||
|
||
data = {
|
||
"fullname": fullname.strip(),
|
||
"email": email.strip().lower(),
|
||
"phone": phone.strip(),
|
||
"company": company.strip(),
|
||
"businessSize": businessSize if businessSize else "",
|
||
"message": message.strip() if message else "",
|
||
}
|
||
|
||
try:
|
||
saved = save_registration_to_file(data)
|
||
print(f"Saved registration: {saved}")
|
||
return JSONResponse(content={
|
||
"success": True,
|
||
"message": "Заявка успешно отправлена! Менеджер сообщества свяжется с вами в ближайшее время."
|
||
})
|
||
except Exception as e:
|
||
print(f"Error saving registration: {e}")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"success": False, "message": f"Ошибка сервера: {str(e)}"}
|
||
)
|
||
|
||
|
||
@app.get("/admin/registrations", response_class=HTMLResponse)
|
||
async def admin_registrations():
|
||
"""Админка для просмотра заявок"""
|
||
filepath = DATA_DIR / "registrations.json"
|
||
registrations = []
|
||
if filepath.exists():
|
||
try:
|
||
with open(filepath, 'r', encoding='utf-8') as f:
|
||
registrations = json.load(f)
|
||
except:
|
||
registrations = []
|
||
|
||
registrations.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||
|
||
# Используем обычное форматирование строки без .format()
|
||
html = f"""
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Админка | Заявки на круглый стол</title>
|
||
<style>
|
||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||
body {{ font-family: 'Inter', Arial, sans-serif; margin: 40px; background: #f5f5f5; }}
|
||
h1 {{ color: #c52a1c; margin-bottom: 20px; }}
|
||
.container {{ max-width: 1400px; margin: 0 auto; background: white; border-radius: 24px; padding: 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }}
|
||
table {{ border-collapse: collapse; width: 100%; margin-top: 20px; }}
|
||
th, td {{ border: 1px solid #ddd; padding: 12px; text-align: left; vertical-align: top; }}
|
||
th {{ background: #c52a1c; color: white; position: sticky; top: 0; }}
|
||
tr:nth-child(even) {{ background: #f9f9f9; }}
|
||
.status-pending {{ color: #e67e22; font-weight: bold; }}
|
||
.count {{ margin: 20px 0; font-size: 1.1rem; }}
|
||
.back-link {{ display: inline-block; margin-top: 20px; color: #c52a1c; text-decoration: none; font-weight: 600; }}
|
||
.back-link:hover {{ text-decoration: underline; }}
|
||
@media (max-width: 768px) {{
|
||
body {{ margin: 20px; }}
|
||
.container {{ padding: 16px; overflow-x: auto; }}
|
||
th, td {{ padding: 8px; font-size: 12px; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>📋 Заявки на круглый стол «Патриот»</h1>
|
||
<div class="count">📊 Всего заявок: <strong>{len(registrations)}</strong></div>
|
||
<div style="overflow-x: auto;">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ID</th>
|
||
<th>Дата</th>
|
||
<th>ФИО</th>
|
||
<th>Email</th>
|
||
<th>Телефон</th>
|
||
<th>Компания</th>
|
||
<th>Оборот</th>
|
||
<th>Сообщение</th>
|
||
<th>Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
"""
|
||
|
||
for reg in registrations:
|
||
created = reg.get("created_at", "")[:16].replace("T", " ")
|
||
status_class = "status-pending" if reg.get('status') == 'pending' else ""
|
||
message_text = reg.get('message', '—')[:100]
|
||
if len(reg.get('message', '')) > 100:
|
||
message_text += "..."
|
||
|
||
html += f"""
|
||
<tr>
|
||
<td>{reg.get('id', '—')}</td>
|
||
<td>{created}</td>
|
||
<td>{reg.get('fullname', '—')}</td>
|
||
<td>{reg.get('email', '—')}</td>
|
||
<td>{reg.get('phone', '—')}</td>
|
||
<td>{reg.get('company', '—')}</td>
|
||
<td>{reg.get('businessSize', '—')}</td>
|
||
<td>{message_text}</td>
|
||
<td class="{status_class}">{reg.get('status', 'pending')}</td>
|
||
</tr>
|
||
"""
|
||
|
||
html += """
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<a href="/" class="back-link">← Вернуться на главную</a>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
return HTMLResponse(content=html, status_code=200)
|
||
|
||
|
||
@app.get("/health")
|
||
async def health_check():
|
||
"""Health check для хостинга"""
|
||
return {"status": "ok", "timestamp": datetime.now().isoformat()}
|
||
|
||
|
||
# ==================== ЗАПУСК ====================
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
|
||
print("=" * 60)
|
||
print("🚀 Сервер для посадочной страницы БС «Патриот»")
|
||
print(f"📁 Шаблоны: {TEMPLATES_DIR}")
|
||
print(f"📁 Статика: {STATIC_DIR}")
|
||
print(f"📁 Данные: {DATA_DIR}")
|
||
print("=" * 60)
|
||
print(f"✨ Главная страница: http://localhost:{PORT}")
|
||
print(f"🔧 Админка: http://localhost:{PORT}/admin/registrations")
|
||
print("=" * 60)
|
||
print("💡 Для остановки сервера нажмите Ctrl+C")
|
||
print("=" * 60)
|
||
|
||
uvicorn.run(
|
||
app,
|
||
host=HOST,
|
||
port=PORT,
|
||
log_level="info"
|
||
) |