Initial commit

This commit is contained in:
kilyabin
2026-03-02 14:21:02 +04:00
commit d78668ce3a
11 changed files with 1278 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

160
.gitignore vendored Normal file
View File

@@ -0,0 +1,160 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# kspsuti-teacher-schedule
Teacher's schedule of KS PSUTI

274
app.py Normal file
View File

@@ -0,0 +1,274 @@
from flask import Flask, render_template, jsonify, request
import requests
from bs4 import BeautifulSoup
import re
import yaml
import os
app = Flask(__name__)
# ── Загрузка конфига ─────────────────────────────────────────────────────────
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.yaml")
def load_config():
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
config = load_config()
BASE_URL = config["base_url"]
DEFAULT_TEACHER = str(config.get("default_teacher", ""))
TEACHERS = {str(t["id"]): t for t in config.get("teachers", [])}
# ── Парсер ───────────────────────────────────────────────────────────────────
def fetch_schedule(obj=None, wk=None):
if obj is None:
obj = DEFAULT_TEACHER
params = {"mn": "3", "obj": obj}
if wk:
params["wk"] = wk
try:
resp = requests.get(BASE_URL, params=params, timeout=10)
resp.encoding = "utf-8"
html = resp.text
except Exception as e:
return {"error": str(e), "days": [], "week_range": "", "prev_wk": None, "next_wk": None}
return parse_schedule(html)
def parse_schedule(html):
soup = BeautifulSoup(html, "lxml")
result = {
"days": [],
"week_range": "",
"prev_wk": None,
"next_wk": None,
"error": None
}
# Диапазон недели
for td in soup.find_all("td"):
text = td.get_text(strip=True)
if re.match(r"с \d{2}\.\d{2}\.\d{4} по \d{2}\.\d{2}\.\d{4}", text):
result["week_range"] = text
break
# Ссылки на соседние недели
seen_wk = set()
for a in soup.find_all("a", href=True):
href = a["href"]
if "wk=" not in href:
continue
wk_val = re.search(r"wk=(\d+)", href)
if not wk_val:
continue
wk_num = int(wk_val.group(1))
if wk_num in seen_wk:
continue
seen_wk.add(wk_num)
text = a.get_text(strip=True).lower()
if "предыдущая" in text and result["prev_wk"] is None:
result["prev_wk"] = wk_num
elif "следующая" in text and result["next_wk"] is None:
result["next_wk"] = wk_num
# Дни недели
day_anchors = soup.find_all("a", class_="t_wth")
for anchor in day_anchors:
day_text = anchor.get_text(strip=True)
m = re.match(r"(\w+)\s*(\d{2}\.\d{2}\.\d{4})/(\d+)\s+неделя", day_text)
if not m:
continue
day_name, day_date, week_num = m.group(1), m.group(2), m.group(3)
# Находим родительскую таблицу с парами
parent_table = anchor
for _ in range(10):
parent_table = parent_table.parent
if parent_table and parent_table.name == "table" and parent_table.get("cellpadding") == "1":
break
lessons = []
if parent_table:
for row in parent_table.find_all("tr"):
tds = row.find_all("td", recursive=False)
if len(tds) != 4:
continue
num_td = tds[0].get_text(strip=True)
time_td = tds[1].get_text(strip=True)
subj_td = tds[2]
room_td = tds[3].get_text(strip=True)
if not re.match(r"^\d+$", num_td):
continue
info = {"subject": "", "group": "", "group_short": "", "lesson_type": "", "location": ""}
bold = subj_td.find("b")
if bold:
info["subject"] = bold.get_text(strip=True)
font_green = subj_td.find("font", class_="t_green_10")
if font_green:
info["location"] = font_green.get_text(strip=True)
raw = ""
if bold:
for node in bold.next_siblings:
if hasattr(node, "name"):
if node.name == "font":
break
if node.name == "br":
continue
raw += node.get_text(strip=True)
else:
raw += str(node).strip()
raw = raw.strip()
if raw:
info["group"] = raw
m_grp = re.search(r'\(([^)]+)\)', raw)
if m_grp:
info["group_short"] = m_grp.group(1)
after = raw[raw.find(")")+1:].strip() if ")" in raw else ""
if after:
unwrapped = re.sub(r'^\((.+)\)$', r'\1', after.strip())
inner = re.search(r'\(([^()]+)\)\s*$', unwrapped)
info["lesson_type"] = inner.group(1) if inner else unwrapped
lessons.append({
"num": num_td,
"time": time_td,
"subject": info["subject"],
"group": info["group"],
"group_short": info["group_short"],
"lesson_type": info["lesson_type"],
"location": info["location"],
"room": room_td,
"has_class": bool(info["subject"])
})
result["days"].append({
"name": day_name,
"date": day_date,
"week_num": week_num,
"lessons": lessons,
"has_classes": any(l["has_class"] for l in lessons)
})
return result
# ── Кэш списка преподавателей ────────────────────────────────────────────────
_teachers_cache = {"data": None, "ts": 0}
CACHE_TTL = 3600 # обновлять раз в час
def fetch_all_teachers():
import time
now = time.time()
if _teachers_cache["data"] and now - _teachers_cache["ts"] < CACHE_TTL:
return _teachers_cache["data"]
try:
resp = requests.get(BASE_URL, params={"mn": "3"}, timeout=10)
resp.encoding = "utf-8"
soup = BeautifulSoup(resp.text, "lxml")
teachers = []
for a in soup.find_all("a", href=lambda h: h and "obj=" in h):
m = re.search(r"obj=(\d+)", a["href"])
bold = a.find("b")
if m and bold:
teachers.append({
"id": m.group(1),
"name": bold.get_text(strip=True)
})
_teachers_cache["data"] = teachers
_teachers_cache["ts"] = now
return teachers
except Exception as e:
return []
@app.route("/api/all-teachers")
def api_all_teachers():
"""Полный список преподавателей с сайта (с кэшем на 1 час)."""
teachers = fetch_all_teachers()
return jsonify({"teachers": teachers, "count": len(teachers)})
# ── Маршруты ─────────────────────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/teachers")
def api_teachers():
teachers_list = [
{"id": str(t["id"]), "name": t["name"], "short": t.get("short", t["name"])}
for t in config.get("teachers", [])
]
return jsonify({"teachers": teachers_list, "default": DEFAULT_TEACHER})
@app.route("/api/schedule")
def api_schedule():
obj = request.args.get("obj", DEFAULT_TEACHER)
wk = request.args.get("wk", None)
# Имя: сначала из конфига, потом из полного списка
teacher_info = TEACHERS.get(str(obj), {})
name = teacher_info.get("name", "")
if not name:
all_t = fetch_all_teachers()
match = next((t for t in all_t if t["id"] == str(obj)), None)
if match:
name = match["name"]
data = fetch_schedule(obj=obj, wk=wk)
data["teacher_name"] = name
return jsonify(data)
@app.route("/api/reload-config")
def api_reload():
global config, BASE_URL, DEFAULT_TEACHER, TEACHERS
config = load_config()
BASE_URL = config["base_url"]
DEFAULT_TEACHER = str(config.get("default_teacher", ""))
TEACHERS = {str(t["id"]): t for t in config.get("teachers", [])}
return jsonify({"ok": True, "teachers_count": len(TEACHERS)})
#if __name__ == "__main__":
# app.run(debug=False, host="0.0.0.0", port=5609)
@app.route("/api/debug")
def api_debug():
"""Отладка: показывает что нашёл парсер."""
obj = request.args.get("obj", DEFAULT_TEACHER)
params = {"mn": "3", "obj": obj}
try:
resp = requests.get(BASE_URL, params=params, timeout=10)
resp.encoding = "utf-8"
html = resp.text
except Exception as e:
return jsonify({"error": str(e)})
soup = BeautifulSoup(html, "lxml")
day_anchors = soup.find_all("a", class_="t_wth")
week_range = ""
for td in soup.find_all("td"):
text = td.get_text(strip=True)
if re.match(r"с \d{2}\.\d{2}\.\d{4} по \d{2}\.\d{2}\.\d{4}", text):
week_range = text
break
return jsonify({
"html_length": len(html),
"html_snippet": html[:300],
"week_range_found": week_range,
"day_anchors_count": len(day_anchors),
"day_anchors": [a.get_text(strip=True) for a in day_anchors],
"full_parse": parse_schedule(html),
})

