Реализованы все требуемые эндпоинты schedule.

Улучшена документация.
This commit is contained in:
2025-03-28 23:24:37 +04:00
parent 30c985a3d7
commit 680419ea78
32 changed files with 998 additions and 257 deletions

11
Cargo.lock generated
View File

@@ -50,7 +50,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rand 0.9.0", "rand 0.9.0",
"sha1", "sha1 0.10.6",
"smallvec", "smallvec",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -2351,7 +2351,7 @@ dependencies = [
[[package]] [[package]]
name = "schedule-parser-rusted" name = "schedule-parser-rusted"
version = "0.7.0" version = "0.8.0"
dependencies = [ dependencies = [
"actix-macros 0.1.0", "actix-macros 0.1.0",
"actix-test", "actix-test",
@@ -2367,6 +2367,7 @@ dependencies = [
"env_logger", "env_logger",
"futures-util", "futures-util",
"fuzzy-matcher", "fuzzy-matcher",
"hex",
"jsonwebtoken", "jsonwebtoken",
"mime", "mime",
"objectid", "objectid",
@@ -2377,7 +2378,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"serde_with", "serde_with",
"sha2", "sha1 0.11.0-pre.5",
"tokio", "tokio",
"utoipa", "utoipa",
"utoipa-actix-web", "utoipa-actix-web",
@@ -2510,10 +2511,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "sha2" name = "sha1"
version = "0.11.0-pre.5" version = "0.11.0-pre.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b4241d1a56954dce82cecda5c8e9c794eef6f53abe5e5216bac0a0ea71ffa7" checksum = "55f44e40722caefdd99383c25d3ae52a1094a1951215ae76f68837ece4e7f566"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",

View File

@@ -3,7 +3,7 @@ members = ["actix-macros", "actix-test"]
[package] [package]
name = "schedule-parser-rusted" name = "schedule-parser-rusted"
version = "0.7.0" version = "0.8.0"
edition = "2024" edition = "2024"
publish = false publish = false
@@ -21,6 +21,7 @@ env_logger = "0.11.7"
futures-util = "0.3.31" futures-util = "0.3.31"
fuzzy-matcher = "0.3.7" fuzzy-matcher = "0.3.7"
jsonwebtoken = { version = "9.3.1", features = ["use_pem"] } jsonwebtoken = { version = "9.3.1", features = ["use_pem"] }
hex = "0.4.3"
mime = "0.3.17" mime = "0.3.17"
objectid = "0.2.0" objectid = "0.2.0"
regex = "1.11.1" regex = "1.11.1"
@@ -29,7 +30,7 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" serde_json = "1.0.140"
serde_with = "3.12.0" serde_with = "3.12.0"
serde_repr = "0.1.20" serde_repr = "0.1.20"
sha2 = "0.11.0-pre.5" sha1 = "0.11.0-pre.5"
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
rand = "0.9.0" rand = "0.9.0"
utoipa = { version = "5", features = ["actix_extras", "chrono"] } utoipa = { version = "5", features = ["actix_extras", "chrono"] }

View File

@@ -121,16 +121,9 @@ mod responder_json {
type Body = ::actix_web::body::EitherBody<::actix_web::body::BoxBody>; type Body = ::actix_web::body::EitherBody<::actix_web::body::BoxBody>;
fn respond_to(self, _: &::actix_web::HttpRequest) -> ::actix_web::HttpResponse<Self::Body> { fn respond_to(self, _: &::actix_web::HttpRequest) -> ::actix_web::HttpResponse<Self::Body> {
match ::serde_json::to_string(&self) { ::actix_web::HttpResponse::Ok()
Ok(body) => ::actix_web::HttpResponse::Ok() .json(self)
.json(body) .map_into_left_body()
.map_into_left_body(),
Err(err) => ::actix_web::HttpResponse::from_error(
::actix_web::error::JsonPayloadError::Serialize(err),
)
.map_into_right_body(),
}
} }
} }
}) })

View File

@@ -5,7 +5,7 @@ use schedule_parser_rusted::parser::parse_xls;
pub fn bench_parse_xls(c: &mut Criterion) { pub fn bench_parse_xls(c: &mut Criterion) {
let buffer: Vec<u8> = include_bytes!("../schedule.xls").to_vec(); let buffer: Vec<u8> = include_bytes!("../schedule.xls").to_vec();
c.bench_function("parse_xls", |b| b.iter(|| parse_xls(&buffer))); c.bench_function("parse_xls", |b| b.iter(|| parse_xls(&buffer).unwrap()));
} }
criterion_group!(benches, bench_parse_xls); criterion_group!(benches, bench_parse_xls);

View File

