diff --git a/app.py b/app.py new file mode 100644 index 00000000..fb4ee9f6 --- /dev/null +++ b/app.py @@ -0,0 +1,248 @@ +from io import BytesIO +from pathlib import Path + +from flask import Flask, jsonify, render_template, request, send_file +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from services.ssau_parser import ( + get_current_week, + get_groups, + get_schedule_by_group, + get_schedule_by_teacher, + get_teachers, +) + +app = Flask(__name__) +app.json.ensure_ascii = False + + +def register_pdf_fonts() -> None: + font_candidates = [ + ( + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"), + ), + ( + Path("C:/Windows/Fonts/arial.ttf"), + Path("C:/Windows/Fonts/arialbd.ttf"), + ), + ] + + for regular_path, bold_path in font_candidates: + if regular_path.exists() and bold_path.exists(): + pdfmetrics.registerFont(TTFont("AppFont", str(regular_path))) + pdfmetrics.registerFont(TTFont("AppFont-Bold", str(bold_path))) + return + + raise FileNotFoundError("Не удалось найти подходящие шрифты для генерации PDF.") + + +def build_schedule_pdf( + schedule_data: dict, + mode: str, + entity_name: str, + week: int, +) -> BytesIO: + register_pdf_fonts() + + buffer = BytesIO() + document = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=16 * mm, + leftMargin=16 * mm, + topMargin=16 * mm, + bottomMargin=16 * mm, + ) + + styles = getSampleStyleSheet() + + title_style = ParagraphStyle( + "TitleStyle", + parent=styles["Heading1"], + fontName="AppFont-Bold", + fontSize=18, + leading=22, + textColor=colors.HexColor("#1a3fa6"), + spaceAfter=10, + ) + + meta_style = ParagraphStyle( + "MetaStyle", + parent=styles["Normal"], + fontName="AppFont", + fontSize=10, + leading=14, + textColor=colors.HexColor("#2d3b5f"), + spaceAfter=4, + ) + + day_style = ParagraphStyle( + "DayStyle", + parent=styles["Heading2"], + fontName="AppFont-Bold", + fontSize=12, + leading=15, + textColor=colors.HexColor("#233b88"), + spaceBefore=10, + spaceAfter=6, + ) + + body_style = ParagraphStyle( + "BodyStyle", + parent=styles["Normal"], + fontName="AppFont", + fontSize=9, + leading=12, + textColor=colors.black, + ) + + center_note_style = ParagraphStyle( + "CenterNote", + parent=styles["Normal"], + fontName="AppFont", + fontSize=10, + leading=14, + alignment=TA_CENTER, + textColor=colors.HexColor("#4b5c84"), + spaceBefore=10, + ) + + story = [ + Paragraph("Расписание Самарского университета", title_style), + Paragraph( + f"{'Группа' if mode == 'group' else 'Преподаватель'}: {entity_name}", + meta_style, + ), + Paragraph(f"Учебная неделя: {week}", meta_style), + Spacer(1, 6), + ] + + days = schedule_data.get("days", []) + + if not days: + message = schedule_data.get("message", "На выбранную неделю занятий нет.") + story.append(Paragraph(message, center_note_style)) + else: + for day in days: + day_name = day.get("day_name", "") + day_date = day.get("date", "") + story.append(Paragraph(f"{day_name} - {day_date}", day_style)) + + table_data = [["Время", "Тип", "Предмет", "Преподаватель", "Аудитория"]] + + for lesson in day.get("lessons", []): + table_data.append( + [ + lesson.get("time", ""), + lesson.get("type", ""), + Paragraph(lesson.get("title", ""), body_style), + Paragraph(lesson.get("teacher", ""), body_style), + lesson.get("room", ""), + ] + ) + + table = Table( + table_data, + colWidths=[26 * mm, 24 * mm, 70 * mm, 42 * mm, 20 * mm], + repeatRows=1, + ) + + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2a56c6")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "AppFont-Bold"), + ("FONTNAME", (0, 1), (-1, -1), "AppFont"), + ("FONTSIZE", (0, 0), (-1, -1), 8.5), + ("LEADING", (0, 0), (-1, -1), 10), + ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#9fb4e8")), + ("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#f7f9ff")), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + + story.extend([table, Spacer(1, 10)]) + + document.build(story) + buffer.seek(0) + return buffer + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/groups") +def api_groups(): + return jsonify(get_groups()) + + +@app.route("/api/teachers") +def api_teachers(): + return jsonify(get_teachers()) + + +@app.route("/api/current-week") +def api_current_week(): + return jsonify({"week": get_current_week()}) + + +@app.route("/api/schedule/group/") +def api_schedule_group(group_id: str): + week = request.args.get("week", type=int) + return jsonify(get_schedule_by_group(group_id, week)) + + +@app.route("/api/schedule/teacher") +def api_schedule_teacher(): + teacher = request.args.get("name", "") + week = request.args.get("week", type=int) + return jsonify(get_schedule_by_teacher(teacher, week)) + + +@app.route("/download/pdf") +def download_pdf(): + mode = request.args.get("mode", "group") + week = request.args.get("week", type=int) or get_current_week() + + if mode == "teacher": + teacher_name = request.args.get("name", "").strip() + schedule_data = get_schedule_by_teacher(teacher_name, week) + entity_name = teacher_name or "teacher" + filename = f"schedule_teacher_week_{week}.pdf" + else: + group_id = request.args.get("group_id", "").strip() + schedule_data = get_schedule_by_group(group_id, week) + entity_name = next( + (group["name"] for group in get_groups() if group["id"] == group_id), + group_id, + ) + filename = f"schedule_{entity_name}_week_{week}.pdf" + + pdf_buffer = build_schedule_pdf(schedule_data, mode, entity_name, week) + + return send_file( + pdf_buffer, + as_attachment=True, + download_name=filename, + mimetype="application/pdf", + ) + + +if __name__ == "__main__": + app.run(debug=True) \ No newline at end of file diff --git a/services/ssau_parser.py b/services/ssau_parser.py new file mode 100644 index 00000000..1fba7e95 --- /dev/null +++ b/services/ssau_parser.py @@ -0,0 +1,419 @@ +import re +import time +from typing import Any, Dict, List, Optional + +import requests +from bs4 import BeautifulSoup, Tag + +BASE_URL = "https://ssau.ru/rasp" +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/123.0.0.0 Safari/537.36" + ) +} +CACHE_TTL_SECONDS = 300 +_CACHE: Dict[str, Dict[str, Any]] = {} + + +def _cache_get(key: str) -> Optional[Any]: + item = _CACHE.get(key) + if item is None: + return None + + if time.time() - item["ts"] > CACHE_TTL_SECONDS: + _CACHE.pop(key, None) + return None + + return item["value"] + + +def _cache_set(key: str, value: Any) -> None: + _CACHE[key] = {"ts": time.time(), "value": value} + + +def _clean_text(text: str) -> str: + return re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip() + + +def _fetch_schedule_page(group_id: str, week: Optional[int] = None) -> str: + params = {"groupId": group_id} + if week is not None: + params["selectedWeek"] = week + + cache_key = f"html:{group_id}:{week}" + cached = _cache_get(cache_key) + if cached is not None: + return cached + + response = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=20) + response.raise_for_status() + response.encoding = "utf-8" + + html = response.text + _cache_set(cache_key, html) + return html + + +def _extract_week_from_soup(soup: BeautifulSoup) -> Optional[int]: + week_element = soup.select_one(".week-nav-current_week") + if week_element is None: + return None + + match = re.search(r"(\d+)", _clean_text(week_element.get_text())) + return int(match.group(1)) if match else None + + +def get_current_week() -> int: + group_id = get_groups()[0]["id"] + html = _fetch_schedule_page(group_id) + soup = BeautifulSoup(html, "lxml") + return _extract_week_from_soup(soup) or 1 + + +def _extract_group_meta(soup: BeautifulSoup, group_id: str) -> Dict[str, Any]: + metadata = { + "group_id": group_id, + "group_name": "6413-100503D", + "program": "10.05.03 Информационная безопасность автоматизированных систем", + "education_form": "Специалист (Очная форма обучения)", + "study_year_start": "01.09.2025", + } + + info_block = soup.select_one(".info-block") + if info_block is None: + return metadata + + title = info_block.select_one(".info-block__title") + if title is not None: + metadata["group_name"] = _clean_text(title.get_text()) + + description = info_block.select(".info-block__description div") + if len(description) >= 1: + metadata["program"] = _clean_text(description[0].get_text()) + if len(description) >= 2: + metadata["education_form"] = _clean_text(description[1].get_text()) + + semester = info_block.select_one(".info-block__semester") + if semester is not None: + text = _clean_text(semester.get_text()) + if "Начало учебного года:" in text: + metadata["study_year_start"] = text.replace("Начало учебного года:", "").strip() + + return metadata + + +def _extract_active_week(soup: BeautifulSoup, requested_week: Optional[int]) -> int: + if requested_week is not None: + return requested_week + return _extract_week_from_soup(soup) or get_current_week() + + +def _parse_schedule_head(schedule_items: Tag) -> List[Dict[str, str]]: + direct_children = schedule_items.find_all(recursive=False) + + head_cells: List[Tag] = [] + for child in direct_children: + classes = child.get("class", []) + if "schedule__item" in classes and "schedule__head" in classes: + head_cells.append(child) + if len(head_cells) == 7: + break + + if len(head_cells) < 7: + return [] + + days = [] + for cell in head_cells[1:]: + weekday_element = cell.select_one(".schedule__head-weekday") + date_element = cell.select_one(".schedule__head-date") + + days.append( + { + "day_name": _clean_text(weekday_element.get_text()).capitalize() + if weekday_element is not None + else "", + "date": _clean_text(date_element.get_text()) if date_element is not None else "", + } + ) + + return days + + +def _parse_teacher_text(lesson: Tag) -> str: + teacher_block = lesson.select_one(".schedule__teacher") + if teacher_block is None: + return "" + return _clean_text(teacher_block.get_text(" ", strip=True)) + + +def _parse_groups(lesson: Tag) -> List[str]: + group_links = lesson.select(".schedule__groups .schedule__group") + groups = [_clean_text(link.get_text()) for link in group_links if _clean_text(link.get_text())] + if groups: + return groups + + groups_block = lesson.select_one(".schedule__groups") + if groups_block is None: + return [] + + text = _clean_text(groups_block.get_text(" ", strip=True)) + return [text] if text else [] + + +def _parse_one_lesson(lesson: Tag) -> Dict[str, Any]: + lesson_type_element = lesson.select_one(".schedule__lesson-type-chip") + discipline_element = lesson.select_one(".schedule__discipline") + place_element = lesson.select_one(".schedule__place") + teacher_link = lesson.select_one(".schedule__teacher a") + + lesson_type = _clean_text(lesson_type_element.get_text()) if lesson_type_element is not None else "Другое" + title = _clean_text(discipline_element.get_text()) if discipline_element is not None else "" + room = _clean_text(place_element.get_text()) if place_element is not None else "" + teacher = _parse_teacher_text(lesson) + groups = _parse_groups(lesson) + + staff_url = "" + staff_id = "" + + if teacher_link is not None and teacher_link.get("href"): + href = teacher_link["href"] + staff_url = f"https://ssau.ru{href}" if href.startswith("/") else href + + match = re.search(r"staffId=(\d+)", href) + if match: + staff_id = match.group(1) + + return { + "title": title, + "type": lesson_type, + "room": room, + "teacher": teacher, + "groups": groups, + "staff_url": staff_url, + "staff_id": staff_id, + } + + +def _parse_schedule_item_cell(cell: Tag) -> List[Dict[str, Any]]: + lesson_blocks = cell.find_all("div", class_="schedule__lesson", recursive=False) + if not lesson_blocks: + lesson_blocks = cell.select(".schedule__lesson") + + lessons = [] + for lesson in lesson_blocks: + parsed_lesson = _parse_one_lesson(lesson) + if parsed_lesson["title"] or parsed_lesson["teacher"] or parsed_lesson["room"]: + lessons.append(parsed_lesson) + + return lessons + + +def _parse_schedule_items_dom(soup: BeautifulSoup) -> List[Dict[str, Any]]: + schedule_items = soup.select_one(".schedule__items") + if schedule_items is None: + return [] + + days = _parse_schedule_head(schedule_items) + if len(days) != 6: + return [] + + parsed_days = [ + { + "day_name": day["day_name"], + "date": day["date"], + "lessons": [], + } + for day in days + ] + + direct_children = schedule_items.find_all(recursive=False) + + content_children: List[Tag] = [] + head_count = 0 + for child in direct_children: + classes = child.get("class", []) + if "schedule__item" in classes and "schedule__head" in classes and head_count < 7: + head_count += 1 + continue + content_children.append(child) + + index = 0 + while index < len(content_children): + node = content_children[index] + classes = node.get("class", []) + + if "schedule__time" not in classes: + index += 1 + continue + + time_items = node.select(".schedule__time-item") + if len(time_items) < 2: + index += 1 + continue + + start = _clean_text(time_items[0].get_text(" ", strip=True)) + end = _clean_text(time_items[1].get_text(" ", strip=True)) + time_range = f"{start} - {end}" + + day_cells = content_children[index + 1:index + 7] + if len(day_cells) < 6: + break + + for day_index, cell in enumerate(day_cells): + lessons = _parse_schedule_item_cell(cell) + for lesson in lessons: + parsed_days[day_index]["lessons"].append( + { + "time": time_range, + "title": lesson["title"], + "type": lesson["type"], + "teacher": lesson["teacher"], + "room": lesson["room"], + "groups": lesson["groups"], + "staff_url": lesson["staff_url"], + "staff_id": lesson["staff_id"], + } + ) + + index += 7 + + return parsed_days + + +def get_groups() -> List[Dict[str, str]]: + return [ + {"id": "1282690301", "name": "6411-100503D"}, + {"id": "1282690279", "name": "6412-100503D"}, + {"id": "1213641978", "name": "6413-100503D"}, + ] + + +def get_teachers() -> List[Dict[str, str]]: + teachers: Dict[str, Dict[str, str]] = {} + + for group in get_groups(): + for week in [31, 32, 33, 34, 35, 36, 37, 38]: + try: + schedule = get_schedule_by_group(group["id"], week) + except Exception: + continue + + for day in schedule.get("days", []): + for lesson in day.get("lessons", []): + teacher = lesson.get("teacher", "").strip() + if teacher: + teachers[teacher] = { + "id": teacher, + "name": teacher, + "staff_url": lesson.get("staff_url", ""), + } + + return [teachers[name] for name in sorted(teachers)] + + +def get_schedule_by_group(group_id: str, week: Optional[int] = None) -> Dict[str, Any]: + cache_key = f"parsed:{group_id}:{week}" + cached = _cache_get(cache_key) + if cached is not None: + return cached + + html = _fetch_schedule_page(group_id, week) + soup = BeautifulSoup(html, "lxml") + + metadata = _extract_group_meta(soup, group_id) + active_week = _extract_active_week(soup, week) + + page_text = _clean_text(soup.get_text(" ", strip=True)) + if "Расписание пока не введено" in page_text: + result = { + "mode": "group", + "entity": group_id, + "week": active_week, + "meta": metadata, + "days": [], + "message": "Расписание пока не введено", + } + _cache_set(cache_key, result) + return result + + schedule_days = _parse_schedule_items_dom(soup) + if not schedule_days: + result = { + "mode": "group", + "entity": group_id, + "week": active_week, + "meta": metadata, + "days": [], + "message": "Не удалось распарсить расписание", + } + _cache_set(cache_key, result) + return result + + result = { + "mode": "group", + "entity": group_id, + "week": active_week, + "meta": metadata, + "days": schedule_days, + } + _cache_set(cache_key, result) + return result + + +def get_schedule_by_teacher(teacher_name: str, week: Optional[int] = None) -> Dict[str, Any]: + teacher_name = (teacher_name or "").strip() + if not teacher_name: + return { + "mode": "teacher", + "entity": "", + "week": week or get_current_week(), + "days": [], + "message": "Имя преподавателя не передано", + "meta": {}, + } + + matched_days: Dict[str, Dict[str, Any]] = {} + teacher_staff_url = "" + + for group in get_groups(): + try: + group_schedule = get_schedule_by_group(group["id"], week) + except Exception: + continue + + for day in group_schedule.get("days", []): + filtered_lessons = [] + for lesson in day.get("lessons", []): + teacher = lesson.get("teacher", "") + if teacher_name.lower() in teacher.lower(): + lesson_copy = dict(lesson) + lesson_copy["group"] = group["name"] + filtered_lessons.append(lesson_copy) + + if lesson.get("staff_url") and not teacher_staff_url: + teacher_staff_url = lesson["staff_url"] + + if filtered_lessons: + key = f'{day["day_name"]}|{day["date"]}' + if key not in matched_days: + matched_days[key] = { + "day_name": day["day_name"], + "date": day["date"], + "lessons": [], + } + matched_days[key]["lessons"].extend(filtered_lessons) + + return { + "mode": "teacher", + "entity": teacher_name, + "week": week or get_current_week(), + "days": list(matched_days.values()), + "meta": { + "title": teacher_name, + "subtitle": "Преподаватель Самарского университета", + "study_year_start": "01.09.2025", + "staff_url": teacher_staff_url, + }, + } \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 00000000..89d910ef --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,566 @@ +:root { + --bg-main: #050915; + --bg-deep: #0a1228; + --bg-panel: rgba(13, 20, 44, 0.84); + --bg-cell: rgba(255, 255, 255, 0.045); + --text-main: #eef4ff; + --text-soft: #b7c6ec; + --text-muted: #91a3d2; + --line-soft: rgba(137, 166, 255, 0.18); + --line-strong: rgba(134, 176, 255, 0.28); + --lecture: #56d6b2; + --practice: #67b3ff; + --lab: #ce7cff; + --exam: #ff6ca8; + --credit: #ffd76c; + --consult: #55f0db; + --other: #ffa85c; + --shadow-panel: 0 16px 50px rgba(0, 0, 0, 0.28); + --shadow-glow: 0 0 28px rgba(81, 142, 255, 0.12); + --radius-xl: 22px; + --radius-lg: 18px; + --radius-md: 14px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + min-height: 100vh; + font-family: "Segoe UI", Arial, sans-serif; + color: var(--text-main); + background: + radial-gradient(circle at 8% 10%, rgba(77, 168, 255, 0.14), transparent 22%), + radial-gradient(circle at 85% 12%, rgba(168, 124, 255, 0.12), transparent 24%), + radial-gradient(circle at 50% 100%, rgba(85, 227, 255, 0.08), transparent 25%), + linear-gradient(180deg, #040814 0%, #071024 35%, #0a1430 70%, #081022 100%); +} + +.container { + width: min(1760px, 98%); + margin: 0 auto; +} + +.hero-header { + position: relative; + overflow: hidden; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 14px 36px rgba(45, 90, 255, 0.2); + background: + radial-gradient(circle at 20% 40%, rgba(255, 255, 255, 0.1), transparent 18%), + radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.08), transparent 16%), + linear-gradient(135deg, rgba(28, 117, 255, 0.96), rgba(105, 49, 218, 0.95)); +} + +.hero-space-layer { + position: absolute; + inset: 0; + opacity: 0.28; + pointer-events: none; + background-image: + radial-gradient(circle, rgba(255, 255, 255, 0.9) 0.7px, transparent 0.8px), + radial-gradient(circle, rgba(255, 255, 255, 0.6) 0.6px, transparent 0.8px), + radial-gradient(circle, rgba(255, 255, 255, 0.35) 0.4px, transparent 0.7px); + background-size: 160px 160px, 220px 220px, 300px 300px; + background-position: 0 0, 40px 80px, 120px 40px; +} + +.hero-content { + position: relative; + z-index: 1; + padding: 38px 0 42px; +} + +.hero-brand { + display: flex; + align-items: center; + justify-content: center; + gap: 18px; +} + +.hero-logo { + width: 54px; + height: 54px; + flex: 0 0 54px; + color: white; + filter: drop-shadow(0 0 14px rgba(255, 255, 255, 0.25)); +} + +.hero-logo-svg { + display: block; + width: 100%; + height: 100%; +} + +.hero-text h1 { + margin: 0; + color: white; + font-size: clamp(34px, 4vw, 64px); + font-weight: 800; + line-height: 1.04; + letter-spacing: -0.02em; + text-align: center; + text-shadow: 0 3px 14px rgba(0, 0, 0, 0.14); +} + +.page-shell { + padding: 18px 0 34px; +} + +.cosmic-card { + border: 1px solid var(--line-soft); + box-shadow: var(--shadow-panel), var(--shadow-glow); + backdrop-filter: blur(14px); + background: var(--bg-panel); +} + +.group-info-card { + margin-bottom: 16px; + padding: 18px 20px 22px; + border-radius: var(--radius-xl); + background: + linear-gradient(135deg, rgba(44, 117, 255, 0.14), rgba(131, 84, 255, 0.12)), + rgba(12, 20, 44, 0.88); +} + +.card-top-line { + margin-bottom: 18px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.legend-badges { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.legend-badge { + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.03em; +} + +.legend-badge.lecture { + color: var(--lecture); +} + +.legend-badge.practice { + color: var(--practice); +} + +.legend-badge.lab { + color: var(--lab); +} + +.legend-badge.exam { + color: var(--exam); +} + +.legend-badge.credit { + color: var(--credit); +} + +.legend-badge.consult { + color: var(--consult); +} + +.legend-badge.other { + color: var(--other); +} + +.toolbar-line { + display: grid; + grid-template-columns: 0.9fr 1fr 1.2fr; + gap: 16px; + align-items: end; + margin-bottom: 20px; +} + +.toolbar-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.toolbar-group label { + color: var(--text-soft); + font-size: 14px; + font-weight: 600; +} + +select, +input { + width: 100%; + min-height: 52px; + padding: 0 16px; + border: 1px solid var(--line-strong); + border-radius: 16px; + outline: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.06); + color: var(--text-main); + font-size: 16px; +} + +select:focus, +input:focus { + border-color: rgba(85, 227, 255, 0.65); + box-shadow: 0 0 0 3px rgba(85, 227, 255, 0.12); +} + +input::placeholder { + color: var(--text-muted); +} + +select option { + background: #162347; + color: #eef4ff; +} + +.info-main-line { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 22px; +} + +.group-info-main { + flex: 1; +} + +.group-title { + margin-bottom: 12px; + font-size: 52px; + font-weight: 800; + line-height: 1.04; +} + +.group-info-card.teacher-mode .group-title { + font-size: 34px; + font-weight: 500; +} + +.group-program, +.group-form, +.group-start { + margin-bottom: 10px; + color: var(--text-soft); + font-size: 16px; +} + +.group-info-side { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + min-width: 220px; + padding-left: 22px; + border-left: 1px solid rgba(255, 255, 255, 0.08); + flex-wrap: wrap; +} + +.side-action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-width: 190px; + min-height: 54px; + padding: 0 18px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + background: rgba(255, 255, 255, 0.05); + color: white; + font-size: 16px; + font-weight: 700; + text-decoration: none; + cursor: pointer; + transition: 0.2s ease; +} + +.side-action-btn:hover { + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.1); +} + +.side-action-icon { + font-size: 18px; +} + +.week-banner { + display: grid; + grid-template-columns: 220px 1fr 220px; + align-items: center; + gap: 18px; + margin-bottom: 18px; + padding: 18px 22px; + border-radius: var(--radius-xl); + background: + linear-gradient(135deg, rgba(41, 88, 210, 0.22), rgba(116, 71, 221, 0.16)), + rgba(12, 20, 44, 0.88); +} + +.week-banner-btn { + min-height: 58px; + border: 1px solid var(--line-strong); + border-radius: 16px; + background: rgba(255, 255, 255, 0.05); + color: var(--text-main); + font-size: 16px; + cursor: pointer; + transition: 0.2s ease; +} + +.week-banner-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.week-banner-center { + text-align: center; +} + +.week-banner-number { + margin-bottom: 4px; + font-size: 36px; + font-weight: 800; +} + +.week-banner-subtitle { + color: var(--text-soft); + font-size: 14px; +} + +.status-block { + margin-bottom: 16px; +} + +.status-message { + padding: 14px 18px; + border: 1px solid var(--line-soft); + border-radius: 14px; + background: rgba(255, 255, 255, 0.06); + color: var(--text-main); + font-size: 16px; +} + +.error-message { + border-color: rgba(255, 108, 168, 0.35); + background: rgba(255, 108, 168, 0.08); + color: #ffc7d9; +} + +.schedule-empty { + padding: 24px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.04); + color: var(--text-soft); + font-size: 18px; +} + +.schedule-table-shell { + overflow: hidden; + border: 1px solid var(--line-soft); + border-radius: 20px; + box-shadow: var(--shadow-panel), var(--shadow-glow); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)), + rgba(10, 17, 36, 0.92); +} + +.schedule-table { + display: grid; + grid-template-columns: 76px repeat(6, minmax(0, 1fr)); + width: 100%; +} + +.schedule-head-cell, +.schedule-time-cell, +.schedule-day-cell { + border-right: 1px solid rgba(128, 161, 255, 0.12); + border-bottom: 1px solid rgba(128, 161, 255, 0.12); +} + +.schedule-head-cell:nth-child(7n), +.schedule-time-cell:last-child, +.schedule-day-cell:last-child { + border-right: none; +} + +.schedule-head-cell { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 84px; + padding: 10px 8px; + background: linear-gradient(180deg, rgba(91, 133, 255, 0.16), rgba(91, 133, 255, 0.06)); + text-align: center; +} + +.schedule-head-cell.time-header { + align-items: center; + color: var(--text-main); + font-size: 13px; + font-weight: 800; +} + +.day-name { + margin-bottom: 4px; + font-size: 14px; + font-weight: 800; +} + +.day-date { + color: var(--text-soft); + font-size: 11px; +} + +.schedule-time-cell { + display: flex; + align-items: flex-start; + justify-content: center; + min-height: 108px; + padding: 12px 6px; + background: rgba(255, 255, 255, 0.025); + color: var(--text-soft); + font-size: 13px; + font-weight: 700; + line-height: 1.35; + text-align: center; +} + +.schedule-day-cell { + min-height: 108px; + padding: 8px; + background: rgba(255, 255, 255, 0.015); +} + +.lesson-box { + position: relative; + overflow: hidden; + margin-bottom: 8px; + padding: 10px 10px 9px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + background: var(--bg-cell); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.lesson-box:last-child { + margin-bottom: 0; +} + +.lesson-box::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + border-radius: 999px; + background: var(--lesson-color, #55e3ff); + box-shadow: 0 0 16px var(--lesson-color, #55e3ff); +} + +.lesson-box:hover { + transform: translateY(-2px); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.2); +} + +.lesson-type-badge { + display: inline-block; + margin-bottom: 8px; + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--lesson-color, #55e3ff) 75%, black 25%); + color: white; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.03em; +} + +.lesson-title { + margin-bottom: 6px; + color: var(--text-main); + font-size: 13px; + font-weight: 800; + line-height: 1.25; +} + +.lesson-room, +.lesson-teacher, +.lesson-group, +.lesson-subgroups { + margin-bottom: 3px; + color: var(--text-soft); + font-size: 10px; + line-height: 1.35; +} + +.lesson-room strong, +.lesson-teacher strong, +.lesson-group strong, +.lesson-subgroups strong { + color: var(--text-main); +} + +.hidden { + display: none !important; +} + +@media (max-width: 1400px) { + .toolbar-line { + grid-template-columns: 1fr 1fr; + } + + .info-main-line { + flex-direction: column; + } + + .group-info-side { + min-width: auto; + justify-content: flex-start; + padding-top: 18px; + padding-left: 0; + border-top: 1px solid rgba(255, 255, 255, 0.08); + border-left: none; + } + + .week-banner { + grid-template-columns: 1fr; + } +} + +@media (max-width: 980px) { + .toolbar-line { + grid-template-columns: 1fr; + } + + .schedule-table-shell { + overflow-x: auto; + } + + .schedule-table { + min-width: 1080px; + } + + .hero-brand { + flex-direction: column; + text-align: center; + } + + .hero-text h1 { + font-size: 36px; + } +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 00000000..134e294b --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,444 @@ +const WEEK_OPTIONS_COUNT = 52; + +let currentScheduleData = null; +let teacherDirectory = {}; + +const LESSON_TYPE_COLOR_MAP = { + "Лекция": "#56d6b2", + "Практика": "#67b3ff", + "Лабораторная": "#ce7cff", + "Экзамен": "#ff6ca8", + "Зачёт": "#ffd76c", + "Зачет": "#ffd76c", + "Консультация": "#55f0db", + "Другое": "#ffa85c", +}; + +const FIXED_DAY_ORDER = [ + "Понедельник", + "Вторник", + "Среда", + "Четверг", + "Пятница", + "Суббота", +]; + +function fillWeekSelect() { + updateWeekBanner(getInitialWeek()); +} + +function getInitialWeek() { + return 1; +} + +function loadGroups() { + $.get("/api/groups", (data) => { + const select = $("#group-select"); + select.empty(); + + data.forEach((group) => { + select.append(`${group.name}`); + }); + + updateInfoCardHeader(); + updatePdfLink(); + }); +} + +function loadTeachers() { + $.get("/api/teachers", (data) => { + const select = $("#teacher-select"); + select.empty(); + teacherDirectory = {}; + + if (!data || data.length === 0) { + select.append('Преподаватели не найдены'); + return; + } + + data.forEach((teacher) => { + teacherDirectory[teacher.name] = teacher; + select.append(`${teacher.name}`); + }); + + updatePdfLink(); + }); +} + +function loadCurrentWeek() { + $.get("/api/current-week", (data) => { + let week = data && data.week ? data.week : 1; + + if (week < 1 || week > WEEK_OPTIONS_COUNT) { + week = 1; + } + + currentScheduleData = currentScheduleData || {}; + currentScheduleData.week = week; + + updateWeekBanner(week); + updatePdfLink(); + loadSchedule(); + }); +} + +function getCurrentWeekValue() { + return currentScheduleData && currentScheduleData.week ? currentScheduleData.week : 1; +} + +function updateWeekBanner(weekValue) { + $("#banner-week-number").text(`${weekValue} неделя`); +} + +function setTeacherCardStyle(isTeacherMode) { + $("#info-card").toggleClass("teacher-mode", isTeacherMode); +} + +function updateInfoCardHeader(data = null) { + const mode = $("#mode").val(); + const isTeacherMode = mode === "teacher"; + + setTeacherCardStyle(isTeacherMode); + + if (mode === "group") { + $("#staff-profile-btn").addClass("hidden").attr("href", "#"); + + const selectedGroupName = $("#group-select option:selected").text() || "6413-100503D"; + + $("#info-entity-title").text(selectedGroupName); + $("#info-program").text( + data?.meta?.program || "10.05.03 Информационная безопасность автоматизированных систем", + ); + $("#info-form").text( + data?.meta?.education_form || "Специалист (Очная форма обучения)", + ); + $("#info-start").text( + `Начало учебного года: ${data?.meta?.study_year_start || "01.09.2025"}`, + ); + + return; + } + + const teacherName = $("#teacher-select option:selected").text() || "Преподаватель"; + const teacherData = teacherDirectory[teacherName] || {}; + const staffUrl = data?.meta?.staff_url || teacherData.staff_url || ""; + + $("#info-entity-title").text(teacherName); + $("#info-program").text( + data?.meta?.subtitle || "Преподаватель Самарского университета", + ); + $("#info-form").text(""); + $("#info-start").text( + `Начало учебного года: ${data?.meta?.study_year_start || "01.09.2025"}`, + ); + + if (staffUrl) { + $("#staff-profile-btn").removeClass("hidden").attr("href", staffUrl); + } else { + $("#staff-profile-btn").addClass("hidden").attr("href", "#"); + } +} + +function updateModeUI() { + const mode = $("#mode").val(); + + $("#group-block").toggleClass("hidden", mode !== "group"); + $("#teacher-block").toggleClass("hidden", mode !== "teacher"); + + updateInfoCardHeader(); + updatePdfLink(); +} + +function updatePdfLink() { + const mode = $("#mode").val(); + const week = getCurrentWeekValue(); + + if (mode === "group") { + const groupId = $("#group-select").val() || ""; + $("#download-pdf-btn").attr( + "href", + `/download/pdf?mode=group&group_id=${encodeURIComponent(groupId)}&week=${encodeURIComponent(week)}`, + ); + return; + } + + const teacherName = $("#teacher-select").val() || ""; + $("#download-pdf-btn").attr( + "href", + `/download/pdf?mode=teacher&name=${encodeURIComponent(teacherName)}&week=${encodeURIComponent(week)}`, + ); +} + +function getSelectedSearchValue() { + return ($("#search-input").val() || "").trim().toLowerCase(); +} + +function filterLessons(lessons) { + const query = getSelectedSearchValue(); + + if (!query) { + return lessons; + } + + return lessons.filter((lesson) => { + const haystack = [ + lesson.title || "", + lesson.teacher || "", + lesson.room || "", + lesson.group || "", + (lesson.groups || []).join(" "), + ] + .join(" ") + .toLowerCase(); + + return haystack.includes(query); + }); +} + +function getLessonColor(type) { + return LESSON_TYPE_COLOR_MAP[type] || "#55e3ff"; +} + +function renderLessonBox(lesson, mode) { + const type = lesson.type || "Другое"; + const color = getLessonColor(type); + + const roomHtml = lesson.room + ? `Аудитория: ${lesson.room}` + : ""; + + const teacherHtml = lesson.teacher + ? `Преподаватель: ${lesson.teacher}` + : ""; + + const groupHtml = mode === "teacher" && lesson.group + ? `Группа: ${lesson.group}` + : ""; + + const subgroupHtml = lesson.groups && lesson.groups.length + ? `Группы: ${lesson.groups.join(", ")}` + : ""; + + return ` + + ${type.toUpperCase()} + ${lesson.title || "Без названия"} + ${roomHtml} + ${teacherHtml} + ${groupHtml} + ${subgroupHtml} + + `; +} + +function normalizeScheduleData(data) { + const dayMap = {}; + const timeSet = new Set(); + + FIXED_DAY_ORDER.forEach((dayName) => { + dayMap[dayName] = { + date: "", + lessonsByTime: {}, + }; + }); + + (data.days || []).forEach((day) => { + const dayName = day.day_name; + + if (!dayMap[dayName]) { + dayMap[dayName] = { + date: day.date || "", + lessonsByTime: {}, + }; + } + + dayMap[dayName].date = day.date || ""; + + filterLessons(day.lessons || []).forEach((lesson) => { + const time = lesson.time || "Время не указано"; + timeSet.add(time); + + if (!dayMap[dayName].lessonsByTime[time]) { + dayMap[dayName].lessonsByTime[time] = []; + } + + dayMap[dayName].lessonsByTime[time].push(lesson); + }); + }); + + const orderedTimes = Array.from(timeSet).sort((a, b) => { + const aStart = a.split(" - ")[0]; + const bStart = b.split(" - ")[0]; + return aStart.localeCompare(bStart); + }); + + return { dayMap, orderedTimes }; +} + +function renderEmptyState(message) { + $("#schedule-container").html(` + + ${message} + + `); +} + +function renderScheduleTable(data) { + if (!data || !data.days || data.days.length === 0) { + renderEmptyState(data?.message || "На выбранную неделю занятий нет."); + return; + } + + const { dayMap, orderedTimes } = normalizeScheduleData(data); + + if (orderedTimes.length === 0) { + renderEmptyState(data?.message || "На выбранную неделю занятий нет."); + return; + } + + let html = ''; + + html += 'Время'; + + FIXED_DAY_ORDER.forEach((dayName) => { + const date = dayMap[dayName]?.date || ""; + html += ` + + ${dayName} + ${date} + + `; + }); + + orderedTimes.forEach((time) => { + html += `${time.replace(" - ", "")}`; + + FIXED_DAY_ORDER.forEach((dayName) => { + const lessons = dayMap[dayName]?.lessonsByTime[time] || []; + const lessonsHtml = lessons.length + ? lessons.map((lesson) => renderLessonBox(lesson, data.mode)).join("") + : ""; + + html += `${lessonsHtml}`; + }); + }); + + html += ""; + $("#schedule-container").html(html); +} + +function renderSchedule(data) { + currentScheduleData = data; + + updateWeekBanner(data.week || getCurrentWeekValue()); + updateInfoCardHeader(data); + updatePdfLink(); + + if (data.message && (!data.days || data.days.length === 0)) { + renderEmptyState(data.message); + return; + } + + renderScheduleTable(data); +} + +function loadSchedule() { + const mode = $("#mode").val(); + const week = getCurrentWeekValue(); + + updatePdfLink(); + $("#loader").removeClass("hidden"); + $("#error-message").addClass("hidden").text(""); + $("#schedule-container").empty(); + + if (mode === "group") { + const groupId = $("#group-select").val(); + + $.get(`/api/schedule/group/${groupId}?week=${week}`) + .done((data) => { + renderSchedule(data); + }) + .fail(() => { + $("#error-message").removeClass("hidden").text("Ошибка загрузки расписания по группе."); + renderEmptyState("Не удалось загрузить расписание."); + }) + .always(() => { + $("#loader").addClass("hidden"); + }); + + return; + } + + const teacherName = $("#teacher-select").val(); + + $.get(`/api/schedule/teacher?name=${encodeURIComponent(teacherName)}&week=${week}`) + .done((data) => { + renderSchedule(data); + }) + .fail(() => { + $("#error-message").removeClass("hidden").text("Ошибка загрузки расписания по преподавателю."); + renderEmptyState("Не удалось загрузить расписание."); + }) + .always(() => { + $("#loader").addClass("hidden"); + }); +} + +function shiftWeek(delta) { + let current = getCurrentWeekValue() + delta; + + if (current < 1) { + current = 1; + } + + if (current > WEEK_OPTIONS_COUNT) { + current = WEEK_OPTIONS_COUNT; + } + + currentScheduleData = currentScheduleData || {}; + currentScheduleData.week = current; + + updateWeekBanner(current); + updatePdfLink(); + loadSchedule(); +} + +$(document).ready(() => { + fillWeekSelect(); + loadGroups(); + loadTeachers(); + updateModeUI(); + + $("#mode").on("change", () => { + updateModeUI(); + loadSchedule(); + }); + + $("#group-select").on("change", () => { + updateModeUI(); + updatePdfLink(); + loadSchedule(); + }); + + $("#teacher-select").on("change", () => { + updateModeUI(); + updatePdfLink(); + loadSchedule(); + }); + + $("#search-input").on("input", () => { + if (currentScheduleData) { + renderSchedule(currentScheduleData); + } + }); + + $("#week-prev-banner").on("click", () => { + shiftWeek(-1); + }); + + $("#week-next-banner").on("click", () => { + shiftWeek(1); + }); + + loadCurrentWeek(); +}); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 00000000..2ae51cf1 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,111 @@ + + + + + + Расписание Самарского университета + + + + + + + + + + + + + + + + + Расписание Самарского университета + + + + + + + + + + ● ЛЕКЦИЯ + ● ПРАКТИКА + ● ЛАБОРАТОРНАЯ + ● ЭКЗАМЕН + ● ЗАЧЁТ + ● КОНСУЛЬТАЦИЯ + ● ДРУГОЕ + + + + + + Режим + + По группе + По преподавателю + + + + + Группа + + + + + Преподаватель + + + + + Поиск + + + + + + + 6413-100503D + 10.05.03 Информационная безопасность автоматизированных систем + Специалист (Очная форма обучения) + Начало учебного года: 01.09.2025 + + + + + 📄 + Скачать PDF + + + + 👤 + Профиль на сайте + + + + + + + ‹ Предыдущая + + + 32 неделя + Учебная неделя + + + Следующая › + + + + Загрузка расписания... + + + + + + + + + \ No newline at end of file