98 Commits

Author SHA1 Message Date
dependabot[bot]
91ad9aef27 chore(deps): bump jsonwebtoken from 9.3.1 to 10.2.0
Bumps [jsonwebtoken](https://github.com/Keats/jsonwebtoken) from 9.3.1 to 10.2.0.
- [Changelog](https://github.com/Keats/jsonwebtoken/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Keats/jsonwebtoken/compare/v9.3.1...v10.2.0)

---
updated-dependencies:
- dependency-name: jsonwebtoken
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 18:44:31 +00:00
2442641479 feat(ci): auto-deploy new version after building docker image 2025-10-29 02:31:13 +04:00
ac16c96e5e chore(schedule): add lesson type 'differentiated exam' 2025-10-29 02:16:25 +04:00
622464e4c3 feat(users): add endpoints for getting user by ids 2025-10-28 22:33:49 +04:00
39c60ef939 feat(middleware): add support of path patterns 2025-10-28 22:33:10 +04:00
d1ef5c032e feat: implement service users 2025-10-28 06:53:31 +04:00
b635750e28 feat(db): add service users table 2025-10-28 06:46:30 +04:00
a59fff927d chore(deps): update dependencies 2025-10-28 06:45:55 +04:00
cdc89b5bcd fix(parser): fix sentry error sending 2025-10-10 03:00:47 +04:00
ad86f6cd64 feat(parser): limit names regex to maximum 2 elements
This allows us to not worry about subgroups array index overflows, and we can make better non-standard case solving.
2025-10-10 01:39:54 +04:00
a3b4a501db feat(parser): improve names regex to exclude some non-standard cases
Like "Название ФАмилия. И.О.".
In that case regex will grab "Название ФА", instead of "Амилия. И. О." (we can't add 'Ф', bc it will make regex checks way more complex).

Now it will ignore "Название ФА" if after that lower or upper char is placed.
Previously only lower chars are excluded and check won't exclude "Название ФА" and grabs "Название Ф" bc after 'Ф' uppercase char is present.
2025-10-10 01:37:52 +04:00
df0e99a4d0 feat(parser): make lesson cell range less strict to support upcoming split-lessons 2025-10-10 01:31:55 +04:00
a8cf8fb0f5 feat(parser): improve street regex 2025-10-10 01:30:56 +04:00
7ed866138e feat(error): add error for unknown lesson type 2025-10-10 01:30:30 +04:00
7bac48f8fc feat(error): add more intuitive CellPos formatting and get rid of ErrorCell 2025-10-10 01:27:05 +04:00
191ec36fef chore: remove useless commented code 2025-10-10 01:25:12 +04:00
f121a04f1b refactor: refactor providers code 2025-10-02 07:55:07 +04:00
df74ab03a1 chore(ci): make building non-verbose 2025-10-02 07:54:09 +04:00
1b79d1cf1e chore: bump provider version to 0.2.2 2025-10-02 07:44:35 +04:00
2b9b1ea66b chore(deps): update dependencies 2025-10-02 07:43:37 +04:00
ca713d8d51 refactor: prevent updater stop because of errors 2025-10-02 07:40:44 +04:00
69df538467 refactor(updater): don't cancel token when uri fetch error occurred 2025-09-29 08:27:25 +04:00
aa019f8fcf fix(parser): rework teacher name parsing 2025-09-29 08:27:24 +04:00
b664ba578d chore(clippy): fix all clippy warnings 2025-09-25 03:42:34 +04:00
983967f8b0 chore(downloader): suppress unused_assignments warning 2025-09-25 03:27:55 +04:00
e5760120e2 chore(release): bump version to 1.3.0 2025-09-25 03:17:14 +04:00
a28fb66dd4 feat(downloader): add retry-mechanism for querying uri from yandex-cloud api (#18) 2025-09-25 03:15:36 +04:00
3780fb3136 feat(downloader): implement etag-based difference check for schedule 2025-09-25 03:14:39 +04:00
6c71bc19f5 chore(parser): fix crash caused by another mistype in schedule 2025-09-25 02:50:43 +04:00
2d0041dc8b feat(schedule): add practice lesson type 2025-09-25 02:49:23 +04:00
b5d372e109 feat(ci): build and push image to docker registry on every push to master 2025-09-10 20:05:11 +04:00
84dca02c34 fix(database): use migrator and change connection options 2025-09-10 20:04:19 +04:00
6c9d3b3b31 chore(release): bump version to 1.2.2 2025-09-08 07:16:53 +04:00
a348b1b99b refactor(auth): interpret failure to find user as an error 2025-09-08 07:15:22 +04:00
ff12ee5da2 chore(release): bump version to 1.2.1 2025-09-06 21:25:26 +04:00
35f707901f chore(clippy): fix all clippy warnings 2025-09-06 21:24:52 +04:00
edea6c5424 chore(release): bump version to 1.2.0 2025-09-06 20:33:17 +04:00
fdbb872fc3 refactor(dev): move tracing to feature 2025-09-06 20:17:14 +04:00
dbc800fef1 feat(database)!: switch from diesel to sea-orm 2025-09-06 20:09:04 +04:00
e729d84c93 fix: fix typo in error description 2025-09-06 18:42:23 +04:00
cc7adf10ed fix(env): make dotenv file optional 2025-09-06 18:40:04 +04:00
57c1699c9a chore(release): bump version to 1.1.1 2025-09-03 07:13:37 +04:00
298c4f4dd3 feat(auth): send raw token on telegram auth 2025-09-03 07:08:51 +04:00
e3904a255b chore(release): bump version to 1.1.0 2025-09-02 09:23:29 +04:00
829c1cf68d chore(deps): bump calamine version 2025-09-02 09:20:02 +04:00
6a535f8d73 chore(ci): remove creating .env.test 2025-09-02 09:20:02 +04:00
5e5cd53f46 chore(ci): remove creating .env.test 2025-09-02 09:11:30 +04:00
8d59e37976 refactor(android)!: remove FCM support 2025-09-02 09:08:17 +04:00
5e39fc9acc feat(schedule)!: move schedule parser, downloader, and updater to external library
This can be used to support more schedule formats in the future.
2025-09-02 08:59:59 +04:00
7c973bfda0 refactor(middlewares): move MiddlewareError from crate::utility to crate::middlewares 2025-09-02 08:52:15 +04:00
8fba0fc709 feat(env): add ability to set custom .env file for testing on local machine 2025-06-13 01:03:06 +04:00
983ff4fa5e feat(env): expose .env.test 2025-06-13 01:02:08 +04:00
fb6f3fc05f chore(deps): upgrade dependencies 2025-06-13 00:56:58 +04:00
e64011ba16 feat!: add telegram auth and async refactor
- Removed "/schedule/update-download-url" endpoint, this mechanism was replaced by Yandex Cloud FaaS. Ура :)
- Improved schedule caching mechanism.
- Added Telegram WebApp authentication support.
- Reworked endpoints responses and errors mechanism.
- Refactored application state management.
- Make synchronous database operations, middlewares and extractors to asynchronous.
- Made user password field optional to support multiple auth methods.
- Renamed users table column "version" to "android_version" and made it nullable.
2025-06-08 01:43:45 +04:00
6a106a366c feat(parser): add ability to parse mistyped date 2025-06-08 01:03:50 +04:00
4fca22662c feat(parser)!: rework of subgroups parsing 2025-06-08 01:03:00 +04:00
d23092a32a feat(parser): add lesson types "course project" and "course project defense" 2025-05-27 02:06:13 +04:00
01bfa38969 feat(parser): speed improvement, lesson type guessing and parsing of merged lesson cabinets 2025-05-27 02:03:54 +04:00
851ec9225f refactor(parser): improve readability 2025-05-26 21:12:23 +04:00
8de1891724 chore(release): bump version to 1.0.5 2025-05-26 05:30:44 +04:00
4cf6df379e fix(parser): fix lessons merging 2025-05-26 05:24:13 +04:00
ba8b164b6a refactor(parser): rewrite some parts of code 2025-05-26 05:24:08 +04:00
ff9d7d6c3a fix(cache): fix setting cache_update_required flag in cache status 2025-05-25 17:39:23 +04:00
9090716f87 fix(test): fix test sign_up_invalid_group 2025-05-25 15:57:18 +04:00
ee992f1b55 chore(xls): update schedule xls 2025-05-25 15:49:52 +04:00
7f71fb1616 refactor(env): remove unsave env::set_var call 2025-05-25 15:48:43 +04:00
234055eaeb feat(test): add ability to use test env without schedule 2025-05-25 15:48:10 +04:00
fceffb900d release/v1.0.3 2025-04-18 00:29:04 +04:00
49ce0005dc Исправление работы подключения к сайтам из-за отсутствия сертификатов. 2025-04-18 00:28:55 +04:00
4c738085f2 release/v1.0.2 2025-04-18 00:11:55 +04:00
20602eb863 Улучшенное отображение ошибок при обновлении ссылки расписания. 2025-04-18 00:11:05 +04:00
e04d462223 1.0.1 2025-04-17 23:08:58 +04:00
22af02464d Исправление работы авторизации с помощью VK ID. 2025-04-17 23:07:19 +04:00
9a517519db User-Agent для reqwest теперь устанавливается с помощью переменной окружения. 2025-04-17 22:41:42 +04:00
65376e75f7 Workflow для публикации релизов.
- Запускает тесты.
- Собирает приложение.
- Отправляет отладочную информацию в Sentry.
- Собирает и отправляет в реестр Docker image с приложением.
- Создаёт релиз со списком изменений и артефактами сборки.
2025-04-17 21:34:46 +04:00
bef6163c1b Отключение тестов при pull request. 2025-04-17 16:39:39 +04:00
283858fea3 Возможный фикс тестов. 2025-04-17 01:10:19 +04:00
66ad4ef938 Подключение sentry. 2025-04-17 01:07:03 +04:00
28f59389ed Исправление тестов.
FCMClient теперь не инициализируется, если отсутствует требуемая переменная окружения.
2025-04-16 16:38:37 +04:00
e71ab0526d Middleware для явного указания кодировки в заголовке Content-Type. 2025-04-16 16:21:53 +04:00
ff05614404 Исправление обработки времени у пар. 2025-04-16 16:21:18 +04:00
9cc03c4ffe Фильтр эндпоинтов для middleware. 2025-04-16 16:20:32 +04:00
5068fe3069 Обновление документации. 2025-04-15 22:09:10 +04:00
2fd6d787a0 Эндпоинт users/change-group. 2025-04-15 19:39:46 +04:00
7a1b32d843 Эндпоинт users/change-username. 2025-04-15 18:55:45 +04:00
542258df01 Эндпоинт fcm/set-token. 2025-04-15 18:44:43 +04:00
ccaabfe909 Асинхронный вариант MutexScope. 2025-04-15 18:44:03 +04:00
4c5e0761eb Подключение FCM. 2025-04-15 14:35:05 +04:00
057dac5b09 Использование функции для осуществления операций в базе данных вместо ручного блокирования мьютекса. 2025-04-15 14:33:58 +04:00
5b6f5c830f Реформат путей к эндпоинтам.
Добавлен экстрактор пользователя с дополнительными полями.

Добавлена связь таблиц User и FCM.

Завершена реализация авторизации с помощью VK ID.

Добавлен эндпоинт fcm/update-callback/{version}.
2025-04-14 22:08:28 +04:00
680419ea78 0.8.0
Реализованы все требуемые эндпоинты schedule.

Улучшена документация.
2025-03-28 23:24:37 +04:00
30c985a3d7 Добавлена возможность создания ResponseError с описанием ошибки.
Добавлен макрос для трансформации ErrorCode в Response, а также для имплементации треита PartialStatusCode.
2025-03-28 15:42:45 +04:00
70a7480ea3 0.7.0
Добавлена OpenAPI документация эндпоинтов и структур с интерфейсом RapiDoc.

Добавлены derive макросы для преобразования структуры в HttpResponse с помощью ResponderJson и IResponse<T> с помощью IntoIResponse.

Ревью кода эндпоинтов связанных с авторизацией.

Эндпоинт users/me теперь объект пользователя в требуемом виде.
2025-03-28 01:21:49 +04:00
1add903f36 0.6.0
Добавлена проверка токена пользователя для перед обработкой запроса.
2025-03-27 20:03:35 +04:00
f703cc8326 0.5.0
Возвращёна реализация сериализации в json для IResponse

Добавлены типы для экстракции данных из запросов средствами actix-web

Добавлен экстрактор для получения пользователя по токену доступа передаваемому в запросе

Добавлен макрос для автоматической реализации ResponseError для ошибок экстракторов

Добавлен эндпоинт users/me

Из главного проекта исключена зависимость actix-http посредством переноса части тестового функционала в отдельный crate
2025-03-26 08:05:22 +04:00
ab1cbd795e 0.4.0
Авторизация через токен вк

Слияние schedule_parser с проектом

Перенос схемы запросов/ответов в файлы эндпоинтов

Переход с библиотеки jwt на jsonwebtokens
2025-03-25 02:05:27 +04:00
0316f58592 Обновление workflow тестов 2025-03-23 06:19:51 +04:00
a95494d3be Регистрация и тесты эндпоинтов 2025-03-23 06:11:13 +04:00
118 changed files with 9766 additions and 2273 deletions

26
.env.test Normal file
View File

@@ -0,0 +1,26 @@
# Schedule
# SCHEDULE_INIT_URL=
SCHEDULE_DISABLE_AUTO_UPDATE=1
# Basic authorization
JWT_SECRET="test-secret-at-least-256-bits-used"
# VKID
VK_ID_CLIENT_ID=0
VK_ID_REDIRECT_URI="vk0://vk.com/blank.html"
# Telegram Mini-App
TELEGRAM_BOT_ID=0
TELEGRAM_MINI_APP_HOST=example.com
TELEGRAM_TEST_DC=false
# Yandex Cloud
YANDEX_CLOUD_API_KEY=""
YANDEX_CLOUD_FUNC_ID=""
# Firebase
# GOOGLE_APPLICATION_CREDENTIALS=
# LOGGING
RUST_BACKTRACE=1
# RUST_LOG=debug

145
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,145 @@
name: build
on:
push:
branches: [ "master" ]
tags-ignore: [ "release/v*" ]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
BINARY_NAME: schedule-parser-rusted
TEST_DB: ${{ secrets.TEST_DATABASE_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
DOCKER_IMAGE_NAME: ${{ github.repository }}
DOCKER_REGISTRY_HOST: registry.n08i40k.ru
DOCKER_REGISTRY_USERNAME: ${{ github.repository_owner }}
DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
- name: Test
run: |
cargo test
env:
DATABASE_URL: ${{ env.TEST_DB }}
SCHEDULE_DISABLE_AUTO_UPDATE: 1
JWT_SECRET: "test-secret-at-least-256-bits-used"
VK_ID_CLIENT_ID: 0
VK_ID_REDIRECT_URI: "vk0://vk.com/blank.html"
TELEGRAM_BOT_ID: 0
TELEGRAM_MINI_APP_HOST: example.com
TELEGRAM_TEST_DC: false
YANDEX_CLOUD_API_KEY: ""
YANDEX_CLOUD_FUNC_ID: ""
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
- name: Build
run: cargo build --release
- name: Extract debug symbols
run: |
objcopy --only-keep-debug target/release/${{ env.BINARY_NAME }}{,.d}
objcopy --strip-debug --strip-unneeded target/release/${{ env.BINARY_NAME }}
objcopy --add-gnu-debuglink target/release/${{ env.BINARY_NAME }}{.d,}
- name: Setup sentry-cli
uses: matbour/setup-sentry-cli@v2.0.0
with:
version: latest
token: ${{ env.SENTRY_AUTH_TOKEN }}
organization: ${{ env.SENTRY_ORG }}
project: ${{ env.SENTRY_PROJECT }}
- name: Upload debug symbols to Sentry
run: |
sentry-cli debug-files upload --include-sources .
- name: Upload build binary artifact
uses: actions/upload-artifact@v4
with:
name: release-binary
path: target/release/${{ env.BINARY_NAME }}
- name: Upload build debug symbols artifact
uses: actions/upload-artifact@v4
with:
name: release-symbols
path: target/release/${{ env.BINARY_NAME }}.d
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: release-binary
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
- name: Login to Registry
uses: docker/login-action@v3.4.0
with:
registry: ${{ env.DOCKER_REGISTRY_HOST }}
username: ${{ env.DOCKER_REGISTRY_USERNAME }}
password: ${{ env.DOCKER_REGISTRY_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.7.0
with:
images: ${{ env.DOCKER_REGISTRY_HOST }}/${{ env.DOCKER_IMAGE_NAME }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6.15.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
"BINARY_NAME=${{ env.BINARY_NAME }}"
- name: Deploy
run: curl ${{ secrets.DEPLOY_URL }}

173
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,173 @@
name: release
on:
push:
tags: [ "release/v*" ]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
BINARY_NAME: schedule-parser-rusted
TEST_DB: ${{ secrets.TEST_DATABASE_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
DOCKER_IMAGE_NAME: ${{ github.repository }}
DOCKER_REGISTRY_HOST: registry.n08i40k.ru
DOCKER_REGISTRY_USERNAME: ${{ github.repository_owner }}
DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
- name: Test
run: |
cargo test --verbose
env:
DATABASE_URL: ${{ env.TEST_DB }}
SCHEDULE_DISABLE_AUTO_UPDATE: 1
JWT_SECRET: "test-secret-at-least-256-bits-used"
VK_ID_CLIENT_ID: 0
VK_ID_REDIRECT_URI: "vk0://vk.com/blank.html"
TELEGRAM_BOT_ID: 0
TELEGRAM_MINI_APP_HOST: example.com
TELEGRAM_TEST_DC: false
YANDEX_CLOUD_API_KEY: ""
YANDEX_CLOUD_FUNC_ID: ""
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
- name: Build
run: cargo build --release --verbose
- name: Extract debug symbols
run: |
objcopy --only-keep-debug target/release/${{ env.BINARY_NAME }}{,.d}
objcopy --strip-debug --strip-unneeded target/release/${{ env.BINARY_NAME }}
objcopy --add-gnu-debuglink target/release/${{ env.BINARY_NAME }}{.d,}
- name: Setup sentry-cli
uses: matbour/setup-sentry-cli@v2.0.0
with:
version: latest
token: ${{ env.SENTRY_AUTH_TOKEN }}
organization: ${{ env.SENTRY_ORG }}
project: ${{ env.SENTRY_PROJECT }}
- name: Upload debug symbols to Sentry
run: |
sentry-cli debug-files upload --include-sources .
- name: Upload build binary artifact
uses: actions/upload-artifact@v4
with:
name: release-binary
path: target/release/${{ env.BINARY_NAME }}
- name: Upload build debug symbols artifact
uses: actions/upload-artifact@v4
with:
name: release-symbols
path: target/release/${{ env.BINARY_NAME }}.d
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: release-binary
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
- name: Login to Registry
uses: docker/login-action@v3.4.0
with:
registry: ${{ env.DOCKER_REGISTRY_HOST }}
username: ${{ env.DOCKER_REGISTRY_USERNAME }}
password: ${{ env.DOCKER_REGISTRY_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.7.0
with:
images: ${{ env.DOCKER_REGISTRY_HOST }}/${{ env.DOCKER_IMAGE_NAME }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6.15.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
"BINARY_NAME=${{ env.BINARY_NAME }}"
release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs:
- build
- docker
# noinspection GrazieInspection,SpellCheckingInspection
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
run: |
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^)
echo "## Коммиты с прошлого релиза $LAST_TAG" > CHANGELOG.md
git log $LAST_TAG..HEAD --oneline >> CHANGELOG.md
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
pattern: release-*
merge-multiple: true
- name: Create Release
id: create_release
uses: ncipollo/release-action@v1.16.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
artifacts: "${{ env.BINARY_NAME }},${{ env.BINARY_NAME }}.d"
bodyFile: CHANGELOG.md

View File

@@ -1,10 +1,9 @@
name: Tests name: cargo test
on: on:
push: push:
branches: [ "master" ] branches: [ "development" ]
pull_request: tags-ignore: [ "release/v*" ]
branches: [ "master" ]
permissions: permissions:
contents: read contents: read
@@ -19,7 +18,20 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build - name: Build
run: cargo build --verbose run: cargo build
- name: Run tests - name: Run tests
run: cargo test --verbose -p schedule-parser-rusted -p schedule_parser run: cargo test
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
SCHEDULE_DISABLE_AUTO_UPDATE: 1
JWT_SECRET: "test-secret-at-least-256-bits-used"
VK_ID_CLIENT_ID: 0
VK_ID_REDIRECT_URI: "vk0://vk.com/blank.html"
TELEGRAM_BOT_ID: 0
TELEGRAM_MINI_APP_HOST: example.com
TELEGRAM_TEST_DC: false
YANDEX_CLOUD_API_KEY: ""
YANDEX_CLOUD_FUNC_ID: ""

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ schedule.json
teachers.json teachers.json
.env* .env*
/*-firebase-adminsdk-*.json

14
.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>

View File

@@ -2,10 +2,19 @@
<module type="EMPTY_MODULE" version="4"> <module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/lib/schedule_parser/benches" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/lib/schedule_parser/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/lib/schedule_parser/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/actix-macros/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/actix-test/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/schedule-parser/benches" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/schedule-parser/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/providers/base/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/providers/provider-engels-polytechnic/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/providers/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/actix-macros/target" />
<excludeFolder url="file://$MODULE_DIR$/actix-test/target" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
<excludeFolder url="file://$MODULE_DIR$/.idea/dataSources" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

9
.idea/sqldialects.xml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/2025-03-21-211822_create_user_role/down.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/migrations/2025-03-21-212111_create_users/up.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/migrations/2025-03-21-212723_create_fcm/down.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/migrations/2025-03-21-212723_create_fcm/up.sql" dialect="PostgreSQL" />
</component>
</project>

4361
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,81 @@
[workspace] [workspace]
members = ["lib/schedule_parser"] members = ["actix-macros", "actix-test", "providers"]
[package] [package]
name = "schedule-parser-rusted" name = "schedule-parser-rusted"
version = "0.3.0" version = "1.3.1"
edition = "2024" edition = "2024"
publish = false publish = false
[profile.release]
debug = true
[features]
trace = ["tracing", "console-subscriber"]
[dependencies] [dependencies]
bcrypt = "0.17.0" providers = { path = "providers" }
jwt = "0.16.0" actix-macros = { path = "actix-macros" }
hmac = "0.12.1"
sha2 = "0.10.8" # serve api
diesel = { version = "2.2.8", features = ["postgres"] } actix-web = "4"
diesel-derive-enum = { git = "https://github.com/Havunen/diesel-derive-enum.git", features = ["postgres"] }
dotenvy = "0.15.7" # basic
serde = { version = "1.0.219", features = ["derive"] } chrono = { version = "0", features = ["serde"] }
schedule_parser = { path = "./lib/schedule_parser" } derive_more = { version = "2", features = ["full"] }
chrono = "0.4.40" dotenvy = "0"
reqwest = "0.12.15"
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] } # sql
actix-web = "4.10.2" database = { path = "database" }
# logging
env_logger = "0"
# async
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-util = "0"
futures-util = "0"
# authorization
bcrypt = "0"
jsonwebtoken = { version = "10", features = ["use_pem"] }
# creating users
objectid = "0"
# schedule downloader
reqwest = { version = "0", features = ["json"] }
mime = "0"
# error handling
sentry = "0"
sentry-actix = "0"
# [de]serializing
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_with = "3"
sha1 = "0.11.0-rc.2"
# documentation
utoipa = { version = "5", features = ["actix_extras", "chrono"] }
utoipa-rapidoc = { version = "6", features = ["actix-web"] }
utoipa-actix-web = "0"
uuid = { version = "1", features = ["v4"] }
hex-literal = "1"
log = "0"
# telegram webdata deciding and verify
base64 = "0"
percent-encoding = "2"
ed25519-dalek = "3.0.0-pre.1"
# development tracing
console-subscriber = { version = "0", optional = true }
tracing = { version = "0", optional = true }
[dev-dependencies]
providers = { path = "providers", features = ["test"] }
actix-test = { path = "actix-test" }

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM debian:stable-slim
LABEL authors="n08i40k"
ARG BINARY_NAME
WORKDIR /app/
RUN apt update && \
apt install -y libpq5 ca-certificates openssl
COPY ./${BINARY_NAME} /bin/main
RUN chmod +x /bin/main
ENTRYPOINT ["main"]

1
actix-macros/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

12
actix-macros/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "actix-macros"
version = "0.1.0"
edition = "2024"
[dependencies]
syn = "2.0.106"
quote = "1.0.40"
proc-macro2 = "1.0.101"
[lib]
proc-macro = true

170
actix-macros/src/lib.rs Normal file
View File

@@ -0,0 +1,170 @@
extern crate proc_macro;
use proc_macro::TokenStream;
mod shared {
use quote::{ToTokens, quote};
use syn::{Attribute, DeriveInput};
pub fn find_status_code(attrs: &[Attribute]) -> Option<proc_macro2::TokenStream> {
attrs
.iter()
.find_map(|attr| -> Option<proc_macro2::TokenStream> {
if !attr.path().is_ident("status_code") {
return None;
}
let meta = attr.meta.require_name_value().ok()?;
let code = meta.value.to_token_stream().to_string();
let trimmed_code = code.trim_matches('"');
if let Ok(numeric_code) = trimmed_code.parse::<u16>() {
Some(quote! { actix_web::http::StatusCode::from_u16(#numeric_code).unwrap() })
} else {
let string_code: proc_macro2::TokenStream =
trimmed_code.to_string().parse().unwrap();
Some(quote! { #string_code })
}
})
}
pub fn get_arms(ast: &DeriveInput) -> Vec<proc_macro2::TokenStream> {
let name = &ast.ident;
let variants = if let syn::Data::Enum(data) = &ast.data {
&data.variants
} else {
panic!("Only enums are supported");
};
let mut status_code_arms: Vec<proc_macro2::TokenStream> = variants
.iter()
.filter_map(|v| -> Option<proc_macro2::TokenStream> {
let status_code = find_status_code(&v.attrs)?;
let variant_name = &v.ident;
Some(quote! { #name::#variant_name => #status_code, })
})
.collect();
if status_code_arms.len() < variants.len() {
let status_code = find_status_code(&ast.attrs)
.unwrap_or_else(|| quote! { ::actix_web::http::StatusCode::INTERNAL_SERVER_ERROR });
status_code_arms.push(quote! { _ => #status_code });
}
status_code_arms
}
}
mod middleware_error {
use proc_macro::TokenStream;
use quote::quote;
pub fn fmt(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let status_code_arms = super::shared::get_arms(ast);
TokenStream::from(quote! {
impl ::actix_web::ResponseError for #name {
fn status_code(&self) -> ::actix_web::http::StatusCode {
match self {
#(#status_code_arms)*
}
}
fn error_response(&self) -> ::actix_web::HttpResponse<BoxBody> {
::actix_web::HttpResponse::build(self.status_code())
.json(crate::middlewares::error::MiddlewareError::new(self.clone()))
}
}
})
}
}
mod responder_json {
use proc_macro::TokenStream;
use quote::quote;
pub fn fmt(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
TokenStream::from(quote! {
impl ::actix_web::Responder for #name {
type Body = ::actix_web::body::EitherBody<::actix_web::body::BoxBody>;
fn respond_to(self, _: &::actix_web::HttpRequest) -> ::actix_web::HttpResponse<Self::Body> {
::actix_web::HttpResponse::Ok()
.json(self)
.map_into_left_body()
}
}
})
}
}
mod ok_response {
use proc_macro::TokenStream;
use quote::quote;
pub fn fmt(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
TokenStream::from(quote! {
impl crate::routes::schema::PartialOkResponse for #name {}
})
}
}
mod err_response {
use proc_macro::TokenStream;
use quote::quote;
pub fn fmt(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let status_code_arms = super::shared::get_arms(ast);
TokenStream::from(quote! {
impl crate::routes::schema::PartialErrResponse for #name {
fn status_code(&self) -> ::actix_web::http::StatusCode {
match self {
#(#status_code_arms)*
}
}
}
})
}
}
#[proc_macro_derive(MiddlewareError, attributes(status_code))]
pub fn moddleware_error_derive(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap();
middleware_error::fmt(&ast)
}
#[proc_macro_derive(ResponderJson)]
pub fn responser_json_derive(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap();
responder_json::fmt(&ast)
}
#[proc_macro_derive(OkResponse)]
pub fn ok_response_derive(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap();
ok_response::fmt(&ast)
}
#[proc_macro_derive(ErrResponse, attributes(status_code))]
pub fn err_response_derive(input: TokenStream) -> TokenStream {
let ast = syn::parse(input).unwrap();
err_response::fmt(&ast)
}

1
actix-test/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

8
actix-test/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "actix-test"
version = "0.1.0"
edition = "2024"
[dependencies]
actix-http = "3.11.1"
actix-web = "4.11.0"

12
actix-test/src/lib.rs Normal file
View File

@@ -0,0 +1,12 @@
use actix_web::dev::{HttpServiceFactory, Service, ServiceResponse};
use actix_web::{App, test, web};
pub async fn test_app<F, A: 'static>(
app_state: web::Data<A>,
factory: F,
) -> impl Service<actix_http::Request, Response = ServiceResponse, Error = actix_web::Error>
where
F: HttpServiceFactory + 'static,
{
test::init_service(App::new().app_data(app_state).service(factory)).await
}

12
database/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "database"
version = "0.1.0"
edition = "2024"
[dependencies]
migration = { path = "migration" }
entity = { path = "entity" }
sea-orm = { version = "2.0.0-rc.15", features = ["sqlx-postgres", "runtime-tokio"] }
paste = "1"
serde = { version = "1", features = ["derive"] }

1
database/entity/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

View File

@@ -0,0 +1,9 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2024"
[dependencies]
sea-orm = "2.0.0-rc.6"
serde = { version = "1.0.219", features = ["derive"] }
utoipa = "5.4.0"

View File

@@ -0,0 +1,7 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
pub mod prelude;
pub mod sea_orm_active_enums;
pub mod service_user;
pub mod user;

View File

@@ -0,0 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
pub use super::service_user::Entity as ServiceUser;
pub use super::user::Entity as User;

View File

@@ -0,0 +1,25 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
use sea_orm::entity::prelude::*;
#[derive(
Debug,
Clone,
PartialEq,
Eq,
EnumIter,
DeriveActiveEnum,
:: serde :: Serialize,
:: serde :: Deserialize,
:: utoipa :: ToSchema,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_role")]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UserRole {
#[sea_orm(string_value = "student")]
Student,
#[sea_orm(string_value = "teacher")]
Teacher,
#[sea_orm(string_value = "admin")]
Admin,
}

View File

@@ -0,0 +1,16 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "service_user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,25 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
use super::sea_orm_active_enums::UserRole;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
#[sea_orm(unique)]
pub username: String,
pub password: Option<String>,
pub vk_id: Option<i32>,
pub group: Option<String>,
pub role: UserRole,
pub android_version: Option<String>,
#[sea_orm(unique)]
pub telegram_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

1
database/migration/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

View File

@@ -0,0 +1,22 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "2.0.0-rc.15"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
"runtime-tokio", # `ASYNC_RUNTIME` feature
"sqlx-postgres", # `DATABASE_DRIVER` feature
]

View File

@@ -0,0 +1,18 @@
pub use sea_orm_migration::prelude::MigratorTrait;
use sea_orm_migration::prelude::*;
mod m20250904_024854_init;
mod m20251027_230335_add_service_users;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20250904_024854_init::Migration),
Box::new(m20251027_230335_add_service_users::Migration),
]
}
}

View File

@@ -0,0 +1,70 @@
use sea_orm_migration::prelude::extension::postgres::Type;
use sea_orm_migration::sea_orm::{EnumIter, Iterable};
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_type(
Type::create()
.as_enum(UserRole)
.values(UserRoleVariants::iter())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(string_uniq(User::Id).primary_key().not_null())
.col(string_uniq(User::Username).not_null())
.col(string_null(User::Password))
.col(integer_null(User::VkId))
.col(string_null(User::Group))
.col(enumeration(User::Role, UserRole, UserRoleVariants::iter()))
.col(string_null(User::AndroidVersion))
.col(big_integer_null(User::TelegramId).unique_key())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await?;
manager
.drop_type(Type::drop().name(UserRole).to_owned())
.await
}
}
#[derive(DeriveIden)]
struct UserRole;
#[derive(DeriveIden, EnumIter)]
enum UserRoleVariants {
Student,
Teacher,
Admin,
}
#[derive(DeriveIden)]
enum User {
Table,
Id,
Username,
Password,
VkId,
Group,
Role,
AndroidVersion,
TelegramId,
}

View File

@@ -0,0 +1,33 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ServiceUser::Table)
.if_not_exists()
.col(string_uniq(ServiceUser::Id).primary_key().not_null())
.col(string(ServiceUser::Name))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ServiceUser::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ServiceUser {
Table,
Id,
Name,
}

View File

@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

31
database/src/lib.rs Normal file
View File

@@ -0,0 +1,31 @@
pub mod query;
pub use migration;
pub use sea_orm;
pub mod entity {
use serde::{Deserialize, Serialize};
pub use entity::*;
pub use entity::user::{
ActiveModel as ActiveUser, //
Column as UserColumn, //
Entity as UserEntity, //
Model as User, //
};
pub use entity::service_user::{
ActiveModel as ActiveServiceUser, //
Column as ServiceUserColumn, //
Entity as ServiceUserEntity, //
Model as ServiceUser, //
};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UserType {
Default,
Service,
}
}

73
database/src/query.rs Normal file
View File

@@ -0,0 +1,73 @@
use paste::paste;
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::QueryFilter;
pub struct Query;
macro_rules! ref_type {
(String) => {
&String
};
(str) => {
&str
};
($other:ty) => {
$other
};
}
macro_rules! define_is_exists {
($entity: ident, $by: ident, $by_type: ident, $by_column: ident) => {
paste! {
pub async fn [<is_ $entity _exists_by_ $by>](
db: &::sea_orm::DbConn,
$by: ref_type!($by_type)
) -> Result<bool, ::sea_orm::DbErr> {
::entity::$entity::Entity::find()
.filter(::entity::$entity::Column::$by_column.eq($by))
.one(db)
.await
.map(|x| x.is_some())
}
}
};
}
macro_rules! define_find_by {
($entity: ident, $by: ident, $by_type: ident, $by_column: ident) => {
paste! {
pub async fn [<find_ $entity _by_ $by>](
db: &::sea_orm::DbConn,
$by: ref_type!($by_type)
) -> Result<Option<::entity::$entity::Model>, ::sea_orm::DbErr> {
::entity::$entity::Entity::find()
.filter(::entity::$entity::Column::$by_column.eq($by))
.one(db)
.await
}
}
};
}
impl Query {
// User
define_find_by!(user, id, str, Id);
define_find_by!(user, telegram_id, i64, TelegramId);
define_find_by!(user, vk_id, i32, VkId);
define_find_by!(user, username, str, Username);
define_is_exists!(user, id, str, Id);
define_is_exists!(user, username, str, Username);
define_is_exists!(user, telegram_id, i64, TelegramId);
define_is_exists!(user, vk_id, i32, VkId);
// Service user
define_find_by!(service_user, id, str, Id);
define_find_by!(service_user, name, str, Name);
define_is_exists!(service_user, id, str, Id);
define_is_exists!(service_user, name, str, Name);
}

View File

@@ -1,9 +0,0 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/database/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "./migrations"

View File

@@ -1,23 +0,0 @@
[package]
name = "schedule_parser"
version = "0.2.0"
edition = "2024"
[lib]
name = "schedule_parser"
path = "src/lib/lib.rs"
[dependencies]
serde = { version = "1.0.219", features = ["derive"] }
serde_repr = "0.1.20"
chrono = { version = "0.4.40", features = ["serde"] }
calamine = "0.26.1"
regex = "1.11.1"
fuzzy-matcher = "0.3.7"
[dev-dependencies]
criterion = "0.5.1"
[[bench]]
name = "parse"
harness = false

View File

@@ -1,11 +0,0 @@
use criterion::{Criterion, criterion_group, criterion_main};
use schedule_parser::parse_xls;
pub fn bench_parse_xls(c: &mut Criterion) {
let buffer: Vec<u8> = include_bytes!("../../../schedule.xls").to_vec();
c.bench_function("parse_xls", |b| b.iter(|| parse_xls(&buffer)));
}
criterion_group!(benches, bench_parse_xls);
criterion_main!(benches);

View File

@@ -1,704 +0,0 @@
use crate::schema::LessonType::Break;
use crate::schema::{Day, Lesson, LessonSubGroup, LessonTime, LessonType, ScheduleEntity};
use crate::LessonParseResult::{Lessons, Street};
use calamine::{open_workbook_from_rs, Reader, Xls};
use chrono::{Duration, NaiveDateTime};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use regex::Regex;
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::LazyLock;
pub mod schema;
struct InternalId {
/**
* Индекс строки
*/
row: u32,
/**
* Индекс столбца
*/
column: u32,
/**
* Текст в ячейке
*/
name: String,
}
struct InternalTime {
/**
* Временной отрезок проведения пары
*/
time_range: LessonTime,
/**
* Тип пары
*/
lesson_type: LessonType,
/**
* Индекс пары
*/
default_index: Option<u32>,
/**
* Рамка ячейки
*/
xls_range: ((u32, u32), (u32, u32)),
}
type WorkSheet = calamine::Range<calamine::Data>;
fn get_string_from_cell(worksheet: &WorkSheet, row: u32, col: u32) -> Option<String> {
let cell_data = if let Some(data) = worksheet.get((row as usize, col as usize)) {
data.to_string()
} else {
return None;
};
if cell_data.trim().is_empty() {
return None;
}
static NL_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"[\n\r]+").unwrap());
static SP_RE: LazyLock<Regex, fn() -> Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
let trimmed_data = SP_RE
.replace_all(&NL_RE.replace_all(&cell_data, " "), " ")
.trim()
.to_string();
if trimmed_data.is_empty() {
None
} else {
Some(trimmed_data)
}
}
fn get_merge_from_start(worksheet: &WorkSheet, row: u32, column: u32) -> ((u32, u32), (u32, u32)) {
let worksheet_end = worksheet.end().unwrap();
let row_end: u32 = {
let mut r: u32 = 0;
for _r in (row + 1)..worksheet_end.0 {
r = _r;
if let Some(_) = worksheet.get((_r as usize, column as usize)) {
break;
}
}
r
};
let column_end: u32 = {
let mut c: u32 = 0;
for _c in (column + 1)..worksheet_end.1 {
c = _c;
if let Some(_) = worksheet.get((row as usize, _c as usize)) {
break;
}
}
c
};
((row, column), (row_end, column_end))
}
fn parse_skeleton(worksheet: &WorkSheet) -> (Vec<InternalId>, Vec<InternalId>) {
let range = &worksheet;
let mut is_parsed = false;
let mut groups: Vec<InternalId> = Vec::new();
let mut days: Vec<InternalId> = Vec::new();
let start = range.start().expect("Could not find start");
let end = range.end().expect("Could not find end");
let mut row = start.0;
while row < end.0 {
row += 1;
let day_name_opt = get_string_from_cell(&worksheet, row, 0);
if day_name_opt.is_none() {
continue;
}
let day_name = day_name_opt.unwrap();
if !is_parsed {
is_parsed = true;
row -= 1;
for column in (start.1 + 2)..=end.1 {
let group_name = get_string_from_cell(&worksheet, row, column);
if group_name.is_none() {
continue;
}
groups.push(InternalId {
row,
column,
name: group_name.unwrap(),
});
}
row += 1;
}
days.push(InternalId {
row,
column: 0,
name: day_name.clone(),
});
if days.len() > 2 && day_name.starts_with("Суббота") {
break;
}
}
(days, groups)
}
enum LessonParseResult {
Lessons(Vec<Lesson>),
Street(String),
}
trait StringInnerSlice {
fn inner_slice(&self, from: usize, to: usize) -> Self;
}
impl StringInnerSlice for String {
fn inner_slice(&self, from: usize, to: usize) -> Self {
self.chars()
.take(from)
.chain(self.chars().skip(to))
.collect()
}
}
fn guess_lesson_type(name: &String) -> Option<(String, LessonType)> {
let map: HashMap<String, LessonType> = HashMap::from([
("(консультация)".to_string(), LessonType::Consultation),
(
"самостоятельная работа".to_string(),
LessonType::IndependentWork,
),
("зачет".to_string(), LessonType::Exam),
("зачет с оценкой".to_string(), LessonType::ExamWithGrade),
("экзамен".to_string(), LessonType::ExamDefault),
]);
let matcher = SkimMatcherV2::default();
let name_lower = name.to_lowercase();
type SearchResult<'a> = (&'a LessonType, i64, Vec<usize>);
let mut search_results: Vec<SearchResult> = map
.iter()
.map(|entry| -> SearchResult {
if let Some((score, indices)) = matcher.fuzzy_indices(&*name_lower, entry.0) {
return (entry.1, score, indices);
}
(entry.1, 0, Vec::new())
})
.collect();
search_results.sort_by(|a, b| b.1.cmp(&a.1));
let guessed_type = search_results.first().unwrap();
if guessed_type.1 > 80 {
Some((
name.inner_slice(guessed_type.2[0], guessed_type.2[guessed_type.2.len() - 1]),
guessed_type.0.clone(),
))
} else {
None
}
}
fn parse_lesson(
worksheet: &WorkSheet,
day: &mut Day,
day_times: &Vec<InternalTime>,
time: &InternalTime,
column: u32,
) -> LessonParseResult {
let row = time.xls_range.0.0;
let (name, lesson_type) = {
let raw_name_opt = get_string_from_cell(&worksheet, row, column);
if raw_name_opt.is_none() {
return Lessons(Vec::new());
}
let raw_name = raw_name_opt.unwrap();
static OTHER_STREET_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"^[А-Я][а-я]+,?\s?[0-9]+$").unwrap());
if OTHER_STREET_RE.is_match(&raw_name) {
return Street(raw_name);
}
if let Some(guess) = guess_lesson_type(&raw_name) {
guess
} else {
(raw_name, time.lesson_type.clone())
}
};
let (default_range, lesson_time): (Option<[u8; 2]>, LessonTime) = {
// check if multi-lesson
let cell_range = get_merge_from_start(worksheet, row, column);
let end_time_arr = day_times
.iter()
.filter(|time| time.xls_range.1.0 == cell_range.1.0)
.collect::<Vec<&InternalTime>>();
let end_time = end_time_arr.first().expect("Unable to find lesson time!");
let range: Option<[u8; 2]> = if time.default_index != None {
let default = time.default_index.unwrap() as u8;
Some([default, end_time.default_index.unwrap() as u8])
} else {
None
};
let time = LessonTime {
start: time.time_range.start,
end: end_time.time_range.end,
};
(range, time)
};
let (name, mut subgroups) = parse_name_and_subgroups(&name);
{
let cabinets: Vec<String> = parse_cabinets(worksheet, row, column + 1);
// Если количество кабинетов равно 1, назначаем этот кабинет всем подгруппам
if cabinets.len() == 1 {
for subgroup in &mut subgroups {
subgroup.cabinet = Some(cabinets.get(0).or(Some(&String::new())).unwrap().clone())
}
}
// Если количество кабинетов совпадает с количеством подгрупп, назначаем кабинеты по порядку
else if cabinets.len() == subgroups.len() {
for subgroup in &mut subgroups {
subgroup.cabinet = Some(
cabinets
.get((subgroup.number - 1) as usize)
.unwrap()
.clone(),
);
}
}
// Если количество кабинетов больше количества подгрупп, делаем ещё одну подгруппу.
else if cabinets.len() > subgroups.len() {
for index in 0..subgroups.len() {
subgroups[index].cabinet = Some(cabinets[index].clone());
}
while cabinets.len() > subgroups.len() {
subgroups.push(LessonSubGroup {
number: (subgroups.len() + 1) as u8,
cabinet: Some(cabinets[subgroups.len()].clone()),
teacher: "Ошибка в расписании".to_string(),
});
}
}
// Если кабинетов нет, но есть подгруппы, назначаем им значение "??"
else {
for subgroup in &mut subgroups {
subgroup.cabinet = Some("??".to_string());
}
}
cabinets
};
let lesson = Lesson {
lesson_type,
default_range,
name: Some(name),
time: lesson_time,
subgroups: Some(subgroups),
group: None,
};
let prev_lesson = if day.lessons.len() == 0 {
return Lessons(Vec::from([lesson]));
} else {
&day.lessons[day.lessons.len() - 1]
};
Lessons(Vec::from([
Lesson {
lesson_type: Break,
default_range: None,
name: None,
time: LessonTime {
start: prev_lesson.time.end,
end: lesson.time.start,
},
subgroups: Some(Vec::new()),
group: None,
},
lesson,
]))
}
fn parse_cabinets(worksheet: &WorkSheet, row: u32, column: u32) -> Vec<String> {
let mut cabinets: Vec<String> = Vec::new();
if let Some(raw) = get_string_from_cell(&worksheet, row, column) {
let clean = raw.replace("\n", " ");
let parts: Vec<&str> = clean.split(" ").collect();
for part in parts {
let clean_part = part.to_string().trim().to_string();
cabinets.push(clean_part);
}
}
cabinets
}
fn parse_name_and_subgroups(name: &String) -> (String, Vec<LessonSubGroup>) {
static LESSON_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"(?:[А-Я][а-я]+[А-Я]{2}(?:\([0-9][а-я]+\))?)+$").unwrap());
static TEACHER_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"([А-Я][а-я]+)([А-Я])([А-Я])(?:\(([0-9])[а-я]+\))?").unwrap());
static CLEAN_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"[\s.,]+").unwrap());
static NAME_CLEAN_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"\.\s+$").unwrap());
let (teachers, lesson_name) = {
let clean_name = CLEAN_RE.replace_all(&name, "").to_string();
if let Some(captures) = LESSON_RE.captures(&clean_name) {
let capture = captures.get(0).unwrap();
let capture_str = capture.as_str().to_string();
let capture_name: String = capture_str.chars().take(5).collect();
(
NAME_CLEAN_RE.replace(&capture_str, "").to_string(),
name[0..name.find(&*capture_name).unwrap()].to_string(),
)
} else {
return (NAME_CLEAN_RE.replace(&name, "").to_string(), Vec::new());
}
};
let mut subgroups: Vec<LessonSubGroup> = Vec::new();
let teacher_it = TEACHER_RE.captures_iter(&teachers);
for captures in teacher_it {
subgroups.push(LessonSubGroup {
number: if let Some(capture) = captures.get(4) {
capture
.as_str()
.to_string()
.parse::<u8>()
.expect("Unable to read subgroup index!")
} else {
0
},
cabinet: None,
teacher: format!(
"{} {}.{}.",
captures.get(1).unwrap().as_str().to_string(),
captures.get(2).unwrap().as_str().to_string(),
captures.get(3).unwrap().as_str().to_string()
),
})
}
// фикс, если у кого-то отсутствует индекс подгруппы
if subgroups.len() == 1 {
let index = subgroups[0].number;
if index == 0 {
subgroups[0].number = 1u8;
} else {
subgroups.push(LessonSubGroup {
number: if index == 1 { 2 } else { 1 },
cabinet: None,
teacher: "Только у другой".to_string(),
});
}
} else if subgroups.len() == 2 {
// если индексы отсутствуют у обоих, ставим поочерёдно
if subgroups[0].number == 0 && subgroups[1].number == 0 {
subgroups[0].number = 1;
subgroups[1].number = 2;
}
// если индекс отсутствует у первого, ставим 2, если у второго индекс 1 и наоборот
else if subgroups[0].number == 0 {
subgroups[0].number = if subgroups[1].number == 1 { 2 } else { 1 };
}
// если индекс отсутствует у второго, ставим 2, если у первого индекс 1 и наоборот
else if subgroups[1].number == 0 {
subgroups[1].number = if subgroups[0].number == 1 { 2 } else { 1 };
}
}
if subgroups.len() == 2 && subgroups[0].number == 2 && subgroups[1].number == 1 {
subgroups.reverse()
}
(lesson_name, subgroups)
}
fn convert_groups_to_teachers(
groups: &HashMap<String, ScheduleEntity>,
) -> HashMap<String, ScheduleEntity> {
let mut teachers: HashMap<String, ScheduleEntity> = HashMap::new();
let empty_days: Vec<Day> = groups
.values()
.next()
.unwrap()
.days
.iter()
.map(|day| Day {
name: day.name.clone(),
street: day.street.clone(),
date: day.date.clone(),
lessons: vec![],
})
.collect();
for group in groups.values() {
for (index, day) in group.days.iter().enumerate() {
for group_lesson in &day.lessons {
if group_lesson.lesson_type == Break {
continue;
}
if group_lesson.subgroups.is_none() {
continue;
}
let subgroups = group_lesson.subgroups.as_ref().unwrap();
for subgroup in subgroups {
if subgroup.teacher == "Ошибка в расписании" {
continue;
}
if !teachers.contains_key(&subgroup.teacher) {
teachers.insert(
subgroup.teacher.clone(),
ScheduleEntity {
name: subgroup.teacher.clone(),
days: empty_days.to_vec(),
},
);
}
let teacher_day = teachers
.get_mut(&subgroup.teacher)
.unwrap()
.days
.get_mut(index)
.unwrap();
teacher_day.lessons.push({
let mut lesson = group_lesson.clone();
lesson.group = Some(group.name.clone());
lesson
});
}
}
}
}
teachers
}
pub fn parse_xls(
buffer: &Vec<u8>,
) -> (
HashMap<String, ScheduleEntity>,
HashMap<String, ScheduleEntity>,
) {
let cursor = Cursor::new(&buffer);
let mut workbook: Xls<_> = open_workbook_from_rs(cursor).expect("Can't open workbook");
let worksheet: WorkSheet = workbook
.worksheets()
.first()
.expect("No worksheet found")
.1
.to_owned();
let (days_markup, groups_markup) = parse_skeleton(&worksheet);
let mut groups: HashMap<String, ScheduleEntity> = HashMap::new();
let mut days_times: Vec<Vec<InternalTime>> = Vec::new();
let saturday_end_row = worksheet.end().unwrap().0;
for group_markup in groups_markup {
let mut group = ScheduleEntity {
name: group_markup.name,
days: Vec::new(),
};
for day_index in 0..(&days_markup).len() {
let day_markup = &days_markup[day_index];
let mut day = {
let space_index = day_markup.name.find(' ').unwrap();
let name = day_markup.name[..space_index].to_string();
let date_raw = day_markup.name[space_index + 1..].to_string();
let date_add = format!("{} 00:00:00", date_raw);
let date = NaiveDateTime::parse_from_str(&*date_add, "%d.%m.%Y %H:%M:%S");
Day {
name,
street: None,
date: date.unwrap().and_utc(),
lessons: Vec::new(),
}
};
let lesson_time_column = days_markup[0].column + 1;
let row_distance = if day_index != days_markup.len() - 1 {
days_markup[day_index + 1].row
} else {
saturday_end_row
} - day_markup.row;
if days_times.len() != 6 {
let mut day_times: Vec<InternalTime> = Vec::new();
for row in day_markup.row..(day_markup.row + row_distance) {
// time
let time_opt = get_string_from_cell(&worksheet, row, lesson_time_column);
if time_opt.is_none() {
continue;
}
let time = time_opt.unwrap();
// type
let lesson_type = if time.contains("пара") {
LessonType::Default
} else {
LessonType::Additional
};
// lesson index
let default_index = if lesson_type == LessonType::Default {
Some(
time.chars()
.next()
.unwrap()
.to_string()
.parse::<u32>()
.unwrap(),
)
} else {
None
};
// time
let time_range = {
static TIME_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"(\d+\.\d+)-(\d+\.\d+)").unwrap());
let parse_res = TIME_RE
.captures(&time)
.expect("Unable to obtain lesson start and end!");
let start_match = parse_res.get(1).unwrap().as_str();
let start_parts: Vec<&str> = start_match.split(".").collect();
let end_match = parse_res.get(2).unwrap().as_str();
let end_parts: Vec<&str> = end_match.split(".").collect();
LessonTime {
start: day.date.clone()
+ Duration::hours(start_parts[0].parse().unwrap())
+ Duration::minutes(start_parts[1].parse().unwrap()),
end: day.date.clone()
+ Duration::hours(end_parts[0].parse().unwrap())
+ Duration::minutes(end_parts[1].parse().unwrap()),
}
};
day_times.push(InternalTime {
time_range,
lesson_type,
default_index,
xls_range: get_merge_from_start(&worksheet, row, lesson_time_column),
});
}
days_times.push(day_times);
}
let day_times = &days_times[day_index];
for time in day_times {
match &mut parse_lesson(
&worksheet,
&mut day,
&day_times,
&time,
group_markup.column,
) {
Lessons(l) => day.lessons.append(l),
Street(s) => day.street = Some(s.to_owned()),
}
}
group.days.push(day);
}
groups.insert(group.name.clone(), group);
}
(convert_groups_to_teachers(&groups), groups)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read() {
let buffer: Vec<u8> = include_bytes!("../../../../schedule.xls").to_vec();
let result = parse_xls(&buffer);
assert_ne!(result.0.len(), 0);
assert_ne!(result.1.len(), 0);
}
}

