поправил ридми
This commit is contained in:
parent
3445f15f44
commit
8c772bf8ae
286
README.md
286
README.md
@ -1,2 +1,286 @@
|
|||||||
# psb_hack
|
|
||||||
|
|
||||||
|
# ВНИМАНИЕ
|
||||||
|
|
||||||
|
Просьба дочитать данный файл от начала и до КОНЦА, чтобы не упустить важную информацию касающуюся демонстрации проекта
|
||||||
|
|
||||||
|
# Lectio
|
||||||
|
|
||||||
|
**Lectio** — Система позволяет создавать курсы с лекциями и заданиями, записываться на них, отмечать прочитанные материалы, загружать решения с файлами любой сложности, а преподавателям — выставлять ручные оценки. Реализована ролевая модель (студент / преподаватель / администратор), автоматический подсчёт прогресса по курсу, детальный журнал успеваемости с максимальными оценками и статусом чтения лекций, а также личный кабинет студента, где он видит только свою успеваемость.
|
||||||
|
|
||||||
|
## Наша команда Numerum
|
||||||
|
|
||||||
|
Дувакин Андрей - фуллстак + дувопс
|
||||||
|
Лучевников Лев - фронтэнд-разработчик
|
||||||
|
Филимонов Михаил - бэкэнд-разработчик
|
||||||
|
Сотов Дмитрий - фронтэнд-разработчик
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
* Backend: FastAPI (Python), SQLAlchemy 2.0, PostgreSQL, JWT-аутентификация
|
||||||
|
* Frontend: React + Vite + JavaScript + Ant Design + RTK Query
|
||||||
|
* Инфраструктура: Docker, Kubernetes (microk8s), Helm-чарт, cert-manager + Let’s Encrypt
|
||||||
|
|
||||||
|
## Развернутый дистрибутив
|
||||||
|
WEB: https://lectio.numerum.team
|
||||||
|
API: https://api.lectio.numerum.team (с автоматической Swagger-документацией)
|
||||||
|
Хранение файлов: PersistentVolume с hostPath
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
### API
|
||||||
|
Бэкенд построен на FastAPI с луковой архитектурой.
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── controllers/ — FastAPI-роутеры (auth, courses, lessons, tasks, solutions и т.д.)
|
||||||
|
├── infrastructure/ — сервисы бизнес-логики (gradebook_service, solutions_service и др.)
|
||||||
|
├── application/ — репозитории (работа с БД через SQLAlchemy 2.0 async)
|
||||||
|
├── domain/
|
||||||
|
│ ├── models/ — SQLAlchemy-модели (ORM)
|
||||||
|
│ └── entities/ — Pydantic-схемы для запросов/ответов
|
||||||
|
├── database/ — сессии, Alembic-миграции
|
||||||
|
├── core/ — константы, настройки
|
||||||
|
└── main.py, settings.py
|
||||||
|
```
|
||||||
|
API спроектирован так, что добавление новых сущностей (например, сертификаты, тесты, группы) требует минимум изменений — достаточно добавить модель, репозиторий, сервис и роутер.
|
||||||
|
|
||||||
|
### WEB
|
||||||
|
Фронтенд реализован на React + Vite + JavaScript с использованием UI-библиотеки Ant Design и state-менеджмента через RTK Query.
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Api/ — RTK Query API-слайсы (authApi, coursesApi, gradebookApi, solutionsApi и др.)
|
||||||
|
├── App/ — маршрутизация, PrivateRoute, AdminRoute, ErrorBoundary
|
||||||
|
├── Components/
|
||||||
|
│ ├── Layouts/ — MainLayout (шапка, сайдбар, адаптивность)
|
||||||
|
│ ├── Pages/ — все страницы: CoursesPage, CourseDetailPage, GradebookPage, Login, Profile и др.
|
||||||
|
│ └── Widgets/ — переиспользуемые компоненты (модалки, лоадеры)
|
||||||
|
├── Redux/ — store + слайсы состояния (auth, courses, modals)
|
||||||
|
├── Core/ — константы, конфиги (VITE_BASE_URL, роли)
|
||||||
|
├── Hooks/ — кастомные хуки (useAuthUtils и др.)
|
||||||
|
└── Styles/ — глобальные стили
|
||||||
|
```
|
||||||
|
|
||||||
|
## Система ролей
|
||||||
|
|
||||||
|
В системе реализована строгая ролевая модель с тремя ролями. Права проверяются как на бэкенде (зависимости FastAPI), так и на фронтенде (PrivateRoute / AdminRoute).
|
||||||
|
|
||||||
|
### Администратор
|
||||||
|
|
||||||
|
- Всё, что может преподаватель
|
||||||
|
- Управление пользователями (создание, редактирование, удаление, смена роли)
|
||||||
|
- Управление ролями и статусами
|
||||||
|
- Просмотр и редактирование всех курсов и их содержимого
|
||||||
|
- Доступ ко всем журналам успеваемости
|
||||||
|
|
||||||
|
|
||||||
|
### Преподаватель
|
||||||
|
|
||||||
|
- Создавать, редактировать и удалять свои курсы
|
||||||
|
- Добавлять/редактировать лекции и задания
|
||||||
|
- Назначать себе и другим преподавателям курсы
|
||||||
|
- Просматривать полный журнал успеваемости по своим курсам
|
||||||
|
- Выставлять оценки и комментарии к решениям студентов
|
||||||
|
- Скачивать все файлы решений
|
||||||
|
|
||||||
|
|
||||||
|
### Студент
|
||||||
|
|
||||||
|
- Просматривать список всех курсов и записываться на них
|
||||||
|
- Просматривать лекции и отмечать их как «прочитанные»
|
||||||
|
- Загружать решения заданий
|
||||||
|
- Видеть свой прогресс по каждому курсу
|
||||||
|
- Видеть свою успеваемость в журнале (только свою)
|
||||||
|
- Редактировать свой профиль (ФИО, фото и т.д.)
|
||||||
|
|
||||||
|
## Развертывание проекта
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
Бэкенд можно запустить тремя способами — от самого простого до полноценного production-деплоймента.
|
||||||
|
|
||||||
|
#### 1. Локальный запуск (без Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонируем репозиторий
|
||||||
|
git clone https://github.com/AndreiDuvakin/lectio.git
|
||||||
|
cd lectio/api
|
||||||
|
|
||||||
|
# Создаём виртуальное окружение и устанавливаем зависимости
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r req.txt
|
||||||
|
|
||||||
|
# Создаём файл .env в корне api/
|
||||||
|
cp .env.example .env
|
||||||
|
# Редактируем .env — обязательно заполняем:
|
||||||
|
DB_DRIVER=postgresql+asyncpg
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_NAME=lectio
|
||||||
|
DB_SCHEMA=public
|
||||||
|
SECRET_KEY=your_very_strong_secret_key_here
|
||||||
|
|
||||||
|
# Применяем миграции
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Запускаем сервер
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
API: http://localhost:8000
|
||||||
|
|
||||||
|
#### 2. Запуск через Docker
|
||||||
|
|
||||||
|
Обратите внимание, что для хранения файлов необходимо создавать хранилище.
|
||||||
|
**Вариант A — просто тянуть готовый образ**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name lectio-api \
|
||||||
|
-p 8000:8000 \
|
||||||
|
-e DB_DRIVER=postgresql+asyncpg \
|
||||||
|
-e DB_HOST=host.docker.internal \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_USER=postgres \
|
||||||
|
-e DB_PASSWORD=your_password \
|
||||||
|
-e DB_NAME=lectio \
|
||||||
|
-e DB_SCHEMA=public \
|
||||||
|
-e SECRET_KEY=supersecretkey123 \
|
||||||
|
-v ./uploads:/app/uploads \
|
||||||
|
andreiduvakin/lectio-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант B — собрать локально**
|
||||||
|
```bash
|
||||||
|
docker build -t lectio-api .
|
||||||
|
docker run -d --name lectio-api -p 8000:8000 lectio-api
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Production-развёртывание в Kubernetes
|
||||||
|
Используется Helm-чарт **k8s/helm/lectio-api**.
|
||||||
|
```bash
|
||||||
|
# Устанавливаем чарт (пример для нашего microk8s)
|
||||||
|
helm upgrade --install lectio-web k8s/helm/lectio-web --namespace lectio-web --create-namespace
|
||||||
|
```
|
||||||
|
Что нужно настроить в **values.yaml**:
|
||||||
|
```
|
||||||
|
env:
|
||||||
|
DB_DRIVER: postgresql+asyncpg
|
||||||
|
DB_HOST: db.numerum.team
|
||||||
|
DB_PORT: 30000
|
||||||
|
DB_USER: lectio
|
||||||
|
DB_NAME: lectio
|
||||||
|
DB_SCHEMA: public
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secret** с чувствительными данными:
|
||||||
|
```
|
||||||
|
# k8s/helm/lectio-api/templates/secrets.yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: lectio-api-secret
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
SECRET_KEY: base64_encoded_very_long_key==
|
||||||
|
DB_PASSWORD: base64_encoded_password==
|
||||||
|
```
|
||||||
|
|
||||||
|
**ОБРАТИТЕ ВНИМАНИЕ**
|
||||||
|
В настоящем решении не предоставлен действительный файл **k8s/helm/lectio-api/templates/secrets.yaml** - для предотвращения несанкционированного доступа к нашей базе данных. Для ВАШЕЙ конфигурации нужно создавать свой файл.
|
||||||
|
|
||||||
|
PersistentVolume автоматически создаётся через pvc.yaml и монтирует папку /mnt/k8s_storage/lectio-api/uploads на хосте — все загруженные студентами файлы сохраняются между перезапусками.
|
||||||
|
После деплоя:
|
||||||
|
|
||||||
|
API: https://api.lectio.numerum.team/
|
||||||
|
Swagger/ReDoc: https://api.lectio.numerum.team/docs
|
||||||
|
|
||||||
|
Именно так система работает в продакшене прямо сейчас — с HTTPS, Let’s Encrypt, персистентным хранилищем и автоматическим масштабированием.
|
||||||
|
|
||||||
|
### WEB
|
||||||
|
|
||||||
|
Фронтенд можно запустить тремя способами — от локальной разработки до production-развёртывания в Kubernetes.
|
||||||
|
|
||||||
|
### 1. Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/AndreiDuvakin/lectio.git
|
||||||
|
cd lectio/web
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Создаём .env файл (в корне web/)
|
||||||
|
echo "VITE_BASE_URL=http://localhost:8000/api/v1
|
||||||
|
VITE_ROOT_ROLE_NAME=root" > .env
|
||||||
|
|
||||||
|
# Запускаем dev-сервер
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Приложение будет доступно по адресу: http://localhost:5173
|
||||||
|
|
||||||
|
### 2. Запуск через Docker
|
||||||
|
|
||||||
|
**Вариант A — готовый образ**
|
||||||
|
Готовый образ уже собран с продакшен-настройками!
|
||||||
|
**VITE_BASE_URL** и **VITE_ROOT_ROLE_NAME** жёстко зашиты в образ на этапе сборки:
|
||||||
|
```Dockerfile
|
||||||
|
ENV VITE_BASE_URL=https://api.lectio.numerum.team/api/v1
|
||||||
|
ENV VITE_ROOT_ROLE_NAME=root
|
||||||
|
```
|
||||||
|
Поэтому запуск предельно прост:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name lectio-web \
|
||||||
|
-p 3000:3000 \
|
||||||
|
andreiduvakin/lectio-web:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант B — сборка локально**
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name lectio-web \
|
||||||
|
-p 3000:3000 \
|
||||||
|
--build-arg VITE_BASE_URL=http://localhost:8000/api/v1 \
|
||||||
|
andreiduvakin/lectio-web:latest
|
||||||
|
```
|
||||||
|
Приложение доступно по адресу: http://localhost:3000
|
||||||
|
|
||||||
|
### 3. Production-развёртывание в Kubernetes
|
||||||
|
|
||||||
|
Используется Helm-чарт k8s/helm/lectio-web.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade --install lectio-web k8s/helm/lectio-web --namespace lectio-web --create-namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
Поскольку переменные окружения уже зашиты в образ на этапе **npm run build**, в **values.yaml** ничего передавать не нужно.
|
||||||
|
|
||||||
|
После деплоя приложение доступно по адресу:
|
||||||
|
https://lectio.numerum.team
|
||||||
|
Именно так работает продакшен прямо сейчас.
|
||||||
|
|
||||||
|
|
||||||
|
## Демонстрация
|
||||||
|
|
||||||
|
Видео УСТАНОВКИ и ДЕМОНСТРАЦИИ проекта достпуно по ссылке: https://disk.yandex.ru/i/sDi_GnOUBnpVaA
|
||||||
|
|
||||||
|
Пожалуйста, досмотрите видео целиком.
|
||||||
|
|
||||||
|
# ВНИМАНИЕ
|
||||||
|
|
||||||
|
Для того, чтобы можно было протестировать систему в развернутом виде с разных ролей были подготовлены следующие авторизационные данные для проверки на:
|
||||||
|
https://lectio.numerum.team
|
||||||
|
|
||||||
|
* Администратор:
|
||||||
|
Логин: admin
|
||||||
|
Пароль: Password1!
|
||||||
|
|
||||||
|
* Студент:
|
||||||
|
Логин: student
|
||||||
|
Пароль: Password1!
|
||||||
|
|
||||||
|
* Учитель:
|
||||||
|
Логин: teacher
|
||||||
|
Пароль: Password1!
|
||||||
|
|
||||||
|
## Регистрация
|
||||||
|
|
||||||
|
В системе можно зарегистрироваться перейдя на страницу регистрации из страницы авторизации, но по умолчанию вам будет выдана роль Студент без зачисления на курс
|
||||||
@ -9,7 +9,7 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_BASE_URL
|
ARG VITE_BASE_URL
|
||||||
ENV VITE_BASE_URL=http://localhost:8000/api/v1
|
ENV VITE_BASE_URL=https://api.lectio.numerum.team/api/v1
|
||||||
ARG VITE_ROOT_ROLE_NAME
|
ARG VITE_ROOT_ROLE_NAME
|
||||||
ENV VITE_ROOT_ROLE_NAME=root
|
ENV VITE_ROOT_ROLE_NAME=root
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const ViewTaskModal = () => {
|
|||||||
currentTaskFiles,
|
currentTaskFiles,
|
||||||
isCurrentTaskFilesLoading,
|
isCurrentTaskFilesLoading,
|
||||||
isCurrentTaskFilesError,
|
isCurrentTaskFilesError,
|
||||||
downloadFile,
|
downloadTasksFile,
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
currentUser,
|
currentUser,
|
||||||
mySolutions,
|
mySolutions,
|
||||||
@ -55,6 +55,7 @@ const ViewTaskModal = () => {
|
|||||||
commentForm,
|
commentForm,
|
||||||
setComment,
|
setComment,
|
||||||
comment,
|
comment,
|
||||||
|
downloadSolutionFile,
|
||||||
} = useViewTaskModal();
|
} = useViewTaskModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -126,7 +127,7 @@ const ViewTaskModal = () => {
|
|||||||
<span>{file.filename || "Не указан"}</span>
|
<span>{file.filename || "Не указан"}</span>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => downloadFile(file.id, file.filename)}
|
onClick={() => downloadTasksFile(file.id, file.filename)}
|
||||||
loading={downloadingFiles[file.id] || false}
|
loading={downloadingFiles[file.id] || false}
|
||||||
disabled={downloadingFiles[file.id] || false}
|
disabled={downloadingFiles[file.id] || false}
|
||||||
type={"dashed"}
|
type={"dashed"}
|
||||||
@ -218,7 +219,7 @@ const ViewTaskModal = () => {
|
|||||||
type="dashed"
|
type="dashed"
|
||||||
icon={<FileOutlined/>}
|
icon={<FileOutlined/>}
|
||||||
style={{textAlign: "left"}}
|
style={{textAlign: "left"}}
|
||||||
onClick={() => downloadFile(file.id, file.filename)}
|
onClick={() => downloadSolutionFile(file.id, file.filename)}
|
||||||
loading={downloadingFiles[file.id]}
|
loading={downloadingFiles[file.id]}
|
||||||
>
|
>
|
||||||
<span style={{marginLeft: 8}}>
|
<span style={{marginLeft: 8}}>
|
||||||
@ -404,7 +405,7 @@ const ViewTaskModal = () => {
|
|||||||
type="dashed"
|
type="dashed"
|
||||||
icon={<FileOutlined/>}
|
icon={<FileOutlined/>}
|
||||||
style={{textAlign: "left"}}
|
style={{textAlign: "left"}}
|
||||||
onClick={() => downloadFile(file.id, file.filename)}
|
onClick={() => downloadSolutionFile(file.id, file.filename)}
|
||||||
loading={downloadingFiles[file.id]}
|
loading={downloadingFiles[file.id]}
|
||||||
>
|
>
|
||||||
<span style={{marginLeft: 8}}>
|
<span style={{marginLeft: 8}}>
|
||||||
|
|||||||
@ -199,7 +199,7 @@ const useViewTaskModal = () => {
|
|||||||
const [commentForm] = Form.useForm();
|
const [commentForm] = Form.useForm();
|
||||||
const [downloadingFiles, setDownloadingFiles] = useState({});
|
const [downloadingFiles, setDownloadingFiles] = useState({});
|
||||||
|
|
||||||
const downloadFile = async (fileId, fileName) => {
|
const downloadTasksFile = async (fileId, fileName) => {
|
||||||
try {
|
try {
|
||||||
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
|
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
|
||||||
|
|
||||||
@ -276,6 +276,83 @@ const useViewTaskModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadSolutionFile = async (fileId, fileName) => {
|
||||||
|
try {
|
||||||
|
setDownloadingFiles((prev) => ({...prev, [fileId]: true}));
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (!token) {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: "Токен не найден",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const response = await fetch(`${CONFIG.BASE_URL}/solutions/file/${fileId}/`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: errorText || "Не удалось скачать файл",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || contentType.includes('text/html')) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: errorText || "Не удалось скачать файл",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let safeFileName = fileName || "file";
|
||||||
|
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
|
||||||
|
if (contentType.includes('application/pdf')) {
|
||||||
|
safeFileName += '.pdf';
|
||||||
|
} else if (contentType.includes('image/jpeg')) {
|
||||||
|
safeFileName += '.jpg';
|
||||||
|
} else if (contentType.includes('image/png')) {
|
||||||
|
safeFileName += '.png';
|
||||||
|
} else {
|
||||||
|
safeFileName += '.bin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.setAttribute("download", safeFileName);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
description: error.message || "Не удалось скачать файл",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [
|
const [
|
||||||
createAssessment,
|
createAssessment,
|
||||||
] = useCreateAssessmentMutation();
|
] = useCreateAssessmentMutation();
|
||||||
@ -374,7 +451,7 @@ const useViewTaskModal = () => {
|
|||||||
currentTaskFiles,
|
currentTaskFiles,
|
||||||
isCurrentTaskFilesLoading,
|
isCurrentTaskFilesLoading,
|
||||||
isCurrentTaskFilesError,
|
isCurrentTaskFilesError,
|
||||||
downloadFile,
|
downloadTasksFile,
|
||||||
downloadingFiles,
|
downloadingFiles,
|
||||||
currentUser,
|
currentUser,
|
||||||
mySolutions,
|
mySolutions,
|
||||||
@ -391,6 +468,7 @@ const useViewTaskModal = () => {
|
|||||||
onCommentSubmit,
|
onCommentSubmit,
|
||||||
setComment,
|
setComment,
|
||||||
comment,
|
comment,
|
||||||
|
downloadSolutionFile,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user