feat: Finaling MVP
This commit is contained in:
parent
060211f023
commit
c356166844
|
|
@ -1,9 +1,16 @@
|
|||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import pytz
|
||||
from sqlmodel import Session, select
|
||||
from .models import Event, GlobalStatus
|
||||
from .db import engine
|
||||
|
||||
MSK = pytz.timezone("Europe/Moscow")
|
||||
def _msk(dt):
|
||||
if dt is None: return None
|
||||
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(MSK)
|
||||
|
||||
def get_response(text: str) -> str:
|
||||
"""Принимает вопрос, определяет интент и возвращает ответ."""
|
||||
text = text.lower().strip()
|
||||
|
|
@ -44,7 +51,7 @@ def get_response(text: str) -> str:
|
|||
).first()
|
||||
|
||||
if next_event:
|
||||
local_time = next_event.start_time.strftime('%d.%m в %H:%M')
|
||||
local_time = _msk(next_event.start_time).strftime('%d.%m в %H:%M')
|
||||
return f"Ближайшее событие: {next_event.title} ({local_time}). Место: {next_event.location or 'не указано'}."
|
||||
else:
|
||||
return "В ближайшее время событий в расписании нет."
|
||||
|
|
@ -52,8 +59,9 @@ def get_response(text: str) -> str:
|
|||
# --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ ---
|
||||
if re.search(r'сегодня|седня', text):
|
||||
# Берем начало сегодняшнего дня и конец сегодняшнего дня
|
||||
now = datetime.now(timezone.utc)
|
||||
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
now_msk = datetime.now(MSK)
|
||||
start_of_day_msk = now_msk.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_of_day = start_of_day_msk.astimezone(timezone.utc)
|
||||
end_of_day = start_of_day + timedelta(days=1)
|
||||
|
||||
events = db_session.exec(
|
||||
|
|
@ -69,7 +77,7 @@ def get_response(text: str) -> str:
|
|||
|
||||
res = "Расписание на сегодня:\n"
|
||||
for e in events:
|
||||
res += f"• {e.start_time.strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n"
|
||||
res += f"• {_msk(e.start_time).strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n"
|
||||
return res
|
||||
|
||||
# --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ ---
|
||||
|
|
@ -90,7 +98,7 @@ def get_response(text: str) -> str:
|
|||
|
||||
res = "Расписание на неделю:\n"
|
||||
for e in events[:5]: # Показываем первые 5, чтобы не спамить
|
||||
res += f"• {e.start_time.strftime('%d.%m %H:%M')} - {e.title}\n"
|
||||
res += f"• {_msk(e.start_time).strftime('%d.%m %H:%M')} - {e.title}\n"
|
||||
if len(events) > 5:
|
||||
res += "...и другие события (смотрите на странице)."
|
||||
return res
|
||||
|
|
|
|||
110
app/assistant.py.bak
Normal file
110
app/assistant.py.bak
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import pytz
|
||||
from sqlmodel import Session, select
|
||||
from .models import Event, GlobalStatus
|
||||
from .db import engine
|
||||
|
||||
MSK = pytz.timezone("Europe/Moscow")
|
||||
def _msk(dt):
|
||||
if dt is None: return None
|
||||
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(MSK)
|
||||
|
||||
def get_response(text: str) -> str:
|
||||
"""Принимает вопрос, определяет интент и возвращает ответ."""
|
||||
text = text.lower().strip()
|
||||
|
||||
with Session(engine) as db_session:
|
||||
# Получаем текущий статус
|
||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||
|
||||
# --- ИНТЕНТ: ГДЕ СЕЙЧАС / СТАТУС ---
|
||||
if re.search(r'где|статус|сейчас', text):
|
||||
if current_status.status == 'on_place':
|
||||
return "Никита Сергеевич сейчас на месте (в аудитории)."
|
||||
elif current_status.status == 'delayed':
|
||||
return f"Никита Сергеевич опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}."
|
||||
elif current_status.status == 'away':
|
||||
return "Никита Сергеевич вне вуза."
|
||||
elif current_status.status == 'cancelled':
|
||||
return f"Занятия отменены! {current_status.message or ''}"
|
||||
else:
|
||||
return "Никита Сергеевич сейчас свободен / вне расписания."
|
||||
|
||||
# --- ИНТЕНТ: ОПАЗДЫВАЕТ? ---
|
||||
if re.search(r'опаздыва|задержива', text):
|
||||
if current_status.status == 'delayed':
|
||||
return f"Да, опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}."
|
||||
else:
|
||||
return "По текущему статусу, он не опаздывает."
|
||||
|
||||
# --- ИНТЕНТ: КОГДА СЛЕДУЮЩАЯ ПАРА / БЛИЖАЙШЕЕ ---
|
||||
if re.search(r'когда|следующ|ближайш', text):
|
||||
now = datetime.now(timezone.utc)
|
||||
next_event = db_session.exec(
|
||||
select(Event)
|
||||
.where(Event.is_public == True)
|
||||
.where(Event.start_time >= now)
|
||||
.order_by(Event.start_time)
|
||||
.limit(1)
|
||||
).first()
|
||||
|
||||
if next_event:
|
||||
local_time = next_event.start_time.strftime('%d.%m в %H:%M')
|
||||
return f"Ближайшее событие: {next_event.title} ({local_time}). Место: {next_event.location or 'не указано'}."
|
||||
else:
|
||||
return "В ближайшее время событий в расписании нет."
|
||||
|
||||
# --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ ---
|
||||
if re.search(r'сегодня|седня', text):
|
||||
# Берем начало сегодняшнего дня и конец сегодняшнего дня
|
||||
now = datetime.now(timezone.utc)
|
||||
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end_of_day = start_of_day + timedelta(days=1)
|
||||
|
||||
events = db_session.exec(
|
||||
select(Event)
|
||||
.where(Event.is_public == True)
|
||||
.where(Event.start_time >= start_of_day)
|
||||
.where(Event.start_time < end_of_day)
|
||||
.order_by(Event.start_time)
|
||||
).all()
|
||||
|
||||
if not events:
|
||||
return "На сегодня событий в расписании нет."
|
||||
|
||||
res = "Расписание на сегодня:\n"
|
||||
for e in events:
|
||||
res += f"• {e.start_time.strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n"
|
||||
return res
|
||||
|
||||
# --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ ---
|
||||
if re.search(r'недел|7 дней', text):
|
||||
now = datetime.now(timezone.utc)
|
||||
end_of_week = now + timedelta(days=7)
|
||||
|
||||
events = db_session.exec(
|
||||
select(Event)
|
||||
.where(Event.is_public == True)
|
||||
.where(Event.start_time >= now)
|
||||
.where(Event.start_time <= end_of_week)
|
||||
.order_by(Event.start_time)
|
||||
).all()
|
||||
|
||||
if not events:
|
||||
return "На ближайшую неделю событий нет."
|
||||
|
||||
res = "Расписание на неделю:\n"
|
||||
for e in events[:5]: # Показываем первые 5, чтобы не спамить
|
||||
res += f"• {e.start_time.strftime('%d.%m %H:%M')} - {e.title}\n"
|
||||
if len(events) > 5:
|
||||
res += "...и другие события (смотрите на странице)."
|
||||
return res
|
||||
|
||||
# --- ИНТЕНТ: КОНТАКТЫ ---
|
||||
if re.search(r'связаться|контакт|телеграм|почта|кабинет', text):
|
||||
return "Связаться с Никитой Сергеевичем можно по почте: freezer.nik@gmail.com (или в Telegram)."
|
||||
|
||||
# --- ЕСЛИ НИЧЕГО НЕ ПОНЯЛ ---
|
||||
return "Извините, я пока не понимаю этот вопрос. Попробуйте спросить: 'Где сейчас?', 'Когда следующая пара?', 'Что сегодня?' или 'Как связаться?'."
|
||||
|
|
@ -9,7 +9,7 @@ def get_gitea_token(code: str):
|
|||
"client_secret": config.GITEA_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": "https://portal.morozovka.space/auth/callback"
|
||||
"redirect_uri": config.GITEA_REDIRECT_URI
|
||||
}
|
||||
response = requests.post(url, data=data, headers={"Accept": "application/json"})
|
||||
return response.json().get("access_token")
|
||||
|
|
|
|||
24
app/auth.py.bak
Normal file
24
app/auth.py.bak
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import requests
|
||||
from . import config
|
||||
|
||||
def get_gitea_token(code: str):
|
||||
"""Меняет временный код на access token."""
|
||||
url = f"{config.GITEA_URL}/login/oauth/access_token"
|
||||
data = {
|
||||
"client_id": config.GITEA_CLIENT_ID,
|
||||
"client_secret": config.GITEA_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": "https://portal.morozovka.space/auth/callback"
|
||||
}
|
||||
response = requests.post(url, data=data, headers={"Accept": "application/json"})
|
||||
return response.json().get("access_token")
|
||||
|
||||
def get_gitea_user(token: str):
|
||||
"""Получает данные пользователя из Gitea по токену."""
|
||||
url = f"{config.GITEA_URL}/api/v1/user"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
response = requests.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
|
|
@ -25,3 +25,4 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space")
|
||||
GITEA_REDIRECT_URI = os.getenv("GITEA_REDIRECT_URI", "https://portal.morozovka.space/auth/callback")
|
||||
|
|
|
|||
27
app/config.py.bak
Normal file
27
app/config.py.bak
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
POSTGRES_USER = os.getenv("POSTGRES_USER", "morozovka")
|
||||
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "rf-slf-1945")
|
||||
POSTGRES_DB = os.getenv("POSTGRES_DB", "morozovka_portal")
|
||||
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1")
|
||||
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
||||
|
||||
# Добавляем psycopg2 в URL
|
||||
DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
||||
|
||||
GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000")
|
||||
GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space")
|
||||
GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa"
|
||||
GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a"
|
||||
|
||||
WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026")
|
||||
|
||||
# Настройки SMTP (если пусто, письма будут падать в консоль)
|
||||
SMTP_SERVER = os.getenv("SMTP_SERVER", "")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space")
|
||||
81
app/google_sync.py
Normal file
81
app/google_sync.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from sqlmodel import Session, select
|
||||
from .db import engine
|
||||
from .models import Event
|
||||
|
||||
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']
|
||||
SERVICE_ACCOUNT_FILE = '/opt/morozovka-portal/google_credentials.json'
|
||||
|
||||
# !!! ВАЖНО: Укажи здесь свой реальный email, к которому привязан календарь !!!
|
||||
CALENDAR_ID = 'korneshoc@gmail.com' # <--- ИЗМЕНИ ЭТО
|
||||
|
||||
def sync_google_calendar():
|
||||
"""Забирает события из Google Calendar и обновляет локальную БД."""
|
||||
print("[Google Sync] Начинаем синхронизацию...", flush=True)
|
||||
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
SERVICE_ACCOUNT_FILE, scopes=SCOPES)
|
||||
service = build('calendar', 'v3', credentials=creds)
|
||||
|
||||
# Берем события начиная с 30 дней назад (чтобы точно захватить тесты)
|
||||
time_min = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
||||
|
||||
print(f"[Google Sync] Запрашиваем календарь: {CALENDAR_ID}", flush=True)
|
||||
events_result = service.events().list(
|
||||
calendarId=CALENDAR_ID,
|
||||
timeMin=time_min,
|
||||
maxResults=100,
|
||||
singleEvents=True,
|
||||
orderBy='startTime'
|
||||
).execute()
|
||||
|
||||
google_events = events_result.get('items', [])
|
||||
print(f"[Google Sync] Найдено событий в Google API: {len(google_events)}", flush=True)
|
||||
|
||||
count = 0
|
||||
with Session(engine) as session:
|
||||
for g_event in google_events:
|
||||
g_id = g_event['id']
|
||||
|
||||
# Парсим время
|
||||
start = g_event['start'].get('dateTime', g_event['start'].get('date'))
|
||||
end = g_event['end'].get('dateTime', g_event['end'].get('date'))
|
||||
|
||||
if 'T' not in start:
|
||||
continue
|
||||
|
||||
start_dt = datetime.fromisoformat(start.replace('Z', '+00:00'))
|
||||
end_dt = datetime.fromisoformat(end.replace('Z', '+00:00'))
|
||||
|
||||
# Ищем событие в БД по google_event_id
|
||||
existing = session.exec(select(Event).where(Event.google_event_id == g_id)).first()
|
||||
|
||||
if existing:
|
||||
if getattr(existing, 'manually_edited', False):
|
||||
continue
|
||||
existing.title = g_event.get('summary', 'Без названия')
|
||||
existing.start_time = start_dt
|
||||
existing.end_time = end_dt
|
||||
existing.location = g_event.get('location')
|
||||
existing.description = g_event.get('description')
|
||||
session.add(existing)
|
||||
else:
|
||||
new_event = Event(
|
||||
google_event_id=g_id,
|
||||
title=g_event.get('summary', 'Без названия'),
|
||||
event_type='lecture',
|
||||
start_time=start_dt,
|
||||
end_time=end_dt,
|
||||
location=g_event.get('location'),
|
||||
description=g_event.get('description'),
|
||||
is_public=True
|
||||
)
|
||||
session.add(new_event)
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
|
||||
print(f"[Google Sync] Успешно сохранено новых событий в БД: {count}", flush=True)
|
||||
79
app/google_sync.py.bak
Normal file
79
app/google_sync.py.bak
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from sqlmodel import Session, select
|
||||
from .db import engine
|
||||
from .models import Event
|
||||
|
||||
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']
|
||||
SERVICE_ACCOUNT_FILE = '/opt/morozovka-portal/google_credentials.json'
|
||||
|
||||
# !!! ВАЖНО: Укажи здесь свой реальный email, к которому привязан календарь !!!
|
||||
CALENDAR_ID = 'korneshoc@gmail.com' # <--- ИЗМЕНИ ЭТО
|
||||
|
||||
def sync_google_calendar():
|
||||
"""Забирает события из Google Calendar и обновляет локальную БД."""
|
||||
print("[Google Sync] Начинаем синхронизацию...", flush=True)
|
||||
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
SERVICE_ACCOUNT_FILE, scopes=SCOPES)
|
||||
service = build('calendar', 'v3', credentials=creds)
|
||||
|
||||
# Берем события начиная с 30 дней назад (чтобы точно захватить тесты)
|
||||
time_min = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
||||
|
||||
print(f"[Google Sync] Запрашиваем календарь: {CALENDAR_ID}", flush=True)
|
||||
events_result = service.events().list(
|
||||
calendarId=CALENDAR_ID,
|
||||
timeMin=time_min,
|
||||
maxResults=100,
|
||||
singleEvents=True,
|
||||
orderBy='startTime'
|
||||
).execute()
|
||||
|
||||
google_events = events_result.get('items', [])
|
||||
print(f"[Google Sync] Найдено событий в Google API: {len(google_events)}", flush=True)
|
||||
|
||||
count = 0
|
||||
with Session(engine) as session:
|
||||
for g_event in google_events:
|
||||
g_id = g_event['id']
|
||||
|
||||
# Парсим время
|
||||
start = g_event['start'].get('dateTime', g_event['start'].get('date'))
|
||||
end = g_event['end'].get('dateTime', g_event['end'].get('date'))
|
||||
|
||||
if 'T' not in start:
|
||||
continue
|
||||
|
||||
start_dt = datetime.fromisoformat(start.replace('Z', '+00:00'))
|
||||
end_dt = datetime.fromisoformat(end.replace('Z', '+00:00'))
|
||||
|
||||
# Ищем событие в БД по google_event_id
|
||||
existing = session.exec(select(Event).where(Event.google_event_id == g_id)).first()
|
||||
|
||||
if existing:
|
||||
existing.title = g_event.get('summary', 'Без названия')
|
||||
existing.start_time = start_dt
|
||||
existing.end_time = end_dt
|
||||
existing.location = g_event.get('location')
|
||||
existing.description = g_event.get('description')
|
||||
session.add(existing)
|
||||
else:
|
||||
new_event = Event(
|
||||
google_event_id=g_id,
|
||||
title=g_event.get('summary', 'Без названия'),
|
||||
event_type='lecture',
|
||||
start_time=start_dt,
|
||||
end_time=end_dt,
|
||||
location=g_event.get('location'),
|
||||
description=g_event.get('description'),
|
||||
is_public=True
|
||||
)
|
||||
session.add(new_event)
|
||||
count += 1
|
||||
|
||||
session.commit()
|
||||
|
||||
print(f"[Google Sync] Успешно сохранено новых событий в БД: {count}", flush=True)
|
||||
475
app/main.py
475
app/main.py
|
|
@ -1,22 +1,37 @@
|
|||
import os
|
||||
import uuid
|
||||
import smtplib
|
||||
import pytz
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory
|
||||
from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory, make_response
|
||||
from icalendar import Calendar, Event as ICalEvent
|
||||
from .db import init_db, engine
|
||||
from . import config, auth, assistant
|
||||
from sqlmodel import Session, select
|
||||
from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession
|
||||
from datetime import datetime, timezone
|
||||
from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from functools import wraps
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = config.WEB_SECRET
|
||||
serializer = URLSafeTimedSerializer(app.secret_key)
|
||||
|
||||
MSK = pytz.timezone("Europe/Moscow")
|
||||
|
||||
def to_msk(dt):
|
||||
"""UTC → Europe/Moscow для отображения в шаблонах и API."""
|
||||
if dt is None: return None
|
||||
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(MSK)
|
||||
|
||||
@app.template_filter("msk_strftime")
|
||||
def _msk_strftime(dt, fmt):
|
||||
if dt is None: return ""
|
||||
return to_msk(dt).strftime(fmt)
|
||||
|
||||
UPLOAD_FOLDER = '/var/lib/gitea/portal-files'
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
|
||||
|
|
@ -84,9 +99,23 @@ def login_required(f):
|
|||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Ввод из формы — в Europe/Moscow, сохраняем как UTC."""
|
||||
if not dt_str: return None
|
||||
clean_str = dt_str.replace('T', ' ').strip()
|
||||
if len(clean_str) > 16: clean_str = clean_str[:16]
|
||||
naive = datetime.strptime(clean_str, "%Y-%m-%d %H:%M")
|
||||
return MSK.localize(naive).astimezone(timezone.utc)
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
return jsonify({"status": "ok"})
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return jsonify({"status": "ok", "db": "ok"})
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "db": str(e)}), 503
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
|
|
@ -108,34 +137,21 @@ def register():
|
|||
if db_session.exec(select(User).where(User.email == email)).first():
|
||||
return render_template("register.html", error="Email уже используется")
|
||||
|
||||
new_user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=generate_password_hash(password),
|
||||
full_name=full_name,
|
||||
organization=organization,
|
||||
role="student",
|
||||
is_verified=False
|
||||
)
|
||||
new_user = User(username=username, email=email, password_hash=generate_password_hash(password), full_name=full_name, organization=organization, role="student", is_verified=False)
|
||||
db_session.add(new_user)
|
||||
db_session.commit()
|
||||
|
||||
token = serializer.dumps(email, salt='email-confirm')
|
||||
confirm_url = url_for('verify_email', token=token, _external=True)
|
||||
confirm_url = confirm_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||
confirm_url = url_for('verify_email', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||
send_email(email, "Подтверждение почты", f"Привет, {full_name}!\nДля подтверждения перейди по ссылке:\n{confirm_url}\n")
|
||||
|
||||
body = f"Привет, {full_name}!\n\nДля подтверждения почты перейди по ссылке:\n{confirm_url}\n"
|
||||
send_email(email, "Подтверждение почты в Morozovka Portal", body)
|
||||
|
||||
return render_template("login.html", msg="Регистрация успешна. Проверьте почту для подтверждения.")
|
||||
return render_template("login.html", msg="Регистрация успешна. Проверьте почту (или логи сервера).")
|
||||
return render_template("register.html")
|
||||
|
||||
@app.route("/verify_email/<token>")
|
||||
def verify_email(token):
|
||||
try:
|
||||
email = serializer.loads(token, salt='email-confirm', max_age=86400)
|
||||
except Exception:
|
||||
return "Ссылка недействительна или устарела.", 400
|
||||
try: email = serializer.loads(token, salt='email-confirm', max_age=86400)
|
||||
except Exception: return "Ссылка недействительна или устарела.", 400
|
||||
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||
|
|
@ -154,27 +170,19 @@ def login():
|
|||
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.username == username)).first()
|
||||
|
||||
if not user or not user.password_hash or not check_password_hash(user.password_hash, password):
|
||||
return render_template("login.html", error="Неверный логин или пароль")
|
||||
|
||||
if not user.is_verified:
|
||||
return render_template("login.html", error="Подтвердите почту перед входом")
|
||||
|
||||
session_token = str(uuid.uuid4())
|
||||
db_session.add(UserSession(
|
||||
session_token=session_token,
|
||||
user_id=user.id,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.user_agent.string
|
||||
))
|
||||
db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string))
|
||||
db_session.commit()
|
||||
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
session["role"] = user.role
|
||||
session["session_id"] = session_token
|
||||
|
||||
return redirect(url_for("index"))
|
||||
|
||||
msg = request.args.get("msg")
|
||||
|
|
@ -185,7 +193,7 @@ def login_gitea():
|
|||
gitea_auth_url = (
|
||||
f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize"
|
||||
f"?client_id={config.GITEA_CLIENT_ID}&response_type=code"
|
||||
f"&redirect_uri=https://portal.morozovka.space/auth/callback"
|
||||
f"&redirect_uri={config.GITEA_REDIRECT_URI}"
|
||||
f"&scope=read:user"
|
||||
)
|
||||
return redirect(gitea_auth_url)
|
||||
|
|
@ -193,16 +201,11 @@ def login_gitea():
|
|||
@app.route("/auth/callback")
|
||||
def auth_callback():
|
||||
code = request.args.get("code")
|
||||
if not code:
|
||||
return "Ошибка авторизации Gitea", 400
|
||||
|
||||
if not code: return "Ошибка авторизации Gitea", 400
|
||||
token = auth.get_gitea_token(code)
|
||||
if not token:
|
||||
return "Ошибка токена Gitea", 400
|
||||
|
||||
if not token: return "Ошибка токена Gitea", 400
|
||||
gitea_user = auth.get_gitea_user(token)
|
||||
if not gitea_user:
|
||||
return "Ошибка профиля Gitea", 400
|
||||
if not gitea_user: return "Ошибка профиля Gitea", 400
|
||||
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
|
||||
|
|
@ -211,31 +214,20 @@ def auth_callback():
|
|||
if user:
|
||||
user.gitea_id = gitea_user["id"]
|
||||
else:
|
||||
user = User(
|
||||
gitea_id=gitea_user["id"],
|
||||
username=gitea_user["login"],
|
||||
full_name=gitea_user.get("full_name"),
|
||||
email=gitea_user.get("email"),
|
||||
role="admin" if gitea_user["login"] == "git_admin" else "student",
|
||||
is_verified=True
|
||||
)
|
||||
existing_admin = db_session.exec(select(User).where(User.role == "admin")).first()
|
||||
bootstrap_role = "admin" if not existing_admin else "student"
|
||||
user = User(gitea_id=gitea_user["id"], username=gitea_user["login"], full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), role=bootstrap_role, is_verified=True)
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
session_token = str(uuid.uuid4())
|
||||
db_session.add(UserSession(
|
||||
session_token=session_token,
|
||||
user_id=user.id,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.user_agent.string
|
||||
))
|
||||
db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string))
|
||||
db_session.commit()
|
||||
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
session["role"] = user.role
|
||||
session["session_id"] = session_token
|
||||
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@app.route("/logout")
|
||||
|
|
@ -249,7 +241,6 @@ def logout():
|
|||
session.clear()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
# --- СБРОС ПАРОЛЯ ---
|
||||
@app.route("/forgot_password", methods=["GET", "POST"])
|
||||
def forgot_password():
|
||||
if request.method == "POST":
|
||||
|
|
@ -258,57 +249,43 @@ def forgot_password():
|
|||
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||
if user:
|
||||
token = serializer.dumps(email, salt='password-reset')
|
||||
reset_url = url_for('reset_password', token=token, _external=True)
|
||||
reset_url = reset_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||
|
||||
body = f"Для сброса пароля перейди по ссылке:\n{reset_url}\n"
|
||||
send_email(email, "Сброс пароля Morozovka Portal", body)
|
||||
|
||||
reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||
send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n")
|
||||
return render_template("login.html", msg="Если такой email есть, инструкция отправлена.")
|
||||
return render_template("forgot_password.html")
|
||||
|
||||
@app.route("/reset_password/<token>", methods=["GET", "POST"])
|
||||
def reset_password(token):
|
||||
try:
|
||||
email = serializer.loads(token, salt='password-reset', max_age=3600)
|
||||
except Exception:
|
||||
return "Ссылка недействительна или устарела.", 400
|
||||
try: email = serializer.loads(token, salt='password-reset', max_age=3600)
|
||||
except Exception: return "Ссылка недействительна или устарела.", 400
|
||||
|
||||
if request.method == "POST":
|
||||
new_password = request.form.get("password")
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||
if user:
|
||||
user.password_hash = generate_password_hash(new_password)
|
||||
user.password_hash = generate_password_hash(request.form.get("password"))
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("login", msg="Пароль успешно изменен!"))
|
||||
return render_template("reset_password.html", token=token)
|
||||
|
||||
# --- УПРАВЛЕНИЕ СЕССИЯМИ ---
|
||||
@app.route("/sessions")
|
||||
@login_required
|
||||
def sessions_list():
|
||||
with Session(engine) as db_session:
|
||||
sessions = db_session.exec(
|
||||
select(UserSession)
|
||||
.where(UserSession.user_id == session.get("user_id"))
|
||||
.order_by(UserSession.created_at.desc())
|
||||
).all()
|
||||
sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id")).order_by(UserSession.created_at.desc())).all()
|
||||
return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id"))
|
||||
|
||||
@app.route("/sessions/logout_all", methods=["POST"])
|
||||
@login_required
|
||||
def logout_all():
|
||||
with Session(engine) as db_session:
|
||||
user_sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all()
|
||||
for s in user_sessions:
|
||||
for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all():
|
||||
db_session.delete(s)
|
||||
db_session.commit()
|
||||
session.clear()
|
||||
return redirect(url_for("login", msg="Вы вышли со всех устройств."))
|
||||
|
||||
# --- ПРОФИЛЬ ---
|
||||
@app.route("/profile")
|
||||
@login_required
|
||||
def profile():
|
||||
|
|
@ -316,14 +293,58 @@ def profile():
|
|||
user = db_session.get(User, session.get("user_id"))
|
||||
return render_template("profile.html", user=user)
|
||||
|
||||
# --- РАСПИСАНИЕ ---
|
||||
# --- РАСПИСАНИЕ (ПУБЛИЧНОЕ И АДМИНКА) ---
|
||||
@app.route("/schedule")
|
||||
def public_schedule():
|
||||
with Session(engine) as db_session:
|
||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||
now = datetime.now(timezone.utc)
|
||||
events = db_session.exec(select(Event).where(Event.is_public == True).where(Event.start_time >= now).order_by(Event.start_time).limit(10)).all()
|
||||
return render_template("schedule.html", status=current_status, events=events)
|
||||
events = db_session.exec(
|
||||
select(Event)
|
||||
.where(Event.is_public == True)
|
||||
.where(Event.start_time >= now - timedelta(days=180))
|
||||
.where(Event.start_time <= now + timedelta(days=365))
|
||||
.order_by(Event.start_time)
|
||||
).all()
|
||||
|
||||
events_json = [
|
||||
{
|
||||
"id": e.id,
|
||||
"title": e.title,
|
||||
"type": e.event_type,
|
||||
"start": to_msk(e.start_time).isoformat(),
|
||||
"end": to_msk(e.end_time).isoformat(),
|
||||
"location": e.location,
|
||||
"description": e.description,
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
return render_template("schedule.html", status=current_status, events_json=events_json)
|
||||
|
||||
@app.route("/schedule/export.ics")
|
||||
def export_ics():
|
||||
with Session(engine) as db_session:
|
||||
events = db_session.exec(select(Event).where(Event.is_public == True)).all()
|
||||
|
||||
cal = Calendar()
|
||||
cal.add('prodid', '-//Morozovka Portal//mxm.dk//')
|
||||
cal.add('version', '2.0')
|
||||
|
||||
for ev in events:
|
||||
ievent = ICalEvent()
|
||||
ievent.add('summary', ev.title)
|
||||
ievent.add('dtstart', ev.start_time.astimezone(pytz.utc))
|
||||
ievent.add('dtend', ev.end_time.astimezone(pytz.utc))
|
||||
if ev.location:
|
||||
ievent.add('location', ev.location)
|
||||
if ev.description:
|
||||
ievent.add('description', ev.description)
|
||||
cal.add_component(ievent)
|
||||
|
||||
response = make_response(cal.to_ical())
|
||||
response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics"
|
||||
response.headers["Content-Type"] = "text/calendar; charset=utf-8"
|
||||
return response
|
||||
|
||||
@app.route("/api/assistant", methods=["POST"])
|
||||
def api_assistant():
|
||||
|
|
@ -336,8 +357,7 @@ def api_assistant():
|
|||
def admin_schedule():
|
||||
with Session(engine) as db_session:
|
||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||
now = datetime.now(timezone.utc)
|
||||
events = db_session.exec(select(Event).where(Event.start_time >= now).order_by(Event.start_time)).all()
|
||||
events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all()
|
||||
return render_template("admin_schedule.html", status=current_status, events=events)
|
||||
|
||||
@app.route("/admin/schedule/status", methods=["POST"])
|
||||
|
|
@ -362,13 +382,35 @@ def update_status():
|
|||
def add_event():
|
||||
title = request.form.get("title")
|
||||
event_type = request.form.get("event_type")
|
||||
start_time = datetime.strptime(request.form.get("start_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
||||
end_time = datetime.strptime(request.form.get("end_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
||||
start_time = parse_datetime(request.form.get("start_time"))
|
||||
end_time = parse_datetime(request.form.get("end_time"))
|
||||
with Session(engine) as db_session:
|
||||
db_session.add(Event(title=title, event_type=event_type, start_time=start_time, end_time=end_time, location=request.form.get("location"), description=request.form.get("description"), is_public=request.form.get("is_public") == "on"))
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
|
||||
@app.route("/admin/schedule/event/<int:event_id>/edit", methods=["GET", "POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def edit_event(event_id):
|
||||
with Session(engine) as db_session:
|
||||
event = db_session.get(Event, event_id)
|
||||
if not event: return "Событие не найдено", 404
|
||||
|
||||
if request.method == "POST":
|
||||
event.title = request.form.get("title")
|
||||
event.event_type = request.form.get("event_type")
|
||||
event.start_time = parse_datetime(request.form.get("start_time"))
|
||||
event.end_time = parse_datetime(request.form.get("end_time"))
|
||||
event.location = request.form.get("location")
|
||||
event.description = request.form.get("description")
|
||||
event.is_public = request.form.get("is_public") == "on"
|
||||
event.manually_edited = True
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
|
||||
return render_template("admin_event_edit.html", event=event)
|
||||
|
||||
@app.route("/admin/schedule/event/<int:event_id>/delete", methods=["POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def delete_event(event_id):
|
||||
|
|
@ -379,77 +421,198 @@ def delete_event(event_id):
|
|||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
|
||||
# --- НОВЫЕ РОУТЫ: ИМПОРТ ICS ---
|
||||
@app.route("/admin/schedule/import", methods=["GET", "POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def import_ics():
|
||||
if request.method == "POST":
|
||||
if 'file' not in request.files:
|
||||
return "Файл не загружен", 400
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return "Файл не выбран", 400
|
||||
|
||||
if file and file.filename.endswith('.ics'):
|
||||
try:
|
||||
cal = Calendar.from_ical(file.read())
|
||||
count = 0
|
||||
with Session(engine) as db_session:
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
summary = str(component.get('summary', 'Без названия'))
|
||||
dtstart_prop = component.get('dtstart')
|
||||
dtend_prop = component.get('dtend')
|
||||
|
||||
if not dtstart_prop or not dtend_prop:
|
||||
continue
|
||||
|
||||
dtstart = dtstart_prop.dt
|
||||
dtend = dtend_prop.dt
|
||||
|
||||
if not isinstance(dtstart, datetime):
|
||||
dtstart = datetime.combine(dtstart, datetime.min.time())
|
||||
if not isinstance(dtend, datetime):
|
||||
dtend = datetime.combine(dtend, datetime.min.time())
|
||||
|
||||
if dtstart.tzinfo is None:
|
||||
dtstart = dtstart.replace(tzinfo=timezone.utc)
|
||||
if dtend.tzinfo is None:
|
||||
dtend = dtend.replace(tzinfo=timezone.utc)
|
||||
|
||||
loc = str(component.get('location', ''))
|
||||
desc = str(component.get('description', ''))
|
||||
|
||||
new_event = Event(
|
||||
title=summary,
|
||||
event_type='lecture',
|
||||
start_time=dtstart.astimezone(timezone.utc),
|
||||
end_time=dtend.astimezone(timezone.utc),
|
||||
location=loc,
|
||||
description=desc,
|
||||
is_public=True
|
||||
)
|
||||
db_session.add(new_event)
|
||||
count += 1
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
except Exception as e:
|
||||
return f"Ошибка при чтении файла .ics: {e}", 400
|
||||
return render_template("admin_schedule_import.html")
|
||||
|
||||
# --- ПРОЕКТЫ ---
|
||||
@app.route("/projects")
|
||||
def projects_list():
|
||||
user_role = session.get("role")
|
||||
user_id = session.get("user_id")
|
||||
with Session(engine) as db_session:
|
||||
projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all()
|
||||
return render_template("projects.html", projects=projects, role=session.get("role"))
|
||||
member_project_ids = []
|
||||
if user_id:
|
||||
memberships = db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all()
|
||||
member_project_ids = [m.project_id for m in memberships]
|
||||
|
||||
filtered_projects = []
|
||||
for p in projects:
|
||||
if p.visibility == "public":
|
||||
filtered_projects.append(p)
|
||||
elif p.visibility == "internal" and user_id:
|
||||
filtered_projects.append(p)
|
||||
elif p.visibility == "private" and user_id and (p.owner_id == user_id or p.id in member_project_ids or user_role == "admin"):
|
||||
filtered_projects.append(p)
|
||||
|
||||
return render_template("projects.html", projects=filtered_projects, role=user_role, user_id=user_id, member_ids=member_project_ids)
|
||||
|
||||
@app.route("/projects/new", methods=["GET", "POST"])
|
||||
@require_role(["admin", "professor", "lead"])
|
||||
def project_new():
|
||||
if request.method == "POST":
|
||||
with Session(engine) as db_session:
|
||||
db_session.add(Project(title=request.form.get("title"), description=request.form.get("description"), status=request.form.get("status"), gitea_url=request.form.get("gitea_url"), is_public=request.form.get("is_public") == "on", owner_id=session.get("user_id")))
|
||||
new_proj = Project(
|
||||
title=request.form.get("title"), description=request.form.get("description"),
|
||||
status=request.form.get("status"), visibility=request.form.get("visibility"),
|
||||
gitea_url=request.form.get("gitea_url"), owner_id=session.get("user_id")
|
||||
)
|
||||
db_session.add(new_proj)
|
||||
db_session.commit()
|
||||
db_session.refresh(new_proj)
|
||||
db_session.add(ProjectMember(project_id=new_proj.id, user_id=session.get("user_id"), role="lead"))
|
||||
db_session.commit()
|
||||
return redirect(url_for("projects_list"))
|
||||
return render_template("project_form.html")
|
||||
return render_template("project_form.html", project=None, is_edit=False)
|
||||
|
||||
@app.route("/projects/<int:project_id>")
|
||||
def project_detail(project_id):
|
||||
user_id = session.get("user_id")
|
||||
user_role = session.get("role")
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
members = db_session.exec(select(ProjectMember, User).join(User, ProjectMember.user_id == User.id).where(ProjectMember.project_id == project_id)).all()
|
||||
is_member = any(m[0].user_id == user_id for m in members)
|
||||
|
||||
if proj.visibility == "private" and not is_member and user_role != "admin":
|
||||
return "403: Это приватный проект", 403
|
||||
if proj.visibility == "internal" and not user_id:
|
||||
return redirect(url_for("login"))
|
||||
return render_template("project_detail.html", project=proj, members=members, is_member=is_member, user_id=user_id, role=user_role)
|
||||
|
||||
@app.route("/projects/<int:project_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def project_edit(project_id):
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first()
|
||||
is_lead = membership and membership.role == "lead"
|
||||
|
||||
if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and not is_lead:
|
||||
return "403: У вас нет прав на редактирование", 403
|
||||
|
||||
if request.method == "POST":
|
||||
proj.title = request.form.get("title")
|
||||
proj.description = request.form.get("description")
|
||||
proj.status = request.form.get("status")
|
||||
proj.visibility = request.form.get("visibility")
|
||||
proj.gitea_url = request.form.get("gitea_url")
|
||||
db_session.add(proj)
|
||||
db_session.commit()
|
||||
return redirect(url_for("project_detail", project_id=proj.id))
|
||||
return render_template("project_form.html", project=proj, is_edit=True)
|
||||
|
||||
@app.route("/projects/<int:project_id>/members/add", methods=["POST"])
|
||||
@login_required
|
||||
def project_add_member(project_id):
|
||||
username = request.form.get("username")
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first()
|
||||
if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"):
|
||||
return "403: Нет прав", 403
|
||||
|
||||
user_to_add = db_session.exec(select(User).where(User.username == username)).first()
|
||||
if not user_to_add: return redirect(url_for("project_detail", project_id=project_id))
|
||||
|
||||
existing = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id)).first()
|
||||
if not existing:
|
||||
db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role="member"))
|
||||
db_session.commit()
|
||||
return redirect(url_for("project_detail", project_id=project_id))
|
||||
|
||||
@app.route("/projects/<int:project_id>/members/<int:user_id>/remove", methods=["POST"])
|
||||
@login_required
|
||||
def project_remove_member(project_id, user_id):
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first()
|
||||
if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"):
|
||||
return "403: Нет прав", 403
|
||||
|
||||
if user_id == proj.owner_id: return "Нельзя удалить владельца", 400
|
||||
|
||||
to_remove = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)).first()
|
||||
if to_remove:
|
||||
db_session.delete(to_remove)
|
||||
db_session.commit()
|
||||
return redirect(url_for("project_detail", project_id=project_id))
|
||||
|
||||
@app.route("/projects/<int:project_id>/delete", methods=["POST"])
|
||||
@require_role(["admin", "professor", "lead"])
|
||||
@login_required
|
||||
def project_delete(project_id):
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if proj and (session.get("role") == "admin" or proj.owner_id == session.get("user_id")):
|
||||
for m in db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all():
|
||||
db_session.delete(m)
|
||||
db_session.delete(proj)
|
||||
db_session.commit()
|
||||
return redirect(url_for("projects_list"))
|
||||
|
||||
# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ ---
|
||||
@app.route("/admin/users")
|
||||
@require_role("admin")
|
||||
def admin_users():
|
||||
with Session(engine) as db_session:
|
||||
users = db_session.exec(select(User).order_by(User.username)).all()
|
||||
return render_template("admin_users.html", users=users)
|
||||
|
||||
@app.route("/admin/users/<int:user_id>/role", methods=["POST"])
|
||||
@require_role("admin")
|
||||
def change_user_role(user_id):
|
||||
new_role = request.form.get("role")
|
||||
valid_roles = ["admin", "professor", "lead", "member", "student", "guest"]
|
||||
if new_role not in valid_roles: return "Ошибка", 400
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.get(User, user_id)
|
||||
if user:
|
||||
if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400
|
||||
user.role = new_role
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
# --- НОВЫЙ РОУТ: УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЯ ---
|
||||
@app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
|
||||
@require_role("admin")
|
||||
def delete_user(user_id):
|
||||
if user_id == session.get("user_id"):
|
||||
return "Ошибка: нельзя удалить самого себя", 400
|
||||
|
||||
with Session(engine) as db_session:
|
||||
# Сначала удаляем все сессии пользователя (чтобы не было ошибки внешнего ключа)
|
||||
sessions = db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all()
|
||||
for s in sessions:
|
||||
db_session.delete(s)
|
||||
|
||||
# Затем удаляем самого пользователя
|
||||
user = db_session.get(User, user_id)
|
||||
if user:
|
||||
db_session.delete(user)
|
||||
db_session.commit()
|
||||
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
# --- ФАЙЛЫ ---
|
||||
@app.route("/files")
|
||||
@login_required
|
||||
|
|
@ -495,3 +658,49 @@ def file_delete(file_id):
|
|||
db_session.delete(db_file)
|
||||
db_session.commit()
|
||||
return redirect(url_for("files_list"))
|
||||
|
||||
# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ ---
|
||||
@app.route("/admin/users")
|
||||
@require_role("admin")
|
||||
def admin_users():
|
||||
with Session(engine) as db_session:
|
||||
users = db_session.exec(select(User).order_by(User.username)).all()
|
||||
return render_template("admin_users.html", users=users)
|
||||
|
||||
@app.route("/admin/users/<int:user_id>/role", methods=["POST"])
|
||||
@require_role("admin")
|
||||
def change_user_role(user_id):
|
||||
new_role = request.form.get("role")
|
||||
valid_roles = ["admin", "professor", "lead", "member", "student", "guest"]
|
||||
if new_role not in valid_roles: return "Ошибка", 400
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.get(User, user_id)
|
||||
if user:
|
||||
if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400
|
||||
user.role = new_role
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
@app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
|
||||
@require_role("admin")
|
||||
def delete_user(user_id):
|
||||
if user_id == session.get("user_id"): return "Нельзя удалить себя", 400
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.get(User, user_id)
|
||||
if not user:
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
owned_projects = db_session.exec(select(Project).where(Project.owner_id == user_id)).all()
|
||||
uploaded_files = db_session.exec(select(PortalFile).where(PortalFile.uploaded_by == user_id)).all()
|
||||
if owned_projects or uploaded_files:
|
||||
return (f"Нельзя удалить {user.username}: у него {len(owned_projects)} проект(ов) "
|
||||
f"и {len(uploaded_files)} файл(ов). Сначала передайте их другому пользователю."), 400
|
||||
|
||||
for pm in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all():
|
||||
db_session.delete(pm)
|
||||
for s in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all():
|
||||
db_session.delete(s)
|
||||
db_session.delete(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_users"))
|
||||
|
|
|
|||
679
app/main.py.bak
Normal file
679
app/main.py.bak
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
import os
|
||||
import uuid
|
||||
import smtplib
|
||||
import pytz
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory, make_response
|
||||
from icalendar import Calendar, Event as ICalEvent
|
||||
from .db import init_db, engine
|
||||
from . import config, auth, assistant
|
||||
from sqlmodel import Session, select
|
||||
from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = config.WEB_SECRET
|
||||
serializer = URLSafeTimedSerializer(app.secret_key)
|
||||
|
||||
MSK = pytz.timezone("Europe/Moscow")
|
||||
|
||||
def to_msk(dt):
|
||||
if dt is None: return None
|
||||
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(MSK)
|
||||
|
||||
@app.template_filter("msk_strftime")
|
||||
def _msk_strftime(dt, fmt):
|
||||
if dt is None: return ""
|
||||
return to_msk(dt).strftime(fmt)
|
||||
|
||||
UPLOAD_FOLDER = '/var/lib/gitea/portal-files'
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
with Session(engine) as db_session:
|
||||
status = db_session.exec(select(GlobalStatus)).first()
|
||||
if not status:
|
||||
db_session.add(GlobalStatus(status="free", message="Вне расписания"))
|
||||
db_session.commit()
|
||||
|
||||
@app.before_request
|
||||
def validate_session():
|
||||
if "session_id" in session:
|
||||
with Session(engine) as db_session:
|
||||
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
|
||||
if not db_sess:
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
def send_email(to_email, subject, body):
|
||||
if not config.SMTP_SERVER:
|
||||
print("\n" + "="*50, flush=True)
|
||||
print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True)
|
||||
print(f"Тема: {subject}", flush=True)
|
||||
print(body, flush=True)
|
||||
print("="*50 + "\n", flush=True)
|
||||
return True
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = config.SMTP_FROM
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain'))
|
||||
|
||||
server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT)
|
||||
server.starttls()
|
||||
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Ошибка отправки email: {e}", flush=True)
|
||||
return False
|
||||
|
||||
def require_role(allowed_roles):
|
||||
if isinstance(allowed_roles, str):
|
||||
allowed_roles = [allowed_roles]
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if session.get("role") not in allowed_roles:
|
||||
return "403: Доступ запрещен.", 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not session.get("user_id"):
|
||||
return redirect(url_for("login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def parse_datetime(dt_str):
|
||||
"""Ввод из формы — в Europe/Moscow, сохраняем как UTC."""
|
||||
if not dt_str: return None
|
||||
clean_str = dt_str.replace('T', ' ').strip()
|
||||
if len(clean_str) > 16: clean_str = clean_str[:16]
|
||||
naive = datetime.strptime(clean_str, "%Y-%m-%d %H:%M")
|
||||
return MSK.localize(naive).astimezone(timezone.utc)
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return jsonify({"status": "ok", "db": "ok"})
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "db": str(e)}), 503
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html", username=session.get("username"))
|
||||
|
||||
# --- АУТЕНТИФИКАЦИЯ ---
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
email = request.form.get("email")
|
||||
password = request.form.get("password")
|
||||
full_name = request.form.get("full_name")
|
||||
organization = request.form.get("organization")
|
||||
|
||||
with Session(engine) as db_session:
|
||||
if db_session.exec(select(User).where(User.username == username)).first():
|
||||
return render_template("register.html", error="Логин уже занят")
|
||||
if db_session.exec(select(User).where(User.email == email)).first():
|
||||
return render_template("register.html", error="Email уже используется")
|
||||
|
||||
new_user = User(username=username, email=email, password_hash=generate_password_hash(password), full_name=full_name, organization=organization, role="student", is_verified=False)
|
||||
db_session.add(new_user)
|
||||
db_session.commit()
|
||||
|
||||
token = serializer.dumps(email, salt='email-confirm')
|
||||
confirm_url = url_for('verify_email', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||
send_email(email, "Подтверждение почты", f"Привет, {full_name}!\nДля подтверждения перейди по ссылке:\n{confirm_url}\n")
|
||||
|
||||
return render_template("login.html", msg="Регистрация успешна. Проверьте почту (или логи сервера).")
|
||||
return render_template("register.html")
|
||||
|
||||
@app.route("/verify_email/<token>")
|
||||
def verify_email(token):
|
||||
try: email = serializer.loads(token, salt='email-confirm', max_age=86400)
|
||||
except Exception: return "Ссылка недействительна или устарела.", 400
|
||||
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||
if user:
|
||||
user.is_verified = True
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти."))
|
||||
return "Пользователь не найден.", 404
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.username == username)).first()
|
||||
if not user or not user.password_hash or not check_password_hash(user.password_hash, password):
|
||||
return render_template("login.html", error="Неверный логин или пароль")
|
||||
if not user.is_verified:
|
||||
return render_template("login.html", error="Подтвердите почту перед входом")
|
||||
|
||||
session_token = str(uuid.uuid4())
|
||||
db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string))
|
||||
db_session.commit()
|
||||
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
session["role"] = user.role
|
||||
session["session_id"] = session_token
|
||||
return redirect(url_for("index"))
|
||||
|
||||
msg = request.args.get("msg")
|
||||
return render_template("login.html", msg=msg)
|
||||
|
||||
@app.route("/login/gitea")
|
||||
def login_gitea():
|
||||
gitea_auth_url = (
|
||||
f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize"
|
||||
f"?client_id={config.GITEA_CLIENT_ID}&response_type=code"
|
||||
f"&redirect_uri={config.GITEA_REDIRECT_URI}"
|
||||
f"&scope=read:user"
|
||||
)
|
||||
return redirect(gitea_auth_url)
|
||||
|
||||
@app.route("/auth/callback")
|
||||
def auth_callback():
|
||||
code = request.args.get("code")
|
||||
if not code: return "Ошибка авторизации Gitea", 400
|
||||
token = auth.get_gitea_token(code)
|
||||
if not token: return "Ошибка токена Gitea", 400
|
||||
gitea_user = auth.get_gitea_user(token)
|
||||
if not gitea_user: return "Ошибка профиля Gitea", 400
|
||||
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
|
||||
if not user:
|
||||
user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first()
|
||||
if user:
|
||||
user.gitea_id = gitea_user["id"]
|
||||
else:
|
||||
existing_admin = db_session.exec(select(User).where(User.role == "admin")).first()
|
||||
bootstrap_role = "admin" if not existing_admin else "student"
|
||||
user = User(gitea_id=gitea_user["id"], username=gitea_user["login"], full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), role=bootstrap_role, is_verified=True)
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
session_token = str(uuid.uuid4())
|
||||
db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string))
|
||||
db_session.commit()
|
||||
|
||||
session["user_id"] = user.id
|
||||
session["username"] = user.username
|
||||
session["role"] = user.role
|
||||
session["session_id"] = session_token
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
if "session_id" in session:
|
||||
with Session(engine) as db_session:
|
||||
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
|
||||
if db_sess:
|
||||
db_session.delete(db_sess)
|
||||
db_session.commit()
|
||||
session.clear()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@app.route("/forgot_password", methods=["GET", "POST"])
|
||||
def forgot_password():
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email")
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||
if user:
|
||||
token = serializer.dumps(email, salt='password-reset')
|
||||
reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||
send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n")
|
||||
return render_template("login.html", msg="Если такой email есть, инструкция отправлена.")
|
||||
return render_template("forgot_password.html")
|
||||
|
||||
@app.route("/reset_password/<token>", methods=["GET", "POST"])
|
||||
def reset_password(token):
|
||||
try: email = serializer.loads(token, salt='password-reset', max_age=3600)
|
||||
except Exception: return "Ссылка недействительна или устарела.", 400
|
||||
|
||||
if request.method == "POST":
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||
if user:
|
||||
user.password_hash = generate_password_hash(request.form.get("password"))
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("login", msg="Пароль успешно изменен!"))
|
||||
return render_template("reset_password.html", token=token)
|
||||
|
||||
@app.route("/sessions")
|
||||
@login_required
|
||||
def sessions_list():
|
||||
with Session(engine) as db_session:
|
||||
sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id")).order_by(UserSession.created_at.desc())).all()
|
||||
return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id"))
|
||||
|
||||
@app.route("/sessions/logout_all", methods=["POST"])
|
||||
@login_required
|
||||
def logout_all():
|
||||
with Session(engine) as db_session:
|
||||
for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all():
|
||||
db_session.delete(s)
|
||||
db_session.commit()
|
||||
session.clear()
|
||||
return redirect(url_for("login", msg="Вы вышли со всех устройств."))
|
||||
|
||||
@app.route("/profile")
|
||||
@login_required
|
||||
def profile():
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.get(User, session.get("user_id"))
|
||||
return render_template("profile.html", user=user)
|
||||
|
||||
# --- РАСПИСАНИЕ (ПУБЛИЧНОЕ И АДМИНКА) ---
|
||||
@app.route("/schedule")
|
||||
def public_schedule():
|
||||
with Session(engine) as db_session:
|
||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||
now = datetime.now(timezone.utc)
|
||||
events = db_session.exec(select(Event).where(Event.is_public == True).where(Event.start_time >= now).order_by(Event.start_time).limit(10)).all()
|
||||
return render_template("schedule.html", status=current_status, events=events)
|
||||
|
||||
@app.route("/schedule/export.ics")
|
||||
def export_ics():
|
||||
with Session(engine) as db_session:
|
||||
events = db_session.exec(select(Event).where(Event.is_public == True)).all()
|
||||
|
||||
cal = Calendar()
|
||||
cal.add('prodid', '-//Morozovka Portal//mxm.dk//')
|
||||
cal.add('version', '2.0')
|
||||
|
||||
for ev in events:
|
||||
ievent = ICalEvent()
|
||||
ievent.add('summary', ev.title)
|
||||
# Приводим к UTC для совместимости
|
||||
ievent.add('dtstart', ev.start_time.astimezone(pytz.utc))
|
||||
ievent.add('dtend', ev.end_time.astimezone(pytz.utc))
|
||||
if ev.location:
|
||||
ievent.add('location', ev.location)
|
||||
if ev.description:
|
||||
ievent.add('description', ev.description)
|
||||
cal.add_component(ievent)
|
||||
|
||||
response = make_response(cal.to_ical())
|
||||
response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics"
|
||||
response.headers["Content-Type"] = "text/calendar; charset=utf-8"
|
||||
return response
|
||||
|
||||
@app.route("/api/assistant", methods=["POST"])
|
||||
def api_assistant():
|
||||
data = request.get_json()
|
||||
if not data or "message" not in data: return jsonify({"error": "Нет сообщения"}), 400
|
||||
return jsonify({"reply": assistant.get_response(data["message"])})
|
||||
|
||||
@app.route("/admin/schedule")
|
||||
@require_role(["admin", "professor"])
|
||||
def admin_schedule():
|
||||
with Session(engine) as db_session:
|
||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||
events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all()
|
||||
return render_template("admin_schedule.html", status=current_status, events=events)
|
||||
|
||||
@app.route("/admin/schedule/status", methods=["POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def update_status():
|
||||
status_val = request.form.get("status")
|
||||
delay = int(request.form.get("delay_minutes") or 0)
|
||||
message = request.form.get("message")
|
||||
with Session(engine) as db_session:
|
||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||
if current_status:
|
||||
current_status.status = status_val
|
||||
current_status.delay_minutes = delay
|
||||
current_status.message = message
|
||||
current_status.updated_at = datetime.now(timezone.utc)
|
||||
db_session.add(current_status)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
|
||||
@app.route("/admin/schedule/event/add", methods=["POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def add_event():
|
||||
title = request.form.get("title")
|
||||
event_type = request.form.get("event_type")
|
||||
start_time = parse_datetime(request.form.get("start_time"))
|
||||
end_time = parse_datetime(request.form.get("end_time"))
|
||||
with Session(engine) as db_session:
|
||||
db_session.add(Event(title=title, event_type=event_type, start_time=start_time, end_time=end_time, location=request.form.get("location"), description=request.form.get("description"), is_public=request.form.get("is_public") == "on"))
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
|
||||
@app.route("/admin/schedule/event/<int:event_id>/edit", methods=["GET", "POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def edit_event(event_id):
|
||||
with Session(engine) as db_session:
|
||||
event = db_session.get(Event, event_id)
|
||||
if not event: return "Событие не найдено", 404
|
||||
|
||||
if request.method == "POST":
|
||||
event.title = request.form.get("title")
|
||||
event.event_type = request.form.get("event_type")
|
||||
event.start_time = parse_datetime(request.form.get("start_time"))
|
||||
event.end_time = parse_datetime(request.form.get("end_time"))
|
||||
event.location = request.form.get("location")
|
||||
event.description = request.form.get("description")
|
||||
event.is_public = request.form.get("is_public") == "on"
|
||||
event.manually_edited = True
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
|
||||
return render_template("admin_event_edit.html", event=event)
|
||||
|
||||
@app.route("/admin/schedule/event/<int:event_id>/delete", methods=["POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def delete_event(event_id):
|
||||
with Session(engine) as db_session:
|
||||
event = db_session.get(Event, event_id)
|
||||
if event:
|
||||
db_session.delete(event)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
|
||||
# --- НОВЫЕ РОУТЫ: ИМПОРТ ICS ---
|
||||
@app.route("/admin/schedule/import", methods=["GET", "POST"])
|
||||
@require_role(["admin", "professor"])
|
||||
def import_ics():
|
||||
if request.method == "POST":
|
||||
if 'file' not in request.files:
|
||||
return "Файл не загружен", 400
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return "Файл не выбран", 400
|
||||
|
||||
if file and file.filename.endswith('.ics'):
|
||||
try:
|
||||
cal = Calendar.from_ical(file.read())
|
||||
count = 0
|
||||
with Session(engine) as db_session:
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
summary = str(component.get('summary', 'Без названия'))
|
||||
dtstart_prop = component.get('dtstart')
|
||||
dtend_prop = component.get('dtend')
|
||||
|
||||
if not dtstart_prop or not dtend_prop:
|
||||
continue
|
||||
|
||||
dtstart = dtstart_prop.dt
|
||||
dtend = dtend_prop.dt
|
||||
|
||||
# Обработка событий на весь день (datetime.date)
|
||||
if not isinstance(dtstart, datetime):
|
||||
dtstart = datetime.combine(dtstart, datetime.min.time())
|
||||
if not isinstance(dtend, datetime):
|
||||
dtend = datetime.combine(dtend, datetime.min.time())
|
||||
|
||||
# Делаем timezone-aware (если нет зоны, считаем UTC)
|
||||
if dtstart.tzinfo is None:
|
||||
dtstart = dtstart.replace(tzinfo=timezone.utc)
|
||||
if dtend.tzinfo is None:
|
||||
dtend = dtend.replace(tzinfo=timezone.utc)
|
||||
|
||||
loc = str(component.get('location', ''))
|
||||
desc = str(component.get('description', ''))
|
||||
|
||||
new_event = Event(
|
||||
title=summary,
|
||||
event_type='lecture', # По умолчанию
|
||||
start_time=dtstart.astimezone(timezone.utc),
|
||||
end_time=dtend.astimezone(timezone.utc),
|
||||
location=loc,
|
||||
description=desc,
|
||||
is_public=True # Импортированные события делаем публичными
|
||||
)
|
||||
db_session.add(new_event)
|
||||
count += 1
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_schedule"))
|
||||
except Exception as e:
|
||||
return f"Ошибка при чтении файла .ics: {e}", 400
|
||||
return render_template("admin_schedule_import.html")
|
||||
|
||||
# --- ПРОЕКТЫ ---
|
||||
@app.route("/projects")
|
||||
def projects_list():
|
||||
user_role = session.get("role")
|
||||
user_id = session.get("user_id")
|
||||
with Session(engine) as db_session:
|
||||
projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all()
|
||||
member_project_ids = []
|
||||
if user_id:
|
||||
memberships = db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all()
|
||||
member_project_ids = [m.project_id for m in memberships]
|
||||
|
||||
filtered_projects = []
|
||||
for p in projects:
|
||||
if p.visibility == "public":
|
||||
filtered_projects.append(p)
|
||||
elif p.visibility == "internal" and user_id:
|
||||
filtered_projects.append(p)
|
||||
elif p.visibility == "private" and user_id and (p.owner_id == user_id or p.id in member_project_ids or user_role == "admin"):
|
||||
filtered_projects.append(p)
|
||||
|
||||
return render_template("projects.html", projects=filtered_projects, role=user_role, user_id=user_id, member_ids=member_project_ids)
|
||||
|
||||
@app.route("/projects/new", methods=["GET", "POST"])
|
||||
@require_role(["admin", "professor", "lead"])
|
||||
def project_new():
|
||||
if request.method == "POST":
|
||||
with Session(engine) as db_session:
|
||||
new_proj = Project(
|
||||
title=request.form.get("title"), description=request.form.get("description"),
|
||||
status=request.form.get("status"), visibility=request.form.get("visibility"),
|
||||
gitea_url=request.form.get("gitea_url"), owner_id=session.get("user_id")
|
||||
)
|
||||
db_session.add(new_proj)
|
||||
db_session.commit()
|
||||
db_session.refresh(new_proj)
|
||||
db_session.add(ProjectMember(project_id=new_proj.id, user_id=session.get("user_id"), role="lead"))
|
||||
db_session.commit()
|
||||
return redirect(url_for("projects_list"))
|
||||
return render_template("project_form.html", project=None, is_edit=False)
|
||||
|
||||
@app.route("/projects/<int:project_id>")
|
||||
def project_detail(project_id):
|
||||
user_id = session.get("user_id")
|
||||
user_role = session.get("role")
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
members = db_session.exec(select(ProjectMember, User).join(User, ProjectMember.user_id == User.id).where(ProjectMember.project_id == project_id)).all()
|
||||
is_member = any(m[0].user_id == user_id for m in members)
|
||||
|
||||
if proj.visibility == "private" and not is_member and user_role != "admin":
|
||||
return "403: Это приватный проект", 403
|
||||
if proj.visibility == "internal" and not user_id:
|
||||
return redirect(url_for("login"))
|
||||
return render_template("project_detail.html", project=proj, members=members, is_member=is_member, user_id=user_id, role=user_role)
|
||||
|
||||
@app.route("/projects/<int:project_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def project_edit(project_id):
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first()
|
||||
is_lead = membership and membership.role == "lead"
|
||||
|
||||
if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and not is_lead:
|
||||
return "403: У вас нет прав на редактирование", 403
|
||||
|
||||
if request.method == "POST":
|
||||
proj.title = request.form.get("title")
|
||||
proj.description = request.form.get("description")
|
||||
proj.status = request.form.get("status")
|
||||
proj.visibility = request.form.get("visibility")
|
||||
proj.gitea_url = request.form.get("gitea_url")
|
||||
db_session.add(proj)
|
||||
db_session.commit()
|
||||
return redirect(url_for("project_detail", project_id=proj.id))
|
||||
return render_template("project_form.html", project=proj, is_edit=True)
|
||||
|
||||
@app.route("/projects/<int:project_id>/members/add", methods=["POST"])
|
||||
@login_required
|
||||
def project_add_member(project_id):
|
||||
username = request.form.get("username")
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first()
|
||||
if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"):
|
||||
return "403: Нет прав", 403
|
||||
|
||||
user_to_add = db_session.exec(select(User).where(User.username == username)).first()
|
||||
if not user_to_add: return redirect(url_for("project_detail", project_id=project_id))
|
||||
|
||||
existing = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id)).first()
|
||||
if not existing:
|
||||
db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role="member"))
|
||||
db_session.commit()
|
||||
return redirect(url_for("project_detail", project_id=project_id))
|
||||
|
||||
@app.route("/projects/<int:project_id>/members/<int:user_id>/remove", methods=["POST"])
|
||||
@login_required
|
||||
def project_remove_member(project_id, user_id):
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if not proj: return "Проект не найден", 404
|
||||
|
||||
membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first()
|
||||
if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"):
|
||||
return "403: Нет прав", 403
|
||||
|
||||
if user_id == proj.owner_id: return "Нельзя удалить владельца", 400
|
||||
|
||||
to_remove = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)).first()
|
||||
if to_remove:
|
||||
db_session.delete(to_remove)
|
||||
db_session.commit()
|
||||
return redirect(url_for("project_detail", project_id=project_id))
|
||||
|
||||
@app.route("/projects/<int:project_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def project_delete(project_id):
|
||||
with Session(engine) as db_session:
|
||||
proj = db_session.get(Project, project_id)
|
||||
if proj and (session.get("role") == "admin" or proj.owner_id == session.get("user_id")):
|
||||
for m in db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all():
|
||||
db_session.delete(m)
|
||||
db_session.delete(proj)
|
||||
db_session.commit()
|
||||
return redirect(url_for("projects_list"))
|
||||
|
||||
# --- ФАЙЛЫ ---
|
||||
@app.route("/files")
|
||||
@login_required
|
||||
def files_list():
|
||||
with Session(engine) as db_session:
|
||||
files = db_session.exec(select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id).order_by(PortalFile.uploaded_at.desc())).all()
|
||||
return render_template("files.html", files=files)
|
||||
|
||||
@app.route("/files/upload", methods=["POST"])
|
||||
@login_required
|
||||
def file_upload():
|
||||
if 'file' not in request.files: return redirect(url_for('files_list'))
|
||||
file = request.files['file']
|
||||
if file.filename == '': return redirect(url_for('files_list'))
|
||||
if file:
|
||||
original_name = file.filename
|
||||
ext = os.path.splitext(original_name)[1]
|
||||
stored_name = f"{uuid.uuid4().hex}{ext}"
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name)
|
||||
file.save(filepath)
|
||||
with Session(engine) as db_session:
|
||||
db_session.add(PortalFile(original_name=original_name, stored_name=stored_name, size_bytes=os.path.getsize(filepath), uploaded_by=session.get("user_id")))
|
||||
db_session.commit()
|
||||
return redirect(url_for('files_list'))
|
||||
|
||||
@app.route("/files/download/<int:file_id>")
|
||||
@login_required
|
||||
def file_download(file_id):
|
||||
with Session(engine) as db_session:
|
||||
db_file = db_session.get(PortalFile, file_id)
|
||||
if db_file:
|
||||
return send_from_directory(app.config['UPLOAD_FOLDER'], db_file.stored_name, as_attachment=True, download_name=db_file.original_name)
|
||||
return "Файл не найден", 404
|
||||
|
||||
@app.route("/files/<int:file_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def file_delete(file_id):
|
||||
with Session(engine) as db_session:
|
||||
db_file = db_session.get(PortalFile, file_id)
|
||||
if db_file and (session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id")):
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name)
|
||||
if os.path.exists(filepath): os.remove(filepath)
|
||||
db_session.delete(db_file)
|
||||
db_session.commit()
|
||||
return redirect(url_for("files_list"))
|
||||
|
||||
# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ ---
|
||||
@app.route("/admin/users")
|
||||
@require_role("admin")
|
||||
def admin_users():
|
||||
with Session(engine) as db_session:
|
||||
users = db_session.exec(select(User).order_by(User.username)).all()
|
||||
return render_template("admin_users.html", users=users)
|
||||
|
||||
@app.route("/admin/users/<int:user_id>/role", methods=["POST"])
|
||||
@require_role("admin")
|
||||
def change_user_role(user_id):
|
||||
new_role = request.form.get("role")
|
||||
valid_roles = ["admin", "professor", "lead", "member", "student", "guest"]
|
||||
if new_role not in valid_roles: return "Ошибка", 400
|
||||
with Session(engine) as db_session:
|
||||
user = db_session.get(User, user_id)
|
||||
if user:
|
||||
if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400
|
||||
user.role = new_role
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
@app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
|
||||
@require_role("admin")
|
||||
def delete_user(user_id):
|
||||
if user_id == session.get("user_id"): return "Нельзя удалить себя", 400
|
||||
with Session(engine) as db_session:
|
||||
for s in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all():
|
||||
db_session.delete(s)
|
||||
user = db_session.get(User, user_id)
|
||||
if user:
|
||||
db_session.delete(user)
|
||||
db_session.commit()
|
||||
return redirect(url_for("admin_users"))
|
||||
|
|
@ -4,12 +4,12 @@ from datetime import datetime, timezone
|
|||
|
||||
class User(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
gitea_id: Optional[int] = Field(default=None, unique=True, index=True) # Теперь может быть None, если регаемся локально
|
||||
gitea_id: Optional[int] = Field(default=None, unique=True, index=True)
|
||||
username: str = Field(unique=True, index=True)
|
||||
password_hash: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[str] = Field(default=None, unique=True)
|
||||
organization: Optional[str] = None # ННГУ, Неймарк, Ядро и т.д.
|
||||
organization: Optional[str] = None
|
||||
role: str = Field(default="student")
|
||||
is_active: bool = Field(default=True)
|
||||
is_verified: bool = Field(default=False)
|
||||
|
|
@ -33,6 +33,8 @@ class Event(SQLModel, table=True):
|
|||
description: Optional[str] = None
|
||||
status: str = Field(default="scheduled")
|
||||
is_public: bool = Field(default=True)
|
||||
google_event_id: Optional[str] = Field(default=None, unique=True, index=True)
|
||||
manually_edited: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class GlobalStatus(SQLModel, table=True):
|
||||
|
|
@ -46,12 +48,20 @@ class Project(SQLModel, table=True):
|
|||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: str = Field(default="idea")
|
||||
status: str = Field(default="idea") # idea, in_progress, completed, frozen
|
||||
visibility: str = Field(default="public") # public, internal, private
|
||||
gitea_url: Optional[str] = None
|
||||
owner_id: int = Field(foreign_key="user.id")
|
||||
is_public: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# --- НОВАЯ МОДЕЛЬ ---
|
||||
class ProjectMember(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
project_id: int = Field(foreign_key="project.id")
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
role: str = Field(default="member") # lead, member
|
||||
joined_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class PortalFile(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
original_name: str
|
||||
|
|
|
|||
100
app/templates/admin_event_edit.html
Normal file
100
app/templates/admin_event_edit.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Редактирование события — Morozovka Portal</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
|
||||
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
h2 { color: #58a6ff; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; }
|
||||
|
||||
.form-row { display: flex; gap: 15px; margin-bottom: 15px; flex-wrap: wrap; }
|
||||
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
|
||||
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
|
||||
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
|
||||
textarea { resize: vertical; min-height: 100px; }
|
||||
|
||||
button[type="submit"] { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||
button[type="submit"]:hover { background: #2ea043; }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/admin/schedule" class="back-link">← Назад к расписанию</a>
|
||||
<h2>Редактирование события</h2>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input type="text" name="title" value="{{ event.title }}" required>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0.5;">
|
||||
<label>Тип</label>
|
||||
<select name="event_type">
|
||||
<option value="lecture" {% if event.event_type == 'lecture' %}selected{% endif %}>Лекция</option>
|
||||
<option value="workshop" {% if event.event_type == 'workshop' %}selected{% endif %}>Мастер-класс</option>
|
||||
<option value="meeting" {% if event.event_type == 'meeting' %}selected{% endif %}>Встреча</option>
|
||||
<option value="personal" {% if event.event_type == 'personal' %}selected{% endif %}>Личное</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Место</label>
|
||||
<input type="text" name="location" value="{{ event.location or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Начало</label>
|
||||
<input type="text" class="flatpickr" name="start_time" required value="{{ event.start_time|msk_strftime('%Y-%m-%d %H:%M') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Конец</label>
|
||||
<input type="text" class="flatpickr" name="end_time" required value="{{ event.end_time|msk_strftime('%Y-%m-%d %H:%M') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="align-items: center;">
|
||||
<div class="form-group" style="flex-direction: row; gap: 10px; align-items: center;">
|
||||
<input type="checkbox" name="is_public" id="is_public" {% if event.is_public %}checked{% endif %}>
|
||||
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное событие</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea name="description">{{ event.description or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Сохранить изменения</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
flatpickr(".flatpickr", {
|
||||
enableTime: true,
|
||||
dateFormat: "Y-m-d H:i",
|
||||
time_24hr: true,
|
||||
locale: "ru",
|
||||
minuteIncrement: 15
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
100
app/templates/admin_event_edit.html.bak
Normal file
100
app/templates/admin_event_edit.html.bak
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Редактирование события — Morozovka Portal</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
|
||||
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
h2 { color: #58a6ff; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; }
|
||||
|
||||
.form-row { display: flex; gap: 15px; margin-bottom: 15px; flex-wrap: wrap; }
|
||||
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
|
||||
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
|
||||
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
|
||||
textarea { resize: vertical; min-height: 100px; }
|
||||
|
||||
button[type="submit"] { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||
button[type="submit"]:hover { background: #2ea043; }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/admin/schedule" class="back-link">← Назад к расписанию</a>
|
||||
<h2>Редактирование события</h2>
|
||||
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input type="text" name="title" value="{{ event.title }}" required>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0.5;">
|
||||
<label>Тип</label>
|
||||
<select name="event_type">
|
||||
<option value="lecture" {% if event.event_type == 'lecture' %}selected{% endif %}>Лекция</option>
|
||||
<option value="workshop" {% if event.event_type == 'workshop' %}selected{% endif %}>Мастер-класс</option>
|
||||
<option value="meeting" {% if event.event_type == 'meeting' %}selected{% endif %}>Встреча</option>
|
||||
<option value="personal" {% if event.event_type == 'personal' %}selected{% endif %}>Личное</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Место</label>
|
||||
<input type="text" name="location" value="{{ event.location or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Начало</label>
|
||||
<input type="text" class="flatpickr" name="start_time" required value="{{ event.start_time.strftime('%Y-%m-%d %H:%M') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Конец</label>
|
||||
<input type="text" class="flatpickr" name="end_time" required value="{{ event.end_time.strftime('%Y-%m-%d %H:%M') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="align-items: center;">
|
||||
<div class="form-group" style="flex-direction: row; gap: 10px; align-items: center;">
|
||||
<input type="checkbox" name="is_public" id="is_public" {% if event.is_public %}checked{% endif %}>
|
||||
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное событие</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea name="description">{{ event.description or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Сохранить изменения</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
flatpickr(".flatpickr", {
|
||||
enableTime: true,
|
||||
dateFormat: "Y-m-d H:i",
|
||||
time_24hr: true,
|
||||
locale: "ru",
|
||||
minuteIncrement: 15
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,45 +3,81 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Управление расписанием — Morozovka Portal</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
|
||||
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 900px; margin: 0 auto; }
|
||||
h2 { color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
.header-actions { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; }
|
||||
h2 { color: #58a6ff; margin: 0; }
|
||||
h3 { color: #8b949e; margin-top: 30px; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||
|
||||
.form-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; }
|
||||
.form-group { display: flex; flex-direction: column; flex: 1; }
|
||||
.form-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
|
||||
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
|
||||
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
|
||||
button { padding: 10px 15px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||
button:hover { background: #2ea043; }
|
||||
button.danger { background: #da3633; }
|
||||
.quick-btns { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
|
||||
.quick-btn { padding: 8px 15px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; font-size: 0.85rem; color: white; transition: opacity 0.2s; }
|
||||
.quick-btn:hover { opacity: 0.8; }
|
||||
.btn-green { background: #238636; }
|
||||
.btn-yellow { background: #d29922; }
|
||||
.btn-red { background: #da3633; }
|
||||
.btn-grey { background: #30363d; }
|
||||
|
||||
button[type="submit"] { padding: 10px 15px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; height: 38px; }
|
||||
button[type="submit"]:hover { background: #2ea043; }
|
||||
button.danger { background: #da3633; padding: 6px 10px; font-size: 0.8rem; height: auto; }
|
||||
button.danger:hover { background: #f85149; }
|
||||
button.edit-btn { background: #1f6feb; padding: 6px 10px; font-size: 0.8rem; height: auto; text-decoration: none; color: white; border-radius: 5px; margin-right: 5px; }
|
||||
button.edit-btn:hover { background: #388bfd; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #30363d; }
|
||||
th { background: #21262d; color: #8b949e; }
|
||||
th { background: #21262d; color: #8b949e; font-size: 0.85rem; }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
.back-link:hover { color: #58a6ff; }
|
||||
|
||||
.action-buttons a { display: inline-block; padding: 8px 15px; border-radius: 5px; text-decoration: none; font-weight: bold; font-size: 0.85rem; margin-left: 10px; color: white; }
|
||||
.btn-import { background: #a371f7; }
|
||||
.btn-export { background: #21262d; border: 1px solid #30363d; }
|
||||
.btn-export:hover { background: #30363d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← На главную</a>
|
||||
<h2>Управление расписанием и статусом</h2>
|
||||
|
||||
<!-- Блок 1: Статус -->
|
||||
<div class="header-actions">
|
||||
<h2>Управление расписанием</h2>
|
||||
<div class="action-buttons">
|
||||
<a href="/admin/schedule/import" class="btn-import">📥 Импорт (.ics)</a>
|
||||
<a href="/schedule/export.ics" class="btn-export">📤 Экспорт (.ics)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Текущий статус (выводится на главной странице)</h3>
|
||||
<form action="/admin/schedule/status" method="POST">
|
||||
<h3>Текущий статус</h3>
|
||||
<div class="quick-btns">
|
||||
<button type="button" class="quick-btn btn-green" onclick="setFastStatus('on_place', 0, 'На месте')">✅ Я на месте</button>
|
||||
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 5, 'Задерживаюсь на 5 мин')">⏳ Опоздаю на 5 мин</button>
|
||||
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 15, 'Задерживаюсь на 15 мин')">⏳ Опоздаю на 15 мин</button>
|
||||
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 30, 'Задерживаюсь на 30 мин')">⏳ Опоздаю на 30 мин</button>
|
||||
<button type="button" class="quick-btn btn-grey" onclick="setFastStatus('away', 0, 'Вне вуза')">🚶 Вне вуза</button>
|
||||
</div>
|
||||
|
||||
<form action="/admin/schedule/status" method="POST" id="status-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Статус</label>
|
||||
<select name="status">
|
||||
<label>Статус (вручную)</label>
|
||||
<select name="status" id="status-select">
|
||||
<option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option>
|
||||
<option value="on_place" {% if status.status == 'on_place' %}selected{% endif %}>На месте</option>
|
||||
<option value="delayed" {% if status.status == 'delayed' %}selected{% endif %}>Опаздываю</option>
|
||||
|
|
@ -51,18 +87,17 @@
|
|||
</div>
|
||||
<div class="form-group" style="flex: 0.5;">
|
||||
<label>Опоздание (мин)</label>
|
||||
<input type="number" name="delay_minutes" value="{{ status.delay_minutes or 0 }}" min="0">
|
||||
<input type="number" name="delay_minutes" id="delay-input" value="{{ status.delay_minutes or 0 }}" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Сообщение для студентов</label>
|
||||
<input type="text" name="message" value="{{ status.message or '' }}" placeholder="Например: задерживаюсь на 15 минут">
|
||||
<input type="text" name="message" id="message-input" value="{{ status.message or '' }}" placeholder="Например: задерживаюсь на 15 минут">
|
||||
</div>
|
||||
<button type="submit">Обновить статус</button>
|
||||
<button type="submit">Обновить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Блок 2: Добавление события -->
|
||||
<div class="card">
|
||||
<h3>Добавить новое событие</h3>
|
||||
<form action="/admin/schedule/event/add" method="POST">
|
||||
|
|
@ -88,11 +123,11 @@
|
|||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Начало</label>
|
||||
<input type="datetime-local" name="start_time" required>
|
||||
<input type="text" class="flatpickr" name="start_time" required placeholder="Выберите дату и время">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Конец</label>
|
||||
<input type="datetime-local" name="end_time" required>
|
||||
<input type="text" class="flatpickr" name="end_time" required placeholder="Выберите дату и время">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0.5; flex-direction: row; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" name="is_public" checked id="is_public">
|
||||
|
|
@ -109,9 +144,8 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Блок 3: Список событий -->
|
||||
<div class="card">
|
||||
<h3>Запланированные события</h3>
|
||||
<h3>Последние 50 событий</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -125,23 +159,41 @@
|
|||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td>{{ event.start_time.strftime('%d.%m %H:%M') }} - {{ event.end_time.strftime('%H:%M') }}</td>
|
||||
<td>{{ event.start_time|msk_strftime('%d.%m.%Y %H:%M') }}<br><small style="color:#8b949e">до {{ event.end_time|msk_strftime('%H:%M') }}</small></td>
|
||||
<td><strong>{{ event.title }}</strong><br><small style="color:#8b949e">{{ event.location or '' }}</small></td>
|
||||
<td>{{ event.event_type }}</td>
|
||||
<td>{{ 'Да' if event.is_public else 'Нет' }}</td>
|
||||
<td>
|
||||
<td style="display:flex; gap: 5px;">
|
||||
<a href="/admin/schedule/event/{{ event.id }}/edit" class="edit-btn">✎ Ред.</a>
|
||||
<form action="/admin/schedule/event/{{ event.id }}/delete" method="POST" onsubmit="return confirm('Удалить событие?');">
|
||||
<button type="submit" class="danger">Удалить</button>
|
||||
<button type="submit" class="danger">✕ Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" style="text-align: center; color: #8b949e;">Нет запланированных событий</td></tr>
|
||||
<tr><td colspan="5" style="text-align: center; color: #8b949e;">Нет событий</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
flatpickr(".flatpickr", {
|
||||
enableTime: true,
|
||||
dateFormat: "Y-m-d H:i",
|
||||
time_24hr: true,
|
||||
locale: "ru",
|
||||
minuteIncrement: 15,
|
||||
defaultDate: "today"
|
||||
});
|
||||
|
||||
function setFastStatus(status, minutes, message) {
|
||||
document.getElementById('status-select').value = status;
|
||||
document.getElementById('delay-input').value = minutes;
|
||||
document.getElementById('message-input').value = message;
|
||||
document.getElementById('status-form').submit();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
199
app/templates/admin_schedule.html.bak
Normal file
199
app/templates/admin_schedule.html.bak
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Управление расписанием — Morozovka Portal</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ru.js"></script>
|
||||
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
.header-actions { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; }
|
||||
h2 { color: #58a6ff; margin: 0; }
|
||||
h3 { color: #8b949e; margin-top: 30px; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||
|
||||
.form-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
|
||||
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
|
||||
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
|
||||
.quick-btns { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
|
||||
.quick-btn { padding: 8px 15px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; font-size: 0.85rem; color: white; transition: opacity 0.2s; }
|
||||
.quick-btn:hover { opacity: 0.8; }
|
||||
.btn-green { background: #238636; }
|
||||
.btn-yellow { background: #d29922; }
|
||||
.btn-red { background: #da3633; }
|
||||
.btn-grey { background: #30363d; }
|
||||
|
||||
button[type="submit"] { padding: 10px 15px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; height: 38px; }
|
||||
button[type="submit"]:hover { background: #2ea043; }
|
||||
button.danger { background: #da3633; padding: 6px 10px; font-size: 0.8rem; height: auto; }
|
||||
button.danger:hover { background: #f85149; }
|
||||
button.edit-btn { background: #1f6feb; padding: 6px 10px; font-size: 0.8rem; height: auto; text-decoration: none; color: white; border-radius: 5px; margin-right: 5px; }
|
||||
button.edit-btn:hover { background: #388bfd; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #30363d; }
|
||||
th { background: #21262d; color: #8b949e; font-size: 0.85rem; }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
.back-link:hover { color: #58a6ff; }
|
||||
|
||||
.action-buttons a { display: inline-block; padding: 8px 15px; border-radius: 5px; text-decoration: none; font-weight: bold; font-size: 0.85rem; margin-left: 10px; color: white; }
|
||||
.btn-import { background: #a371f7; }
|
||||
.btn-export { background: #21262d; border: 1px solid #30363d; }
|
||||
.btn-export:hover { background: #30363d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← На главную</a>
|
||||
|
||||
<div class="header-actions">
|
||||
<h2>Управление расписанием</h2>
|
||||
<div class="action-buttons">
|
||||
<a href="/admin/schedule/import" class="btn-import">📥 Импорт (.ics)</a>
|
||||
<a href="/schedule/export.ics" class="btn-export">📤 Экспорт (.ics)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Текущий статус</h3>
|
||||
<div class="quick-btns">
|
||||
<button type="button" class="quick-btn btn-green" onclick="setFastStatus('on_place', 0, 'На месте')">✅ Я на месте</button>
|
||||
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 5, 'Задерживаюсь на 5 мин')">⏳ Опоздаю на 5 мин</button>
|
||||
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 15, 'Задерживаюсь на 15 мин')">⏳ Опоздаю на 15 мин</button>
|
||||
<button type="button" class="quick-btn btn-yellow" onclick="setFastStatus('delayed', 30, 'Задерживаюсь на 30 мин')">⏳ Опоздаю на 30 мин</button>
|
||||
<button type="button" class="quick-btn btn-grey" onclick="setFastStatus('away', 0, 'Вне вуза')">🚶 Вне вуза</button>
|
||||
</div>
|
||||
|
||||
<form action="/admin/schedule/status" method="POST" id="status-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Статус (вручную)</label>
|
||||
<select name="status" id="status-select">
|
||||
<option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option>
|
||||
<option value="on_place" {% if status.status == 'on_place' %}selected{% endif %}>На месте</option>
|
||||
<option value="delayed" {% if status.status == 'delayed' %}selected{% endif %}>Опаздываю</option>
|
||||
<option value="away" {% if status.status == 'away' %}selected{% endif %}>Вне вуза</option>
|
||||
<option value="cancelled" {% if status.status == 'cancelled' %}selected{% endif %}>Отменено</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0.5;">
|
||||
<label>Опоздание (мин)</label>
|
||||
<input type="number" name="delay_minutes" id="delay-input" value="{{ status.delay_minutes or 0 }}" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Сообщение для студентов</label>
|
||||
<input type="text" name="message" id="message-input" value="{{ status.message or '' }}" placeholder="Например: задерживаюсь на 15 минут">
|
||||
</div>
|
||||
<button type="submit">Обновить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Добавить новое событие</h3>
|
||||
<form action="/admin/schedule/event/add" method="POST">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex: 2;">
|
||||
<label>Название</label>
|
||||
<input type="text" name="title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип</label>
|
||||
<select name="event_type">
|
||||
<option value="lecture">Лекция</option>
|
||||
<option value="workshop">Мастер-класс</option>
|
||||
<option value="meeting">Встреча</option>
|
||||
<option value="personal">Личное</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Место</label>
|
||||
<input type="text" name="location">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Начало</label>
|
||||
<input type="text" class="flatpickr" name="start_time" required placeholder="Выберите дату и время">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Конец</label>
|
||||
<input type="text" class="flatpickr" name="end_time" required placeholder="Выберите дату и время">
|
||||
</div>
|
||||
<div class="form-group" style="flex: 0.5; flex-direction: row; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" name="is_public" checked id="is_public">
|
||||
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea name="description"></textarea>
|
||||
</div>
|
||||
<button type="submit" style="height: 60px;">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Последние 50 событий</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата и время</th>
|
||||
<th>Название</th>
|
||||
<th>Тип</th>
|
||||
<th>Публичное</th>
|
||||
<th>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td>{{ event.start_time.strftime('%d.%m.%Y %H:%M') }}<br><small style="color:#8b949e">до {{ event.end_time.strftime('%H:%M') }}</small></td>
|
||||
<td><strong>{{ event.title }}</strong><br><small style="color:#8b949e">{{ event.location or '' }}</small></td>
|
||||
<td>{{ event.event_type }}</td>
|
||||
<td>{{ 'Да' if event.is_public else 'Нет' }}</td>
|
||||
<td style="display:flex; gap: 5px;">
|
||||
<a href="/admin/schedule/event/{{ event.id }}/edit" class="edit-btn">✎ Ред.</a>
|
||||
<form action="/admin/schedule/event/{{ event.id }}/delete" method="POST" onsubmit="return confirm('Удалить событие?');">
|
||||
<button type="submit" class="danger">✕ Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" style="text-align: center; color: #8b949e;">Нет событий</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
flatpickr(".flatpickr", {
|
||||
enableTime: true,
|
||||
dateFormat: "Y-m-d H:i",
|
||||
time_24hr: true,
|
||||
locale: "ru",
|
||||
minuteIncrement: 15,
|
||||
defaultDate: "today"
|
||||
});
|
||||
|
||||
function setFastStatus(status, minutes, message) {
|
||||
document.getElementById('status-select').value = status;
|
||||
document.getElementById('delay-input').value = minutes;
|
||||
document.getElementById('message-input').value = message;
|
||||
document.getElementById('status-form').submit();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
54
app/templates/admin_schedule_import.html
Normal file
54
app/templates/admin_schedule_import.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Импорт расписания — Morozovka Portal</title>
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
h2 { color: #58a6ff; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 30px; margin-top: 20px; }
|
||||
|
||||
.upload-area { border: 2px dashed #30363d; border-radius: 8px; padding: 40px; text-align: center; margin-bottom: 20px; transition: border-color 0.2s; }
|
||||
.upload-area:hover { border-color: #58a6ff; }
|
||||
|
||||
input[type="file"] { display: none; }
|
||||
.btn-file { display: inline-block; padding: 10px 20px; background: #21262d; color: white; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||
.btn-file:hover { background: #30363d; }
|
||||
|
||||
button[type="submit"] { width: 100%; padding: 12px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 1rem; }
|
||||
button[type="submit"]:hover { background: #2ea043; }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
.back-link:hover { color: #58a6ff; }
|
||||
|
||||
.hint { color: #8b949e; font-size: 0.85rem; margin-top: 15px; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/admin/schedule" class="back-link">← Назад к расписанию</a>
|
||||
<h2>Импорт расписания</h2>
|
||||
|
||||
<div class="card">
|
||||
<form action="/admin/schedule/import" method="POST" enctype="multipart/form-data">
|
||||
<div class="upload-area">
|
||||
<label for="file-upload" class="btn-file">Выбрать .ics файл</label>
|
||||
<input id="file-upload" type="file" name="file" accept=".ics" required onchange="document.getElementById('file-name').textContent = this.files[0].name">
|
||||
<p id="file-name" style="margin-top: 15px; color: #3fb950;">Файл не выбран</p>
|
||||
</div>
|
||||
<button type="submit">Загрузить и импортировать</button>
|
||||
</form>
|
||||
|
||||
<div class="hint">
|
||||
<b>Как выгрузить расписание из Google Calendar?</b><br>
|
||||
1. Зайдите в Google Calendar на компьютере.<br>
|
||||
2. Нажмите на три точки рядом с нужным календарем.<br>
|
||||
3. Выберите «Настройки и общий доступ».<br>
|
||||
4. Прокрутите вниз до раздела «Экспорт календаря» и скачайте ZIP-архив.<br>
|
||||
5. Распакуйте архив и загрузите сюда файл с расширением .ics.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
<td><strong>{{ db_file.original_name }}</strong></td>
|
||||
<td>{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ</td>
|
||||
<td>{{ uploader_name }}</td>
|
||||
<td>{{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>{{ db_file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td style="display: flex; gap: 15px;">
|
||||
<a href="/files/download/{{ db_file.id }}" class="btn-download">Скачать</a>
|
||||
|
||||
|
|
|
|||
83
app/templates/files.html.bak
Normal file
83
app/templates/files.html.bak
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Файлы — Morozovka Portal</title>
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
h1 { color: #58a6ff; margin-top: 0; }
|
||||
|
||||
.upload-box { background: #161b22; border: 1px dashed #30363d; border-radius: 8px; padding: 30px; text-align: center; margin-bottom: 30px; }
|
||||
.upload-box input[type="file"] { display: none; }
|
||||
.upload-label { display: inline-block; padding: 10px 20px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: bold; margin-bottom: 15px; }
|
||||
.upload-label:hover { background: #30363d; }
|
||||
.upload-btn { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; cursor: pointer; }
|
||||
.upload-btn:hover { background: #2ea043; }
|
||||
#file-name-display { margin-left: 15px; color: #8b949e; font-size: 0.9rem; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; background: #161b22; border-radius: 8px; overflow: hidden; }
|
||||
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #30363d; }
|
||||
th { background: #21262d; color: #8b949e; font-size: 0.85rem; text-transform: uppercase; }
|
||||
tr:hover { background: #21262d; }
|
||||
|
||||
.btn-download { color: #58a6ff; text-decoration: none; font-weight: bold; }
|
||||
.btn-download:hover { text-decoration: underline; }
|
||||
.btn-delete { background: none; border: none; color: #da3633; cursor: pointer; font-weight: bold; }
|
||||
.btn-delete:hover { text-decoration: underline; }
|
||||
|
||||
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← На главную</a>
|
||||
<h1>Файлохранилище</h1>
|
||||
|
||||
<div class="upload-box">
|
||||
<form action="/files/upload" method="POST" enctype="multipart/form-data">
|
||||
<label for="file-upload" class="upload-label">Выбрать файл</label>
|
||||
<input id="file-upload" type="file" name="file" required onchange="document.getElementById('file-name-display').textContent = this.files[0].name">
|
||||
<span id="file-name-display">Файл не выбран</span>
|
||||
<br><br>
|
||||
<button type="submit" class="upload-btn">Загрузить (макс 100 МБ)</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя файла</th>
|
||||
<th>Размер</th>
|
||||
<th>Загрузил</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for db_file, uploader_name in files %}
|
||||
<tr>
|
||||
<td><strong>{{ db_file.original_name }}</strong></td>
|
||||
<td>{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ</td>
|
||||
<td>{{ uploader_name }}</td>
|
||||
<td>{{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td style="display: flex; gap: 15px;">
|
||||
<a href="/files/download/{{ db_file.id }}" class="btn-download">Скачать</a>
|
||||
|
||||
{% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %}
|
||||
<form action="/files/{{ db_file.id }}/delete" method="POST" onsubmit="return confirm('Удалить файл?');">
|
||||
<button type="submit" class="btn-delete">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: #8b949e;">Файлов пока нет. Загрузите первый!</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
98
app/templates/project_detail.html
Normal file
98
app/templates/project_detail.html
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ project.title }} — Morozovka Portal</title>
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #58a6ff; margin-top: 0; margin-bottom: 10px; }
|
||||
.badges { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||
.badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; text-transform: uppercase; }
|
||||
.status-in_progress { background: #1f6feb; color: white; }
|
||||
.status-idea { background: #30363d; color: white; }
|
||||
.status-completed { background: #238636; color: white; }
|
||||
.status-frozen { background: #da3633; color: white; }
|
||||
.vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; }
|
||||
.vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; }
|
||||
.vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; }
|
||||
|
||||
.desc { font-size: 1.1rem; line-height: 1.6; color: #c9d1d9; margin-bottom: 30px; white-space: pre-wrap; }
|
||||
.repo-btn { display: inline-block; background: #21262d; color: #58a6ff; text-decoration: none; padding: 10px 20px; border-radius: 5px; border: 1px solid #30363d; font-weight: bold; margin-bottom: 30px; }
|
||||
|
||||
.section-title { border-bottom: 1px solid #30363d; padding-bottom: 10px; margin-bottom: 20px; color: #8b949e; font-size: 1.2rem; }
|
||||
|
||||
.members-list { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 30px; }
|
||||
.member { background: #161b22; border: 1px solid #30363d; padding: 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.member-name { font-weight: bold; }
|
||||
.member-role { font-size: 0.75rem; color: #8b949e; text-transform: uppercase; }
|
||||
.member-role.lead { color: #d29922; }
|
||||
|
||||
.add-member-form { display: flex; gap: 10px; margin-bottom: 40px; }
|
||||
.add-member-form input { flex: 1; padding: 10px; background: #0d1117; border: 1px solid #30363d; border-radius: 5px; color: white; }
|
||||
.btn { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; text-decoration: none; }
|
||||
.btn:hover { background: #2ea043; }
|
||||
.btn-danger { background: #da3633; }
|
||||
.btn-danger:hover { background: #f85149; }
|
||||
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/projects" class="back-link">← К проектам</a>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div>
|
||||
<h1>{{ project.title }}</h1>
|
||||
<div class="badges">
|
||||
<span class="badge status-{{ project.status }}">{{ project.status }}</span>
|
||||
<span class="badge vis-{{ project.visibility }}">{{ project.visibility }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead') %}
|
||||
<a href="/projects/{{ project.id }}/edit" class="btn" style="background: #21262d; border: 1px solid #30363d;">Редактировать</a>
|
||||
{% endif %}
|
||||
|
||||
{% if role == 'admin' or project.owner_id == user_id %}
|
||||
<form action="/projects/{{ project.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');">
|
||||
<button type="submit" class="btn btn-danger">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="desc">{{ project.description or 'Описание пока не добавлено.' }}</div>
|
||||
|
||||
{% if project.gitea_url %}
|
||||
<a href="{{ project.gitea_url }}" target="_blank" class="repo-btn">📦 Перейти в репозиторий Gitea</a>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="section-title">Участники ({{ members|length }})</h2>
|
||||
<div class="members-list">
|
||||
{% for member_obj, user in members %}
|
||||
<div class="member">
|
||||
<div>
|
||||
<div class="member-name">{{ user.full_name or user.username }}</div>
|
||||
<div class="member-role {{ member_obj.role }}">{{ member_obj.role }}</div>
|
||||
</div>
|
||||
{% if (role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead')) and user.id != project.owner_id %}
|
||||
<form action="/projects/{{ project.id }}/members/{{ user.id }}/remove" method="POST" onsubmit="return confirm('Удалить участника?');">
|
||||
<button type="submit" style="background:none; border:none; color:#da3633; cursor:pointer;">✕</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead') %}
|
||||
<h3 style="color:#8b949e; margin-bottom: 10px;">Добавить участника</h3>
|
||||
<form action="/projects/{{ project.id }}/members/add" method="POST" class="add-member-form">
|
||||
<input type="text" name="username" placeholder="Введите логин пользователя (например, testuser)" required>
|
||||
<button type="submit" class="btn">Добавить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Новый проект — Morozovka Portal</title>
|
||||
<title>{{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal</title>
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
|
|
@ -13,41 +13,44 @@
|
|||
textarea { resize: vertical; min-height: 100px; }
|
||||
button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
|
||||
button:hover { background: #2ea043; }
|
||||
.checkbox-group { flex-direction: row; align-items: center; gap: 10px; }
|
||||
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/projects" class="back-link">← К проектам</a>
|
||||
<h2>Создание нового проекта</h2>
|
||||
<a href="{{ '/projects/' + project.id|string if is_edit else '/projects' }}" class="back-link">← Назад</a>
|
||||
<h2>{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}</h2>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>Название проекта *</label>
|
||||
<input type="text" name="title" required>
|
||||
<input type="text" name="title" value="{{ project.title if is_edit else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Описание</label>
|
||||
<textarea name="description"></textarea>
|
||||
<textarea name="description">{{ project.description if is_edit else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Статус</label>
|
||||
<select name="status">
|
||||
<option value="idea">Идея</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="completed">Завершён</option>
|
||||
<option value="frozen">Заморожен</option>
|
||||
<option value="idea" {% if is_edit and project.status == 'idea' %}selected{% endif %}>Идея</option>
|
||||
<option value="in_progress" {% if is_edit and project.status == 'in_progress' %}selected{% endif %}>В работе</option>
|
||||
<option value="completed" {% if is_edit and project.status == 'completed' %}selected{% endif %}>Завершён</option>
|
||||
<option value="frozen" {% if is_edit and project.status == 'frozen' %}selected{% endif %}>Заморожен</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Видимость</label>
|
||||
<select name="visibility">
|
||||
<option value="public" {% if is_edit and project.visibility == 'public' %}selected{% endif %}>Публичный (видят все)</option>
|
||||
<option value="internal" {% if is_edit and project.visibility == 'internal' %}selected{% endif %}>Внутренний (только для своих)</option>
|
||||
<option value="private" {% if is_edit and project.visibility == 'private' %}selected{% endif %}>Приватный (только участники)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ссылка на репозиторий Gitea</label>
|
||||
<input type="url" name="gitea_url" placeholder="https://git.morozovka.space/user/repo">
|
||||
<input type="url" name="gitea_url" value="{{ project.gitea_url if is_edit and project.gitea_url else '' }}" placeholder="https://git.morozovka.space/user/repo">
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" name="is_public" id="is_public" checked>
|
||||
<label for="is_public" style="margin: 0;">Показывать публично</label>
|
||||
</div>
|
||||
<button type="submit">Создать проект</button>
|
||||
<button type="submit">{{ 'Сохранить изменения' if is_edit else 'Создать проект' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -12,18 +12,25 @@
|
|||
.btn-add:hover { background: #2ea043; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; }
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: border-color 0.2s; }
|
||||
.card:hover { border-color: #58a6ff; }
|
||||
.card h3 { margin: 0 0 10px 0; color: #e6edf3; }
|
||||
.card p { color: #8b949e; font-size: 0.9rem; flex-grow: 1; margin-bottom: 15px; }
|
||||
.status-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; margin-bottom: 10px; }
|
||||
|
||||
.badges { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; }
|
||||
.status-idea { background: #30363d; color: white; }
|
||||
.status-in_progress { background: #1f6feb; color: white; }
|
||||
.status-completed { background: #238636; color: white; }
|
||||
.status-frozen { background: #da3633; color: white; }
|
||||
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; border-top: 1px solid #30363d; padding-top: 15px; }
|
||||
.repo-link { color: #58a6ff; text-decoration: none; font-size: 0.85rem; }
|
||||
.delete-btn { background: none; border: none; color: #da3633; cursor: pointer; font-size: 0.85rem; }
|
||||
.vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; }
|
||||
.vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; }
|
||||
.vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; }
|
||||
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; border-top: 1px solid #30363d; padding-top: 15px; font-size: 0.85rem; }
|
||||
.repo-link { color: #58a6ff; text-decoration: none; }
|
||||
.delete-btn { background: none; border: none; color: #da3633; cursor: pointer; font-size: 0.85rem; padding: 0; }
|
||||
.delete-btn:hover { text-decoration: underline; }
|
||||
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
</style>
|
||||
|
|
@ -40,33 +47,31 @@
|
|||
|
||||
<div class="grid">
|
||||
{% for proj in projects %}
|
||||
<div class="card">
|
||||
<div>
|
||||
<span class="status-badge status-{{ proj.status }}">
|
||||
<a href="/projects/{{ proj.id }}" class="card">
|
||||
<div class="badges">
|
||||
<span class="badge status-{{ proj.status }}">
|
||||
{% if proj.status == 'idea' %}Идея
|
||||
{% elif proj.status == 'in_progress' %}В работе
|
||||
{% elif proj.status == 'completed' %}Завершён
|
||||
{% else %}Заморожен{% endif %}
|
||||
</span>
|
||||
<span class="badge vis-{{ proj.visibility }}">
|
||||
{% if proj.visibility == 'public' %}Публичный
|
||||
{% elif proj.visibility == 'internal' %}Внутренний
|
||||
{% else %}Приватный{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{{ proj.title }}</h3>
|
||||
<p>{{ proj.description or 'Описание отсутствует.' }}</p>
|
||||
<div class="card-footer">
|
||||
<span style="color: #8b949e;">Подробнее →</span>
|
||||
{% if proj.gitea_url %}
|
||||
<a href="{{ proj.gitea_url }}" target="_blank" class="repo-link">📦 Репозиторий</a>
|
||||
{% else %}
|
||||
<span style="color: #8b949e; font-size: 0.85rem;">Без репозитория</span>
|
||||
{% endif %}
|
||||
|
||||
{% if role == 'admin' or (role in ['professor', 'lead'] and proj.owner_id == session.get('user_id')) %}
|
||||
<form action="/projects/{{ proj.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');">
|
||||
<button type="submit" class="delete-btn">Удалить</button>
|
||||
</form>
|
||||
<span class="repo-link">📦 Есть репозиторий</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<p style="color: #8b949e;">Проектов пока нет.</p>
|
||||
<p style="color: #8b949e;">Доступных проектов пока нет.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,143 +1,469 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Расписание — Morozovka Portal</title>
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Расписание — Morozovka Portal</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; min-height: 100vh; }
|
||||
.page { max-width: 1200px; margin: 0 auto; }
|
||||
h1 { color: #58a6ff; margin: 0 0 15px; }
|
||||
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
|
||||
.back-link:hover { color: #58a6ff; }
|
||||
|
||||
.status-banner { padding: 20px; border-radius: 10px; text-align: center; margin-bottom: 30px; }
|
||||
.status-on_place { background: rgba(35, 134, 54, 0.2); border: 1px solid #238636; color: #3fb950; }
|
||||
.status-delayed { background: rgba(210, 153, 34, 0.2); border: 1px solid #d29922; color: #d29922; }
|
||||
.status-away, .status-cancelled { background: rgba(248, 81, 73, 0.2); border: 1px solid #f85149; color: #f85149; }
|
||||
.status-free { background: rgba(139, 148, 158, 0.2); border: 1px solid #8b949e; color: #8b949e; }
|
||||
.status-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; }
|
||||
.status-message { font-size: 1.1rem; }
|
||||
.status-banner { padding: 18px 20px; border-radius: 10px; text-align: center; margin-bottom: 20px; }
|
||||
.status-on_place { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
|
||||
.status-delayed { background: rgba(210,153,34,.15); border: 1px solid #d29922; color: #d29922; }
|
||||
.status-away, .status-cancelled { background: rgba(248,81,73,.15); border: 1px solid #f85149; color: #f85149; }
|
||||
.status-free { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
|
||||
.status-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 4px; }
|
||||
.status-message { font-size: .95rem; opacity: .9; }
|
||||
|
||||
.event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: flex; gap: 20px; }
|
||||
.event-time { font-family: monospace; font-size: 1.1rem; color: #58a6ff; min-width: 120px; }
|
||||
.event-details { flex: 1; }
|
||||
.event-title { font-size: 1.2rem; font-weight: bold; margin: 0 0 5px 0; }
|
||||
.event-meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 5px; }
|
||||
.event-type { display: inline-block; background: #21262d; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; text-transform: uppercase; }
|
||||
.event-desc { font-size: 0.95rem; margin-top: 10px; }
|
||||
.calendar { background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; }
|
||||
.cal-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30363d; gap: 15px; flex-wrap: wrap; }
|
||||
.cal-nav { display: flex; gap: 8px; align-items: center; }
|
||||
.btn-icon { background: #21262d; border: 1px solid #30363d; color: #e6edf3; width: 36px; height: 36px; border-radius: 6px; cursor: pointer; font-size: 1.1rem; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.btn-icon:hover { background: #30363d; }
|
||||
.btn-secondary { background: #21262d; border: 1px solid #30363d; color: #e6edf3; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .9rem; }
|
||||
.btn-secondary:hover { background: #30363d; }
|
||||
.cal-title { font-size: 1.2rem; margin: 0; color: #e6edf3; font-weight: 600; flex: 1; text-align: center; min-width: 150px; }
|
||||
.view-switcher { display: flex; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
|
||||
.view-switcher button { background: transparent; border: none; color: #8b949e; padding: 8px 14px; cursor: pointer; font-weight: 600; font-size: .9rem; transition: all .15s; }
|
||||
.view-switcher button:hover { color: #e6edf3; }
|
||||
.view-switcher button.active { background: #58a6ff; color: #fff; }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
.back-link:hover { color: #58a6ff; }
|
||||
.cal-filters { display: flex; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #30363d; flex-wrap: wrap; background: #0d1117; }
|
||||
.filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; cursor: pointer; font-size: .85rem; color: #8b949e; user-select: none; transition: all .15s; }
|
||||
.filter-chip:hover { border-color: #58a6ff; }
|
||||
.filter-chip input { display: none; }
|
||||
.filter-chip:has(input:checked) { background: rgba(88,166,255,.1); border-color: #58a6ff; color: #e6edf3; }
|
||||
|
||||
/* Стили для чата */
|
||||
.chat-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-top: 40px; display: flex; flex-direction: column; height: 400px; }
|
||||
.chat-header { padding: 15px; border-bottom: 1px solid #30363d; background: #21262d; border-radius: 8px 8px 0 0; font-weight: bold; }
|
||||
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||
.msg { max-width: 80%; padding: 10px 15px; border-radius: 8px; font-size: 0.95rem; line-height: 1.4; white-space: pre-wrap; }
|
||||
.msg.bot { background: #21262d; align-self: flex-start; border: 1px solid #30363d; }
|
||||
.msg.user { background: #1f6feb; color: white; align-self: flex-end; }
|
||||
.chat-input-area { padding: 15px; border-top: 1px solid #30363d; display: flex; gap: 10px; }
|
||||
.chat-input-area input { flex: 1; padding: 10px; border-radius: 5px; border: 1px solid #30363d; background: #0d1117; color: white; outline: none; }
|
||||
.chat-input-area button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||
.chat-input-area button:hover { background: #2ea043; }
|
||||
</style>
|
||||
.month-header { display: grid; grid-template-columns: repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
|
||||
.month-header-cell { padding: 10px; text-align: center; color: #8b949e; font-weight: 600; font-size: .75rem; text-transform: uppercase; }
|
||||
.month-grid { display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: minmax(110px,auto); }
|
||||
.month-cell { border-right: 1px solid #30363d; border-bottom: 1px solid #30363d; padding: 6px; overflow: hidden; position: relative; min-height: 110px; }
|
||||
.month-cell:nth-child(7n) { border-right: none; }
|
||||
.month-cell.other-month { background: rgba(0,0,0,.15); }
|
||||
.month-cell.other-month .month-day-num { color: #4d5560; }
|
||||
.month-cell.today { background: rgba(88,166,255,.05); }
|
||||
.month-cell.today .month-day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.month-day-num { font-size: .85rem; color: #8b949e; font-weight: 600; margin-bottom: 6px; }
|
||||
.month-event { display: flex; gap: 4px; font-size: .7rem; padding: 2px 5px; border-radius: 3px; margin-bottom: 3px; cursor: pointer; overflow: hidden; white-space: nowrap; border-left: 3px solid; }
|
||||
.month-event:hover { opacity: .85; }
|
||||
.month-event .event-time { font-weight: 600; opacity: .75; }
|
||||
.month-event .event-title { overflow: hidden; text-overflow: ellipsis; }
|
||||
.month-more { font-size: .7rem; color: #58a6ff; padding: 2px 4px; }
|
||||
|
||||
.week-view, .day-view { display: flex; flex-direction: column; }
|
||||
.week-header { display: grid; grid-template-columns: 50px repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
|
||||
.week-day-header { padding: 10px 4px; text-align: center; border-left: 1px solid #30363d; }
|
||||
.week-day-header.today .day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; margin: 0 auto; }
|
||||
.day-name { font-size: .7rem; color: #8b949e; text-transform: uppercase; font-weight: 600; margin-bottom: 4px; }
|
||||
.day-num { font-size: 1.1rem; font-weight: 600; color: #e6edf3; }
|
||||
.week-body { display: grid; grid-template-columns: 50px repeat(7,1fr); overflow-y: auto; max-height: 560px; position: relative; }
|
||||
.day-body { display: grid; grid-template-columns: 50px 1fr; overflow-y: auto; max-height: 600px; position: relative; }
|
||||
.time-gutter-col { background: #0d1117; border-right: 1px solid #30363d; }
|
||||
.hour-label { height: 40px; padding: 2px 6px; font-size: .7rem; color: #6e7681; text-align: right; }
|
||||
.day-col { position: relative; border-left: 1px solid #30363d; }
|
||||
.day-col-single { border-left: none; }
|
||||
.hour-cell { height: 40px; border-bottom: 1px solid #21262d; }
|
||||
.week-event { position: absolute; background: rgba(88,166,255,.2); border-left: 3px solid #58a6ff; border-radius: 4px; padding: 3px 6px; font-size: .7rem; color: #e6edf3; overflow: hidden; cursor: pointer; z-index: 2; min-height: 18px; transition: filter .15s; }
|
||||
.week-event:hover { filter: brightness(1.3); z-index: 10; }
|
||||
.we-time { font-size: .65rem; opacity: .85; font-weight: 600; }
|
||||
.we-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
|
||||
.we-loc { font-size: .65rem; opacity: .7; margin-top: 1px; }
|
||||
.we-desc { font-size: .7rem; opacity: .8; margin-top: 4px; line-height: 1.3; }
|
||||
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #f85149; z-index: 20; pointer-events: none; }
|
||||
.now-line::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; background: #f85149; border-radius: 50%; }
|
||||
|
||||
.event-type-lecture { background: rgba(88,166,255,.18); border-left-color: #58a6ff; }
|
||||
.event-type-workshop { background: rgba(163,113,247,.18); border-left-color: #a371f7; }
|
||||
.event-type-meeting { background: rgba(210,153,34,.18); border-left-color: #d29922; }
|
||||
.event-type-personal { background: rgba(139,148,158,.18); border-left-color: #8b949e; }
|
||||
.month-event.event-type-lecture { background: rgba(88,166,255,.15); color: #58a6ff; }
|
||||
.month-event.event-type-workshop { background: rgba(163,113,247,.15); color: #a371f7; }
|
||||
.month-event.event-type-meeting { background: rgba(210,153,34,.15); color: #d29922; }
|
||||
.month-event.event-type-personal { background: rgba(139,148,158,.15); color: #8b949e; }
|
||||
|
||||
.event-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; z-index: 1000; box-shadow: 0 20px 50px rgba(0,0,0,.6); }
|
||||
.event-popup.hidden { display: none; }
|
||||
.event-popup h3 { margin: 0 0 12px; color: #58a6ff; }
|
||||
.popup-close { position: absolute; top: 12px; right: 15px; cursor: pointer; color: #8b949e; font-size: 1.1rem; }
|
||||
.popup-close:hover { color: #f85149; }
|
||||
#popup-meta { color: #8b949e; font-size: .9rem; line-height: 1.8; }
|
||||
#popup-desc { margin-top: 15px; color: #c9d1d9; line-height: 1.5; white-space: pre-wrap; }
|
||||
|
||||
.chat-fab { position: fixed; bottom: 25px; right: 25px; width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg,#58a6ff,#a371f7); color: #fff; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: 0 6px 20px rgba(88,166,255,.4); z-index: 999; transition: transform .2s; display: flex; align-items: center; justify-content: center; }
|
||||
.chat-fab:hover { transform: scale(1.08); }
|
||||
.chat-window { position: fixed; bottom: 95px; right: 25px; width: 360px; height: 500px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; z-index: 998; box-shadow: 0 20px 50px rgba(0,0,0,.6); overflow: hidden; }
|
||||
.chat-window.hidden { display: none; }
|
||||
.chat-header { padding: 14px 18px; background: #0d1117; border-bottom: 1px solid #30363d; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
|
||||
.chat-close { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 1rem; }
|
||||
.chat-close:hover { color: #f85149; }
|
||||
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.msg { max-width: 85%; padding: 8px 12px; border-radius: 10px; font-size: .9rem; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.msg.bot { background: #21262d; border: 1px solid #30363d; align-self: flex-start; }
|
||||
.msg.user { background: #1f6feb; color: #fff; align-self: flex-end; }
|
||||
.chat-form { display: flex; padding: 10px; border-top: 1px solid #30363d; background: #0d1117; gap: 8px; }
|
||||
.chat-form input { flex: 1; padding: 10px 12px; background: #21262d; border: 1px solid #30363d; color: #fff; border-radius: 6px; outline: none; font-size: .9rem; }
|
||||
.chat-form input:focus { border-color: #58a6ff; }
|
||||
.chat-form button { padding: 10px 16px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
|
||||
.chat-form button:hover { background: #2ea043; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body { padding: 10px; }
|
||||
.cal-title { font-size: 1rem; }
|
||||
.view-switcher button { padding: 6px 10px; font-size: .8rem; }
|
||||
.chat-window { width: calc(100vw - 20px); right: 10px; bottom: 90px; height: 60vh; }
|
||||
.month-cell { min-height: 70px; }
|
||||
.month-event .event-title { display: none; }
|
||||
.week-body { max-height: 400px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← На главную</a>
|
||||
<h1 style="margin-top: 0;">Расписание</h1>
|
||||
<div class="page">
|
||||
<a href="/" class="back-link">← На главную</a>
|
||||
<h1>Расписание</h1>
|
||||
|
||||
{% if status %}
|
||||
<div class="status-banner status-{{ status.status }}">
|
||||
<div class="status-title">
|
||||
{% if status.status == 'on_place' %}На месте
|
||||
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
|
||||
{% elif status.status == 'away' %}Вне вуза
|
||||
{% elif status.status == 'cancelled' %}Отменено
|
||||
{% else %}Свободен / Вне расписания{% endif %}
|
||||
</div>
|
||||
{% if status.message %}
|
||||
<div class="status-message">{{ status.message }}</div>
|
||||
{% endif %}
|
||||
{% if status %}
|
||||
<div class="status-banner status-{{ status.status }}">
|
||||
<div class="status-title">
|
||||
{% if status.status == 'on_place' %}На месте
|
||||
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
|
||||
{% elif status.status == 'away' %}Вне вуза
|
||||
{% elif status.status == 'cancelled' %}Отменено
|
||||
{% else %}Свободен / Вне расписания{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 style="color: #8b949e; font-size: 1.2rem; border-bottom: 1px solid #30363d; padding-bottom: 10px;">Ближайшие события</h2>
|
||||
|
||||
{% for event in events %}
|
||||
<div class="event-card">
|
||||
<div class="event-time">
|
||||
{{ event.start_time.strftime('%d.%m %H:%M') }}<br>
|
||||
<span style="color: #8b949e; font-size: 0.9rem;">до {{ event.end_time.strftime('%H:%M') }}</span>
|
||||
</div>
|
||||
<div class="event-details">
|
||||
<h3 class="event-title">{{ event.title }}</h3>
|
||||
<div class="event-meta">
|
||||
{% if event.location %}📍 {{ event.location }}{% endif %}
|
||||
</div>
|
||||
<span class="event-type">{{ event.event_type }}</span>
|
||||
{% if event.description %}
|
||||
<div class="event-desc">{{ event.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: #8b949e;">Пока нет запланированных событий.</p>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Блок Чат-бота -->
|
||||
<div class="chat-container">
|
||||
<div class="chat-header">🤖 Ассистент расписания</div>
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="msg bot">Привет! Я могу подсказать статус Никиты Сергеевича, его расписание или контакты. Спроси меня о чем-нибудь.</div>
|
||||
</div>
|
||||
<form class="chat-input-area" id="chat-form">
|
||||
<input type="text" id="chat-input" placeholder="Например: Где сейчас? Что на неделе?" autocomplete="off">
|
||||
<button type="submit">Отправить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if status.message %}<div class="status-message">{{ status.message }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
<div class="calendar">
|
||||
<div class="cal-toolbar">
|
||||
<div class="cal-nav">
|
||||
<button class="btn-icon" id="cal-prev">←</button>
|
||||
<button class="btn-secondary" id="cal-today">Сегодня</button>
|
||||
<button class="btn-icon" id="cal-next">→</button>
|
||||
</div>
|
||||
<h2 class="cal-title" id="cal-title">—</h2>
|
||||
<div class="view-switcher">
|
||||
<button data-view="day">День</button>
|
||||
<button data-view="week" class="active">Неделя</button>
|
||||
<button data-view="month">Месяц</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-filters" id="cal-filters">
|
||||
<label class="filter-chip"><input type="checkbox" data-type="lecture" checked><span>📚 Лекции</span></label>
|
||||
<label class="filter-chip"><input type="checkbox" data-type="workshop" checked><span>🛠 Мастер-классы</span></label>
|
||||
<label class="filter-chip"><input type="checkbox" data-type="meeting" checked><span>👥 Встречи</span></label>
|
||||
<label class="filter-chip"><input type="checkbox" data-type="personal" checked><span>🔒 Личное</span></label>
|
||||
</div>
|
||||
<div class="cal-body" id="cal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const text = chatInput.value.trim();
|
||||
if (!text) return;
|
||||
<div id="event-popup" class="event-popup hidden">
|
||||
<span class="popup-close" id="popup-close">✕</span>
|
||||
<h3 id="popup-title"></h3>
|
||||
<div id="popup-meta"></div>
|
||||
<div id="popup-desc"></div>
|
||||
</div>
|
||||
|
||||
// Добавляем сообщение пользователя
|
||||
addMessage(text, 'user');
|
||||
chatInput.value = '';
|
||||
<button id="chat-toggle" class="chat-fab" aria-label="Открыть чат">💬</button>
|
||||
<div id="chat-window" class="chat-window hidden">
|
||||
<div class="chat-header">
|
||||
<span>🤖 Ассистент</span>
|
||||
<button id="chat-close" class="chat-close">✕</button>
|
||||
</div>
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="msg bot">Привет! Спроси меня: «Где сейчас?», «Когда следующая пара?», «Что на неделе?»</div>
|
||||
</div>
|
||||
<form id="chat-form" class="chat-form">
|
||||
<input id="chat-input" placeholder="Ваш вопрос..." autocomplete="off">
|
||||
<button type="submit">→</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
// Добавляем индикатор загрузки
|
||||
const loadingMsg = addMessage('...', 'bot');
|
||||
<script>
|
||||
const EVENTS = {{ events_json|tojson }};
|
||||
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||
const state = { view: 'week', cursor: new Date(), filters: new Set(['lecture','workshop','meeting','personal']) };
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/assistant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text })
|
||||
});
|
||||
function inMSK(d) { return new Date(d.getTime() + MSK_OFFSET_MS); }
|
||||
function fromMSK(y, m, d, h, mn) { return new Date(Date.UTC(y, m, d, h||0, mn||0) - MSK_OFFSET_MS); }
|
||||
function mskParts(d) {
|
||||
const m = inMSK(d);
|
||||
return { year: m.getUTCFullYear(), month: m.getUTCMonth(), day: m.getUTCDate(),
|
||||
hour: m.getUTCHours(), minute: m.getUTCMinutes(), weekday: m.getUTCDay() };
|
||||
}
|
||||
function startOfMSKDay(d) { const p = mskParts(d); return fromMSK(p.year, p.month, p.day); }
|
||||
function startOfMSKWeek(d) {
|
||||
const sod = startOfMSKDay(d);
|
||||
const dow = mskParts(sod).weekday;
|
||||
return new Date(sod.getTime() - ((dow + 6) % 7) * 86400000);
|
||||
}
|
||||
function startOfMSKMonth(d) { const p = mskParts(d); return fromMSK(p.year, p.month, 1); }
|
||||
function addDays(d, n) { return new Date(d.getTime() + n * 86400000); }
|
||||
function addMonths(d, n) {
|
||||
const p = mskParts(d);
|
||||
const target = new Date(Date.UTC(p.year, p.month + n, 1));
|
||||
return fromMSK(target.getUTCFullYear(), target.getUTCMonth(), 1);
|
||||
}
|
||||
function fmtTime(d) { const p = mskParts(d); return String(p.hour).padStart(2,'0')+':'+String(p.minute).padStart(2,'0'); }
|
||||
function fmtDate(d) { const p = mskParts(d); return String(p.day).padStart(2,'0')+'.'+String(p.month+1).padStart(2,'0'); }
|
||||
function fmtDateFull(d) {
|
||||
const p = mskParts(d);
|
||||
const days = ['вс','пн','вт','ср','чт','пт','сб'];
|
||||
return days[p.weekday] + ', ' + String(p.day).padStart(2,'0') + '.' + String(p.month+1).padStart(2,'0') + '.' + p.year;
|
||||
}
|
||||
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
|
||||
const MONTHS_GEN = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Заменяем индикатор загрузки на ответ бота
|
||||
loadingMsg.textContent = data.reply || 'Ошибка: пустой ответ';
|
||||
} catch (err) {
|
||||
loadingMsg.textContent = 'Ошибка соединения с сервером.';
|
||||
}
|
||||
});
|
||||
|
||||
function addMessage(text, sender) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `msg ${sender}`;
|
||||
div.textContent = text;
|
||||
chatMessages.appendChild(div);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
return div;
|
||||
function getFilteredEvents() {
|
||||
return EVENTS.filter(e => state.filters.has(e.type))
|
||||
.map(e => Object.assign({}, e, { _start: new Date(e.start), _end: new Date(e.end) }));
|
||||
}
|
||||
function eventsForDay(dayStart, dayEnd) {
|
||||
return getFilteredEvents().filter(e => e._start < dayEnd && e._end > dayStart).sort((a,b) => a._start - b._start);
|
||||
}
|
||||
function layoutOverlaps(events, dayStart, dayEnd) {
|
||||
const clipped = events.map(e => {
|
||||
const s = e._start < dayStart ? dayStart : e._start;
|
||||
const en = e._end > dayEnd ? dayEnd : e._end;
|
||||
return Object.assign({}, e, { _clipStart: s, _clipEnd: en });
|
||||
});
|
||||
const cols = []; const placed = [];
|
||||
for (const ev of clipped) {
|
||||
let col = -1;
|
||||
for (let c = 0; c < cols.length; c++) {
|
||||
if (!cols[c].some(o => ev._clipStart < o._clipEnd && ev._clipEnd > o._clipStart)) { col = c; break; }
|
||||
}
|
||||
</script>
|
||||
if (col === -1) { col = cols.length; cols.push([]); }
|
||||
cols[col].push(ev);
|
||||
placed.push({ event: ev, col: col });
|
||||
}
|
||||
const total = cols.length || 1;
|
||||
return placed.map(p => Object.assign({}, p, { leftPct: (p.col/total)*100, widthPct: (1/total)*100 }));
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const body = document.getElementById('cal-body');
|
||||
document.getElementById('cal-title').textContent = getTitle();
|
||||
if (state.view === 'month') renderMonth(body);
|
||||
else if (state.view === 'week') renderWeek(body);
|
||||
else renderDay(body);
|
||||
}
|
||||
function getTitle() {
|
||||
const p = mskParts(state.cursor);
|
||||
if (state.view === 'month') return MONTHS[p.month] + ' ' + p.year;
|
||||
if (state.view === 'week') {
|
||||
const ws = startOfMSKWeek(state.cursor); const we = addDays(ws, 6);
|
||||
const wp = mskParts(ws); const wep = mskParts(we);
|
||||
if (wp.month === wep.month) return wp.day + '–' + wep.day + ' ' + MONTHS_GEN[wp.month] + ' ' + wp.year;
|
||||
return wp.day + ' ' + MONTHS_GEN[wp.month] + ' – ' + wep.day + ' ' + MONTHS_GEN[wep.month] + ' ' + wep.year;
|
||||
}
|
||||
return fmtDateFull(state.cursor);
|
||||
}
|
||||
|
||||
function renderMonth(container) {
|
||||
const first = startOfMSKMonth(state.cursor);
|
||||
const gridStart = startOfMSKWeek(first);
|
||||
const days = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||
const today = startOfMSKDay(new Date());
|
||||
let html = '<div class="month-view"><div class="month-header">';
|
||||
for (const d of days) html += '<div class="month-header-cell">' + d + '</div>';
|
||||
html += '</div><div class="month-grid">';
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const cs = addDays(gridStart, i);
|
||||
const ce = addDays(cs, 1);
|
||||
const p = mskParts(cs);
|
||||
const isOther = p.month !== mskParts(first).month;
|
||||
const isToday = cs.getTime() === today.getTime();
|
||||
const evs = eventsForDay(cs, ce);
|
||||
html += '<div class="month-cell' + (isOther ? ' other-month' : '') + (isToday ? ' today' : '') + '">';
|
||||
html += '<div class="month-day-num">' + p.day + '</div>';
|
||||
for (const ev of evs.slice(0, 3)) {
|
||||
html += '<div class="month-event event-type-' + ev.type + '" data-event-id="' + ev.id + '"><span class="event-time">' + fmtTime(ev._start) + '</span> <span class="event-title">' + escapeHtml(ev.title) + '</span></div>';
|
||||
}
|
||||
if (evs.length > 3) html += '<div class="month-more">+' + (evs.length - 3) + ' ещё</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
container.innerHTML = html;
|
||||
attachEventHandlers();
|
||||
}
|
||||
|
||||
function renderWeek(container) {
|
||||
const weekStart = startOfMSKWeek(state.cursor);
|
||||
const today = startOfMSKDay(new Date());
|
||||
let html = '<div class="week-view"><div class="week-header"><div></div>';
|
||||
const names = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const ds = addDays(weekStart, i);
|
||||
const p = mskParts(ds);
|
||||
const isToday = ds.getTime() === today.getTime();
|
||||
html += '<div class="week-day-header' + (isToday ? ' today' : '') + '"><div class="day-name">' + names[i] + '</div><div class="day-num">' + p.day + '</div></div>';
|
||||
}
|
||||
html += '</div><div class="week-body"><div class="time-gutter-col">';
|
||||
for (let h = 0; h < 24; h++) html += '<div class="hour-label">' + String(h).padStart(2,'0') + ':00</div>';
|
||||
html += '</div>';
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const ds = addDays(weekStart, i);
|
||||
const de = addDays(ds, 1);
|
||||
const evs = eventsForDay(ds, de);
|
||||
const laid = layoutOverlaps(evs, ds, de);
|
||||
html += '<div class="day-col">';
|
||||
for (let h = 0; h < 24; h++) html += '<div class="hour-cell"></div>';
|
||||
const dayMs = 86400000;
|
||||
for (const p of laid) {
|
||||
const top = ((p.event._clipStart - ds) / dayMs) * 100;
|
||||
const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5);
|
||||
html += '<div class="week-event event-type-' + p.event.type + '" style="top:' + top + '%;height:' + height + '%;left:' + p.leftPct + '%;width:calc(' + p.widthPct + '% - 2px);" data-event-id="' + p.event.id + '">';
|
||||
html += '<div class="we-time">' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '</div>';
|
||||
html += '<div class="we-title">' + escapeHtml(p.event.title) + '</div>';
|
||||
if (p.event.location) html += '<div class="we-loc">📍 ' + escapeHtml(p.event.location) + '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
if (ds.getTime() === today.getTime()) {
|
||||
const now = new Date();
|
||||
if (now >= ds && now < de) {
|
||||
const nowFrac = (now - ds) / dayMs;
|
||||
html += '<div class="now-line" style="top:' + (nowFrac * 100) + '%;"></div>';
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
container.innerHTML = html;
|
||||
attachEventHandlers();
|
||||
if (!container.dataset.scrolled) {
|
||||
const nowParts = mskParts(new Date());
|
||||
const wb = container.querySelector('.week-body');
|
||||
if (wb) wb.scrollTop = Math.max(0, (nowParts.hour - 1) * 40);
|
||||
container.dataset.scrolled = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDay(container) {
|
||||
const ds = startOfMSKDay(state.cursor);
|
||||
const de = addDays(ds, 1);
|
||||
const evs = eventsForDay(ds, de);
|
||||
const laid = layoutOverlaps(evs, ds, de);
|
||||
let html = '<div class="day-view"><div class="day-body"><div class="time-gutter-col">';
|
||||
for (let h = 0; h < 24; h++) html += '<div class="hour-label">' + String(h).padStart(2,'0') + ':00</div>';
|
||||
html += '</div><div class="day-col day-col-single">';
|
||||
for (let h = 0; h < 24; h++) html += '<div class="hour-cell"></div>';
|
||||
const dayMs = 86400000;
|
||||
for (const p of laid) {
|
||||
const top = ((p.event._clipStart - ds) / dayMs) * 100;
|
||||
const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5);
|
||||
html += '<div class="week-event event-type-' + p.event.type + '" style="top:' + top + '%;height:' + height + '%;left:' + p.leftPct + '%;width:calc(' + p.widthPct + '% - 8px);" data-event-id="' + p.event.id + '">';
|
||||
html += '<div class="we-time">' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '</div>';
|
||||
html += '<div class="we-title">' + escapeHtml(p.event.title) + '</div>';
|
||||
if (p.event.location) html += '<div class="we-loc">📍 ' + escapeHtml(p.event.location) + '</div>';
|
||||
if (p.event.description) html += '<div class="we-desc">' + escapeHtml(p.event.description) + '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
const now = new Date();
|
||||
if (now >= ds && now < de) {
|
||||
const nowFrac = (now - ds) / dayMs;
|
||||
html += '<div class="now-line" style="top:' + (nowFrac * 100) + '%;"></div>';
|
||||
}
|
||||
html += '</div></div></div>';
|
||||
container.innerHTML = html;
|
||||
attachEventHandlers();
|
||||
}
|
||||
|
||||
function attachEventHandlers() {
|
||||
document.querySelectorAll('[data-event-id]').forEach(el => {
|
||||
el.addEventListener('click', e => { e.stopPropagation(); showEventPopup(parseInt(el.dataset.eventId)); });
|
||||
});
|
||||
}
|
||||
function showEventPopup(id) {
|
||||
const ev = EVENTS.find(e => e.id === id);
|
||||
if (!ev) return;
|
||||
const start = new Date(ev.start); const end = new Date(ev.end);
|
||||
const types = { lecture:'📚 Лекция', workshop:'🛠 Мастер-класс', meeting:'👥 Встреча', personal:'🔒 Личное' };
|
||||
document.getElementById('popup-title').textContent = ev.title;
|
||||
let meta = '<div>' + (types[ev.type] || ev.type) + '</div>';
|
||||
meta += '<div>🕐 ' + fmtDate(start) + ' ' + fmtTime(start) + ' – ' + fmtTime(end) + '</div>';
|
||||
if (ev.location) meta += '<div>📍 ' + escapeHtml(ev.location) + '</div>';
|
||||
document.getElementById('popup-meta').innerHTML = meta;
|
||||
document.getElementById('popup-desc').textContent = ev.description || '';
|
||||
document.getElementById('event-popup').classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('cal-prev').addEventListener('click', () => {
|
||||
if (state.view === 'month') state.cursor = addMonths(state.cursor, -1);
|
||||
else if (state.view === 'week') state.cursor = addDays(state.cursor, -7);
|
||||
else state.cursor = addDays(state.cursor, -1);
|
||||
render();
|
||||
});
|
||||
document.getElementById('cal-next').addEventListener('click', () => {
|
||||
if (state.view === 'month') state.cursor = addMonths(state.cursor, 1);
|
||||
else if (state.view === 'week') state.cursor = addDays(state.cursor, 7);
|
||||
else state.cursor = addDays(state.cursor, 1);
|
||||
render();
|
||||
});
|
||||
document.getElementById('cal-today').addEventListener('click', () => { state.cursor = new Date(); render(); });
|
||||
document.querySelectorAll('.view-switcher button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
state.view = btn.dataset.view;
|
||||
render();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('#cal-filters input').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) state.filters.add(cb.dataset.type);
|
||||
else state.filters.delete(cb.dataset.type);
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('popup-close').addEventListener('click', () => {
|
||||
document.getElementById('event-popup').classList.add('hidden');
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
const popup = document.getElementById('event-popup');
|
||||
if (!popup.classList.contains('hidden') && !popup.contains(e.target) && !e.target.closest('[data-event-id]')) {
|
||||
popup.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const chatWindow = document.getElementById('chat-window');
|
||||
document.getElementById('chat-toggle').addEventListener('click', () => chatWindow.classList.toggle('hidden'));
|
||||
document.getElementById('chat-close').addEventListener('click', () => chatWindow.classList.add('hidden'));
|
||||
document.getElementById('chat-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('chat-input');
|
||||
const text = input.value.trim(); if (!text) return;
|
||||
addMsg(text, 'user'); input.value = '';
|
||||
const loader = addMsg('...', 'bot');
|
||||
try {
|
||||
const res = await fetch('/api/assistant', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text }),
|
||||
});
|
||||
const data = await res.json();
|
||||
loader.textContent = data.reply || 'Ошибка';
|
||||
} catch (e) { loader.textContent = 'Ошибка соединения'; }
|
||||
});
|
||||
function addMsg(text, sender) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg ' + sender; div.textContent = text;
|
||||
const c = document.getElementById('chat-messages');
|
||||
c.appendChild(div); c.scrollTop = c.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
143
app/templates/schedule.html.bak
Normal file
143
app/templates/schedule.html.bak
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Расписание — Morozovka Portal</title>
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
|
||||
.status-banner { padding: 20px; border-radius: 10px; text-align: center; margin-bottom: 30px; }
|
||||
.status-on_place { background: rgba(35, 134, 54, 0.2); border: 1px solid #238636; color: #3fb950; }
|
||||
.status-delayed { background: rgba(210, 153, 34, 0.2); border: 1px solid #d29922; color: #d29922; }
|
||||
.status-away, .status-cancelled { background: rgba(248, 81, 73, 0.2); border: 1px solid #f85149; color: #f85149; }
|
||||
.status-free { background: rgba(139, 148, 158, 0.2); border: 1px solid #8b949e; color: #8b949e; }
|
||||
.status-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; }
|
||||
.status-message { font-size: 1.1rem; }
|
||||
|
||||
.event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: flex; gap: 20px; }
|
||||
.event-time { font-family: monospace; font-size: 1.1rem; color: #58a6ff; min-width: 120px; }
|
||||
.event-details { flex: 1; }
|
||||
.event-title { font-size: 1.2rem; font-weight: bold; margin: 0 0 5px 0; }
|
||||
.event-meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 5px; }
|
||||
.event-type { display: inline-block; background: #21262d; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; text-transform: uppercase; }
|
||||
.event-desc { font-size: 0.95rem; margin-top: 10px; }
|
||||
|
||||
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
.back-link:hover { color: #58a6ff; }
|
||||
|
||||
/* Стили для чата */
|
||||
.chat-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-top: 40px; display: flex; flex-direction: column; height: 400px; }
|
||||
.chat-header { padding: 15px; border-bottom: 1px solid #30363d; background: #21262d; border-radius: 8px 8px 0 0; font-weight: bold; }
|
||||
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||
.msg { max-width: 80%; padding: 10px 15px; border-radius: 8px; font-size: 0.95rem; line-height: 1.4; white-space: pre-wrap; }
|
||||
.msg.bot { background: #21262d; align-self: flex-start; border: 1px solid #30363d; }
|
||||
.msg.user { background: #1f6feb; color: white; align-self: flex-end; }
|
||||
.chat-input-area { padding: 15px; border-top: 1px solid #30363d; display: flex; gap: 10px; }
|
||||
.chat-input-area input { flex: 1; padding: 10px; border-radius: 5px; border: 1px solid #30363d; background: #0d1117; color: white; outline: none; }
|
||||
.chat-input-area button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||
.chat-input-area button:hover { background: #2ea043; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/" class="back-link">← На главную</a>
|
||||
<h1 style="margin-top: 0;">Расписание</h1>
|
||||
|
||||
{% if status %}
|
||||
<div class="status-banner status-{{ status.status }}">
|
||||
<div class="status-title">
|
||||
{% if status.status == 'on_place' %}На месте
|
||||
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
|
||||
{% elif status.status == 'away' %}Вне вуза
|
||||
{% elif status.status == 'cancelled' %}Отменено
|
||||
{% else %}Свободен / Вне расписания{% endif %}
|
||||
</div>
|
||||
{% if status.message %}
|
||||
<div class="status-message">{{ status.message }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 style="color: #8b949e; font-size: 1.2rem; border-bottom: 1px solid #30363d; padding-bottom: 10px;">Ближайшие события</h2>
|
||||
|
||||
{% for event in events %}
|
||||
<div class="event-card">
|
||||
<div class="event-time">
|
||||
{{ event.start_time.strftime('%d.%m %H:%M') }}<br>
|
||||
<span style="color: #8b949e; font-size: 0.9rem;">до {{ event.end_time.strftime('%H:%M') }}</span>
|
||||
</div>
|
||||
<div class="event-details">
|
||||
<h3 class="event-title">{{ event.title }}</h3>
|
||||
<div class="event-meta">
|
||||
{% if event.location %}📍 {{ event.location }}{% endif %}
|
||||
</div>
|
||||
<span class="event-type">{{ event.event_type }}</span>
|
||||
{% if event.description %}
|
||||
<div class="event-desc">{{ event.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color: #8b949e;">Пока нет запланированных событий.</p>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Блок Чат-бота -->
|
||||
<div class="chat-container">
|
||||
<div class="chat-header">🤖 Ассистент расписания</div>
|
||||
<div class="chat-messages" id="chat-messages">
|
||||
<div class="msg bot">Привет! Я могу подсказать статус Никиты Сергеевича, его расписание или контакты. Спроси меня о чем-нибудь.</div>
|
||||
</div>
|
||||
<form class="chat-input-area" id="chat-form">
|
||||
<input type="text" id="chat-input" placeholder="Например: Где сейчас? Что на неделе?" autocomplete="off">
|
||||
<button type="submit">Отправить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const text = chatInput.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Добавляем сообщение пользователя
|
||||
addMessage(text, 'user');
|
||||
chatInput.value = '';
|
||||
|
||||
// Добавляем индикатор загрузки
|
||||
const loadingMsg = addMessage('...', 'bot');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/assistant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Заменяем индикатор загрузки на ответ бота
|
||||
loadingMsg.textContent = data.reply || 'Ошибка: пустой ответ';
|
||||
} catch (err) {
|
||||
loadingMsg.textContent = 'Ошибка соединения с сервером.';
|
||||
}
|
||||
});
|
||||
|
||||
function addMessage(text, sender) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `msg ${sender}`;
|
||||
div.textContent = text;
|
||||
chatMessages.appendChild(div);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
<tr class="{% if s.session_token == current_token %}current{% endif %}">
|
||||
<td><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
|
||||
<td>{{ s.ip_address }}</td>
|
||||
<td>{{ s.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>{{ s.created_at|msk_strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
|
|||
51
app/templates/sessions.html.bak
Normal file
51
app/templates/sessions.html.bak
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Активные сессии — Morozovka Portal</title>
|
||||
<style>
|
||||
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
h2 { color: #58a6ff; }
|
||||
table { width: 100%; border-collapse: collapse; background: #161b22; margin-top: 20px; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
||||
th { background: #21262d; color: #8b949e; }
|
||||
.current { background: rgba(35, 134, 54, 0.2); }
|
||||
.btn-danger { background: #da3633; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 20px; }
|
||||
.btn-danger:hover { background: #f85149; }
|
||||
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/profile" class="back-link">← В профиль</a>
|
||||
<h2>Управление устройствами</h2>
|
||||
<p style="color: #8b949e;">Список всех мест, где выполнен вход в ваш аккаунт.</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Устройство (User-Agent)</th>
|
||||
<th>IP-адрес</th>
|
||||
<th>Дата входа</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in sessions %}
|
||||
<tr class="{% if s.session_token == current_token %}current{% endif %}">
|
||||
<td><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
|
||||
<td>{{ s.ip_address }}</td>
|
||||
<td>{{ s.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form action="/sessions/logout_all" method="POST" onsubmit="return confirm('Вы уверены, что хотите выйти на всех устройствах?');">
|
||||
<button type="submit" class="btn-danger">Выйти везде</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
177
dump_project.sh
Executable file
177
dump_project.sh
Executable file
|
|
@ -0,0 +1,177 @@
|
|||
#!/usr/bin/env bash
|
||||
# dump_project.sh — собирает всё нужное для аудита проекта в один файл
|
||||
# Запуск: sudo bash dump_project.sh
|
||||
# Результат: /root/morozovka_dump_YYYY-MM-DD_HH-MM.txt
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/morozovka-portal"
|
||||
OUT="/root/morozovka_dump_$(date +%F_%H-%M).txt"
|
||||
MAX_FILE_SIZE=$((512 * 1024)) # файлы больше 512 КБ пропускаем (обычно мусор)
|
||||
|
||||
# ----- исключения -----
|
||||
EXCLUDES=(
|
||||
"*/venv/*"
|
||||
"*/__pycache__/*"
|
||||
"*.pyc"
|
||||
"*/.git/*"
|
||||
"*/node_modules/*"
|
||||
"*.log"
|
||||
"*.sqlite"
|
||||
"*.db"
|
||||
"google_credentials.json" # СЕКРЕТ — не включаем
|
||||
"*.png" "*.jpg" "*.jpeg" "*.gif" "*.ico" "*.pdf" "*.zip" "*.tar" "*.gz" "*.woff" "*.woff2" "*.ttf"
|
||||
)
|
||||
|
||||
should_skip() {
|
||||
local f="$1"
|
||||
for pat in "${EXCLUDES[@]}"; do
|
||||
# shellcheck disable=SC2053
|
||||
[[ "$f" == $pat ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
section() {
|
||||
echo "" >> "$OUT"
|
||||
echo "================================================================================" >> "$OUT"
|
||||
echo "== $*" >> "$OUT"
|
||||
echo "================================================================================" >> "$OUT"
|
||||
}
|
||||
|
||||
dump_file() {
|
||||
local f="$1"
|
||||
[[ -f "$f" ]] || { echo "[skip: not a file] $f" >> "$OUT"; return; }
|
||||
local size
|
||||
size=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
||||
if (( size > MAX_FILE_SIZE )); then
|
||||
echo "" >> "$OUT"
|
||||
echo "---- FILE: $f (SKIPPED, size=${size}B > ${MAX_FILE_SIZE}B) ----" >> "$OUT"
|
||||
return
|
||||
fi
|
||||
echo "" >> "$OUT"
|
||||
echo "---- FILE: $f (size=${size}B) ----" >> "$OUT"
|
||||
cat "$f" >> "$OUT"
|
||||
echo "" >> "$OUT"
|
||||
echo "---- END: $f ----" >> "$OUT"
|
||||
}
|
||||
|
||||
# ----- начинаем -----
|
||||
: > "$OUT"
|
||||
echo "DUMP GENERATED: $(date -Is)" >> "$OUT"
|
||||
echo "HOSTNAME: $(hostname -f 2>/dev/null || hostname)" >> "$OUT"
|
||||
echo "UPTIME: $(uptime -p 2>/dev/null || uptime)" >> "$OUT"
|
||||
|
||||
# 1) Дерево файлов проекта
|
||||
section "TREE: $PROJECT_DIR"
|
||||
if command -v tree >/dev/null 2>&1; then
|
||||
tree -a -I 'venv|__pycache__|.git|node_modules' -L 4 "$PROJECT_DIR" >> "$OUT" 2>&1
|
||||
else
|
||||
find "$PROJECT_DIR" \
|
||||
\( -path '*/venv' -o -path '*/__pycache__' -o -path '*/.git' -o -path '*/node_modules' \) -prune -o \
|
||||
-type f -print | sort >> "$OUT"
|
||||
fi
|
||||
|
||||
# 2) requirements.txt + версия Python
|
||||
section "ENVIRONMENT"
|
||||
echo "python: $(command -v python3) $(python3 --version 2>&1)" >> "$OUT"
|
||||
if [[ -f "$PROJECT_DIR/requirements.txt" ]]; then
|
||||
echo "" >> "$OUT"
|
||||
echo "--- pip freeze (из venv) ---" >> "$OUT"
|
||||
if [[ -x "$PROJECT_DIR/venv/bin/pip" ]]; then
|
||||
"$PROJECT_DIR/venv/bin/pip" freeze >> "$OUT" 2>&1
|
||||
else
|
||||
cat "$PROJECT_DIR/requirements.txt" >> "$OUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3) Все текстовые файлы проекта
|
||||
section "PROJECT FILES ($PROJECT_DIR)"
|
||||
while IFS= read -r -d '' f; do
|
||||
should_skip "$f" && continue
|
||||
# бинарные — пропускаем по mime
|
||||
if command -v file >/dev/null 2>&1; then
|
||||
case "$(file -b --mime-encoding "$f" 2>/dev/null)" in
|
||||
binary) echo "" >> "$OUT"; echo "---- BINARY SKIPPED: $f ----" >> "$OUT"; continue ;;
|
||||
esac
|
||||
fi
|
||||
dump_file "$f"
|
||||
done < <(find "$PROJECT_DIR" \
|
||||
\( -path '*/venv' -o -path '*/__pycache__' -o -path '*/.git' -o -path '*/node_modules' \) -prune -o \
|
||||
-type f -print0 | sort -z)
|
||||
|
||||
# 4) systemd-юниты и таймеры проекта
|
||||
section "SYSTEMD UNITS"
|
||||
for u in \
|
||||
/etc/systemd/system/morozovka-portal.service \
|
||||
/etc/systemd/system/morozovka-sync.service \
|
||||
/etc/systemd/system/morozovka-sync.timer \
|
||||
/etc/systemd/system/gitea.service \
|
||||
/etc/systemd/system/bridge-webui.service; do
|
||||
[[ -f "$u" ]] && dump_file "$u" || echo "[missing] $u" >> "$OUT"
|
||||
done
|
||||
|
||||
echo "" >> "$OUT"
|
||||
echo "--- systemctl list-units (morozovka*) ---" >> "$OUT"
|
||||
systemctl list-units --type=service --type=timer --no-pager --all --no-legend 2>/dev/null \
|
||||
| grep -Ei 'morozovka|gitea|bridge' >> "$OUT" || true
|
||||
|
||||
echo "" >> "$OUT"
|
||||
echo "--- systemctl status (кратко) ---" >> "$OUT"
|
||||
for s in morozovka-portal morozovka-sync.timer gitea; do
|
||||
echo "" >> "$OUT"
|
||||
echo "### $s" >> "$OUT"
|
||||
systemctl is-enabled "$s" 2>&1 >> "$OUT" || true
|
||||
systemctl is-active "$s" 2>&1 >> "$OUT" || true
|
||||
done
|
||||
|
||||
# 5) Nginx-конфиги (сервер 3)
|
||||
section "NGINX CONFIGS (server 3)"
|
||||
for d in /etc/nginx/sites-available /etc/nginx/sites-enabled /etc/nginx/conf.d; do
|
||||
[[ -d "$d" ]] || continue
|
||||
while IFS= read -r -d '' f; do
|
||||
dump_file "$f"
|
||||
done < <(find "$d" -maxdepth 1 -type f -o -type l -print0 2>/dev/null | sort -z)
|
||||
done
|
||||
|
||||
section "NGINX TEST OUTPUT"
|
||||
nginx -t 2>&1 >> "$OUT" || true
|
||||
|
||||
# 6) PostgreSQL: список таблиц и схема (без данных)
|
||||
section "POSTGRESQL SCHEMA (morozovka_portal)"
|
||||
if command -v psql >/dev/null 2>&1 || [[ -x /usr/bin/sudo ]]; then
|
||||
sudo -u postgres psql -d morozovka_portal -c "\dt" >> "$OUT" 2>&1 || true
|
||||
sudo -u postgres psql -d morozovka_portal -c "\d+" >> "$OUT" 2>&1 || true
|
||||
echo "" >> "$OUT"
|
||||
echo "--- pg_dump --schema-only ---" >> "$OUT"
|
||||
sudo -u postgres pg_dump --schema-only --no-owner --no-privileges morozovka_portal >> "$OUT" 2>&1 || true
|
||||
else
|
||||
echo "[psql not found]" >> "$OUT"
|
||||
fi
|
||||
|
||||
# 7) Проверка, что портал отвечает
|
||||
section "PORTAL HEALTH"
|
||||
curl -sS -m 5 http://127.0.0.1:8000/healthz >> "$OUT" 2>&1 || echo "[healthz failed]" >> "$OUT"
|
||||
echo "" >> "$OUT"
|
||||
curl -sS -m 5 -o /dev/null -w "HTTP %{http_code} time=%{time_total}s\n" http://127.0.0.1:8000/ >> "$OUT" 2>&1 || true
|
||||
|
||||
# 8) Последние логи (обрезанные)
|
||||
section "RECENT LOGS (last 40 lines each)"
|
||||
for u in morozovka-portal morozovka-sync; do
|
||||
echo "" >> "$OUT"
|
||||
echo "### journalctl -u $u -n 40 --no-pager" >> "$OUT"
|
||||
journalctl -u "$u" -n 40 --no-pager >> "$OUT" 2>&1 || true
|
||||
done
|
||||
|
||||
# 9) Проверка VPN/маршрутов на всякий
|
||||
section "NETWORK (server 3)"
|
||||
ip -brief addr >> "$OUT" 2>&1 || true
|
||||
echo "" >> "$OUT"
|
||||
ip route >> "$OUT" 2>&1 || true
|
||||
|
||||
echo "" >> "$OUT"
|
||||
echo "DUMP COMPLETE: $(date -Is)" >> "$OUT"
|
||||
echo "SIZE: $(du -h "$OUT" | cut -f1)" >> "$OUT"
|
||||
|
||||
echo "Готово: $OUT"
|
||||
echo "Размер: $(du -h "$OUT" | cut -f1)"
|
||||
13
google_credentials.json
Normal file
13
google_credentials.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"type": "service_account",
|
||||
"project_id": "morozovka-portal",
|
||||
"private_key_id": "9f91b62410bd12b999277f1f74d7a428fb523d24",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC437ddiGGmsWbG\njItUKokzYMKw0b4Qz4AHy1S75B2PuLwcjB2N5K78BR84XNusrguHVHJsgxaEg8Ib\nnaw8K3yNKfQSPTe2+F8BA1QgJjaWZDJJbASIEaJzX4KOOBmqAfYc0TlQRb8Knm6Y\nB7Hqm3/UysWy5gVEED9d3ZPgds+xy+X5HreJLPM6Uf61TPpr+u8GpaFKBsTZrgfi\n/mrjm6d2SKqgz0O1nMtFQMbhWXiLK4MiRjNjQLNPC5IjD/VXInIXdqvpkUtaO1k9\n5RNr6TMlvZ0FDbZxxMYbDBEcCxEafsCSsvjucq5U2527BNeqUKty3IrKXoLAW5zG\nykv2BmmxAgMBAAECggEACl5VBsxPP72rgt9fMWio+mZG8mBHuDfPBCuO9HJnhwfo\nbZ3HyuK5vqgx4fkb7UFzTj3PT2SDEYpgi1Mf6whRq2AgOxqkV+EdAO31HKV6rRtw\nUjuvkwsRe5uDyLf+69DsLZ29XhIUqW2NIdPX/Q1cvBblmMeQ9t+GEkdgU7fjek2Z\nqf7ZFXzNAWQ8MOdAmr3NRXR8PT5tjb5e/rmu2c8B5L/qmC4ULKbaZMPQjky006pt\nL/i0af2KDUPRLaur7Fwhb3FxBWk+mtNISjzw3uMuJeAyo0l3Kd++3Erc0wewS5Oz\nOR6Ibdf82I1a+zuTRwgpg5lyt7L/qUZ3YUvyuCdb3QKBgQDqYH39ve9pxOGaj0dY\nwCJ02m0cO+pQold00xKxavKs3iJen8Q1F0RWRyWk3r/QhDq42BguX6mBiqrC28ry\nkvA0DXrSul7aMtaSaIdPabx/lCmUFFpy+XuwAiI/5ljSIJ7RZUmnIIAV5qfZ33c5\nQK9d7XEeezRbvvlvUXdRQvRHfQKBgQDJ7hAYL3AvCQ9yJ9wqrrmjq90FwwMlGb+c\niJSs89mEDTOsrPuAMCXIyhBWkdOLCfVeRAjzgEzP9YBV5JrmDqocT+5ujnbCtTBn\nhIRWdQKmBKAonrF0xOlMDJ3MjP6NeZlwvhhDK16x0I+y/CCilwKvod+w+QLtNPMW\nqLU5P6bJRQKBgDAhFH5QzKM5rOJiXdJZ0jmQySstGpboX3E8DvCj96WMEB7CTDZi\nysO05IQQwe792Fkw9vY+6r4RTbOqEujxR6ZIxloTr3q7SdjS4kZqfizsrgUckN5f\nYQjpx0K7+K1dyX9Sf2d0gxj8Pqe8/eyh3eE56yrJPh1BAFPdtNTZHPS5AoGARFfb\ngKQFnxW88ZEGFjgewoN3gey30sW9MViUoVjC5MunEqlVUdR55VRmZTZj16oeLrJu\nKSPiW3wm3GtL9dM+xehTtX1oOowifta+B5kAtXnZTwYu32uJ3mBixAlwWieuxBbC\nlXFHUn1ZNWXaEc10bkcmM3qEAA6su52MAH1HkNUCgYA7GGzM79Ms4r/VysJUK/xm\n1z33wvog3D6UeltX2blSLqsGsn0t9HbQnrYOJtQ38X4NZRO1w0I4eEMVqE0vfu5f\nNtbxbD7MCC9N0bglYUR9EuYuW4s7ge+HO1zE5STKLnHAGClqjNFPIu+crZhE3Sex\nK8UsUYvOyoXW/n5GVENwsw==\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "portal-sync@morozovka-portal.iam.gserviceaccount.com",
|
||||
"client_id": "114793071668131489151",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/portal-sync%40morozovka-portal.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
|
|
@ -1,21 +1,45 @@
|
|||
annotated-types==0.8.0
|
||||
blinker==1.9.0
|
||||
certifi==2026.10.18
|
||||
cffi==2.1.1
|
||||
charset-normalizer==3.5.2
|
||||
click==8.5.0
|
||||
cryptography==50.0.2
|
||||
Flask==3.1.3
|
||||
google-api-core==2.42.0
|
||||
google-api-python-client==2.201.0
|
||||
google-auth==2.62.0
|
||||
google-auth-httplib2==0.4.4
|
||||
google-auth-oauthlib==1.5.0
|
||||
googleapis-common-protos==1.75.5
|
||||
gunicorn==26.2.0
|
||||
httplib2==0.32.0
|
||||
icalendar==7.3.0
|
||||
idna==3.20
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.4
|
||||
oauthlib==4.0.0
|
||||
opentelemetry-api==1.45.1
|
||||
proto-plus==1.29.0
|
||||
protobuf==7.36.2
|
||||
psycopg2-binary==2.9.13
|
||||
pyasn1==0.6.4
|
||||
pyasn1_modules==0.4.2
|
||||
pycparser==3.11
|
||||
pydantic==2.14.1
|
||||
pydantic_core==2.50.1
|
||||
pyparsing==3.3.3
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2026.5
|
||||
requests==2.34.2
|
||||
requests-oauthlib==2.0.0
|
||||
six==1.17.0
|
||||
SQLAlchemy==2.1.5
|
||||
sqlmodel==0.0.48
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
tzdata==2026.5
|
||||
uritemplate==4.2.0
|
||||
urllib3==2.8.0
|
||||
Werkzeug==3.1.9
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user