View File

@@ -1,107 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LessonTime {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Clone)]
#[repr(u8)]
pub enum LessonType {
Default = 0, // Обычная
Additional, // Допы
Break, // Перемена
Consultation, // Консультация
IndependentWork, // Самостоятельная работа
Exam, // Зачёт
ExamWithGrade, // Зачет с оценкой
ExamDefault, // Экзамен
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LessonSubGroup {
pub number: u8,
pub cabinet: Option<String>,
pub teacher: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Lesson {
/**
* Тип занятия
*/
#[serde(rename = "type")]
pub lesson_type: LessonType,
/**
* Индексы пар, если присутствуют
*/
#[serde(rename = "defaultRange")]
pub default_range: Option<[u8; 2]>,
/**
* Название занятия
*/
pub name: Option<String>,
/**
* Начало и конец занятия
*/
pub time: LessonTime,
/**
* Подгруппы
*/
#[serde(rename = "subGroups")]
pub subgroups: Option<Vec<LessonSubGroup>>,
/**
* Группа (только для расписания преподавателей)
*/
pub group: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Day {
pub name: String,
pub street: Option<String>,
pub date: DateTime<Utc>,
pub lessons: Vec<Lesson>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ScheduleEntity {
pub name: String,
pub days: Vec<Day>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Schedule {
#[serde(rename = "updatedAt")]
pub updated_at: DateTime<Utc>,
pub groups: HashMap<String, ScheduleEntity>,
#[serde(rename = "updatedGroups")]
pub updated_groups: Vec<Vec<usize>>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TeacherSchedule {
#[serde(rename = "updatedAt")]
pub updated_at: DateTime<Utc>,
pub teacher: ScheduleEntity,
pub updated: Vec<usize>,
}

View File

View File

@@ -1,6 +0,0 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@@ -1,36 +0,0 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -1 +0,0 @@
DROP TYPE user_role;

View File

@@ -1,4 +0,0 @@
CREATE TYPE user_role AS ENUM (
'STUDENT',
'TEACHER',
'ADMIN');

View File

@@ -1 +0,0 @@
DROP TABLE users;

View File

@@ -1,11 +0,0 @@
CREATE TABLE users
(
id text PRIMARY KEY NOT NULL,
username text UNIQUE NOT NULL,
"password" text NOT NULL,
vk_id int4 NULL,
access_token text UNIQUE NOT NULL,
"group" text NOT NULL,
role user_role NOT NULL,
version text NOT NULL
);

View File

@@ -1 +0,0 @@
DROP TABLE fcm;

View File

@@ -1,11 +0,0 @@
CREATE TABLE fcm
(
user_id text PRIMARY KEY NOT NULL,
token text NOT NULL,
topics text[] NULL
);
CREATE UNIQUE INDEX fcm_user_id_key ON fcm USING btree (user_id);
ALTER TABLE fcm
ADD CONSTRAINT fcm_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT ON UPDATE CASCADE;

12
providers/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "providers"
version = "0.1.0"
edition = "2024"
[features]
test = ["provider-engels-polytechnic/test"]
[dependencies]
base = { path = "base" }
provider-engels-polytechnic = { path = "provider-engels-polytechnic" }

17
providers/base/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "base"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio-util = "0.7.16"
async-trait = "0.1.89"
chrono = { version = "0.4.41", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_repr = "0.1.20"
utoipa = { version = "5.4.0", features = ["macros", "chrono"] }
sha1 = "0.11.0-rc.2"

View File

@@ -0,0 +1,53 @@
use sha1::Digest;
use sha1::digest::OutputSizeUser;
use sha1::digest::typenum::Unsigned;
use std::hash::Hasher;
/// Hesher returning hash from the algorithm implementing Digest
pub struct DigestHasher<D: Digest> {
digest: D,
}
impl<D> DigestHasher<D>
where
D: Digest,
{
/// Obtain hash.
pub fn finalize(self) -> String {
static ALPHABET: [char; 16] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
];
let mut hex = String::with_capacity(<D as OutputSizeUser>::OutputSize::USIZE * 2);
for byte in self.digest.finalize().0.into_iter() {
let byte: u8 = byte;
hex.push(ALPHABET[(byte >> 4) as usize]);
hex.push(ALPHABET[(byte & 0xF) as usize]);
}
hex
}
}
impl<D> From<D> for DigestHasher<D>
where
D: Digest,
{
/// Creating a hash from an algorithm implementing Digest.
fn from(digest: D) -> Self {
DigestHasher { digest }
}
}
impl<D: Digest> Hasher for DigestHasher<D> {
/// Stopper to prevent calling the standard Hasher result.
fn finish(&self) -> u64 {
unimplemented!("Do not call finish()");
}
fn write(&mut self, bytes: &[u8]) {
self.digest.update(bytes);
}
}

231
providers/base/src/lib.rs Normal file
View File

@@ -0,0 +1,231 @@
use crate::hasher::DigestHasher;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use sha1::{Digest, Sha1};
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use utoipa::ToSchema;
mod hasher;
// pub(crate) mod internal {
// use super::{LessonBoundaries, LessonType};
// use chrono::{DateTime, Utc};
//
// /// Data cell storing the group name.
// pub struct GroupCellInfo {
// /// Column index.
// pub column: u32,
//
// /// Text in the cell.
// pub name: String,
// }
//
// /// Data cell storing the line.
// pub struct DayCellInfo {
// /// Line index.
// pub row: u32,
//
// /// Column index.
// pub column: u32,
//
// /// Day name.
// pub name: String,
//
// /// Date of the day.
// pub date: DateTime<Utc>,
// }
//
// /// Data on the time of lessons from the second column of the schedule.
// pub struct BoundariesCellInfo {
// /// Temporary segment of the lesson.
// pub time_range: LessonBoundaries,
//
// /// Type of lesson.
// pub lesson_type: LessonType,
//
// /// The lesson index.
// pub default_index: Option<u32>,
//
// /// The frame of the cell.
// pub xls_range: ((u32, u32), (u32, u32)),
// }
// }
/// The beginning and end of the lesson.
#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct LessonBoundaries {
/// The beginning of a lesson.
pub start: DateTime<Utc>,
/// The end of the lesson.
pub end: DateTime<Utc>,
}
/// Type of lesson.
#[derive(Clone, Hash, PartialEq, Debug, Serialize_repr, Deserialize_repr, ToSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[repr(u8)]
pub enum LessonType {
/// Обычная.
Default = 0,
/// Допы.
Additional,
/// Перемена.
Break,
/// Консультация.
Consultation,
/// Самостоятельная работа.
IndependentWork,
/// Зачёт.
Exam,
/// Зачёт с оценкой.
ExamWithGrade,
/// Экзамен.
ExamDefault,
/// Курсовой проект.
CourseProject,
/// Защита курсового проекта.
CourseProjectDefense,
/// Практическое занятие.
Practice,
/// Дифференцированный зачёт.
DifferentiatedExam,
}
#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct LessonSubGroup {
/// Cabinet, if present.
pub cabinet: Option<String>,
/// Full name of the teacher.
pub teacher: Option<String>,
}
#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct Lesson {
/// Type.
#[serde(rename = "type")]
pub lesson_type: LessonType,
/// Lesson indexes, if present.
pub range: Option<[u8; 2]>,
/// Name.
pub name: Option<String>,
/// The beginning and end.
pub time: LessonBoundaries,
/// List of subgroups.
#[serde(rename = "subgroups")]
pub subgroups: Option<Vec<Option<LessonSubGroup>>>,
/// Group name, if this is a schedule for teachers.
pub group: Option<String>,
}
#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct Day {
/// Day of the week.
pub name: String,
/// Address of another corps.
pub street: Option<String>,
/// Date.
pub date: DateTime<Utc>,
/// List of lessons on this day.
pub lessons: Vec<Lesson>,
}
#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct ScheduleEntry {
/// The name of the group or name of the teacher.
pub name: String,
/// List of six days.
pub days: Vec<Day>,
}
#[derive(Clone)]
pub struct ParsedSchedule {
/// List of groups.
pub groups: HashMap<String, ScheduleEntry>,
/// List of teachers.
pub teachers: HashMap<String, ScheduleEntry>,
}
/// Represents a snapshot of the schedule parsed from an XLS file.
#[derive(Clone)]
pub struct ScheduleSnapshot {
/// Timestamp when the Polytechnic website was queried for the schedule.
pub fetched_at: DateTime<Utc>,
/// Timestamp indicating when the schedule was last updated on the Polytechnic website.
///
/// <note>
/// This value is determined by the website's content and does not depend on the application.
/// </note>
pub updated_at: DateTime<Utc>,
/// URL pointing to the XLS file containing the source schedule data.
pub url: String,
/// Parsed schedule data in the application's internal representation.
pub data: ParsedSchedule,
}
impl ScheduleSnapshot {
/// Converting the schedule data into a hash.
/// ### Important!
/// The hash does not depend on the dates.
/// If the application is restarted, but the file with source schedule will remain unchanged, then the hash will not change.
pub fn hash(&self) -> String {
let mut hasher = DigestHasher::from(Sha1::new());
self.data.teachers.iter().for_each(|e| e.hash(&mut hasher));
self.data.groups.iter().for_each(|e| e.hash(&mut hasher));
hasher.finalize()
}
/// Simply updates the value of [`ScheduleSnapshot::fetched_at`].
/// Used for auto-updates.
pub fn update(&mut self) {
self.fetched_at = Utc::now();
}
}
#[async_trait]
pub trait ScheduleProvider
where
Self: Sync + Send,
{
/// Returns ok when task has been canceled.
/// Returns err when error appeared while trying to parse or download schedule
async fn start_auto_update_task(
&self,
cancellation_token: CancellationToken,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>>;
async fn get_schedule(&self) -> Arc<ScheduleSnapshot>;
}

View File

@@ -0,0 +1,31 @@
[package]
name = "provider-engels-polytechnic"
version = "0.2.3"
edition = "2024"
[features]
test = []
[dependencies]
base = { path = "../base" }
tokio = { version = "1", features = ["sync", "macros", "time"] }
tokio-util = "0"
chrono = { version = "0", features = ["serde"] }
derive_more = { version = "2", features = ["error", "display", "from"] }
utoipa = { version = "5", features = ["macros", "chrono"] }
calamine = "0"
async-trait = "0"
reqwest = "0"
ua_generator = "0"
regex = "1"
strsim = "0"
log = "0"
sentry = "0"
fancy-regex = "0"

View File

@@ -0,0 +1,84 @@
pub use crate::updater::{UpdateSource, Updater};
use async_trait::async_trait;
use base::{ScheduleProvider, ScheduleSnapshot};
use std::ops::DerefMut;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::interval;
use tokio_util::sync::CancellationToken;
mod parser;
mod updater;
mod xls_downloader;
#[cfg(feature = "test")]
pub mod test_utils {
pub use crate::parser::test_utils::test_result;
}
pub struct EngelsPolytechnicProvider {
updater: Updater,
snapshot: Arc<ScheduleSnapshot>,
}
impl EngelsPolytechnicProvider {
pub async fn get(
update_source: UpdateSource,
) -> Result<Arc<dyn ScheduleProvider>, crate::updater::Error> {
let (updater, snapshot) = Updater::new(update_source).await?;
Ok(Arc::new(Wrapper {
inner: RwLock::new(Self {
updater,
snapshot: Arc::new(snapshot),
}),
}))
}
}
pub struct Wrapper {
inner: RwLock<EngelsPolytechnicProvider>,
}
#[async_trait]
impl ScheduleProvider for Wrapper {
async fn start_auto_update_task(
&self,
cancellation_token: CancellationToken,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
let mut ticker = interval(Duration::from_secs(60 * 30));
ticker.tick().await; // bc we already have the latest schedule, when instantiating provider
loop {
tokio::select! {
_ = ticker.tick() => {
let mut lock = self.inner.write().await;
let this= lock.deref_mut();
log::info!("Updating schedule...");
match this.updater.update(&this.snapshot).await {
Ok(snapshot) => {
this.snapshot = Arc::new(snapshot);
},
Err(updater::Error::EmptyUri) => {},
Err(err) => {
sentry::capture_error(&err);
}
}
}
_ = cancellation_token.cancelled() => {
return Ok(());
}
}
}
}
async fn get_schedule(&self) -> Arc<ScheduleSnapshot> {
self.inner.read().await.snapshot.clone()
}
}

View File

@@ -0,0 +1,25 @@
use crate::parser::worksheet::CellPos;
use derive_more::{Display, Error, From};
#[derive(Debug, Display, Error, From)]
pub enum Error {
#[from]
BadXls(calamine::XlsError),
#[display("No work sheets found.")]
NoWorkSheets,
#[display("There is no data on work sheet boundaries.")]
UnknownWorkSheetRange,
#[display("Failed to read lesson start and end of lesson at {_0}.")]
NoLessonBoundaries(CellPos),
#[display("No start and end times matching the lesson (at {_0}) was found.")]
LessonTimeNotFound(CellPos),
#[display("Unknown lesson type `{type}` at {pos}")]
UnknownLessonType { pos: CellPos, r#type: String },
}
pub type Result<T> = core::result::Result<T, Error>;

View File

@@ -0,0 +1,21 @@
#[macro_export]
macro_rules! or_continue {
( $e:expr ) => {{
if let Some(x) = $e {
x
} else {
continue;
}
}};
}
#[macro_export]
macro_rules! or_break {
( $e:expr ) => {{
if let Some(x) = $e {
x
} else {
break;
}
}};
}

View File

@@ -0,0 +1,859 @@
pub use self::error::{Error, Result};
use crate::or_continue;
use crate::parser::worksheet::{CellPos, CellRange, WorkSheet};
use crate::parser::LessonParseResult::{Lessons, Street};
use base::LessonType::Break;
use base::{
Day, Lesson, LessonBoundaries, LessonSubGroup, LessonType, ParsedSchedule, ScheduleEntry,
};
use calamine::{open_workbook_from_rs, Reader, Xls};
use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Utc};
use regex::Regex;
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::LazyLock;
mod error;
mod macros;
mod worksheet;
/// Data cell storing the group name.
pub struct GroupMarkup {
/// Column index.
pub column: u32,
/// Text in the cell.
pub name: String,
}
/// Data cell storing the line.
pub struct DayMarkup {
/// Line index.
pub row: u32,
/// Column index.
pub column: u32,
/// Day name.
pub name: String,
/// Date of the day.
pub date: DateTime<Utc>,
}
pub struct WorkSheetMarkup {
days: Box<[DayMarkup]>,
groups: Box<[GroupMarkup]>,
}
/// Data on the time of lessons from the second column of the schedule.
pub struct BoundariesData {
/// Temporary segment of the lesson.
pub time_range: LessonBoundaries,
/// Type of lesson.
pub lesson_type: LessonType,
/// The lesson index.
pub default_index: Option<u32>,
/// The frame of the cell.
pub range: CellRange,
}
/// Obtaining a "skeleton" schedule from the working sheet.
fn parse_markup(worksheet: &WorkSheet) -> Result<WorkSheetMarkup> {
struct PartialDayMarkup {
row: u32,
name: String,
date: Option<DateTime<Utc>>,
}
let mut groups: Vec<GroupMarkup> = Vec::new();
let mut days: Vec<PartialDayMarkup> = Vec::new();
let (start_row, start_col) = worksheet.start().ok_or(Error::UnknownWorkSheetRange)?;
let (end_row, end_col) = worksheet.end().ok_or(Error::UnknownWorkSheetRange)?;
let mut row = start_row;
while row < end_row {
row += 1;
let day_full_name = or_continue!(worksheet.get_string_from_cell(row, 0));
// parse groups row when days column will found
if groups.is_empty() {
// переход на предыдущую строку
row -= 1;
for column in (start_col + 2)..=end_col {
groups.push(GroupMarkup {
column,
name: or_continue!(worksheet.get_string_from_cell(row, column))
.replace(" ", ""),
});
}
// возврат на текущую строку
row += 1;
}
let (day_name, day_date) = {
let space_index = match day_full_name.find(' ') {
Some(index) => {
if index < 10 {
break;
} else {
index
}
}
None => break,
};
let name = day_full_name[..space_index].to_string();
let date_slice = &day_full_name[space_index + 1..];
let date = NaiveDate::parse_from_str(date_slice, "%d.%m.%Y")
.map(|date| date.and_time(NaiveTime::default()).and_utc())
.ok();
(name, date)
};
days.push(PartialDayMarkup {
row,
name: day_name,
date: day_date,
});
}
// fix unparsable day dates
let days_max = days.len().min(5);
for i in 0..days_max {
if days[i].date.is_none() && days[i + 1].date.is_some() {
days[i].date = Some(days[i + 1].date.unwrap() - Duration::days(1));
}
}
for i in 0..days_max {
let i = days_max - i;
if days[i - 1].date.is_none() && days[i].date.is_some() {
days[i - 1].date = Some(days[i].date.unwrap() - Duration::days(1));
}
}
let days = days
.into_iter()
.map(|day| DayMarkup {
row: day.row,
column: 0,
name: day.name,
date: day.date.unwrap(),
})
.collect();
Ok(WorkSheetMarkup {
days,
groups: groups.into_boxed_slice(),
})
}
/// The result of obtaining a lesson from the cell.
enum LessonParseResult {
/// List of lessons long from one to two.
///
/// The number of lessons will be equal to one if the couple is the first in the day,
/// otherwise the list from the change template and the lesson itself will be returned.
Lessons(Vec<Lesson>),
/// Street on which the Polytechnic Corps is located.
Street(String),
}
// noinspection GrazieInspection
/// Obtaining a non-standard type of lesson by name.
fn guess_lesson_type(text: &str) -> Option<LessonType> {
static MAP: LazyLock<HashMap<&str, LessonType>> = LazyLock::new(|| {
HashMap::from([
("о важном", LessonType::Additional),
("консультация", LessonType::Consultation),
("самостоятельная работа", LessonType::IndependentWork),
("зачет", LessonType::Exam),
("зачет с оценкой", LessonType::ExamWithGrade),
("экзамен", LessonType::ExamDefault),
("курсовой проект", LessonType::CourseProject),
("защита курсового проекта", LessonType::CourseProjectDefense),
("практическое занятие", LessonType::Practice),
("дифференцированный зачет", LessonType::DifferentiatedExam),
])
});
let name_lower = text.to_lowercase();
MAP.iter()
.map(|(text, lesson_type)| (lesson_type, strsim::levenshtein(text, &name_lower)))
.filter(|x| x.1 <= 4)
.min_by_key(|(_, score)| *score)
.map(|v| v.0.clone())
}
/// Getting a pair or street from a cell.
fn parse_lesson(
worksheet: &WorkSheet,
day: &Day,
day_boundaries: &[BoundariesData],
lesson_boundaries: &BoundariesData,
group_column: u32,
) -> Result<LessonParseResult> {
let row = lesson_boundaries.range.start.row;
let name = {
let cell_data = match worksheet.get_string_from_cell(row, group_column) {
Some(x) => x,
None => return Ok(Lessons(Vec::new())),
};
static OTHER_STREET_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[А-Я][а-я]+[,\s]+д\.\s\d+$").unwrap());
if OTHER_STREET_RE.is_match(&cell_data) {
return Ok(Street(cell_data));
}
cell_data
};
let lesson_cell_range = worksheet.get_merge_from_start(row, group_column);
let (default_range, lesson_time) = {
let end_time_arr = day_boundaries
.iter()
.filter(
|BoundariesData {
range: CellRange { end, .. },
..
}| { lesson_cell_range.end.row <= end.row },
)
.collect::<Vec<&BoundariesData>>();
let end_time = end_time_arr
.first()
.ok_or(Error::LessonTimeNotFound(CellPos::new(row, group_column)))?;
let range: Option<[u8; 2]> = if lesson_boundaries.default_index.is_some() {
let default = lesson_boundaries.default_index.unwrap() as u8;
Some([default, end_time.default_index.unwrap() as u8])
} else {
None
};
let time = LessonBoundaries {
start: lesson_boundaries.time_range.start,
end: end_time.time_range.end,
};
(range, time)
};
let ParsedLessonName {
name,
mut subgroups,
r#type: lesson_type,
} = parse_name_and_subgroups(&name, row, group_column)?;
{
let cabinets: Vec<String> = parse_cabinets(
worksheet,
(lesson_cell_range.start.row, lesson_cell_range.end.row),
group_column + 1,
);
let cab_count = cabinets.len();
if cab_count == 1 {
// Назначаем этот кабинет всем подгруппам
let cab = Some(cabinets.first().unwrap().clone());
for subgroup in subgroups.iter_mut().flatten() {
subgroup.cabinet = cab.clone()
}
} else if cab_count == 2 {
while subgroups.len() < cab_count {
subgroups.push(subgroups.last().unwrap_or(&None).clone());
}
for i in 0..cab_count {
let subgroup = subgroups.get_mut(i).unwrap();
let cabinet = Some(cabinets.get(i).unwrap().clone());
match subgroup {
None => {
let _ = subgroup.insert(LessonSubGroup {
teacher: None,
cabinet,
});
}
Some(subgroup) => {
subgroup.cabinet = cabinet;
}
}
}
}
};
let lesson = Lesson {
lesson_type: lesson_type.unwrap_or(lesson_boundaries.lesson_type.clone()),
range: default_range,
name: Some(name),
time: lesson_time,
subgroups: if subgroups.len() == 2 && subgroups.iter().all(|x| x.is_none()) {
None
} else {
Some(subgroups)
},
group: None,
};
let prev_lesson = if day.lessons.is_empty() {
return Ok(Lessons(Vec::from([lesson])));
} else {
&day.lessons[day.lessons.len() - 1]
};
Ok(Lessons(Vec::from([
Lesson {
lesson_type: Break,
range: None,
name: None,
time: LessonBoundaries {
start: prev_lesson.time.end,
end: lesson.time.start,
},
subgroups: Some(Vec::new()),
group: None,
},
lesson,
])))
}
/// Obtaining a list of cabinets to the right of the lesson cell.
fn parse_cabinets(worksheet: &WorkSheet, row_range: (u32, u32), column: u32) -> Vec<String> {
let mut cabinets: Vec<String> = Vec::new();
for row in row_range.0..row_range.1 {
let raw = or_continue!(worksheet.get_string_from_cell(row, column));
let clean = raw.replace("\n", " ");
let parts: Vec<&str> = clean.split(" ").collect();
parts.iter().take(2).for_each(|part| {
let clean_part = part.to_string().trim().to_string();
cabinets.push(clean_part);
});
break;
}
cabinets
}
struct ParsedLessonName {
name: String,
subgroups: Vec<Option<LessonSubGroup>>,
r#type: Option<LessonType>,
}
//noinspection GrazieInspection
/// Getting the "pure" name of the lesson and list of teachers from the text of the lesson cell.
fn parse_name_and_subgroups(text: &str, row: u32, column: u32) -> Result<ParsedLessonName> {
// Части названия пары:
// 1. Само название.
// 2. Список преподавателей и подгрупп.
// 3. "Модификатор" (чаще всего).
//
// Регулярное выражение для получения ФИО преподавателей и номеров подгрупп (aka. второй части).
static NAME_RE: LazyLock<fancy_regex::Regex> = LazyLock::new(|| {
fancy_regex::Regex::new(
r"([А-Я][а-я]+(?:[\s.]*[А-Я]){1,2})(?=[^А-Яа-я])[.\s]*(?:\(?(\d)[\sа-я]*\)?)?",
)
.unwrap()
});
let text = text
.chars()
.filter(|c: &char| {
c.is_whitespace()
|| c.is_ascii_digit()
|| (*c >= 'а' && *c <= 'я')
|| (*c >= 'А' && *c <= 'Я')
|| *c == '.'
|| *c == '-'
})
.collect::<String>()
.replace(r"\s+", " ");
let mut lesson_name: Option<&str> = None;
let mut extra: Option<&str> = None;
let mut shared_subgroup = true;
let mut subgroups: [Option<LessonSubGroup>; 2] = [None, None];
for capture in NAME_RE.captures_iter(&text).take(2) {
let capture = capture.unwrap();
if lesson_name.is_none() {
lesson_name = Some(&text[..capture.get(0).unwrap().start()]);
}
extra = Some(&text[capture.get(0).unwrap().end()..]);
let teacher_name = {
let clean = capture
.get(1)
.unwrap()
.as_str()
.chars()
.filter(|c| c.is_alphabetic())
.collect::<Vec<char>>();
if clean.get(clean.len() - 2).is_some_and(|c| c.is_uppercase()) {
let (name, remaining) = clean.split_at(clean.len() - 2);
format!(
"{} {}.{}.",
name.iter().collect::<String>(),
remaining[0],
remaining[1]
)
} else {
let (remaining, name) = clean.split_last().unwrap();
format!("{} {}.", name.iter().collect::<String>(), remaining)
}
};
let subgroup_index = capture.get(2).map(|m| m.as_str().parse::<u32>().unwrap());
let subgroup = Some(LessonSubGroup {
cabinet: None,
teacher: Some(teacher_name),
});
match subgroup_index {
None => {
// we have only 2 matches max so more than 2 subgroups we cant have 100%
*subgroups.iter_mut().find(|x| x.is_none()).unwrap() = subgroup;
}
Some(num) => {
// bc we have indexed subgroup
shared_subgroup = false;
// 1 - 1 = 0 | 2 - 1 = 1 | 3 - 1 = 2 (schedule index to array index)
// 0 % 2 = 0 | 1 % 2 = 1 | 2 % 2 = 0 (clamp)
let subgroup_index = ((num - 1) % 2) as usize;
// if we have subgroup in that index (probably non-indexed, we change it index to free)
if subgroups[subgroup_index].is_some() {
subgroups.swap(0, 1);
}
subgroups[subgroup_index] = subgroup;
}
}
}
let subgroups = if lesson_name.is_none() {
Vec::new()
} else if shared_subgroup {
Vec::from([subgroups.into_iter().next().unwrap()])
} else {
Vec::from(subgroups)
};
if extra.is_none() {
extra = text
.rfind(" ")
.and_then(|i| text[..i].rfind(" "))
.map(|i| &text[i + 1..]);
}
let lesson_type = if let Some(extra) = extra
&& extra.len() > 4
{
let result = guess_lesson_type(extra);
if result.is_none() {
#[cfg(not(debug_assertions))]
sentry::capture_error(&Error::UnknownLessonType {
r#type: extra.to_string(),
pos: CellPos::new(row, column),
});
#[cfg(debug_assertions)]
log::warn!(
"{}",
Error::UnknownLessonType {
r#type: extra.to_string(),
pos: CellPos::new(row, column),
}
);
}
result
} else {
None
};
Ok(ParsedLessonName {
name: lesson_name.unwrap_or(&text).to_string(),
subgroups,
r#type: lesson_type,
})
}
/// Getting the start and end of a pair from a cell in the first column of a document.
///
/// # Arguments
///
/// * `cell_data`: text in cell.
/// * `date`: date of the current day.
fn parse_lesson_boundaries_cell(cell_data: &str, date: DateTime<Utc>) -> Option<LessonBoundaries> {
static TIME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\d+\.\d+)-(\d+\.\d+)").unwrap());
let parse_res = TIME_RE.captures(cell_data)?;
let start_match = parse_res.get(1).unwrap().as_str();
let start_parts: Vec<&str> = start_match.split(".").collect();
let end_match = parse_res.get(2).unwrap().as_str();
let end_parts: Vec<&str> = end_match.split(".").collect();
static GET_TIME: fn(DateTime<Utc>, &Vec<&str>) -> DateTime<Utc> = |date, parts| {
date + Duration::hours(parts[0].parse::<i64>().unwrap() - 4)
+ Duration::minutes(parts[1].parse::<i64>().unwrap())
};
Some(LessonBoundaries {
start: GET_TIME(date, &start_parts),
end: GET_TIME(date, &end_parts),
})
}
/// Parse the column of the document to obtain a list of day's lesson boundaries.
///
/// # Arguments
///
/// * `worksheet`: document.
/// * `date`: date of the current day.
/// * `row_range`: row boundaries of the current day.
/// * `column`: column with the required data.
fn parse_day_boundaries(
worksheet: &WorkSheet,
date: DateTime<Utc>,
row_range: (u32, u32),
column: u32,
) -> Result<Vec<BoundariesData>> {
let mut day_times: Vec<BoundariesData> = Vec::new();
for row in row_range.0..row_range.1 {
let time_cell = if let Some(str) = worksheet.get_string_from_cell(row, column) {
str
} else {
continue;
};
let lesson_time = parse_lesson_boundaries_cell(&time_cell, date)
.ok_or(Error::NoLessonBoundaries(CellPos::new(row, column)))?;
// type
let lesson_type = if time_cell.contains("пара") {
LessonType::Default
} else {
LessonType::Additional
};
// lesson index
let default_index = if lesson_type == LessonType::Default {
Some(
time_cell
.chars()
.next()
.unwrap()
.to_string()
.parse::<u32>()
.unwrap(),
)
} else {
None
};
day_times.push(BoundariesData {
time_range: lesson_time,
lesson_type,
default_index,
range: worksheet.get_merge_from_start(row, column),
});
}
Ok(day_times)
}
/// Parse the column of the document to obtain a list of week's lesson boundaries.
///
/// # Arguments
///
/// * `worksheet`: document.
/// * `week_markup`: markup of the current week.
fn parse_week_boundaries(
worksheet: &WorkSheet,
week_markup: &[DayMarkup],
) -> Result<Vec<Vec<BoundariesData>>> {
let mut result: Vec<Vec<BoundariesData>> = Vec::new();
let worksheet_end_row = worksheet.end().unwrap().0;
let lesson_time_column = week_markup[0].column + 1;
for day_index in 0..week_markup.len() {
let day_markup = &week_markup[day_index];
// Если текущий день не последнему, то индекс строки следующего дня.
// Если текущий день - последний, то индекс последней строки документа.
let end_row = if day_index != week_markup.len() - 1 {
week_markup[day_index + 1].row
} else {
worksheet_end_row
};
let day_boundaries = parse_day_boundaries(
worksheet,
day_markup.date,
(day_markup.row, end_row),
lesson_time_column,
)?;
result.push(day_boundaries);
}
Ok(result)
}
/// Conversion of the list of couples of groups in the list of lessons of teachers.
fn convert_groups_to_teachers(
groups: &HashMap<String, ScheduleEntry>,
) -> HashMap<String, ScheduleEntry> {
let mut teachers: HashMap<String, ScheduleEntry> = HashMap::new();
let empty_days: Vec<Day> = groups
.values()
.next()
.unwrap()
.days
.iter()
.map(|day| Day {
name: day.name.clone(),
street: day.street.clone(),
date: day.date,
lessons: vec![],
})
.collect();
for group in groups.values() {
for (index, day) in group.days.iter().enumerate() {
for group_lesson in &day.lessons {
if group_lesson.lesson_type == Break {
continue;
}
if group_lesson.subgroups.is_none() {
continue;
}
let subgroups = group_lesson.subgroups.as_ref().unwrap();
for subgroup in subgroups {
let teacher = match subgroup {
None => continue,
Some(subgroup) => match &subgroup.teacher {
None => continue,
Some(teacher) => teacher,
},
};
if teacher == "Ошибка в расписании" {
continue;
}
if !teachers.contains_key(teacher) {
teachers.insert(
teacher.clone(),
ScheduleEntry {
name: teacher.clone(),
days: empty_days.to_vec(),
},
);
}
let teacher_day = teachers
.get_mut(teacher)
.unwrap()
.days
.get_mut(index)
.unwrap();
teacher_day.lessons.push({
let mut lesson = group_lesson.clone();
lesson.group = Some(group.name.clone());
lesson
});
}
}
}
}
teachers.iter_mut().for_each(|(_, teacher)| {
teacher.days.iter_mut().for_each(|day| {
day.lessons
.sort_by(|a, b| a.range.as_ref().unwrap()[1].cmp(&b.range.as_ref().unwrap()[1]))
})
});
teachers
}
/// Reading XLS Document from the buffer and converting it into the schedule ready to use.
///
/// # Arguments
///
/// * `buffer`: XLS data containing schedule.
///
/// returns: Result<ParseResult, Error>
pub fn parse_xls(buffer: &Vec<u8>) -> Result<ParsedSchedule> {
let cursor = Cursor::new(&buffer);
let mut workbook: Xls<_> = open_workbook_from_rs(cursor)?;
let worksheet = {
let (worksheet_name, worksheet) = workbook
.worksheets()
.first()
.ok_or(Error::NoWorkSheets)?
.clone();
let worksheet_merges = workbook
.worksheet_merge_cells(&worksheet_name)
.ok_or(Error::NoWorkSheets)?;
WorkSheet {
data: worksheet,
merges: worksheet_merges,
}
};
let WorkSheetMarkup {
days: week_markup,
groups: groups_markup,
} = parse_markup(&worksheet)?;
let week_boundaries = parse_week_boundaries(&worksheet, &week_markup)?;
let mut groups: HashMap<String, ScheduleEntry> = HashMap::new();
for group_markup in groups_markup {
let mut group = ScheduleEntry {
name: group_markup.name,
days: Vec::new(),
};
for day_index in 0..week_markup.len() {
let day_markup = &week_markup[day_index];
let mut day = Day {
name: day_markup.name.clone(),
street: None,
date: day_markup.date,
lessons: Vec::new(),
};
let day_boundaries = &week_boundaries[day_index];
for lesson_boundaries in day_boundaries {
match &mut parse_lesson(
&worksheet,
&day,
day_boundaries,
lesson_boundaries,
group_markup.column,
)? {
Lessons(lesson) => day.lessons.append(lesson),
Street(street) => day.street = Some(street.to_owned()),
}
}
group.days.push(day);
}
groups.insert(group.name.clone(), group);
}
Ok(ParsedSchedule {
teachers: convert_groups_to_teachers(&groups),
groups,
})
}
#[cfg(any(test, feature = "test"))]
pub mod test_utils {
use super::*;
use base::ParsedSchedule;
pub fn test_result() -> Result<ParsedSchedule> {
parse_xls(&include_bytes!("../../../../test-data/engels-polytechnic.xls").to_vec())
}
}
#[cfg(test)]
pub mod tests {
#[test]
fn read() {
let result = super::test_utils::test_result();
assert!(result.is_ok(), "{}", result.err().unwrap());
assert_ne!(result.as_ref().unwrap().groups.len(), 0);
assert_ne!(result.as_ref().unwrap().teachers.len(), 0);
}
#[test]
fn test_split_lesson() {
let result = super::test_utils::test_result();
assert!(result.is_ok(), "{}", result.err().unwrap());
let result = result.unwrap();
assert!(result.groups.contains_key("ИС-214/23"));
let group = result.groups.get("ИС-214/23").unwrap();
let thursday = group.days.get(3).unwrap();
assert_eq!(thursday.lessons.len(), 1);
let lesson = &thursday.lessons[0];
assert_eq!(lesson.range.unwrap()[1], 3);
assert!(lesson.subgroups.is_some());
let subgroups = lesson.subgroups.as_ref().unwrap();
assert_eq!(subgroups.len(), 2);
assert_eq!(
subgroups[0].as_ref().unwrap().cabinet,
Some("44".to_string())
);
assert_eq!(
subgroups[1].as_ref().unwrap().cabinet,
Some("43".to_string())
);
}
}

View File

@@ -0,0 +1,105 @@
use regex::Regex;
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use std::sync::LazyLock;
/// XLS WorkSheet data.
pub struct WorkSheet {
pub data: calamine::Range<calamine::Data>,
pub merges: Vec<calamine::Dimensions>,
}
#[derive(Clone, Debug, derive_more::Error)]
pub struct CellPos {
pub row: u32,
pub column: u32,
}
fn format_column_index(index: u32) -> String {
// https://stackoverflow.com/a/297214
let quotient = index / 26;
let char = char::from((65 + (index % 26)) as u8);
if quotient > 0 {
return format!("{}{}", format_column_index(quotient - 1), char);
}
char.to_string()
}
impl Display for CellPos {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"column {}, row {}",
format_column_index(self.column),
self.row + 1,
))
}
}
pub struct CellRange {
pub start: CellPos,
pub end: CellPos,
}
impl Deref for WorkSheet {
type Target = calamine::Range<calamine::Data>;
fn deref(&self) -> &Self::Target {
&self.data
}
}
impl WorkSheet {
/// Getting a line from the required cell.
pub fn get_string_from_cell(&self, row: u32, col: u32) -> Option<String> {
let cell_data = if let Some(data) = self.get((row as usize, col as usize)) {
data.to_string()
} else {
return None;
};
if cell_data.trim().is_empty() {
return None;
}
static NL_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\n\r]+").unwrap());
static SP_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
let trimmed_data = SP_RE
.replace_all(&NL_RE.replace_all(&cell_data, " "), " ")
.trim()
.to_string();
if trimmed_data.is_empty() {
None
} else {
Some(trimmed_data)
}
}
/// Obtaining the boundaries of the cell along its upper left coordinate.
pub fn get_merge_from_start(&self, row: u32, column: u32) -> CellRange {
match self
.merges
.iter()
.find(|merge| merge.start.0 == row && merge.start.1 == column)
{
Some(merge) => CellRange {
start: CellPos::new(merge.start.0, merge.start.1),
end: CellPos::new(merge.end.0 + 1, merge.end.1 + 1),
},
None => CellRange {
start: CellPos::new(row, column),
end: CellPos::new(row + 1, column + 1),
},
}
}
}
impl CellPos {
pub fn new(row: u32, column: u32) -> Self {
Self { row, column }
}
}

