init
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
**/bin
|
||||
**/obj
|
||||
artifacts
|
||||
@@ -0,0 +1,47 @@
|
||||
# Скопируйте файл в .env. Не добавляйте .env в Git.
|
||||
# Инфраструктура
|
||||
APP_BIND_ADDRESS=127.0.0.1
|
||||
APP_PORT=8080
|
||||
POSTGRES_IMAGE=postgres:18
|
||||
POSTGRES_DB=training_planner
|
||||
POSTGRES_USER=training_planner
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Планировщик. Переопределяют секцию Planner в appsettings.json.
|
||||
PLANNER_DEFAULT_WEEK_COUNT=4
|
||||
PLANNER_DEFAULT_SETS_COUNT=3
|
||||
PLANNER_DEFAULT_REST_MINUTES=1.5
|
||||
|
||||
# При первом запуске обязательно заполните логин и пароль тренера.
|
||||
# Пароль: от 12 символов, строчные/прописные буквы, цифра, спецсимвол.
|
||||
BOOTSTRAP_TRAINER_USERNAME=
|
||||
BOOTSTRAP_TRAINER_PASSWORD=
|
||||
|
||||
# Локальная разработка на 127.0.0.1. Для публичного HTTPS установите true.
|
||||
REQUIRE_HTTPS=false
|
||||
REVERSE_PROXY_IP=127.0.0.1
|
||||
COOKIE_HOURS=8
|
||||
AUTH_REVALIDATION_MINUTES=5
|
||||
DATABASE_INITIALIZE_ON_STARTUP=true
|
||||
PLANNER_DEFAULT_REPETITIONS=10
|
||||
PLANNER_DEFAULT_WORKOUT_NAME=Тренировка
|
||||
PLANNER_DEFAULT_WORKOUT_DURATION=1 час
|
||||
PLANNER_DEFAULT_INTENSITY_PERCENT=80
|
||||
PLANNER_AUTOSAVE_DELAY_MS=1200
|
||||
|
||||
# Ширина столбца названий полей клиента в PDF (см).
|
||||
Pdf__ClientLabelWidthCentimeters=4.5
|
||||
# Пустые строки между данными клиента и первой неделей PDF.
|
||||
Pdf__ClientDetailsGapLines=5
|
||||
|
||||
# Массовый импорт из буфера обмена.
|
||||
ExerciseImport__MaxRows=1000
|
||||
ExerciseImport__MaxClipboardCharacters=100000
|
||||
# Размер сообщения Blazor: учитывайте запас для UTF-8 и JSON при увеличении лимита вставки.
|
||||
InteractiveServer__MaximumReceiveMessageBytes=1048576
|
||||
|
||||
# Время показа плашки с отменой удаления упражнения (секунды).
|
||||
Planner__UndoDeleteDurationSeconds=10
|
||||
|
||||
# Размер иконки камеры в PDF (пункты).
|
||||
Pdf__CameraSizePoints=18
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
.vs/
|
||||
.idea/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
artifacts/
|
||||
*.user
|
||||
*.suo
|
||||
backups/
|
||||
backup*.sql
|
||||
*.dump
|
||||
@@ -0,0 +1,37 @@
|
||||
# Правила разработки
|
||||
|
||||
## Обязательные требования пользователя
|
||||
|
||||
- Использовать C#, .NET 10 и PostgreSQL. Приложение и БД запускаются в отдельных Docker-контейнерах в общей сети.
|
||||
- Писать небольшие, читаемые классы с одной ясной ответственностью. Не создавать сложные объёмные классы и абстракции без практической необходимости.
|
||||
- Не использовать `record` и `record struct`. Модели и DTO — обычные классы со свойствами `get; set;`.
|
||||
- Использовать привычные типы данных. Не вводить экзотические типы и конструкции без обсуждения.
|
||||
- Соблюдать инверсию зависимостей. Domain не зависит от инфраструктуры и UI; Application не зависит от Infrastructure или Web.
|
||||
- Не зашивать настраиваемые значения в код. Значения по умолчанию и параметры поведения выносить в `appsettings.json`, секреты — в переменные окружения. Поддерживать переопределение через `.env` и Docker Compose.
|
||||
- Не хранить пароли, строки подключения с секретами или реальные клиентские данные в репозитории.
|
||||
- При неясности в архитектуре или устройстве конкретного класса остановиться и задать пользователю вопрос до реализации спорного решения.
|
||||
- Не добавлять функциональность вне согласованного объёма.
|
||||
- Не проверять результат скриншотами, пока пользователь явно не попросит об этом. Для проверки UI использовать состояние страницы и функциональные проверки без снимков.
|
||||
|
||||
## Согласованный объём первой версии
|
||||
|
||||
- Рабочее место одного тренера с возможностью расширения на нескольких.
|
||||
- Вход по логину и паролю, без регистрации и клиентского кабинета.
|
||||
- Клиенты и библиотека упражнений; добавление упражнений также вручную.
|
||||
- Группы содержат независимые упражнения. Удаление группы удаляет её упражнения из библиотеки; тренировки сохраняют свои снимки. Корзина внутри группы удаляет упражнение полностью из библиотеки.
|
||||
- Drag and drop переносит упражнение в другую группу или в «Без группы». Добавление упражнения из другой группы создаёт независимую копию.
|
||||
- В конфигураторе — адаптивный выпадающий список с текстовым поиском и фильтром по группе. Двойной клик сразу применяет выбранное упражнение.
|
||||
- Персональная месячная программа: недели, тренировки, упорядоченные блоки.
|
||||
- Упражнение: общие количество подходов, текстовые повторения и вес; разминка и примечание текстом; процент от максимума вручную; ссылка на видео и флаг камеры.
|
||||
- Сеты удалены из функционала. Один блок содержит ровно одно упражнение.
|
||||
- Отдых в минутах (дробные значения допустимы). Общий отдых: сумма количества подходов × отдых, включая паузу после последнего подхода. Время тренировки вводится вручную.
|
||||
- Перестановка блоков, тренировок и недель с автоматической перенумерацией.
|
||||
- История программ клиента и независимое копирование для следующего месяца.
|
||||
- Экспорт PDF: альбомная таблица, цветные заголовки недель и тренировок, кликабельные ссылки. Видео не загружаем и не принимаем.
|
||||
- Приоритет — компьютер. Полноценная адаптация под iPhone 15 — следующий этап.
|
||||
|
||||
## Работа с проектом
|
||||
|
||||
- После изменений выполнять сборку и проверки, соответствующие изменению.
|
||||
- Поддерживать README и `.env.example` в соответствии с реальной конфигурацией.
|
||||
- Не заявлять о проверке Docker, БД, PDF или UI, если соответствующая проверка не выполнена.
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /source
|
||||
COPY Directory.Build.props TrainingPlanner.slnx ./
|
||||
COPY src/TrainingPlanner.Domain/TrainingPlanner.Domain.csproj src/TrainingPlanner.Domain/
|
||||
COPY src/TrainingPlanner.Application/TrainingPlanner.Application.csproj src/TrainingPlanner.Application/
|
||||
COPY src/TrainingPlanner.Infrastructure/TrainingPlanner.Infrastructure.csproj src/TrainingPlanner.Infrastructure/
|
||||
COPY src/TrainingPlanner.Web/TrainingPlanner.Web.csproj src/TrainingPlanner.Web/
|
||||
RUN dotnet restore src/TrainingPlanner.Web/TrainingPlanner.Web.csproj
|
||||
COPY src/ src/
|
||||
RUN dotnet publish src/TrainingPlanner.Web -c Release -o /app --no-restore
|
||||
RUN test -f /app/wwwroot/_framework/blazor.web.js
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core libgssapi-krb5-2 && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/keys && chown $APP_UID /app/keys
|
||||
COPY --from=build /app ./
|
||||
USER $APP_UID
|
||||
ENTRYPOINT ["dotnet", "TrainingPlanner.Web.dll"]
|
||||
@@ -0,0 +1,117 @@
|
||||
# Training Planner
|
||||
|
||||
Рабочее место тренера на C# / .NET 10: персональные программы на месяц, упражнения, история клиента и выгрузка PDF.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Вход по логину и паролю без регистрации; первый аккаунт создаётся при первом запуске.
|
||||
- Карточки клиентов: имя, цель, режим тренировок, состояние здоровья, отдельные заметки тренера и история программ. Корзина в карточке удаляет клиента вместе с его программами после подтверждения. У каждой программы есть отдельная корзина с подтверждением удаления.
|
||||
- Библиотека упражнений с видеоссылками, независимыми группами и ручным добавлением в программу.
|
||||
- «Импорт из таблицы»: вставка двух столбцов из Excel/Google Sheets без заголовков (название и ссылка) в модальном окне. Количество строк растёт автоматически; ячейки можно исправить, строки добавить или удалить. Ссылка необязательна. Можно выбрать существующую группу, создать новую или оставить без группы. Пустые строки пропускаются, одинаковые названия создают отдельные упражнения. При ошибке импорт целиком отклоняется, данные остаются для исправления.
|
||||
- Создание, переименование и удаление групп вместе с их упражнениями. Корзина упражнения удаляет его из библиотеки; составленные тренировки не меняются.
|
||||
- Перенос упражнения на группу или «Без группы» перетаскиванием. Добавление из другой группы создаёт независимую копию.
|
||||
- Поиск в конфигураторе: адаптивный выпадающий список с фильтром названия и группы. Двойной клик или Enter сразу добавляет упражнение; одиночный клик выбирает его для кнопки «Добавить». Точное совпадение и начало названия выше остальных; регистр и «ё/е» не мешают поиску.
|
||||
- Удаление из библиотеки через корзину с подтверждением; упражнения в ранее составленных программах сохраняются.
|
||||
- Недели, тренировки и перемещаемые блоки. Перетаскивание за маркер или кнопки вверх/вниз.
|
||||
- Общие подходы, текстовые повторения и вес; текстовые разминка и примечание; процент от максимума вручную.
|
||||
- Отдых в минутах. Итог — количество подходов × отдых, включая последний подход. Время тренировки задаётся вручную.
|
||||
- Копирование блоков, тренировок, недель и всей программы. Каждое упражнение — отдельный блок; сетов нет.
|
||||
- Перетаскивание недель с автоматической перенумерацией и сохранением порядка.
|
||||
- Перетаскивание тренировок внутри недели с указателем вставки, перенумерацией и сохранением выбранной тренировки.
|
||||
- Удаление выбранной недели вместе с тренировками после подтверждения; в программе остаётся минимум одна неделя.
|
||||
- Удаление тренировки через иконку корзины с подтверждением; после удаления последней тренировки неделю можно заполнить заново.
|
||||
- Автосохранение, ручное сохранение, отмена последнего удаления блока в течение 10 секунд только в исходной тренировке (настройка `.env`: `Planner__UndoDeleteDurationSeconds`) и защита от перезаписи из другой вкладки.
|
||||
- В начале PDF — таблица с именем, целью, режимом тренировок и состоянием здоровья клиента. Заметки тренера не экспортируются.
|
||||
- Альбомный PDF: одна непрерывная таблица на неделю, цветные разделители тренировок с отдельными итогами, одна строка на упражнение, кликабельные ссылки и значки камеры. Ширина таблицы — 27,5 см; PNG камеры встроен в сборку, выровнен по центру ячейки. Размер иконки задаётся `Pdf__CameraSizePoints`.
|
||||
|
||||
Интерфейс первой версии рассчитан на компьютер. Полная адаптация под iPhone 15 отложена. Клиентского кабинета, загрузки видео, регистрации и работы без интернета нет. Blazor Interactive Server требует соединения с сервером.
|
||||
|
||||
## Запуск в Docker
|
||||
|
||||
Нужны Docker Engine/Desktop и Docker Compose.
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Заполните POSTGRES_PASSWORD, BOOTSTRAP_TRAINER_USERNAME и BOOTSTRAP_TRAINER_PASSWORD.
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
По умолчанию приложение доступно на `http://localhost:8080`; порт меняется через `APP_PORT`. Пароль тренера должен соответствовать настройкам Identity: по умолчанию минимум 12 символов, цифра, строчная/прописная буква и спецсимвол.
|
||||
|
||||
Миграции применяются при запуске, затем создаётся первый тренер. Если аккаунт уже есть, переменные BootstrapTrainer его не изменяют и не сбрасывают пароль.
|
||||
|
||||
Два контейнера находятся в общей сети. PostgreSQL 18 не публикует порт на хост. Данные БД и ключи cookie сохраняются в именованных volumes. `docker compose down` сохраняет их; не используйте `down -v`, если данные нужны.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
`appsettings.json` задаёт значения по умолчанию. Docker Compose читает `.env`; `env_file` передаёт его содержимое контейнеру. Сам .NET автоматически `.env` не читает.
|
||||
|
||||
| Переменная в .env | Назначение |
|
||||
|---|---|
|
||||
| `APP_BIND_ADDRESS`, `APP_PORT` | Адрес и порт приложения на хосте |
|
||||
| `POSTGRES_IMAGE`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | База данных |
|
||||
| `BOOTSTRAP_TRAINER_USERNAME`, `BOOTSTRAP_TRAINER_PASSWORD` | Первый тренер |
|
||||
| `PLANNER_DEFAULT_WEEK_COUNT` | Число недель новой программы |
|
||||
| `PLANNER_DEFAULT_SETS_COUNT` | Начальное число подходов |
|
||||
| `PLANNER_DEFAULT_REPETITIONS` | Начальное значение повторений |
|
||||
| `PLANNER_DEFAULT_REST_MINUTES` | Начальный отдых в минутах (например, 1.5) |
|
||||
| `PLANNER_DEFAULT_WORKOUT_NAME` | Основа названия новой тренировки |
|
||||
| `PLANNER_DEFAULT_WORKOUT_DURATION` | Время новой тренировки, по умолчанию `1 час` |
|
||||
| `PLANNER_DEFAULT_INTENSITY_PERCENT` | Процент нового упражнения, по умолчанию `80` |
|
||||
| `PLANNER_AUTOSAVE_DELAY_MS` | Задержка автосохранения |
|
||||
| `REQUIRE_HTTPS` | Защищённые cookie и перенаправление на HTTPS |
|
||||
| `REVERSE_PROXY_IP` | Доверенный адрес обратного прокси |
|
||||
| `COOKIE_HOURS`, `AUTH_REVALIDATION_MINUTES` | Срок cookie и повторная проверка авторизации |
|
||||
| `DATABASE_INITIALIZE_ON_STARTUP` | Применение миграций и создание первого тренера |
|
||||
|
||||
Другие секции можно переопределять через стандартные имена .NET с `__`, например `Pdf__FontSize=11`, `Pdf__ColumnWidthsCentimeters__0=7`, `Identity__Password__RequiredLength=16`. Для настроек, явно перечисленных в Compose, используйте переменные из таблицы: значения секции `environment` имеют приоритет над `env_file`.
|
||||
|
||||
Лимиты импорта: `ExerciseImport__MaxRows=1000`, `ExerciseImport__MaxClipboardCharacters=100000`. Размер сообщения Blazor задаётся `InteractiveServer__MaximumReceiveMessageBytes=1048576`; при увеличении лимита текста учитывайте запас для UTF-8 и JSON. Все три параметра переопределяются через `.env`.
|
||||
|
||||
Шрифты PDF по умолчанию устанавливаются внутрь образа. Если задаёте другие пути, файлы должны быть доступны внутри контейнера. Не храните секреты в Git; `.env` исключён из Git и Docker build context.
|
||||
|
||||
## Публичное развёртывание
|
||||
|
||||
Локальная `.env.example` использует HTTP и привязку к loopback. Для публичного сервера настройте TLS на обратном прокси, `REQUIRE_HTTPS=true` и доверенный `REVERSE_PROXY_IP`. Прокси должен передавать `X-Forwarded-Proto`, поддерживать WebSocket для Blazor и иметь доступ к выбранному адресу приложения. По умолчанию appsettings требует HTTPS.
|
||||
|
||||
Регистрации нет. Сервер проверяет принадлежность клиентов, упражнений и программ текущему тренеру. Повторные неудачные входы блокируются настройками Identity. Формы входа/выхода защищены antiforgery.
|
||||
|
||||
Резервная копия БД:
|
||||
|
||||
```sh
|
||||
docker compose exec -T database sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup.sql
|
||||
```
|
||||
|
||||
Храните резервные копии отдельно от сервера. Настройку расписания и хранения резервных копий нужно выполнить на целевом сервере.
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
Нужны .NET SDK 10 и доступный PostgreSQL. Передайте `ConnectionStrings__TrainingDatabase`, `BootstrapTrainer__UserName`, `BootstrapTrainer__Password` и пути `Pdf__FontPath`, `Pdf__BoldFontPath` к TTF-шрифтам с кириллицей. В Linux-контейнере пути настроены автоматически. Для HTTP-разработки задайте `ASPNETCORE_ENVIRONMENT=Development`.
|
||||
|
||||
```sh
|
||||
dotnet restore TrainingPlanner.slnx
|
||||
dotnet build TrainingPlanner.slnx
|
||||
dotnet test tests/TrainingPlanner.Tests
|
||||
dotnet run --project src/TrainingPlanner.Web
|
||||
```
|
||||
|
||||
Для новой миграции:
|
||||
|
||||
```sh
|
||||
dotnet tool restore
|
||||
dotnet ef migrations add MigrationName --project src/TrainingPlanner.Web --output-dir Data/Migrations
|
||||
```
|
||||
|
||||
## Структура
|
||||
|
||||
- Domain: небольшие предметные классы со свойствами `get; set;`.
|
||||
- Application: сценарии редактирования, валидация, копирование и контракты.
|
||||
- Infrastructure: PostgreSQL, Identity, PDF.
|
||||
- Web: Blazor-компоненты, HTTP-страницы авторизации и экспорта, конфигурация.
|
||||
- Tests: проверки копирования параметров, порядка, валидации и расчёта отдыха.
|
||||
|
||||
Подробности — `docs/architecture.md`. Обязательные правила разработки — `AGENTS.md`.
|
||||
|
||||
### Обновление старых программ
|
||||
|
||||
Перед применением миграции ExercisePrescription сделайте резервную копию БД. Общие вес и повторения берутся из первого подхода, старые сеты разделяются на одиночные упражнения, отдых переводится из секунд в минуты. Разные веса последующих подходов больше не используются. Для возврата к прежней модели нужны резервная копия и прежняя версия приложения. Переменные `PLANNER_DEFAULT_REST_SECONDS` и `PLANNER_DEFAULT_ROUNDS` заменены на `PLANNER_DEFAULT_REST_MINUTES` и `PLANNER_DEFAULT_SETS_COUNT`.
|
||||
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/TrainingPlanner.Application/TrainingPlanner.Application.csproj" />
|
||||
<Project Path="src/TrainingPlanner.Domain/TrainingPlanner.Domain.csproj" />
|
||||
<Project Path="src/TrainingPlanner.Infrastructure/TrainingPlanner.Infrastructure.csproj" />
|
||||
<Project Path="src/TrainingPlanner.Web/TrainingPlanner.Web.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/TrainingPlanner.Tests/TrainingPlanner.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,60 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-8080}:8080"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
ASPNETCORE_HTTP_PORTS: "8080"
|
||||
Security__RequireHttps: "${REQUIRE_HTTPS:-true}"
|
||||
Security__DataProtectionKeysPath: /app/keys
|
||||
Security__KnownProxies__0: "${REVERSE_PROXY_IP:-127.0.0.1}"
|
||||
Security__CookieHours: "${COOKIE_HOURS:-8}"
|
||||
Security__RevalidationMinutes: "${AUTH_REVALIDATION_MINUTES:-5}"
|
||||
Database__InitializeOnStartup: "${DATABASE_INITIALIZE_ON_STARTUP:-true}"
|
||||
Planner__DefaultRepetitions: "${PLANNER_DEFAULT_REPETITIONS:-10}"
|
||||
Planner__DefaultWorkoutName: "${PLANNER_DEFAULT_WORKOUT_NAME:-Тренировка}"
|
||||
Planner__DefaultWorkoutDuration: "${PLANNER_DEFAULT_WORKOUT_DURATION:-1 час}"
|
||||
Planner__DefaultIntensityPercent: "${PLANNER_DEFAULT_INTENSITY_PERCENT:-80}"
|
||||
Planner__AutosaveDelayMilliseconds: "${PLANNER_AUTOSAVE_DELAY_MS:-1200}"
|
||||
ConnectionStrings__TrainingDatabase: "Host=database;Database=${POSTGRES_DB:?Set POSTGRES_DB};Username=${POSTGRES_USER:?Set POSTGRES_USER};Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}"
|
||||
Planner__DefaultWeekCount: "${PLANNER_DEFAULT_WEEK_COUNT:-4}"
|
||||
Planner__DefaultSetsCount: "${PLANNER_DEFAULT_SETS_COUNT:-3}"
|
||||
Planner__DefaultRestMinutes: "${PLANNER_DEFAULT_REST_MINUTES:-1.5}"
|
||||
BootstrapTrainer__UserName: "${BOOTSTRAP_TRAINER_USERNAME:-}"
|
||||
BootstrapTrainer__Password: "${BOOTSTRAP_TRAINER_PASSWORD:-}"
|
||||
volumes:
|
||||
- data_protection_keys:/app/keys
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- training
|
||||
restart: unless-stopped
|
||||
|
||||
database:
|
||||
image: "${POSTGRES_IMAGE:-postgres:18}"
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:?Set POSTGRES_DB}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:?Set POSTGRES_USER}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- training
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
training:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
data_protection_keys:
|
||||
@@ -0,0 +1,68 @@
|
||||
# Архитектура и границы первой версии
|
||||
|
||||
## Стек
|
||||
|
||||
.NET 10, ASP.NET Core, Blazor, EF Core, PostgreSQL. Один процесс приложения и отдельный контейнер БД. Без микросервисов.
|
||||
|
||||
## Зависимости
|
||||
|
||||
- Domain: простые предметные классы, без зависимостей от UI и хранилища.
|
||||
- Application → Domain: прикладные сценарии и контракты хранилища.
|
||||
- Infrastructure → Application: EF Core, PostgreSQL, авторизация и внешние сервисы.
|
||||
- Web → Application + Infrastructure: интерфейс и регистрация зависимостей.
|
||||
|
||||
Infrastructure подключается в Web для сборки приложения. Компоненты работают через контракты Application.
|
||||
|
||||
## Программа
|
||||
|
||||
Клиент принадлежит тренеру. У клиента несколько сохранённых месячных программ. Программа содержит недели, недели — тренировки, тренировки — упорядоченные блоки. Блок содержит ровно одно упражнение.
|
||||
|
||||
Количество подходов (`SetsCount`) и отдых в минутах (`RestMinutes`) находятся у блока. У упражнения общие текстовые повторения и вес, текстовые разминка и примечание, nullable decimal процент от максимума, название, ссылка и флаг камеры. Процент вводит тренер; автоматического соответствия между повторениями и процентом нет. Сеты не поддерживаются.
|
||||
|
||||
Общий отдых вычисляется как сумма `SetsCount × RestMinutes` всех блоков, включая отдых после последнего подхода. Текст повторений на эту формулу не влияет. Длительность тренировки (`Duration`) вводится тренером текстом и не вычисляется из отдыха.
|
||||
|
||||
Миграция ExercisePrescription переносит общие вес и повторения из первого подхода, секунды делит на 60, старые сеты разделяет на отдельные блоки без перестановки упражнений. Перед применением нужна резервная копия. Откат с восстановлением индивидуальных подходов возможен только из резервной копии; автоматический Down запрещён.
|
||||
|
||||
Упражнение программы хранит снимок названия и ссылки из библиотеки. Изменение библиотеки не изменяет старые программы. Копирование создаёт новые идентификаторы всех элементов программы. История — список сохранённых программ клиента, а не журнал каждого нажатия клавиши.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Настраиваемые значения задаются в appsettings.json и переопределяются стандартным провайдером переменных окружения .NET. В именах переменных `__` разделяет секции. Docker Compose читает `.env` и передаёт переменные контейнеру. Сам .NET файл `.env` автоматически не читает.
|
||||
|
||||
Секреты не имеют рабочих значений по умолчанию и не хранятся в Git. Версии инструментов, имена маршрутов, CSS и структура данных не являются runtime-настройками.
|
||||
|
||||
## Этапы
|
||||
|
||||
1. Основа решения, правила разработки, предметные классы и конфигурация.
|
||||
2. PostgreSQL, авторизация, клиенты и библиотека упражнений.
|
||||
3. Редактор месячной программы и независимое копирование.
|
||||
4. PDF и проверка полного сценария в Docker.
|
||||
5. Адаптация под iPhone 15 — после первой версии.
|
||||
|
||||
## Сохранение и безопасность
|
||||
|
||||
UI использует Blazor Interactive Server. Каждая операция хранилища создаёт отдельный DbContext; общий контекст на долгоживущий Blazor circuit не используется. Репозитории проверяют тренера, полученного из серверного состояния авторизации. Экспорт получает идентификатор тренера из авторизованного HTTP-запроса.
|
||||
|
||||
Сохранение небольшой месячной программы заменяет её внутренние дочерние записи одной транзакцией. Идентификаторы недель, тренировок и блоков являются внутренними и не используются в ссылках. Это сохраняет простую реализацию для первого этапа; при необходимости частичных обновлений контракт репозитория можно сохранить. Идентификатор программы стабилен, `Version` защищает от перезаписи из другой вкладки. История программ не удаляется.
|
||||
|
||||
Автосохранение срабатывает после редактирования с задержкой из конфигурации. Незаполненное упражнение остаётся локальным черновиком до заполнения обязательных полей. Статус сохранения и ошибка видны в интерфейсе. Переход со страницы с несохранёнными данными требует подтверждения.
|
||||
|
||||
ASP.NET Core Identity: хеширование паролей, блокировка повторных неудачных входов, antiforgery для форм, HTTP-only cookie и периодическая проверка security stamp. Регистрации нет. Первый тренер создаётся из конфигурации только в пустой базе. Для HTTPS за прокси задаётся адрес доверенного прокси; произвольные forwarded-заголовки не принимаются.
|
||||
|
||||
PDF генерируется PDFsharp/MigraDoc, шрифты с кириллицей устанавливаются в Docker-образ. Каждая неделя — одна непрерывная альбомная A4-таблица. Общие заголовки недели и столбцов повторяются на продолжении страниц; тренировки разделяются цветными строками и имеют собственные итоги. Заголовок тренировки удерживается вместе с первой строкой, итог — с последней. Размеры, цвета и пути задаются в Pdf. Ссылки являются PDF-аннотациями; значок камеры встроен как статический рисунок и не зависит от emoji-шрифтов ОС.
|
||||
|
||||
## Публикация Blazor в Docker
|
||||
|
||||
В Web-проекте явно установлен `RequiresAspNetWebAssets=true`: при отдельном Docker-слое восстановления зависимостей Razor-файлы ещё не скопированы, поэтому автоматическое обнаружение компонентов не подходит. Dockerfile дополнительно проверяет наличие `wwwroot/_framework/blazor.web.js` после публикации. Это настройка сборки, а не runtime-параметр appsettings.
|
||||
|
||||
Документация: https://learn.microsoft.com/aspnet/core/blazor/project-structure?view=aspnetcore-10.0
|
||||
|
||||
## Группы библиотеки
|
||||
|
||||
У `LibraryExercise` необязательная ссылка `GroupId` на `ExerciseGroup`, обе записи принадлежат тренеру. Упражнения внутри разных групп независимы. Добавление из другой группы создаёт новую запись с копией названия и ссылки. Перетаскивание меняет только `GroupId`, не переписывая остальные поля. `null` означает «Без группы».
|
||||
|
||||
Удаление группы каскадно удаляет только её упражнения библиотеки. Снимки упражнений в программах не имеют внешнего ключа на библиотеку, поэтому история не меняется. Все операции проверяют тренера и у источника, и у целевой группы. Миграция ExerciseGroups добавляет таблицу групп и nullable GroupId: прежние упражнения остаются без группы.
|
||||
|
||||
Поиск фильтрует по всем словам без учёта порядка, регистра и «ё/е»; точные совпадения и совпадения с начала имени выше совпадений внутри имени. В списке показана группа, чтобы различать независимые упражнения с одинаковыми названиями.
|
||||
|
||||
Компонент ExercisePicker использует всплывающий список поверх страницы: ширина соответствует полю, направление раскрытия и высота зависят от доступного места окна. Нативный popover закрывается по Escape и клику снаружи. Двойной клик и Enter вызывают явный OnApply, одиночный клик меняет выбор; флаг применения предотвращает повторное добавление во время операции.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Проверка первой версии — 24 сентября 2026
|
||||
|
||||
## Выполнено
|
||||
|
||||
- Решение .NET 10 собирается без ошибок и предупреждений.
|
||||
- Пять тестов Application проходят: независимое копирование подходов, сохранение нагрузок при объединении/разделении и перемещении, запрет потери подходов при разном их количестве, добавление круга, проверка отрицательного веса и схемы видеоссылки.
|
||||
- Docker-образ собирается; публикация отдельно проверяет наличие скрипта Blazor.
|
||||
- Два контейнера запускаются в общей сети; PostgreSQL проходит healthcheck, миграция применяется, первый тренер создаётся из окружения.
|
||||
- В Chrome проверены вход, создание клиента и программы, ручное добавление упражнения, разные веса 40/45/50 и 12/14/16 по подходам, сохранение и повторное открытие.
|
||||
- Проверены объединение/разделение сета, изменение порядка, перетаскивание, библиотека и отмена удаления блока.
|
||||
- Проверена независимая копия программы: изменение её веса не затрагивает оригинал.
|
||||
- Проверены две вкладки: устаревшая версия не перезаписывает более новую.
|
||||
- Неавторизованный запрос PDF перенаправляется на вход.
|
||||
- PDF скачан из приложения и отрендерен: кириллица, значки камеры, веса и шесть кликабельных ссылок присутствуют.
|
||||
- Отдельная длинная тренировка экспортирована на шесть страниц: все 18 упражнений и 54 ссылки сохранены; переносы страниц просмотрены.
|
||||
|
||||
## Границы проверки
|
||||
|
||||
Проверка проведена локально в Docker на macOS и в настольном Chrome. Публичный TLS-прокси, нагрузка нескольких тренеров и мобильный интерфейс не проверялись. Данные проверки явно помечены как демонстрационные. Результаты и скрипты ручной браузерной проверки находятся в игнорируемом `artifacts/qa`; это не данные реальных клиентов.
|
||||
|
||||
## Общие параметры упражнений — 25 сентября 2026
|
||||
|
||||
- Убраны индивидуальные подходы и сеты. Добавлены общие повторения, текстовый вес, разминка, примечание, ручной процент, отдых в минутах и ручное время тренировки.
|
||||
- 8 тестов: независимое копирование полей, валидация, порядок блоков, общий отдых с паузой после последнего подхода.
|
||||
- Перед миграцией создана локальная резервная копия в игнорируемом каталоге backups. Миграция проверена на отдельной копии PostgreSQL: 19 упражнений из 15 блоков перенесены в одиночные блоки; проверены порядок, названия, ссылки, камера, число подходов, первые вес/повторения и перевод секунд в минуты.
|
||||
- Браузерные проверки выполнялись на отдельном временном экземпляре приложения и копии БД: ввод новых полей, сохранение и перезагрузка, независимая копия блока, текстовый вес, дробный отдых, итоги 6 и 10,5 минуты, отсутствие управления сетами, экспорт HTTP 200.
|
||||
- PDF проверен по тексту и структуре без рендеринга страниц: альбомная ориентация, новые колонки, кликабельные аннотации, встроенные камеры, итог отдыха и ручное время. Длинный PDF: все 18 упражнений на 3 страницах, повтор заголовков, текст в границах страниц.
|
||||
- По просьбе пользователя скриншоты и визуальная проверка PDF не выполнялись.
|
||||
|
||||
## Единая PDF-таблица недели — 25 сентября 2026
|
||||
|
||||
- Каждая неделя экспортируется одной таблицей с общими заголовками и последовательными тренировками; после каждой тренировки свой итог.
|
||||
- Проверена структура документа: одна таблица на неделю, только две общие строки заголовка повторяются при переносе.
|
||||
- Тестовый PDF: две короткие тренировки на одной странице без повторения заголовков между ними; длинная тренировка на следующих страницах, пустая тренировка, отдельные итоги и кликабельные ссылки. Проверены альбомная ориентация, повтор общих заголовков и границы текста.
|
||||
- Проверки без скриншотов и рендеринга страниц.
|
||||
|
||||
## Группы библиотеки и поиск — 25 сентября 2026
|
||||
|
||||
- 11 тестов проходят, включая точные/начальные совпадения, поиск по нескольким словам, регистр, «ё/е» и отсутствие результатов.
|
||||
- Миграция ExerciseGroups соответствует модели EF Core. На отдельной копии PostgreSQL проверен переход старых упражнений к nullable GroupId без удаления исходных записей.
|
||||
- На отдельном тестовом экземпляре проверены создание и переименование групп, независимое копирование упражнения между группами, drag and drop в группу и «Без группы», сохранение после перезагрузки.
|
||||
- Проверены отмена удаления группы, удаление группы с упражнениями, сохранение независимой копии в другой группе и индивидуальная корзина.
|
||||
- В конфигураторе проверены фильтр группы, поиск, пустой результат, блокировка добавления без выбранного упражнения; добавленное в программу упражнение остаётся после удаления библиотечной группы.
|
||||
- Заодно подтверждены значения по умолчанию: время новой тренировки «1 час», процент нового упражнения 80.
|
||||
- Все UI-проверки выполнены без скриншотов. Тестовые изменения делались только в отдельной базе.
|
||||
|
||||
## Выпадающий поиск — 26 сентября 2026
|
||||
|
||||
- Постоянный список заменён popover со строкой поиска; ширина соответствует полю, высота и направление раскрытия зависят от свободного места.
|
||||
- На отдельной копии базы проверены: скрытое начальное состояние, фильтрация, двойной клик без повторного добавления, одиночный выбор с кнопкой «Добавить», стрелки и Enter, Escape, закрытие по клику снаружи, отсутствие результатов и сохранение после перезагрузки.
|
||||
- Проверено размещение внутри уменьшенного окна и двойной клик при создании независимой копии упражнения в группе.
|
||||
- Сборка прошла. Скриншоты не выполнялись.
|
||||
|
||||
## Удаление клиента — 29 сентября 2026
|
||||
|
||||
- В карточку клиента добавлена корзина с подтверждением удаления клиента и всех его программ.
|
||||
- Сборка без предупреждений и ошибок, 11 тестов прошли; приложение обновлено в Docker.
|
||||
- В браузере на специально созданном тестовом клиенте проверены отмена удаления с сохранением программы, подтверждение, возврат к списку и недоступность удалённых клиента и программы по прямым ссылкам. Тестовый клиент удалён. Скриншоты не выполнялись.
|
||||
|
||||
## Данные клиента в PDF — 29 сентября 2026
|
||||
|
||||
- Миграция ClientProfile добавляет Goal, TrainingSchedule и HealthStatus с пустыми значениями; существующий столбец Notes сохраняется без изменений.
|
||||
- 11 тестов прошли; EF Core не обнаружил расхождений модели и миграций. Сборка Docker и применение миграции успешны.
|
||||
- На специально созданном клиенте проверены порядок полей, сохранение и загрузка после обновления страницы, экспорт PDF. Тестовые клиент и программа удалены после проверки.
|
||||
- Из PDF извлечены все новые значения; таблица содержит четыре строки и два столбца, находится перед неделями и встречается один раз. Проверены альбомные страницы и отсутствие приватной тестовой заметки. Скриншоты и визуальный рендер не выполнялись.
|
||||
|
||||
## Удаление программы — 29 сентября 2026
|
||||
|
||||
- В истории программ клиента добавлена корзина с подтверждением по названию программы. Удаление ограничено текущим тренером; дочерние недели и тренировки удаляются каскадно.
|
||||
- Сборка без предупреждений и ошибок, 11 тестов прошли; Docker-приложение обновлено.
|
||||
- В браузере без скриншотов на собственных тестовых данных проверены отмена, подтверждение, обновление счётчика, закрытие формы копирования удалённой программы, сохранение результата после перезагрузки и недоступность удалённой программы по ссылке. Клиент и другая программа сохранились. Тестовые данные удалены после проверки.
|
||||
|
||||
## Импорт упражнений из таблицы — 29 сентября 2026
|
||||
|
||||
- Сборка без предупреждений и ошибок, 16 тестов прошли. Тесты разбора буфера покрывают кавычки Excel, переносы внутри ячеек, пустые ячейки, CR/LF/CRLF, большие вставки и лимиты; проверена валидация ссылок и номера ошибочной строки.
|
||||
- В Docker-приложении без скриншотов проверена вставка 250 строк одним событием буфера обмена, автоматическое расширение таблицы, сохранение после перезагрузки, импорт в новую/существующую группу и без группы.
|
||||
- Ошибочная ссылка оставляет данные в модальном окне и не создаёт ни группу, ни упражнения. Проверены лишний столбец, обязательность названия новой группы, ручное исправление ячеек, вставка только в столбец ссылки, добавление/удаление строк, отмена и Escape.
|
||||
- Сохранённая ссылка проверена в библиотеке. Собственные тестовые записи удалены после проверки. Настоящее приложение Excel не автоматизировалось: использовалось событие paste с табличным текстом формата Excel.
|
||||
|
||||
## Очистка поиска после добавления — 29 сентября 2026
|
||||
|
||||
- После успешного добавления сбрасываются текст поиска и активный пункт, список закрывается; фильтр группы сохраняется. Сброс явно запрашивается родителем, поэтому обычный ввод и перерисовка не стирают следующий запрос.
|
||||
- Сборка успешна; приложение обновлено в Docker. В браузере без скриншотов проверены добавление кнопкой, двойным кликом и Enter, очистка поиска и выбора, закрытие списка и сохранение следующего введённого запроса. Собственные тестовые данные удалены.
|
||||
|
||||
## Плашка отмены удаления — 29 сентября 2026
|
||||
|
||||
- Плашка видна только в тренировке, где удалён блок, и исчезает через Planner:UndoDeleteDurationSeconds (по умолчанию 10 секунд). Повторное удаление заменяет предыдущую отмену и перезапускает таймер.
|
||||
- Таймер отменяется при восстановлении блока, удалении исходной тренировки/недели, смене программы и закрытии редактора.
|
||||
- Сборка успешна, 16 тестов прошли. В браузере без скриншотов на собственных тестовых данных проверены переключение тренировок, восстановление блока, автоматическое исчезновение и отсутствие повторного появления. Тестовые данные удалены; Docker-приложение обновлено.
|
||||
|
||||
## PDF без названия программы — 29 сентября 2026
|
||||
|
||||
- Название программы удалено из содержимого и метаданных PDF.
|
||||
- Сборка успешна, Docker-приложение обновлено. В тестовом экспорте проверено отсутствие названия на всех четырёх страницах и в метаданных, наличие данных клиента. Собственные тестовые записи удалены. Скриншоты не выполнялись.
|
||||
|
||||
## Отступ после данных клиента в PDF — 29 сентября 2026
|
||||
|
||||
- Добавлены пять пустых строк только между таблицей данных клиента и первой неделей. Настройка: Pdf:ClientDetailsGapLines, переопределение через .env.
|
||||
- Сборка и Docker-обновление успешны. Координаты текста тестового PDF подтверждают смещение первой недели вниз при неизменных следующих неделях и количестве страниц. Скриншоты не выполнялись; собственные тестовые записи удалены.
|
||||
|
||||
## Ширина таблицы и ресурс камеры — 29 сентября 2026
|
||||
|
||||
- Ширина таблицы PDF увеличена с 25,5 до 27,5 см настройками столбцов. Приложенный PNG без изменений включён как EmbeddedResource в Infrastructure; размер камеры 18 pt настраивается через Pdf:CameraSizePoints.
|
||||
- Иконка выровнена по центру абзаца и вертикально по центру ячейки. Сборка и Docker-обновление успешны.
|
||||
- Из PDF, экспортированного Docker-приложением, извлечена иконка: пиксели совпадают с исходным PNG. По координатам проверены ширина таблицы, границы страницы и центрирование (горизонтальное отклонение 0 pt, вертикальное менее 1 pt).
|
||||
- Скриншоты не выполнялись. Собственные тестовые данные удалены после экспорта.
|
||||
|
||||
## Полные ссылки и центрирование в PDF — 29 сентября 2026
|
||||
|
||||
- Вместо «Открыть видео» выводится полный URL с невидимыми возможностями переноса; адрес гиперссылки остаётся исходным.
|
||||
- Все столбцы после «Примечания», включая заголовки и итоговые значения, выровнены по горизонтальному и вертикальному центру.
|
||||
- Сборка и Docker-обновление успешны. В тестовом PDF проверены полный длинный URL внутри ячейки, неизменный адрес перехода в аннотациях PDF и центрирование числовых ячеек по координатам (отклонение менее 1 pt). Скриншоты не выполнялись; собственные тестовые данные удалены.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.8",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class BlockEditor
|
||||
{
|
||||
public void Move(Workout workout, int from, int to)
|
||||
{
|
||||
if (from < 0 || to < 0 || from >= workout.Blocks.Count || to >= workout.Blocks.Count)
|
||||
return;
|
||||
var block = workout.Blocks[from];
|
||||
workout.Blocks.RemoveAt(from);
|
||||
workout.Blocks.Insert(to, block);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class ClipboardTableParser(ExerciseImportOptions options)
|
||||
{
|
||||
public List<List<string>> Parse(string text)
|
||||
{
|
||||
if (text.Length > options.MaxClipboardCharacters)
|
||||
throw new InvalidOperationException("Слишком большой объём данных. Вставьте таблицу частями.");
|
||||
var rows = new List<List<string>>();
|
||||
var row = new List<string>();
|
||||
var cell = new StringBuilder();
|
||||
var quoted = false;
|
||||
for (var index = 0; index < text.Length; index++)
|
||||
{
|
||||
var character = text[index];
|
||||
if (character == '"')
|
||||
{
|
||||
if (quoted && index + 1 < text.Length && text[index + 1] == '"')
|
||||
{ cell.Append('"'); index++; }
|
||||
else if (quoted || cell.Length == 0) quoted = !quoted;
|
||||
else cell.Append(character);
|
||||
}
|
||||
else if (!quoted && (character == '\t' || character == '\r' || character == '\n'))
|
||||
{
|
||||
row.Add(cell.ToString());
|
||||
cell.Clear();
|
||||
if (character == '\t') continue;
|
||||
rows.Add(row);
|
||||
row = [];
|
||||
if (character == '\r' && index + 1 < text.Length && text[index + 1] == '\n') index++;
|
||||
}
|
||||
else cell.Append(character);
|
||||
}
|
||||
if (quoted) throw new InvalidOperationException("Не удалось прочитать ячейку в кавычках. Проверьте скопированные данные.");
|
||||
if (cell.Length > 0 || row.Count > 0)
|
||||
{
|
||||
row.Add(cell.ToString());
|
||||
rows.Add(row);
|
||||
}
|
||||
while (rows.Count > 0 && rows[^1].All(string.IsNullOrWhiteSpace)) rows.RemoveAt(rows.Count - 1);
|
||||
if (rows.Count > options.MaxRows)
|
||||
throw new InvalidOperationException($"За один раз можно вставить не более {options.MaxRows} строк.");
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class ExerciseImportOptions
|
||||
{
|
||||
public int MaxRows { get; set; }
|
||||
public int MaxClipboardCharacters { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class ExerciseImportRow
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string VideoUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class ExerciseImportValidator(ExerciseImportOptions options)
|
||||
{
|
||||
public List<ExerciseImportRow> Validate(List<ExerciseImportRow> rows)
|
||||
{
|
||||
if (rows.Count > options.MaxRows)
|
||||
throw new InvalidOperationException($"За один раз можно импортировать не более {options.MaxRows} строк.");
|
||||
var result = new List<ExerciseImportRow>();
|
||||
for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var row = rows[index];
|
||||
if (string.IsNullOrWhiteSpace(row.Name) && string.IsNullOrWhiteSpace(row.VideoUrl)) continue;
|
||||
try
|
||||
{
|
||||
PlanValidator.RequireName(row.Name, "Название упражнения");
|
||||
PlanValidator.ValidateVideoUrl(row.VideoUrl.Trim());
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
throw new InvalidOperationException($"Строка {index + 1}: {exception.Message}");
|
||||
}
|
||||
result.Add(new ExerciseImportRow { Name = row.Name.Trim(), VideoUrl = row.VideoUrl.Trim() });
|
||||
}
|
||||
if (result.Count == 0) throw new InvalidOperationException("Добавьте хотя бы одно упражнение.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public static class ExerciseSearch
|
||||
{
|
||||
public static List<LibraryExercise> Find(IEnumerable<LibraryExercise> exercises, string query)
|
||||
{
|
||||
var search = Normalize(query);
|
||||
var words = search.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return exercises
|
||||
.Where(exercise => words.All(word => Normalize(exercise.Name).Contains(word)))
|
||||
.OrderByDescending(exercise => Normalize(exercise.Name) == search)
|
||||
.ThenByDescending(exercise => Normalize(exercise.Name).StartsWith(search))
|
||||
.ThenBy(exercise => exercise.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().ToLowerInvariant().Replace('ё', 'е');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface IClientRepository
|
||||
{
|
||||
Task<List<Client>> GetAllAsync();
|
||||
Task<Client?> GetAsync(int id);
|
||||
Task SaveAsync(Client client);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface IExerciseGroupRepository
|
||||
{
|
||||
Task<List<ExerciseGroup>> GetAllAsync();
|
||||
Task SaveAsync(ExerciseGroup group);
|
||||
Task AddExerciseAsync(int groupId, int exerciseId);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface IExerciseImporter
|
||||
{
|
||||
Task<int> ImportAsync(List<ExerciseImportRow> rows, int? groupId, string newGroupName);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<List<LibraryExercise>> GetAllAsync();
|
||||
Task SaveAsync(LibraryExercise exercise);
|
||||
Task DeleteAsync(int id);
|
||||
Task MoveAsync(int id, int? groupId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface IPlanPdfExporter
|
||||
{
|
||||
byte[] Export(TrainingPlan plan, Client client);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface IPlanReader
|
||||
{
|
||||
Task<TrainingPlan?> GetAsync(int id, string trainerId);
|
||||
Task<Client?> GetClientAsync(int id, string trainerId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface IPlanRepository
|
||||
{
|
||||
Task<List<TrainingPlan>> GetForClientAsync(int clientId);
|
||||
Task<TrainingPlan?> GetAsync(int id);
|
||||
Task SaveAsync(TrainingPlan plan);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public interface ITrainerContext
|
||||
{
|
||||
Task<string> GetTrainerIdAsync();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class PlanCopier
|
||||
{
|
||||
public TrainingPlan Copy(TrainingPlan source, string name)
|
||||
{
|
||||
return new TrainingPlan
|
||||
{
|
||||
ClientId = source.ClientId,
|
||||
Name = name,
|
||||
Weeks = source.Weeks.Select(CopyWeek).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public TrainingWeek CopyWeek(TrainingWeek source)
|
||||
{
|
||||
return new TrainingWeek
|
||||
{
|
||||
Position = source.Position,
|
||||
Workouts = source.Workouts.Select(CopyWorkout).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public Workout CopyWorkout(Workout source)
|
||||
{
|
||||
return new Workout
|
||||
{
|
||||
Name = source.Name,
|
||||
Duration = source.Duration,
|
||||
Position = source.Position,
|
||||
Blocks = source.Blocks.Select(CopyBlock).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public WorkoutBlock CopyBlock(WorkoutBlock source)
|
||||
{
|
||||
return new WorkoutBlock
|
||||
{
|
||||
Position = source.Position,
|
||||
RestMinutes = source.RestMinutes,
|
||||
SetsCount = source.SetsCount,
|
||||
Exercises = source.Exercises.Select(CopyExercise).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public WorkoutExercise CopyExercise(WorkoutExercise source)
|
||||
{
|
||||
return new WorkoutExercise
|
||||
{
|
||||
Position = source.Position,
|
||||
Name = source.Name,
|
||||
VideoUrl = source.VideoUrl,
|
||||
RequiresVideo = source.RequiresVideo,
|
||||
Repetitions = source.Repetitions,
|
||||
Weight = source.Weight,
|
||||
WarmupSets = source.WarmupSets,
|
||||
Notes = source.Notes,
|
||||
IntensityPercent = source.IntensityPercent
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class PlanFactory(PlannerOptions options)
|
||||
{
|
||||
public TrainingPlan Create(int clientId, string name)
|
||||
{
|
||||
var plan = new TrainingPlan { ClientId = clientId, Name = name };
|
||||
for (var index = 0; index < options.DefaultWeekCount; index++)
|
||||
plan.Weeks.Add(new TrainingWeek { Position = index });
|
||||
return plan;
|
||||
}
|
||||
|
||||
public WorkoutBlock CreateBlock(LibraryExercise? source = null)
|
||||
{
|
||||
var exercise = new WorkoutExercise
|
||||
{
|
||||
Name = source?.Name ?? string.Empty,
|
||||
VideoUrl = source?.VideoUrl ?? string.Empty,
|
||||
Repetitions = options.DefaultRepetitions,
|
||||
IntensityPercent = options.DefaultIntensityPercent
|
||||
};
|
||||
return new WorkoutBlock
|
||||
{
|
||||
SetsCount = options.DefaultSetsCount,
|
||||
RestMinutes = options.DefaultRestMinutes,
|
||||
Exercises = [exercise]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class PlanValidator
|
||||
{
|
||||
public void Validate(TrainingPlan plan)
|
||||
{
|
||||
RequireName(plan.Name, "Название программы");
|
||||
if (plan.Weeks.Count == 0)
|
||||
throw new InvalidOperationException("Добавьте хотя бы одну неделю.");
|
||||
for (var weekIndex = 0; weekIndex < plan.Weeks.Count; weekIndex++)
|
||||
{
|
||||
var week = plan.Weeks[weekIndex];
|
||||
week.Position = weekIndex;
|
||||
for (var workoutIndex = 0; workoutIndex < week.Workouts.Count; workoutIndex++)
|
||||
{
|
||||
var workout = week.Workouts[workoutIndex];
|
||||
workout.Position = workoutIndex;
|
||||
RequireName(workout.Name, "Название тренировки");
|
||||
for (var blockIndex = 0; blockIndex < workout.Blocks.Count; blockIndex++)
|
||||
{
|
||||
var block = workout.Blocks[blockIndex];
|
||||
block.Position = blockIndex;
|
||||
ValidateBlock(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBlock(WorkoutBlock block)
|
||||
{
|
||||
if (block.RestMinutes < 0 || block.SetsCount < 1 || block.Exercises.Count != 1)
|
||||
throw new InvalidOperationException("Проверьте отдых и состав блока.");
|
||||
for (var index = 0; index < block.Exercises.Count; index++)
|
||||
{
|
||||
var exercise = block.Exercises[index];
|
||||
exercise.Position = index;
|
||||
RequireName(exercise.Name, "Название упражнения");
|
||||
ValidateVideoUrl(exercise.VideoUrl);
|
||||
RequireName(exercise.Repetitions, "Повторения");
|
||||
if (exercise.IntensityPercent is < 0 or > 100)
|
||||
throw new InvalidOperationException("Процент от максимума должен быть от 0 до 100.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequireName(string value, string field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException($"Заполните поле «{field}».");
|
||||
}
|
||||
|
||||
public static void ValidateVideoUrl(string value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
(!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)))
|
||||
throw new InvalidOperationException("Ссылка на видео должна начинаться с https:// или http://.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public class PlannerOptions
|
||||
{
|
||||
public string DefaultWorkoutName { get; set; } = string.Empty;
|
||||
public string DefaultWorkoutDuration { get; set; } = string.Empty;
|
||||
public decimal DefaultIntensityPercent { get; set; }
|
||||
public int DefaultWeekCount { get; set; }
|
||||
public int DefaultSetsCount { get; set; }
|
||||
public decimal DefaultRestMinutes { get; set; }
|
||||
public string DefaultRepetitions { get; set; } = string.Empty;
|
||||
public int UndoDeleteDurationSeconds { get; set; }
|
||||
public int AutosaveDelayMilliseconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TrainingPlanner.Domain\TrainingPlanner.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Application;
|
||||
|
||||
public static class WorkoutTotals
|
||||
{
|
||||
public static decimal RestMinutes(Workout workout)
|
||||
{
|
||||
return workout.Blocks.Sum(block => block.SetsCount * block.RestMinutes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class Client
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string TrainerId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
public string Goal { get; set; } = string.Empty;
|
||||
public string TrainingSchedule { get; set; } = string.Empty;
|
||||
public string HealthStatus { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class ExerciseGroup
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string TrainerId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class LibraryExercise
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string TrainerId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string VideoUrl { get; set; } = string.Empty;
|
||||
public int? GroupId { get; set; }
|
||||
public ExerciseGroup? Group { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class TrainingPlan
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ClientId { get; set; }
|
||||
public string TrainerId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
public int Version { get; set; }
|
||||
public List<TrainingWeek> Weeks { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class TrainingWeek
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TrainingPlanId { get; set; }
|
||||
public int Position { get; set; }
|
||||
public List<Workout> Workouts { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class Workout
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int TrainingWeekId { get; set; }
|
||||
public int Position { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Duration { get; set; } = string.Empty;
|
||||
public List<WorkoutBlock> Blocks { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class WorkoutBlock
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int WorkoutId { get; set; }
|
||||
public int Position { get; set; }
|
||||
public int SetsCount { get; set; }
|
||||
public decimal RestMinutes { get; set; }
|
||||
public List<WorkoutExercise> Exercises { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace TrainingPlanner.Domain;
|
||||
|
||||
public class WorkoutExercise
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int WorkoutBlockId { get; set; }
|
||||
public int Position { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Repetitions { get; set; } = string.Empty;
|
||||
public string Weight { get; set; } = string.Empty;
|
||||
public string WarmupSets { get; set; } = string.Empty;
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
public decimal? IntensityPercent { get; set; }
|
||||
public string VideoUrl { get; set; } = string.Empty;
|
||||
public bool RequiresVideo { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class ClientRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer)
|
||||
: IClientRepository
|
||||
{
|
||||
public async Task<List<Client>> GetAllAsync()
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.Clients.AsNoTracking().Where(client => client.TrainerId == trainerId)
|
||||
.OrderBy(client => client.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Client?> GetAsync(int id)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.Clients.AsNoTracking().SingleOrDefaultAsync(client => client.Id == id && client.TrainerId == trainerId);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var client = await db.Clients.SingleOrDefaultAsync(item => item.Id == id && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Клиент не найден.");
|
||||
|
||||
await db.TrainingPlans.Where(plan => plan.ClientId == id && plan.TrainerId == trainerId)
|
||||
.ExecuteDeleteAsync();
|
||||
db.Clients.Remove(client);
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(Client client)
|
||||
{
|
||||
PlanValidator.RequireName(client.Name, "Имя клиента");
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var entity = client.Id == 0 ? new Client { TrainerId = trainerId } :
|
||||
await db.Clients.SingleOrDefaultAsync(item => item.Id == client.Id && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Клиент не найден.");
|
||||
entity.Name = client.Name.Trim();
|
||||
entity.Notes = client.Notes;
|
||||
entity.Goal = client.Goal;
|
||||
entity.TrainingSchedule = client.TrainingSchedule;
|
||||
entity.HealthStatus = client.HealthStatus;
|
||||
if (entity.Id == 0) db.Clients.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
client.Id = entity.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class DatabaseInitializer(TrainingDbContext db, UserManager<IdentityUser> users, IConfiguration configuration)
|
||||
{
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
if (await users.Users.AnyAsync()) return;
|
||||
var name = configuration["BootstrapTrainer:UserName"];
|
||||
var password = configuration["BootstrapTrainer:Password"];
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(password))
|
||||
throw new InvalidOperationException("Для первого запуска задайте BootstrapTrainer__UserName и BootstrapTrainer__Password.");
|
||||
var result = await users.CreateAsync(new IdentityUser { UserName = name }, password);
|
||||
if (!result.Succeeded)
|
||||
throw new InvalidOperationException("Не удалось создать тренера: " + string.Join("; ", result.Errors.Select(error => error.Description)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class ExerciseGroupRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer)
|
||||
: IExerciseGroupRepository
|
||||
{
|
||||
public async Task<List<ExerciseGroup>> GetAllAsync()
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.ExerciseGroups.AsNoTracking().Where(group => group.TrainerId == trainerId)
|
||||
.OrderBy(group => group.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(ExerciseGroup group)
|
||||
{
|
||||
PlanValidator.RequireName(group.Name, "Название группы");
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var entity = group.Id == 0 ? new ExerciseGroup { TrainerId = trainerId } :
|
||||
await db.ExerciseGroups.SingleOrDefaultAsync(item => item.Id == group.Id && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Группа не найдена.");
|
||||
entity.Name = group.Name.Trim();
|
||||
if (entity.Id == 0) db.ExerciseGroups.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
group.Id = entity.Id;
|
||||
}
|
||||
|
||||
public async Task AddExerciseAsync(int groupId, int exerciseId)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var group = await db.ExerciseGroups.SingleOrDefaultAsync(item => item.Id == groupId && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Группа не найдена.");
|
||||
var exercise = await db.LibraryExercises
|
||||
.SingleOrDefaultAsync(item => item.Id == exerciseId && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Упражнение не найдено.");
|
||||
db.LibraryExercises.Add(new LibraryExercise
|
||||
{
|
||||
TrainerId = trainerId, GroupId = group.Id, Name = exercise.Name, VideoUrl = exercise.VideoUrl
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
// The database deletes this group's independent library exercises atomically.
|
||||
var deleted = await db.ExerciseGroups.Where(group => group.Id == id && group.TrainerId == trainerId)
|
||||
.ExecuteDeleteAsync();
|
||||
if (deleted == 0)
|
||||
throw new InvalidOperationException("Группа не найдена. Обновите список.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class ExerciseImporter(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer,
|
||||
ExerciseImportValidator validator) : IExerciseImporter
|
||||
{
|
||||
public async Task<int> ImportAsync(List<ExerciseImportRow> rows, int? groupId, string newGroupName)
|
||||
{
|
||||
var exercises = validator.Validate(rows);
|
||||
if (groupId.HasValue && !string.IsNullOrWhiteSpace(newGroupName))
|
||||
throw new InvalidOperationException("Выберите существующую группу или создайте новую.");
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var group = groupId.HasValue ? await db.ExerciseGroups.SingleOrDefaultAsync(item =>
|
||||
item.Id == groupId && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Группа не найдена. Откройте импорт заново.") : null;
|
||||
if (!string.IsNullOrWhiteSpace(newGroupName))
|
||||
{
|
||||
group = new ExerciseGroup { Name = newGroupName.Trim(), TrainerId = trainerId };
|
||||
db.ExerciseGroups.Add(group);
|
||||
}
|
||||
db.LibraryExercises.AddRange(exercises.Select(row => new LibraryExercise
|
||||
{
|
||||
Name = row.Name, VideoUrl = row.VideoUrl, TrainerId = trainerId, Group = group
|
||||
}));
|
||||
// One SaveChanges transaction includes the new group and every imported exercise.
|
||||
await db.SaveChangesAsync();
|
||||
return exercises.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class ExerciseRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer)
|
||||
: IExerciseRepository
|
||||
{
|
||||
public async Task<List<LibraryExercise>> GetAllAsync()
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.LibraryExercises.AsNoTracking().Include(item => item.Group).Where(item => item.TrainerId == trainerId)
|
||||
.OrderBy(item => item.Name).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(LibraryExercise exercise)
|
||||
{
|
||||
PlanValidator.RequireName(exercise.Name, "Название упражнения");
|
||||
PlanValidator.ValidateVideoUrl(exercise.VideoUrl);
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var entity = exercise.Id == 0 ? new LibraryExercise { TrainerId = trainerId } :
|
||||
await db.LibraryExercises.Include(item => item.Group).SingleOrDefaultAsync(item => item.Id == exercise.Id && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Упражнение не найдено.");
|
||||
if (exercise.GroupId.HasValue && !await db.ExerciseGroups.AnyAsync(group =>
|
||||
group.Id == exercise.GroupId && group.TrainerId == trainerId))
|
||||
throw new InvalidOperationException("Группа не найдена. Обновите список.");
|
||||
entity.Name = exercise.Name.Trim();
|
||||
entity.VideoUrl = exercise.VideoUrl.Trim();
|
||||
entity.GroupId = exercise.GroupId;
|
||||
if (entity.Id == 0) db.LibraryExercises.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
exercise.Id = entity.Id;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var deleted = await db.LibraryExercises
|
||||
.Where(exercise => exercise.Id == id && exercise.TrainerId == trainerId)
|
||||
.ExecuteDeleteAsync();
|
||||
if (deleted == 0)
|
||||
throw new InvalidOperationException("Упражнение не найдено. Обновите список.");
|
||||
}
|
||||
|
||||
public async Task MoveAsync(int id, int? groupId)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
if (groupId.HasValue && !await db.ExerciseGroups.AnyAsync(group => group.Id == groupId && group.TrainerId == trainerId))
|
||||
throw new InvalidOperationException("Группа не найдена. Обновите список.");
|
||||
var changed = await db.LibraryExercises.Where(exercise => exercise.Id == id && exercise.TrainerId == trainerId)
|
||||
.ExecuteUpdateAsync(update => update.SetProperty(exercise => exercise.GroupId, groupId));
|
||||
if (changed == 0)
|
||||
throw new InvalidOperationException("Упражнение не найдено. Обновите список.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class PlanReader(IDbContextFactory<TrainingDbContext> factory) : IPlanReader
|
||||
{
|
||||
public async Task<TrainingPlan?> GetAsync(int id, string trainerId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.TrainingPlans.AsNoTracking().AsSplitQuery()
|
||||
.Include(plan => plan.Weeks.OrderBy(week => week.Position))
|
||||
.ThenInclude(week => week.Workouts.OrderBy(workout => workout.Position))
|
||||
.ThenInclude(workout => workout.Blocks.OrderBy(block => block.Position))
|
||||
.ThenInclude(block => block.Exercises.OrderBy(exercise => exercise.Position))
|
||||
.SingleOrDefaultAsync(plan => plan.Id == id && plan.TrainerId == trainerId);
|
||||
}
|
||||
|
||||
public async Task<Client?> GetClientAsync(int id, string trainerId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.Clients.AsNoTracking()
|
||||
.SingleOrDefaultAsync(client => client.Id == id && client.TrainerId == trainerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class PlanRepository(IDbContextFactory<TrainingDbContext> factory, ITrainerContext trainer,
|
||||
PlanValidator validator, PlanCopier copier, IPlanReader reader) : IPlanRepository
|
||||
{
|
||||
public async Task<List<TrainingPlan>> GetForClientAsync(int clientId)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.TrainingPlans.AsNoTracking()
|
||||
.Where(plan => plan.ClientId == clientId && plan.TrainerId == trainerId)
|
||||
.OrderByDescending(plan => plan.CreatedAtUtc).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<TrainingPlan?> GetAsync(int id)
|
||||
{
|
||||
return await reader.GetAsync(id, await trainer.GetTrainerIdAsync());
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var deleted = await db.TrainingPlans
|
||||
.Where(plan => plan.Id == id && plan.TrainerId == trainerId).ExecuteDeleteAsync();
|
||||
if (deleted == 0) throw new InvalidOperationException("Программа не найдена.");
|
||||
}
|
||||
|
||||
public async Task SaveAsync(TrainingPlan plan)
|
||||
{
|
||||
validator.Validate(plan);
|
||||
var trainerId = await trainer.GetTrainerIdAsync();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
if (!await db.Clients.AnyAsync(client => client.Id == plan.ClientId && client.TrainerId == trainerId))
|
||||
throw new InvalidOperationException("Клиент не найден.");
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var stored = plan.Id == 0 ? new TrainingPlan
|
||||
{
|
||||
ClientId = plan.ClientId, TrainerId = trainerId, CreatedAtUtc = DateTime.UtcNow
|
||||
} : await db.TrainingPlans.SingleOrDefaultAsync(item => item.Id == plan.Id && item.TrainerId == trainerId)
|
||||
?? throw new InvalidOperationException("Программа не найдена.");
|
||||
if (stored.ClientId != plan.ClientId)
|
||||
throw new InvalidOperationException("Программа принадлежит другому клиенту.");
|
||||
if (stored.Version != plan.Version)
|
||||
throw new InvalidOperationException("Программа изменена в другой вкладке. Откройте её заново перед сохранением.");
|
||||
stored.Name = plan.Name.Trim();
|
||||
stored.UpdatedAtUtc = DateTime.UtcNow;
|
||||
stored.Version++;
|
||||
if (stored.Id == 0) db.TrainingPlans.Add(stored);
|
||||
try
|
||||
{
|
||||
// Check the root version before replacing its private child graph, in one transaction.
|
||||
await db.SaveChangesAsync();
|
||||
await db.Set<TrainingWeek>().Where(week => week.TrainingPlanId == stored.Id).ExecuteDeleteAsync();
|
||||
stored.Weeks = plan.Weeks.Select(copier.CopyWeek).ToList();
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new InvalidOperationException("Программа изменена в другой вкладке. Откройте её заново перед сохранением.");
|
||||
}
|
||||
plan.Id = stored.Id;
|
||||
plan.Version = stored.Version;
|
||||
plan.CreatedAtUtc = stored.CreatedAtUtc;
|
||||
plan.UpdatedAtUtc = stored.UpdatedAtUtc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
public class TrainingDbContext(DbContextOptions<TrainingDbContext> options)
|
||||
: IdentityDbContext<IdentityUser>(options)
|
||||
{
|
||||
public DbSet<Client> Clients { get; set; }
|
||||
public DbSet<LibraryExercise> LibraryExercises { get; set; }
|
||||
public DbSet<ExerciseGroup> ExerciseGroups { get; set; }
|
||||
public DbSet<TrainingPlan> TrainingPlans { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
builder.Entity<Client>().HasIndex(client => client.TrainerId);
|
||||
builder.Entity<Client>().HasOne<IdentityUser>().WithMany()
|
||||
.HasForeignKey(client => client.TrainerId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Entity<LibraryExercise>().HasOne<IdentityUser>().WithMany()
|
||||
.HasForeignKey(exercise => exercise.TrainerId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Entity<ExerciseGroup>().HasOne<IdentityUser>().WithMany()
|
||||
.HasForeignKey(group => group.TrainerId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Entity<LibraryExercise>().HasOne(exercise => exercise.Group).WithMany()
|
||||
.HasForeignKey(exercise => exercise.GroupId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Entity<TrainingPlan>().HasOne<Client>().WithMany()
|
||||
.HasForeignKey(plan => plan.ClientId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Entity<TrainingPlan>().HasOne<IdentityUser>().WithMany()
|
||||
.HasForeignKey(plan => plan.TrainerId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.Entity<TrainingPlan>().Property(plan => plan.Version).IsConcurrencyToken();
|
||||
builder.Entity<TrainingPlan>().HasMany(plan => plan.Weeks).WithOne()
|
||||
.HasForeignKey(week => week.TrainingPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Entity<TrainingWeek>().HasMany(week => week.Workouts).WithOne()
|
||||
.HasForeignKey(workout => workout.TrainingWeekId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Entity<Workout>().HasMany(workout => workout.Blocks).WithOne()
|
||||
.HasForeignKey(block => block.WorkoutId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Entity<WorkoutBlock>().HasMany(block => block.Exercises).WithOne()
|
||||
.HasForeignKey(exercise => exercise.WorkoutBlockId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TrainingPlanner.Infrastructure.Pdf;
|
||||
|
||||
internal static class CameraImage
|
||||
{
|
||||
public static string Data { get; set; } = Load();
|
||||
|
||||
private static string Load()
|
||||
{
|
||||
using var resource = typeof(CameraImage).Assembly.GetManifestResourceStream("TrainingPlanner.Pdf.Camera.png")
|
||||
?? throw new InvalidOperationException("Ресурс камеры для PDF не найден.");
|
||||
using var stream = new MemoryStream();
|
||||
resource.CopyTo(stream);
|
||||
return "base64:" + Convert.ToBase64String(stream.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Pdf;
|
||||
|
||||
public class PdfClientTableBuilder(PdfOptions options)
|
||||
{
|
||||
public void Add(Section section, Client client)
|
||||
{
|
||||
var table = section.AddTable();
|
||||
table.Borders.Color = Color.Parse(options.BorderColor);
|
||||
table.Borders.Width = Unit.FromPoint(0.5);
|
||||
table.TopPadding = table.BottomPadding = Unit.FromPoint(options.FontSize / 2);
|
||||
table.AddColumn(Unit.FromCentimeter(options.ClientLabelWidthCentimeters));
|
||||
table.AddColumn(Unit.FromCentimeter(options.ColumnWidthsCentimeters.Sum() - options.ClientLabelWidthCentimeters));
|
||||
var labels = new[] { "КЛИЕНТ:", "ЦЕЛЬ:", "РЕЖИМ ТРЕНИРОВОК:", "СОСТОЯНИЕ ЗДОРОВЬЯ:" };
|
||||
var values = new[] { client.Name, client.Goal, client.TrainingSchedule, client.HealthStatus };
|
||||
for (var index = 0; index < labels.Length; index++)
|
||||
{
|
||||
var row = table.AddRow();
|
||||
row.Cells[0].AddParagraph(labels[index]);
|
||||
row.Cells[1].AddParagraph(values[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace TrainingPlanner.Infrastructure.Pdf;
|
||||
|
||||
public class PdfOptions
|
||||
{
|
||||
public string FontPath { get; set; } = string.Empty;
|
||||
public string BoldFontPath { get; set; } = string.Empty;
|
||||
public string FontFamily { get; set; } = string.Empty;
|
||||
public double FontSize { get; set; }
|
||||
public double CameraSizePoints { get; set; }
|
||||
public double HeadingSize { get; set; }
|
||||
public double MarginCentimeters { get; set; }
|
||||
public double ClientLabelWidthCentimeters { get; set; }
|
||||
public int ClientDetailsGapLines { get; set; }
|
||||
public string AccentColor { get; set; } = string.Empty;
|
||||
public string HeaderBackground { get; set; } = string.Empty;
|
||||
public string WeekBackground { get; set; } = string.Empty;
|
||||
public string WorkoutBackground { get; set; } = string.Empty;
|
||||
public string BorderColor { get; set; } = string.Empty;
|
||||
public List<double> ColumnWidthsCentimeters { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Globalization;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.DocumentObjectModel.Tables;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Pdf;
|
||||
|
||||
public class PdfWeekTableBuilder(PdfOptions options)
|
||||
{
|
||||
public void Add(Section section, TrainingWeek week)
|
||||
{
|
||||
var table = section.AddTable();
|
||||
table.Borders.Color = Color.Parse(options.BorderColor);
|
||||
table.Borders.Width = Unit.FromPoint(0.5);
|
||||
table.TopPadding = table.BottomPadding = Unit.FromPoint(options.FontSize / 2);
|
||||
for (var index = 0; index < options.ColumnWidthsCentimeters.Count; index++)
|
||||
{
|
||||
var column = table.AddColumn(Unit.FromCentimeter(options.ColumnWidthsCentimeters[index]));
|
||||
if (index > 3)
|
||||
{
|
||||
column.Format.Alignment = ParagraphAlignment.Center;
|
||||
column.Format.SpaceAfter = Unit.FromPoint(0);
|
||||
}
|
||||
}
|
||||
AddTitle(table, $"Неделя {week.Position + 1}", options.WeekBackground, true);
|
||||
var header = table.AddRow();
|
||||
header.HeadingFormat = true;
|
||||
header.Shading.Color = Color.Parse(options.HeaderBackground);
|
||||
header.Format.Font.Bold = true;
|
||||
var labels = new[] { "№", "Название упражнения", "Разминочные подходы", "Примечание",
|
||||
"Кол-во подходов", "Кол-во повторений", "Ссылка на видео", "Вес, кг", "%",
|
||||
"Отдых, мин", "Съёмка", "Время тренировки" };
|
||||
for (var index = 0; index < labels.Length; index++)
|
||||
header.Cells[index].AddParagraph(labels[index]);
|
||||
foreach (var workout in week.Workouts)
|
||||
AddWorkout(table, workout);
|
||||
for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++)
|
||||
for (var index = 4; index < table.Columns.Count; index++)
|
||||
table.Rows[rowIndex].Cells[index].VerticalAlignment = VerticalAlignment.Center;
|
||||
}
|
||||
|
||||
private void AddWorkout(Table table, Workout workout)
|
||||
{
|
||||
AddTitle(table, workout.Name, options.WorkoutBackground, false);
|
||||
foreach (var block in workout.Blocks)
|
||||
AddExercise(table, block);
|
||||
if (workout.Blocks.Count == 0)
|
||||
{
|
||||
var empty = table.AddRow();
|
||||
empty.Cells[0].MergeRight = table.Columns.Count - 1;
|
||||
empty.Cells[0].AddParagraph("Упражнения не назначены.");
|
||||
}
|
||||
table.Rows[table.Rows.Count - 1].KeepWith = 1;
|
||||
var total = table.AddRow();
|
||||
total.Format.Font.Bold = true;
|
||||
total.Shading.Color = Color.Parse(options.HeaderBackground);
|
||||
total.Cells[0].MergeRight = 8;
|
||||
total.Cells[0].AddParagraph("Общее время");
|
||||
total.Cells[9].AddParagraph(Number(WorkoutTotals.RestMinutes(workout)));
|
||||
total.Cells[11].AddParagraph(workout.Duration);
|
||||
}
|
||||
|
||||
private static void AddTitle(Table table, string text, string color, bool repeat)
|
||||
{
|
||||
var row = table.AddRow();
|
||||
row.HeadingFormat = repeat;
|
||||
row.KeepWith = 1;
|
||||
row.Cells[0].MergeRight = table.Columns.Count - 1;
|
||||
row.Shading.Color = Color.Parse(color);
|
||||
row.Format.Font.Bold = true;
|
||||
row.Cells[0].AddParagraph(text);
|
||||
}
|
||||
|
||||
private void AddExercise(Table table, WorkoutBlock block)
|
||||
{
|
||||
var exercise = block.Exercises[0];
|
||||
var row = table.AddRow();
|
||||
row.Cells[0].AddParagraph((block.Position + 1).ToString(CultureInfo.InvariantCulture));
|
||||
row.Cells[1].AddParagraph(exercise.Name);
|
||||
row.Cells[2].AddParagraph(exercise.WarmupSets);
|
||||
row.Cells[3].AddParagraph(exercise.Notes);
|
||||
row.Cells[4].AddParagraph(block.SetsCount.ToString(CultureInfo.InvariantCulture));
|
||||
row.Cells[5].AddParagraph(exercise.Repetitions);
|
||||
if (!string.IsNullOrWhiteSpace(exercise.VideoUrl))
|
||||
{
|
||||
var link = row.Cells[6].AddParagraph().AddHyperlink(exercise.VideoUrl, HyperlinkType.Url);
|
||||
// Invisible break opportunities keep long URLs within the cell; the target stays unchanged.
|
||||
link.AddText(string.Join("\u200B", exercise.VideoUrl.Select(character => character.ToString())));
|
||||
link.Font.Color = Color.Parse(options.AccentColor);
|
||||
link.Font.Underline = Underline.Single;
|
||||
}
|
||||
row.Cells[7].AddParagraph(exercise.Weight);
|
||||
row.Cells[8].AddParagraph(exercise.IntensityPercent.HasValue ? Number(exercise.IntensityPercent.Value) : "");
|
||||
row.Cells[9].AddParagraph(Number(block.RestMinutes));
|
||||
if (exercise.RequiresVideo)
|
||||
{
|
||||
var cell = row.Cells[10];
|
||||
cell.VerticalAlignment = VerticalAlignment.Center;
|
||||
var paragraph = cell.AddParagraph();
|
||||
paragraph.Format.Alignment = ParagraphAlignment.Center;
|
||||
paragraph.Format.SpaceBefore = Unit.FromPoint(0);
|
||||
paragraph.Format.SpaceAfter = Unit.FromPoint(0);
|
||||
var image = paragraph.AddImage(CameraImage.Data);
|
||||
image.Width = image.Height = Unit.FromPoint(options.CameraSizePoints);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Number(decimal value) => value.ToString("0.##", CultureInfo.GetCultureInfo("ru-RU"));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.Rendering;
|
||||
using PdfSharp.Fonts;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Pdf;
|
||||
|
||||
public class PlanPdfExporter : IPlanPdfExporter
|
||||
{
|
||||
private readonly PdfOptions options;
|
||||
private readonly PdfWeekTableBuilder tables;
|
||||
|
||||
public PlanPdfExporter(PdfOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
tables = new PdfWeekTableBuilder(options);
|
||||
GlobalFontSettings.FontResolver = new PlannerFontResolver(options);
|
||||
}
|
||||
|
||||
public byte[] Export(TrainingPlan plan, Client client)
|
||||
{
|
||||
new PlanValidator().Validate(plan);
|
||||
var document = new Document();
|
||||
var normal = document.Styles[StyleNames.Normal]!;
|
||||
normal.Font.Name = options.FontFamily;
|
||||
normal.Font.Size = options.FontSize;
|
||||
normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(options.FontSize / 2);
|
||||
var firstWeek = true;
|
||||
foreach (var week in plan.Weeks)
|
||||
{
|
||||
var section = document.AddSection();
|
||||
section.PageSetup.PageFormat = PageFormat.A4;
|
||||
section.PageSetup.Orientation = Orientation.Landscape;
|
||||
section.PageSetup.TopMargin = section.PageSetup.BottomMargin = Unit.FromCentimeter(options.MarginCentimeters);
|
||||
section.PageSetup.LeftMargin = section.PageSetup.RightMargin = Unit.FromCentimeter(options.MarginCentimeters);
|
||||
if (firstWeek)
|
||||
{
|
||||
new PdfClientTableBuilder(options).Add(section, client);
|
||||
for (var line = 0; line < options.ClientDetailsGapLines; line++)
|
||||
{
|
||||
var spacer = section.AddParagraph();
|
||||
spacer.Format.SpaceBefore = Unit.FromPoint(0);
|
||||
spacer.Format.SpaceAfter = Unit.FromPoint(0);
|
||||
spacer.Format.LineSpacingRule = LineSpacingRule.Single;
|
||||
}
|
||||
firstWeek = false;
|
||||
}
|
||||
var footer = section.Footers.Primary.AddParagraph();
|
||||
footer.Format.Alignment = ParagraphAlignment.Right;
|
||||
footer.AddPageField();
|
||||
if (week.Workouts.Count == 0)
|
||||
section.AddParagraph($"Неделя {week.Position + 1}. Тренировки не назначены.");
|
||||
else
|
||||
tables.Add(section, week);
|
||||
}
|
||||
var renderer = new PdfDocumentRenderer { Document = document };
|
||||
renderer.RenderDocument();
|
||||
using var stream = new MemoryStream();
|
||||
renderer.PdfDocument.Save(stream, false);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using PdfSharp.Fonts;
|
||||
|
||||
namespace TrainingPlanner.Infrastructure.Pdf;
|
||||
|
||||
public class PlannerFontResolver(PdfOptions options) : IFontResolver
|
||||
{
|
||||
public FontResolverInfo ResolveTypeface(string familyName, bool bold, bool italic)
|
||||
=> new(bold ? options.BoldFontPath : options.FontPath);
|
||||
|
||||
public byte[] GetFont(string faceName) => File.ReadAllBytes(faceName);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TrainingPlanner.Application\TrainingPlanner.Application.csproj" />
|
||||
<EmbeddedResource Include="Pdf/Resources/camera.png" LogicalName="TrainingPlanner.Pdf.Camera.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["TrainingPlanner.Web.styles.css"]" />
|
||||
<link rel="icon" type="image/svg+xml" href="@Assets["favicon.svg"]" />
|
||||
<ImportMap />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["planner.js"]">
|
||||
</script>
|
||||
<script src="@Assets["week-drag.js"]"></script>
|
||||
<script src="@Assets["workout-drag.js"]"></script>
|
||||
<script src="@Assets["library-drag.js"]"></script>
|
||||
<script src="@Assets["exercise-picker.js"]"></script>
|
||||
<script src="@Assets["exercise-import.js"]"></script>
|
||||
<script src="@Assets["_framework/blazor.web.js"]">
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<article class="block-card">
|
||||
<header class="block-header">
|
||||
<div class="actions">
|
||||
<span class="drag-handle" draggable="true" @ondragstart="() => OnDrag.InvokeAsync()" title="Перетащить блок">⠿</span>
|
||||
<span class="block-number">@Number.ToString("00")</span>
|
||||
<strong>Упражнение</strong>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button title="Вверх" aria-label="Переместить вверх" disabled="@IsFirst" @onclick="() => OnMove.InvokeAsync(-1)">↑</button>
|
||||
<button title="Вниз" aria-label="Переместить вниз" disabled="@IsLast" @onclick="() => OnMove.InvokeAsync(1)">↓</button>
|
||||
<button @onclick="() => OnCopy.InvokeAsync()">Копия</button>
|
||||
<button class="danger-text" @onclick="() => OnDelete.InvokeAsync()">Удалить</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="exercise-columns"><ExerciseEditor Exercise="Block.Exercises[0]" OnChanged="OnChanged" /></div>
|
||||
<footer class="block-footer">
|
||||
<div class="actions">
|
||||
<label class="rest-input">Подходов
|
||||
<input type="number" min="1" step="1" @bind="Block.SetsCount" @bind:after="Changed" />
|
||||
</label>
|
||||
<label class="rest-input">Отдых, мин
|
||||
<input type="number" min="0" step="any" @bind="Block.RestMinutes" @bind:after="Changed" />
|
||||
</label>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
@code {
|
||||
[Parameter, EditorRequired] public WorkoutBlock Block { get; set; } = null!;
|
||||
[Parameter] public int Number { get; set; }
|
||||
[Parameter] public bool IsFirst { get; set; }
|
||||
[Parameter] public bool IsLast { get; set; }
|
||||
[Parameter] public EventCallback OnChanged { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMove { get; set; }
|
||||
[Parameter] public EventCallback OnCopy { get; set; }
|
||||
[Parameter] public EventCallback OnDelete { get; set; }
|
||||
[Parameter] public EventCallback OnDrag { get; set; }
|
||||
private Task Changed() => OnChanged.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="exercise-editor">
|
||||
<div class="exercise-heading">
|
||||
<label class="grow">
|
||||
Упражнение
|
||||
<input @bind="Exercise.Name" @bind:event="oninput" @bind:after="Changed" placeholder="Название упражнения" />
|
||||
</label>
|
||||
<label class="camera-toggle" title="Нужно снять видео">
|
||||
<input type="checkbox" @bind="Exercise.RequiresVideo" @bind:after="Changed" aria-label="Нужно снять видео" />
|
||||
<span>📷</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="video-input">
|
||||
Ссылка на видео
|
||||
<input type="url" @bind="Exercise.VideoUrl" @bind:event="oninput" @bind:after="Changed"
|
||||
placeholder="https://disk.yandex.ru/…" />
|
||||
</label>
|
||||
<div class="exercise-fields">
|
||||
<label>Повторения
|
||||
<input @bind="Exercise.Repetitions" @bind:event="oninput" @bind:after="Changed" placeholder="10 или 10 (1 RIR)" />
|
||||
</label>
|
||||
<label>Вес, кг
|
||||
<input @bind="Exercise.Weight" @bind:event="oninput" @bind:after="Changed" placeholder="60 или Подобрать" />
|
||||
</label>
|
||||
<label>От максимума, %
|
||||
<input type="number" min="0" max="100" step="any" @bind="Exercise.IntensityPercent"
|
||||
@bind:after="Changed" placeholder="—" />
|
||||
</label>
|
||||
</div>
|
||||
<label>Разминочные подходы
|
||||
<textarea rows="2" @bind="Exercise.WarmupSets" @bind:event="oninput" @bind:after="Changed"
|
||||
placeholder="1 — гриф; 2 — 40 кг"></textarea>
|
||||
</label>
|
||||
<label>Примечание
|
||||
<textarea rows="2" @bind="Exercise.Notes" @bind:event="oninput" @bind:after="Changed"
|
||||
placeholder="Техника, темп или другие указания"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public WorkoutExercise Exercise { get; set; } = null!;
|
||||
[Parameter] public EventCallback OnChanged { get; set; }
|
||||
|
||||
private Task Changed() => OnChanged.InvokeAsync();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="exercise-picker grow" @ref="root">
|
||||
@if (Groups.Count > 0)
|
||||
{
|
||||
<label>Группа упражнений
|
||||
<select aria-label="Группа упражнений" @bind="groupId" @bind:after="SearchChangedAsync">
|
||||
<option value="-1">Все группы</option>
|
||||
<option value="0">Без группы</option>
|
||||
@foreach (var group in Groups)
|
||||
{
|
||||
<option value="@group.Id">@group.Name</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<label for="@Id">Из библиотеки</label>
|
||||
<div class="exercise-combobox">
|
||||
<input id="@Id" type="search" role="combobox" aria-label="Поиск упражнения"
|
||||
aria-autocomplete="list" aria-haspopup="listbox" aria-expanded="false"
|
||||
aria-controls="@(Id + "-results")" aria-activedescendant="@ActiveOptionId"
|
||||
@bind="search" @bind:event="oninput" @bind:after="SearchChangedAsync"
|
||||
@onfocus="OpenAsync" @onclick="OpenAsync" @onkeydown="HandleKeyAsync" disabled="@applying"
|
||||
placeholder="Выберите или найдите упражнение…" autocomplete="off" />
|
||||
<button type="button" class="picker-toggle" aria-label="Раскрыть список упражнений"
|
||||
popovertarget="@(Id + "-results")" disabled="@applying">⌄</button>
|
||||
</div>
|
||||
<div id="@(Id + "-results")" class="exercise-dropdown" popover="auto" role="listbox" aria-label="Упражнения из библиотеки">
|
||||
@for (var index = 0; index < Matches.Count; index++)
|
||||
{
|
||||
var exercise = Matches[index];
|
||||
var optionIndex = index;
|
||||
<button @key="exercise.Id" id="@(Id + "-option-" + exercise.Id)" type="button" role="option" tabindex="-1"
|
||||
class="exercise-option @(activeIndex == index ? "active" : "")"
|
||||
aria-selected="@(Value == exercise.Id ? "true" : "false")"
|
||||
@onmousedown:preventDefault @onclick="() => SelectAsync(exercise.Id, optionIndex)"
|
||||
@ondblclick="() => ApplyAsync(exercise.Id)">
|
||||
<strong>@exercise.Name</strong>
|
||||
<span>@(exercise.Group?.Name ?? "Без группы")</span>
|
||||
</button>
|
||||
}
|
||||
@if (Matches.Count == 0)
|
||||
{
|
||||
<p class="muted" role="status">Упражнения не найдены. Измените запрос.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="picker-hint">Двойной клик или Enter — добавить</p>
|
||||
}
|
||||
</div>
|
||||
@if (SelectedExercise != null)
|
||||
{
|
||||
<p class="picker-selection" role="status">Выбрано: @SelectedExercise.Name</p>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Web.Components.Editor;
|
||||
|
||||
public partial class ExercisePicker
|
||||
{
|
||||
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
|
||||
[Parameter, EditorRequired] public string Id { get; set; } = string.Empty;
|
||||
[Parameter] public List<LibraryExercise> Exercises { get; set; } = [];
|
||||
[Parameter] public int Value { get; set; }
|
||||
[Parameter] public int ResetVersion { get; set; }
|
||||
[Parameter] public EventCallback<int> ValueChanged { get; set; }
|
||||
[Parameter] public EventCallback<int> OnApply { get; set; }
|
||||
private ElementReference root;
|
||||
private string search = string.Empty;
|
||||
private int groupId = -1;
|
||||
private int activeIndex = -1;
|
||||
private bool applying;
|
||||
private int lastResetVersion;
|
||||
private bool closeAfterReset;
|
||||
private List<LibraryExercise> Matches = [];
|
||||
private LibraryExercise? SelectedExercise => Exercises.SingleOrDefault(exercise => exercise.Id == Value);
|
||||
private string? ActiveOptionId => activeIndex >= 0 && activeIndex < Matches.Count
|
||||
? Id + "-option-" + Matches[activeIndex].Id : null;
|
||||
private List<ExerciseGroup> Groups => Exercises.Where(exercise => exercise.Group != null).Select(exercise => exercise.Group!)
|
||||
.DistinctBy(group => group.Id).OrderBy(group => group.Name).ToList();
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (lastResetVersion != ResetVersion)
|
||||
{
|
||||
lastResetVersion = ResetVersion;
|
||||
search = string.Empty;
|
||||
activeIndex = -1;
|
||||
closeAfterReset = true;
|
||||
}
|
||||
if (groupId > 0 && Groups.All(group => group.Id != groupId)) groupId = -1;
|
||||
UpdateMatches();
|
||||
}
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (closeAfterReset)
|
||||
{
|
||||
closeAfterReset = false;
|
||||
await JavaScript.InvokeVoidAsync("trainingPlanner.closeExercisePicker", root);
|
||||
}
|
||||
await JavaScript.InvokeVoidAsync("trainingPlanner.positionExercisePicker", root);
|
||||
}
|
||||
|
||||
private void UpdateMatches()
|
||||
{
|
||||
Matches = ExerciseSearch.Find(Exercises.Where(exercise => groupId < 0 ||
|
||||
(groupId == 0 ? exercise.GroupId == null : exercise.GroupId == groupId)), search);
|
||||
if (activeIndex >= Matches.Count) activeIndex = -1;
|
||||
}
|
||||
private async Task SearchChangedAsync()
|
||||
{
|
||||
activeIndex = -1;
|
||||
UpdateMatches();
|
||||
await ValueChanged.InvokeAsync(0);
|
||||
await OpenAsync();
|
||||
}
|
||||
private async Task OpenAsync() => await JavaScript.InvokeVoidAsync("trainingPlanner.openExercisePicker", root);
|
||||
private async Task SelectAsync(int id, int index)
|
||||
{
|
||||
activeIndex = index;
|
||||
await ValueChanged.InvokeAsync(id);
|
||||
}
|
||||
private async Task ApplyAsync(int id)
|
||||
{
|
||||
if (applying || Matches.All(exercise => exercise.Id != id)) return;
|
||||
applying = true;
|
||||
try
|
||||
{
|
||||
await JavaScript.InvokeVoidAsync("trainingPlanner.closeExercisePicker", root);
|
||||
await OnApply.InvokeAsync(id);
|
||||
}
|
||||
finally { applying = false; }
|
||||
}
|
||||
private async Task HandleKeyAsync(KeyboardEventArgs args)
|
||||
{
|
||||
if (applying) return;
|
||||
if (args.Key is "ArrowDown" or "ArrowUp")
|
||||
{
|
||||
await OpenAsync();
|
||||
if (Matches.Count == 0) return;
|
||||
activeIndex = args.Key == "ArrowDown" ? Math.Min(activeIndex + 1, Matches.Count - 1)
|
||||
: activeIndex < 0 ? Matches.Count - 1 : Math.Max(activeIndex - 1, 0);
|
||||
}
|
||||
else if (args.Key == "Enter" && await JavaScript.InvokeAsync<bool>("trainingPlanner.isExercisePickerOpen", root))
|
||||
{
|
||||
var index = activeIndex < 0 ? 0 : activeIndex;
|
||||
if (index < Matches.Count) await ApplyAsync(Matches[index].Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@inherits LayoutComponentBase
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">TP</span>
|
||||
<span>Training<br/>
|
||||
<strong>Planner</strong>
|
||||
</span>
|
||||
</a>
|
||||
<div class="nav-label">РАБОЧЕЕ ПРОСТРАНСТВО</div>
|
||||
<nav>
|
||||
<NavLink href="/" Match="NavLinkMatch.All">◎ <span>Клиенты</span>
|
||||
</NavLink>
|
||||
<NavLink href="/exercises">▤ <span>Библиотека упражнений</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div class="sidebar-bottom">
|
||||
<span class="muted">Кабинет тренера</span>
|
||||
<form method="post" action="/Logout">
|
||||
<AntiforgeryToken/>
|
||||
<button class="text-button">Выйти ↗</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main-content">@Body</main>
|
||||
</div>
|
||||
<div id="blazor-error-ui" data-nosnippet>Соединение прервано или произошла ошибка. <a href=".">Перезагрузить</a>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]">
|
||||
</script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Восстанавливаем соединение…
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Следующая попытка через <span id="components-seconds-to-next-attempt">
|
||||
</span> сек.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Не удалось восстановить соединение.<br />Попробуйте ещё раз или обновите страницу.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Повторить
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
Сеанс приостановлен.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Не удалось продолжить сеанс.<br />Попробуйте ещё раз или обновите страницу.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@inherits UiPageBase
|
||||
@inject IExerciseGroupRepository Repository
|
||||
@inject IJSRuntime JavaScript
|
||||
|
||||
<section class="panel compact group-panel">
|
||||
<fieldset class="editor-fieldset" disabled="@(Busy || Disabled || !RendererInfo.IsInteractive)">
|
||||
<div class="list-toolbar">
|
||||
<h2>Группы упражнений</h2>
|
||||
<button type="button" @onclick="NewGroup">+ Новая группа</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
|
||||
<div class="group-tabs" aria-label="Группы упражнений">
|
||||
<button class="@(Value == -1 ? "selected" : "")" @onclick="() => ValueChanged.InvokeAsync(-1)">Все упражнения</button>
|
||||
<button class="@(Value == 0 ? "selected" : "")" data-exercise-group="0" @ondragover:preventDefault @ondrop:preventDefault
|
||||
@ondrop="() => DropExerciseAsync(0)" @onclick="() => ValueChanged.InvokeAsync(0)">Без группы</button>
|
||||
@foreach (var group in Groups)
|
||||
{
|
||||
<button @key="group.Id" class="@(Value == group.Id ? "selected" : "")"
|
||||
data-exercise-group="@group.Id" @ondragover:preventDefault @ondrop:preventDefault
|
||||
@ondrop="() => DropExerciseAsync(group.Id)"
|
||||
@onclick="() => ValueChanged.InvokeAsync(group.Id)">@group.Name</button>
|
||||
}
|
||||
</div>
|
||||
@if (SelectedGroup != null)
|
||||
{
|
||||
<div class="actions group-actions">
|
||||
<button @onclick="() => editing = new ExerciseGroup { Id = SelectedGroup.Id, Name = SelectedGroup.Name }">Переименовать группу</button>
|
||||
<button class="danger-text" disabled="@(Busy || Disabled || !RendererInfo.IsInteractive)" @onclick="DeleteAsync">Удалить группу</button>
|
||||
</div>
|
||||
}
|
||||
@if (editing != null)
|
||||
{
|
||||
<form class="group-form" @onsubmit="SaveAsync" @onsubmit:preventDefault>
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
|
||||
<label>Название группы<input @bind="editing.Name" required /></label>
|
||||
<div class="actions">
|
||||
<button class="primary">Сохранить группу</button>
|
||||
<button type="button" @onclick="() => editing = null">Отмена</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<ExerciseGroup> Groups { get; set; } = [];
|
||||
[Parameter] public int Value { get; set; }
|
||||
[Parameter] public EventCallback<int> ValueChanged { get; set; }
|
||||
[Parameter] public EventCallback OnChanged { get; set; }
|
||||
[Parameter] public EventCallback<int> OnDropExercise { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
private ExerciseGroup? editing;
|
||||
private ExerciseGroup? SelectedGroup => Groups.SingleOrDefault(group => group.Id == Value);
|
||||
private void NewGroup() => editing = new();
|
||||
private Task DropExerciseAsync(int id) => Busy || Disabled ? Task.CompletedTask : OnDropExercise.InvokeAsync(id);
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
await Repository.SaveAsync(editing!);
|
||||
var id = editing!.Id;
|
||||
editing = null;
|
||||
await OnChanged.InvokeAsync();
|
||||
await ValueChanged.InvokeAsync(id);
|
||||
});
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
var group = SelectedGroup;
|
||||
if (group == null || Busy || Disabled) return;
|
||||
if (!await JavaScript.InvokeAsync<bool>("confirm",
|
||||
$"Удалить группу «{group.Name}» вместе со всеми её упражнениями? Составленные тренировки сохранятся.")) return;
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await Repository.DeleteAsync(group.Id);
|
||||
editing = null;
|
||||
await OnChanged.InvokeAsync();
|
||||
await ValueChanged.InvokeAsync(-1);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@inherits UiPageBase
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<dialog @ref="dialog" class="exercise-import" aria-labelledby="import-title" @oncancel:preventDefault @oncancel="CloseAsync">
|
||||
<div class="list-toolbar">
|
||||
<h2 id="import-title">Импорт упражнений</h2>
|
||||
<button type="button" class="icon-button" aria-label="Закрыть импорт" disabled="@Busy" @onclick="CloseAsync">×</button>
|
||||
</div>
|
||||
<p class="muted">Скопируйте из Excel два столбца без заголовков и вставьте в первую ячейку.
|
||||
Строки добавятся автоматически. Ссылка необязательна; одинаковые упражнения создаются как отдельные записи.</p>
|
||||
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
|
||||
<p class="error clipboard-error" role="alert" hidden></p>
|
||||
<fieldset disabled="@Busy">
|
||||
<label>Группа для импорта
|
||||
<select @bind="groupChoice">
|
||||
<option value="">Без группы</option>
|
||||
@foreach (var group in Groups) { <option value="@group.Id">@group.Name</option> }
|
||||
<option value="new">Создать новую группу</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (groupChoice == "new")
|
||||
{
|
||||
<label>Название новой группы<input @bind="newGroupName" @bind:event="oninput" /></label>
|
||||
}
|
||||
<div class="import-table-scroll">
|
||||
<table class="import-table">
|
||||
<thead><tr><th>Наименование упражнения</th><th>Ссылка на видео</th><th><span class="muted">Удалить</span></th></tr></thead>
|
||||
<tbody>
|
||||
@for (var index = 0; index < rows.Count; index++)
|
||||
{
|
||||
var rowIndex = index;
|
||||
var row = rows[index];
|
||||
<tr @key="row">
|
||||
<td><input aria-label="@($"Название, строка {index + 1}")" data-row="@index" data-column="0" @bind="row.Name" @bind:event="oninput" /></td>
|
||||
<td><input aria-label="@($"Видео, строка {index + 1}")" data-row="@index" data-column="1" @bind="row.VideoUrl" @bind:event="oninput" /></td>
|
||||
<td><button type="button" class="icon-button danger-text" aria-label="@($"Удалить строку {index + 1}")" @onclick="() => RemoveRow(rowIndex)">×</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="list-toolbar">
|
||||
<button type="button" disabled="@(rows.Count >= Options.MaxRows)" @onclick="() => rows.Add(new())">Добавить строку</button>
|
||||
<span class="muted">Упражнений: @FilledCount · максимум @Options.MaxRows</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" disabled="@(FilledCount == 0)" @onclick="ImportAsync">Подтвердить импорт</button>
|
||||
<button type="button" @onclick="CloseAsync">Отмена</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</dialog>
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Web.Components.Library;
|
||||
|
||||
public partial class ExerciseImport
|
||||
{
|
||||
[Inject] public IExerciseImporter Importer { get; set; } = null!;
|
||||
[Inject] public ExerciseImportOptions Options { get; set; } = null!;
|
||||
[Inject] public ClipboardTableParser Parser { get; set; } = null!;
|
||||
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
|
||||
[Parameter] public List<ExerciseGroup> Groups { get; set; } = [];
|
||||
[Parameter] public int? InitialGroupId { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback<int> OnImported { get; set; }
|
||||
private ElementReference dialog;
|
||||
private DotNetObjectReference<ExerciseImport>? reference;
|
||||
private List<ExerciseImportRow> rows = [new()];
|
||||
private string groupChoice = string.Empty;
|
||||
private string newGroupName = string.Empty;
|
||||
private int FilledCount => rows.Count(row => !string.IsNullOrWhiteSpace(row.Name) || !string.IsNullOrWhiteSpace(row.VideoUrl));
|
||||
|
||||
protected override void OnInitialized() => groupChoice = InitialGroupId?.ToString() ?? string.Empty;
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
reference = DotNetObjectReference.Create(this);
|
||||
await JavaScript.InvokeVoidAsync("exerciseImport.open", dialog, reference, Options.MaxClipboardCharacters);
|
||||
}
|
||||
[JSInvokable]
|
||||
public async Task PasteAsync(string text, int rowIndex, int columnIndex)
|
||||
{
|
||||
await RunAsync(() =>
|
||||
{
|
||||
var pasted = Parser.Parse(text);
|
||||
if (rowIndex < 0 || rowIndex >= rows.Count || columnIndex < 0 || columnIndex > 1)
|
||||
throw new InvalidOperationException("Выберите ячейку для вставки.");
|
||||
if (pasted.Any(row => row.Count + columnIndex > 2))
|
||||
throw new InvalidOperationException("В таблице два столбца. Вставьте название и ссылку начиная с первого столбца.");
|
||||
if (rowIndex + pasted.Count > Options.MaxRows)
|
||||
throw new InvalidOperationException($"В таблице может быть не более {Options.MaxRows} строк.");
|
||||
while (rows.Count < rowIndex + pasted.Count) rows.Add(new());
|
||||
for (var index = 0; index < pasted.Count; index++)
|
||||
{
|
||||
var cells = pasted[index];
|
||||
var row = rows[rowIndex + index];
|
||||
if (columnIndex == 0) row.Name = cells[0];
|
||||
else row.VideoUrl = cells[0];
|
||||
if (cells.Count == 2) row.VideoUrl = cells[1];
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
private void RemoveRow(int index)
|
||||
{
|
||||
rows.RemoveAt(index);
|
||||
if (rows.Count == 0) rows.Add(new());
|
||||
}
|
||||
private Task CloseAsync() => Busy ? Task.CompletedTask : OnClose.InvokeAsync();
|
||||
private Task ImportAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (groupChoice == "new") PlanValidator.RequireName(newGroupName, "Название новой группы");
|
||||
var groupId = int.TryParse(groupChoice, out var id) ? id : (int?)null;
|
||||
var count = await Importer.ImportAsync(rows, groupId, groupChoice == "new" ? newGroupName : string.Empty);
|
||||
await OnImported.InvokeAsync(count);
|
||||
});
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try { await JavaScript.InvokeVoidAsync("exerciseImport.close", dialog); }
|
||||
catch (JSDisconnectedException) { }
|
||||
finally { reference?.Dispose(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
@page "/clients/{Id:int}"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inherits UiPageBase
|
||||
@inject IClientRepository Clients
|
||||
@inject IPlanRepository Plans
|
||||
@inject PlanFactory Factory
|
||||
@inject PlanCopier Copier
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JavaScript
|
||||
<PageTitle>Программы клиента · Training Planner</PageTitle>
|
||||
<a class="breadcrumb" href="/">← Все клиенты</a>
|
||||
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
|
||||
@if (client != null)
|
||||
{
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">КАРТОЧКА КЛИЕНТА</p>
|
||||
<h1>@client.Name</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="count-badge">Программ: @plans.Count</span>
|
||||
<button type="button" class="icon-button danger-text" title="Удалить клиента" aria-label="Удалить клиента"
|
||||
disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="DeleteAsync">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<details class="panel compact">
|
||||
<summary>Данные клиента</summary>
|
||||
<form @onsubmit="SaveClientAsync" @onsubmit:preventDefault>
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
|
||||
<label>Имя<input @bind="client.Name" required />
|
||||
</label>
|
||||
<label>Цель<textarea @bind="client.Goal" /></label>
|
||||
<label>Режим тренировок<textarea @bind="client.TrainingSchedule" /></label>
|
||||
<label>Состояние здоровья<textarea @bind="client.HealthStatus" /></label>
|
||||
<label>Заметки<textarea @bind="client.Notes" /></label>
|
||||
<button disabled="@Busy">Сохранить данные</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</details>
|
||||
<section class="panel compact">
|
||||
<h2>Программа на месяц</h2>
|
||||
<form @onsubmit="CreateAsync" @onsubmit:preventDefault>
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
|
||||
<div class="inline-form">
|
||||
<label class="grow">Название программы<input @bind="newName" placeholder="Например, октябрь · набор массы" required />
|
||||
</label>
|
||||
<button class="primary" disabled="@Busy">Создать программу</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
<h2 class="section-title">История программ</h2>
|
||||
@if (plans.Count == 0) { <section class="empty-state">
|
||||
<h2>Пока нет программ</h2>
|
||||
<p class="muted">Создайте первую программу, а в следующем месяце используйте её как основу.</p>
|
||||
</section> }
|
||||
@foreach (var plan in plans)
|
||||
{
|
||||
<div class="program-row">
|
||||
<div>
|
||||
<a class="program-title" href="@($"/programs/{plan.Id}")">@plan.Name</a>
|
||||
<p class="muted">Создана @plan.CreatedAtUtc.ToLocalTime().ToString("dd.MM.yyyy")</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button disabled="@Busy" @onclick="() => BeginCopy(plan)">Создать на основе</button>
|
||||
<a class="button" href="@($"/programs/{plan.Id}")">Открыть →</a>
|
||||
<button type="button" class="icon-button danger-text" title="Удалить программу" aria-label="@($"Удалить программу «{plan.Name}»")"
|
||||
disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => DeletePlanAsync(plan)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (copySource != null)
|
||||
{
|
||||
<section class="panel compact">
|
||||
<h2>Копия: @copySource.Name</h2>
|
||||
<form @onsubmit="CopyAsync" @onsubmit:preventDefault>
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
|
||||
<label>Новое название<input @bind="copyName" required />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="primary" disabled="@Busy">Создать независимую копию</button>
|
||||
<button type="button" @onclick="() => copySource = null">Отмена</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
private Client? client;
|
||||
private List<TrainingPlan> plans = [];
|
||||
private string newName = string.Empty;
|
||||
private string copyName = string.Empty;
|
||||
private TrainingPlan? copySource;
|
||||
protected override Task OnParametersSetAsync() => RunAsync(async () =>
|
||||
{
|
||||
client = await Clients.GetAsync(Id);
|
||||
if (client == null) throw new InvalidOperationException("Клиент не найден.");
|
||||
plans = await Plans.GetForClientAsync(Id);
|
||||
});
|
||||
private Task SaveClientAsync() => RunAsync(() => Clients.SaveAsync(client!));
|
||||
private Task DeleteAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (!await JavaScript.InvokeAsync<bool>("confirm",
|
||||
$"Удалить клиента «{client!.Name}» и все его программы тренировок? Это действие нельзя отменить.")) return;
|
||||
await Clients.DeleteAsync(Id);
|
||||
Navigation.NavigateTo("/");
|
||||
});
|
||||
private Task CreateAsync() => RunAsync(async () =>
|
||||
{
|
||||
var plan = Factory.Create(Id, newName);
|
||||
await Plans.SaveAsync(plan);
|
||||
Navigation.NavigateTo($"/programs/{plan.Id}");
|
||||
});
|
||||
private void BeginCopy(TrainingPlan plan) { copySource = plan; copyName = plan.Name; }
|
||||
private Task DeletePlanAsync(TrainingPlan plan) => RunAsync(async () =>
|
||||
{
|
||||
if (!await JavaScript.InvokeAsync<bool>("confirm",
|
||||
$"Удалить программу «{plan.Name}» со всеми её неделями и тренировками? Это действие нельзя отменить.")) return;
|
||||
await Plans.DeleteAsync(plan.Id);
|
||||
plans.Remove(plan);
|
||||
if (copySource?.Id == plan.Id) copySource = null;
|
||||
});
|
||||
private Task CopyAsync() => RunAsync(async () =>
|
||||
{
|
||||
var original = await Plans.GetAsync(copySource!.Id) ?? throw new InvalidOperationException("Программа не найдена.");
|
||||
var copy = Copier.Copy(original, copyName);
|
||||
await Plans.SaveAsync(copy);
|
||||
Navigation.NavigateTo($"/programs/{copy.Id}");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/Error"
|
||||
<PageTitle>Ошибка · Training Planner</PageTitle>
|
||||
<h1>Не удалось открыть страницу</h1>
|
||||
<p class="muted">Попробуйте обновить страницу. Если ошибка повторяется, вернитесь к списку клиентов.</p>
|
||||
<a class="button" href="/">К клиентам</a>
|
||||
@@ -0,0 +1,81 @@
|
||||
@page "/exercises"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inherits UiPageBase
|
||||
<PageTitle>Библиотека упражнений · Training Planner</PageTitle>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">БИБЛИОТЕКА</p>
|
||||
<h1>Упражнения</h1>
|
||||
<p class="muted">Название и ссылка на видео — для быстрого добавления в программу.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="count-badge">Упражнений: @items.Count</span>
|
||||
<button disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => importOpen = true">Импорт из таблицы</button>
|
||||
</div>
|
||||
</header>
|
||||
@if (importOpen)
|
||||
{
|
||||
<ExerciseImport Groups="groups" InitialGroupId="@(selectedGroupId > 0 ? selectedGroupId : null)"
|
||||
OnClose="() => importOpen = false" OnImported="ImportedAsync" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(importMessage)) { <p role="status">@importMessage</p> }
|
||||
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
|
||||
<ExerciseGroups Groups="groups" Value="selectedGroupId" ValueChanged="SelectGroup" OnChanged="ReloadAsync"
|
||||
OnDropExercise="MoveToGroupAsync" Disabled="Busy" />
|
||||
<p class="muted">Перетащите упражнение на название группы или на «Без группы», чтобы перенести его.</p>
|
||||
<section class="panel compact">
|
||||
<h2>@(editing.Id == 0 ? "Новое упражнение" : "Редактирование упражнения")</h2>
|
||||
<form @onsubmit="SaveAsync" @onsubmit:preventDefault>
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
|
||||
<div class="form-grid">
|
||||
<label>Название<input @bind="editing.Name" required />
|
||||
</label>
|
||||
<label>Ссылка на видео<input type="url" @bind="editing.VideoUrl" placeholder="https://disk.yandex.ru/…" />
|
||||
</label>
|
||||
</div>
|
||||
<label>Группа
|
||||
<select @bind="editing.GroupId">
|
||||
<option value="">Без группы</option>
|
||||
@foreach (var group in groups) { <option value="@group.Id">@group.Name</option> }
|
||||
</select>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="primary" disabled="@Busy">Сохранить упражнение</button>@if (editing.Id != 0) { <button type="button" @onclick="ResetEditor">Отмена</button> }</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
<div class="list-toolbar">
|
||||
<h2>@ListTitle</h2>
|
||||
<input class="search" @bind="search" @bind:event="oninput" placeholder="Найти упражнение…" aria-label="Поиск упражнений" />
|
||||
</div>
|
||||
@if (selectedGroupId > 0)
|
||||
{
|
||||
<details class="panel compact">
|
||||
<summary>Добавить копию упражнения из библиотеки</summary>
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)" class="add-exercise-bar">
|
||||
<ExercisePicker ResetVersion="pickerResetVersion" Id="group-exercise-picker" @key="selectedGroupId" Exercises="AvailableExercises" @bind-Value="existingExerciseId" OnApply="ApplyExistingAsync" />
|
||||
<button disabled="@(existingExerciseId == 0)" @onclick="AddExistingAsync">Добавить копию в группу</button>
|
||||
</fieldset>
|
||||
</details>
|
||||
}
|
||||
@foreach (var exercise in FilteredExercises)
|
||||
{
|
||||
<div class="program-row library-exercise" @key="exercise.Id" draggable="@(!Busy && RendererInfo.IsInteractive ? "true" : "false")"
|
||||
@ondragstart="() => draggedExerciseId = exercise.Id" @ondragend="() => draggedExerciseId = null">
|
||||
<div><strong>@exercise.Name</strong><p class="muted">@(exercise.Group?.Name ?? "Без группы")</p></div>
|
||||
<div class="actions">@if (!string.IsNullOrEmpty(exercise.VideoUrl)) { <a href="@exercise.VideoUrl" target="_blank" rel="noopener noreferrer">Видео ↗</a> }<button disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => Edit(exercise)">Изменить</button>
|
||||
<button class="icon-button danger-text" title="Удалить из библиотеки" aria-label="@($"Удалить упражнение {exercise.Name}")"
|
||||
disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="() => DeleteAsync(exercise)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (FilteredExercises.Count == 0)
|
||||
{
|
||||
<p class="muted">В этом списке пока нет упражнений, подходящих под запрос.</p>
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Web.Components.Pages;
|
||||
|
||||
public partial class Exercises
|
||||
{
|
||||
[Inject] public IExerciseRepository ExerciseLibrary { get; set; } = null!;
|
||||
[Inject] public IExerciseGroupRepository GroupRepository { get; set; } = null!;
|
||||
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
|
||||
private List<LibraryExercise> items = [];
|
||||
private List<ExerciseGroup> groups = [];
|
||||
private LibraryExercise editing = new();
|
||||
private string search = string.Empty;
|
||||
private int selectedGroupId = -1;
|
||||
private int existingExerciseId;
|
||||
private int pickerResetVersion;
|
||||
private int? draggedExerciseId;
|
||||
private bool importOpen;
|
||||
private string importMessage = string.Empty;
|
||||
private Task ImportedAsync(int count)
|
||||
{
|
||||
importOpen = false;
|
||||
importMessage = $"Импортировано упражнений: {count}.";
|
||||
SelectGroup(-1);
|
||||
return RunAsync(ReloadAsync);
|
||||
}
|
||||
private List<LibraryExercise> AvailableExercises => items.Where(item => item.GroupId != selectedGroupId).ToList();
|
||||
private string ListTitle => selectedGroupId < 0 ? "Все упражнения" : selectedGroupId == 0 ? "Без группы" :
|
||||
groups.SingleOrDefault(group => group.Id == selectedGroupId)?.Name ?? "Группа";
|
||||
private List<LibraryExercise> FilteredExercises => ExerciseSearch.Find(items.Where(item =>
|
||||
selectedGroupId < 0 || (selectedGroupId == 0 ? item.GroupId == null : item.GroupId == selectedGroupId)), search);
|
||||
|
||||
protected override Task OnInitializedAsync() => RunAsync(ReloadAsync);
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
items = await ExerciseLibrary.GetAllAsync();
|
||||
groups = await GroupRepository.GetAllAsync();
|
||||
if (selectedGroupId > 0 && groups.All(group => group.Id != selectedGroupId)) selectedGroupId = -1;
|
||||
if (editing.Id != 0 && items.All(item => item.Id != editing.Id)) ResetEditor();
|
||||
if (editing.GroupId.HasValue && groups.All(group => group.Id != editing.GroupId)) editing.GroupId = null;
|
||||
}
|
||||
private void SelectGroup(int id)
|
||||
{
|
||||
selectedGroupId = id;
|
||||
existingExerciseId = 0;
|
||||
search = string.Empty;
|
||||
if (editing.Id == 0) ResetEditor();
|
||||
}
|
||||
private void ResetEditor() => editing = new LibraryExercise
|
||||
{
|
||||
GroupId = selectedGroupId > 0 ? selectedGroupId : null
|
||||
};
|
||||
private void Edit(LibraryExercise exercise) => editing = new LibraryExercise
|
||||
{
|
||||
Id = exercise.Id, Name = exercise.Name, VideoUrl = exercise.VideoUrl, GroupId = exercise.GroupId
|
||||
};
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
await ExerciseLibrary.SaveAsync(editing);
|
||||
await ReloadAsync();
|
||||
ResetEditor();
|
||||
});
|
||||
private Task ApplyExistingAsync(int id)
|
||||
{
|
||||
existingExerciseId = id;
|
||||
return AddExistingAsync();
|
||||
}
|
||||
private Task AddExistingAsync() => RunAsync(async () =>
|
||||
{
|
||||
await GroupRepository.AddExerciseAsync(selectedGroupId, existingExerciseId);
|
||||
existingExerciseId = 0;
|
||||
pickerResetVersion++;
|
||||
await ReloadAsync();
|
||||
});
|
||||
private async Task MoveToGroupAsync(int targetGroupId)
|
||||
{
|
||||
var id = draggedExerciseId;
|
||||
draggedExerciseId = null;
|
||||
if (id == null || Busy || targetGroupId < 0) return;
|
||||
int? groupId = targetGroupId == 0 ? null : targetGroupId;
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await ExerciseLibrary.MoveAsync(id.Value, groupId);
|
||||
if (editing.Id == id.Value) editing.GroupId = groupId;
|
||||
await ReloadAsync();
|
||||
});
|
||||
}
|
||||
private async Task DeleteAsync(LibraryExercise exercise)
|
||||
{
|
||||
if (Busy) return;
|
||||
if (!await JavaScript.InvokeAsync<bool>("confirm",
|
||||
$"Удалить «{exercise.Name}» из библиотеки? В составленных тренировках упражнение сохранится.")) return;
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await ExerciseLibrary.DeleteAsync(exercise.Id);
|
||||
if (editing.Id == exercise.Id) ResetEditor();
|
||||
await ReloadAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inherits UiPageBase
|
||||
@inject IClientRepository Clients
|
||||
<PageTitle>Клиенты · Training Planner</PageTitle>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">РАБОЧЕЕ ПРОСТРАНСТВО</p>
|
||||
<h1>Клиенты</h1>
|
||||
<p class="muted">Персональные программы и история тренировок.</p>
|
||||
</div>
|
||||
<span class="count-badge">Клиентов: @items.Count</span>
|
||||
</header>
|
||||
@if (!string.IsNullOrEmpty(Error)) { <div class="error" role="alert">@Error</div> }
|
||||
<section class="panel compact">
|
||||
<h2>Новый клиент</h2>
|
||||
<form @onsubmit="CreateAsync" @onsubmit:preventDefault>
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)">
|
||||
<div class="inline-form">
|
||||
<label class="grow">Имя клиента<input @bind="name" placeholder="Имя и фамилия" required />
|
||||
</label>
|
||||
<button class="primary" disabled="@Busy">Добавить клиента</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
@if (items.Count == 0) { <section class="empty-state">
|
||||
<span class="empty-symbol">◎</span>
|
||||
<h2>Начните с первого клиента</h2>
|
||||
<p class="muted">После добавления клиента можно создать его программу на месяц.</p>
|
||||
</section> }
|
||||
else { <div class="client-grid">@foreach (var client in items) { <a class="client-card" href="@($"/clients/{client.Id}")">
|
||||
<span class="avatar">@client.Name[..1].ToUpperInvariant()</span>
|
||||
<div>
|
||||
<h2>@client.Name</h2>
|
||||
<p class="muted">Открыть программы</p>
|
||||
</div>
|
||||
<span class="arrow">↗</span>
|
||||
</a> }</div> }
|
||||
@code {
|
||||
private List<Client> items = [];
|
||||
private string name = string.Empty;
|
||||
protected override Task OnInitializedAsync() => RunAsync(async () => items = await Clients.GetAllAsync());
|
||||
private Task CreateAsync() => RunAsync(async () =>
|
||||
{
|
||||
await Clients.SaveAsync(new Client { Name = name });
|
||||
name = string.Empty;
|
||||
items = await Clients.GetAllAsync();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@page "/not-found"
|
||||
<PageTitle>Страница не найдена · Training Planner</PageTitle>
|
||||
<h1>Страница не найдена</h1>
|
||||
<a class="button" href="/">К клиентам</a>
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace TrainingPlanner.Web.Components.Pages;
|
||||
|
||||
public partial class ProgramEditor
|
||||
{
|
||||
private CancellationTokenSource? pendingUndoExpiry;
|
||||
|
||||
private Task DeleteBlockAsync(int index) => ModifyAsync(() =>
|
||||
{
|
||||
ClearDeletedBlock();
|
||||
deletedFrom = CurrentWorkout;
|
||||
deletedIndex = index;
|
||||
deletedBlock = CurrentWorkout!.Blocks[index];
|
||||
CurrentWorkout.Blocks.RemoveAt(index);
|
||||
pendingUndoExpiry = new CancellationTokenSource();
|
||||
_ = ExpireUndoAsync(pendingUndoExpiry.Token);
|
||||
});
|
||||
|
||||
private Task UndoDeleteAsync()
|
||||
{
|
||||
if (deletedBlock == null || deletedFrom != CurrentWorkout) return Task.CompletedTask;
|
||||
return ModifyAsync(() =>
|
||||
{
|
||||
deletedFrom!.Blocks.Insert(Math.Min(deletedIndex, deletedFrom.Blocks.Count), deletedBlock);
|
||||
ClearDeletedBlock();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ExpireUndoAsync(CancellationToken cancellation)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(Options.UndoDeleteDurationSeconds), cancellation);
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
if (disposed || cancellation.IsCancellationRequested) return;
|
||||
ClearDeletedBlock();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private void ClearDeletedBlock()
|
||||
{
|
||||
pendingUndoExpiry?.Cancel();
|
||||
pendingUndoExpiry?.Dispose();
|
||||
pendingUndoExpiry = null;
|
||||
deletedBlock = null;
|
||||
deletedFrom = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using TrainingPlanner.Domain;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace TrainingPlanner.Web.Components.Pages;
|
||||
|
||||
public partial class ProgramEditor
|
||||
{
|
||||
private TrainingWeek? draggedWeek;
|
||||
|
||||
private async Task DeleteWeekAsync()
|
||||
{
|
||||
if (plan == null || Busy || plan.Weeks.Count <= 1) return;
|
||||
|
||||
var week = CurrentWeek;
|
||||
var confirmed = await JavaScript.InvokeAsync<bool>("confirm",
|
||||
$"Удалить неделю {weekIndex + 1} и все её тренировки?");
|
||||
if (!confirmed || Busy || plan.Weeks.Count <= 1 || !plan.Weeks.Contains(week)) return;
|
||||
|
||||
await ModifyAsync(() =>
|
||||
{
|
||||
var index = plan.Weeks.IndexOf(week);
|
||||
plan.Weeks.RemoveAt(index);
|
||||
for (var position = 0; position < plan.Weeks.Count; position++)
|
||||
plan.Weeks[position].Position = position;
|
||||
|
||||
SelectWeek(Math.Min(index, plan.Weeks.Count - 1));
|
||||
draggedWeek = null;
|
||||
dragIndex = -1;
|
||||
if (deletedFrom != null && week.Workouts.Contains(deletedFrom))
|
||||
{
|
||||
ClearDeletedBlock();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void StartWeekDrag(int index)
|
||||
{
|
||||
draggedWorkout = null;
|
||||
draggedWeek = plan!.Weeks[index];
|
||||
dragIndex = -1;
|
||||
}
|
||||
|
||||
private async Task DropWeekAsync(int targetIndex, DragEventArgs args)
|
||||
{
|
||||
var week = draggedWeek;
|
||||
draggedWeek = null;
|
||||
if (plan == null || week == null || Busy)
|
||||
return;
|
||||
|
||||
var sourceIndex = plan.Weeks.IndexOf(week);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= plan.Weeks.Count)
|
||||
return;
|
||||
|
||||
var insertionIndex = await JavaScript.InvokeAsync<int>("trainingPlanner.weekInsertionIndex", targetIndex, args.ClientX);
|
||||
if (insertionIndex < 0 || insertionIndex > plan.Weeks.Count) return;
|
||||
if (sourceIndex < insertionIndex) insertionIndex--;
|
||||
if (sourceIndex == insertionIndex) return;
|
||||
|
||||
await ModifyAsync(() =>
|
||||
{
|
||||
var selectedWeek = CurrentWeek;
|
||||
plan.Weeks.RemoveAt(sourceIndex);
|
||||
plan.Weeks.Insert(insertionIndex, week);
|
||||
for (var index = 0; index < plan.Weeks.Count; index++)
|
||||
plan.Weeks[index].Position = index;
|
||||
|
||||
weekIndex = plan.Weeks.IndexOf(selectedWeek);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Web.Components.Pages;
|
||||
|
||||
public partial class ProgramEditor
|
||||
{
|
||||
private Workout? draggedWorkout;
|
||||
|
||||
private void StartWorkoutDrag(int index)
|
||||
{
|
||||
draggedWorkout = CurrentWeek.Workouts[index];
|
||||
draggedWeek = null;
|
||||
dragIndex = -1;
|
||||
}
|
||||
|
||||
private async Task DropWorkoutAsync(int targetIndex, DragEventArgs args)
|
||||
{
|
||||
var workout = draggedWorkout;
|
||||
draggedWorkout = null;
|
||||
if (plan == null || workout == null || Busy) return;
|
||||
|
||||
var week = CurrentWeek;
|
||||
var sourceIndex = week.Workouts.IndexOf(workout);
|
||||
if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= week.Workouts.Count) return;
|
||||
|
||||
var insertionIndex = await JavaScript.InvokeAsync<int>(
|
||||
"trainingPlanner.workoutInsertionIndex", targetIndex, args.ClientY);
|
||||
if (Busy || CurrentWeek != week || insertionIndex < 0 || insertionIndex > week.Workouts.Count) return;
|
||||
if (sourceIndex < insertionIndex) insertionIndex--;
|
||||
if (sourceIndex == insertionIndex) return;
|
||||
|
||||
await ModifyAsync(() =>
|
||||
{
|
||||
var selectedWorkout = CurrentWorkout;
|
||||
week.Workouts.RemoveAt(sourceIndex);
|
||||
week.Workouts.Insert(insertionIndex, workout);
|
||||
for (var index = 0; index < week.Workouts.Count; index++)
|
||||
week.Workouts[index].Position = index;
|
||||
|
||||
workoutIndex = week.Workouts.IndexOf(selectedWorkout!);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace TrainingPlanner.Web.Components.Pages;
|
||||
|
||||
public partial class ProgramEditor
|
||||
{
|
||||
private async Task DeleteWorkoutAsync()
|
||||
{
|
||||
if (plan == null || Busy || CurrentWorkout == null) return;
|
||||
|
||||
var week = CurrentWeek;
|
||||
var workout = CurrentWorkout;
|
||||
var confirmed = await JavaScript.InvokeAsync<bool>("confirm",
|
||||
$"Удалить тренировку «{workout.Name}» и все её упражнения?");
|
||||
if (!confirmed || Busy || !plan.Weeks.Contains(week) || !week.Workouts.Contains(workout)) return;
|
||||
|
||||
await ModifyAsync(() =>
|
||||
{
|
||||
var index = week.Workouts.IndexOf(workout);
|
||||
week.Workouts.RemoveAt(index);
|
||||
for (var position = 0; position < week.Workouts.Count; position++)
|
||||
week.Workouts[position].Position = position;
|
||||
|
||||
weekIndex = plan.Weeks.IndexOf(week);
|
||||
workoutIndex = Math.Max(0, Math.Min(index, week.Workouts.Count - 1));
|
||||
dragIndex = -1;
|
||||
if (deletedFrom == workout)
|
||||
{
|
||||
ClearDeletedBlock();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
@page "/programs/{Id:int}"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inherits UiPageBase
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>@(plan?.Name ?? "Программа") · Training Planner</PageTitle>
|
||||
<NavigationLock ConfirmExternalNavigation="dirty" OnBeforeInternalNavigation="BeforeNavigation" />
|
||||
|
||||
@if (plan != null)
|
||||
{
|
||||
<a class="breadcrumb" href="@($"/clients/{plan.ClientId}")">← @clientName · Все программы</a>
|
||||
<header class="page-heading editor-heading">
|
||||
<div>
|
||||
<p class="eyebrow">ПРОГРАММА НА МЕСЯЦ</p>
|
||||
<h1>
|
||||
<input class="title-input" disabled="@(Busy || !RendererInfo.IsInteractive)"
|
||||
aria-label="Название программы" @bind="plan.Name"
|
||||
@bind:event="oninput" @bind:after="ChangedAsync" />
|
||||
</h1>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="save-status" role="status">
|
||||
@(Busy ? "Сохраняем…" : dirty ? "Есть несохранённые изменения" : "✓ Сохранено")
|
||||
</span>
|
||||
<button disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="SaveAsync">Сохранить</button>
|
||||
<button class="primary" disabled="@(Busy || !RendererInfo.IsInteractive)" @onclick="ExportAsync">
|
||||
↓ Скачать PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Error))
|
||||
{
|
||||
<div class="error" role="alert">@Error</div>
|
||||
}
|
||||
|
||||
@if (plan != null)
|
||||
{
|
||||
<fieldset disabled="@(Busy || !RendererInfo.IsInteractive)" class="editor-fieldset">
|
||||
<div class="week-tabs" role="tablist" aria-label="Недели программы">
|
||||
@for (var index = 0; index < plan.Weeks.Count; index++)
|
||||
{
|
||||
var selectedIndex = index;
|
||||
<button @key="plan.Weeks[index]" role="tab" aria-selected="@(weekIndex == index ? "true" : "false")"
|
||||
data-week-index="@index"
|
||||
class="@(weekIndex == index ? "selected" : "")"
|
||||
draggable="true" title="Перетащите, чтобы изменить порядок недель"
|
||||
@ondragstart="() => StartWeekDrag(selectedIndex)"
|
||||
@ondragend="() => draggedWeek = null"
|
||||
@ondragover:preventDefault
|
||||
@ondrop:preventDefault
|
||||
@ondrop="args => DropWeekAsync(selectedIndex, args)"
|
||||
@onclick="() => SelectWeek(selectedIndex)">
|
||||
Неделя @(index + 1)
|
||||
<span>Тренировок: @plan.Weeks[index].Workouts.Count</span>
|
||||
</button>
|
||||
}
|
||||
<button class="add-week" @onclick="AddWeekAsync">+ Неделя</button>
|
||||
</div>
|
||||
|
||||
<div class="program-workspace">
|
||||
<aside class="workout-nav">
|
||||
<div class="list-toolbar">
|
||||
<h2>Тренировки</h2>
|
||||
<button class="icon-button" @onclick="AddWorkoutAsync" aria-label="Добавить тренировку">+</button>
|
||||
</div>
|
||||
@for (var index = 0; index < CurrentWeek.Workouts.Count; index++)
|
||||
{
|
||||
var selectedIndex = index;
|
||||
<button @key="CurrentWeek.Workouts[index]" class="workout-tab @(workoutIndex == index ? "selected" : "")"
|
||||
data-workout-index="@index" draggable="true"
|
||||
title="Перетащите, чтобы изменить порядок тренировок"
|
||||
@ondragstart="() => StartWorkoutDrag(selectedIndex)"
|
||||
@ondragend="() => draggedWorkout = null"
|
||||
@ondragover:preventDefault @ondrop:preventDefault
|
||||
@ondrop="args => DropWorkoutAsync(selectedIndex, args)"
|
||||
@onclick="() => workoutIndex = selectedIndex">
|
||||
<span>@((index + 1).ToString("00"))</span>
|
||||
<strong>@CurrentWeek.Workouts[index].Name</strong>
|
||||
</button>
|
||||
}
|
||||
<button class="text-button" @onclick="CopyWeekAsync">Копировать неделю</button>
|
||||
<button class="text-button danger-text" disabled="@(plan.Weeks.Count == 1)"
|
||||
title="@(plan.Weeks.Count == 1 ? "В программе должна остаться хотя бы одна неделя" : "Удалить выбранную неделю вместе с тренировками")"
|
||||
@onclick="DeleteWeekAsync">Удалить неделю</button>
|
||||
</aside>
|
||||
|
||||
<section class="workout-editor">
|
||||
@if (CurrentWorkout == null)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<span class="empty-symbol">+</span>
|
||||
<h2>Первая тренировка недели</h2>
|
||||
<p class="muted">Добавьте тренировку, затем выберите упражнения.</p>
|
||||
<button class="primary" @onclick="AddWorkoutAsync">Добавить тренировку</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="workout-heading">
|
||||
<input aria-label="Название тренировки" @bind="CurrentWorkout.Name"
|
||||
@bind:event="oninput" @bind:after="ChangedAsync" />
|
||||
<button @onclick="CopyWorkoutAsync">Копировать тренировку</button>
|
||||
<button class="icon-button danger-text" title="Удалить тренировку"
|
||||
aria-label="Удалить тренировку" @onclick="DeleteWorkoutAsync">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="workout-summary">
|
||||
<p class="muted">Общий отдых: @WorkoutTotals.RestMinutes(CurrentWorkout).ToString("0.##") мин</p>
|
||||
<label>Время тренировки
|
||||
<input @bind="CurrentWorkout.Duration" @bind:event="oninput" @bind:after="ChangedAsync"
|
||||
placeholder="Например, 1 ч 15 мин" />
|
||||
</label>
|
||||
</div>
|
||||
@if (deletedBlock != null && deletedFrom == CurrentWorkout)
|
||||
{
|
||||
<div class="undo-message">
|
||||
Блок удалён.
|
||||
<button class="text-button" @onclick="UndoDeleteAsync">Отменить</button>
|
||||
</div>
|
||||
}
|
||||
@for (var index = 0; index < CurrentWorkout.Blocks.Count; index++)
|
||||
{
|
||||
var blockIndex = index;
|
||||
var block = CurrentWorkout.Blocks[index];
|
||||
<div @key="block" @ondragover:preventDefault @ondrop="() => DropAsync(blockIndex)">
|
||||
<BlockCard Block="block" Number="@(index + 1)"
|
||||
IsFirst="@(index == 0)" IsLast="@(index == CurrentWorkout.Blocks.Count - 1)"
|
||||
OnChanged="ChangedAsync" OnMove="direction => MoveAsync(blockIndex, direction)"
|
||||
OnCopy="() => CopyBlockAsync(blockIndex)" OnDelete="() => DeleteBlockAsync(blockIndex)"
|
||||
OnDrag="() => { draggedWorkout = null; draggedWeek = null; dragIndex = blockIndex; }" />
|
||||
</div>
|
||||
}
|
||||
<div class="add-exercise-bar">
|
||||
<ExercisePicker ResetVersion="pickerResetVersion" Id="workout-exercise-picker" Exercises="library" @bind-Value="libraryId" OnApply="ApplyLibraryExerciseAsync" />
|
||||
<button disabled="@(libraryId == 0)" @onclick="AddFromLibraryAsync">Добавить</button>
|
||||
<button class="primary" @onclick="AddManualAsync">+ Ввести вручную</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.JSInterop;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Domain;
|
||||
|
||||
namespace TrainingPlanner.Web.Components.Pages;
|
||||
|
||||
public partial class ProgramEditor
|
||||
{
|
||||
[Parameter] public int Id { get; set; }
|
||||
[Inject] public IPlanRepository Plans { get; set; } = null!;
|
||||
[Inject] public IClientRepository Clients { get; set; } = null!;
|
||||
[Inject] public IExerciseRepository Exercises { get; set; } = null!;
|
||||
[Inject] public PlanFactory Factory { get; set; } = null!;
|
||||
[Inject] public PlanCopier Copier { get; set; } = null!;
|
||||
[Inject] public BlockEditor Blocks { get; set; } = null!;
|
||||
[Inject] public PlannerOptions Options { get; set; } = null!;
|
||||
[Inject] public NavigationManager Navigation { get; set; } = null!;
|
||||
[Inject] public IJSRuntime JavaScript { get; set; } = null!;
|
||||
private TrainingPlan? plan;
|
||||
private List<LibraryExercise> library = [];
|
||||
private string clientName = string.Empty;
|
||||
private int weekIndex;
|
||||
private int workoutIndex;
|
||||
private int libraryId;
|
||||
private int pickerResetVersion;
|
||||
private int dragIndex = -1;
|
||||
private bool dirty;
|
||||
private bool disposed;
|
||||
private int editVersion;
|
||||
private CancellationTokenSource? pendingSave;
|
||||
private WorkoutBlock? deletedBlock;
|
||||
private Workout? deletedFrom;
|
||||
private int deletedIndex;
|
||||
private TrainingWeek CurrentWeek => plan!.Weeks[weekIndex];
|
||||
private Workout? CurrentWorkout => CurrentWeek.Workouts.ElementAtOrDefault(workoutIndex);
|
||||
|
||||
protected override Task OnParametersSetAsync() => RunAsync(async () =>
|
||||
{
|
||||
pendingSave?.Cancel();
|
||||
ClearDeletedBlock();
|
||||
plan = await Plans.GetAsync(Id) ?? throw new InvalidOperationException("Программа не найдена.");
|
||||
clientName = (await Clients.GetAsync(plan.ClientId))?.Name ?? string.Empty;
|
||||
library = await Exercises.GetAllAsync();
|
||||
weekIndex = 0;
|
||||
workoutIndex = 0;
|
||||
draggedWeek = null;
|
||||
draggedWorkout = null;
|
||||
dragIndex = -1;
|
||||
dirty = false;
|
||||
});
|
||||
|
||||
private async Task ChangedAsync()
|
||||
{
|
||||
dirty = true;
|
||||
editVersion++;
|
||||
pendingSave?.Cancel();
|
||||
pendingSave?.Dispose();
|
||||
var cancellation = new CancellationTokenSource();
|
||||
pendingSave = cancellation;
|
||||
try
|
||||
{
|
||||
await Task.Delay(Options.AutosaveDelayMilliseconds, cancellation.Token);
|
||||
if (!disposed) await SaveAsync();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private Task SaveAsync()
|
||||
{
|
||||
pendingSave?.Cancel();
|
||||
return RunAsync(async () =>
|
||||
{
|
||||
var savedVersion = editVersion;
|
||||
var snapshot = Copier.Copy(plan!, plan!.Name);
|
||||
snapshot.Id = plan.Id;
|
||||
snapshot.Version = plan.Version;
|
||||
await Plans.SaveAsync(snapshot);
|
||||
plan.Version = snapshot.Version;
|
||||
plan.CreatedAtUtc = snapshot.CreatedAtUtc;
|
||||
plan.UpdatedAtUtc = snapshot.UpdatedAtUtc;
|
||||
dirty = savedVersion != editVersion;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ExportAsync()
|
||||
{
|
||||
await SaveAsync();
|
||||
if (!dirty && string.IsNullOrEmpty(Error))
|
||||
await JavaScript.InvokeVoidAsync("trainingPlanner.download", $"/programs/{Id}/pdf");
|
||||
}
|
||||
|
||||
private async Task ModifyAsync(Action action)
|
||||
{
|
||||
try { action(); Error = string.Empty; }
|
||||
catch (InvalidOperationException exception) { Error = exception.Message; return; }
|
||||
await ChangedAsync();
|
||||
}
|
||||
|
||||
private void SelectWeek(int index) { weekIndex = index; workoutIndex = 0; draggedWorkout = null; dragIndex = -1; }
|
||||
private Task AddWeekAsync() => ModifyAsync(() =>
|
||||
{
|
||||
plan!.Weeks.Add(new TrainingWeek());
|
||||
SelectWeek(plan.Weeks.Count - 1);
|
||||
});
|
||||
private Task CopyWeekAsync() => ModifyAsync(() =>
|
||||
{
|
||||
plan!.Weeks.Add(Copier.CopyWeek(CurrentWeek));
|
||||
SelectWeek(plan.Weeks.Count - 1);
|
||||
});
|
||||
private Task AddWorkoutAsync() => ModifyAsync(() =>
|
||||
{
|
||||
CurrentWeek.Workouts.Add(new Workout
|
||||
{
|
||||
Name = $"{Options.DefaultWorkoutName} {CurrentWeek.Workouts.Count + 1}",
|
||||
Duration = Options.DefaultWorkoutDuration
|
||||
});
|
||||
workoutIndex = CurrentWeek.Workouts.Count - 1;
|
||||
});
|
||||
private Task CopyWorkoutAsync() => ModifyAsync(() =>
|
||||
{
|
||||
CurrentWeek.Workouts.Add(Copier.CopyWorkout(CurrentWorkout!));
|
||||
workoutIndex = CurrentWeek.Workouts.Count - 1;
|
||||
});
|
||||
private Task AddManualAsync() => ModifyAsync(() => CurrentWorkout!.Blocks.Add(Factory.CreateBlock()));
|
||||
private Task ApplyLibraryExerciseAsync(int id)
|
||||
{
|
||||
libraryId = id;
|
||||
return AddFromLibraryAsync();
|
||||
}
|
||||
private Task AddFromLibraryAsync() => ModifyAsync(() =>
|
||||
{
|
||||
CurrentWorkout!.Blocks.Add(Factory.CreateBlock(library.Single(exercise => exercise.Id == libraryId)));
|
||||
libraryId = 0;
|
||||
pickerResetVersion++;
|
||||
});
|
||||
private Task MoveAsync(int index, int direction) => ModifyAsync(() => Blocks.Move(CurrentWorkout!, index, index + direction));
|
||||
private Task DropAsync(int index) => ModifyAsync(() => { Blocks.Move(CurrentWorkout!, dragIndex, index); dragIndex = -1; });
|
||||
private Task CopyBlockAsync(int index) => ModifyAsync(() => CurrentWorkout!.Blocks.Insert(index + 1, Copier.CopyBlock(CurrentWorkout.Blocks[index])));
|
||||
private async Task BeforeNavigation(LocationChangingContext context)
|
||||
{
|
||||
if (dirty && !await JavaScript.InvokeAsync<bool>("confirm", "Есть несохранённые изменения. Покинуть программу?"))
|
||||
context.PreventNavigation();
|
||||
}
|
||||
public void Dispose() { disposed = true; ClearDeletedBlock(); pendingSave?.Cancel(); pendingSave?.Dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@inject NavigationManager Navigation
|
||||
@code {
|
||||
protected override void OnInitialized() => Navigation.NavigateTo("/Login", forceLoad: true);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace TrainingPlanner.Web.Components;
|
||||
|
||||
public class UiPageBase : ComponentBase
|
||||
{
|
||||
[Inject] public ILogger<UiPageBase> Logger { get; set; } = null!;
|
||||
protected string Error { get; set; } = string.Empty;
|
||||
protected bool Busy { get; set; }
|
||||
|
||||
protected async Task RunAsync(Func<Task> action)
|
||||
{
|
||||
if (Busy) return;
|
||||
Busy = true;
|
||||
Error = string.Empty;
|
||||
try { await action(); }
|
||||
catch (InvalidOperationException exception) { Error = exception.Message; }
|
||||
catch (Exception exception)
|
||||
{
|
||||
Logger.LogError(exception, "Operation failed");
|
||||
Error = "Не удалось выполнить действие. Изменения остались на экране. Попробуйте ещё раз.";
|
||||
}
|
||||
finally { Busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using TrainingPlanner.Web
|
||||
@using TrainingPlanner.Web.Components
|
||||
@using TrainingPlanner.Web.Components.Layout
|
||||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using TrainingPlanner.Application
|
||||
@using TrainingPlanner.Domain
|
||||
@using TrainingPlanner.Web.Components.Editor
|
||||
|
||||
@using TrainingPlanner.Web.Components.Library
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TrainingPlanner.Application;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
using TrainingPlanner.Infrastructure.Pdf;
|
||||
using TrainingPlanner.Web.Security;
|
||||
|
||||
namespace TrainingPlanner.Web.Configuration;
|
||||
|
||||
public static class ServiceRegistration
|
||||
{
|
||||
public static void AddTrainingPlanner(this IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
services.AddOptions<ExerciseImportOptions>().Bind(config.GetSection("ExerciseImport"))
|
||||
.Validate(options => options.MaxRows > 0 && options.MaxClipboardCharacters > 0,
|
||||
"Проверьте настройки ExerciseImport.").ValidateOnStart();
|
||||
services.AddSingleton(provider => provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<ExerciseImportOptions>>().Value);
|
||||
services.AddSingleton<ClipboardTableParser>();
|
||||
services.AddSingleton<ExerciseImportValidator>();
|
||||
services.AddScoped<IExerciseImporter, ExerciseImporter>();
|
||||
services.AddOptions<PlannerOptions>().Bind(config.GetSection("Planner"))
|
||||
.Validate(options => options.DefaultWeekCount > 0 && options.DefaultSetsCount > 0
|
||||
&& options.DefaultRestMinutes >= 0 && options.AutosaveDelayMilliseconds > 0 && options.UndoDeleteDurationSeconds > 0
|
||||
&& options.DefaultIntensityPercent is >= 0 and <= 100
|
||||
&& !string.IsNullOrWhiteSpace(options.DefaultWorkoutDuration),
|
||||
"Проверьте настройки Planner.").ValidateOnStart();
|
||||
services.AddSingleton(provider => provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<PlannerOptions>>().Value);
|
||||
services.AddDbContextFactory<TrainingDbContext>(options => options.UseNpgsql(
|
||||
config.GetConnectionString("TrainingDatabase"), database => database.MigrationsAssembly("TrainingPlanner.Web")));
|
||||
services.AddIdentity<IdentityUser, IdentityRole>(options => config.GetSection("Identity").Bind(options))
|
||||
.AddEntityFrameworkStores<TrainingDbContext>().AddDefaultTokenProviders();
|
||||
services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/Login";
|
||||
options.AccessDeniedPath = "/Login";
|
||||
options.ExpireTimeSpan = TimeSpan.FromHours(config.GetValue<double>("Security:CookieHours"));
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = config.GetValue<bool>("Security:RequireHttps")
|
||||
? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
|
||||
});
|
||||
var keyPath = config["Security:DataProtectionKeysPath"];
|
||||
if (!string.IsNullOrWhiteSpace(keyPath))
|
||||
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(keyPath));
|
||||
services.AddCascadingAuthenticationState();
|
||||
services.AddScoped<AuthenticationStateProvider, TrainerAuthenticationStateProvider>();
|
||||
services.AddScoped<ITrainerContext, TrainerContext>();
|
||||
services.AddScoped<IClientRepository, ClientRepository>();
|
||||
services.AddScoped<IExerciseRepository, ExerciseRepository>();
|
||||
services.AddScoped<IExerciseGroupRepository, ExerciseGroupRepository>();
|
||||
services.AddScoped<IPlanRepository, PlanRepository>();
|
||||
services.AddScoped<IPlanReader, PlanReader>();
|
||||
services.AddScoped<DatabaseInitializer>();
|
||||
services.AddSingleton<PlanFactory>();
|
||||
services.AddSingleton<PlanCopier>();
|
||||
services.AddSingleton<PlanValidator>();
|
||||
services.AddSingleton<BlockEditor>();
|
||||
services.AddSingleton<IPlanPdfExporter, PlanPdfExporter>();
|
||||
services.AddSingleton(config.GetSection("Pdf").Get<PdfOptions>() ?? new PdfOptions());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
namespace TrainingPlanner.Web.Data;
|
||||
|
||||
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<TrainingDbContext>
|
||||
{
|
||||
public TrainingDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var builder = new DbContextOptionsBuilder<TrainingDbContext>();
|
||||
builder.UseNpgsql(options => options.MigrationsAssembly(typeof(Program).Assembly.GetName().Name));
|
||||
return new TrainingDbContext(builder.Options);
|
||||
}
|
||||
}
|
||||
+602
@@ -0,0 +1,602 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TrainingDbContext))]
|
||||
[Migration("20260924164145_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("Clients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseSet", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Repetitions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("WeightKg")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<int>("WorkoutExerciseId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutExerciseId");
|
||||
|
||||
b.ToTable("ExerciseSet");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("LibraryExercises");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("TrainingPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingPlanId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingPlanId");
|
||||
|
||||
b.ToTable("TrainingWeek");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingWeekId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingWeekId");
|
||||
|
||||
b.ToTable("Workout");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("RestSeconds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("WorkoutId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutId");
|
||||
|
||||
b.ToTable("WorkoutBlock");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("RequiresVideo")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("WorkoutBlockId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutBlockId");
|
||||
|
||||
b.ToTable("WorkoutExercise");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseSet", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.WorkoutExercise", null)
|
||||
.WithMany("Sets")
|
||||
.HasForeignKey("WorkoutExerciseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Client", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
|
||||
.WithMany("Weeks")
|
||||
.HasForeignKey("TrainingPlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
|
||||
.WithMany("Workouts")
|
||||
.HasForeignKey("TrainingWeekId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Workout", null)
|
||||
.WithMany("Blocks")
|
||||
.HasForeignKey("WorkoutId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
|
||||
.WithMany("Exercises")
|
||||
.HasForeignKey("WorkoutBlockId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Navigation("Weeks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Navigation("Workouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Navigation("Blocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Navigation("Exercises");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.Navigation("Sets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUsers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "text", nullable: true),
|
||||
SecurityStamp = table.Column<string>(type: "text", nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "text", nullable: true),
|
||||
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoleClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
RoleId = table.Column<string>(type: "text", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<string>(type: "text", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserLogins",
|
||||
columns: table => new
|
||||
{
|
||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
||||
ProviderKey = table.Column<string>(type: "text", nullable: false),
|
||||
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
|
||||
UserId = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserRoles",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "text", nullable: false),
|
||||
RoleId = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserTokens",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "text", nullable: false),
|
||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Value = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Clients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TrainerId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Notes = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Clients", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Clients_AspNetUsers_TrainerId",
|
||||
column: x => x.TrainerId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LibraryExercises",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TrainerId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
VideoUrl = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LibraryExercises", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LibraryExercises_AspNetUsers_TrainerId",
|
||||
column: x => x.TrainerId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TrainingPlans",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ClientId = table.Column<int>(type: "integer", nullable: false),
|
||||
TrainerId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Version = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TrainingPlans", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingPlans_AspNetUsers_TrainerId",
|
||||
column: x => x.TrainerId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingPlans_Clients_ClientId",
|
||||
column: x => x.ClientId,
|
||||
principalTable: "Clients",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TrainingWeek",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TrainingPlanId = table.Column<int>(type: "integer", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TrainingWeek", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TrainingWeek_TrainingPlans_TrainingPlanId",
|
||||
column: x => x.TrainingPlanId,
|
||||
principalTable: "TrainingPlans",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Workout",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TrainingWeekId = table.Column<int>(type: "integer", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Workout", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Workout_TrainingWeek_TrainingWeekId",
|
||||
column: x => x.TrainingWeekId,
|
||||
principalTable: "TrainingWeek",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkoutBlock",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
WorkoutId = table.Column<int>(type: "integer", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
RestSeconds = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkoutBlock", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_WorkoutBlock_Workout_WorkoutId",
|
||||
column: x => x.WorkoutId,
|
||||
principalTable: "Workout",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkoutExercise",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
WorkoutBlockId = table.Column<int>(type: "integer", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
VideoUrl = table.Column<string>(type: "text", nullable: false),
|
||||
RequiresVideo = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkoutExercise", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_WorkoutExercise_WorkoutBlock_WorkoutBlockId",
|
||||
column: x => x.WorkoutBlockId,
|
||||
principalTable: "WorkoutBlock",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExerciseSet",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
WorkoutExerciseId = table.Column<int>(type: "integer", nullable: false),
|
||||
Position = table.Column<int>(type: "integer", nullable: false),
|
||||
Repetitions = table.Column<string>(type: "text", nullable: false),
|
||||
WeightKg = table.Column<decimal>(type: "numeric", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExerciseSet", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExerciseSet_WorkoutExercise_WorkoutExerciseId",
|
||||
column: x => x.WorkoutExerciseId,
|
||||
principalTable: "WorkoutExercise",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetRoleClaims_RoleId",
|
||||
table: "AspNetRoleClaims",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "RoleNameIndex",
|
||||
table: "AspNetRoles",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserClaims_UserId",
|
||||
table: "AspNetUserClaims",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserLogins_UserId",
|
||||
table: "AspNetUserLogins",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserRoles_RoleId",
|
||||
table: "AspNetUserRoles",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "EmailIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UserNameIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedUserName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Clients_TrainerId",
|
||||
table: "Clients",
|
||||
column: "TrainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExerciseSet_WorkoutExerciseId",
|
||||
table: "ExerciseSet",
|
||||
column: "WorkoutExerciseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LibraryExercises_TrainerId",
|
||||
table: "LibraryExercises",
|
||||
column: "TrainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingPlans_ClientId",
|
||||
table: "TrainingPlans",
|
||||
column: "ClientId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingPlans_TrainerId",
|
||||
table: "TrainingPlans",
|
||||
column: "TrainerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrainingWeek_TrainingPlanId",
|
||||
table: "TrainingWeek",
|
||||
column: "TrainingPlanId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Workout_TrainingWeekId",
|
||||
table: "Workout",
|
||||
column: "TrainingWeekId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkoutBlock_WorkoutId",
|
||||
table: "WorkoutBlock",
|
||||
column: "WorkoutId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkoutExercise_WorkoutBlockId",
|
||||
table: "WorkoutExercise",
|
||||
column: "WorkoutBlockId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoleClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserLogins");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExerciseSet");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LibraryExercises");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkoutExercise");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkoutBlock");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Workout");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TrainingWeek");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TrainingPlans");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Clients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
+586
@@ -0,0 +1,586 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TrainingDbContext))]
|
||||
[Migration("20260924224103_ExercisePrescription")]
|
||||
partial class ExercisePrescription
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("Clients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("LibraryExercises");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("TrainingPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingPlanId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingPlanId");
|
||||
|
||||
b.ToTable("TrainingWeek");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Duration")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingWeekId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingWeekId");
|
||||
|
||||
b.ToTable("Workout");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("RestMinutes")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<int>("SetsCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("WorkoutId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutId");
|
||||
|
||||
b.ToTable("WorkoutBlock");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal?>("IntensityPercent")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Repetitions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("RequiresVideo")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("WarmupSets")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Weight")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("WorkoutBlockId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutBlockId");
|
||||
|
||||
b.ToTable("WorkoutExercise");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Client", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
|
||||
.WithMany("Weeks")
|
||||
.HasForeignKey("TrainingPlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
|
||||
.WithMany("Workouts")
|
||||
.HasForeignKey("TrainingWeekId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Workout", null)
|
||||
.WithMany("Blocks")
|
||||
.HasForeignKey("WorkoutId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
|
||||
.WithMany("Exercises")
|
||||
.HasForeignKey("WorkoutBlockId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Navigation("Weeks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Navigation("Workouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Navigation("Blocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Navigation("Exercises");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExercisePrescription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SetsCount", table: "WorkoutBlock", type: "integer", nullable: false, defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "IntensityPercent",
|
||||
table: "WorkoutExercise",
|
||||
type: "numeric",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Notes",
|
||||
table: "WorkoutExercise",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Repetitions",
|
||||
table: "WorkoutExercise",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "WarmupSets",
|
||||
table: "WorkoutExercise",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Weight",
|
||||
table: "WorkoutExercise",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "RestMinutes",
|
||||
table: "WorkoutBlock",
|
||||
type: "numeric",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Duration",
|
||||
table: "Workout",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
// Preserve the first approach as the shared prescription before removing the old rows.
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "WorkoutExercise" AS exercise
|
||||
SET "Repetitions" = COALESCE((
|
||||
SELECT "Repetitions" FROM "ExerciseSet"
|
||||
WHERE "WorkoutExerciseId" = exercise."Id" ORDER BY "Position", "Id" LIMIT 1), ''),
|
||||
"Weight" = COALESCE((
|
||||
SELECT replace("WeightKg"::text, '.', ',') FROM "ExerciseSet"
|
||||
WHERE "WorkoutExerciseId" = exercise."Id" ORDER BY "Position", "Id" LIMIT 1), '');
|
||||
|
||||
UPDATE "WorkoutBlock" SET "RestMinutes" = "RestSeconds" / 60.0;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
item RECORD;
|
||||
target_block integer;
|
||||
previous_block integer := NULL;
|
||||
previous_workout integer := NULL;
|
||||
block_position integer := 0;
|
||||
BEGIN
|
||||
FOR item IN
|
||||
SELECT b."Id" AS block_id, b."WorkoutId" AS workout_id,
|
||||
b."RestMinutes" AS rest_minutes, b."RestSeconds" AS rest_seconds,
|
||||
e."Id" AS exercise_id,
|
||||
(SELECT count(*) FROM "ExerciseSet" s WHERE s."WorkoutExerciseId" = e."Id")::integer AS sets_count
|
||||
FROM "WorkoutBlock" b JOIN "WorkoutExercise" e ON e."WorkoutBlockId" = b."Id"
|
||||
ORDER BY b."WorkoutId", b."Position", b."Id", e."Position", e."Id"
|
||||
LOOP
|
||||
IF previous_workout IS DISTINCT FROM item.workout_id THEN
|
||||
block_position := 0;
|
||||
END IF;
|
||||
IF previous_block IS DISTINCT FROM item.block_id THEN
|
||||
target_block := item.block_id;
|
||||
UPDATE "WorkoutBlock" SET "Position" = block_position,
|
||||
"SetsCount" = GREATEST(1, item.sets_count) WHERE "Id" = target_block;
|
||||
ELSE
|
||||
INSERT INTO "WorkoutBlock" ("WorkoutId", "Position", "RestSeconds", "RestMinutes", "SetsCount")
|
||||
VALUES (item.workout_id, block_position, item.rest_seconds, item.rest_minutes, GREATEST(1, item.sets_count))
|
||||
RETURNING "Id" INTO target_block;
|
||||
END IF;
|
||||
UPDATE "WorkoutExercise" SET "WorkoutBlockId" = target_block, "Position" = 0
|
||||
WHERE "Id" = item.exercise_id;
|
||||
previous_block := item.block_id;
|
||||
previous_workout := item.workout_id;
|
||||
block_position := block_position + 1;
|
||||
END LOOP;
|
||||
END $$;
|
||||
""");
|
||||
migrationBuilder.DropTable(name: "ExerciseSet");
|
||||
migrationBuilder.DropColumn(name: "RestSeconds", table: "WorkoutBlock");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"Автоматический откат не восстанавливает индивидуальные подходы. Восстановите резервную копию БД вместе с предыдущей версией приложения.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TrainingDbContext))]
|
||||
[Migration("20260925193904_ExerciseGroups")]
|
||||
partial class ExerciseGroups
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("Clients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("ExerciseGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("GroupId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GroupId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("LibraryExercises");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("TrainingPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingPlanId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingPlanId");
|
||||
|
||||
b.ToTable("TrainingWeek");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Duration")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingWeekId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingWeekId");
|
||||
|
||||
b.ToTable("Workout");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("RestMinutes")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<int>("SetsCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("WorkoutId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutId");
|
||||
|
||||
b.ToTable("WorkoutBlock");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal?>("IntensityPercent")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Repetitions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("RequiresVideo")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("WarmupSets")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Weight")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("WorkoutBlockId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutBlockId");
|
||||
|
||||
b.ToTable("WorkoutExercise");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.ExerciseGroup", "Group")
|
||||
.WithMany()
|
||||
.HasForeignKey("GroupId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Client", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
|
||||
.WithMany("Weeks")
|
||||
.HasForeignKey("TrainingPlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
|
||||
.WithMany("Workouts")
|
||||
.HasForeignKey("TrainingWeekId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Workout", null)
|
||||
.WithMany("Blocks")
|
||||
.HasForeignKey("WorkoutId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
|
||||
.WithMany("Exercises")
|
||||
.HasForeignKey("WorkoutBlockId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Navigation("Weeks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Navigation("Workouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Navigation("Blocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Navigation("Exercises");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExerciseGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GroupId",
|
||||
table: "LibraryExercises",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExerciseGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TrainerId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExerciseGroups", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExerciseGroups_AspNetUsers_TrainerId",
|
||||
column: x => x.TrainerId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LibraryExercises_GroupId",
|
||||
table: "LibraryExercises",
|
||||
column: "GroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExerciseGroups_TrainerId",
|
||||
table: "ExerciseGroups",
|
||||
column: "TrainerId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_LibraryExercises_ExerciseGroups_GroupId",
|
||||
table: "LibraryExercises",
|
||||
column: "GroupId",
|
||||
principalTable: "ExerciseGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_LibraryExercises_ExerciseGroups_GroupId",
|
||||
table: "LibraryExercises");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExerciseGroups");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_LibraryExercises_GroupId",
|
||||
table: "LibraryExercises");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GroupId",
|
||||
table: "LibraryExercises");
|
||||
}
|
||||
}
|
||||
}
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TrainingDbContext))]
|
||||
[Migration("20260929161544_ClientProfile")]
|
||||
partial class ClientProfile
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HealthStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainingSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("Clients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("ExerciseGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("GroupId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GroupId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("LibraryExercises");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("TrainingPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingPlanId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingPlanId");
|
||||
|
||||
b.ToTable("TrainingWeek");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Duration")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingWeekId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingWeekId");
|
||||
|
||||
b.ToTable("Workout");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("RestMinutes")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<int>("SetsCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("WorkoutId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutId");
|
||||
|
||||
b.ToTable("WorkoutBlock");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal?>("IntensityPercent")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Repetitions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("RequiresVideo")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("WarmupSets")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Weight")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("WorkoutBlockId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutBlockId");
|
||||
|
||||
b.ToTable("WorkoutExercise");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.ExerciseGroup", "Group")
|
||||
.WithMany()
|
||||
.HasForeignKey("GroupId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Client", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
|
||||
.WithMany("Weeks")
|
||||
.HasForeignKey("TrainingPlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
|
||||
.WithMany("Workouts")
|
||||
.HasForeignKey("TrainingWeekId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Workout", null)
|
||||
.WithMany("Blocks")
|
||||
.HasForeignKey("WorkoutId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
|
||||
.WithMany("Exercises")
|
||||
.HasForeignKey("WorkoutBlockId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Navigation("Weeks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Navigation("Workouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Navigation("Blocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Navigation("Exercises");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ClientProfile : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Goal",
|
||||
table: "Clients",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "HealthStatus",
|
||||
table: "Clients",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TrainingSchedule",
|
||||
table: "Clients",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Goal",
|
||||
table: "Clients");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HealthStatus",
|
||||
table: "Clients");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TrainingSchedule",
|
||||
table: "Clients");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TrainingPlanner.Infrastructure.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TrainingPlanner.Web.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TrainingDbContext))]
|
||||
partial class TrainingDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("HealthStatus")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainingSchedule")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("Clients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("ExerciseGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("GroupId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GroupId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("LibraryExercises");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("ClientId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TrainerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ClientId");
|
||||
|
||||
b.HasIndex("TrainerId");
|
||||
|
||||
b.ToTable("TrainingPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingPlanId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingPlanId");
|
||||
|
||||
b.ToTable("TrainingWeek");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Duration")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TrainingWeekId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrainingWeekId");
|
||||
|
||||
b.ToTable("Workout");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("RestMinutes")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<int>("SetsCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("WorkoutId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutId");
|
||||
|
||||
b.ToTable("WorkoutBlock");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal?>("IntensityPercent")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Repetitions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("RequiresVideo")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("VideoUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("WarmupSets")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Weight")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("WorkoutBlockId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkoutBlockId");
|
||||
|
||||
b.ToTable("WorkoutExercise");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Client", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.ExerciseGroup", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.LibraryExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.ExerciseGroup", "Group")
|
||||
.WithMany()
|
||||
.HasForeignKey("GroupId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Group");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Client", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TrainerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingPlan", null)
|
||||
.WithMany("Weeks")
|
||||
.HasForeignKey("TrainingPlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.TrainingWeek", null)
|
||||
.WithMany("Workouts")
|
||||
.HasForeignKey("TrainingWeekId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.Workout", null)
|
||||
.WithMany("Blocks")
|
||||
.HasForeignKey("WorkoutId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutExercise", b =>
|
||||
{
|
||||
b.HasOne("TrainingPlanner.Domain.WorkoutBlock", null)
|
||||
.WithMany("Exercises")
|
||||
.HasForeignKey("WorkoutBlockId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingPlan", b =>
|
||||
{
|
||||
b.Navigation("Weeks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.TrainingWeek", b =>
|
||||
{
|
||||
b.Navigation("Workouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.Workout", b =>
|
||||
{
|
||||
b.Navigation("Blocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TrainingPlanner.Domain.WorkoutBlock", b =>
|
||||
{
|
||||
b.Navigation("Exercises");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@page "/programs/{id:int}/pdf"
|
||||
@model TrainingPlanner.Web.Pages.ExportModel
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using TrainingPlanner.Application;
|
||||
|
||||
namespace TrainingPlanner.Web.Pages;
|
||||
|
||||
[Authorize]
|
||||
public class ExportModel(IPlanReader reader, IPlanPdfExporter exporter) : PageModel
|
||||
{
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
var trainerId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (trainerId == null) return Challenge();
|
||||
var plan = await reader.GetAsync(id, trainerId);
|
||||
if (plan == null) return NotFound();
|
||||
var client = await reader.GetClientAsync(plan.ClientId, trainerId);
|
||||
if (client == null) return NotFound();
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
return File(exporter.Export(plan, client), "application/pdf", $"training-program-{id}.pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@page
|
||||
@model TrainingPlanner.Web.Pages.LoginModel
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="icon" href="/favicon.svg"/><title>Вход · Training Planner</title><link rel="stylesheet" href="/app.css"/></head>
|
||||
<body class="login-page"><main class="login-card"><div class="brand-mark">TP</div><p class="eyebrow">РАБОЧЕЕ МЕСТО ТРЕНЕРА</p><h1>С возвращением</h1><p class="muted">Войдите, чтобы продолжить работу с программами.</p>
|
||||
<form method="post"><div asp-validation-summary="All" class="error" role="alert"></div>
|
||||
<label>Логин<input asp-for="UserName" autocomplete="username" required autofocus/></label>
|
||||
<label>Пароль<input asp-for="Password" type="password" autocomplete="current-password" required/></label>
|
||||
<button class="primary" type="submit">Войти →</button></form></main></body></html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user