@@ -1,19 +1,37 @@
use crate::parser::schema::ParseResult; use crate::parser::schema::ParseResult;
use crate::utility::hasher::DigestHasher;
use crate::xls_downloader::basic_impl::BasicXlsDownloader; use crate::xls_downloader::basic_impl::BasicXlsDownloader;
use actix_web::web; use actix_web::web;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use diesel::{Connection, PgConnection}; use diesel::{Connection, PgConnection};
use sha1::{Digest, Sha1};
use std::env; use std::env;
use std::hash::Hash;
use std::sync::{Mutex, MutexGuard}; use std::sync::{Mutex, MutexGuard};
#[derive(Clone)] #[derive(Clone)]
pub struct Schedule { pub struct Schedule {
pub etag: String, pub etag: String,
pub fetched_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub parsed_at: DateTime<Utc>, pub parsed_at: DateTime<Utc>,
pub data: ParseResult, pub data: ParseResult,
} }
impl Schedule {
pub fn hash(&self) -> String {
let mut hasher = DigestHasher::from(Sha1::new());
self.etag.hash(&mut hasher);
self.data.teachers.iter().for_each(|e| e.hash(&mut hasher));
self.data.groups.iter().for_each(|e| e.hash(&mut hasher));
hasher.finalize()
}
}
/// Общие данные передаваемые в эндпоинты
pub struct AppState { pub struct AppState {
pub downloader: Mutex<BasicXlsDownloader>, pub downloader: Mutex<BasicXlsDownloader>,
pub schedule: Mutex<Option<Schedule>>, pub schedule: Mutex<Option<Schedule>>,
@@ -21,11 +39,13 @@ pub struct AppState {
} }
impl AppState { impl AppState {
/// Получение объекта соединения с базой данных PostgreSQL
pub fn connection(&self) -> MutexGuard<PgConnection> { pub fn connection(&self) -> MutexGuard<PgConnection> {
self.database.lock().unwrap() self.database.lock().unwrap()
} }
} }
/// Создание нового объекта web::Data<AppState>
pub fn app_state() -> web::Data<AppState> { pub fn app_state() -> web::Data<AppState> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");

View File

@@ -30,7 +30,7 @@ pub mod users {
.select(User::as_select()) .select(User::as_select())
.first(con) .first(con)
} }
pub fn get_by_vk_id( pub fn get_by_vk_id(
connection: &Mutex<PgConnection>, connection: &Mutex<PgConnection>,
_vk_id: i32, _vk_id: i32,
@@ -72,6 +72,14 @@ pub mod users {
} }
} }
pub fn insert(connection: &Mutex<PgConnection>, user: &User) -> QueryResult<usize> {
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
insert_into(users).values(user).execute(con)
}
#[cfg(test)]
pub fn delete_by_username(connection: &Mutex<PgConnection>, _username: &String) -> bool { pub fn delete_by_username(connection: &Mutex<PgConnection>, _username: &String) -> bool {
let mut lock = connection.lock().unwrap(); let mut lock = connection.lock().unwrap();
let con = lock.deref_mut(); let con = lock.deref_mut();
@@ -81,14 +89,8 @@ pub mod users {
Err(_) => false, Err(_) => false,
} }
} }
pub fn insert(connection: &Mutex<PgConnection>, user: &User) -> QueryResult<usize> { #[cfg(test)]
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
insert_into(users).values(user).execute(con)
}
pub fn insert_or_ignore(connection: &Mutex<PgConnection>, user: &User) -> QueryResult<usize> { pub fn insert_or_ignore(connection: &Mutex<PgConnection>, user: &User) -> QueryResult<usize> {
let mut lock = connection.lock().unwrap(); let mut lock = connection.lock().unwrap();
let con = lock.deref_mut(); let con = lock.deref_mut();

View File

@@ -35,12 +35,27 @@ pub enum UserRole {
#[diesel(table_name = crate::database::schema::users)] #[diesel(table_name = crate::database::schema::users)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
pub struct User { pub struct User {
/// UUID аккаунта
pub id: String, pub id: String,
/// Имя пользователя
pub username: String, pub username: String,
/// BCrypt хеш пароля
pub password: String, pub password: String,
/// Идентификатор привязанного аккаунта VK
pub vk_id: Option<i32>, pub vk_id: Option<i32>,
/// JWT токен доступа
pub access_token: String, pub access_token: String,
/// Группа
pub group: String, pub group: String,
/// Роль
pub role: UserRole, pub role: UserRole,
/// Версия установленного приложения Polytechnic+
pub version: String, pub version: String,
} }

View File

@@ -6,25 +6,29 @@ use crate::utility::jwt;
use actix_macros::ResponseErrorMessage; use actix_macros::ResponseErrorMessage;
use actix_web::body::BoxBody; use actix_web::body::BoxBody;
use actix_web::dev::Payload; use actix_web::dev::Payload;
use actix_web::http::header;
use actix_web::{HttpRequest, web}; use actix_web::{HttpRequest, web};
use derive_more::Display; use derive_more::Display;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Debug; use std::fmt::Debug;
use actix_web::http::header;
#[derive(Clone, Debug, Serialize, Deserialize, Display, ResponseErrorMessage)] #[derive(Clone, Debug, Serialize, Deserialize, Display, ResponseErrorMessage)]
#[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"] #[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Error { pub enum Error {
/// В запросе отсутствует заголовок Authorization
#[display("No Authorization header found")] #[display("No Authorization header found")]
NoHeader, NoHeader,
/// Неизвестный тип авторизации, отличающийся от Bearer
#[display("Bearer token is required")] #[display("Bearer token is required")]
UnknownAuthorizationType, UnknownAuthorizationType,
/// Токен не действителен
#[display("Invalid or expired access token")] #[display("Invalid or expired access token")]
InvalidAccessToken, InvalidAccessToken,
/// Пользователь привязанный к токену не найден в базе данных
#[display("No user associated with access token")] #[display("No user associated with access token")]
NoUser, NoUser,
} }
@@ -35,6 +39,7 @@ impl Error {
} }
} }
/// Экстрактор пользователя из запроса с токеном
impl FromRequestSync for User { impl FromRequestSync for User {
type Error = actix_web::Error; type Error = actix_web::Error;

View File

@@ -3,20 +3,25 @@ use actix_web::{FromRequest, HttpRequest};
use futures_util::future::LocalBoxFuture; use futures_util::future::LocalBoxFuture;
use std::future::{Ready, ready}; use std::future::{Ready, ready};
pub trait FromRequestAsync: Sized { /// Асинхронный экстрактор объектов из запроса
type Error: Into<actix_web::Error>;
async fn from_request_async(req: HttpRequest, payload: Payload) -> Result<Self, Self::Error>;
}
pub struct AsyncExtractor<T>(T); pub struct AsyncExtractor<T>(T);
impl<T> AsyncExtractor<T> { impl<T> AsyncExtractor<T> {
#[allow(dead_code)]
/// Получение объекта, извлечённого с помощью экстрактора
pub fn into_inner(self) -> T { pub fn into_inner(self) -> T {
self.0 self.0
} }
} }
pub trait FromRequestAsync: Sized {
type Error: Into<actix_web::Error>;
/// Асинхронная функция для извлечения данных из запроса
async fn from_request_async(req: HttpRequest, payload: Payload) -> Result<Self, Self::Error>;
}
/// Реализация треита FromRequest для всех асинхронных экстракторов
impl<T: FromRequestAsync> FromRequest for AsyncExtractor<T> { impl<T: FromRequestAsync> FromRequest for AsyncExtractor<T> {
type Error = T::Error; type Error = T::Error;
type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>; type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
@@ -32,20 +37,24 @@ impl<T: FromRequestAsync> FromRequest for AsyncExtractor<T> {
} }
} }
pub trait FromRequestSync: Sized { /// Синхронный экстрактор объектов из запроса
type Error: Into<actix_web::Error>;
fn from_request_sync(req: &HttpRequest, payload: &mut Payload) -> Result<Self, Self::Error>;
}
pub struct SyncExtractor<T>(T); pub struct SyncExtractor<T>(T);
impl<T> SyncExtractor<T> { impl<T> SyncExtractor<T> {
/// Получение объекта, извлечённого с помощью экстрактора
pub fn into_inner(self) -> T { pub fn into_inner(self) -> T {
self.0 self.0
} }
} }
pub trait FromRequestSync: Sized {
type Error: Into<actix_web::Error>;
/// Синхронная функция для извлечения данных из запроса
fn from_request_sync(req: &HttpRequest, payload: &mut Payload) -> Result<Self, Self::Error>;
}
/// Реализация треита FromRequest для всех синхронных экстракторов
impl<T: FromRequestSync> FromRequest for SyncExtractor<T> { impl<T: FromRequestSync> FromRequest for SyncExtractor<T> {
type Error = T::Error; type Error = T::Error;
type Future = Ready<Result<Self, Self::Error>>; type Future = Ready<Result<Self, Self::Error>>;

View File

@@ -1,13 +1,19 @@
use crate::app_state::{app_state, AppState}; use crate::app_state::{AppState, app_state};
use crate::middlewares::authorization::Authorization; use crate::middlewares::authorization::JWTAuthorization;
use crate::routes::auth::sign_in::{sign_in_default, sign_in_vk}; use crate::routes::auth::sign_in::{sign_in_default, sign_in_vk};
use crate::routes::auth::sign_up::{sign_up_default, sign_up_vk}; use crate::routes::auth::sign_up::{sign_up_default, sign_up_vk};
use crate::routes::schedule::get_cache_status::get_cache_status;
use crate::routes::schedule::get_group::get_group;
use crate::routes::schedule::get_group_names::get_group_names;
use crate::routes::schedule::get_schedule::get_schedule;
use crate::routes::schedule::get_teacher::get_teacher;
use crate::routes::schedule::get_teacher_names::get_teacher_names;
use crate::routes::schedule::update_download_url::update_download_url;
use crate::routes::users::me::me; use crate::routes::users::me::me;
use actix_web::{App, HttpServer}; use actix_web::{App, HttpServer};
use dotenvy::dotenv; use dotenvy::dotenv;
use utoipa_actix_web::AppExt; use utoipa_actix_web::AppExt;
use utoipa_rapidoc::RapiDoc; use utoipa_rapidoc::RapiDoc;
use crate::routes::schedule::get_schedule::get_schedule;
mod app_state; mod app_state;
@@ -31,6 +37,8 @@ async fn main() {
unsafe { std::env::set_var("RUST_LOG", "debug") }; unsafe { std::env::set_var("RUST_LOG", "debug") };
env_logger::init(); env_logger::init();
let app_state = app_state();
HttpServer::new(move || { HttpServer::new(move || {
let auth_scope = utoipa_actix_web::scope("/auth") let auth_scope = utoipa_actix_web::scope("/auth")
.service(sign_in_default) .service(sign_in_default)
@@ -39,12 +47,18 @@ async fn main() {
.service(sign_up_vk); .service(sign_up_vk);
let users_scope = utoipa_actix_web::scope("/users") let users_scope = utoipa_actix_web::scope("/users")
.wrap(Authorization) .wrap(JWTAuthorization)
.service(me); .service(me);
let schedule_scope = utoipa_actix_web::scope("/schedule") let schedule_scope = utoipa_actix_web::scope("/schedule")
.wrap(Authorization) .wrap(JWTAuthorization)
.service(get_schedule); .service(get_schedule)
.service(update_download_url)
.service(get_cache_status)
.service(get_group)
.service(get_group_names)
.service(get_teacher)
.service(get_teacher_names);
let api_scope = utoipa_actix_web::scope("/api/v1") let api_scope = utoipa_actix_web::scope("/api/v1")
.service(auth_scope) .service(auth_scope)
@@ -53,7 +67,7 @@ async fn main() {
let (app, api) = App::new() let (app, api) = App::new()
.into_utoipa_app() .into_utoipa_app()
.app_data(app_state()) .app_data(app_state.clone())
.service(api_scope) .service(api_scope)
.split_for_parts(); .split_for_parts();
@@ -67,6 +81,7 @@ async fn main() {
app.service(rapidoc_service.custom_html(patched_rapidoc_html)) app.service(rapidoc_service.custom_html(patched_rapidoc_html))
}) })
.workers(4)
.bind(("0.0.0.0", 8080)) .bind(("0.0.0.0", 8080))
.unwrap() .unwrap()
.run() .run()

View File

@@ -7,9 +7,10 @@ use actix_web::{Error, HttpRequest, ResponseError};
use futures_util::future::LocalBoxFuture; use futures_util::future::LocalBoxFuture;
use std::future::{Ready, ready}; use std::future::{Ready, ready};
pub struct Authorization; /// Middleware guard работающий с токенами JWT
pub struct JWTAuthorization;
impl<S, B> Transform<S, ServiceRequest> for Authorization impl<S, B> Transform<S, ServiceRequest> for JWTAuthorization
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static, S::Future: 'static,
@@ -17,20 +18,21 @@ where
{ {
type Response = ServiceResponse<EitherBody<B, BoxBody>>; type Response = ServiceResponse<EitherBody<B, BoxBody>>;
type Error = Error; type Error = Error;
type Transform = AuthorizationMiddleware<S>; type Transform = JWTAuthorizationMiddleware<S>;
type InitError = (); type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>; type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future { fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(AuthorizationMiddleware { service })) ready(Ok(JWTAuthorizationMiddleware { service }))
} }
} }
pub struct AuthorizationMiddleware<S> { pub struct JWTAuthorizationMiddleware<S> {
service: S, service: S,
} }
impl<S, B> AuthorizationMiddleware<S> /// Функция для проверки наличия и действительности токена в запросе, а так же существования пользователя к которому он привязан
impl<S, B> JWTAuthorizationMiddleware<S>
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static, S::Future: 'static,
@@ -47,7 +49,7 @@ where
} }
} }
impl<S, B> Service<ServiceRequest> for AuthorizationMiddleware<S> impl<S, B> Service<ServiceRequest> for JWTAuthorizationMiddleware<S>
where where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>, S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static, S::Future: 'static,

View File

@@ -1,7 +1,7 @@
use crate::parser::LessonParseResult::{Lessons, Street}; use crate::parser::LessonParseResult::{Lessons, Street};
use crate::parser::schema::LessonType::Break; use crate::parser::schema::LessonType::Break;
use crate::parser::schema::{ use crate::parser::schema::{
Day, Lesson, LessonSubGroup, LessonTime, LessonType, ParseResult, ScheduleEntry, Day, Lesson, LessonSubGroup, LessonTime, LessonType, ParseError, ParseResult, ScheduleEntry,
}; };
use calamine::{Reader, Xls, open_workbook_from_rs}; use calamine::{Reader, Xls, open_workbook_from_rs};
use chrono::{Duration, NaiveDateTime}; use chrono::{Duration, NaiveDateTime};
@@ -14,15 +14,12 @@ use std::sync::LazyLock;
pub mod schema; pub mod schema;
/// Данные ячейке хранящей строку
struct InternalId { struct InternalId {
/** /// Индекс строки
* Индекс строки
*/
row: u32, row: u32,
/** /// Индекс столбца
* Индекс столбца
*/
column: u32, column: u32,
/** /**
@@ -31,30 +28,25 @@ struct InternalId {
name: String, name: String,
} }
/// Данные о времени проведения пар из второй колонки расписания
struct InternalTime { struct InternalTime {
/** /// Временной отрезок проведения пары
* Временной отрезок проведения пары
*/
time_range: LessonTime, time_range: LessonTime,
/** /// Тип пары
* Тип пары
*/
lesson_type: LessonType, lesson_type: LessonType,
/** /// Индекс пары
* Индекс пары
*/
default_index: Option<u32>, default_index: Option<u32>,
/** /// Рамка ячейки
* Рамка ячейки
*/
xls_range: ((u32, u32), (u32, u32)), xls_range: ((u32, u32), (u32, u32)),
} }
/// Сокращение типа рабочего листа
type WorkSheet = calamine::Range<calamine::Data>; type WorkSheet = calamine::Range<calamine::Data>;
/// Получение строки из требуемой ячейки
fn get_string_from_cell(worksheet: &WorkSheet, row: u32, col: u32) -> Option<String> { 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)) { let cell_data = if let Some(data) = worksheet.get((row as usize, col as usize)) {
data.to_string() data.to_string()
@@ -82,6 +74,7 @@ fn get_string_from_cell(worksheet: &WorkSheet, row: u32, col: u32) -> Option<Str
} }
} }
/// Получение границ ячейки по её верхней левой координате
fn get_merge_from_start(worksheet: &WorkSheet, row: u32, column: u32) -> ((u32, u32), (u32, u32)) { fn get_merge_from_start(worksheet: &WorkSheet, row: u32, column: u32) -> ((u32, u32), (u32, u32)) {
let worksheet_end = worksheet.end().unwrap(); let worksheet_end = worksheet.end().unwrap();
@@ -116,7 +109,8 @@ fn get_merge_from_start(worksheet: &WorkSheet, row: u32, column: u32) -> ((u32,
((row, column), (row_end, column_end)) ((row, column), (row_end, column_end))
} }
fn parse_skeleton(worksheet: &WorkSheet) -> (Vec<InternalId>, Vec<InternalId>) { /// Получение "скелета" расписания из рабочего листа
fn parse_skeleton(worksheet: &WorkSheet) -> Result<(Vec<InternalId>, Vec<InternalId>), ParseError> {
let range = &worksheet; let range = &worksheet;
let mut is_parsed = false; let mut is_parsed = false;
@@ -124,8 +118,8 @@ fn parse_skeleton(worksheet: &WorkSheet) -> (Vec<InternalId>, Vec<InternalId>) {
let mut groups: Vec<InternalId> = Vec::new(); let mut groups: Vec<InternalId> = Vec::new();
let mut days: Vec<InternalId> = Vec::new(); let mut days: Vec<InternalId> = Vec::new();
let start = range.start().expect("Could not find start"); let start = range.start().ok_or(ParseError::UnknownWorkSheetRange)?;
let end = range.end().expect("Could not find end"); let end = range.end().ok_or(ParseError::UnknownWorkSheetRange)?;
let mut row = start.0; let mut row = start.0;
while row < end.0 { while row < end.0 {
@@ -170,15 +164,22 @@ fn parse_skeleton(worksheet: &WorkSheet) -> (Vec<InternalId>, Vec<InternalId>) {
} }
} }
(days, groups) Ok((days, groups))
} }
/// Результат получения пары из ячейки
enum LessonParseResult { enum LessonParseResult {
/// Список пар длинной от одного до двух
///
/// Количество пар будет равно одному, если пара первая за день, иначе будет возвращен список из шаблона перемены и самой пары
Lessons(Vec<Lesson>), Lessons(Vec<Lesson>),
/// Улица на которой находится корпус политехникума
Street(String), Street(String),
} }
trait StringInnerSlice { trait StringInnerSlice {
/// Получения отрезка строки из строки по начальному и конечному индексу
fn inner_slice(&self, from: usize, to: usize) -> Self; fn inner_slice(&self, from: usize, to: usize) -> Self;
} }
@@ -191,6 +192,7 @@ impl StringInnerSlice for String {
} }
} }
/// Получение нестандартного типа пары по названию
fn guess_lesson_type(name: &String) -> Option<(String, LessonType)> { fn guess_lesson_type(name: &String) -> Option<(String, LessonType)> {
let map: HashMap<String, LessonType> = HashMap::from([ let map: HashMap<String, LessonType> = HashMap::from([
("(консультация)".to_string(), LessonType::Consultation), ("(консультация)".to_string(), LessonType::Consultation),
@@ -232,19 +234,20 @@ fn guess_lesson_type(name: &String) -> Option<(String, LessonType)> {
} }
} }
/// Получение пары или улицы из ячейки
fn parse_lesson( fn parse_lesson(
worksheet: &WorkSheet, worksheet: &WorkSheet,
day: &mut Day, day: &mut Day,
day_times: &Vec<InternalTime>, day_times: &Vec<InternalTime>,
time: &InternalTime, time: &InternalTime,
column: u32, column: u32,
) -> LessonParseResult { ) -> Result<LessonParseResult, ParseError> {
let row = time.xls_range.0.0; let row = time.xls_range.0.0;
let (name, lesson_type) = { let (name, lesson_type) = {
let raw_name_opt = get_string_from_cell(&worksheet, row, column); let raw_name_opt = get_string_from_cell(&worksheet, row, column);
if raw_name_opt.is_none() { if raw_name_opt.is_none() {
return Lessons(Vec::new()); return Ok(Lessons(Vec::new()));
} }
let raw_name = raw_name_opt.unwrap(); let raw_name = raw_name_opt.unwrap();
@@ -253,7 +256,7 @@ fn parse_lesson(
LazyLock::new(|| Regex::new(r"^[А-Я][а-я]+,?\s?[0-9]+$").unwrap()); LazyLock::new(|| Regex::new(r"^[А-Я][а-я]+,?\s?[0-9]+$").unwrap());
if OTHER_STREET_RE.is_match(&raw_name) { if OTHER_STREET_RE.is_match(&raw_name) {
return Street(raw_name); return Ok(Street(raw_name));
} }
if let Some(guess) = guess_lesson_type(&raw_name) { if let Some(guess) = guess_lesson_type(&raw_name) {
@@ -263,7 +266,7 @@ fn parse_lesson(
} }
}; };
let (default_range, lesson_time): (Option<[u8; 2]>, LessonTime) = { let (default_range, lesson_time) = || -> Result<(Option<[u8; 2]>, LessonTime), ParseError> {
// check if multi-lesson // check if multi-lesson
let cell_range = get_merge_from_start(worksheet, row, column); let cell_range = get_merge_from_start(worksheet, row, column);
@@ -272,7 +275,7 @@ fn parse_lesson(
.filter(|time| time.xls_range.1.0 == cell_range.1.0) .filter(|time| time.xls_range.1.0 == cell_range.1.0)
.collect::<Vec<&InternalTime>>(); .collect::<Vec<&InternalTime>>();
let end_time = end_time_arr.first().expect("Unable to find lesson time!"); let end_time = end_time_arr.first().ok_or(ParseError::LessonTimeNotFound)?;
let range: Option<[u8; 2]> = if time.default_index != None { let range: Option<[u8; 2]> = if time.default_index != None {
let default = time.default_index.unwrap() as u8; let default = time.default_index.unwrap() as u8;
@@ -286,10 +289,10 @@ fn parse_lesson(
end: end_time.time_range.end, end: end_time.time_range.end,
}; };
(range, time) Ok((range, time))
}; }()?;
let (name, mut subgroups) = parse_name_and_subgroups(&name); let (name, mut subgroups) = parse_name_and_subgroups(&name)?;
{ {
let cabinets: Vec<String> = parse_cabinets(worksheet, row, column + 1); let cabinets: Vec<String> = parse_cabinets(worksheet, row, column + 1);
@@ -345,12 +348,12 @@ fn parse_lesson(
}; };
let prev_lesson = if day.lessons.len() == 0 { let prev_lesson = if day.lessons.len() == 0 {
return Lessons(Vec::from([lesson])); return Ok(Lessons(Vec::from([lesson])));
} else { } else {
&day.lessons[day.lessons.len() - 1] &day.lessons[day.lessons.len() - 1]
}; };
Lessons(Vec::from([ Ok(Lessons(Vec::from([
Lesson { Lesson {
lesson_type: Break, lesson_type: Break,
default_range: None, default_range: None,
@@ -363,9 +366,10 @@ fn parse_lesson(
group: None, group: None,
}, },
lesson, lesson,
])) ])))
} }
/// Получение списка кабинетов справа от ячейки пары
fn parse_cabinets(worksheet: &WorkSheet, row: u32, column: u32) -> Vec<String> { fn parse_cabinets(worksheet: &WorkSheet, row: u32, column: u32) -> Vec<String> {
let mut cabinets: Vec<String> = Vec::new(); let mut cabinets: Vec<String> = Vec::new();
@@ -383,15 +387,16 @@ fn parse_cabinets(worksheet: &WorkSheet, row: u32, column: u32) -> Vec<String> {
cabinets cabinets
} }
fn parse_name_and_subgroups(name: &String) -> (String, Vec<LessonSubGroup>) { /// Получение "чистого" названия пары и списка преподавателей из текста ячейки пары
fn parse_name_and_subgroups(name: &String) -> Result<(String, Vec<LessonSubGroup>), ParseError> {
static LESSON_RE: LazyLock<Regex, fn() -> Regex> = static LESSON_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"(?:[А-Я][а-я]+[А-Я]{2}(?:\([0-9][а-я]+\))?)+$").unwrap()); LazyLock::new(|| Regex::new(r"(?:[А-Я][а-я]+[А-Я]{2}(?:\([0-9][а-я]+\))?)+$").unwrap());
static TEACHER_RE: LazyLock<Regex, fn() -> Regex> = static TEACHER_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"([А-Я][а-я]+)([А-Я])([А-Я])(?:\(([0-9])[а-я]+\))?").unwrap()); LazyLock::new(|| Regex::new(r"([А-Я][а-я]+)([А-Я])([А-Я])(?:\(([0-9])[а-я]+\))?").unwrap());
static CLEAN_RE: LazyLock<Regex, fn() -> Regex> = static CLEAN_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"[\s.,]+").unwrap()); LazyLock::new(|| Regex::new(r"[\s.,]+").unwrap());
static NAME_CLEAN_RE: LazyLock<Regex, fn() -> Regex> = static END_CLEAN_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"\.\s+$").unwrap()); LazyLock::new(|| Regex::new(r"[.\s]+$").unwrap());
let (teachers, lesson_name) = { let (teachers, lesson_name) = {
let clean_name = CLEAN_RE.replace_all(&name, "").to_string(); let clean_name = CLEAN_RE.replace_all(&name, "").to_string();
@@ -402,11 +407,13 @@ fn parse_name_and_subgroups(name: &String) -> (String, Vec<LessonSubGroup>) {
let capture_name: String = capture_str.chars().take(5).collect(); let capture_name: String = capture_str.chars().take(5).collect();
( (
NAME_CLEAN_RE.replace(&capture_str, "").to_string(), END_CLEAN_RE.replace(&capture_str, "").to_string(),
name[0..name.find(&*capture_name).unwrap()].to_string(), END_CLEAN_RE
.replace(&name[0..name.find(&*capture_name).unwrap()], "")
.to_string(),
) )
} else { } else {
return (NAME_CLEAN_RE.replace(&name, "").to_string(), Vec::new()); return Ok((END_CLEAN_RE.replace(&name, "").to_string(), Vec::new()));
} }
}; };
@@ -421,7 +428,7 @@ fn parse_name_and_subgroups(name: &String) -> (String, Vec<LessonSubGroup>) {
.as_str() .as_str()
.to_string() .to_string()
.parse::<u8>() .parse::<u8>()
.expect("Unable to read subgroup index!") .map_err(|_| ParseError::SubgroupIndexParsingFailed)?
} else { } else {
0 0
}, },
@@ -432,7 +439,7 @@ fn parse_name_and_subgroups(name: &String) -> (String, Vec<LessonSubGroup>) {
captures.get(2).unwrap().as_str().to_string(), captures.get(2).unwrap().as_str().to_string(),
captures.get(3).unwrap().as_str().to_string() captures.get(3).unwrap().as_str().to_string()
), ),
}) });
} }
// фикс, если у кого-то отсутствует индекс подгруппы // фикс, если у кого-то отсутствует индекс подгруппы
@@ -469,9 +476,10 @@ fn parse_name_and_subgroups(name: &String) -> (String, Vec<LessonSubGroup>) {
subgroups.reverse() subgroups.reverse()
} }
(lesson_name, subgroups) Ok((lesson_name, subgroups))
} }
/// Конвертация списка пар групп в список пар преподавателей
fn convert_groups_to_teachers( fn convert_groups_to_teachers(
groups: &HashMap<String, ScheduleEntry>, groups: &HashMap<String, ScheduleEntry>,
) -> HashMap<String, ScheduleEntry> { ) -> HashMap<String, ScheduleEntry> {
@@ -537,21 +545,31 @@ fn convert_groups_to_teachers(
} }
} }
teachers.iter_mut().for_each(|(_, teacher)| {
teacher.days.iter_mut().for_each(|day| {
day.lessons.sort_by(|a, b| {
a.default_range.as_ref().unwrap()[1].cmp(&b.default_range.as_ref().unwrap()[1])
})
})
});
teachers teachers
} }
pub fn parse_xls(buffer: &Vec<u8>) -> ParseResult { /// Чтение XLS документа из буфера и преобразование его в готовые к использованию расписания
pub fn parse_xls(buffer: &Vec<u8>) -> Result<ParseResult, ParseError> {
let cursor = Cursor::new(&buffer); let cursor = Cursor::new(&buffer);
let mut workbook: Xls<_> = open_workbook_from_rs(cursor).expect("Can't open workbook"); let mut workbook: Xls<_> =
open_workbook_from_rs(cursor).map_err(|e| ParseError::BadXLS(std::sync::Arc::new(e)))?;
let worksheet: WorkSheet = workbook let worksheet: WorkSheet = workbook
.worksheets() .worksheets()
.first() .first()
.expect("No worksheet found") .ok_or(ParseError::NoWorkSheets)?
.1 .1
.to_owned(); .to_owned();
let (days_markup, groups_markup) = parse_skeleton(&worksheet); let (days_markup, groups_markup) = parse_skeleton(&worksheet)?;
let mut groups: HashMap<String, ScheduleEntry> = HashMap::new(); let mut groups: HashMap<String, ScheduleEntry> = HashMap::new();
let mut days_times: Vec<Vec<InternalTime>> = Vec::new(); let mut days_times: Vec<Vec<InternalTime>> = Vec::new();
@@ -631,9 +649,7 @@ pub fn parse_xls(buffer: &Vec<u8>) -> ParseResult {
static TIME_RE: LazyLock<Regex, fn() -> Regex> = static TIME_RE: LazyLock<Regex, fn() -> Regex> =
LazyLock::new(|| Regex::new(r"(\d+\.\d+)-(\d+\.\d+)").unwrap()); LazyLock::new(|| Regex::new(r"(\d+\.\d+)-(\d+\.\d+)").unwrap());
let parse_res = TIME_RE let parse_res = TIME_RE.captures(&time).ok_or(ParseError::GlobalTime)?;
.captures(&time)
.expect("Unable to obtain lesson start and end!");
let start_match = parse_res.get(1).unwrap().as_str(); let start_match = parse_res.get(1).unwrap().as_str();
let start_parts: Vec<&str> = start_match.split(".").collect(); let start_parts: Vec<&str> = start_match.split(".").collect();
@@ -671,7 +687,7 @@ pub fn parse_xls(buffer: &Vec<u8>) -> ParseResult {
&day_times, &day_times,
&time, &time,
group_markup.column, group_markup.column,
) { )? {
Lessons(l) => day.lessons.append(l), Lessons(l) => day.lessons.append(l),
Street(s) => day.street = Some(s.to_owned()), Street(s) => day.street = Some(s.to_owned()),
} }
@@ -683,27 +699,27 @@ pub fn parse_xls(buffer: &Vec<u8>) -> ParseResult {
groups.insert(group.name.clone(), group); groups.insert(group.name.clone(), group);
} }
ParseResult { Ok(ParseResult {
teachers: convert_groups_to_teachers(&groups), teachers: convert_groups_to_teachers(&groups),
groups, groups,
} })
} }
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use super::*; use super::*;
pub fn test_result() -> ParseResult { pub fn test_result() -> Result<ParseResult, ParseError> {
let buffer: Vec<u8> = include_bytes!("../../schedule.xls").to_vec(); parse_xls(&include_bytes!("../../schedule.xls").to_vec())
parse_xls(&buffer)
} }
#[test] #[test]
fn read() { fn read() {
let result = test_result(); let result = test_result();
assert_ne!(result.groups.len(), 0); assert!(result.is_ok());
assert_ne!(result.teachers.len(), 0);
assert_ne!(result.as_ref().unwrap().groups.len(), 0);
assert_ne!(result.as_ref().unwrap().teachers.len(), 0);
} }
} }