View File

@@ -0,0 +1,33 @@
use crate::xls_downloader::FetchError;
use derive_more::{Display, Error, From};
#[derive(Debug, Display, Error, From)]
pub enum Error {
/// Occurs when the request to the Yandex Cloud API fails.
///
/// This may be due to network issues, invalid API key, incorrect function ID, or other
/// problems with the Yandex Cloud Function invocation.
#[display("An error occurred during the request to the Yandex Cloud API: {_0}")]
Reqwest(reqwest::Error),
#[display("Unable to get URI in 3 retries")]
EmptyUri,
/// The ETag is the same (no update needed).
#[display("The ETag is the same.")]
SameETag,
/// The URL query for the XLS file failed to execute, either due to network issues or invalid API parameters.
#[display("Failed to fetch URL: {_0}")]
ScheduleFetchFailed(FetchError),
/// Downloading the XLS file content failed after successfully obtaining the URL.
#[display("Download failed: {_0}")]
ScheduleDownloadFailed(FetchError),
/// The XLS file could not be parsed into a valid schedule format.
#[from]
InvalidSchedule(crate::parser::Error),
}
pub type Result<T> = core::result::Result<T, Error>;

View File

@@ -0,0 +1,225 @@
pub use self::error::{Error, Result};
use crate::parser::parse_xls;
use crate::xls_downloader::{FetchError, XlsDownloader};
use base::ScheduleSnapshot;
mod error;
pub enum UpdateSource {
Prepared(ScheduleSnapshot),
Url(String),
GrabFromSite {
yandex_api_key: String,
yandex_func_id: String,
},
}
pub struct Updater {
downloader: XlsDownloader,
update_source: UpdateSource,
}
impl Updater {
/// Constructs a new `ScheduleSnapshot` by downloading and parsing schedule data from the specified URL.
///
/// This method first checks if the provided URL is the same as the one already configured in the downloader.
/// If different, it updates the downloader's URL, fetches the XLS content, parses it, and creates a snapshot.
/// Errors are returned for URL conflicts, network issues, download failures, or invalid data.
///
/// # Arguments
///
/// * `downloader`: A mutable reference to an `XLSDownloader` implementation used to fetch and parse the schedule data.
/// * `url`: The source URL pointing to the XLS file containing schedule data.
///
/// returns: Result<ScheduleSnapshot, SnapshotCreationError>
async fn new_snapshot(downloader: &mut XlsDownloader, url: String) -> Result<ScheduleSnapshot> {
let head_result = downloader.set_url(&url).await.map_err(|error| {
if let FetchError::Reqwest(error) = &error {
sentry::capture_error(&error);
}
Error::ScheduleFetchFailed(error)
})?;
if downloader.etag == Some(head_result.etag) {
return Err(Error::SameETag);
}
let xls_data = downloader
.fetch(false)
.await
.map_err(|error| {
if let FetchError::Reqwest(error) = &error {
sentry::capture_error(&error);
}
Error::ScheduleDownloadFailed(error)
})?
.data
.unwrap();
let parse_result = parse_xls(&xls_data)?;
Ok(ScheduleSnapshot {
fetched_at: head_result.requested_at,
updated_at: head_result.uploaded_at,
url,
data: parse_result,
})
}
/// Queries the Yandex Cloud Function (FaaS) to obtain a URL for the schedule file.
///
/// This sends a POST request to the specified Yandex Cloud Function endpoint,
/// using the provided API key for authentication. The returned URI is combined
/// with the "https://politehnikum-eng.ru" base domain to form the complete URL.
///
/// # Arguments
///
/// * `api_key` - Authentication token for Yandex Cloud API
/// * `func_id` - ID of the target Yandex Cloud Function to invoke
///
/// # Returns
///
/// Result containing:
/// - `Ok(String)` - Complete URL constructed from the Function's response
/// - `Err(QueryUrlError)` - If the request or response processing fails
async fn query_url(api_key: &str, func_id: &str) -> Result<String> {
let client = reqwest::Client::new();
let uri = {
// вот бы добавили named-scopes как в котлине,
// чтоб мне не пришлось такой хуйнёй страдать.
#[allow(unused_assignments)]
let mut uri = String::new();
let mut counter = 0;
loop {
if counter == 3 {
return Err(Error::EmptyUri);
}
counter += 1;
uri = client
.post(format!(
"https://functions.yandexcloud.net/{}?integration=raw",
func_id
))
.header("Authorization", format!("Api-Key {}", api_key))
.send()
.await
.map_err(Error::Reqwest)?
.text()
.await
.map_err(Error::Reqwest)?;
if uri.is_empty() {
log::warn!("[{}] Unable to get uri! Retrying in 5 seconds...", counter);
continue;
}
break;
}
uri
};
Ok(format!("https://politehnikum-eng.ru{}", uri.trim()))
}
/// Initializes the schedule by fetching the URL from the environment or Yandex Cloud Function (FaaS)
/// and creating a [`ScheduleSnapshot`] with the downloaded data.
///
/// # Arguments
///
/// * `downloader`: Mutable reference to an `XLSDownloader` implementation used to fetch and parse the schedule
/// * `app_env`: Reference to the application environment containing either a predefined URL or Yandex Cloud credentials
///
/// # Returns
///
/// Returns `Ok(())` if the snapshot was successfully initialized, or an `Error` if:
/// - URL query to Yandex Cloud failed ([`QueryUrlError`])
/// - Schedule snapshot creation failed ([`SnapshotCreationError`])
pub async fn new(update_source: UpdateSource) -> Result<(Self, ScheduleSnapshot)> {
let mut this = Updater {
downloader: XlsDownloader::new(),
update_source,
};
if let UpdateSource::Prepared(snapshot) = &this.update_source {
let snapshot = snapshot.clone();
return Ok((this, snapshot));
}
let url = match &this.update_source {
UpdateSource::Url(url) => {
log::info!("The default link {} will be used", url);
url.clone()
}
UpdateSource::GrabFromSite {
yandex_api_key,
yandex_func_id,
} => {
log::info!("Obtaining a link using FaaS...");
Self::query_url(yandex_api_key, yandex_func_id).await?
}
_ => unreachable!(),
};
log::info!("For the initial setup, a link {} will be used", url);
let snapshot = Self::new_snapshot(&mut this.downloader, url).await?;
log::info!("Schedule snapshot successfully created!");
Ok((this, snapshot))
}
/// Updates the schedule snapshot by querying the latest URL from FaaS and checking for changes.
/// If the URL hasn't changed, only updates the [`fetched_at`] timestamp. If changed, downloads
/// and parses the new schedule data.
///
/// # Arguments
///
/// * `downloader`: XLS file downloader used to fetch and parse the schedule data
/// * `app_env`: Application environment containing Yandex Cloud configuration and auto-update settings
///
/// returns: `Result<(), Error>` - Returns error if URL query fails or schedule parsing encounters issues
///
/// # Safety
///
/// Use `unsafe` to access the initialized snapshot, guaranteed valid by prior `init()` call
pub async fn update(
&mut self,
current_snapshot: &ScheduleSnapshot,
) -> Result<ScheduleSnapshot> {
if let UpdateSource::Prepared(snapshot) = &self.update_source {
let mut snapshot = snapshot.clone();
snapshot.update();
return Ok(snapshot);
}
let url = match &self.update_source {
UpdateSource::Url(url) => url.clone(),
UpdateSource::GrabFromSite {
yandex_api_key,
yandex_func_id,
} => Self::query_url(yandex_api_key.as_str(), yandex_func_id.as_str()).await?,
_ => unreachable!(),
};
let snapshot = match Self::new_snapshot(&mut self.downloader, url).await {
Ok(snapshot) => snapshot,
Err(Error::SameETag) => {
let mut clone = current_snapshot.clone();
clone.update();
clone
}
Err(error) => return Err(error),
};
Ok(snapshot)
}
}

