feat: Finaling MVP

This commit is contained in:
Ivan Budakov 2026-10-20 03:27:14 +03:00
parent 060211f023
commit c356166844
28 changed files with 2987 additions and 331 deletions

View File

@ -1,9 +1,16 @@
import re import re
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import pytz
from sqlmodel import Session, select from sqlmodel import Session, select
from .models import Event, GlobalStatus from .models import Event, GlobalStatus
from .db import engine 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: def get_response(text: str) -> str:
"""Принимает вопрос, определяет интент и возвращает ответ.""" """Принимает вопрос, определяет интент и возвращает ответ."""
text = text.lower().strip() text = text.lower().strip()
@ -44,7 +51,7 @@ def get_response(text: str) -> str:
).first() ).first()
if next_event: 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 'не указано'}." return f"Ближайшее событие: {next_event.title} ({local_time}). Место: {next_event.location or 'не указано'}."
else: else:
return "В ближайшее время событий в расписании нет." return "В ближайшее время событий в расписании нет."
@ -52,8 +59,9 @@ def get_response(text: str) -> str:
# --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ --- # --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ ---
if re.search(r'сегодня|седня', text): if re.search(r'сегодня|седня', text):
# Берем начало сегодняшнего дня и конец сегодняшнего дня # Берем начало сегодняшнего дня и конец сегодняшнего дня
now = datetime.now(timezone.utc) now_msk = datetime.now(MSK)
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) 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) end_of_day = start_of_day + timedelta(days=1)
events = db_session.exec( events = db_session.exec(
@ -69,7 +77,7 @@ def get_response(text: str) -> str:
res = "Расписание на сегодня:\n" res = "Расписание на сегодня:\n"
for e in events: 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 return res
# --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ --- # --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ ---
@ -90,7 +98,7 @@ def get_response(text: str) -> str:
res = "Расписание на неделю:\n" res = "Расписание на неделю:\n"
for e in events[:5]: # Показываем первые 5, чтобы не спамить 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: if len(events) > 5:
res += "...и другие события (смотрите на странице)." res += "...и другие события (смотрите на странице)."
return res return res

110
app/assistant.py.bak Normal file
View 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 "Извините, я пока не понимаю этот вопрос. Попробуйте спросить: 'Где сейчас?', 'Когда следующая пара?', 'Что сегодня?' или 'Как связаться?'."

View File

@ -9,7 +9,7 @@ def get_gitea_token(code: str):
"client_secret": config.GITEA_CLIENT_SECRET, "client_secret": config.GITEA_CLIENT_SECRET,
"code": code, "code": code,
"grant_type": "authorization_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"}) response = requests.post(url, data=data, headers={"Accept": "application/json"})
return response.json().get("access_token") return response.json().get("access_token")

24
app/auth.py.bak Normal file
View 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

View File

@ -25,3 +25,4 @@ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "") SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space") 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
View 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
View 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
View 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)

View File