37
config.yaml Normal file
View File

@@ -0,0 +1,37 @@
# Настройки приложения расписания
# ------------------------------------
# base_url: адрес сайта с расписанием (без слеша в конце)
# default_teacher: id преподавателя, который открывается по умолчанию
# teachers: список преподавателей для быстрого переключения
# - id: число из параметра obj= в адресной строке
# например: https://lk.ks.psuti.ru/?mn=3&obj=244 → id: 244
# - name: полное имя (отображается в шапке)
# - short: короткое имя для кнопки-таба
base_url: "https://lk.ks.psuti.ru/?mn=3"
default_teacher: 244
teachers:
- id: 244
name: "Комаров Илья Сергеевич"
short: "Комаров И.С."
- id: 229
name: "Шахматов Егор Денисович"
short: "Шахматов Е.Д."
- id: 13
name: "Гончарова Анастасия Александровна"
short: "Гончарова А.А."
# Добавь других преподавателей по аналогии — id берётся из ссылки:
# https://lk.ks.psuti.ru/?mn=3&obj=65 → id: 65
#
# - id: 65
# name: "Андреевская Наталья Владимировна"
# short: "Андреевская Н.В."
#
# - id: 3
# name: "Абалымова Людмила Павловна"
# short: "Абалымова Л.П."

