From bbe6470223b7d3fb2dade2dbe61bf919cb93487d Mon Sep 17 00:00:00 2001 From: Leshechka123 Date: Mon, 6 Apr 2026 19:39:00 +0400 Subject: [PATCH 1/2] This laba destroy me, but it's ready --- .gitignore | 32 +++ README.md | 64 +---- app/__init__.py | 0 app/cache.py | 127 +++++++++ app/client.py | 149 +++++++++++ app/main.py | 203 ++++++++++++++ app/models.py | 58 ++++ app/parser.py | 234 ++++++++++++++++ requirements.txt | 6 + run.py | 10 + static/app.js | 405 ++++++++++++++++++++++++++++ static/index.html | 88 ++++++ static/styles.css | 669 ++++++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 1994 insertions(+), 51 deletions(-) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/cache.py create mode 100644 app/client.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/parser.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b945d6e30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +venv/ +env/ +ENV/ +.venv/ + +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +debug*.html +test_*.html +*.log + +cache/ + +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +*.egg-info/ +*.egg +dist/ +build/ + +.env +.env.local \ No newline at end of file diff --git a/README.md b/README.md index 163d41b9a..c60af095a 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,23 @@ -# Безопасность веб-приложений. Лабораторка №2 +## Установка и запуск -## Схема сдачи +### 1. Клонирование репозитория -1. Получить задание -2. Сделать форк данного репозитория -3. Выполнить задание согласно полученному варианту -4. Сделать PR (pull request) в данный репозиторий -6. Исправить замечания после code review -7. Получить approve -8. Прийти на занятие и защитить работу +git clone +cd websec-2 -Что нужно проявить в работе: -- умение разработать завершенное целое веб-приложение, с клиентской и серверной частями (допустимы открытые АПИ) -- навыки верстки на html в объеме 200-300 тегов -- навыки применения css для лейаута и стилизации, желательно с адаптацией к мобилке -- использование jQuery или аналогичных JS-фреймворков -- динамическая подгрузка контента -- динамическое изменение DOM и CSSOM - -Если у вас своя идея по заданию, то расскажите, обсудим и подкорректирую. - -## Вариант 1. Расписания - -Сделать аналог раздела https://ssau.ru/rasp?groupId=531030143 - -Какие нужны возможности: -- справочники групп, табличные данные по расписаниям добывать с настоящего сайта на серверной стороне приложения -- в клиентскую часть подгружать эти сведения динамически по JSON-API -- обеспечить возможность смотреть расписания в разрезе группы или препода -- обеспечить возможность выбора учебной недели (по умолчанию выбирается автоматически) - -## Вариант 2. Аналог Прибывалки для электричек - -Сделать веб-версию Прибывалки, только для электричек - -Какие нужны возможности: -- находить желаемую ЖД-станцию поиском по названию и по карте -- отображать расписания всех проходящих поездов через выбранную станцию -- отображать расписания для поездов между двумя станциями -- работа через АПИ Яндекс.Расписаний https://yandex.ru/dev/rasp/doc/ru/ (доступ получите сами) -- хорошая работа в условиях экрана смартфона -- бонус: функция "любимых остановок" - -## Вариант 3. Прогноз погоды - -Сделать одностраничный сайт с картой, на которой можно выбрать населенный пункт и получить прогноз погоды на несколько дней по нему. - -Какие нужны возможности: - - увидеть на карте точки с населенными пунктами. Координаты населенных пунктов взять из https://tochno.st/datasets/allsettlements - но все 150 тысяч не нужно, выберите 1 тысячу с самым большим населением. - - при нажатии на точку получить всплывающее окошко с графиками изменения температуры, осадков, силы ветра. API для прогнозов возьмите с https://projecteol.ru/ru/ с соблюдением правил. - - графики рисовать каким-нибудь приличным компонентом, например, https://www.chartjs.org/ - - находить населенный пункт по названию - - можете реализовать с собственным серверным компонентом или придумать, как обойтись без него +### 2. Создание виртуального окружения +python -m venv venv +venv\Scripts\activate +### 3. Установка зависимостей +pip install -r requirements.txt +### 4. Запуск сервера +python run.py +### 5. Открыть в браузере +http://localhost:8000 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/cache.py b/app/cache.py new file mode 100644 index 000000000..2a284ac98 --- /dev/null +++ b/app/cache.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import asyncio +import pickle +import time +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, Generic, Optional, TypeVar + +T = TypeVar("T") + + +class AsyncTTLCache(Generic[T]): + """ + In-memory cache with optional disk persistence. + """ + + def __init__(self, ttl_seconds: int, max_items: int = 512, persist_dir: Optional[str] = None): + self.ttl_seconds = int(ttl_seconds) + self.max_items = int(max_items) + + self._data: Dict[Any, tuple[float, T]] = {} + self._inflight: Dict[Any, asyncio.Task[T]] = {} + self._lock = asyncio.Lock() + + self._persist_dir = persist_dir + if persist_dir: + Path(persist_dir).mkdir(parents=True, exist_ok=True) + self._load_from_disk() + + def _now(self) -> float: + return time.monotonic() + + def _is_expired(self, expires_at: float) -> bool: + return expires_at <= self._now() + + def _get_key_path(self, key: Any) -> Optional[Path]: + if not self._persist_dir: + return None + safe_key = str(key).replace("/", "_").replace("\\", "_").replace(":", "_") + return Path(self._persist_dir) / f"{safe_key}.pickle" + + def _load_from_disk(self): + """Загружает кэш с диска при старте""" + if not self._persist_dir: + return + try: + for p in Path(self._persist_dir).glob("*.pickle"): + try: + with open(p, "rb") as f: + data = pickle.load(f) + if isinstance(data, tuple) and len(data) == 2: + expires_at, value = data + if not self._is_expired(expires_at): + # Восстанавливаем ключ из имени файла + key = p.stem.replace("_", "/") + self._data[key] = (expires_at, value) + except Exception: + pass + except Exception: + pass + + def _save_to_disk(self, key: Any, expires_at: float, value: T): + """Сохраняет значение на диск""" + path = self._get_key_path(key) + if path: + try: + with open(path, "wb") as f: + pickle.dump((expires_at, value), f) + except Exception: + pass + + async def get(self, key: Any) -> Optional[T]: + async with self._lock: + item = self._data.get(key) + if not item: + return None + expires_at, value = item + if self._is_expired(expires_at): + self._data.pop(key, None) + return None + return value + + async def set(self, key: Any, value: T) -> None: + async with self._lock: + if len(self._data) >= self.max_items: + self._data.pop(next(iter(self._data.keys())), None) + expires_at = self._now() + self.ttl_seconds + self._data[key] = (expires_at, value) + self._save_to_disk(key, expires_at, value) + + async def get_or_set(self, key: Any, factory: Callable[[], Awaitable[T]]) -> T: + cached = await self.get(key) + if cached is not None: + return cached + + async with self._lock: + item = self._data.get(key) + if item and not self._is_expired(item[0]): + return item[1] + + inflight = self._inflight.get(key) + if inflight is None: + task = asyncio.create_task(factory()) + self._inflight[key] = task + else: + task = inflight + + try: + value = await task + finally: + async with self._lock: + self._inflight.pop(key, None) + + await self.set(key, value) + return value + + def clear(self): + """Очищает кэш (синхронный метод)""" + self._data.clear() + self._inflight.clear() + + if self._persist_dir: + try: + for p in Path(self._persist_dir).glob("*.pickle"): + p.unlink() + except Exception: + pass \ No newline at end of file diff --git a/app/client.py b/app/client.py new file mode 100644 index 000000000..ab5f86e61 --- /dev/null +++ b/app/client.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import re +from typing import List, Tuple, Optional +from urllib.parse import urljoin + +import httpx +from bs4 import BeautifulSoup + +SSAU_BASE = "https://ssau.ru" + + +class SsauClient: + """HTTP клиент для взаимодействия с ssau.ru""" + + def __init__(self, timeout: float = 30.0): + self._client = httpx.AsyncClient( + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", + }, + timeout=httpx.Timeout(timeout), + follow_redirects=True, + ) + self._csrf_token: Optional[str] = None + + async def close(self): + await self._client.aclose() + + async def fetch_html(self, url: str) -> str: + """Получает HTML страницы""" + print(f"Fetching URL: {url}") + response = await self._client.get(url) + response.raise_for_status() + + if "ssau.ru/rasp" not in response.url.path: + print(f"Warning: Redirected to {response.url}") + + return response.text + + async def get_institutes(self) -> List[Tuple[int, str, str]]: + """Получает список институтов/факультетов""" + html = await self.fetch_html(urljoin(SSAU_BASE, "/rasp")) + soup = BeautifulSoup(html, "lxml") + + institutes = [] + for link in soup.select('a[href*="/rasp/faculty/"]'): + href = link.get("href", "") + match = re.search(r"/rasp/faculty/(\d+)", href) + if match: + inst_id = int(match.group(1)) + name = link.get_text(strip=True) + if name and len(name) > 1: + institutes.append((inst_id, name, urljoin(SSAU_BASE, href))) + + return sorted(institutes, key=lambda x: x[1]) + + async def get_courses(self, institute_id: int) -> List[int]: + """Получает доступные курсы для института""" + url = urljoin(SSAU_BASE, f"/rasp/faculty/{institute_id}") + html = await self.fetch_html(url) + soup = BeautifulSoup(html, "lxml") + + courses = set() + for link in soup.select('a[href*="course="], button[data-course]'): + href = link.get("href", "") + match = re.search(r"course=(\d+)", href) + if match: + course = int(match.group(1)) + if 1 <= course <= 12: + courses.add(course) + + data_course = link.get("data-course") + if data_course: + try: + course = int(data_course) + if 1 <= course <= 12: + courses.add(course) + except ValueError: + pass + + return sorted(courses) if courses else [1, 2, 3, 4] + + async def get_groups(self, institute_id: int, course: int) -> List[Tuple[int, str]]: + """Получает группы для института и курса""" + url = urljoin(SSAU_BASE, f"/rasp/faculty/{institute_id}?course={course}") + html = await self.fetch_html(url) + soup = BeautifulSoup(html, "lxml") + + groups = [] + for link in soup.select('a[href*="groupId="]'): + href = link.get("href", "") + match = re.search(r"groupId=(\d+)", href) + if match: + group_id = int(match.group(1)) + name = link.get_text(strip=True) + if name and len(name) > 1: + groups.append((group_id, name)) + + if not groups: + for link in soup.select('[data-group-id]'): + group_id = link.get("data-group-id") + if group_id: + name = link.get_text(strip=True) + groups.append((int(group_id), name)) + + return sorted(groups, key=lambda x: x[1]) + + async def search_teachers(self, query: str) -> List[Tuple[int, str]]: + """Ищет преподавателей по имени""" + if len(query) < 2: + return [] + + if not self._csrf_token: + html = await self.fetch_html(urljoin(SSAU_BASE, "/rasp")) + match = re.search(r'csrf-token" content="([^"]+)"', html) + if match: + self._csrf_token = match.group(1) + + if not self._csrf_token: + return [] + + try: + response = await self._client.post( + urljoin(SSAU_BASE, "/rasp/search"), + data={"text": query}, + headers={ + "X-CSRF-TOKEN": self._csrf_token, + "X-Requested-With": "XMLHttpRequest", + "Content-Type": "application/x-www-form-urlencoded", + "Referer": urljoin(SSAU_BASE, "/rasp"), + }, + ) + + if response.status_code == 200: + data = response.json() + teachers = [] + for item in data: + url = item.get("url", "") + if "staffId=" in url: + match = re.search(r"staffId=(\d+)", url) + if match: + teachers.append((int(match.group(1)), item.get("text", ""))) + return teachers + except Exception as e: + print(f"Search error: {e}") + + return [] \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 000000000..5454c7b00 --- /dev/null +++ b/app/main.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import Optional + +from fastapi import FastAPI, Query +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from .cache import AsyncTTLCache +from .client import SsauClient +from .parser import ScheduleParser +from .models import ApiResponse, WeekSchedule + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STATIC_DIR = os.path.join(PROJECT_ROOT, "static") +CACHE_DIR = os.path.join(PROJECT_ROOT, "cache") + +os.makedirs(STATIC_DIR, exist_ok=True) +os.makedirs(CACHE_DIR, exist_ok=True) + +ssau_client = SsauClient() + +institutes_cache = AsyncTTLCache(3600 * 24, 10, os.path.join(CACHE_DIR, "institutes")) +courses_cache = AsyncTTLCache(3600 * 24, 100, os.path.join(CACHE_DIR, "courses")) +groups_cache = AsyncTTLCache(3600 * 12, 500, os.path.join(CACHE_DIR, "groups")) +teachers_cache = AsyncTTLCache(3600, 1000, os.path.join(CACHE_DIR, "teachers")) +schedule_cache = AsyncTTLCache(300, 500, os.path.join(CACHE_DIR, "schedule")) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Управление жизненным циклом приложения""" + yield + await ssau_client.close() + + +app = FastAPI( + title="SSAU Schedule API", + description="Прокси-сервер для расписания СГАУ", + version="2.0.0", + lifespan=lifespan, +) + +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.get("/") +async def index(): + """Главная страница""" + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + return JSONResponse( + status_code=404, + content={"error": "index.html not found"} + ) + + +# API endpoints + +@app.get("/api/institutes") +async def get_institutes(): + """Список институтов/факультетов""" + async def fetch(): + items = await ssau_client.get_institutes() + return {"institutes": [{"id": i, "name": n, "url": u} for i, n, u in items]} + + data = await institutes_cache.get_or_set("institutes", fetch) + return ApiResponse(success=True, data=data) + + +@app.get("/api/courses") +async def get_courses(institute_id: int = Query(...)): + """Список курсов для института""" + async def fetch(): + courses = await ssau_client.get_courses(institute_id) + return {"institute_id": institute_id, "courses": courses} + + data = await courses_cache.get_or_set(f"courses:{institute_id}", fetch) + return ApiResponse(success=True, data=data) + + +@app.get("/api/groups") +async def get_groups( + institute_id: int = Query(...), + course: int = Query(..., ge=1, le=12), +): + """Список групп для института и курса""" + async def fetch(): + groups = await ssau_client.get_groups(institute_id, course) + return { + "institute_id": institute_id, + "course": course, + "groups": [{"id": g, "name": n} for g, n in groups], + } + + data = await groups_cache.get_or_set(f"groups:{institute_id}:{course}", fetch) + return ApiResponse(success=True, data=data) + + +@app.get("/api/teachers") +async def search_teachers(q: str = Query(..., min_length=2)): + """Поиск преподавателей""" + async def fetch(): + teachers = await ssau_client.search_teachers(q) + return { + "query": q, + "teachers": [{"id": t, "name": n} for t, n in teachers], + } + + data = await teachers_cache.get_or_set(f"teachers:{q.lower()}", fetch) + return ApiResponse(success=True, data=data) + + +@app.get("/api/schedule") +async def get_schedule( + group_id: Optional[int] = Query(None), + staff_id: Optional[int] = Query(None), + week: Optional[int] = Query(None), +): + """Расписание для группы или преподавателя""" + + if group_id is None and staff_id is None: + return JSONResponse( + status_code=400, + content=ApiResponse(success=False, error="Укажите group_id или staff_id").model_dump(), + ) + + entity_type = "group" if group_id else "teacher" + entity_id = group_id or staff_id + week_key = week if week else "current" + cache_key = f"{entity_type}:{entity_id}:{week_key}" + + async def fetch(): + if group_id: + url = f"https://ssau.ru/rasp?groupId={group_id}" + if week: + url += f"&selectedWeek={week}&selectedWeekday=1" + else: + url = f"https://ssau.ru/rasp?staffId={staff_id}" + if week: + url += f"&selectedWeek={week}&selectedWeekday=1" + + print(f"Fetching: {url}") + + html = await ssau_client.fetch_html(url) + + parsed = ScheduleParser.parse_schedule(html) + + days_list = [] + for day in parsed.days: + lessons_list = [] + for lesson in day.lessons: + lessons_list.append({ + "time_start": lesson.time_start, + "time_end": lesson.time_end, + "subject": lesson.subject, + "lesson_type": lesson.lesson_type, + "room": lesson.room, + "teachers": lesson.teachers, + "groups": lesson.groups, + "subgroup": lesson.subgroup, + "comment": lesson.comment, + }) + days_list.append({ + "weekday": day.weekday, + "date": day.date, + "date_iso": day.date_iso, + "lessons": lessons_list, + }) + + time_slots_list = [(start, end) for start, end in parsed.time_slots] + + result = { + "week_number": parsed.week_number, + "week_label": parsed.week_label, + "week_dates": parsed.week_dates, + "week_start_date": parsed.week_start_date, + "week_end_date": parsed.week_end_date, + "prev_week": parsed.prev_week, + "next_week": parsed.next_week, + "entity_name": parsed.entity_name, + "entity_type": entity_type, + "days": days_list, + "time_slots": time_slots_list, + } + + print(f"Parsed days: {len(days_list)}, time slots: {len(time_slots_list)}") + return result + + try: + data = await schedule_cache.get_or_set(cache_key, fetch) + return ApiResponse(success=True, data=data) + except Exception as e: + import traceback + traceback.print_exc() + return JSONResponse( + status_code=502, + content=ApiResponse(success=False, error=f"Ошибка загрузки: {str(e)}").model_dump(), + ) \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 000000000..99ad5ce7e --- /dev/null +++ b/app/models.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional, Literal +from pydantic import BaseModel, Field + + +class Institute(BaseModel): + id: int + name: str + url: str + + +class Group(BaseModel): + id: int + name: str + + +class Teacher(BaseModel): + id: int + name: str + + +class Lesson(BaseModel): + time_start: str + time_end: str + subject: str + lesson_type: str + room: Optional[str] = None + teachers: List[dict] = [] + groups: List[dict] = [] + subgroup: Optional[str] = None + + +class Day(BaseModel): + weekday: str + weekday_num: int + date: Optional[str] = None + date_iso: Optional[str] = None + lessons: List[Lesson] = [] + + +class WeekSchedule(BaseModel): + week_number: Optional[int] = None + week_start_date: Optional[str] = None + week_end_date: Optional[str] = None + entity_name: str + entity_type: Literal["group", "teacher"] + days: List[Day] = [] + prev_week: Optional[int] = None + next_week: Optional[int] = None + fetched_at: datetime = Field(default_factory=datetime.utcnow) + + +class ApiResponse(BaseModel): + success: bool + data: Optional[dict] = None + error: Optional[str] = None \ No newline at end of file diff --git a/app/parser.py b/app/parser.py new file mode 100644 index 000000000..ecbdd34af --- /dev/null +++ b/app/parser.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional, Tuple +from urllib.parse import parse_qs, urlparse + +from bs4 import BeautifulSoup + + +@dataclass +class ParsedLesson: + time_start: str + time_end: str + subject: str + lesson_type: str + room: Optional[str] + teachers: List[dict] + groups: List[dict] + subgroup: Optional[str] + comment: Optional[str] + + +@dataclass +class ParsedDay: + weekday: str + date: Optional[str] + date_iso: Optional[str] + lessons: List[ParsedLesson] + + +@dataclass +class ParsedSchedule: + week_number: Optional[int] + week_label: Optional[str] + week_dates: Optional[str] + week_start_date: Optional[str] + week_end_date: Optional[str] + prev_week: Optional[int] + next_week: Optional[int] + entity_name: str + days: List[ParsedDay] + time_slots: List[Tuple[str, str]] + + +class ScheduleParser: + + @staticmethod + def parse_schedule(html: str) -> ParsedSchedule: + soup = BeautifulSoup(html, "lxml") + + print("=== НАЧАЛО ПАРСИНГА ===") + + entity_name = "—" + h1 = soup.find("h1", class_="h1-text") + if h1: + entity_name = h1.get_text(strip=True).replace("Расписание,", "").strip() + print(f"Название: {entity_name}") + + week_number = None + week_label = None + week_nav = soup.select_one(".week-nav-current_week") + if week_nav: + week_label = week_nav.get_text(strip=True) + print(f"Week nav text: {week_label}") + match = re.search(r"(\d+)", week_label) + if match: + week_number = int(match.group(1)) + + prev_week = None + next_week = None + prev_link = soup.select_one(".week-nav-prev") + if prev_link: + href = prev_link.get("href", "") + match = re.search(r"selectedWeek=(\d+)", href) + if match: + prev_week = int(match.group(1)) + + next_link = soup.select_one(".week-nav-next") + if next_link: + href = next_link.get("href", "") + match = re.search(r"selectedWeek=(\d+)", href) + if match: + next_week = int(match.group(1)) + + print(f"Week: {week_number}, prev: {prev_week}, next: {next_week}") + + headers = soup.select(".schedule__head") + print(f"Найдено заголовков: {len(headers)}") + + days = [] + for idx, header in enumerate(headers): + print(f" Заголовок {idx}: {header.get_text(strip=True)[:50]}") + if idx == 0: + continue + + weekday_elem = header.select_one(".schedule__head-weekday") + date_elem = header.select_one(".schedule__head-date") + + weekday = weekday_elem.get_text(strip=True) if weekday_elem else f"День {idx}" + date_str = date_elem.get_text(strip=True) if date_elem else None + + date_iso = None + if date_str: + try: + dt = datetime.strptime(date_str, "%d.%m.%Y") + date_iso = dt.date().isoformat() + except: + pass + + days.append(ParsedDay( + weekday=weekday, + date=date_str, + date_iso=date_iso, + lessons=[] + )) + + print(f"Дней: {len(days)}") + + time_blocks = soup.select(".schedule__time") + print(f"Найдено временных блоков: {len(time_blocks)}") + + time_slots = [] + + for ti, time_block in enumerate(time_blocks): + time_items = time_block.select(".schedule__time-item") + print(f" Временной блок {ti}: {len(time_items)} элементов") + + if len(time_items) >= 2: + time_start = time_items[0].get_text(strip=True) + time_end = time_items[1].get_text(strip=True) + time_slots.append((time_start, time_end)) + print(f" Время: {time_start} - {time_end}") + + current = time_block.find_next_sibling() + day_idx = 0 + while current and day_idx < len(days): + if "schedule__item" in current.get("class", []): + lessons = ScheduleParser._parse_lesson_cell(current, time_start, time_end) + if lessons: + print(f" День {day_idx}: {len(lessons)} занятий") + days[day_idx].lessons.extend(lessons) + day_idx += 1 + current = current.find_next_sibling() + + print(f"Итого временных слотов: {len(time_slots)}") + + week_start_date = None + week_end_date = None + week_dates = None + + if days and days[0].date and days[-1].date: + week_dates = f"{days[0].date} - {days[-1].date}" + + return ParsedSchedule( + week_number=week_number, + week_label=week_label, + week_dates=week_dates, + week_start_date=week_start_date, + week_end_date=week_end_date, + prev_week=prev_week, + next_week=next_week, + entity_name=entity_name, + days=days, + time_slots=time_slots, + ) + + @staticmethod + def _parse_lesson_cell(cell, time_start: str, time_end: str) -> List[ParsedLesson]: + """Парсит ячейку с занятиями""" + lessons = [] + + lesson_divs = cell.select(".schedule__lesson") + + for lesson_div in lesson_divs: + type_chip = lesson_div.select_one(".schedule__lesson-type-chip") + lesson_type = type_chip.get_text(strip=True) if type_chip else "—" + + discipline = lesson_div.select_one(".schedule__discipline") + subject = discipline.get_text(strip=True) if discipline else "—" + + place = lesson_div.select_one(".schedule__place") + room = place.get_text(strip=True) if place else None + + teachers = [] + teacher_links = lesson_div.select(".schedule__teacher a") + for link in teacher_links: + href = link.get("href", "") + match = re.search(r"staffId=(\d+)", href) + teachers.append({ + "staff_id": int(match.group(1)) if match else None, + "name": link.get_text(strip=True) + }) + + if not teachers: + teacher_text = lesson_div.select_one(".schedule__teacher") + if teacher_text: + text = teacher_text.get_text(strip=True) + if text and text not in ["—", ""]: + teachers.append({"staff_id": None, "name": text}) + + groups = [] + group_links = lesson_div.select(".schedule__groups a") + for link in group_links: + href = link.get("href", "") + match = re.search(r"groupId=(\d+)", href) + groups.append({ + "group_id": int(match.group(1)) if match else None, + "name": link.get_text(strip=True) + }) + + subgroup = None + groups_text = lesson_div.select_one(".schedule__groups") + if groups_text: + text = groups_text.get_text(strip=True) + if "Подгруппы:" in text or "Подгруппа:" in text: + match = re.search(r"Подгрупп[аы]*:\s*(.+)", text) + if match: + subgroup = match.group(1).strip() + + lessons.append(ParsedLesson( + time_start=time_start, + time_end=time_end, + subject=subject, + lesson_type=lesson_type, + room=room, + teachers=teachers, + groups=groups, + subgroup=subgroup, + comment=None + )) + + return lessons \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..c0db95af0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +httpx==0.25.1 +beautifulsoup4==4.12.2 +lxml==4.9.3 +pydantic==2.5.0 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 000000000..20056eba3 --- /dev/null +++ b/run.py @@ -0,0 +1,10 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host="127.0.0.1", + port=8000, + reload=True, + log_level="info" + ) \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 000000000..982b8fd27 --- /dev/null +++ b/static/app.js @@ -0,0 +1,405 @@ +(function() { + 'use strict'; + + let currentState = { + mode: 'group', + groupId: null, + teacherId: null, + teacherName: null, + currentSchedule: null + }; + + function loadInstitutes() { + $('#instituteSelect').html(''); + + $.get('/api/institutes') + .done(function(response) { + if (response.success && response.data) { + const institutes = response.data.institutes; + const $select = $('#instituteSelect'); + $select.empty().append(''); + + institutes.forEach(function(inst) { + $select.append(``); + }); + } + }) + .fail(function() { + $('#instituteSelect').html(''); + }); + } + + function loadCourses(instituteId) { + $('#courseSelect').prop('disabled', true).html(''); + + $.get('/api/courses', { institute_id: instituteId }) + .done(function(response) { + if (response.success && response.data) { + const courses = response.data.courses; + const $select = $('#courseSelect'); + $select.empty().append(''); + + courses.forEach(function(course) { + $select.append(``); + }); + $select.prop('disabled', false); + } + }); + } + + function loadGroups(instituteId, course) { + $('#groupSelect').prop('disabled', true).html(''); + $('#showScheduleBtn').prop('disabled', true); + + $.get('/api/groups', { institute_id: instituteId, course: course }) + .done(function(response) { + if (response.success && response.data) { + const groups = response.data.groups; + const $select = $('#groupSelect'); + $select.empty().append(''); + + groups.forEach(function(group) { + $select.append(``); + }); + $select.prop('disabled', false); + } + }); + } + + + let searchTimeout; + function initTeacherSearch() { + const $input = $('#teacherInput'); + const $suggestions = $('#teacherSuggestions'); + let currentQuery = ''; + + $input.on('input', function() { + clearTimeout(searchTimeout); + const query = $(this).val().trim(); + currentQuery = query; + + if (query.length < 2) { + $suggestions.hide().empty(); + $('#showTeacherScheduleBtn').prop('disabled', true); + currentState.teacherId = null; + currentState.teacherName = null; + return; + } + + searchTimeout = setTimeout(function() { + $.get('/api/teachers', { q: query }) + .done(function(response) { + if (currentQuery !== query) return; + + if (response.success && response.data && response.data.teachers.length > 0) { + const teachers = response.data.teachers; + $suggestions.empty(); + + teachers.slice(0, 10).forEach(function(teacher) { + $suggestions.append(` +
+
${escapeHtml(teacher.name)}
+
ID: ${teacher.id}
+
+ `); + }); + $suggestions.show(); + } else { + $suggestions.html('
Ничего не найдено
').show(); + } + }) + .fail(function() { + $suggestions.html('
Ошибка поиска
').show(); + }); + }, 300); + }); + + $(document).on('click', '.suggestion-item[data-id]', function() { + const id = $(this).data('id'); + const name = $(this).data('name'); + $('#teacherInput').val(name); + currentState.teacherId = id; + currentState.teacherName = name; + $('#showTeacherScheduleBtn').prop('disabled', false); + $('#teacherSuggestions').hide(); + }); + + $(document).on('click', function(e) { + if (!$(e.target).closest('.search-wrapper').length) { + $('#teacherSuggestions').hide(); + } + }); + } + + function loadSchedule() { + const container = $('#scheduleContainer'); + container.html('

Загрузка расписания...

'); + + let params = {}; + if (currentState.mode === 'group' && currentState.groupId) { + params = { group_id: currentState.groupId }; + } else if (currentState.mode === 'teacher' && currentState.teacherId) { + params = { staff_id: currentState.teacherId }; + } else { + return; + } + + if (currentState.currentWeek && currentState.currentWeek !== currentState.currentSchedule?.week_number) { + params.week = currentState.currentWeek; + } + + $.get('/api/schedule', params) + .done(function(response) { + if (response.success && response.data) { + currentState.currentSchedule = response.data; + renderSchedule(response.data); + updateWeekControls(response.data); + } else { + container.html('

Ошибка загрузки расписания

'); + } + }) + .fail(function() { + container.html('

Ошибка соединения с сервером

'); + }); + } + + function loadScheduleWithWeek(week) { + currentState.currentWeek = week; + loadSchedule(); + } + + function updateWeekControls(schedule) { + $('#weekControls').show(); + $('#entityName').text(schedule.entity_name); + + let weekText = schedule.week_label || `${schedule.week_number} неделя`; + $('#weekBadge').text(weekText); + + const $weekSelect = $('#weekSelect'); + $weekSelect.empty(); + for (let w = 1; w <= 52; w++) { + $weekSelect.append(``); + } + + $('#prevWeekBtn').prop('disabled', !schedule.prev_week); + $('#nextWeekBtn').prop('disabled', !schedule.next_week); + } + + function renderSchedule(schedule) { + const days = schedule.days || []; + const timeSlots = schedule.time_slots || []; + + if (days.length === 0 || timeSlots.length === 0) { + $('#scheduleContainer').html('
📭

Нет занятий на эту неделю

'); + return; + } + + const lessonsBySlot = {}; + days.forEach(function(day, dayIdx) { + (day.lessons || []).forEach(function(lesson) { + const key = `${lesson.time_start}|${lesson.time_end}`; + if (!lessonsBySlot[key]) lessonsBySlot[key] = {}; + if (!lessonsBySlot[key][dayIdx]) lessonsBySlot[key][dayIdx] = []; + lessonsBySlot[key][dayIdx].push(lesson); + }); + }); + + let headerHtml = '
Время
'; + days.forEach(function(day) { + headerHtml += `
+
${escapeHtml(day.weekday)}
+
${day.date || ''}
+
`; + }); + headerHtml += '
'; + + let rowsHtml = ''; + timeSlots.forEach(function(slot) { + const key = `${slot[0]}|${slot[1]}`; + rowsHtml += `
+
+
${escapeHtml(slot[0])}
+
${escapeHtml(slot[1])}
+
`; + + days.forEach(function(_, dayIdx) { + const lessons = lessonsBySlot[key]?.[dayIdx] || []; + rowsHtml += `
`; + lessons.forEach(function(lesson) { + rowsHtml += renderLessonCard(lesson); + }); + rowsHtml += `
`; + }); + + rowsHtml += `
`; + }); + + $('#scheduleContainer').html(`
${headerHtml}${rowsHtml}
`); + } + + function renderLessonCard(lesson) { + const typeClass = getLessonTypeClass(lesson.lesson_type); + + let teachersHtml = ''; + if (lesson.teachers && lesson.teachers.length > 0) { + teachersHtml = `
👨‍🏫 ${lesson.teachers.map(function(t) { + if (t.staff_id) { + return `${escapeHtml(t.name)}`; + } + return escapeHtml(t.name); + }).join(', ')}
`; + } + + let groupsHtml = ''; + if (lesson.groups && lesson.groups.length > 0) { + groupsHtml = `
👥 ${lesson.groups.map(function(g) { return escapeHtml(g.name); }).join(', ')}
`; + } + + let roomHtml = ''; + if (lesson.room) { + roomHtml = `
🏛️ ${escapeHtml(lesson.room)}
`; + } + + let subgroupHtml = ''; + if (lesson.subgroup) { + subgroupHtml = `(подгр. ${escapeHtml(lesson.subgroup)})`; + } + + return `
+
${escapeHtml(lesson.lesson_type)}${subgroupHtml}
+
${escapeHtml(lesson.subject)}
+ ${roomHtml} + ${teachersHtml} + ${groupsHtml} +
`; + } + + function getLessonTypeClass(type) { + const t = (type || '').toLowerCase(); + if (t.includes('лекция')) return 'lesson--lecture'; + if (t.includes('практика')) return 'lesson--practice'; + if (t.includes('лабораторная')) return 'lesson--lab'; + if (t.includes('экзамен')) return 'lesson--exam'; + if (t.includes('зачёт')) return 'lesson--credit'; + return ''; + } + + function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function setMode(mode) { + currentState.mode = mode; + currentState.currentSchedule = null; + currentState.currentWeek = null; + + $('#weekControls').hide(); + $('#scheduleContainer').html('
📋

Выберите группу или преподавателя

'); + + if (mode === 'group') { + $('#groupFilters').show(); + $('#teacherFilters').hide(); + } else { + $('#groupFilters').hide(); + $('#teacherFilters').show(); + $('#teacherInput').focus(); + } + } + + function initEvents() { + $('.mode-btn').on('click', function() { + const mode = $(this).data('mode'); + $('.mode-btn').removeClass('active'); + $(this).addClass('active'); + setMode(mode); + }); + + $('#instituteSelect').on('change', function() { + const id = parseInt($(this).val()); + if (id) loadCourses(id); + else { + $('#courseSelect').prop('disabled', true).html(''); + $('#groupSelect').prop('disabled', true).html(''); + } + }); + + $('#courseSelect').on('change', function() { + const instituteId = parseInt($('#instituteSelect').val()); + const course = parseInt($(this).val()); + if (instituteId && course) loadGroups(instituteId, course); + else { + $('#groupSelect').prop('disabled', true).html(''); + } + }); + + $('#groupSelect').on('change', function() { + currentState.groupId = parseInt($(this).val()); + $('#showScheduleBtn').prop('disabled', !currentState.groupId); + }); + + $('#showScheduleBtn').on('click', function() { + if (currentState.groupId) { + currentState.currentWeek = null; + loadSchedule(); + } + }); + + $('#showTeacherScheduleBtn').on('click', function() { + if (currentState.teacherId) { + currentState.currentWeek = null; + loadSchedule(); + } + }); + + $('#prevWeekBtn').on('click', function() { + if (currentState.currentSchedule && currentState.currentSchedule.prev_week) { + loadScheduleWithWeek(currentState.currentSchedule.prev_week); + } + }); + + $('#nextWeekBtn').on('click', function() { + if (currentState.currentSchedule && currentState.currentSchedule.next_week) { + loadScheduleWithWeek(currentState.currentSchedule.next_week); + } + }); + + $('#weekSelect').on('change', function() { + const week = parseInt($(this).val()); + if (week && week !== currentState.currentSchedule?.week_number) { + loadScheduleWithWeek(week); + } + }); + + $('#scheduleContainer').on('click', '.teacher-link', function(e) { + e.preventDefault(); + const staffId = $(this).data('staff-id'); + const teacherName = $(this).data('name'); + + if (staffId) { + $('.mode-btn[data-mode="teacher"]').addClass('active'); + $('.mode-btn[data-mode="group"]').removeClass('active'); + + currentState.mode = 'teacher'; + currentState.teacherId = staffId; + currentState.teacherName = teacherName; + currentState.currentWeek = null; + + $('#teacherInput').val(teacherName); + $('#showTeacherScheduleBtn').prop('disabled', false); + $('#groupFilters').hide(); + $('#teacherFilters').show(); + $('#weekControls').hide(); + + loadSchedule(); + } + }); + } + + $(function() { + initEvents(); + initTeacherSearch(); + loadInstitutes(); + setMode('group'); + }); +})(); \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 000000000..b1465785d --- /dev/null +++ b/static/index.html @@ -0,0 +1,88 @@ + + + + + + Расписание СГАУ + + + + +
+
+
+

📅 СГАУ Расписание

+
+ + +
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + + +
+
+
📋
+

Выберите группу или преподавателя, чтобы увидеть расписание

+
+
+
+
+ +
+
+

Источник: ssau.ru/rasp • Данные обновляются автоматически

+
+
+
+ + + + + \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 000000000..4bbcf0792 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,669 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #2563eb; + --primary-dark: #1d4ed8; + --secondary: #64748b; + --success: #22c55e; + --danger: #ef4444; + --warning: #f59e0b; + --bg: #f8fafc; + --card-bg: #ffffff; + --text: #1e293b; + --text-muted: #64748b; + --border: #e2e8f0; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.1); + --radius: 12px; + --radius-sm: 8px; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 0 20px; +} + +.header { + background: var(--card-bg); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.95); +} + +.header .container { + display: flex; + justify-content: space-between; + align-items: center; + height: 64px; +} + +.logo { + font-size: 1.5rem; + font-weight: 700; + background: linear-gradient(135deg, var(--primary), #06b6d4); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.mode-switch { + display: flex; + gap: 8px; +} + +.mode-btn { + padding: 8px 20px; + border: 1px solid var(--border); + background: var(--card-bg); + border-radius: 40px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.mode-btn.active { + background: var(--primary); + color: white; + border-color: var(--primary); +} + +.filters-panel { + background: var(--card-bg); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 24px; + box-shadow: var(--shadow); + display: flex; + gap: 20px; + flex-wrap: wrap; + align-items: flex-end; +} + +.filter-group { + flex: 1; + min-width: 180px; +} + +.filter-group label { + display: block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.filter-select, +.filter-input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + font-family: inherit; + background: var(--card-bg); + transition: all 0.2s; +} + +.filter-select:focus, +.filter-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.btn-primary { + padding: 10px 24px; + background: var(--primary); + color: white; + border: none; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-dark); + transform: translateY(-1px); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.search-wrapper { + position: relative; +} + +.suggestions-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + max-height: 300px; + overflow-y: auto; + z-index: 50; +} + +.suggestion-item { + padding: 12px 16px; + cursor: pointer; + transition: background 0.2s; + border-bottom: 1px solid var(--border); +} + +.suggestion-item:last-child { + border-bottom: none; +} + +.suggestion-item:hover { + background: var(--bg); +} + +.suggestion-name { + font-weight: 500; + font-size: 0.875rem; +} + +.suggestion-meta { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.week-controls { + background: var(--card-bg); + border-radius: var(--radius); + padding: 16px 24px; + margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; + box-shadow: var(--shadow); +} + +.week-info { + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; +} + +.entity-name { + font-weight: 700; + font-size: 1.125rem; +} + +.week-badge { + padding: 4px 12px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; +} + +.week-nav { + display: flex; + gap: 12px; + align-items: center; +} + +.week-nav-btn { + padding: 8px 16px; + border: 1px solid var(--border); + background: var(--card-bg); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; +} + +.week-nav-btn:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); +} + +.week-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.week-select { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + background: var(--card-bg); +} + +.schedule-container { + background: var(--card-bg); + border-radius: var(--radius); + overflow-x: auto; + box-shadow: var(--shadow); +} + +.placeholder { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.placeholder-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.schedule-grid { + min-width: 800px; +} + +.grid-header { + display: flex; + background: var(--bg); + border-bottom: 2px solid var(--border); + position: sticky; + top: 0; +} + +.grid-time-header { + width: 90px; + flex-shrink: 0; + padding: 14px 12px; + font-weight: 600; + font-size: 0.75rem; + color: var(--text-muted); + border-right: 1px solid var(--border); +} + +.grid-day-header { + flex: 1; + padding: 12px; + text-align: center; + border-right: 1px solid var(--border); +} + +.grid-day-header:last-child { + border-right: none; +} + +.day-name { + font-weight: 700; + font-size: 0.875rem; +} + +.day-date { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.grid-row { + display: flex; + border-bottom: 1px solid var(--border); +} + +.grid-row:last-child { + border-bottom: none; +} + +.grid-time-cell { + width: 90px; + flex-shrink: 0; + padding: 12px; + background: var(--bg); + border-right: 1px solid var(--border); + font-size: 0.75rem; + font-weight: 600; +} + +.time-start { + font-size: 0.875rem; +} + +.time-end { + color: var(--text-muted); + font-size: 0.75rem; +} + +.grid-lesson-cell { + flex: 1; + padding: 8px; + border-right: 1px solid var(--border); + min-height: 100px; +} + +.grid-lesson-cell:last-child { + border-right: none; +} + +.lesson-card { + background: var(--bg); + border-radius: var(--radius-sm); + padding: 10px; + margin-bottom: 8px; + border-left: 3px solid var(--primary); + transition: all 0.2s; +} + +.lesson-card:last-child { + margin-bottom: 0; +} + +.lesson-card:hover { + transform: translateX(2px); + box-shadow: var(--shadow); +} + +.lesson-type { + display: inline-block; + padding: 2px 8px; + background: rgba(37, 99, 235, 0.1); + color: var(--primary); + border-radius: 12px; + font-size: 0.6875rem; + font-weight: 600; + margin-bottom: 6px; +} + +.lesson-subject { + font-weight: 700; + font-size: 0.875rem; + margin-bottom: 6px; +} + +.lesson-details { + font-size: 0.75rem; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} + +.lesson-room { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.lesson-teacher { + cursor: pointer; + color: var(--primary); + text-decoration: none; +} + +.lesson-teacher:hover { + text-decoration: underline; +} + +.lesson-subgroup { + font-size: 0.6875rem; + color: var(--warning); + margin-left: 8px; +} + +.day-view { + display: flex; + flex-direction: column; +} + +.day-card { + background: var(--card-bg); + border-bottom: 1px solid var(--border); +} + +.day-card:last-child { + border-bottom: none; +} + +.day-header { + padding: 16px; + background: var(--bg); + border-bottom: 1px solid var(--border); + position: sticky; + top: 64px; +} + +.day-title { + font-weight: 700; + font-size: 1rem; +} + +.day-date-mobile { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.time-slot { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.time-slot-header { + margin-bottom: 12px; +} + +.time-slot-start { + font-weight: 700; + font-size: 0.875rem; +} + +.time-slot-end { + font-size: 0.75rem; + color: var(--text-muted); + margin-left: 8px; +} + +.footer { + margin-top: 48px; + padding: 24px 0; + border-top: 1px solid var(--border); + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); +} + +.footer a { + color: var(--primary); + text-decoration: none; +} + +@media (max-width: 768px) { + .container { + padding: 0 16px; + } + + .filters-panel { + flex-direction: column; + align-items: stretch; + } + + .filter-group { + min-width: auto; + } + + .week-controls { + flex-direction: column; + align-items: stretch; + } + + .week-nav { + justify-content: center; + } + + .header .container { + flex-direction: column; + height: auto; + padding: 12px 16px; + gap: 12px; + } + + .logo { + font-size: 1.25rem; + } +} + +@media (max-width: 480px) { + .mode-switch { + width: 100%; + } + + .mode-btn { + flex: 1; + text-align: center; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.schedule-container { + animation: fadeIn 0.3s ease; +} + +.schedule-grid { + background: white; + border-radius: 12px; + overflow-x: auto; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.grid-header { + display: flex; + background: #f8fafc; + border-bottom: 2px solid #e2e8f0; + font-weight: 600; +} + +.grid-time-header { + width: 90px; + flex-shrink: 0; + padding: 12px; + border-right: 1px solid #e2e8f0; +} + +.grid-day-header { + flex: 1; + padding: 12px; + text-align: center; + border-right: 1px solid #e2e8f0; +} + +.day-name { + font-weight: 700; + font-size: 14px; +} + +.day-date { + font-size: 11px; + color: #64748b; + margin-top: 2px; +} + +.grid-row { + display: flex; + border-bottom: 1px solid #e2e8f0; +} + +.grid-time-cell { + width: 90px; + flex-shrink: 0; + padding: 12px; + background: #fafafa; + border-right: 1px solid #e2e8f0; +} + +.time-start { + font-weight: 700; + font-size: 13px; +} + +.time-end { + font-size: 11px; + color: #64748b; +} + +.grid-lesson-cell { + flex: 1; + padding: 8px; + border-right: 1px solid #e2e8f0; + min-height: 80px; +} + +.lesson-card { + background: #f8fafc; + border-radius: 8px; + padding: 8px; + margin-bottom: 6px; + border-left: 3px solid #3b82f6; +} + +.lesson-card.lesson--lecture { border-left-color: #3b82f6; } +.lesson-card.lesson--practice { border-left-color: #22c55e; } +.lesson-card.lesson--lab { border-left-color: #f97316; } +.lesson-card.lesson--exam { border-left-color: #ef4444; } +.lesson-card.lesson--credit { border-left-color: #a855f7; } + +.lesson-type { + display: inline-block; + font-size: 10px; + padding: 2px 6px; + background: #e2e8f0; + border-radius: 20px; + margin-bottom: 4px; +} + +.lesson-subject { + font-weight: 600; + font-size: 12px; + margin-bottom: 4px; +} + +.lesson-details { + font-size: 10px; + color: #64748b; + margin-top: 2px; +} + +.lesson-subgroup { + font-size: 9px; + color: #f97316; + margin-left: 6px; +} \ No newline at end of file From 74bb4a8af0276ed355f2c4d5dc4429e2ed112002 Mon Sep 17 00:00:00 2001 From: Leshechka123 Date: Mon, 6 Apr 2026 19:43:45 +0400 Subject: [PATCH 2/2] cool readme --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c60af095a..8731f8340 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,26 @@ ## Установка и запуск ### 1. Клонирование репозитория - +```bash git clone cd websec-2 +``` ### 2. Создание виртуального окружения - +```bash python -m venv venv venv\Scripts\activate +``` ### 3. Установка зависимостей - +```bash pip install -r requirements.txt +``` ### 4. Запуск сервера - +```bash python run.py +``` ### 5. Открыть в браузере