View File

@@ -1,129 +1,162 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use derive_more::Display;
use serde::{Deserialize, Serialize, Serializer};
use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_repr::{Deserialize_repr, Serialize_repr};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] #[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct LessonTime { pub struct LessonTime {
/// Начало пары
pub start: DateTime<Utc>, pub start: DateTime<Utc>,
/// Конец пары
pub end: DateTime<Utc>, pub end: DateTime<Utc>,
} }
#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Clone, utoipa::ToSchema)] #[derive(Clone, Hash, PartialEq, Debug, Serialize_repr, Deserialize_repr, ToSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[repr(u8)] #[repr(u8)]
pub enum LessonType { pub enum LessonType {
Default = 0, // Обычная /// Обычная
Additional, // Допы Default = 0,
Break, // Перемена
Consultation, // Консультация /// Допы
IndependentWork, // Самостоятельная работа Additional,
Exam, // Зачёт
ExamWithGrade, // Зачет с оценкой /// Перемена
ExamDefault, // Экзамен Break,
/// Консультация
Consultation,
/// Самостоятельная работа
IndependentWork,
/// Зачёт
Exam,
/// Зачет с оценкой
ExamWithGrade,
/// Экзамен
ExamDefault,
} }
#[derive( Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] #[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct LessonSubGroup { pub struct LessonSubGroup {
/** /// Номер подгруппы
* Номер подгруппы.
*/
pub number: u8, pub number: u8,
/** /// Кабинет, если присутствует
* Кабинет, если присутствует.
*/
pub cabinet: Option<String>, pub cabinet: Option<String>,
/** /// Фио преподавателя
* Фио преподавателя.
*/
pub teacher: String, pub teacher: String,
} }
#[derive(Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] #[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Lesson { pub struct Lesson {
/** /// Тип занятия
* Тип занятия.
*/
#[serde(rename = "type")] #[serde(rename = "type")]
pub lesson_type: LessonType, pub lesson_type: LessonType,
/** /// Индексы пар, если присутствуют
* Индексы пар, если присутствуют.
*/
pub default_range: Option<[u8; 2]>, pub default_range: Option<[u8; 2]>,
/** /// Название занятия
* Название занятия.
*/
pub name: Option<String>, pub name: Option<String>,
/** /// Начало и конец занятия
* Начало и конец занятия.
*/
pub time: LessonTime, pub time: LessonTime,
/** /// Список подгрупп
* Подгруппы.
*/
#[serde(rename = "subGroups")] #[serde(rename = "subGroups")]
pub subgroups: Option<Vec<LessonSubGroup>>, pub subgroups: Option<Vec<LessonSubGroup>>,
/** /// Группа, если это расписание для преподавателей
* Группа, если это расписание для преподавателей.
*/
pub group: Option<String>, pub group: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] #[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct Day { pub struct Day {
/** /// День недели
* День недели.
*/
pub name: String, pub name: String,
/** /// Адрес другого корпуса
* Адрес другого корпуса.
*/
pub street: Option<String>, pub street: Option<String>,
/** /// Дата
* Дата.
*/
pub date: DateTime<Utc>, pub date: DateTime<Utc>,
/** /// Список пар в этот день
* Список пар в этот день.
*/
pub lessons: Vec<Lesson>, pub lessons: Vec<Lesson>,
} }
#[derive(Clone, Serialize, Deserialize, Debug, utoipa::ToSchema)] #[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
pub struct ScheduleEntry { pub struct ScheduleEntry {
/** /// Название группы или ФИО преподавателя
* Название группы или ФИО преподавателя.
*/
pub name: String, pub name: String,
/** /// Список из шести дней
* Список из шести дней.
*/
pub days: Vec<Day>, pub days: Vec<Day>,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct ParseResult { pub struct ParseResult {
/** /// Список групп
* Список групп.
* Ключом является название группы.
*/
pub groups: HashMap<String, ScheduleEntry>, pub groups: HashMap<String, ScheduleEntry>,
/** /// Список преподавателей
* Список преподавателей.
* Ключом является ФИО преподавателя.
*/
pub teachers: HashMap<String, ScheduleEntry>, pub teachers: HashMap<String, ScheduleEntry>,
} }
#[derive(Debug, Display, Clone, ToSchema)]
pub enum ParseError {
/// Ошибки связанные с чтением XLS файла.
#[display("{}: Failed to read XLS file.", "_0")]
#[schema(value_type = String)]
BadXLS(Arc<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 times from string.")]
GlobalTime,
/// Не найдены начало и конец соответствующее паре
#[display("No start and end times matching the lesson was found.")]
LessonTimeNotFound,
/// Не удалось прочитать индекс подгруппы
#[display("Failed to read subgroup index.")]
SubgroupIndexParsingFailed,
}
impl Serialize for ParseError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
ParseError::BadXLS(_) => serializer.serialize_str("BAD_XLS"),
ParseError::NoWorkSheets => serializer.serialize_str("NO_WORK_SHEETS"),
ParseError::UnknownWorkSheetRange => {
serializer.serialize_str("UNKNOWN_WORK_SHEET_RANGE")
}
ParseError::GlobalTime => serializer.serialize_str("GLOBAL_TIME"),
ParseError::LessonTimeNotFound => serializer.serialize_str("LESSON_TIME_NOT_FOUND"),
ParseError::SubgroupIndexParsingFailed => {
serializer.serialize_str("SUBGROUP_INDEX_PARSING_FAILED")
}
}
}
}