@ -1,22 +1,37 @@
import os import os
import uuid import uuid
import smtplib import smtplib
import pytz
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from itsdangerous import URLSafeTimedSerializer from itsdangerous import URLSafeTimedSerializer
from werkzeug.security import generate_password_hash, check_password_hash 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 .db import init_db, engine
from . import config, auth, assistant from . import config, auth, assistant
from sqlmodel import Session, select from sqlmodel import Session, select
from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from functools import wraps from functools import wraps
app = Flask(__name__) app = Flask(__name__)
app.secret_key = config.WEB_SECRET app.secret_key = config.WEB_SECRET
serializer = URLSafeTimedSerializer(app.secret_key) 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' UPLOAD_FOLDER = '/var/lib/gitea/portal-files'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
@ -84,9 +99,23 @@ def login_required(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function 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") @app.route("/healthz")
def 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("/") @app.route("/")
def index(): def index():
@ -108,34 +137,21 @@ def register():
if db_session.exec(select(User).where(User.email == email)).first(): if db_session.exec(select(User).where(User.email == email)).first():
return render_template("register.html", error="Email уже используется") return render_template("register.html", error="Email уже используется")
new_user = User( new_user = User(username=username, email=email, password_hash=generate_password_hash(password), full_name=full_name, organization=organization, role="student", is_verified=False)
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.add(new_user)
db_session.commit() db_session.commit()
token = serializer.dumps(email, salt='email-confirm') token = serializer.dumps(email, salt='email-confirm')
confirm_url = url_for('verify_email', token=token, _external=True) confirm_url = url_for('verify_email', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
confirm_url = confirm_url.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" return render_template("login.html", msg="Регистрация успешна. Проверьте почту (или логи сервера).")
send_email(email, "Подтверждение почты в Morozovka Portal", body)
return render_template("login.html", msg="Регистрация успешна. Проверьте почту для подтверждения.")
return render_template("register.html") return render_template("register.html")
@app.route("/verify_email/<token>") @app.route("/verify_email/<token>")
def verify_email(token): def verify_email(token):
try: try: email = serializer.loads(token, salt='email-confirm', max_age=86400)
email = serializer.loads(token, salt='email-confirm', max_age=86400) except Exception: return "Ссылка недействительна или устарела.", 400
except Exception:
return "Ссылка недействительна или устарела.", 400
with Session(engine) as db_session: with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first() user = db_session.exec(select(User).where(User.email == email)).first()
@ -154,27 +170,19 @@ def login():
with Session(engine) as db_session: with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.username == username)).first() 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): if not user or not user.password_hash or not check_password_hash(user.password_hash, password):
return render_template("login.html", error="Неверный логин или пароль") return render_template("login.html", error="Неверный логин или пароль")
if not user.is_verified: if not user.is_verified:
return render_template("login.html", error="Подтвердите почту перед входом") return render_template("login.html", error="Подтвердите почту перед входом")
session_token = str(uuid.uuid4()) session_token = str(uuid.uuid4())
db_session.add(UserSession( db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string))
session_token=session_token,
user_id=user.id,
ip_address=request.remote_addr,
user_agent=request.user_agent.string
))
db_session.commit() db_session.commit()
session["user_id"] = user.id session["user_id"] = user.id
session["username"] = user.username session["username"] = user.username
session["role"] = user.role session["role"] = user.role
session["session_id"] = session_token session["session_id"] = session_token
return redirect(url_for("index")) return redirect(url_for("index"))
msg = request.args.get("msg") msg = request.args.get("msg")
@ -185,7 +193,7 @@ def login_gitea():
gitea_auth_url = ( gitea_auth_url = (
f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize"
f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" 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" f"&scope=read:user"
) )
return redirect(gitea_auth_url) return redirect(gitea_auth_url)
@ -193,16 +201,11 @@ def login_gitea():
@app.route("/auth/callback") @app.route("/auth/callback")
def auth_callback(): def auth_callback():
code = request.args.get("code") code = request.args.get("code")
if not code: if not code: return "Ошибка авторизации Gitea", 400
return "Ошибка авторизации Gitea", 400
token = auth.get_gitea_token(code) token = auth.get_gitea_token(code)
if not token: if not token: return "Ошибка токена Gitea", 400
return "Ошибка токена Gitea", 400
gitea_user = auth.get_gitea_user(token) gitea_user = auth.get_gitea_user(token)
if not gitea_user: if not gitea_user: return "Ошибка профиля Gitea", 400
return "Ошибка профиля Gitea", 400
with Session(engine) as db_session: with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
@ -211,31 +214,20 @@ def auth_callback():
if user: if user:
user.gitea_id = gitea_user["id"] user.gitea_id = gitea_user["id"]
else: else:
user = User( existing_admin = db_session.exec(select(User).where(User.role == "admin")).first()
gitea_id=gitea_user["id"], bootstrap_role = "admin" if not existing_admin else "student"
username=gitea_user["login"], 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)
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
)
db_session.add(user) db_session.add(user)
db_session.commit() db_session.commit()
session_token = str(uuid.uuid4()) session_token = str(uuid.uuid4())
db_session.add(UserSession( db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string))
session_token=session_token,
user_id=user.id,
ip_address=request.remote_addr,
user_agent=request.user_agent.string
))
db_session.commit() db_session.commit()
session["user_id"] = user.id session["user_id"] = user.id
session["username"] = user.username session["username"] = user.username
session["role"] = user.role session["role"] = user.role
session["session_id"] = session_token session["session_id"] = session_token
return redirect(url_for("index")) return redirect(url_for("index"))
@app.route("/logout") @app.route("/logout")
@ -249,7 +241,6 @@ def logout():
session.clear() session.clear()
return redirect(url_for("index")) return redirect(url_for("index"))
# --- СБРОС ПАРОЛЯ ---
@app.route("/forgot_password", methods=["GET", "POST"]) @app.route("/forgot_password", methods=["GET", "POST"])
def forgot_password(): def forgot_password():
if request.method == "POST": if request.method == "POST":
@ -258,57 +249,43 @@ def forgot_password():
user = db_session.exec(select(User).where(User.email == email)).first() user = db_session.exec(select(User).where(User.email == email)).first()
if user: if user:
token = serializer.dumps(email, salt='password-reset') token = serializer.dumps(email, salt='password-reset')
reset_url = url_for('reset_password', token=token, _external=True) reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
reset_url = reset_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n")
body = f"Для сброса пароля перейди по ссылке:\n{reset_url}\n"
send_email(email, "Сброс пароля Morozovka Portal", body)
return render_template("login.html", msg="Если такой email есть, инструкция отправлена.") return render_template("login.html", msg="Если такой email есть, инструкция отправлена.")
return render_template("forgot_password.html") return render_template("forgot_password.html")
@app.route("/reset_password/<token>", methods=["GET", "POST"]) @app.route("/reset_password/<token>", methods=["GET", "POST"])
def reset_password(token): def reset_password(token):
try: try: email = serializer.loads(token, salt='password-reset', max_age=3600)
email = serializer.loads(token, salt='password-reset', max_age=3600) except Exception: return "Ссылка недействительна или устарела.", 400
except Exception:
return "Ссылка недействительна или устарела.", 400
if request.method == "POST": if request.method == "POST":
new_password = request.form.get("password")
with Session(engine) as db_session: with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first() user = db_session.exec(select(User).where(User.email == email)).first()
if user: 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.add(user)
db_session.commit() db_session.commit()
return redirect(url_for("login", msg="Пароль успешно изменен!")) return redirect(url_for("login", msg="Пароль успешно изменен!"))
return render_template("reset_password.html", token=token) return render_template("reset_password.html", token=token)
# --- УПРАВЛЕНИЕ СЕССИЯМИ ---
@app.route("/sessions") @app.route("/sessions")
@login_required @login_required
def sessions_list(): def sessions_list():
with Session(engine) as db_session: with Session(engine) as db_session:
sessions = db_session.exec( sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id")).order_by(UserSession.created_at.desc())).all()
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")) return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id"))
@app.route("/sessions/logout_all", methods=["POST"]) @app.route("/sessions/logout_all", methods=["POST"])
@login_required @login_required
def logout_all(): def logout_all():
with Session(engine) as db_session: 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 db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all():
for s in user_sessions:
db_session.delete(s) db_session.delete(s)
db_session.commit() db_session.commit()
session.clear() session.clear()
return redirect(url_for("login", msg="Вы вышли со всех устройств.")) return redirect(url_for("login", msg="Вы вышли со всех устройств."))
# --- ПРОФИЛЬ ---
@app.route("/profile") @app.route("/profile")
@login_required @login_required
def profile(): def profile():
@ -316,14 +293,58 @@ def profile():
user = db_session.get(User, session.get("user_id")) user = db_session.get(User, session.get("user_id"))
return render_template("profile.html", user=user) return render_template("profile.html", user=user)
# --- РАСПИСАНИЕ --- # --- РАСПИСАНИЕ (ПУБЛИЧНОЕ И АДМИНКА) ---
@app.route("/schedule") @app.route("/schedule")
def public_schedule(): def public_schedule():
with Session(engine) as db_session: with Session(engine) as db_session:
current_status = db_session.exec(select(GlobalStatus)).first() current_status = db_session.exec(select(GlobalStatus)).first()
now = datetime.now(timezone.utc) 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() events = db_session.exec(
return render_template("schedule.html", status=current_status, events=events) 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"]) @app.route("/api/assistant", methods=["POST"])
def api_assistant(): def api_assistant():
@ -336,8 +357,7 @@ def api_assistant():
def admin_schedule(): def admin_schedule():
with Session(engine) as db_session: with Session(engine) as db_session:
current_status = db_session.exec(select(GlobalStatus)).first() current_status = db_session.exec(select(GlobalStatus)).first()
now = datetime.now(timezone.utc) events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all()
events = db_session.exec(select(Event).where(Event.start_time >= now).order_by(Event.start_time)).all()
return render_template("admin_schedule.html", status=current_status, events=events) return render_template("admin_schedule.html", status=current_status, events=events)
@app.route("/admin/schedule/status", methods=["POST"]) @app.route("/admin/schedule/status", methods=["POST"])
@ -362,13 +382,35 @@ def update_status():
def add_event(): def add_event():
title = request.form.get("title") title = request.form.get("title")
event_type = request.form.get("event_type") 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) start_time = parse_datetime(request.form.get("start_time"))
end_time = datetime.strptime(request.form.get("end_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) end_time = parse_datetime(request.form.get("end_time"))
with Session(engine) as db_session: 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.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() db_session.commit()
return redirect(url_for("admin_schedule")) 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"]) @app.route("/admin/schedule/event/<int:event_id>/delete", methods=["POST"])
@require_role(["admin", "professor"]) @require_role(["admin", "professor"])
def delete_event(event_id): def delete_event(event_id):
@ -379,77 +421,198 @@ def delete_event(event_id):
db_session.commit() db_session.commit()
return redirect(url_for("admin_schedule")) 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") @app.route("/projects")
def projects_list(): def projects_list():
user_role = session.get("role")
user_id = session.get("user_id")
with Session(engine) as db_session: with Session(engine) as db_session:
projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() 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"]) @app.route("/projects/new", methods=["GET", "POST"])
@require_role(["admin", "professor", "lead"]) @require_role(["admin", "professor", "lead"])
def project_new(): def project_new():
if request.method == "POST": if request.method == "POST":
with Session(engine) as db_session: 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() db_session.commit()
return redirect(url_for("projects_list")) 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"]) @app.route("/projects/<int:project_id>/delete", methods=["POST"])
@require_role(["admin", "professor", "lead"]) @login_required
def project_delete(project_id): def project_delete(project_id):
with Session(engine) as db_session: with Session(engine) as db_session:
proj = db_session.get(Project, project_id) proj = db_session.get(Project, project_id)
if proj and (session.get("role") == "admin" or proj.owner_id == session.get("user_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.delete(proj)
db_session.commit() db_session.commit()
return redirect(url_for("projects_list")) 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") @app.route("/files")
@login_required @login_required
@ -495,3 +658,49 @@ def file_delete(file_id):
db_session.delete(db_file) db_session.delete(db_file)
db_session.commit() db_session.commit()
return redirect(url_for("files_list")) 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
View 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"))

View File

@ -4,12 +4,12 @@ from datetime import datetime, timezone
class User(SQLModel, table=True): class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=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) username: str = Field(unique=True, index=True)
password_hash: Optional[str] = None password_hash: Optional[str] = None
full_name: Optional[str] = None full_name: Optional[str] = None
email: Optional[str] = Field(default=None, unique=True) email: Optional[str] = Field(default=None, unique=True)
organization: Optional[str] = None # ННГУ, Неймарк, Ядро и т.д. organization: Optional[str] = None
role: str = Field(default="student") role: str = Field(default="student")
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
is_verified: bool = Field(default=False) is_verified: bool = Field(default=False)
@ -33,6 +33,8 @@ class Event(SQLModel, table=True):
description: Optional[str] = None description: Optional[str] = None
status: str = Field(default="scheduled") status: str = Field(default="scheduled")
is_public: bool = Field(default=True) 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)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class GlobalStatus(SQLModel, table=True): class GlobalStatus(SQLModel, table=True):
@ -46,12 +48,20 @@ class Project(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
title: str title: str
description: Optional[str] = None 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 gitea_url: Optional[str] = None
owner_id: int = Field(foreign_key="user.id") 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)) 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): class PortalFile(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
original_name: str original_name: str

View 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>

View 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>

View File

@ -3,45 +3,81 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Управление расписанием — Morozovka Portal</title> <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> <style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; } body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 900px; margin: 0 auto; } .container { max-width: 1000px; margin: 0 auto; }
h2 { color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 10px; } .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; } h3 { color: #8b949e; margin-top: 30px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; } .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-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; flex-wrap: wrap; }
.form-group { display: flex; flex-direction: column; flex: 1; } .form-group { display: flex; flex-direction: column; flex: 1; min-width: 200px; }
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; } .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; } 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; } textarea { resize: vertical; min-height: 60px; }
button { padding: 10px 15px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; } .quick-btns { display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; }
button:hover { background: #2ea043; } .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; }
button.danger { background: #da3633; } .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.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; } table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #30363d; } 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 { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; } .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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<a href="/" class="back-link">← На главную</a> <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"> <div class="card">
<h3>Текущий статус (выводится на главной странице)</h3> <h3>Текущий статус</h3>
<form action="/admin/schedule/status" method="POST"> <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-row">
<div class="form-group"> <div class="form-group">
<label>Статус</label> <label>Статус (вручную)</label>
<select name="status"> <select name="status" id="status-select">
<option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option> <option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option>
<option value="on_place" {% if status.status == 'on_place' %}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="delayed" {% if status.status == 'delayed' %}selected{% endif %}>Опаздываю</option>
@ -51,18 +87,17 @@
</div> </div>
<div class="form-group" style="flex: 0.5;"> <div class="form-group" style="flex: 0.5;">
<label>Опоздание (мин)</label> <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>
<div class="form-group"> <div class="form-group">
<label>Сообщение для студентов</label> <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> </div>
<button type="submit">Обновить статус</button> <button type="submit">Обновить</button>
</div> </div>
</form> </form>
</div> </div>
<!-- Блок 2: Добавление события -->
<div class="card"> <div class="card">
<h3>Добавить новое событие</h3> <h3>Добавить новое событие</h3>
<form action="/admin/schedule/event/add" method="POST"> <form action="/admin/schedule/event/add" method="POST">
@ -88,11 +123,11 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>Начало</label> <label>Начало</label>
<input type="datetime-local" name="start_time" required> <input type="text" class="flatpickr" name="start_time" required placeholder="Выберите дату и время">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Конец</label> <label>Конец</label>
<input type="datetime-local" name="end_time" required> <input type="text" class="flatpickr" name="end_time" required placeholder="Выберите дату и время">
</div> </div>
<div class="form-group" style="flex: 0.5; flex-direction: row; align-items: center; gap: 10px;"> <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"> <input type="checkbox" name="is_public" checked id="is_public">
@ -109,9 +144,8 @@
</form> </form>
</div> </div>
<!-- Блок 3: Список событий -->
<div class="card"> <div class="card">
<h3>Запланированные события</h3> <h3>Последние 50 событий</h3>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -125,23 +159,41 @@
<tbody> <tbody>
{% for event in events %} {% for event in events %}
<tr> <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><strong>{{ event.title }}</strong><br><small style="color:#8b949e">{{ event.location or '' }}</small></td>
<td>{{ event.event_type }}</td> <td>{{ event.event_type }}</td>
<td>{{ 'Да' if event.is_public else 'Нет' }}</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('Удалить событие?');"> <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> </form>
</td> </td>
</tr> </tr>
{% else %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</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> </body>
</html> </html>

View 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>

View 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>

View File

@ -60,7 +60,7 @@
<td><strong>{{ db_file.original_name }}</strong></td> <td><strong>{{ db_file.original_name }}</strong></td>
<td>{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ</td> <td>{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ</td>
<td>{{ uploader_name }}</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;"> <td style="display: flex; gap: 15px;">
<a href="/files/download/{{ db_file.id }}" class="btn-download">Скачать</a> <a href="/files/download/{{ db_file.id }}" class="btn-download">Скачать</a>

View 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>

View 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>

View File

@ -2,7 +2,7 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Новый проект — Morozovka Portal</title> <title>{{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal</title>
<style> <style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; } body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 600px; margin: 0 auto; } .container { max-width: 600px; margin: 0 auto; }
@ -13,41 +13,44 @@
textarea { resize: vertical; min-height: 100px; } 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 { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
button:hover { background: #2ea043; } 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; } .back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<a href="/projects" class="back-link">← К проектам</a> <a href="{{ '/projects/' + project.id|string if is_edit else '/projects' }}" class="back-link">← Назад</a>
<h2>Создание нового проекта</h2> <h2>{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}</h2>
<form method="POST"> <form method="POST">
<div class="form-group"> <div class="form-group">
<label>Название проекта *</label> <label>Название проекта *</label>
<input type="text" name="title" required> <input type="text" name="title" value="{{ project.title if is_edit else '' }}" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Описание</label> <label>Описание</label>
<textarea name="description"></textarea> <textarea name="description">{{ project.description if is_edit else '' }}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Статус</label> <label>Статус</label>
<select name="status"> <select name="status">
<option value="idea">Идея</option> <option value="idea" {% if is_edit and project.status == 'idea' %}selected{% endif %}>Идея</option>
<option value="in_progress">В работе</option> <option value="in_progress" {% if is_edit and project.status == 'in_progress' %}selected{% endif %}>В работе</option>
<option value="completed">Завершён</option> <option value="completed" {% if is_edit and project.status == 'completed' %}selected{% endif %}>Завершён</option>
<option value="frozen">Заморожен</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> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Ссылка на репозиторий Gitea</label> <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>
<div class="form-group checkbox-group"> <button type="submit">{{ 'Сохранить изменения' if is_edit else 'Создать проект' }}</button>
<input type="checkbox" name="is_public" id="is_public" checked>
<label for="is_public" style="margin: 0;">Показывать публично</label>
</div>
<button type="submit">Создать проект</button>
</form> </form>
</div> </div>
</body> </body>

View File

@ -12,18 +12,25 @@
.btn-add:hover { background: #2ea043; } .btn-add:hover { background: #2ea043; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } .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 h3 { margin: 0 0 10px 0; color: #e6edf3; }
.card p { color: #8b949e; font-size: 0.9rem; flex-grow: 1; margin-bottom: 15px; } .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-idea { background: #30363d; color: white; }
.status-in_progress { background: #1f6feb; color: white; } .status-in_progress { background: #1f6feb; color: white; }
.status-completed { background: #238636; color: white; } .status-completed { background: #238636; color: white; }
.status-frozen { background: #da3633; 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; } .vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; }
.repo-link { color: #58a6ff; text-decoration: none; font-size: 0.85rem; } .vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; }
.delete-btn { background: none; border: none; color: #da3633; cursor: pointer; font-size: 0.85rem; } .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; } .delete-btn:hover { text-decoration: underline; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; } .back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style> </style>
@ -40,33 +47,31 @@
<div class="grid"> <div class="grid">
{% for proj in projects %} {% for proj in projects %}
<div class="card"> <a href="/projects/{{ proj.id }}" class="card">
<div> <div class="badges">
<span class="status-badge status-{{ proj.status }}"> <span class="badge status-{{ proj.status }}">
{% if proj.status == 'idea' %}Идея {% if proj.status == 'idea' %}Идея
{% elif proj.status == 'in_progress' %}В работе {% elif proj.status == 'in_progress' %}В работе
{% elif proj.status == 'completed' %}Завершён {% elif proj.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %} {% else %}Заморожен{% endif %}
</span> </span>
<span class="badge vis-{{ proj.visibility }}">
{% if proj.visibility == 'public' %}Публичный
{% elif proj.visibility == 'internal' %}Внутренний
{% else %}Приватный{% endif %}
</span>
</div> </div>
<h3>{{ proj.title }}</h3> <h3>{{ proj.title }}</h3>
<p>{{ proj.description or 'Описание отсутствует.' }}</p> <p>{{ proj.description or 'Описание отсутствует.' }}</p>
<div class="card-footer"> <div class="card-footer">
<span style="color: #8b949e;">Подробнее →</span>
{% if proj.gitea_url %} {% if proj.gitea_url %}
<a href="{{ proj.gitea_url }}" target="_blank" class="repo-link">📦 Репозиторий</a> <span class="repo-link">📦 Есть репозиторий</span>
{% 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>
{% endif %} {% endif %}
</div> </div>
</div> </a>
{% else %} {% else %}
<p style="color: #8b949e;">Проектов пока нет.</p> <p style="color: #8b949e;">Доступных проектов пока нет.</p>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@ -1,143 +1,469 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание — Morozovka Portal</title> <title>Расписание — Morozovka Portal</title>
<style> <style>
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; } * { box-sizing: border-box; }
.container { max-width: 800px; margin: 0 auto; } 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-banner { padding: 18px 20px; border-radius: 10px; text-align: center; margin-bottom: 20px; }
.status-on_place { background: rgba(35, 134, 54, 0.2); border: 1px solid #238636; color: #3fb950; } .status-on_place { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
.status-delayed { background: rgba(210, 153, 34, 0.2); border: 1px solid #d29922; color: #d29922; } .status-delayed { background: rgba(210,153,34,.15); border: 1px solid #d29922; color: #d29922; }
.status-away, .status-cancelled { background: rgba(248, 81, 73, 0.2); border: 1px solid #f85149; color: #f85149; } .status-away, .status-cancelled { background: rgba(248,81,73,.15); border: 1px solid #f85149; color: #f85149; }
.status-free { background: rgba(139, 148, 158, 0.2); border: 1px solid #8b949e; color: #8b949e; } .status-free { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
.status-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; } .status-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 4px; }
.status-message { font-size: 1.1rem; } .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; } .calendar { background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; }
.event-time { font-family: monospace; font-size: 1.1rem; color: #58a6ff; min-width: 120px; } .cal-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30363d; gap: 15px; flex-wrap: wrap; }
.event-details { flex: 1; } .cal-nav { display: flex; gap: 8px; align-items: center; }
.event-title { font-size: 1.2rem; font-weight: bold; margin: 0 0 5px 0; } .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; }
.event-meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 5px; } .btn-icon:hover { background: #30363d; }
.event-type { display: inline-block; background: #21262d; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; text-transform: uppercase; } .btn-secondary { background: #21262d; border: 1px solid #30363d; color: #e6edf3; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .9rem; }
.event-desc { font-size: 0.95rem; margin-top: 10px; } .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; } .cal-filters { display: flex; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #30363d; flex-wrap: wrap; background: #0d1117; }
.back-link:hover { color: #58a6ff; } .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; }
/* Стили для чата */ .month-header { display: grid; grid-template-columns: repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
.chat-container { background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-top: 40px; display: flex; flex-direction: column; height: 400px; } .month-header-cell { padding: 10px; text-align: center; color: #8b949e; font-weight: 600; font-size: .75rem; text-transform: uppercase; }
.chat-header { padding: 15px; border-bottom: 1px solid #30363d; background: #21262d; border-radius: 8px 8px 0 0; font-weight: bold; } .month-grid { display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: minmax(110px,auto); }
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; } .month-cell { border-right: 1px solid #30363d; border-bottom: 1px solid #30363d; padding: 6px; overflow: hidden; position: relative; min-height: 110px; }
.msg { max-width: 80%; padding: 10px 15px; border-radius: 8px; font-size: 0.95rem; line-height: 1.4; white-space: pre-wrap; } .month-cell:nth-child(7n) { border-right: none; }
.msg.bot { background: #21262d; align-self: flex-start; border: 1px solid #30363d; } .month-cell.other-month { background: rgba(0,0,0,.15); }
.msg.user { background: #1f6feb; color: white; align-self: flex-end; } .month-cell.other-month .month-day-num { color: #4d5560; }
.chat-input-area { padding: 15px; border-top: 1px solid #30363d; display: flex; gap: 10px; } .month-cell.today { background: rgba(88,166,255,.05); }
.chat-input-area input { flex: 1; padding: 10px; border-radius: 5px; border: 1px solid #30363d; background: #0d1117; color: white; outline: none; } .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; }
.chat-input-area button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; } .month-day-num { font-size: .85rem; color: #8b949e; font-weight: 600; margin-bottom: 6px; }
.chat-input-area button:hover { background: #2ea043; } .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; }
</style> .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> </head>
<body> <body>
<div class="container"> <div class="page">
<a href="/" class="back-link">← На главную</a> <a href="/" class="back-link">← На главную</a>
<h1 style="margin-top: 0;">Расписание</h1> <h1>Расписание</h1>
{% if status %} {% if status %}
<div class="status-banner status-{{ status.status }}"> <div class="status-banner status-{{ status.status }}">
<div class="status-title"> <div class="status-title">
{% if status.status == 'on_place' %}На месте {% if status.status == 'on_place' %}На месте
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин {% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
{% elif status.status == 'away' %}Вне вуза {% elif status.status == 'away' %}Вне вуза
{% elif status.status == 'cancelled' %}Отменено {% elif status.status == 'cancelled' %}Отменено
{% else %}Свободен / Вне расписания{% endif %} {% else %}Свободен / Вне расписания{% endif %}
</div>
{% if status.message %}
<div class="status-message">{{ status.message }}</div>
{% endif %}
</div> </div>
{% endif %} {% if status.message %}<div class="status-message">{{ status.message }}</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> </div>
{% endif %}
<script> <div class="calendar">
const chatForm = document.getElementById('chat-form'); <div class="cal-toolbar">
const chatInput = document.getElementById('chat-input'); <div class="cal-nav">
const chatMessages = document.getElementById('chat-messages'); <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) => { <div id="event-popup" class="event-popup hidden">
e.preventDefault(); <span class="popup-close" id="popup-close">✕</span>
const text = chatInput.value.trim(); <h3 id="popup-title"></h3>
if (!text) return; <div id="popup-meta"></div>
<div id="popup-desc"></div>
</div>
// Добавляем сообщение пользователя <button id="chat-toggle" class="chat-fab" aria-label="Открыть чат">💬</button>
addMessage(text, 'user'); <div id="chat-window" class="chat-window hidden">
chatInput.value = ''; <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>
// Добавляем индикатор загрузки <script>
const loadingMsg = addMessage('...', 'bot'); 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 { function inMSK(d) { return new Date(d.getTime() + MSK_OFFSET_MS); }
const response = await fetch('/api/assistant', { function fromMSK(y, m, d, h, mn) { return new Date(Date.UTC(y, m, d, h||0, mn||0) - MSK_OFFSET_MS); }
method: 'POST', function mskParts(d) {
headers: { 'Content-Type': 'application/json' }, const m = inMSK(d);
body: JSON.stringify({ message: text }) 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(); 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) }));
loadingMsg.textContent = data.reply || 'Ошибка: пустой ответ'; }
} catch (err) { function eventsForDay(dayStart, dayEnd) {
loadingMsg.textContent = 'Ошибка соединения с сервером.'; 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 => {
function addMessage(text, sender) { const s = e._start < dayStart ? dayStart : e._start;
const div = document.createElement('div'); const en = e._end > dayEnd ? dayEnd : e._end;
div.className = `msg ${sender}`; return Object.assign({}, e, { _clipStart: s, _clipEnd: en });
div.textContent = text; });
chatMessages.appendChild(div); const cols = []; const placed = [];
chatMessages.scrollTop = chatMessages.scrollHeight; for (const ev of clipped) {
return div; 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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> </body>
</html> </html>

View 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>

View File

@ -36,7 +36,7 @@
<tr class="{% if s.session_token == current_token %}current{% endif %}"> <tr class="{% if s.session_token == current_token %}current{% endif %}">
<td><small>{{ s.user_agent or 'Неизвестно' }}</small></td> <td><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
<td>{{ s.ip_address }}</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> <td>{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View 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
View 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
View 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"
}

View File

@ -1,21 +1,45 @@
annotated-types==0.8.0 annotated-types==0.8.0
blinker==1.9.0 blinker==1.9.0
certifi==2026.10.18 certifi==2026.10.18
cffi==2.1.1
charset-normalizer==3.5.2 charset-normalizer==3.5.2
click==8.5.0 click==8.5.0
cryptography==50.0.2
Flask==3.1.3 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 gunicorn==26.2.0
httplib2==0.32.0
icalendar==7.3.0
idna==3.20 idna==3.20
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
MarkupSafe==3.0.4 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 psycopg2-binary==2.9.13
pyasn1==0.6.4
pyasn1_modules==0.4.2
pycparser==3.11
pydantic==2.14.1 pydantic==2.14.1
pydantic_core==2.50.1 pydantic_core==2.50.1
pyparsing==3.3.3
python-dateutil==2.9.0.post0
pytz==2026.5
requests==2.34.2 requests==2.34.2
requests-oauthlib==2.0.0
six==1.17.0
SQLAlchemy==2.1.5 SQLAlchemy==2.1.5
sqlmodel==0.0.48 sqlmodel==0.0.48
typing-inspection==0.4.4 typing-inspection==0.4.4
typing_extensions==4.16.0 typing_extensions==4.16.0
tzdata==2026.5
uritemplate==4.2.0
urllib3==2.8.0 urllib3==2.8.0
Werkzeug==3.1.9 Werkzeug==3.1.9