25
install-service.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Запускать с sudo: sudo bash install-service.sh
# Должен быть запущен из папки с проектом
set -e
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVICE_USER="${SUDO_USER:-$(whoami)}"
echo "==> Устанавливаем systemd сервис..."
echo " Папка проекта : $PROJECT_DIR"
echo " Пользователь : $SERVICE_USER"
sed \
-e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
"$PROJECT_DIR/schedule.service.template" \
> /etc/systemd/system/schedule.service
systemctl daemon-reload
systemctl enable schedule
systemctl restart schedule
echo ""
echo "✓ Сервис запущен!"
systemctl status schedule --no-pager -l

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
flask>=2.3.0
requests>=2.31.0
beautifulsoup4>=4.12.0
lxml>=4.9.0
pyyaml
gunicorn

18
schedule.service.template Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=Schedule Parser — расписание преподавателей
After=network.target
[Service]
Type=simple
User=__SERVICE_USER__
WorkingDirectory=__PROJECT_DIR__
ExecStart=__PROJECT_DIR__/venv/bin/python app.py
Restart=on-failure
RestartSec=5
# Логи доступны через: journalctl -u schedule -f
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

19
setup.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Запускать из папки с проектом: bash setup.sh
set -e
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "==> Создаём venv..."
python3 -m venv "$PROJECT_DIR/venv"
echo "==> Устанавливаем зависимости..."
"$PROJECT_DIR/venv/bin/pip" install --upgrade pip -q
"$PROJECT_DIR/venv/bin/pip" install -r "$PROJECT_DIR/requirements.txt" -q
echo ""
echo "✓ venv готов: $PROJECT_DIR/venv"
echo " Python: $($PROJECT_DIR/venv/bin/python --version)"
echo ""
echo "Следующий шаг:"
echo " sudo bash install-service.sh"

729
templates/index.html Normal file
View File