View File

@@ -0,0 +1,253 @@
use chrono::{DateTime, Utc};
use derive_more::{Display, Error};
use std::mem::discriminant;
use std::sync::Arc;
use utoipa::ToSchema;
/// XLS data retrieval errors.
#[derive(Clone, Debug, ToSchema, Display, Error)]
pub enum FetchError {
/// File url is not set.
#[display("The link to the timetable was not provided earlier.")]
NoUrlProvided,
/// Unknown error.
#[display("An unknown error occurred while downloading the file.")]
#[schema(value_type = String)]
Reqwest(Arc<reqwest::Error>),
/// Server returned a status code different from 200.
#[display("Server returned a status code {status_code}.")]
BadStatusCode { status_code: u16 },
/// The url leads to a file of a different type.
#[display("The link leads to a file of type '{content_type}'.")]
BadContentType { content_type: String },
/// Server doesn't return expected headers.
#[display("Server doesn't return expected header(s) '{expected_header}'.")]
BadHeaders { expected_header: String },
}
impl FetchError {
pub fn unknown(error: Arc<reqwest::Error>) -> Self {
Self::Reqwest(error)
}
pub fn bad_status_code(status_code: u16) -> Self {
Self::BadStatusCode { status_code }
}
pub fn bad_content_type(content_type: &str) -> Self {
Self::BadContentType {
content_type: content_type.to_string(),
}
}
pub fn bad_headers(expected_header: &str) -> Self {
Self::BadHeaders {
expected_header: expected_header.to_string(),
}
}
}
impl PartialEq for FetchError {
fn eq(&self, other: &Self) -> bool {
discriminant(self) == discriminant(other)
}
}
/// Result of XLS data retrieval.
#[derive(Debug, PartialEq)]
pub struct FetchOk {
/// File upload date.
pub uploaded_at: DateTime<Utc>,
/// Date data received.
pub requested_at: DateTime<Utc>,
/// Etag.
pub etag: String,
/// File data.
pub data: Option<Vec<u8>>,
}
impl FetchOk {
/// Result without file content.
pub fn head(uploaded_at: DateTime<Utc>, etag: String) -> Self {
FetchOk {
uploaded_at,
requested_at: Utc::now(),
etag,
data: None,
}
}
/// Full result.
pub fn get(uploaded_at: DateTime<Utc>, etag: String, data: Vec<u8>) -> Self {
FetchOk {
uploaded_at,
requested_at: Utc::now(),
etag,
data: Some(data),
}
}
}
pub type FetchResult = Result<FetchOk, FetchError>;
pub struct XlsDownloader {
pub url: Option<String>,
pub etag: Option<String>,
}
impl XlsDownloader {
pub fn new() -> Self {
XlsDownloader {
url: None,
etag: None,
}
}
async fn fetch_specified(url: &str, head: bool) -> FetchResult {
let client = reqwest::Client::new();
let response = if head {
client.head(url)
} else {
client.get(url)
}
.header("User-Agent", ua_generator::ua::spoof_chrome_ua())
.send()
.await
.map_err(|e| FetchError::unknown(Arc::new(e)))?;
if response.status().as_u16() != 200 {
return Err(FetchError::bad_status_code(response.status().as_u16()));
}
let headers = response.headers();
let content_type = headers
.get("Content-Type")
.ok_or(FetchError::bad_headers("Content-Type"))?;
let etag = headers
.get("etag")
.ok_or(FetchError::bad_headers("etag"))?
.to_str()
.or(Err(FetchError::bad_headers("etag")))?
.to_string();
let last_modified = headers
.get("last-modified")
.ok_or(FetchError::bad_headers("last-modified"))?;
if content_type != "application/vnd.ms-excel" {
return Err(FetchError::bad_content_type(content_type.to_str().unwrap()));
}
let last_modified = DateTime::parse_from_rfc2822(last_modified.to_str().unwrap())
.unwrap()
.with_timezone(&Utc);
Ok(if head {
FetchOk::head(last_modified, etag)
} else {
FetchOk::get(
last_modified,
etag,
response.bytes().await.unwrap().to_vec(),
)
})
}
pub async fn fetch(&self, head: bool) -> FetchResult {
if self.url.is_none() {
Err(FetchError::NoUrlProvided)
} else {
Self::fetch_specified(self.url.as_ref().unwrap(), head).await
}
}
pub async fn set_url(&mut self, url: &str) -> FetchResult {
let result = Self::fetch_specified(url, true).await;
if result.is_ok() {
self.url = Some(url.to_string());
}
result
}
}
#[cfg(test)]
mod tests {
use crate::xls_downloader::{FetchError, XlsDownloader};
#[tokio::test]
async fn bad_url() {
let url = "bad_url";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_err());
}
#[tokio::test]
async fn bad_status_code() {
let url = "https://www.google.com/not-found";
let mut downloader = XlsDownloader::new();
assert_eq!(
downloader.set_url(url).await,
Err(FetchError::bad_status_code(404))
);
}
#[tokio::test]
async fn bad_headers() {
let url = "https://www.google.com/favicon.ico";
let mut downloader = XlsDownloader::new();
assert_eq!(
downloader.set_url(url).await,
Err(FetchError::BadHeaders {
expected_header: "ETag".to_string(),
})
);
}
#[tokio::test]
async fn bad_content_type() {
let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb59fd46303008396ac96%2Fexample.txt";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_err());
}
#[tokio::test]
async fn ok() {
let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_ok());
}
#[tokio::test]
async fn downloader_ok() {
let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_ok());
assert!(downloader.fetch(false).await.is_ok());
}
#[tokio::test]
async fn downloader_no_url_provided() {
let downloader = XlsDownloader::new();
let result = downloader.fetch(false).await;
assert_eq!(result, Err(FetchError::NoUrlProvided));
}
}