View File

@@ -5,7 +5,7 @@ use crate::routes::auth::shared::parse_vk_id;
use crate::routes::auth::sign_in::schema::SignInData::{Default, Vk}; use crate::routes::auth::sign_in::schema::SignInData::{Default, Vk};
use crate::routes::schema::user::UserResponse; use crate::routes::schema::user::UserResponse;
use crate::routes::schema::{IntoResponseAsError, ResponseError}; use crate::routes::schema::{IntoResponseAsError, ResponseError};
use crate::{AppState, utility}; use crate::{utility, AppState};
use actix_web::{post, web}; use actix_web::{post, web};
use diesel::SaveChangesDsl; use diesel::SaveChangesDsl;
use std::ops::DerefMut; use std::ops::DerefMut;
@@ -55,7 +55,7 @@ async fn sign_in(
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>) (status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))] ))]
#[post("/sign-in")] #[post("/sign-in")]
pub async fn sign_in_default(data: Json<Request>, app_state: web::Data<AppState>) -> Response { pub async fn sign_in_default(data: Json<Request>, app_state: web::Data<AppState>) -> ServiceResponse {
sign_in(Default(data.into_inner()), &app_state).await.into() sign_in(Default(data.into_inner()), &app_state).await.into()
} }
@@ -64,7 +64,7 @@ pub async fn sign_in_default(data: Json<Request>, app_state: web::Data<AppState>
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>) (status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))] ))]
#[post("/sign-in-vk")] #[post("/sign-in-vk")]
pub async fn sign_in_vk(data_json: Json<vk::Request>, app_state: web::Data<AppState>) -> Response { pub async fn sign_in_vk(data_json: Json<vk::Request>, app_state: web::Data<AppState>) -> ServiceResponse {
let data = data_json.into_inner(); let data = data_json.into_inner();
match parse_vk_id(&data.access_token) { match parse_vk_id(&data.access_token) {
@@ -74,51 +74,57 @@ pub async fn sign_in_vk(data_json: Json<vk::Request>, app_state: web::Data<AppSt
} }
mod schema { mod schema {
use crate::routes::schema::PartialStatusCode;
use crate::routes::schema::user::UserResponse; use crate::routes::schema::user::UserResponse;
use actix_macros::IntoResponseError; use actix_macros::{IntoResponseError, StatusCode};
use actix_web::http::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, utoipa::ToSchema)] #[derive(Deserialize, Serialize, ToSchema)]
#[schema(as = SignIn::Request)] #[schema(as = SignIn::Request)]
pub struct Request { pub struct Request {
/// Имя пользователя
#[schema(examples("n08i40k"))] #[schema(examples("n08i40k"))]
pub username: String, pub username: String,
/// Пароль
pub password: String, pub password: String,
} }
pub mod vk { pub mod vk {
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, utoipa::ToSchema)] #[derive(Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[schema(as = SignInVk::Request)] #[schema(as = SignInVk::Request)]
pub struct Request { pub struct Request {
/// Токен VK ID
pub access_token: String, pub access_token: String,
} }
} }
pub type Response = crate::routes::schema::Response<UserResponse, ErrorCode>; pub type ServiceResponse = crate::routes::schema::Response<UserResponse, ErrorCode>;
#[derive(Serialize, utoipa::ToSchema, Clone, IntoResponseError)] #[derive(Serialize, ToSchema, Clone, IntoResponseError, StatusCode)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = SignIn::ErrorCode)] #[schema(as = SignIn::ErrorCode)]
#[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"]
pub enum ErrorCode { pub enum ErrorCode {
/// Некорректное имя пользователя или пароль
IncorrectCredentials, IncorrectCredentials,
/// Недействительный токен VK ID
InvalidVkAccessToken, InvalidVkAccessToken,
} }
impl PartialStatusCode for ErrorCode {
fn status_code(&self) -> StatusCode {
StatusCode::NOT_ACCEPTABLE
}
}
/// Internal /// Internal
/// Тип авторизации
pub enum SignInData { pub enum SignInData {
/// Имя пользователя и пароль
Default(Request), Default(Request),
/// Идентификатор привязанного аккаунта VK
Vk(i32), Vk(i32),
} }
} }
@@ -136,7 +142,7 @@ mod tests {
use actix_web::http::Method; use actix_web::http::Method;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::test; use actix_web::test;
use sha2::{Digest, Sha256}; use sha1::{Digest, Sha1};
use std::fmt::Write; use std::fmt::Write;
async fn sign_in_client(data: Request) -> ServiceResponse { async fn sign_in_client(data: Request) -> ServiceResponse {
@@ -152,7 +158,7 @@ mod tests {
fn prepare(username: String) { fn prepare(username: String) {
let id = { let id = {
let mut sha = Sha256::new(); let mut sha = Sha1::new();
sha.update(&username); sha.update(&username);
let result = sha.finalize(); let result = sha.finalize();

View File

@@ -50,7 +50,10 @@ async fn sign_up(
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>) (status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))] ))]
#[post("/sign-up")] #[post("/sign-up")]
pub async fn sign_up_default(data_json: Json<Request>, app_state: web::Data<AppState>) -> Response { pub async fn sign_up_default(
data_json: Json<Request>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
let data = data_json.into_inner(); let data = data_json.into_inner();
sign_up( sign_up(
@@ -73,7 +76,10 @@ pub async fn sign_up_default(data_json: Json<Request>, app_state: web::Data<AppS
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>) (status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>)
))] ))]
#[post("/sign-up-vk")] #[post("/sign-up-vk")]
pub async fn sign_up_vk(data_json: Json<vk::Request>, app_state: web::Data<AppState>) -> Response { pub async fn sign_up_vk(
data_json: Json<vk::Request>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
let data = data_json.into_inner(); let data = data_json.into_inner();
match parse_vk_id(&data.access_token) { match parse_vk_id(&data.access_token) {
@@ -107,11 +113,9 @@ pub async fn sign_up_vk(data_json: Json<vk::Request>, app_state: web::Data<AppSt
mod schema { mod schema {
use crate::database::models::{User, UserRole}; use crate::database::models::{User, UserRole};
use crate::routes::schema::PartialStatusCode;
use crate::routes::schema::user::UserResponse; use crate::routes::schema::user::UserResponse;
use crate::utility; use crate::utility;
use actix_macros::{IntoResponseError, StatusCode}; use actix_macros::{IntoResponseError, StatusCode};
use actix_web::http::StatusCode;
use objectid::ObjectId; use objectid::ObjectId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -120,16 +124,21 @@ mod schema {
#[derive(Serialize, Deserialize, utoipa::ToSchema)] #[derive(Serialize, Deserialize, utoipa::ToSchema)]
#[schema(as = SignUp::Request)] #[schema(as = SignUp::Request)]
pub struct Request { pub struct Request {
/// Имя пользователя
#[schema(examples("n08i40k"))] #[schema(examples("n08i40k"))]
pub username: String, pub username: String,
/// Пароль
pub password: String, pub password: String,
/// Группа
#[schema(examples("ИС-214/23"))] #[schema(examples("ИС-214/23"))]
pub group: String, pub group: String,
/// Роль
pub role: UserRole, pub role: UserRole,
/// Версия установленного приложения Polytechnic+
#[schema(examples("3.0.0"))] #[schema(examples("3.0.0"))]
pub version: String, pub version: String,
} }
@@ -142,43 +151,71 @@ mod schema {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[schema(as = SignUpVk::Request)] #[schema(as = SignUpVk::Request)]
pub struct Request { pub struct Request {
/// Токен VK ID
pub access_token: String, pub access_token: String,
/// Имя пользователя
#[schema(examples("n08i40k"))] #[schema(examples("n08i40k"))]
pub username: String, pub username: String,
/// Группа
#[schema(examples("ИС-214/23"))] #[schema(examples("ИС-214/23"))]
pub group: String, pub group: String,
/// Роль
pub role: UserRole, pub role: UserRole,
/// Версия установленного приложения Polytechnic+
#[schema(examples("3.0.0"))] #[schema(examples("3.0.0"))]
pub version: String, pub version: String,
} }
} }
pub type Response = crate::routes::schema::Response<UserResponse, ErrorCode>; pub type ServiceResponse = crate::routes::schema::Response<UserResponse, ErrorCode>;
#[derive(Clone, Serialize, utoipa::ToSchema, IntoResponseError, StatusCode)] #[derive(Clone, Serialize, utoipa::ToSchema, IntoResponseError, StatusCode)]
#[status_code = "StatusCode::NOT_ACCEPTABLE"]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = SignUp::ErrorCode)] #[schema(as = SignUp::ErrorCode)]
#[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"]
pub enum ErrorCode { pub enum ErrorCode {
/// Передана роль ADMIN
DisallowedRole, DisallowedRole,
/// Неизвестное название группы
InvalidGroupName, InvalidGroupName,
/// Пользователь с таким именем уже зарегистрирован
UsernameAlreadyExists, UsernameAlreadyExists,
/// Недействительный токен VK ID
InvalidVkAccessToken, InvalidVkAccessToken,
/// Пользователь с таким аккаунтом VK уже зарегистрирован
VkAlreadyExists, VkAlreadyExists,
} }
/// Internal /// Internal
/// Данные для регистрации
pub struct SignUpData { pub struct SignUpData {
/// Имя пользователя
pub username: String, pub username: String,
/// Пароль
///
/// Должен присутствовать даже если регистрация происходит с помощью токена VK ID
pub password: String, pub password: String,
/// Идентификатор аккаунта VK
pub vk_id: Option<i32>, pub vk_id: Option<i32>,
/// Группа
pub group: String, pub group: String,
/// Роль
pub role: UserRole, pub role: UserRole,
/// Версия установленного приложения Polytechnic+
pub version: String, pub version: String,
} }

View File

@@ -0,0 +1,23 @@
use crate::AppState;
use crate::routes::schedule::schema::CacheStatus;
use actix_web::{get, web};
#[utoipa::path(responses(
(status = OK, body = CacheStatus),
))]
#[get("/cache-status")]
pub async fn get_cache_status(app_state: web::Data<AppState>) -> CacheStatus {
// Prevent thread lock
let has_schedule = app_state
.schedule
.lock()
.as_ref()
.map(|res| res.is_some())
.unwrap();
match has_schedule {
true => CacheStatus::from(&app_state),
false => CacheStatus::default(),
}
.into()
}

View File

@@ -0,0 +1,99 @@
use self::schema::*;
use crate::AppState;
use crate::database::models::User;
use crate::extractors::base::SyncExtractor;
use crate::routes::schema::{IntoResponseAsError, ResponseError};
use actix_web::{get, web};
#[utoipa::path(responses(
(status = OK, body = Response),
(
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 get_group(
user: SyncExtractor<User>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
// Prevent thread lock
let schedule_lock = app_state.schedule.lock().unwrap();
match schedule_lock.as_ref() {
None => ErrorCode::NoSchedule.into_response(),
Some(schedule) => match schedule.data.groups.get(&user.into_inner().group) {
None => ErrorCode::NotFound.into_response(),
Some(entry) => Ok(entry.clone().into()).into(),
},
}
}
mod schema {
use crate::parser::schema::ScheduleEntry;
use actix_macros::{IntoResponseErrorNamed, StatusCode};
use chrono::{DateTime, NaiveDateTime, Utc};
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<Response, ErrorCode>;
#[derive(Serialize, ToSchema)]
#[schema(as = GetGroup::Response)]
#[serde(rename_all = "camelCase")]
pub struct Response {
/// Расписание группы
pub group: ScheduleEntry,
/// Устаревшая переменная
///
/// По умолчанию возвращается пустой список
#[deprecated = "Will be removed in future versions"]
pub updated: Vec<i32>,
/// Устаревшая переменная
///
/// По умолчанию начальная дата по Unix
#[deprecated = "Will be removed in future versions"]
pub updated_at: DateTime<Utc>,
}
#[allow(deprecated)]
impl From<ScheduleEntry> for Response {
fn from(group: ScheduleEntry) -> Self {
Self {
group,
updated: Vec::new(),
updated_at: NaiveDateTime::default().and_utc(),
}
}
}
#[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = GroupSchedule::ErrorCode)]
pub enum ErrorCode {
/// Расписания ещё не получены
#[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"]
#[display("Schedule not parsed yet.")]
NoSchedule,
/// Группа не найдена
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required group not found.")]
NotFound,
}
}

View File

@@ -0,0 +1,48 @@
use self::schema::*;
use crate::AppState;
use crate::routes::schedule::schema::ErrorCode;
use crate::routes::schema::{IntoResponseAsError, ResponseError};
use actix_web::{get, web};
#[utoipa::path(responses(
(status = OK, body = Response),
(status = SERVICE_UNAVAILABLE, body = ResponseError<ErrorCode>),
))]
#[get("/group-names")]
pub async fn get_group_names(app_state: web::Data<AppState>) -> ServiceResponse {
// Prevent thread lock
let schedule_lock = app_state.schedule.lock().unwrap();
match schedule_lock.as_ref() {
None => ErrorCode::NoSchedule.into_response(),
Some(schedule) => {
let mut names: Vec<String> = schedule.data.groups.keys().cloned().collect();
names.sort();
Ok(names.into()).into()
}
}
.into()
}
mod schema {
use crate::routes::schedule::schema::ErrorCode;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<Response, ErrorCode>;
#[derive(Serialize, ToSchema)]
#[schema(as = GetGroupNames::Response)]
pub struct Response {
/// Список названий групп отсортированный в алфавитном порядке
#[schema(examples(json!(["ИС-214/23"])))]
pub names: Vec<String>,
}
impl From<Vec<String>> for Response {
fn from(names: Vec<String>) -> Self {
Self { names }
}
}
}

View File

@@ -1,6 +1,6 @@
use self::schema::*; use self::schema::*;
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::routes::schedule::schema::{Error, ScheduleView}; use crate::routes::schedule::schema::{ErrorCode, ScheduleView};
use crate::routes::schema::{IntoResponseAsError, ResponseError}; use crate::routes::schema::{IntoResponseAsError, ResponseError};
use actix_web::{get, web}; use actix_web::{get, web};
@@ -9,30 +9,17 @@ use actix_web::{get, web};
(status = SERVICE_UNAVAILABLE, body = ResponseError<ErrorCode>) (status = SERVICE_UNAVAILABLE, body = ResponseError<ErrorCode>)
))] ))]
#[get("/")] #[get("/")]
pub async fn get_schedule(app_state: web::Data<AppState>) -> Response { pub async fn get_schedule(app_state: web::Data<AppState>) -> ServiceResponse {
match ScheduleView::try_from(app_state.get_ref()) { match ScheduleView::try_from(&app_state) {
Ok(res) => Ok(res).into(), Ok(res) => Ok(res).into(),
Err(e) => match e { Err(e) => match e {
Error::NoSchedule => ErrorCode::NoSchedule.into_response(), ErrorCode::NoSchedule => ErrorCode::NoSchedule.into_response(),
}, },
} }
} }
mod schema { mod schema {
use crate::routes::schedule::schema::ScheduleView; use crate::routes::schedule::schema::{ErrorCode, ScheduleView};
use actix_macros::{IntoResponseErrorNamed, StatusCode};
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type Response = crate::routes::schema::Response<ScheduleView, ErrorCode>; pub type ServiceResponse = crate::routes::schema::Response<ScheduleView, ErrorCode>;
#[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)]
#[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = ScheduleView::ErrorCode)]
pub enum ErrorCode {
#[display("Schedule not parsed yet")]
NoSchedule,
}
} }