@@ -0,0 +1,729 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание преподавателей КС ПГУТИ</title>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@300;400;600;700&family=Onest:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d0f14;
--surface: #13161e;
--surface2: #1a1e28;
--border: #252a38;
--accent: #4f8ef7;
--accent-glow: rgba(79,142,247,0.15);
--text: #e8eaf0;
--text-muted: #6b7280;
--text-dim: #9ca3af;
--success: #34d399;
--r: 14px;
--font-d: 'Unbounded', sans-serif;
--font-b: 'Onest', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-b);
min-height: 100vh;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
body::before {
content: '';
position: fixed; top: -20%; left: -10%;
width: 70vw; height: 70vw;
background: radial-gradient(circle, rgba(79,142,247,0.07) 0%, transparent 70%);
pointer-events: none; z-index: 0;
}
.app {
position: relative; z-index: 1;
max-width: 1400px; margin: 0 auto;
padding: 0 16px 80px;
}
/* ── Header ── */
header {
padding: 24px 0 20px;
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.header-left h1 {
font-family: var(--font-d);
font-size: clamp(1rem, 4vw, 1.4rem);
font-weight: 600; letter-spacing: -0.02em; line-height: 1.2;
}
.header-left h1 span { color: var(--accent); }
.header-left .subtitle {
font-size: 0.78rem; color: var(--text-muted);
margin-top: 5px; font-weight: 300;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 55vw;
}
.live-badge {
display: flex; align-items: center; gap: 6px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 100px; padding: 6px 12px;
font-size: 0.7rem; color: var(--text-dim);
white-space: nowrap; flex-shrink: 0; margin-top: 2px;
}
.live-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--success); box-shadow: 0 0 6px var(--success);
animation: pulse 2s infinite; flex-shrink: 0;
}
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(1.3)} }
/* ── Teacher search ── */
.teachers-section { margin-bottom: 20px; }
.teachers-label {
font-size: 0.65rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-muted);
margin-bottom: 8px; font-family: var(--font-d);
}
/* Поле поиска */
.search-wrap { position: relative; margin-bottom: 8px; }
.search-input {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 10px 14px 10px 38px;
font-family: var(--font-b); font-size: 0.85rem; color: var(--text);
outline: none; transition: border-color .2s;
-webkit-appearance: none;
}
.search-input::placeholder { color: var(--text-muted); }
.search-input:focus { border-color: var(--accent); }
.search-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--text-muted); pointer-events: none;
width: 16px; height: 16px;
}
.search-clear {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; color: var(--text-muted); cursor: pointer;
padding: 2px 6px; font-size: 0.7rem; display: none; line-height: 1.4;
transition: all .15s;
}
.search-clear:hover { color: var(--text); border-color: var(--accent); }
/* Выпадающий список */
.search-dropdown {
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 100;
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,.4);
max-height: 280px; overflow-y: auto;
display: none;
}
.search-dropdown.open { display: block; }
.search-dropdown::-webkit-scrollbar { width: 4px; }
.search-dropdown::-webkit-scrollbar-track { background: var(--surface); }
.search-dropdown::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.dd-item {
padding: 10px 14px; font-size: 0.82rem; cursor: pointer;
transition: background .12s; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.dd-item:last-child { border-bottom: none; }
.dd-item:hover, .dd-item.selected { background: var(--surface2); }
.dd-item.selected { color: var(--accent); }
.dd-item-name { flex: 1; min-width: 0; }
.dd-item-name mark {
background: rgba(79,142,247,.25); color: var(--accent);
border-radius: 2px; padding: 0 1px;
}
.dd-item-id {
font-family: var(--font-d); font-size: 0.6rem; color: var(--text-muted);
flex-shrink: 0;
}
.dd-empty {
padding: 20px 14px; text-align: center;
color: var(--text-muted); font-size: 0.82rem;
}
.dd-loading {
padding: 16px 14px; text-align: center;
color: var(--text-muted); font-size: 0.8rem;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
/* Таб избранных из конфига */
.fav-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.teacher-tab {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 7px 14px;
font-family: var(--font-b); font-size: 0.78rem; font-weight: 500;
color: var(--text-dim); cursor: pointer;
transition: all 0.18s; white-space: nowrap;
min-height: 34px; display: flex; align-items: center;
}
.teacher-tab:hover { background: var(--surface2); border-color: rgba(79,142,247,0.4); color: var(--text); }
.teacher-tab.active { background: var(--accent-glow); border-color: var(--accent); color: var(--accent); font-weight: 600; }
@keyframes shimmer { 0%,100%{opacity:.4} 50%{opacity:.8} }
.teacher-tab-skeleton {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; height: 34px; width: 120px;
animation: shimmer 1.4s infinite;
}
/* ── Week nav — ключевое изменение ── */
.week-nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.week-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 10px 14px;
font-family: var(--font-b); font-size: 0.8rem; font-weight: 500;
color: var(--text-dim); cursor: pointer; transition: all 0.2s;
min-height: 42px; width: 100%;
}
.week-btn:hover:not(:disabled) { background: var(--surface2); border-color: var(--accent); color: var(--accent); }
.week-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.week-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
/* Скрыть текст кнопок на узких экранах, оставить только стрелку */
.week-btn-text { display: inline; }
.week-label {
font-family: var(--font-d); font-size: clamp(0.6rem, 2.5vw, 0.8rem);
font-weight: 400; color: var(--text);
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 10px 10px;
text-align: center; line-height: 1.3;
min-height: 42px; display: flex; align-items: center; justify-content: center;
white-space: nowrap;
}
@media (max-width: 480px) {
.week-btn-text { display: none; }
.week-btn { padding: 10px; }
}
/* ── Stats — горизонтальная полоса ── */
.stats-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 20px;
}
@media (max-width: 600px) {
.stats-bar { grid-template-columns: repeat(2, 1fr); }
}
.stat {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 12px;
display: flex; align-items: center; gap: 10px;
}
.stat-icon {
width: 30px; height: 30px; border-radius: 8px;
background: var(--accent-glow);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.stat-icon svg { width: 14px; height: 14px; color: var(--accent); }
.stat-num { font-family: var(--font-d); font-size: 1.1rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.65rem; color: var(--text-muted); margin-top: 2px; }
@media (max-width: 400px) {
.stat { flex-direction: column; align-items: flex-start; gap: 6px; padding: 10px; }
.stat-icon { width: 26px; height: 26px; }
.stat-num { font-size: 1rem; }
}
/* ── Loading ── */
.loading {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 80px 20px; gap: 16px;
}
.spinner {
width: 36px; height: 36px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading p { color: var(--text-muted); font-size: 0.85rem; }
.error-box {
background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3);
border-radius: var(--r); padding: 20px; text-align: center; color: #f87171;
}
/* ── Days grid ── */
.days-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (max-width: 700px) { .days-grid { grid-template-columns: 1fr; gap: 10px; } }
/* ── Day card ── */
.day-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r); overflow: hidden;
transition: border-color .2s;
animation: fadeUp .35s ease both;
}
.day-card.has-classes { border-color: rgba(79,142,247,.2); }
.day-card.today {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 4px 20px rgba(79,142,247,.12);
}
/* hover только на не-тач устройствах */
@media (hover: hover) {
.day-card:hover { border-color: rgba(79,142,247,.35); transform: translateY(-1px); }
}
@keyframes fadeUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
.day-header {
padding: 12px 14px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--border); background: var(--surface2);
gap: 8px;
}
.day-name {
font-family: var(--font-d); font-size: 0.72rem; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase; line-height: 1;
}
.today .day-name { color: var(--accent); }
.day-date { font-size: 0.68rem; color: var(--text-muted); margin-top: 3px; }
.today-tag {
background: var(--accent); color: #fff;
font-size: 0.58rem; font-weight: 600; font-family: var(--font-d);
letter-spacing: .04em; padding: 3px 7px; border-radius: 5px;
text-transform: uppercase; flex-shrink: 0;
}
/* ── Lesson row ── */
.lesson {
display: grid;
/* номер | время | инфо | аудитория */
grid-template-columns: 28px 1fr auto;
align-items: start;
border-bottom: 1px solid var(--border);
transition: background .15s;
padding: 10px 14px;
gap: 10px;
}
.lesson:last-child { border-bottom: none; }
@media (hover: hover) { .lesson:hover { background: var(--surface2); } }
.lesson.active-lesson { background: rgba(79,142,247,.07); }
.lesson-num {
font-family: var(--font-d); font-size: 0.65rem; font-weight: 600;
color: var(--text-muted); padding-top: 2px;
text-align: center;
}
.active-lesson .lesson-num { color: var(--accent); }
.lesson-info { min-width: 0; }
.lesson-time {
font-size: 0.68rem; color: var(--text-muted);
margin-bottom: 4px; white-space: nowrap;
}
.lesson-subject {
font-size: 0.78rem; font-weight: 600; line-height: 1.35;
margin-bottom: 5px; word-break: break-word;
}
.lesson-group { display: flex; flex-wrap: wrap; gap: 4px; }
.group-badge {
background: rgba(79,142,247,.12); border: 1px solid rgba(79,142,247,.25);
color: var(--accent); border-radius: 5px; padding: 2px 7px;
font-size: 0.65rem; font-weight: 600; font-family: var(--font-d);
}
.type-badge {
background: rgba(124,58,237,.1); border: 1px solid rgba(124,58,237,.2);
color: #a78bfa; border-radius: 5px; padding: 2px 7px;
font-size: 0.63rem; font-weight: 500;
}
.lesson-location {
display: flex; align-items: center; gap: 3px;
font-size: 0.66rem; color: var(--accent); margin-top: 5px;
}
.lesson-location svg { width: 9px; height: 9px; flex-shrink: 0; }
.lesson-room {
display: flex; align-items: flex-start; justify-content: flex-end;
padding-top: 2px; flex-shrink: 0;
}
.room-badge {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 7px; padding: 3px 7px;
font-family: var(--font-d); font-size: 0.63rem; font-weight: 600; color: var(--accent);
white-space: nowrap;
}
.empty-day {
padding: 24px 16px; text-align: center;
color: var(--text-muted); font-size: 0.78rem;
}
.empty-day-icon { font-size: 1.4rem; margin-bottom: 6px; opacity: .4; }
</style>
</head>
<body>
<div class="app">
<header>
<div class="header-left">
<h1>Расписание <span>преподавателя</span></h1>
<p class="subtitle" id="teacher-full-name">Загрузка...</p>
</div>
<div class="live-badge">
<span class="live-dot"></span>
<span id="last-updated">...</span>
</div>
</header>
<div class="teachers-section" id="teachers-section">
<div class="teachers-label">Преподаватель</div>
<!-- Поиск по всем преподавателям -->
<div class="search-wrap" id="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input class="search-input" id="search-input" type="text"
placeholder="Поиск по ФИО..." autocomplete="off" spellcheck="false">
<button class="search-clear" id="search-clear" onclick="clearSearch()"></button>
<div class="search-dropdown" id="search-dropdown">
<div class="dd-loading">
<div class="spinner" style="width:16px;height:16px;border-width:2px"></div>
Загружаем список...
</div>
</div>
</div>
<!-- Избранные из config.yaml (если больше одного) -->
<div class="fav-tabs" id="teachers-tabs" style="margin-top:10px">
<div class="teacher-tab-skeleton"></div>
<div class="teacher-tab-skeleton" style="width:90px"></div>
</div>
</div>
<div class="week-nav">
<button class="week-btn" id="prev-btn" disabled onclick="navigate('prev')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15,18 9,12 15,6"/></svg>
<span class="week-btn-text">Предыдущая</span>
</button>
<div class="week-label" id="week-label"></div>
<button class="week-btn" id="next-btn" disabled onclick="navigate('next')">
<span class="week-btn-text">Следующая</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9,18 15,12 9,6"/></svg>
</button>
</div>
<div class="stats-bar" id="stats-bar" style="display:none">
<div class="stat">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</div>
<div><div class="stat-num" id="stat-days">0</div><div class="stat-label">дней</div></div>
</div>
<div class="stat">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12,6 12,12 16,14"/>
</svg>
</div>
<div><div class="stat-num" id="stat-lessons">0</div><div class="stat-label">пар</div></div>
</div>
<div class="stat">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div><div class="stat-num" id="stat-groups"></div><div class="stat-label">групп</div></div>
</div>
<div class="stat">
<div class="stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<div><div class="stat-num" id="stat-hours"></div><div class="stat-label">часов</div></div>
</div>
</div>
<div id="schedule-container">
<div class="loading"><div class="spinner"></div><p>Загружаем расписание...</p></div>
</div>
</div>
<script>
let currentObj = null;
let prevWk = null, nextWk = null;
const todayStr = getTodayStr();
function getTodayStr() {
const d = new Date();
return [String(d.getDate()).padStart(2,'0'), String(d.getMonth()+1).padStart(2,'0'), d.getFullYear()].join('.');
}
function nowMin() { const d = new Date(); return d.getHours()*60+d.getMinutes(); }
function isActive(t) {
const m = t.match(/(\d{2}):(\d{2})\s*[-]\s*(\d{2}):(\d{2})/);
if (!m) return false;
const n = nowMin();
return n >= +m[1]*60 + +m[2] && n <= +m[3]*60 + +m[4];
}
let allTeachers = []; // полный список с сайта
async function loadTeachers() {
try {
// Грузим избранных из конфига
const r = await fetch('/api/teachers');
const data = await r.json();
renderFavTabs(data.teachers, data.default);
currentObj = String(data.default);
loadSchedule(currentObj, null);
} catch(e) {
document.getElementById('teachers-tabs').innerHTML =
`<span style="color:var(--text-muted);font-size:.8rem">Ошибка: ${e.message}</span>`;
}
// Фоном грузим полный список для поиска
loadAllTeachers();
}
async function loadAllTeachers() {
try {
const r = await fetch('/api/all-teachers');
const data = await r.json();
allTeachers = data.teachers;
} catch(e) {
allTeachers = [];
}
}
function renderFavTabs(teachers, defaultId) {
const tabs = document.getElementById('teachers-tabs');
if (teachers.length <= 1) {
tabs.style.display = 'none';
return;
}
tabs.innerHTML = teachers.map(t => `
<button class="teacher-tab${String(t.id)===String(defaultId)?' active':''}"
data-id="${t.id}" title="${t.name}"
onclick="switchTeacher('${t.id}',this)">${t.short}</button>
`).join('');
}
function switchTeacher(id, btn) {
if (String(id) === currentObj) return;
// Снять активный у табов
document.querySelectorAll('.teacher-tab').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
currentObj = String(id);
prevWk = null; nextWk = null;
loadSchedule(id, null);
}
// ── Поиск ────────────────────────────────────────────────────────────────
const searchInput = document.getElementById('search-input');
const searchClear = document.getElementById('search-clear');
const dropdown = document.getElementById('search-dropdown');
let ddSelected = -1;
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim();
searchClear.style.display = q ? 'block' : 'none';
if (!q) { closeDropdown(); return; }
renderDropdown(q);
});
searchInput.addEventListener('focus', () => {
if (searchInput.value.trim()) renderDropdown(searchInput.value.trim());
});
searchInput.addEventListener('keydown', e => {
const items = dropdown.querySelectorAll('.dd-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
ddSelected = Math.min(ddSelected + 1, items.length - 1);
highlightDD(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
ddSelected = Math.max(ddSelected - 1, 0);
highlightDD(items);
} else if (e.key === 'Enter' && ddSelected >= 0 && items[ddSelected]) {
items[ddSelected].click();
} else if (e.key === 'Escape') {
closeDropdown();
searchInput.blur();
}
});
document.addEventListener('click', e => {
if (!document.getElementById('search-wrap').contains(e.target)) closeDropdown();
});
function highlightDD(items) {
items.forEach((el, i) => el.classList.toggle('selected', i === ddSelected));
if (items[ddSelected]) items[ddSelected].scrollIntoView({ block: 'nearest' });
}
function renderDropdown(q) {
if (!allTeachers.length) {
dropdown.innerHTML = '<div class="dd-loading"><div class="spinner" style="width:14px;height:14px;border-width:2px"></div>Загружаем список...</div>';
dropdown.classList.add('open');
// Попробуем ещё раз загрузить
loadAllTeachers().then(() => { if (searchInput.value.trim()) renderDropdown(searchInput.value.trim()); });
return;
}
const norm = q.toLowerCase();
const filtered = allTeachers.filter(t => t.name.toLowerCase().includes(norm)).slice(0, 30);
ddSelected = -1;
if (!filtered.length) {
dropdown.innerHTML = `<div class="dd-empty">Не найдено: «${q}»</div>`;
} else {
dropdown.innerHTML = filtered.map(t => {
const hi = t.name.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'), '<mark>$1</mark>');
return `<div class="dd-item${String(t.id)===currentObj?' selected':''}" onclick="selectTeacher('${t.id}','${t.name.replace(/'/g,"\'")}')">
<span class="dd-item-name">${hi}</span>
</div>`;
}).join('');
}
dropdown.classList.add('open');
}
function selectTeacher(id, name) {
closeDropdown();
searchInput.value = name;
searchClear.style.display = 'block';
// Снять активный у табов
document.querySelectorAll('.teacher-tab').forEach(b => b.classList.remove('active'));
switchTeacher(id, null);
}
function clearSearch() {
searchInput.value = '';
searchClear.style.display = 'none';
closeDropdown();
searchInput.focus();
}
function closeDropdown() {
dropdown.classList.remove('open');
ddSelected = -1;
}
async function loadSchedule(obj, wk) {
document.getElementById('schedule-container').innerHTML =
`<div class="loading"><div class="spinner"></div><p>Загружаем расписание...</p></div>`;
document.getElementById('stats-bar').style.display = 'none';
const p = new URLSearchParams({ obj });
if (wk) p.set('wk', wk);
try {
const r = await fetch('/api/schedule?' + p);
render(await r.json());
} catch(e) {
document.getElementById('schedule-container').innerHTML =
`<div class="error-box">⚠️ ${e.message}</div>`;
}
}
function render(data) {
if (data.error && !data.days?.length) {
document.getElementById('schedule-container').innerHTML = `<div class="error-box">⚠️ ${data.error}</div>`;
return;
}
prevWk = data.prev_wk; nextWk = data.next_wk;
document.getElementById('prev-btn').disabled = !prevWk;
document.getElementById('next-btn').disabled = !nextWk;
document.getElementById('week-label').textContent = data.week_range || '—';
if (data.teacher_name) document.getElementById('teacher-full-name').textContent = data.teacher_name;
const activeDays = data.days.filter(d => d.has_classes).length;
const totalLessons = data.days.reduce((a,d) => a + d.lessons.filter(l=>l.has_class).length, 0);
const groups = new Set(data.days.flatMap(d=>d.lessons).filter(l=>l.group_short).map(l=>l.group_short));
document.getElementById('stat-days').textContent = activeDays;
document.getElementById('stat-lessons').textContent = totalLessons;
document.getElementById('stat-groups').textContent = groups.size || '—';
// 1 пара = 1.5 акад. часа (90 мин), но по условию 1 пара = 2 часа
document.getElementById('stat-hours').textContent = totalLessons * 2;
document.getElementById('stats-bar').style.display = 'grid';
const now = new Date();
document.getElementById('last-updated').textContent =
`${now.getHours()}:${String(now.getMinutes()).padStart(2,'0')}`;
if (!data.days.length) {
document.getElementById('schedule-container').innerHTML =
`<div class="error-box" style="background:rgba(79,142,247,.07);border-color:var(--border);color:var(--text-muted)">Расписание не найдено</div>`;
return;
}
const pin = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="9" height="9"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>`;
let html = '<div class="days-grid">';
data.days.forEach((day, i) => {
const isToday = day.date === todayStr;
const cls = ['day-card', day.has_classes?'has-classes':'', isToday?'today':''].filter(Boolean).join(' ');
html += `<div class="${cls}" style="animation-delay:${i*.04}s">
<div class="day-header">
<div>
<div class="day-name">${day.name}</div>
<div class="day-date">${day.date}</div>
</div>
${isToday ? '<span class="today-tag">Сегодня</span>' : ''}
</div>`;
if (!day.has_classes) {
html += `<div class="empty-day"><div class="empty-day-icon">☕</div>Выходной / нет пар</div>`;
} else {
day.lessons.forEach(l => {
if (!l.has_class) return;
const active = isToday && isActive(l.time);
html += `<div class="lesson${active?' active-lesson':''}">
<div class="lesson-num">${l.num}</div>
<div class="lesson-info">
<div class="lesson-time">${l.time}</div>
<div class="lesson-subject">${l.subject}</div>
<div class="lesson-group">
${l.group_short ? `<span class="group-badge">${l.group_short}</span>` : ''}
${l.lesson_type ? `<span class="type-badge">${l.lesson_type}</span>` : ''}
</div>
${l.location ? `<div class="lesson-location">${pin}${l.location}</div>` : ''}
</div>
<div class="lesson-room">${l.room ? `<div class="room-badge">${l.room}</div>` : ''}</div>
</div>`;
});
}
html += '</div>';
});
html += '</div>';
document.getElementById('schedule-container').innerHTML = html;
}
function navigate(dir) {
const wk = dir === 'prev' ? prevWk : nextWk;
if (wk) loadSchedule(currentObj, wk);
}
loadTeachers();
</script>
</body>
</html>

6
wsgi.py Normal file
View File

@@ -0,0 +1,6 @@
# wsgi.py ← должен лежать рядом с app.py
from app import app
# Это опционально, но полезно оставить
if __name__ == "__main__":
app.run()