9
providers/src/lib.rs Normal file
View File

@@ -0,0 +1,9 @@
pub use base;
pub use provider_engels_polytechnic::EngelsPolytechnicProvider;
pub use provider_engels_polytechnic::UpdateSource as EngelsPolytechnicUpdateSource;
#[cfg(feature = "test")]
pub mod test_utils {
pub use provider_engels_polytechnic::test_utils as engels_polytechnic;
}

Binary file not shown.

View File

@@ -1,34 +0,0 @@
pub mod users {
use crate::database::models::User;
use crate::database::schema::fcm::user_id;
use crate::database::schema::users::dsl::users;
use crate::database::schema::users::dsl::*;
use diesel::{ExpressionMethods, QueryResult};
use diesel::{PgConnection, SelectableHelper};
use diesel::{QueryDsl, RunQueryDsl};
use std::ops::DerefMut;
use std::sync::Mutex;
pub fn get(connection: &Mutex<PgConnection>, _id: String) -> QueryResult<User> {
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
users
.filter(id.eq(_id))
.select(User::as_select())
.first(con)
}
pub fn get_by_username(
connection: &Mutex<PgConnection>,
_username: String,
) -> QueryResult<User> {
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
users
.filter(username.eq(_username))
.select(User::as_select())
.first(con)
}
}

View File

@@ -1,3 +0,0 @@
pub mod schema;
pub mod models;
pub mod driver;

View File

@@ -1,26 +0,0 @@
use diesel::prelude::*;
use serde::Serialize;
#[derive(diesel_derive_enum::DbEnum, Serialize, Debug, Clone, Copy, PartialEq)]
#[ExistingTypePath = "crate::database::schema::sql_types::UserRole"]
#[DbValueStyle = "UPPERCASE"]
#[serde(rename_all = "UPPERCASE")]
pub enum UserRole {
Student,
Teacher,
Admin,
}
#[derive(Identifiable, AsChangeset, Queryable, Selectable, Serialize)]
#[diesel(table_name = crate::database::schema::users)]
#[diesel(treat_none_as_null = true)]
pub struct User {
pub id: String,
pub username: String,
pub password: String,
pub vk_id: Option<i32>,
pub access_token: String,
pub group: String,
pub role: UserRole,
pub version: String,
}

View File