View File

@@ -0,0 +1,97 @@
use self::schema::*;
use crate::routes::schema::{IntoResponseAsError, ResponseError};
use crate::AppState;
use actix_web::{get, web};
#[utoipa::path(responses(
(status = OK, body = Response),
(
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 teacher not found."
})
),
))]
#[get("/teacher/{name}")]
pub async fn get_teacher(
name: web::Path<String>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
// Prevent thread lock
let schedule_lock = app_state.schedule.lock().unwrap();
match schedule_lock.as_ref() {
None => ErrorCode::NoSchedule.into_response(),
Some(schedule) => match schedule.data.teachers.get(&name.into_inner()) {
None => ErrorCode::NotFound.into_response(),
Some(entry) => Ok(entry.clone().into()).into(),
},
}
}
mod schema {
use crate::parser::schema::ScheduleEntry;
use actix_macros::{IntoResponseErrorNamed, StatusCode};
use chrono::{DateTime, NaiveDateTime, Utc};
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<Response, ErrorCode>;
#[derive(Serialize, ToSchema)]
#[schema(as = GetTeacher::Response)]
#[serde(rename_all = "camelCase")]
pub struct Response {
/// Расписание преподавателя
pub teacher: ScheduleEntry,
/// Устаревшая переменная
///
/// По умолчанию возвращается пустой список
#[deprecated = "Will be removed in future versions"]
pub updated: Vec<i32>,
/// Устаревшая переменная
///
/// По умолчанию начальная дата по Unix
#[deprecated = "Will be removed in future versions"]
pub updated_at: DateTime<Utc>,
}
#[allow(deprecated)]
impl From<ScheduleEntry> for Response {
fn from(teacher: ScheduleEntry) -> Self {
Self {
teacher,
updated: Vec::new(),
updated_at: NaiveDateTime::default().and_utc(),
}
}
}
#[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = TeacherSchedule::ErrorCode)]
pub enum ErrorCode {
/// Расписания ещё не получены
#[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"]
#[display("Schedule not parsed yet.")]
NoSchedule,
/// Преподаватель не найден
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required teacher not found.")]
NotFound,
}
}

