Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
3 changes: 1 addition & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-mock responses
pip install -r requirements-dev.txt

- name: Run tests
run: |
Expand Down
5 changes: 4 additions & 1 deletion courses/operating-systems-2025.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,10 @@ course:
taskid-max: 20
penalty-max: 7
ci:
- workflows
workflows:
- run-autograding-tests
- build (MINGW64, MinGW Makefiles)
- build (MSVC, Visual Studio 17 2022)
files:
- lab3.cpp
moss:
Expand Down
64 changes: 64 additions & 0 deletions frontend/courses-front/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,63 @@ function formatValidationError(err) {

export const fetchCourses = async () => {
const response = await fetch(`${API_BASE_URL}/courses`);
if (response.status === 429) {
let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова.";
try {
const data = await response.json();
errorMessage = data.detail || data.message || errorMessage;
} catch (e) {
// Если не удалось распарсить JSON, используем сообщение по умолчанию
}
throw new Error(errorMessage);
}
return response.json();
};

export const fetchCourseDetails = async (courseId) => {
const response = await fetch(`${API_BASE_URL}/courses/${courseId}`);
if (response.status === 429) {
let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова.";
try {
const data = await response.json();
errorMessage = data.detail || data.message || errorMessage;
} catch (e) {
// Если не удалось распарсить JSON, используем сообщение по умолчанию
}
throw new Error(errorMessage);
}
return response.json();
};

export const fetchGroups = async (courseId) => {
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/groups`);
if (response.status === 429) {
let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова.";
try {
const data = await response.json();
errorMessage = data.detail || data.message || errorMessage;
} catch (e) {
// Если не удалось распарсить JSON, используем сообщение по умолчанию
}
throw new Error(errorMessage);
}
return response.json();
};

export const fetchLabs = async (courseId, groupId) => {
const response = await fetch(
`${API_BASE_URL}/courses/${courseId}/groups/${groupId}/labs`
);
if (response.status === 429) {
let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова.";
try {
const data = await response.json();
errorMessage = data.detail || data.message || errorMessage;
} catch (e) {
// Если не удалось распарсить JSON, используем сообщение по умолчанию
}
throw new Error(errorMessage);
}
return response.json();
};

Expand All @@ -59,6 +99,18 @@ export const registerAndCheck = async (courseId, groupId, formData) => {
}
);

// Обрабатываем 429 Rate Limit как специальный случай (до парсинга JSON)
if (response.status === 429) {
let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова.";
try {
const data = await response.json();
errorMessage = data.detail || data.message || errorMessage;
} catch (e) {
// Если не удалось распарсить JSON, используем сообщение по умолчанию
}
throw new Error(errorMessage);
}

const data = await response.json();

// Обрабатываем 409 Conflict как специальный случай
Expand Down Expand Up @@ -99,6 +151,18 @@ export async function gradeLab(courseId, groupId, labId, github) {
}
);

// Обрабатываем 429 Rate Limit как специальный случай (до парсинга JSON)
if (response.status === 429) {
let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова.";
try {
const data = await response.json();
errorMessage = data.detail || data.message || errorMessage;
} catch (e) {
// Если не удалось распарсить JSON, используем сообщение по умолчанию
}
throw new Error(errorMessage);
}

const data = await response.json();

// Если ответ не успешный, выбрасываем ошибку с сообщением от сервера
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => {
setExpandedCourse(courseId);
} catch (error) {
console.error(error);
showSnackbar(t("errorLoadingCourseDetails"), "error");
showSnackbar(error.message || t("errorLoadingCourseDetails"), "error");
}
};

Expand Down
18 changes: 17 additions & 1 deletion frontend/courses-front/src/components/group-list/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@ import { CardContainer, DescriptionContainer, Number, Title } from "./styled";
import { CardTitle, MainContainer } from "../../../theme";
import { ButtonBack } from "../course-list/styled";
import { Breadcrumb } from "../breadcrumb";
import { Snackbar, Alert } from "@mui/material";

export const GroupList = ({ courseId, onSelectGroup, onBack }) => {
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
setLoading(true);
setError(null);
fetchGroups(courseId)
.then((data) => {
setGroups(data);
setLoading(false);
})
.catch(() => setLoading(false));
.catch((err) => {
setLoading(false);
setError(err.message || "Ошибка при загрузке групп");
});
}, [courseId]);

return (
Expand All @@ -37,6 +43,16 @@ export const GroupList = ({ courseId, onSelectGroup, onBack }) => {
))}
</CardContainer>
)}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={() => setError(null)} severity="error" sx={{ width: "100%" }}>
{error}
</Alert>
</Snackbar>
</MainContainer>
);
};
18 changes: 17 additions & 1 deletion frontend/courses-front/src/components/lab-list/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,25 @@ import {
import { CardTitle, MainContainer } from "../../../theme";
import { ButtonBack } from "../course-list/styled";
import { Breadcrumb } from "../breadcrumb";
import { Snackbar, Alert } from "@mui/material";

export const LabList = ({ courseId, groupId, onSelectLab, onBack }) => {
const [labs, setLabs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
setLoading(true);
setError(null);
fetchLabs(courseId, groupId)
.then((data) => {
setLabs(data);
setLoading(false);
})
.catch(() => setLoading(false));
.catch((err) => {
setLoading(false);
setError(err.message || "Ошибка при загрузке лабораторных работ");
});
}, [courseId, groupId]);

return (
Expand All @@ -42,6 +48,16 @@ export const LabList = ({ courseId, groupId, onSelectLab, onBack }) => {
))}
</CardContainer>
)}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={() => setError(null)} severity="error" sx={{ width: "100%" }}>
{error}
</Alert>
</Snackbar>
</MainContainer>
);
};
52 changes: 37 additions & 15 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
import re
import logging
from datetime import datetime
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

from grading import (
LabGrader,
Expand Down Expand Up @@ -79,6 +82,11 @@
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
SECRET_KEY = os.getenv("SECRET_KEY", "super-secret-key")

# Rate limiting configuration
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

# Проверка обязательных переменных окружения
if not ADMIN_LOGIN or not ADMIN_PASSWORD:
raise RuntimeError(
Expand Down Expand Up @@ -228,11 +236,13 @@ class StudentRegistration(BaseModel):


@app.get("/")
async def read_index():
@limiter.limit("100/minute")
async def read_index(request: Request):
return FileResponse("dist/index.html")

@app.post("/admin/login")
def admin_login(data: AuthRequest, response: Response):
@limiter.limit("5/minute")
def admin_login(request: Request, data: AuthRequest, response: Response):
if data.login == ADMIN_LOGIN and data.password == ADMIN_PASSWORD:
token = signer.sign(data.login.encode()).decode()
response.set_cookie(
Expand All @@ -247,6 +257,7 @@ def admin_login(data: AuthRequest, response: Response):
raise HTTPException(status_code=401, detail="Неверный логин или пароль")

@app.get("/admin/check-auth")
@limiter.limit("30/minute")
def check_auth(request: Request):
cookie = request.cookies.get("admin_session")
if not cookie:
Expand All @@ -263,13 +274,15 @@ def check_auth(request: Request):
return {"authenticated": True}

@app.post("/admin/logout")
def logout(response: Response):
@limiter.limit("30/minute")
def logout(request: Request, response: Response):
response.delete_cookie("admin_session", path="/")
return {"message": "Logged out"}


@app.get("/courses")
def get_courses(status: str = "active"):
@limiter.limit("100/minute")
def get_courses(request: Request, status: str = "active"):
"""
Get courses filtered by status

Expand Down Expand Up @@ -329,7 +342,8 @@ def parse_lab_id(lab_id: str) -> int:
return int(match.group(0))

@app.get("/courses/{course_id}")
def get_course(course_id: str):
@limiter.limit("100/minute")
def get_course(request: Request, course_id: str):
course_info = get_course_by_id(course_id)

return {
Expand All @@ -346,7 +360,8 @@ def get_course(course_id: str):
}

@app.delete("/courses/{course_id}")
def delete_course(course_id: str):
@limiter.limit("20/minute")
def delete_course(request: Request, course_id: str):
"""
Mark course as hidden in index (soft delete)
The course file is preserved in repository
Expand Down Expand Up @@ -376,7 +391,8 @@ class EditCourseRequest(BaseModel):


@app.get("/courses/{course_id}/edit")
def edit_course_get(course_id: str):
@limiter.limit("30/minute")
def edit_course_get(request: Request, course_id: str):
"""Получить YAML содержимое курса для редактирования"""
course_info = get_course_by_id(course_id)
filename = course_info["_meta"]["filename"]
Expand All @@ -392,7 +408,8 @@ def edit_course_get(course_id: str):


@app.put("/courses/{course_id}/edit")
def edit_course_put(course_id: str, data: EditCourseRequest):
@limiter.limit("20/minute")
def edit_course_put(request: Request, course_id: str, data: EditCourseRequest):
"""Сохранить изменения в YAML файле курса"""
course_info = get_course_by_id(course_id)
filename = course_info["_meta"]["filename"]
Expand All @@ -411,7 +428,8 @@ def edit_course_put(course_id: str, data: EditCourseRequest):


@app.get("/courses/{course_id}/groups")
def get_course_groups(course_id: str):
@limiter.limit("10/minute")
def get_course_groups(request: Request, course_id: str):
course_info = get_course_by_id(course_id)
spreadsheet_id = course_info.get("google", {}).get("spreadsheet")
info_sheet = course_info.get("google", {}).get("info-sheet")
Expand All @@ -434,7 +452,8 @@ def get_course_groups(course_id: str):


@app.get("/courses/{course_id}/groups/{group_id}/labs")
def get_course_labs(course_id: str, group_id: str):
@limiter.limit("10/minute")
def get_course_labs(request: Request, course_id: str, group_id: str):
course_info = get_course_by_id(course_id)
spreadsheet_id = course_info.get("google", {}).get("spreadsheet")
labs = [lab["short-name"] for lab in course_info.get("labs", {}).values() if "short-name" in lab]
Expand All @@ -461,7 +480,8 @@ def get_course_labs(course_id: str, group_id: str):


@app.post("/courses/{course_id}/groups/{group_id}/register")
def register_student(course_id: str, group_id: str, student: StudentRegistration):
@limiter.limit("10/minute")
def register_student(request: Request, course_id: str, group_id: str, student: StudentRegistration):
# Build full name first for consistent logging
full_name = f"{student.surname} {student.name} {student.patronymic}".strip()

Expand Down Expand Up @@ -568,7 +588,8 @@ class GradeRequest(BaseModel):
github: str = Field(..., min_length=1)

@app.post("/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade")
def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest):
@limiter.limit("10/minute")
def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grade_request: GradeRequest):
"""
Grade a lab submission by checking GitHub repository and CI status.

Expand All @@ -581,7 +602,7 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest)
3. Return early for errors/pending (no Sheets connection needed)
4. Connect to Sheets only when we have a result to write
"""
logger.info(f"Grading attempt - Course: {course_id}, Group: {group_id}, Lab: {lab_id}, GitHub: {request.github}")
logger.info(f"Grading attempt - Course: {course_id}, Group: {group_id}, Lab: {lab_id}, GitHub: {grade_request.github}")

try:
# Load course and lab configuration
Expand All @@ -604,7 +625,7 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest)
github_client = GitHubClient(GITHUB_TOKEN)
grader = LabGrader(github_client)

username = request.github
username = grade_request.github
repo_name = f"{repo_prefix}-{username}"
logger.info(f"Checking repository: {org}/{repo_name}")

Expand Down Expand Up @@ -775,7 +796,8 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest)


@app.post("/courses/upload")
async def upload_course(file: UploadFile = File(...)):
@limiter.limit("10/minute")
async def upload_course(request: Request, file: UploadFile = File(...)):
"""
Upload a new course file and add it to index

Expand Down
Loading