@@ -1,38 +0,0 @@
// @generated automatically by Diesel CLI.
pub mod sql_types {
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "user_role"))]
pub struct UserRole;
}
diesel::table! {
fcm (user_id) {
user_id -> Text,
token -> Text,
topics -> Nullable<Array<Nullable<Text>>>,
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::UserRole;
users (id) {
id -> Text,
username -> Text,
password -> Text,
vk_id -> Nullable<Int4>,
access_token -> Text,
group -> Text,
role -> UserRole,
version -> Text,
}
}
diesel::joinable!(fcm -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
fcm,
users,
);

View File

@@ -0,0 +1,80 @@
use crate::extractors::base::FromRequestAsync;
use crate::state::AppState;
use crate::utility::req_auth;
use crate::utility::req_auth::get_claims_from_req;
use actix_macros::MiddlewareError;
use actix_web::body::BoxBody;
use actix_web::dev::Payload;
use actix_web::{web, HttpRequest};
use database::entity::{User, UserType};
use database::query::Query;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Display, MiddlewareError)]
#[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Error {
/// There is no Authorization header or cookie in the request.
#[display("No Authorization header or cookie found")]
NoHeaderOrCookieFound,
/// Unknown authorization type other than Bearer.
#[display("Bearer token is required")]
UnknownAuthorizationType,
/// Invalid or expired access token.
#[display("Invalid or expired access token")]
InvalidAccessToken,
/// Default user is required.
#[display("Non-default user type is owning this access token")]
#[status_code = "actix_web::http::StatusCode::FORBIDDEN"]
NonDefaultUserType,
/// The user bound to the token is not found in the database.
#[display("No user associated with access token")]
NoUser,
/// User doesn't have required role.
#[display("You don't have sufficient rights")]
#[status_code = "actix_web::http::StatusCode::FORBIDDEN"]
InsufficientRights,
}
impl From<req_auth::Error> for Error {
fn from(value: req_auth::Error) -> Self {
match value {
req_auth::Error::NoHeaderOrCookieFound => Error::NoHeaderOrCookieFound,
req_auth::Error::UnknownAuthorizationType => Error::UnknownAuthorizationType,
req_auth::Error::InvalidAccessToken => Error::InvalidAccessToken,
}
}
}
/// User extractor from request with Bearer access token.
impl FromRequestAsync for User {
type Error = Error;
async fn from_request_async(
req: &HttpRequest,
_payload: &mut Payload,
) -> Result<Self, Self::Error> {
let claims = get_claims_from_req(req).map_err(Error::from)?;
if claims.user_type.unwrap_or(UserType::Default) != UserType::Default {
return Err(Error::NonDefaultUserType);
}
let db = req
.app_data::<web::Data<AppState>>()
.unwrap()
.get_database();
match Query::find_user_by_id(db, &claims.id).await {
Ok(Some(user)) => Ok(user),
_ => Err(Error::NoUser),
}
}
}

154
src/extractors/base.rs Normal file
View File

@@ -0,0 +1,154 @@
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest};
use futures_util::future::LocalBoxFuture;
use std::future::{Ready, ready};
use std::ops;
/// # Async extractor.
/// Asynchronous object extractor from a query.
pub struct AsyncExtractor<T>(T);
impl<T> AsyncExtractor<T> {
#[allow(dead_code)]
/// Retrieve the object extracted with the extractor.
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> ops::Deref for AsyncExtractor<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> ops::DerefMut for AsyncExtractor<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
pub trait FromRequestAsync: Sized {
type Error: Into<actix_web::Error>;
/// Asynchronous function for extracting data from a query.
///
/// returns: Result<Self, Self::Error>
///
/// # Examples
///
/// ```
/// struct User {
/// pub id: String,
/// pub username: String,
/// }
///
/// // TODO: Я вообще этот экстрактор не использую, нахуя мне тогда писать пример, если я не ебу как его использовать. Я забыл.
///
/// #[get("/")]
/// fn get_user_async(
/// user: web::AsyncExtractor<User>,
/// ) -> web::Json<User> {
/// let user = user.into_inner();
///
/// web::Json(user)
/// }
/// ```
async fn from_request_async(
req: &HttpRequest,
payload: &mut Payload,
) -> Result<Self, Self::Error>;
}
impl<T: FromRequestAsync> FromRequest for AsyncExtractor<T> {
type Error = T::Error;
type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let req = req.clone();
let mut payload = Payload::None;
Box::pin(async move {
T::from_request_async(&req, &mut payload)
.await
.map(|res| Self(res))
})
}
}
/// # Sync extractor.
/// Synchronous object extractor from a query.
pub struct SyncExtractor<T>(T);
impl<T> SyncExtractor<T> {
/// Retrieving an object extracted with the extractor.
#[allow(unused)]
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> ops::Deref for SyncExtractor<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> ops::DerefMut for SyncExtractor<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
pub trait FromRequestSync: Sized {
type Error: Into<actix_web::Error>;
/// Synchronous function for extracting data from a query.
///
/// returns: Result<Self, Self::Error>
///
/// # Examples
///
/// ```
/// struct User {
/// pub id: String,
/// pub username: String,
/// }
///
/// impl FromRequestSync for User {
/// type Error = actix_web::Error;
///
/// fn from_request_sync(req: &HttpRequest, _: &mut Payload) -> Result<Self, Self::Error> {
/// // do magic here.
///
/// Ok(User {
/// id: "qwerty".to_string(),
/// username: "n08i40k".to_string()
/// })
/// }
/// }
///
/// #[get("/")]
/// fn get_user_sync(
/// user: web::SyncExtractor<User>,
/// ) -> web::Json<User> {
/// let user = user.into_inner();
///
/// web::Json(user)
/// }
/// ```
fn from_request_sync(req: &HttpRequest, payload: &mut Payload) -> Result<Self, Self::Error>;
}
impl<T: FromRequestSync> FromRequest for SyncExtractor<T> {
type Error = T::Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
ready(T::from_request_sync(req, payload).map(|res| Self(res)))
}
}

2
src/extractors/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod authorized_user;
pub mod base;

View File

@@ -1,64 +1,175 @@
use crate::routes::auth::sign_in::sign_in; use crate::middlewares::authorization::{JWTAuthorizationBuilder, ServiceConfig};
use crate::xls_downloader::basic_impl::BasicXlsDownloader; use crate::middlewares::content_type::ContentTypeBootstrap;
use actix_web::{App, HttpServer, web}; use crate::state::{new_app_state, AppState};
use chrono::{DateTime, Utc}; use actix_web::dev::{ServiceFactory, ServiceRequest};
use diesel::{Connection, PgConnection}; use actix_web::{App, Error, HttpServer};
use database::entity::sea_orm_active_enums::UserRole;
use dotenvy::dotenv; use dotenvy::dotenv;
use schedule_parser::schema::ScheduleEntity; use log::info;
use std::collections::HashMap; use std::io;
use std::env; use utoipa_actix_web::scope::Scope;
use std::sync::{Mutex, MutexGuard}; use utoipa_actix_web::AppExt;
use utoipa_rapidoc::RapiDoc;
mod database; mod state;
mod extractors;
mod middlewares;
mod routes; mod routes;
mod utility; mod utility;
mod xls_downloader;
pub struct AppState { mod test_env;
downloader: Mutex<BasicXlsDownloader>,
schedule: Mutex< pub fn get_api_scope<
Option<( I: Into<Scope<T>>,
String, T: ServiceFactory<ServiceRequest, Config = (), Error = Error, InitError = ()>,
DateTime<Utc>, >(
( scope: I,
HashMap<String, ScheduleEntity>, ) -> Scope<T> {
HashMap<String, ScheduleEntity>, let admin_scope = {
), let service_user_scope =
)>, utoipa_actix_web::scope("/service-users").service(routes::admin::service_users::create);
>,
database: Mutex<PgConnection>, utoipa_actix_web::scope("/admin")
.wrap(
JWTAuthorizationBuilder::new()
.with_default(Some(ServiceConfig {
allow_service: false,
user_roles: Some(&[UserRole::Admin]),
}))
.build(),
)
.service(service_user_scope)
};
let auth_scope = utoipa_actix_web::scope("/auth")
.service(routes::auth::sign_in)
.service(routes::auth::sign_in_vk)
.service(routes::auth::sign_up)
.service(routes::auth::sign_up_vk);
let users_scope = utoipa_actix_web::scope("/users")
.wrap(
JWTAuthorizationBuilder::new()
.add_paths(
["/by/id/{id}", "/by/telegram-id/{id}"],
Some(ServiceConfig {
allow_service: true,
user_roles: Some(&[UserRole::Admin]),
}),
)
.build(),
)
.service(
utoipa_actix_web::scope("/by")
.service(routes::users::by::by_id)
.service(routes::users::by::by_telegram_id),
)
.service(routes::users::change_group)
.service(routes::users::change_username)
.service(routes::users::me);
let schedule_scope = utoipa_actix_web::scope("/schedule")
.wrap(
JWTAuthorizationBuilder::new()
.with_default(Some(ServiceConfig {
allow_service: true,
user_roles: None,
}))
.add_paths(["/group-names", "/teacher-names"], None)
.add_paths(
["/"],
Some(ServiceConfig {
allow_service: true,
user_roles: Some(&[UserRole::Admin]),
}),
)
.add_paths(
["/group"],
Some(ServiceConfig {
allow_service: false,
user_roles: None,
}),
)
.build(),
)
.service(routes::schedule::cache_status)
.service(routes::schedule::schedule)
.service(routes::schedule::group)
.service(routes::schedule::group_by_name)
.service(routes::schedule::group_names)
.service(routes::schedule::teacher)
.service(routes::schedule::teacher_names);
let flow_scope = utoipa_actix_web::scope("/flow")
.wrap(
JWTAuthorizationBuilder::new()
.add_paths(["/telegram-auth"], None)
.build(),
)
.service(routes::flow::telegram_auth)
.service(routes::flow::telegram_complete);
let vk_id_scope = utoipa_actix_web::scope("/vkid") //
.service(routes::vk_id::oauth);
utoipa_actix_web::scope(scope)
.service(admin_scope)
.service(auth_scope)
.service(users_scope)
.service(schedule_scope)
.service(flow_scope)
.service(vk_id_scope)
} }
impl AppState { async fn async_main() -> io::Result<()> {
pub fn connection(&self) -> MutexGuard<PgConnection> { info!("Запуск сервера...");
self.database.lock().unwrap()
}
}
#[actix_web::main] let app_state = new_app_state(None).await.unwrap();
async fn main() {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let data = web::Data::new(AppState {
downloader: Mutex::new(BasicXlsDownloader::new()),
schedule: Mutex::new(None),
database: Mutex::new(
PgConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url)),
),
});
HttpServer::new(move || { HttpServer::new(move || {
let schedule_scope = web::scope("/auth").service(sign_in); let (app, api) = App::new()
let api_scope = web::scope("/api/v1").service(schedule_scope); .into_utoipa_app()
.app_data(app_state.clone())
.service(
get_api_scope("/api/v1")
.wrap(sentry_actix::Sentry::new())
.wrap(ContentTypeBootstrap),
)
.split_for_parts();
App::new().app_data(data.clone()).service(api_scope) let rapidoc_service = RapiDoc::with_openapi("/api-docs-json", api).path("/api-docs");
// Because CORS error on non-localhost
let patched_rapidoc_html = rapidoc_service.to_html().replace(
"https://unpkg.com/rapidoc/dist/rapidoc-min.js",
"https://cdn.jsdelivr.net/npm/rapidoc/dist/rapidoc-min.min.js",
);
app.service(rapidoc_service.custom_html(patched_rapidoc_html))
}) })
.bind(("127.0.0.1", 8080)) .workers(4)
.unwrap() .bind(("0.0.0.0", 5050))?
.run() .run()
.await .await
.unwrap(); }
fn main() -> io::Result<()> {
let _guard = sentry::init((
"https://9c33db76e89984b3f009b28a9f4b5954@sentry.n08i40k.ru/8",
sentry::ClientOptions {
release: sentry::release_name!(),
send_default_pii: true,
..Default::default()
},
));
let _ = dotenv();
env_logger::init();
actix_web::rt::System::new().block_on(async { async_main().await })?;
Ok(())
} }

View File

@@ -0,0 +1,219 @@
use crate::extractors::authorized_user;
use crate::state::AppState;
use crate::utility::req_auth::get_claims_from_req;
use actix_web::body::{BoxBody, EitherBody};
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{web, Error, HttpRequest, ResponseError};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::UserType;
use database::query::Query;
use futures_util::future::LocalBoxFuture;
use std::future::{ready, Ready};
use std::ops::Deref;
use std::rc::Rc;
use std::sync::Arc;
#[derive(Default, Clone)]
pub struct ServiceConfig {
/// Allow service users to access endpoints.
pub allow_service: bool,
/// List of required roles to access endpoints.
pub user_roles: Option<&'static [UserRole]>,
}
type ServiceKV = (Arc<[&'static str]>, Option<ServiceConfig>);
pub struct JWTAuthorizationBuilder {
pub default_config: Option<ServiceConfig>,
pub path_configs: Vec<ServiceKV>,
}
impl JWTAuthorizationBuilder {
pub fn new() -> Self {
JWTAuthorizationBuilder {
default_config: Some(ServiceConfig::default()),
path_configs: vec![],
}
}
pub fn with_default(mut self, default: Option<ServiceConfig>) -> Self {
self.default_config = default;
self
}
pub fn add_paths(
mut self,
paths: impl AsRef<[&'static str]>,
config: Option<ServiceConfig>,
) -> Self {
self.path_configs.push((Arc::from(paths.as_ref()), config));
self
}
pub fn build(self) -> JWTAuthorization {
JWTAuthorization {
default_config: Arc::new(self.default_config),
path_configs: Arc::from(self.path_configs),
}
}
}
/// Middleware guard working with JWT tokens.
pub struct JWTAuthorization {
pub default_config: Arc<Option<ServiceConfig>>,
pub path_configs: Arc<[ServiceKV]>,
}
impl<S, B> Transform<S, ServiceRequest> for JWTAuthorization
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = Error;
type Transform = JWTAuthorizationMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(JWTAuthorizationMiddleware {
service: Rc::new(service),
default_config: self.default_config.clone(),
path_configs: self.path_configs.clone(),
}))
}
}
pub struct JWTAuthorizationMiddleware<S> {
service: Rc<S>,
default_config: Arc<Option<ServiceConfig>>,
path_configs: Arc<[ServiceKV]>,
}
impl<S, B> JWTAuthorizationMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
/// Checking the validity of the token.
async fn check_authorization(
req: &HttpRequest,
allow_service_user: bool,
required_user_roles: Option<&'static [UserRole]>,
) -> Result<(), authorized_user::Error> {
let claims = get_claims_from_req(req).map_err(authorized_user::Error::from)?;
let db = req
.app_data::<web::Data<AppState>>()
.unwrap()
.get_database();
let user_type = claims.user_type.unwrap_or(UserType::Default);
match user_type {
UserType::Default => {
if let Some(required_user_roles) = required_user_roles {
let Ok(Some(user)) = Query::find_user_by_id(db, &claims.id).await else {
return Err(authorized_user::Error::NoUser);
};
if !required_user_roles.contains(&user.role) {
return Err(authorized_user::Error::InsufficientRights);
}
return Ok(());
}
match Query::is_user_exists_by_id(db, &claims.id).await {
Ok(true) => Ok(()),
_ => Err(authorized_user::Error::NoUser),
}
}
UserType::Service => {
if !allow_service_user {
return Err(authorized_user::Error::NonDefaultUserType);
}
match Query::is_service_user_exists_by_id(db, &claims.id).await {
Ok(true) => Ok(()),
_ => Err(authorized_user::Error::NoUser),
}
}
}
}
fn find_config(
current_path: &str,
per_route: &[ServiceKV],
default: &Option<ServiceConfig>,
) -> Option<ServiceConfig> {
for (service_paths, config) in per_route {
for service_path in service_paths.deref() {
if !service_path.eq(&current_path) {
continue;
}
return config.clone();
}
}
default.clone()
}
}
impl<S, B> Service<ServiceRequest> for JWTAuthorizationMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
let match_info = req.match_info();
let path = if let Some(pattern) = req.match_pattern() {
let scope_start_idx = match_info
.as_str()
.find(match_info.unprocessed())
.unwrap_or(0);
pattern.as_str().split_at(scope_start_idx).1.to_owned()
} else {
match_info.unprocessed().to_owned()
};
let Some(config) = Self::find_config(&path, &self.path_configs, &self.default_config)
else {
let fut = self.service.call(req);
return Box::pin(async move { Ok(fut.await?.map_into_left_body()) });
};
let allow_service_user = config.allow_service;
let required_user_roles = config.user_roles;
Box::pin(async move {
match Self::check_authorization(req.request(), allow_service_user, required_user_roles)
.await
{
Ok(_) => {
let fut = service.call(req).await?;
Ok(fut.map_into_left_body())
}
Err(err) => Ok(ServiceResponse::new(
req.into_parts().0,
err.error_response().map_into_right_body(),
)),
}
})
}
}

View File

@@ -0,0 +1,65 @@
use actix_web::body::{BoxBody, EitherBody};
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::header;
use actix_web::http::header::HeaderValue;
use actix_web::Error;
use futures_util::future::LocalBoxFuture;
use std::future::{ready, Ready};
/// Middleware to specify the encoding in the Content-Type header.
pub struct ContentTypeBootstrap;
impl<S, B> Transform<S, ServiceRequest> for ContentTypeBootstrap
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = Error;
type Transform = ContentTypeMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(ContentTypeMiddleware { service }))
}
}
pub struct ContentTypeMiddleware<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for ContentTypeMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let fut = self.service.call(req);
Box::pin(async move {
let mut response = fut.await?;
let headers = response.response_mut().headers_mut();
if let Some(content_type) = headers.get("Content-Type")
&& content_type == "application/json"
{
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf8"),
);
}
Ok(response.map_into_left_body())
})
}
}

19
src/middlewares/error.rs Normal file
View File

@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::fmt::Write;
/// Server response to errors within Middleware.
#[derive(Serialize, Deserialize)]
pub struct MiddlewareError<T: Display> {
code: T,
message: String,
}
impl<T: Display + Serialize> MiddlewareError<T> {
pub fn new(code: T) -> Self {
let mut message = String::new();
write!(&mut message, "{}", code).unwrap();
Self { code, message }
}
}

4
src/middlewares/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod error;
pub mod authorization;
pub mod content_type;

1
src/routes/admin/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod service_users;

View File

@@ -0,0 +1,75 @@
use self::schema::*;
use crate::{utility, AppState};
use actix_web::{post, web};
use database::entity::{ActiveServiceUser, UserType};
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, Set};
use objectid::ObjectId;
use web::Json;
#[utoipa::path(responses(
(status = OK, body = Response),
))]
#[post("/create")]
pub async fn create(data_json: Json<Request>, app_state: web::Data<AppState>) -> ServiceResponse {
let service_user =
match Query::find_service_user_by_id(app_state.get_database(), &data_json.name)
.await
.expect("Failed to find service user by name")
{
Some(_) => return Err(ErrorCode::AlreadyExists).into(),
None => {
let new_user = ActiveServiceUser {
id: Set(ObjectId::new().unwrap().to_string()),
name: Set(data_json.name.clone()),
};
new_user
.insert(app_state.get_database())
.await
.expect("Failed to insert service user")
}
};
let access_token = utility::jwt::encode(UserType::Service, &service_user.id);
Ok(Response::new(access_token)).into()
}
mod schema {
use actix_macros::{ErrResponse, OkResponse};
use derive_more::Display;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
#[schema(as = ServiceUser::Create::Request)]
pub struct Request {
/// Service username.
pub name: String,
}
#[derive(Serialize, ToSchema, OkResponse)]
#[serde(rename_all = "camelCase")]
#[schema(as = ServiceUser::Create::Response)]
pub struct Response {
access_token: String,
}
impl Response {
pub fn new(access_token: String) -> Self {
Self { access_token }
}
}
pub type ServiceResponse = crate::routes::schema::Response<Response, ErrorCode>;
#[derive(Clone, ToSchema, Display, ErrResponse, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"]
#[schema(as = ServiceUser::Create::ErrorCode)]
pub enum ErrorCode {
#[display("Service user with that name already exists.")]
AlreadyExists,
}
}

View File

@@ -0,0 +1,3 @@
mod create;
pub use create::*;

View File

@@ -1,2 +1,8 @@
pub mod sign_in; mod shared;
mod schema; mod sign_in;
mod sign_up;
pub use sign_in::*;
pub use sign_up::*;
// TODO: change-password

View File

@@ -1,56 +0,0 @@
use crate::database::models::User;
use serde::{Deserialize, Serialize, Serializer};
#[derive(Deserialize)]
pub struct SignInDto {
pub username: String,
pub password: String,
}
pub struct SignInResult(Result<SignInOk, SignInErr>);
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignInOk {
id: String,
access_token: String,
group: String,
}
#[derive(Serialize)]
pub struct SignInErr {
code: SignInErrCode,
}
#[derive(Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SignInErrCode {
IncorrectCredentials,
InvalidVkAccessToken,
}
impl SignInResult {
pub fn ok(user: &User) -> Self {
Self(Ok(SignInOk {
id: user.id.clone(),
access_token: user.access_token.clone(),
group: user.group.clone(),
}))
}
pub fn err(code: SignInErrCode) -> SignInResult {
Self(Err(SignInErr { code }))
}
}
impl Serialize for SignInResult {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match &self.0 {
Ok(ok) => serializer.serialize_some(&ok),
Err(err) => serializer.serialize_some(&err),
}
}
}

73
src/routes/auth/shared.rs Normal file
View File