View File

@@ -0,0 +1,48 @@
use self::schema::*;
use crate::AppState;
use crate::routes::schedule::schema::ErrorCode;
use crate::routes::schema::{IntoResponseAsError, ResponseError};
use actix_web::{get, web};
#[utoipa::path(responses(
(status = OK, body = Response),
(status = SERVICE_UNAVAILABLE, body = ResponseError<ErrorCode>),
))]
#[get("/teacher-names")]
pub async fn get_teacher_names(app_state: web::Data<AppState>) -> ServiceResponse {
// Prevent thread lock
let schedule_lock = app_state.schedule.lock().unwrap();
match schedule_lock.as_ref() {
None => ErrorCode::NoSchedule.into_response(),
Some(schedule) => {
let mut names: Vec<String> = schedule.data.teachers.keys().cloned().collect();
names.sort();
Ok(names.into()).into()
}
}
.into()
}
mod schema {
use crate::routes::schedule::schema::ErrorCode;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<Response, ErrorCode>;
#[derive(Serialize, ToSchema)]
#[schema(as = GetTeacherNames::Response)]
pub struct Response {
/// Список имён преподавателей отсортированный в алфавитном порядке
#[schema(examples(json!(["Хомченко Н.Е."])))]
pub names: Vec<String>,
}
impl From<Vec<String>> for Response {
fn from(names: Vec<String>) -> Self {
Self { names }
}
}
}