@@ -0,0 +1,73 @@
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: i32,
iis: String,
jti: i32,
app: i32,
}
#[derive(Debug, PartialEq)]
pub enum Error {
Jwt(ErrorKind),
InvalidSignature,
InvalidToken,
Expired,
UnknownIssuer(String),
UnknownType(i32),
UnknownClientId(i32),
}
//noinspection SpellCheckingInspection
const VK_PUBLIC_KEY: &str = concat!(
"-----BEGIN PUBLIC KEY-----\n",
"MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvsvJlhFX9Ju/pvCz1frB\n",
"DgJs592VjdwQuRAmnlJAItyHkoiDIOEocPzgcUBTbDf1plDcTyO2RCkUt0pz0WK6\n",
"6HNhpJyIfARjaWHeUlv4TpuHXAJJsBKklkU2gf1cjID+40sWWYjtq5dAkXnSJUVA\n",
"UR+sq0lJ7GmTdJtAr8hzESqGEcSP15PTs7VUdHZ1nkC2XgkuR8KmKAUb388ji1Q4\n",
"n02rJNOPQgd9r0ac4N2v/yTAFPXumO78N25bpcuWf5vcL9e8THk/U2zt7wf+aAWL\n",
"748e0pREqNluTBJNZfmhC79Xx6GHtwqHyyduiqfPmejmiujNM/rqnA4e30Tg86Yn\n",
"cNZ6vLJyF72Eva1wXchukH/aLispbY+EqNPxxn4zzCWaLKHG87gaCxpVv9Tm0jSD\n",
"2es22NjrUbtb+2pAGnXbyDp2eGUqw0RrTQFZqt/VcmmSCE45FlcZMT28otrwG1ZB\n",
"kZAb5Js3wLEch3ZfYL8sjhyNRPBmJBrAvzrd8qa3rdUjkC9sKyjGAaHu2MNmFl1Y\n",
"JFQ3J54tGpkGgJjD7Kz3w0K6OiPDlVCNQN5sqXm24fCw85Pbi8SJiaLTp/CImrs1\n",
"Z3nHW5q8hljA7OGmqfOP0nZS/5zW9GHPyepsI1rW6CympYLJ15WeNzePxYS5KEX9\n",
"EncmkSD9b45ge95hJeJZteUCAwEAAQ==\n",
"-----END PUBLIC KEY-----"
);
pub fn parse_vk_id(token_str: &str, client_id: i32) -> Result<i32, Error> {
let dkey = DecodingKey::from_rsa_pem(VK_PUBLIC_KEY.as_bytes()).unwrap();
match decode::<Claims>(token_str, &dkey, &Validation::new(Algorithm::RS256)) {
Ok(token_data) => {
let claims = token_data.claims;
if claims.iis != "VK" {
Err(Error::UnknownIssuer(claims.iis))
} else if claims.jti != 21 {
Err(Error::UnknownType(claims.jti))
} else if claims.app != client_id {
Err(Error::UnknownClientId(claims.app))
} else {
Ok(claims.sub)
}
}
Err(err) => Err(match err.into_kind() {
ErrorKind::InvalidToken => Error::InvalidToken,
ErrorKind::InvalidSignature => Error::InvalidSignature,
ErrorKind::InvalidAlgorithmName => Error::InvalidToken,
ErrorKind::MissingRequiredClaim(_) => Error::InvalidToken,
ErrorKind::ExpiredSignature => Error::Expired,
ErrorKind::InvalidAlgorithm => Error::InvalidToken,
ErrorKind::MissingAlgorithm => Error::InvalidToken,
ErrorKind::Base64(_) => Error::InvalidToken,
ErrorKind::Json(_) => Error::InvalidToken,
ErrorKind::Utf8(_) => Error::InvalidToken,
kind => Error::Jwt(kind),
}),
}
}

View File

@@ -1,33 +1,235 @@
use crate::database::driver; use self::schema::*;
use crate::database::models::User; use crate::routes::auth::shared::parse_vk_id;
use crate::routes::auth::schema::SignInErrCode::IncorrectCredentials; use crate::routes::auth::sign_in::schema::SignInData::{Default, VkOAuth};
use crate::routes::auth::schema::{SignInDto, SignInResult}; use crate::routes::schema::user::UserResponse;
use crate::{AppState, utility}; use crate::routes::schema::ResponseError;
use crate::{utility, AppState};
use actix_web::{post, web}; use actix_web::{post, web};
use diesel::SaveChangesDsl; use database::query::Query;
use std::ops::DerefMut;
use web::Json; use web::Json;
use database::entity::UserType;
#[post("/sign-in")] async fn sign_in_combined(
pub async fn sign_in(data: Json<SignInDto>, app_state: web::Data<AppState>) -> Json<SignInResult> { data: SignInData,
let result = match driver::users::get_by_username(&app_state.database, data.username.clone()) { app_state: &web::Data<AppState>,
Ok(mut user) => match bcrypt::verify(&data.password, &user.password) { ) -> Result<UserResponse, ErrorCode> {
Ok(true) => { let db = app_state.get_database();
let mut lock = app_state.connection();
let conn = lock.deref_mut();
user.access_token = utility::jwt::encode(&user.id); let user = match &data {
Default(data) => Query::find_user_by_username(db, &data.username).await,
user.save_changes::<User>(conn) VkOAuth(id) => Query::find_user_by_vk_id(db, *id).await,
.expect("Failed to update user");
SignInResult::ok(&user)
} }
Ok(false) | Err(_) => SignInResult::err(IncorrectCredentials), .ok()
}, .flatten();
Err(_) => SignInResult::err(IncorrectCredentials), match user {
Some(user) => {
if let Default(data) = data {
if user.password.is_none() {
return Err(ErrorCode::IncorrectCredentials);
}
match bcrypt::verify(&data.password, user.password.as_ref().unwrap()) {
Ok(result) => {
if !result {
return Err(ErrorCode::IncorrectCredentials);
}
}
Err(_) => {
return Err(ErrorCode::IncorrectCredentials);
}
}
}
let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(UserResponse::from_user_with_token(user, access_token))
}
None => Err(ErrorCode::IncorrectCredentials),
}
}
#[utoipa::path(responses(
(status = OK, body = UserResponse),
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))]
#[post("/sign-in")]
pub async fn sign_in(data: Json<Request>, app_state: web::Data<AppState>) -> ServiceResponse {
sign_in_combined(Default(data.into_inner()), &app_state)
.await
.into()
}
#[utoipa::path(responses(
(status = OK, body = UserResponse),
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))]
#[post("/sign-in-vk")]
pub async fn sign_in_vk(
data_json: Json<vk::Request>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
let data = data_json.into_inner();
match parse_vk_id(&data.access_token, app_state.get_env().vk_id.client_id) {
Ok(id) => sign_in_combined(VkOAuth(id), &app_state).await,
Err(_) => Err(ErrorCode::InvalidVkAccessToken),
}
.into()
}
mod schema {
use crate::routes::schema::user::UserResponse;
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, ToSchema)]
#[schema(as = SignIn::Request)]
pub struct Request {
/// User name.
#[schema(examples("n08i40k"))]
pub username: String,
/// Password.
pub password: String,
}
pub mod vk {
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
#[schema(as = SignInVk::Request)]
pub struct Request {
/// VK ID token.
pub access_token: String,
}
}
pub type ServiceResponse = crate::routes::schema::Response<UserResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[schema(as = SignIn::ErrorCode)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"]
pub enum ErrorCode {
/// Incorrect username or password.
#[display("Incorrect username or password.")]
IncorrectCredentials,
/// Invalid VK ID token.
#[display("Invalid VK ID token.")]
InvalidVkAccessToken,
}
/// Type of authorization.
pub enum SignInData {
/// User and password name and password.
Default(Request),
/// Identifier of the attached account VK.
VkOAuth(i32),
}
}
#[cfg(test)]
mod tests {
use super::schema::*;
use crate::routes::auth::sign_in::sign_in;
use crate::test_env::tests::{static_app_state, test_app_state, test_env};
use actix_test::test_app;
use actix_web::dev::ServiceResponse;
use actix_web::http::Method;
use actix_web::http::StatusCode;
use actix_web::test;
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::ActiveUser;
use database::sea_orm::{ActiveModelTrait, Set};
use sha1::{Digest, Sha1};
use std::fmt::Write;
async fn sign_in_client(data: Request) -> ServiceResponse {
let app = test_app(test_app_state().await, sign_in).await;
let req = test::TestRequest::with_uri("/sign-in")
.method(Method::POST)
.set_json(data)
.to_request();
test::call_service(&app, req).await
}
async fn prepare(username: String) {
let id = {
let mut sha = Sha1::new();
sha.update(&username);
let result = sha.finalize();
let bytes = &result[..12];
let mut hex = String::new();
for byte in bytes {
write!(&mut hex, "{:02x}", byte).unwrap();
}
hex
}; };
Json(result) test_env();
let app_state = static_app_state().await;
let active_user = ActiveUser {
id: Set(id.clone()),
username: Set(username),
password: Set(Some(bcrypt::hash("example", bcrypt::DEFAULT_COST).unwrap())),
vk_id: Set(None),
telegram_id: Set(None),
group: Set(Some("ИС-214/23".to_string())),
role: Set(UserRole::Student),
android_version: Set(None),
};
active_user
.save(app_state.get_database())
.await
.expect("Failed to save user");
}
#[actix_web::test]
async fn sign_in_ok() {
prepare("test::sign_in_ok".to_string()).await;
let resp = sign_in_client(Request {
username: "test::sign_in_ok".to_string(),
password: "example".to_string(),
})
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn sign_in_err() {
prepare("test::sign_in_err".to_string()).await;
let invalid_username = sign_in_client(Request {
username: "test::sign_in_err::username".to_string(),
password: "example".to_string(),
})
.await;
assert_eq!(invalid_username.status(), StatusCode::NOT_ACCEPTABLE);
let invalid_password = sign_in_client(Request {
username: "test::sign_in_err".to_string(),
password: "bad_password".to_string(),
})
.await;
assert_eq!(invalid_password.status(), StatusCode::NOT_ACCEPTABLE);
}
} }

373
src/routes/auth/sign_up.rs Normal file
View File

@@ -0,0 +1,373 @@
use self::schema::*;
use crate::routes::auth::shared::parse_vk_id;
use crate::routes::schema::user::UserResponse;
use crate::routes::schema::ResponseError;
use crate::{utility, AppState};
use actix_web::{post, web};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::{ActiveUser, UserType};
use database::query::Query;
use database::sea_orm::ActiveModelTrait;
use web::Json;
async fn sign_up_combined(
data: SignUpData,
app_state: &web::Data<AppState>,
) -> Result<UserResponse, ErrorCode> {
// If user selected forbidden role.
if data.role == UserRole::Admin {
return Err(ErrorCode::DisallowedRole);
}
if !app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.groups
.contains_key(&data.group)
{
return Err(ErrorCode::InvalidGroupName);
}
let db = app_state.get_database();
// If user with specified username already exists.O
if Query::find_user_by_username(db, &data.username)
.await
.is_ok_and(|user| user.is_some())
{
return Err(ErrorCode::UsernameAlreadyExists);
}
// If user with specified VKID already exists.
if let Some(id) = data.vk_id
&& Query::is_user_exists_by_vk_id(db, id)
.await
.expect("Failed to check user existence")
{
return Err(ErrorCode::VkAlreadyExists);
}
let active_user: ActiveUser = data.into();
let user = active_user.insert(db).await.unwrap();
let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(UserResponse::from_user_with_token(user, access_token))
}
#[utoipa::path(responses(
(status = OK, body = UserResponse),
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))]
#[post("/sign-up")]
pub async fn sign_up(data_json: Json<Request>, app_state: web::Data<AppState>) -> ServiceResponse {
let data = data_json.into_inner();
sign_up_combined(
SignUpData {
username: data.username,
password: Some(data.password),
vk_id: None,
group: data.group,
role: data.role,
version: data.version,
},
&app_state,
)
.await
.into()
}
#[utoipa::path(responses(
(status = OK, body = UserResponse),
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))]
#[post("/sign-up-vk")]
pub async fn sign_up_vk(
data_json: Json<vk::Request>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
let data = data_json.into_inner();
match parse_vk_id(&data.access_token, app_state.get_env().vk_id.client_id) {
Ok(id) => {
sign_up_combined(
SignUpData {
username: data.username,
password: None,
vk_id: Some(id),
group: data.group,
role: data.role,
version: data.version,
},
&app_state,
)
.await
}
Err(_) => Err(ErrorCode::InvalidVkAccessToken),
}
.into()
}
mod schema {
use crate::routes::schema::user::UserResponse;
use actix_macros::ErrResponse;
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::ActiveUser;
use database::sea_orm::Set;
use derive_more::Display;
use objectid::ObjectId;
use serde::{Deserialize, Serialize};
/// WEB
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
#[schema(as = SignUp::Request)]
pub struct Request {
/// User name.
#[schema(examples("n08i40k"))]
pub username: String,
/// Password.
pub password: String,
/// Group.
#[schema(examples("ИС-214/23"))]
pub group: String,
/// Role.
pub role: UserRole,
/// Version of the installed Polytechnic+ application.
#[schema(examples("3.0.0"))]
pub version: String,
}
pub mod vk {
use database::entity::sea_orm_active_enums::UserRole;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
#[schema(as = SignUpVk::Request)]
pub struct Request {
/// VK ID token.
pub access_token: String,
/// User name.
#[schema(examples("n08i40k"))]
pub username: String,
/// Group.
#[schema(examples("ИС-214/23"))]
pub group: String,
/// Role.
pub role: UserRole,
/// Version of the installed Polytechnic+ application.
#[schema(examples("3.0.0"))]
pub version: String,
}
}
pub type ServiceResponse = crate::routes::schema::Response<UserResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, utoipa::ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = SignUp::ErrorCode)]
#[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"]
pub enum ErrorCode {
/// Conveyed the role of Admin.
#[display("Conveyed the role of Admin.")]
DisallowedRole,
/// Unknown name of the group.
#[display("Unknown name of the group.")]
InvalidGroupName,
/// User with this name is already registered.
#[display("User with this name is already registered.")]
UsernameAlreadyExists,
/// Invalid VK ID token.
#[display("Invalid VK ID token.")]
InvalidVkAccessToken,
/// User with such an account VK is already registered.
#[display("User with such an account VK is already registered.")]
VkAlreadyExists,
}
/// Data for registration.
pub struct SignUpData {
// TODO: сделать ограничение на минимальную и максимальную длину при регистрации и смене.
/// User name.
pub username: String,
/// Password.
///
/// Should be present even if registration occurs using the VK ID token.
pub password: Option<String>,
/// Account identifier VK.
pub vk_id: Option<i32>,
/// Group.
pub group: String,
/// Role.
pub role: UserRole,
/// Version of the installed Polytechnic+ application.
pub version: String,
}
impl From<SignUpData> for ActiveUser {
fn from(value: SignUpData) -> Self {
assert_ne!(value.password.is_some(), value.vk_id.is_some());
ActiveUser {
id: Set(ObjectId::new().unwrap().to_string()),
username: Set(value.username),
password: Set(value
.password
.map(|x| bcrypt::hash(x, bcrypt::DEFAULT_COST).unwrap())),
vk_id: Set(value.vk_id),
telegram_id: Set(None),
group: Set(Some(value.group)),
role: Set(value.role),
android_version: Set(Some(value.version)),
}
}
}
}
#[cfg(test)]
mod tests {
use crate::routes::auth::sign_up::schema::Request;
use crate::routes::auth::sign_up::sign_up;
use crate::test_env::tests::{static_app_state, test_app_state, test_env};
use actix_test::test_app;
use actix_web::dev::ServiceResponse;
use actix_web::http::Method;
use actix_web::http::StatusCode;
use actix_web::test;
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::{UserColumn, UserEntity};
use database::sea_orm::ColumnTrait;
use database::sea_orm::{EntityTrait, QueryFilter};
struct SignUpPartial<'a> {
username: &'a str,
group: &'a str,
role: UserRole,
}
async fn sign_up_client(data: SignUpPartial<'_>) -> ServiceResponse {
let app = test_app(test_app_state().await, sign_up).await;
let req = test::TestRequest::with_uri("/sign-up")
.method(Method::POST)
.set_json(Request {
username: data.username.to_string(),
password: "example".to_string(),
group: data.group.to_string(),
role: data.role.clone(),
version: "1.0.0".to_string(),
})
.to_request();
test::call_service(&app, req).await
}
#[actix_web::test]
async fn sign_up_valid() {
// prepare
test_env();
let app_state = static_app_state().await;
UserEntity::delete_many()
.filter(UserColumn::Username.eq("test::sign_up_valid"))
.exec(app_state.get_database())
.await
.expect("Failed to delete user");
// test
let resp = sign_up_client(SignUpPartial {
username: "test::sign_up_valid",
group: "ИС-214/23",
role: UserRole::Student,
})
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn sign_up_multiple() {
// prepare
test_env();
let app_state = static_app_state().await;
UserEntity::delete_many()
.filter(UserColumn::Username.eq("test::sign_up_multiple"))
.exec(app_state.get_database())
.await
.expect("Failed to delete user");
let create = sign_up_client(SignUpPartial {
username: "test::sign_up_multiple",
group: "ИС-214/23",
role: UserRole::Student,
})
.await;
assert_eq!(create.status(), StatusCode::OK);
let resp = sign_up_client(SignUpPartial {
username: "test::sign_up_multiple",
group: "ИС-214/23",
role: UserRole::Student,
})
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[actix_web::test]
async fn sign_up_invalid_role() {
test_env();
// test
let resp = sign_up_client(SignUpPartial {
username: "test::sign_up_invalid_role",
group: "ИС-214/23",
role: UserRole::Admin,
})
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[actix_web::test]
async fn sign_up_invalid_group() {
test_env();
// test
let resp = sign_up_client(SignUpPartial {
username: "test::sign_up_invalid_group",
group: "invalid_group",
role: UserRole::Student,
})
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
}

5
src/routes/flow/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
mod telegram_auth;
mod telegram_complete;
pub use telegram_auth::*;
pub use telegram_complete::*;

View File

@@ -0,0 +1,173 @@
use self::schema::*;
use crate::routes::schema::ResponseError;
use crate::utility::telegram::{WebAppInitDataMap, WebAppUser};
use crate::{utility, AppState};
use actix_web::{post, web};
use chrono::{DateTime, Duration, Utc};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::{ActiveUser, UserType};
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, Set};
use objectid::ObjectId;
use std::sync::Arc;
use web::Json;
#[utoipa::path(responses(
(status = OK, body = Response),
(status = UNAUTHORIZED, body = ResponseError<ErrorCode>),
))]
#[post("/telegram-auth")]
pub async fn telegram_auth(
data_json: Json<Request>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
let init_data = WebAppInitDataMap::from_str(data_json.into_inner().init_data);
{
let env = &app_state.get_env().telegram;
if let Err(error) = init_data.verify(env.bot_id, env.test_dc) {
return Err(ErrorCode::InvalidInitData(Arc::new(error))).into();
}
}
let auth_date = DateTime::<Utc>::from_timestamp(
init_data
.data_map
.get("auth_date")
.unwrap()
.parse()
.unwrap(),
0,
)
.unwrap();
if Utc::now() - auth_date > Duration::minutes(5) {
return Err(ErrorCode::ExpiredInitData).into();
}
let web_app_user =
serde_json::from_str::<WebAppUser>(init_data.data_map.get("user").unwrap()).unwrap();
let user = match Query::find_user_by_telegram_id(app_state.get_database(), web_app_user.id)
.await
.expect("Failed to find user by telegram id")
{
Some(value) => value,
None => {
let new_user = ActiveUser {
id: Set(ObjectId::new().unwrap().to_string()),
username: Set(format!("telegram_{}", web_app_user.id)), // можно оставить, а можно поменять
password: Set(None), // ибо нехуй
vk_id: Set(None),
telegram_id: Set(Some(web_app_user.id)),
group: Set(None),
role: Set(UserRole::Student), // TODO: при реге проверять данные
android_version: Set(None),
};
new_user
.insert(app_state.get_database())
.await
.expect("Failed to insert user")
}
};
let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(Response::new(&access_token, user.group.is_some())).into()
}
mod schema {
use crate::routes::schema::PartialOkResponse;
use crate::state::AppState;
use crate::utility::telegram::VerifyError;
use actix_macros::ErrResponse;
use actix_web::body::EitherBody;
use actix_web::cookie::time::OffsetDateTime;
use actix_web::cookie::CookieBuilder;
use actix_web::{web, HttpRequest, HttpResponse};
use derive_more::Display;
use serde::{Deserialize, Serialize, Serializer};
use std::ops::Add;
use std::sync::Arc;
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
#[schema(as = Flow::TelegramAuth::Request)]
pub struct Request {
/// Telegram WebApp init data.
pub init_data: String,
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
#[schema(as = Flow::TelegramAuth::Response)]
pub struct Response {
// #[serde(skip)] // TODO: я пока не придумал как не отдавать сырой токен в ответе
// #[schema(ignore)]
access_token: String,
pub completed: bool,
}
impl Response {
pub fn new(access_token: &str, completed: bool) -> Self {
Self {
access_token: access_token.to_string(),
completed,
}
}
}
impl PartialOkResponse for Response {
fn post_process(
&mut self,
request: &HttpRequest,
response: &mut HttpResponse<EitherBody<String>>,
) {
let access_token = &self.access_token;
let app_state = request.app_data::<web::Data<AppState>>().unwrap();
let mini_app_host = &*app_state.get_env().telegram.mini_app_host;
let cookie = CookieBuilder::new("access_token", access_token)
.domain(mini_app_host)
.path("/")
.expires(
OffsetDateTime::now_utc().add(std::time::Duration::from_secs(60 * 60 * 24 * 7)),
)
.http_only(true)
.secure(true)
.finish();
response.add_cookie(&cookie).unwrap();
}
}
pub type ServiceResponse = crate::routes::schema::Response<Response, ErrorCode>;
#[derive(Clone, ToSchema, Display, ErrResponse)]
#[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"]
#[schema(as = Flow::TelegramAuth::ErrorCode)]
pub enum ErrorCode {
#[display("Invalid init data provided: {_0}")]
#[schema(value_type = String)]
InvalidInitData(Arc<VerifyError>),
#[display("Expired init data provided.")]
ExpiredInitData,
}
impl Serialize for ErrorCode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
ErrorCode::InvalidInitData(_) => serializer.serialize_str("INVALID_INIT_DATA"),
ErrorCode::ExpiredInitData => serializer.serialize_str("EXPIRED_INIT_DATA"),
}
}
}
}

View File

@@ -0,0 +1,101 @@
use self::schema::*;
use crate::extractors::base::AsyncExtractor;
use crate::routes::schema::ResponseError;
use crate::AppState;
use actix_web::{post, web};
use database::entity::User;
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
use web::Json;
#[utoipa::path(responses(
(status = OK),
(status = CONFLICT, body = ResponseError<ErrorCode>),
(status = INTERNAL_SERVER_ERROR, body = ResponseError<ErrorCode>),
(status = BAD_REQUEST, body = ResponseError<ErrorCode>)
))]
#[post("/telegram-complete")]
pub async fn telegram_complete(
data: Json<Request>,
app_state: web::Data<AppState>,
user: AsyncExtractor<User>,
) -> ServiceResponse {
let user = user.into_inner();
// проверка на перезапись уже имеющихся данных
if user.group.is_some() {
return Err(ErrorCode::AlreadyCompleted).into();
}
let data = data.into_inner();
let db = app_state.get_database();
let mut active_user = user.clone().into_active_model();
// замена существующего имени, если оно отличается
if user.username != data.username {
if Query::is_user_exists_by_username(db, &data.username)
.await
.unwrap()
{
return Err(ErrorCode::UsernameAlreadyExists).into();
}
active_user.username = Set(data.username);
}
// проверка на существование группы
if !app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.groups
.contains_key(&data.group)
{
return Err(ErrorCode::InvalidGroupName).into();
}
active_user.group = Set(Some(data.group));
active_user.update(db).await.expect("Failed to update user");
Ok(()).into()
}
mod schema {
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[schema(as = Flow::TelegramFill::Request)]
pub struct Request {
/// Username.
pub username: String,
/// Group.
pub group: String,
}
pub type ServiceResponse = crate::routes::schema::Response<(), ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = Flow::TelegramFill::ErrorCode)]
pub enum ErrorCode {
#[display("This flow is already completed.")]
#[status_code = "actix_web::http::StatusCode::CONFLICT"]
AlreadyCompleted,
#[display("User with that name already exists.")]
#[status_code = "actix_web::http::StatusCode::BAD_REQUEST"]
UsernameAlreadyExists,
#[display("The required group does not exist.")]
#[status_code = "actix_web::http::StatusCode::BAD_REQUEST"]
InvalidGroupName,
}
}

View File

@@ -1 +1,7 @@
pub mod admin;
pub mod auth; pub mod auth;
pub mod flow;
pub mod schedule;
mod schema;
pub mod users;
pub mod vk_id;

View File

@@ -0,0 +1,17 @@
use crate::routes::schedule::schema::CacheStatus;
use crate::AppState;
use actix_web::{get, web};
use std::ops::Deref;
#[utoipa::path(responses(
(status = OK, body = CacheStatus),
))]
#[get("/cache-status")]
pub async fn cache_status(app_state: web::Data<AppState>) -> CacheStatus {
app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.deref()
.into()
}

View File

@@ -0,0 +1,9 @@
use crate::routes::schedule::schema::ScheduleView;
use crate::state::AppState;
use actix_web::{get, web};
#[utoipa::path(responses((status = OK, body = ScheduleView)))]
#[get("/")]
pub async fn schedule(app_state: web::Data<AppState>) -> ScheduleView {
ScheduleView::from(&app_state).await
}

View File

@@ -0,0 +1,72 @@
use self::schema::*;
use crate::AppState;
use crate::extractors::base::AsyncExtractor;
use crate::routes::schedule::schema::ScheduleEntryResponse;
use crate::routes::schema::ResponseError;
use actix_web::{get, web};
use database::entity::User;
#[utoipa::path(responses(
(status = OK, body = ScheduleEntryResponse),
(
status = SERVICE_UNAVAILABLE,
body = ResponseError<ErrorCode>,
example = json!({
"code": "NO_SCHEDULE",
"message": "Schedule not parsed yet."
})
),
(
status = NOT_FOUND,
body = ResponseError<ErrorCode>,
example = json!({
"code": "NOT_FOUND",
"message": "Required group not found."
})
),
))]
#[get("/group")]
pub async fn group(user: AsyncExtractor<User>, app_state: web::Data<AppState>) -> ServiceResponse {
match &user.into_inner().group {
None => Err(ErrorCode::SignUpNotCompleted),
Some(group) => match app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.groups
.get(group)
{
None => Err(ErrorCode::NotFound),
Some(entry) => Ok(entry.clone().into()),
},
}
.into()
}
mod schema {
use crate::routes::schedule::schema::ScheduleEntryResponse;
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<ScheduleEntryResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = GroupSchedule::ErrorCode)]
pub enum ErrorCode {
/// The user tried to access the API without completing singing up.
#[status_code = "actix_web::http::StatusCode::FORBIDDEN"]
#[display("You have not completed signing up.")]
SignUpNotCompleted,
/// Group not found.
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required group not found.")]
NotFound,
}
}

View File

@@ -0,0 +1,65 @@
use self::schema::*;
use crate::routes::schedule::schema::ScheduleEntryResponse;
use crate::routes::schema::ResponseError;
use crate::AppState;
use actix_web::{get, web};
#[utoipa::path(responses(
(status = OK, body = ScheduleEntryResponse),
(
status = SERVICE_UNAVAILABLE,
body = ResponseError<ErrorCode>,
example = json!({
"code": "NO_SCHEDULE",
"message": "Schedule not parsed yet."
})
),
(
status = NOT_FOUND,
body = ResponseError<ErrorCode>,
example = json!({
"code": "NOT_FOUND",
"message": "Required group not found."
})
),
))]
#[get("/group/{group_name}")]
pub async fn group_by_name(
path: web::Path<String>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
let group_name = path.into_inner();
match app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.groups
.get(&group_name)
{
None => Err(ErrorCode::NotFound),
Some(entry) => Ok(entry.clone().into()),
}
.into()
}
mod schema {
use crate::routes::schedule::schema::ScheduleEntryResponse;
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<ScheduleEntryResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = GroupByNameSchedule::ErrorCode)]
pub enum ErrorCode {
/// Group not found.
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required group not found.")]
NotFound,
}
}

View File

@@ -0,0 +1,35 @@
use self::schema::*;
use crate::AppState;
use actix_web::{get, web};
#[utoipa::path(responses((status = OK, body = Response)))]
#[get("/group-names")]
pub async fn group_names(app_state: web::Data<AppState>) -> Response {
let mut names: Vec<String> = app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.groups
.keys()
.cloned()
.collect();
names.sort();
Response { names }
}
mod schema {
use actix_macros::ResponderJson;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema, ResponderJson)]
#[schema(as = GetGroupNames::Response)]
pub struct Response {
/// List of group names sorted in alphabetical order.
#[schema(examples(json!(["ИС-214/23"])))]
pub names: Vec<String>,
}
}

View File

@@ -0,0 +1,16 @@
mod cache_status;
mod group;
mod group_by_name;
mod group_names;
mod get;
mod schema;
mod teacher;
mod teacher_names;
pub use cache_status::*;
pub use group::*;
pub use group_by_name::*;
pub use group_names::*;
pub use get::*;
pub use teacher::*;
pub use teacher_names::*;

View File

@@ -0,0 +1,74 @@
use crate::state::AppState;
use actix_macros::{OkResponse, ResponderJson};
use actix_web::web;
use providers::base::{ScheduleEntry, ScheduleSnapshot};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ops::Deref;
use utoipa::ToSchema;
/// Response from schedule server.
#[derive(Serialize, ToSchema, OkResponse, ResponderJson)]
#[serde(rename_all = "camelCase")]
pub struct ScheduleView {
/// Url to xls file.
url: String,
/// Groups schedule.
groups: HashMap<String, ScheduleEntry>,
/// Teachers schedule.
teachers: HashMap<String, ScheduleEntry>,
}
#[derive(Serialize, ToSchema, OkResponse)]
pub struct ScheduleEntryResponse(ScheduleEntry);
impl From<ScheduleEntry> for ScheduleEntryResponse {
fn from(value: ScheduleEntry) -> Self {
Self(value)
}
}
impl ScheduleView {
pub async fn from(app_state: &web::Data<AppState>) -> Self {
let schedule = app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.deref()
.clone();
Self {
url: schedule.url,
groups: schedule.data.groups,
teachers: schedule.data.teachers,
}
}
}
/// Cached schedule status.
#[derive(Serialize, Deserialize, ToSchema, ResponderJson, OkResponse)]
#[serde(rename_all = "camelCase")]
pub struct CacheStatus {
/// Schedule hash.
pub hash: String,
/// Last cache update date.
pub fetched_at: i64,
/// Cached schedule update date.
///
/// Determined by the polytechnic's server.
pub updated_at: i64,
}
impl From<&ScheduleSnapshot> for CacheStatus {
fn from(value: &ScheduleSnapshot) -> Self {
Self {
hash: value.hash(),
fetched_at: value.fetched_at.timestamp(),
updated_at: value.updated_at.timestamp(),
}
}
}

View File

@@ -0,0 +1,53 @@
use self::schema::*;
use crate::AppState;
use crate::routes::schema::ResponseError;
use actix_web::{get, web};
use providers::base::ScheduleEntry;
#[utoipa::path(responses(
(status = OK, body = ScheduleEntry),
(
status = NOT_FOUND,
body = ResponseError<ErrorCode>,
example = json!({
"code": "NOT_FOUND",
"message": "Required teacher not found."
})
),
))]
#[get("/teacher/{name}")]
pub async fn teacher(name: web::Path<String>, app_state: web::Data<AppState>) -> ServiceResponse {
match app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.teachers
.get(&name.into_inner())
{
None => Err(ErrorCode::NotFound),
Some(entry) => Ok(entry.clone().into()),
}
.into()
}
mod schema {
use crate::routes::schedule::schema::ScheduleEntryResponse;
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<ScheduleEntryResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = TeacherSchedule::ErrorCode)]
pub enum ErrorCode {
/// Teacher not found.
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required teacher not found.")]
NotFound,
}
}

View File

@@ -0,0 +1,35 @@
use self::schema::*;
use crate::AppState;
use actix_web::{get, web};
#[utoipa::path(responses((status = OK, body = Response)))]
#[get("/teacher-names")]
pub async fn teacher_names(app_state: web::Data<AppState>) -> Response {
let mut names: Vec<String> = app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.teachers
.keys()
.cloned()
.collect();
names.sort();
Response { names }
}
mod schema {
use actix_macros::ResponderJson;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema, ResponderJson)]
#[schema(as = GetTeacherNames::Response)]
pub struct Response {
/// List of teacher names sorted alphabetically.
#[schema(examples(json!(["Хомченко Н.Е."])))]
pub names: Vec<String>,
}
}

213
src/routes/schema.rs Normal file
View File

@@ -0,0 +1,213 @@
use actix_web::body::EitherBody;
use actix_web::error::JsonPayloadError;
use actix_web::http::StatusCode;
use actix_web::{HttpRequest, HttpResponse, Responder};
use serde::{Serialize, Serializer};
use std::convert::Into;
use std::fmt::Display;
use utoipa::PartialSchema;
pub struct Response<T, E>(pub Result<T, E>)
where
T: Serialize + PartialSchema + PartialOkResponse,
E: Serialize + PartialSchema + Display + PartialErrResponse;
/// Transform Response<T, E> into Result<T, E>
impl<T, E> From<Response<T, E>> for Result<T, E>
where
T: Serialize + PartialSchema + PartialOkResponse,
E: Serialize + PartialSchema + Display + PartialErrResponse,
{
fn from(value: Response<T, E>) -> Self {
value.0
}
}
/// Transform T into Response<T, E>
impl<T, E> From<Result<T, E>> for Response<T, E>
where
T: Serialize + PartialSchema + PartialOkResponse,
E: Serialize + PartialSchema + Display + PartialErrResponse,
{
fn from(value: Result<T, E>) -> Self {
Response(value)
}
}
/// Serialize Response<T, E>
impl<T, E> Serialize for Response<T, E>
where
T: Serialize + PartialSchema + PartialOkResponse,
E: Serialize + PartialSchema + Display + PartialErrResponse + Clone + Into<ResponseError<E>>,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match &self.0 {
Ok(ok) => serializer.serialize_some(&ok),
Err(err) => serializer.serialize_some(&err.clone().into()),
}
}
}
/// Transform Response<T, E> to HttpResponse<String>
impl<T, E> Responder for Response<T, E>
where
T: Serialize + PartialSchema + PartialOkResponse,
E: Serialize + PartialSchema + Display + PartialErrResponse + Clone + Into<ResponseError<E>>,
{
type Body = EitherBody<String>;
fn respond_to(mut self, request: &HttpRequest) -> HttpResponse<Self::Body> {
match serde_json::to_string(&self) {
Ok(body) => {
let code = match &self.0 {
Ok(_) => StatusCode::OK,
Err(e) => e.status_code(),
};
let mut response = match HttpResponse::build(code)
.content_type(mime::APPLICATION_JSON)
.message_body(body)
{
Ok(res) => res.map_into_left_body(),
Err(err) => HttpResponse::from_error(err).map_into_right_body(),
};
if let Ok(ok) = &mut self.0 {
ok.post_process(request, &mut response);
}
response
}
Err(err) => {
HttpResponse::from_error(JsonPayloadError::Serialize(err)).map_into_right_body()
}
}
}
}
/// Трейт для всех положительных ответов от сервера
pub trait PartialOkResponse {
fn post_process(
&mut self,
_request: &HttpRequest,
_response: &mut HttpResponse<EitherBody<String>>,
) {
}
}
impl PartialOkResponse for () {}
/// Трейт для всех отрицательных ответов от сервера
pub trait PartialErrResponse {
fn status_code(&self) -> StatusCode;
}
/// ResponseError<T>
#[derive(Serialize, utoipa::ToSchema)]
pub struct ResponseError<T: Serialize + PartialSchema + Clone> {
pub code: T,
pub message: String,
}
impl<T> From<T> for ResponseError<T>
where
T: Serialize + PartialSchema + Display + Clone,
{
fn from(code: T) -> Self {
Self {
message: format!("{}", code),
code,
}
}
}
pub mod user {
use actix_macros::{OkResponse, ResponderJson};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::User;
use serde::Serialize;
//noinspection SpellCheckingInspection
/// Используется для скрытия чувствительных полей, таких как хеш пароля
#[derive(Serialize, utoipa::ToSchema, ResponderJson, OkResponse)]
#[serde(rename_all = "camelCase")]
pub struct UserResponse {
/// UUID
#[schema(examples("67dcc9a9507b0000772744a2"))]
pub id: String,
/// Имя пользователя
#[schema(examples("n08i40k"))]
pub username: String,
/// Группа
#[schema(examples("ИС-214/23"))]
pub group: Option<String>,
/// Роль
pub role: UserRole,
/// Идентификатор привязанного аккаунта VK
#[schema(examples(498094647, json!(null)))]
pub vk_id: Option<i32>,
/// Идентификатор привязанного аккаунта Telegram
#[schema(examples(996004735, json!(null)))]
pub telegram_id: Option<i64>,
/// JWT токен доступа
#[schema(examples(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
}
impl UserResponse {
pub fn from_user_with_token(user: User, access_token: String) -> Self {
Self {
id: user.id.clone(),
username: user.username.clone(),
group: user.group.clone(),
role: user.role.clone(),
vk_id: user.vk_id,
telegram_id: user.telegram_id,
access_token: Some(access_token),
}
}
}
/// Create UserResponse from User ref.
impl From<&User> for UserResponse {
fn from(user: &User) -> Self {
Self {
id: user.id.clone(),
username: user.username.clone(),
group: user.group.clone(),
role: user.role.clone(),
vk_id: user.vk_id,
telegram_id: user.telegram_id,
access_token: None,
}
}
}
/// Transform User to UserResponse.
impl From<User> for UserResponse {
fn from(user: User) -> Self {
Self {
id: user.id,
username: user.username,
group: user.group,
role: user.role,
vk_id: user.vk_id,
telegram_id: user.telegram_id,
access_token: None,
}
}
}
}

56
src/routes/users/by.rs Normal file
View File

@@ -0,0 +1,56 @@
use crate::routes::schema::user::UserResponse;
use crate::routes::users::by::schema::{ErrorCode, ServiceResponse};
use crate::state::AppState;
use actix_web::{get, web};
use database::query::Query;
#[utoipa::path(responses((status = OK, body = UserResponse)))]
#[get("/id/{id}")]
pub async fn by_id(app_state: web::Data<AppState>, path: web::Path<String>) -> ServiceResponse {
let user_id = path.into_inner();
let db = app_state.get_database();
match Query::find_user_by_id(db, &user_id).await {
Ok(Some(user)) => Ok(UserResponse::from(user)),
_ => Err(ErrorCode::NotFound),
}
.into()
}
#[utoipa::path(responses((status = OK, body = UserResponse)))]
#[get("/telegram-id/{id}")]
pub async fn by_telegram_id(
app_state: web::Data<AppState>,
path: web::Path<i64>,
) -> ServiceResponse {
let telegram_id = path.into_inner();
let db = app_state.get_database();
match Query::find_user_by_telegram_id(db, telegram_id).await {
Ok(Some(user)) => Ok(UserResponse::from(user)),
_ => Err(ErrorCode::NotFound),
}
.into()
}
mod schema {
use crate::routes::schema::user::UserResponse;
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<UserResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = Users::By::ErrorCode)]
pub enum ErrorCode {
/// User not found.
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required user not found.")]
NotFound,
}
}

View File

@@ -0,0 +1,69 @@
use self::schema::*;
use crate::extractors::base::AsyncExtractor;
use crate::state::AppState;
use actix_web::{post, web};
use database::entity::User;
use database::sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
#[utoipa::path(responses((status = OK)))]
#[post("/change-group")]
pub async fn change_group(
app_state: web::Data<AppState>,
user: AsyncExtractor<User>,
data: web::Json<Request>,
) -> ServiceResponse {
let user = user.into_inner();
if user
.group
.as_ref()
.is_some_and(|group| group.eq(&data.group))
{
return Ok(()).into();
}
if !app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.groups
.contains_key(&data.group)
{
return Err(ErrorCode::NotFound).into();
}
let mut active_user = user.clone().into_active_model();
active_user.group = Set(Some(data.into_inner().group));
active_user.update(app_state.get_database()).await.unwrap();
Ok(()).into()
}
mod schema {
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<(), ErrorCode>;
#[derive(Deserialize, ToSchema)]
#[schema(as = ChangeGroup::Request)]
pub struct Request {
// Group.
pub group: String,
}
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = ChangeGroup::ErrorCode)]
#[status_code = "actix_web::http::StatusCode::CONFLICT"]
pub enum ErrorCode {
/// The required group does not exist.
#[display("The required group does not exist.")]
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
NotFound,
}
}

View File

@@ -0,0 +1,62 @@
use self::schema::*;
use crate::extractors::base::AsyncExtractor;
use crate::state::AppState;
use actix_web::{post, web};
use database::entity::User;
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
#[utoipa::path(responses((status = OK)))]
#[post("/change-username")]
pub async fn change_username(
app_state: web::Data<AppState>,
user: AsyncExtractor<User>,
data: web::Json<Request>,
) -> ServiceResponse {
let user = user.into_inner();
if user.username == data.username {
return Ok(()).into();
}
let db = app_state.get_database();
if Query::is_user_exists_by_username(db, &data.username)
.await
.unwrap()
{
return Err(ErrorCode::AlreadyExists).into();
}
let mut active_user = user.into_active_model();
active_user.username = Set(data.into_inner().username);
active_user.update(db).await.unwrap();
Ok(()).into()
}
mod schema {
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<(), ErrorCode>;
#[derive(Serialize, Deserialize, ToSchema)]
#[schema(as = ChangeUsername::Request)]
pub struct Request {
/// User name.
pub username: String,
}
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = ChangeUsername::ErrorCode)]
#[status_code = "actix_web::http::StatusCode::CONFLICT"]
pub enum ErrorCode {
/// A user with this name already exists.
#[display("A user with this name already exists.")]
AlreadyExists,
}
}

10
src/routes/users/me.rs Normal file
View File

@@ -0,0 +1,10 @@
use crate::extractors::base::AsyncExtractor;
use crate::routes::schema::user::UserResponse;
use actix_web::get;
use database::entity::User;
#[utoipa::path(responses((status = OK, body = UserResponse)))]
#[get("/me")]
pub async fn me(user: AsyncExtractor<User>) -> UserResponse {
user.into_inner().into()
}

Some files were not shown because too many files have changed in this diff Show More