View File

@@ -1,2 +1,8 @@
pub mod get_cache_status;
pub mod get_schedule; pub mod get_schedule;
mod schema; pub mod get_group;
pub mod get_group_names;
pub mod get_teacher;
pub mod get_teacher_names;
mod schema;
pub mod update_download_url;

View File

@@ -1,47 +1,107 @@
use crate::app_state::AppState; use crate::app_state::{AppState, Schedule};
use crate::parser::schema::ScheduleEntry; use crate::parser::schema::ScheduleEntry;
use chrono::{DateTime, Utc}; use actix_macros::{IntoResponseErrorNamed, ResponderJson, StatusCode};
use serde::Serialize; use actix_web::web;
use chrono::{DateTime, Duration, Utc};
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use utoipa::ToSchema; use utoipa::ToSchema;
/// Ответ от сервера с расписаниями
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ScheduleView { pub struct ScheduleView {
/// ETag расписания на сервере политехникума
etag: String, etag: String,
replacer_id: Option<String>,
/// Дата обновления расписания на сайте политехникума
uploaded_at: DateTime<Utc>, uploaded_at: DateTime<Utc>,
/// Дата последнего скачивания расписания с сервера политехникума
downloaded_at: DateTime<Utc>, downloaded_at: DateTime<Utc>,
/// Расписание групп
groups: HashMap<String, ScheduleEntry>, groups: HashMap<String, ScheduleEntry>,
/// Расписание преподавателей
teachers: HashMap<String, ScheduleEntry>, teachers: HashMap<String, ScheduleEntry>,
updated_groups: Vec<Vec<i32>>,
updated_teachers: Vec<Vec<i32>>,
} }
pub enum Error { #[derive(Clone, Serialize, ToSchema, StatusCode, Display, IntoResponseErrorNamed)]
#[status_code = "actix_web::http::StatusCode::SERVICE_UNAVAILABLE"]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = ScheduleShared::ErrorCode)]
pub enum ErrorCode {
/// Расписания ещё не получены
#[display("Schedule not parsed yet.")]
NoSchedule, NoSchedule,
} }
impl TryFrom<&AppState> for ScheduleView { impl TryFrom<&web::Data<AppState>> for ScheduleView {
type Error = Error; type Error = ErrorCode;
fn try_from(app_state: &AppState) -> Result<Self, Self::Error> {
let schedule_lock = app_state.schedule.lock().unwrap();
if let Some(schedule_ref) = schedule_lock.as_ref() {
let schedule = schedule_ref.clone();
fn try_from(app_state: &web::Data<AppState>) -> Result<Self, Self::Error> {
if let Some(schedule) = app_state.schedule.lock().unwrap().clone() {
Ok(Self { Ok(Self {
etag: schedule.etag, etag: schedule.etag,
replacer_id: None,
uploaded_at: schedule.updated_at, uploaded_at: schedule.updated_at,
downloaded_at: schedule.parsed_at, downloaded_at: schedule.parsed_at,
groups: schedule.data.groups, groups: schedule.data.groups,
teachers: schedule.data.teachers, teachers: schedule.data.teachers,
updated_groups: vec![],
updated_teachers: vec![],
}) })
} else { } else {
Err(Error::NoSchedule) Err(ErrorCode::NoSchedule)
}
}
}
/// Статус кешированного расписаний
#[derive(Serialize, Deserialize, ToSchema, ResponderJson)]
#[serde(rename_all = "camelCase")]
pub struct CacheStatus {
/// Хеш расписаний
pub cache_hash: String,
/// Требуется ли обновить ссылку на расписание
pub cache_update_required: bool,
/// Дата последнего обновления кеша
pub last_cache_update: i64,
/// Дата обновления кешированного расписания
///
/// Определяется сервером политехникума
pub last_schedule_update: i64,
}
impl CacheStatus {
pub fn default() -> Self {
CacheStatus {
cache_hash: "0000000000000000000000000000000000000000".to_string(),
cache_update_required: true,
last_cache_update: 0,
last_schedule_update: 0,
}
}
}
impl From<&web::Data<AppState>> for CacheStatus {
fn from(value: &web::Data<AppState>) -> Self {
let schedule_lock = value.schedule.lock().unwrap();
let schedule = schedule_lock.as_ref().unwrap();
CacheStatus::from(schedule)
}
}
impl From<&Schedule> for CacheStatus {
fn from(value: &Schedule) -> Self {
Self {
cache_hash: value.hash(),
cache_update_required: (value.fetched_at - Utc::now()) > Duration::minutes(5),
last_cache_update: value.fetched_at.timestamp(),
last_schedule_update: value.updated_at.timestamp(),
} }
} }
} }

View File

@@ -0,0 +1,132 @@
use self::schema::*;
use crate::AppState;
use crate::app_state::Schedule;
use crate::parser::parse_xls;
use crate::routes::schedule::schema::CacheStatus;
use crate::routes::schema::{IntoResponseAsError, ResponseError};
use crate::xls_downloader::interface::XLSDownloader;
use actix_web::web::Json;
use actix_web::{patch, web};
use chrono::Utc;
#[utoipa::path(responses(
(status = OK, body = CacheStatus),
(status = NOT_ACCEPTABLE, body = ResponseError<ErrorCode>),
))]
#[patch("/update-download-url")]
pub async fn update_download_url(
data: Json<Request>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
if !data.url.starts_with("https://politehnikum-eng.ru/") {
return ErrorCode::NonWhitelistedHost.into_response();
}
let mut downloader = app_state.downloader.lock().unwrap();
if let Some(url) = &downloader.url {
if url.eq(&data.url) {
return Ok(CacheStatus::from(&app_state)).into();
}
}
match downloader.set_url(data.url.clone()).await {
Ok(fetch_result) => {
let mut schedule = app_state.schedule.lock().unwrap();
if schedule.is_some()
&& fetch_result.uploaded_at < schedule.as_ref().unwrap().updated_at
{
return ErrorCode::OutdatedSchedule.into_response();
}
match downloader.fetch(false).await {
Ok(download_result) => match parse_xls(download_result.data.as_ref().unwrap()) {
Ok(data) => {
*schedule = Some(Schedule {
etag: download_result.etag,
fetched_at: download_result.requested_at,
updated_at: download_result.uploaded_at,
parsed_at: Utc::now(),
data,
});
Ok(CacheStatus::from(schedule.as_ref().unwrap())).into()
}
Err(error) => ErrorCode::InvalidSchedule(error).into_response(),
},
Err(error) => {
eprintln!("Unknown url provided {}", data.url);
eprintln!("{:?}", error);
ErrorCode::DownloadFailed.into_response()
}
}
}
Err(error) => {
eprintln!("Unknown url provided {}", data.url);
eprintln!("{:?}", error);
ErrorCode::FetchFailed.into_response()
}
}
}
mod schema {
use crate::parser::schema::ParseError;
use crate::routes::schedule::schema::CacheStatus;
use actix_macros::{IntoResponseErrorNamed, StatusCode};
use derive_more::Display;
use serde::{Deserialize, Serialize, Serializer};
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<CacheStatus, ErrorCode>;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Request {
/// Ссылка на расписание
pub url: String,
}
#[derive(Clone, ToSchema, StatusCode, Display, IntoResponseErrorNamed)]
#[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"]
#[schema(as = SetDownloadUrl::ErrorCode)]
pub enum ErrorCode {
/// Передана ссылка с хостом отличающимся от politehnikum-eng.ru
#[display("URL with unknown host provided. Provide url with politehnikum-eng.ru host.")]
NonWhitelistedHost,
/// Не удалось получить мета-данные файла
#[display("Unable to retrieve metadata from the specified URL.")]
FetchFailed,
/// Не удалось скачать файл
#[display("Unable to retrieve data from the specified URL.")]
DownloadFailed,
/// Ссылка ведёт на устаревшее расписание
///
/// Под устаревшим расписанием подразумевается расписание, которое было опубликовано раньше, чем уже имеется на данный момент
#[display("The schedule is older than it already is.")]
OutdatedSchedule,
/// Не удалось преобразовать расписание
#[display("{}", "_0.display()")]
InvalidSchedule(ParseError),
}
impl Serialize for ErrorCode {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
ErrorCode::NonWhitelistedHost => serializer.serialize_str("NON_WHITELISTED_HOST"),
ErrorCode::FetchFailed => serializer.serialize_str("FETCH_FAILED"),
ErrorCode::DownloadFailed => serializer.serialize_str("DOWNLOAD_FAILED"),
ErrorCode::OutdatedSchedule => serializer.serialize_str("OUTDATED_SCHEDULE"),
ErrorCode::InvalidSchedule(_) => serializer.serialize_str("INVALID_SCHEDULE"),
}
}
}
}

View File

@@ -112,27 +112,31 @@ pub mod user {
use crate::database::models::{User, UserRole}; use crate::database::models::{User, UserRole};
use actix_macros::ResponderJson; use actix_macros::ResponderJson;
use serde::Serialize; use serde::Serialize;
/// UserResponse /// Используется для скрытия чувствительных полей, таких как хеш пароля или FCM
///
/// Uses for stripping sensitive fields (password, fcm, etc.) from response.
#[derive(Serialize, utoipa::ToSchema, ResponderJson)] #[derive(Serialize, utoipa::ToSchema, ResponderJson)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UserResponse { pub struct UserResponse {
/// UUID
#[schema(examples("67dcc9a9507b0000772744a2"))] #[schema(examples("67dcc9a9507b0000772744a2"))]
id: String, id: String,
/// Имя пользователя
#[schema(examples("n08i40k"))] #[schema(examples("n08i40k"))]
username: String, username: String,
/// Группа
#[schema(examples("ИС-214/23"))] #[schema(examples("ИС-214/23"))]
group: String, group: String,
/// Роль
role: UserRole, role: UserRole,
/// Идентификатор прявязанного аккаунта VK
#[schema(examples(498094647, json!(null)))] #[schema(examples(498094647, json!(null)))]
vk_id: Option<i32>, vk_id: Option<i32>,
/// JWT токен доступа
#[schema(examples( #[schema(examples(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY" "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY"
))] ))]

View File

@@ -15,9 +15,10 @@ pub(crate) mod tests {
*schedule_lock = Some(Schedule { *schedule_lock = Some(Schedule {
etag: "".to_string(), etag: "".to_string(),
fetched_at: Default::default(),
updated_at: Default::default(), updated_at: Default::default(),
parsed_at: Default::default(), parsed_at: Default::default(),
data: test_result(), data: test_result().unwrap(),
}); });
state.clone() state.clone()

View File

@@ -2,6 +2,7 @@ use std::fmt::{Write};
use std::fmt::Display; use std::fmt::Display;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Ответ от сервера при ошибках внутри Middleware
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ResponseErrorMessage<T: Display> { pub struct ResponseErrorMessage<T: Display> {
code: T, code: T,

38
src/utility/hasher.rs Normal file
View File

@@ -0,0 +1,38 @@
use sha1::Digest;
use std::hash::Hasher;
/// Хешер возвращающий хеш из алгоритма реализующего Digest
pub struct DigestHasher<D: Digest> {
digest: D,
}
impl<D> DigestHasher<D>
where
D: Digest,
{
/// Получение хеша
pub fn finalize(self) -> String {
hex::encode(self.digest.finalize().0)
}
}
impl<D> From<D> for DigestHasher<D>
where
D: Digest,
{
/// Создания хешера из алгоритма реализующего Digest
fn from(digest: D) -> Self {
DigestHasher { digest }
}
}
impl<D: Digest> Hasher for DigestHasher<D> {
/// Заглушка для предотвращения вызова стандартного результата Hasher
fn finish(&self) -> u64 {
unimplemented!("Do not call finish()");
}
fn write(&mut self, bytes: &[u8]) {
self.digest.update(bytes);
}
}

View File

@@ -9,22 +9,31 @@ use std::env;
use std::mem::discriminant; use std::mem::discriminant;
use std::sync::LazyLock; use std::sync::LazyLock;
/// Ключ для верификации токена
static DECODING_KEY: LazyLock<DecodingKey> = LazyLock::new(|| { static DECODING_KEY: LazyLock<DecodingKey> = LazyLock::new(|| {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
DecodingKey::from_secret(secret.as_bytes()) DecodingKey::from_secret(secret.as_bytes())
}); });
/// Ключ для создания подписанного токена
static ENCODING_KEY: LazyLock<EncodingKey> = LazyLock::new(|| { static ENCODING_KEY: LazyLock<EncodingKey> = LazyLock::new(|| {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
EncodingKey::from_secret(secret.as_bytes()) EncodingKey::from_secret(secret.as_bytes())
}); });
/// Ошибки верификации токена
#[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
pub enum Error { pub enum Error {
/// Токен имеет другую подпись
InvalidSignature, InvalidSignature,
/// Ошибка чтения токена
InvalidToken(ErrorKind), InvalidToken(ErrorKind),
/// Токен просрочен
Expired, Expired,
} }
@@ -34,18 +43,26 @@ impl PartialEq for Error {
} }
} }
/// Данные, которые хранит в себе токен
#[serde_as] #[serde_as]
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct Claims { struct Claims {
/// UUID аккаунта пользователя
id: String, id: String,
/// Дата создания токена
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
iat: u64, iat: u64,
/// Дата окончания действия токена
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
exp: u64, exp: u64,
} }
/// Алгоритм подписи токенов
pub(crate) const DEFAULT_ALGORITHM: Algorithm = Algorithm::HS256; pub(crate) const DEFAULT_ALGORITHM: Algorithm = Algorithm::HS256;
/// Проверка токена и извлечение из него UUID аккаунта пользователя
pub fn verify_and_decode(token: &String) -> Result<String, Error> { pub fn verify_and_decode(token: &String) -> Result<String, Error> {
let mut validation = Validation::new(DEFAULT_ALGORITHM); let mut validation = Validation::new(DEFAULT_ALGORITHM);
@@ -70,6 +87,7 @@ pub fn verify_and_decode(token: &String) -> Result<String, Error> {
} }
} }
/// Создание токена пользователя
pub fn encode(id: &String) -> String { pub fn encode(id: &String) -> String {
let header = Header { let header = Header {
typ: Some(String::from("JWT")), typ: Some(String::from("JWT")),

View File

@@ -1,2 +1,3 @@
pub mod jwt; pub mod jwt;
pub mod error; pub mod error;
pub mod hasher;

View File

@@ -2,7 +2,7 @@ use crate::xls_downloader::interface::{FetchError, FetchOk, FetchResult, XLSDown
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
pub struct BasicXlsDownloader { pub struct BasicXlsDownloader {
url: Option<String>, pub url: Option<String>,
} }
async fn fetch_specified(url: &String, user_agent: String, head: bool) -> FetchResult { async fn fetch_specified(url: &String, user_agent: String, head: bool) -> FetchResult {
@@ -73,14 +73,14 @@ impl XLSDownloader for BasicXlsDownloader {
} }
} }
async fn set_url(&mut self, url: String) -> Result<(), FetchError> { async fn set_url(&mut self, url: String) -> FetchResult {
let result = fetch_specified(&url, "t.me/polytechnic_next".to_string(), true).await; let result = fetch_specified(&url, "t.me/polytechnic_next".to_string(), true).await;
if let Ok(_) = result { if let Ok(_) = result {
Ok(self.url = Some(url)) self.url = Some(url);
} else {
Err(result.err().unwrap())
} }
result
} }
} }

View File

@@ -1,22 +1,41 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
/// Ошибки получения данных XLS
#[derive(PartialEq, Debug)] #[derive(PartialEq, Debug)]
pub enum FetchError { pub enum FetchError {
/// Не установлена ссылка на файл
NoUrlProvided, NoUrlProvided,
/// Неизвестная ошибка
Unknown, Unknown,
/// Сервер вернул статус код отличающийся от 200
BadStatusCode, BadStatusCode,
/// Ссылка ведёт на файл другого типа
BadContentType, BadContentType,
/// Сервер не вернул ожидаемые заголовки
BadHeaders, BadHeaders,
} }
/// Результат получения данных XLS
pub struct FetchOk { pub struct FetchOk {
/// ETag объекта
pub etag: String, pub etag: String,
/// Дата загрузки файла
pub uploaded_at: DateTime<Utc>, pub uploaded_at: DateTime<Utc>,
/// Дата получения данных
pub requested_at: DateTime<Utc>, pub requested_at: DateTime<Utc>,
/// Данные файла
pub data: Option<Vec<u8>>, pub data: Option<Vec<u8>>,
} }
impl FetchOk { impl FetchOk {
/// Результат без контента файла
pub fn head(etag: String, uploaded_at: DateTime<Utc>) -> Self { pub fn head(etag: String, uploaded_at: DateTime<Utc>) -> Self {
FetchOk { FetchOk {
etag, etag,
@@ -26,6 +45,7 @@ impl FetchOk {
} }
} }
/// Полный результат
pub fn get(etag: String, uploaded_at: DateTime<Utc>, data: Vec<u8>) -> Self { pub fn get(etag: String, uploaded_at: DateTime<Utc>, data: Vec<u8>) -> Self {
FetchOk { FetchOk {
etag, etag,
@@ -39,6 +59,9 @@ impl FetchOk {
pub type FetchResult = Result<FetchOk, FetchError>; pub type FetchResult = Result<FetchOk, FetchError>;
pub trait XLSDownloader { pub trait XLSDownloader {
/// Получение данных о файле, и, опционально, его контент
async fn fetch(&self, head: bool) -> FetchResult; async fn fetch(&self, head: bool) -> FetchResult;
async fn set_url(&mut self, url: String) -> Result<(), FetchError>;
/// Установка ссылки на файл
async fn set_url(&mut self, url: String) -> FetchResult;
} }