diff --git a/Cargo.lock b/Cargo.lock index c50be0f..f2dd7bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,7 +50,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rand 0.9.0", - "sha1", + "sha1 0.10.6", "smallvec", "tokio", "tokio-util", @@ -2351,7 +2351,7 @@ dependencies = [ [[package]] name = "schedule-parser-rusted" -version = "0.7.0" +version = "0.8.0" dependencies = [ "actix-macros 0.1.0", "actix-test", @@ -2367,6 +2367,7 @@ dependencies = [ "env_logger", "futures-util", "fuzzy-matcher", + "hex", "jsonwebtoken", "mime", "objectid", @@ -2377,7 +2378,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_with", - "sha2", + "sha1 0.11.0-pre.5", "tokio", "utoipa", "utoipa-actix-web", @@ -2510,10 +2511,10 @@ dependencies = [ ] [[package]] -name = "sha2" +name = "sha1" version = "0.11.0-pre.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b4241d1a56954dce82cecda5c8e9c794eef6f53abe5e5216bac0a0ea71ffa7" +checksum = "55f44e40722caefdd99383c25d3ae52a1094a1951215ae76f68837ece4e7f566" dependencies = [ "cfg-if", "cpufeatures", diff --git a/Cargo.toml b/Cargo.toml index 050feae..8cf27b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["actix-macros", "actix-test"] [package] name = "schedule-parser-rusted" -version = "0.7.0" +version = "0.8.0" edition = "2024" publish = false @@ -21,6 +21,7 @@ env_logger = "0.11.7" futures-util = "0.3.31" fuzzy-matcher = "0.3.7" jsonwebtoken = { version = "9.3.1", features = ["use_pem"] } +hex = "0.4.3" mime = "0.3.17" objectid = "0.2.0" regex = "1.11.1" @@ -29,7 +30,7 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" serde_with = "3.12.0" 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"] } rand = "0.9.0" utoipa = { version = "5", features = ["actix_extras", "chrono"] } diff --git a/actix-macros/src/lib.rs b/actix-macros/src/lib.rs index c581038..2ca131c 100644 --- a/actix-macros/src/lib.rs +++ b/actix-macros/src/lib.rs @@ -121,16 +121,9 @@ mod responder_json { type Body = ::actix_web::body::EitherBody<::actix_web::body::BoxBody>; fn respond_to(self, _: &::actix_web::HttpRequest) -> ::actix_web::HttpResponse { - match ::serde_json::to_string(&self) { - Ok(body) => ::actix_web::HttpResponse::Ok() - .json(body) - .map_into_left_body(), - - Err(err) => ::actix_web::HttpResponse::from_error( - ::actix_web::error::JsonPayloadError::Serialize(err), - ) - .map_into_right_body(), - } + ::actix_web::HttpResponse::Ok() + .json(self) + .map_into_left_body() } } }) diff --git a/benches/parse.rs b/benches/parse.rs index fefb411..91d5f8f 100644 --- a/benches/parse.rs +++ b/benches/parse.rs @@ -5,7 +5,7 @@ use schedule_parser_rusted::parser::parse_xls; pub fn bench_parse_xls(c: &mut Criterion) { let buffer: Vec = 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); diff --git a/src/app_state.rs b/src/app_state.rs index 77a49d5..1a6e398 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,19 +1,37 @@ use crate::parser::schema::ParseResult; +use crate::utility::hasher::DigestHasher; use crate::xls_downloader::basic_impl::BasicXlsDownloader; use actix_web::web; use chrono::{DateTime, Utc}; use diesel::{Connection, PgConnection}; +use sha1::{Digest, Sha1}; use std::env; +use std::hash::Hash; use std::sync::{Mutex, MutexGuard}; #[derive(Clone)] pub struct Schedule { pub etag: String, + pub fetched_at: DateTime, pub updated_at: DateTime, pub parsed_at: DateTime, 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 downloader: Mutex, pub schedule: Mutex>, @@ -21,11 +39,13 @@ pub struct AppState { } impl AppState { + /// Получение объекта соединения с базой данных PostgreSQL pub fn connection(&self) -> MutexGuard { self.database.lock().unwrap() } } +/// Создание нового объекта web::Data pub fn app_state() -> web::Data { let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); diff --git a/src/database/driver.rs b/src/database/driver.rs index df2aa29..0aa1dba 100644 --- a/src/database/driver.rs +++ b/src/database/driver.rs @@ -30,7 +30,7 @@ pub mod users { .select(User::as_select()) .first(con) } - + pub fn get_by_vk_id( connection: &Mutex, _vk_id: i32, @@ -72,6 +72,14 @@ pub mod users { } } + pub fn insert(connection: &Mutex, user: &User) -> QueryResult { + 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, _username: &String) -> bool { let mut lock = connection.lock().unwrap(); let con = lock.deref_mut(); @@ -81,14 +89,8 @@ pub mod users { Err(_) => false, } } - - pub fn insert(connection: &Mutex, user: &User) -> QueryResult { - let mut lock = connection.lock().unwrap(); - let con = lock.deref_mut(); - - insert_into(users).values(user).execute(con) - } - + + #[cfg(test)] pub fn insert_or_ignore(connection: &Mutex, user: &User) -> QueryResult { let mut lock = connection.lock().unwrap(); let con = lock.deref_mut(); diff --git a/src/database/models.rs b/src/database/models.rs index 2771f22..8b33c96 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -35,12 +35,27 @@ pub enum UserRole { #[diesel(table_name = crate::database::schema::users)] #[diesel(treat_none_as_null = true)] pub struct User { + /// UUID аккаунта pub id: String, + + /// Имя пользователя pub username: String, + + /// BCrypt хеш пароля pub password: String, + + /// Идентификатор привязанного аккаунта VK pub vk_id: Option, + + /// JWT токен доступа pub access_token: String, + + /// Группа pub group: String, + + /// Роль pub role: UserRole, + + /// Версия установленного приложения Polytechnic+ pub version: String, } diff --git a/src/extractors/authorized_user.rs b/src/extractors/authorized_user.rs index 3e4733a..befa422 100644 --- a/src/extractors/authorized_user.rs +++ b/src/extractors/authorized_user.rs @@ -6,25 +6,29 @@ use crate::utility::jwt; use actix_macros::ResponseErrorMessage; use actix_web::body::BoxBody; use actix_web::dev::Payload; +use actix_web::http::header; use actix_web::{HttpRequest, web}; use derive_more::Display; use serde::{Deserialize, Serialize}; use std::fmt::Debug; -use actix_web::http::header; #[derive(Clone, Debug, Serialize, Deserialize, Display, ResponseErrorMessage)] #[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Error { + /// В запросе отсутствует заголовок Authorization #[display("No Authorization header found")] NoHeader, + /// Неизвестный тип авторизации, отличающийся от Bearer #[display("Bearer token is required")] UnknownAuthorizationType, + /// Токен не действителен #[display("Invalid or expired access token")] InvalidAccessToken, + /// Пользователь привязанный к токену не найден в базе данных #[display("No user associated with access token")] NoUser, } @@ -35,6 +39,7 @@ impl Error { } } +/// Экстрактор пользователя из запроса с токеном impl FromRequestSync for User { type Error = actix_web::Error; diff --git a/src/extractors/base.rs b/src/extractors/base.rs index d6f9597..608c22a 100644 --- a/src/extractors/base.rs +++ b/src/extractors/base.rs @@ -3,20 +3,25 @@ use actix_web::{FromRequest, HttpRequest}; use futures_util::future::LocalBoxFuture; use std::future::{Ready, ready}; -pub trait FromRequestAsync: Sized { - type Error: Into; - - async fn from_request_async(req: HttpRequest, payload: Payload) -> Result; -} - +/// Асинхронный экстрактор объектов из запроса pub struct AsyncExtractor(T); impl AsyncExtractor { + #[allow(dead_code)] + /// Получение объекта, извлечённого с помощью экстрактора pub fn into_inner(self) -> T { self.0 } } +pub trait FromRequestAsync: Sized { + type Error: Into; + + /// Асинхронная функция для извлечения данных из запроса + async fn from_request_async(req: HttpRequest, payload: Payload) -> Result; +} + +/// Реализация треита FromRequest для всех асинхронных экстракторов impl FromRequest for AsyncExtractor { type Error = T::Error; type Future = LocalBoxFuture<'static, Result>; @@ -32,20 +37,24 @@ impl FromRequest for AsyncExtractor { } } -pub trait FromRequestSync: Sized { - type Error: Into; - - fn from_request_sync(req: &HttpRequest, payload: &mut Payload) -> Result; -} - +/// Синхронный экстрактор объектов из запроса pub struct SyncExtractor(T); impl SyncExtractor { + /// Получение объекта, извлечённого с помощью экстрактора pub fn into_inner(self) -> T { self.0 } } +pub trait FromRequestSync: Sized { + type Error: Into; + + /// Синхронная функция для извлечения данных из запроса + fn from_request_sync(req: &HttpRequest, payload: &mut Payload) -> Result; +} + +/// Реализация треита FromRequest для всех синхронных экстракторов impl FromRequest for SyncExtractor { type Error = T::Error; type Future = Ready>; diff --git a/src/main.rs b/src/main.rs index 13cf9c6..4c3fd7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,19 @@ -use crate::app_state::{app_state, AppState}; -use crate::middlewares::authorization::Authorization; +use crate::app_state::{AppState, app_state}; +use crate::middlewares::authorization::JWTAuthorization; 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::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 actix_web::{App, HttpServer}; use dotenvy::dotenv; use utoipa_actix_web::AppExt; use utoipa_rapidoc::RapiDoc; -use crate::routes::schedule::get_schedule::get_schedule; mod app_state; @@ -31,6 +37,8 @@ async fn main() { unsafe { std::env::set_var("RUST_LOG", "debug") }; env_logger::init(); + let app_state = app_state(); + HttpServer::new(move || { let auth_scope = utoipa_actix_web::scope("/auth") .service(sign_in_default) @@ -39,12 +47,18 @@ async fn main() { .service(sign_up_vk); let users_scope = utoipa_actix_web::scope("/users") - .wrap(Authorization) + .wrap(JWTAuthorization) .service(me); - + let schedule_scope = utoipa_actix_web::scope("/schedule") - .wrap(Authorization) - .service(get_schedule); + .wrap(JWTAuthorization) + .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") .service(auth_scope) @@ -53,7 +67,7 @@ async fn main() { let (app, api) = App::new() .into_utoipa_app() - .app_data(app_state()) + .app_data(app_state.clone()) .service(api_scope) .split_for_parts(); @@ -67,6 +81,7 @@ async fn main() { app.service(rapidoc_service.custom_html(patched_rapidoc_html)) }) + .workers(4) .bind(("0.0.0.0", 8080)) .unwrap() .run() diff --git a/src/middlewares/authorization.rs b/src/middlewares/authorization.rs index ca85abe..6f5b32b 100644 --- a/src/middlewares/authorization.rs +++ b/src/middlewares/authorization.rs @@ -7,9 +7,10 @@ use actix_web::{Error, HttpRequest, ResponseError}; use futures_util::future::LocalBoxFuture; use std::future::{Ready, ready}; -pub struct Authorization; +/// Middleware guard работающий с токенами JWT +pub struct JWTAuthorization; -impl Transform for Authorization +impl Transform for JWTAuthorization where S: Service, Error = Error>, S::Future: 'static, @@ -17,20 +18,21 @@ where { type Response = ServiceResponse>; type Error = Error; - type Transform = AuthorizationMiddleware; + type Transform = JWTAuthorizationMiddleware; type InitError = (); type Future = Ready>; fn new_transform(&self, service: S) -> Self::Future { - ready(Ok(AuthorizationMiddleware { service })) + ready(Ok(JWTAuthorizationMiddleware { service })) } } -pub struct AuthorizationMiddleware { +pub struct JWTAuthorizationMiddleware { service: S, } -impl AuthorizationMiddleware +/// Функция для проверки наличия и действительности токена в запросе, а так же существования пользователя к которому он привязан +impl JWTAuthorizationMiddleware where S: Service, Error = Error>, S::Future: 'static, @@ -47,7 +49,7 @@ where } } -impl Service for AuthorizationMiddleware +impl Service for JWTAuthorizationMiddleware where S: Service, Error = Error>, S::Future: 'static, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index cc3a31c..19773b3 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,7 +1,7 @@ use crate::parser::LessonParseResult::{Lessons, Street}; use crate::parser::schema::LessonType::Break; 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 chrono::{Duration, NaiveDateTime}; @@ -14,15 +14,12 @@ use std::sync::LazyLock; pub mod schema; +/// Данные ячейке хранящей строку struct InternalId { - /** - * Индекс строки - */ + /// Индекс строки row: u32, - /** - * Индекс столбца - */ + /// Индекс столбца column: u32, /** @@ -31,30 +28,25 @@ struct InternalId { name: String, } +/// Данные о времени проведения пар из второй колонки расписания struct InternalTime { - /** - * Временной отрезок проведения пары - */ + /// Временной отрезок проведения пары time_range: LessonTime, - /** - * Тип пары - */ + /// Тип пары lesson_type: LessonType, - /** - * Индекс пары - */ + /// Индекс пары default_index: Option, - /** - * Рамка ячейки - */ + /// Рамка ячейки xls_range: ((u32, u32), (u32, u32)), } +/// Сокращение типа рабочего листа type WorkSheet = calamine::Range; +/// Получение строки из требуемой ячейки fn get_string_from_cell(worksheet: &WorkSheet, row: u32, col: u32) -> Option { let cell_data = if let Some(data) = worksheet.get((row as usize, col as usize)) { data.to_string() @@ -82,6 +74,7 @@ fn get_string_from_cell(worksheet: &WorkSheet, row: u32, col: u32) -> Option ((u32, u32), (u32, u32)) { 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)) } -fn parse_skeleton(worksheet: &WorkSheet) -> (Vec, Vec) { +/// Получение "скелета" расписания из рабочего листа +fn parse_skeleton(worksheet: &WorkSheet) -> Result<(Vec, Vec), ParseError> { let range = &worksheet; let mut is_parsed = false; @@ -124,8 +118,8 @@ fn parse_skeleton(worksheet: &WorkSheet) -> (Vec, Vec) { let mut groups: Vec = Vec::new(); let mut days: Vec = Vec::new(); - let start = range.start().expect("Could not find start"); - let end = range.end().expect("Could not find end"); + let start = range.start().ok_or(ParseError::UnknownWorkSheetRange)?; + let end = range.end().ok_or(ParseError::UnknownWorkSheetRange)?; let mut row = start.0; while row < end.0 { @@ -170,15 +164,22 @@ fn parse_skeleton(worksheet: &WorkSheet) -> (Vec, Vec) { } } - (days, groups) + Ok((days, groups)) } +/// Результат получения пары из ячейки enum LessonParseResult { + /// Список пар длинной от одного до двух + /// + /// Количество пар будет равно одному, если пара первая за день, иначе будет возвращен список из шаблона перемены и самой пары Lessons(Vec), + + /// Улица на которой находится корпус политехникума Street(String), } trait StringInnerSlice { + /// Получения отрезка строки из строки по начальному и конечному индексу 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)> { let map: HashMap = HashMap::from([ ("(консультация)".to_string(), LessonType::Consultation), @@ -232,19 +234,20 @@ fn guess_lesson_type(name: &String) -> Option<(String, LessonType)> { } } +/// Получение пары или улицы из ячейки fn parse_lesson( worksheet: &WorkSheet, day: &mut Day, day_times: &Vec, time: &InternalTime, column: u32, -) -> LessonParseResult { +) -> Result { 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()); + return Ok(Lessons(Vec::new())); } let raw_name = raw_name_opt.unwrap(); @@ -253,7 +256,7 @@ fn parse_lesson( LazyLock::new(|| Regex::new(r"^[А-Я][а-я]+,?\s?[0-9]+$").unwrap()); 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) { @@ -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 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) .collect::>(); - 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 default = time.default_index.unwrap() as u8; @@ -286,10 +289,10 @@ fn parse_lesson( 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 = parse_cabinets(worksheet, row, column + 1); @@ -345,12 +348,12 @@ fn parse_lesson( }; let prev_lesson = if day.lessons.len() == 0 { - return Lessons(Vec::from([lesson])); + return Ok(Lessons(Vec::from([lesson]))); } else { &day.lessons[day.lessons.len() - 1] }; - Lessons(Vec::from([ + Ok(Lessons(Vec::from([ Lesson { lesson_type: Break, default_range: None, @@ -363,9 +366,10 @@ fn parse_lesson( group: None, }, lesson, - ])) + ]))) } +/// Получение списка кабинетов справа от ячейки пары fn parse_cabinets(worksheet: &WorkSheet, row: u32, column: u32) -> Vec { let mut cabinets: Vec = Vec::new(); @@ -383,15 +387,16 @@ fn parse_cabinets(worksheet: &WorkSheet, row: u32, column: u32) -> Vec { cabinets } -fn parse_name_and_subgroups(name: &String) -> (String, Vec) { +/// Получение "чистого" названия пары и списка преподавателей из текста ячейки пары +fn parse_name_and_subgroups(name: &String) -> Result<(String, Vec), ParseError> { static LESSON_RE: LazyLock Regex> = LazyLock::new(|| Regex::new(r"(?:[А-Я][а-я]+[А-Я]{2}(?:\([0-9][а-я]+\))?)+$").unwrap()); static TEACHER_RE: LazyLock Regex> = LazyLock::new(|| Regex::new(r"([А-Я][а-я]+)([А-Я])([А-Я])(?:\(([0-9])[а-я]+\))?").unwrap()); static CLEAN_RE: LazyLock Regex> = LazyLock::new(|| Regex::new(r"[\s.,]+").unwrap()); - static NAME_CLEAN_RE: LazyLock Regex> = - LazyLock::new(|| Regex::new(r"\.\s+$").unwrap()); + static END_CLEAN_RE: LazyLock Regex> = + LazyLock::new(|| Regex::new(r"[.\s]+$").unwrap()); let (teachers, lesson_name) = { let clean_name = CLEAN_RE.replace_all(&name, "").to_string(); @@ -402,11 +407,13 @@ fn parse_name_and_subgroups(name: &String) -> (String, Vec) { 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(), + END_CLEAN_RE.replace(&capture_str, "").to_string(), + END_CLEAN_RE + .replace(&name[0..name.find(&*capture_name).unwrap()], "") + .to_string(), ) } 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) { .as_str() .to_string() .parse::() - .expect("Unable to read subgroup index!") + .map_err(|_| ParseError::SubgroupIndexParsingFailed)? } else { 0 }, @@ -432,7 +439,7 @@ fn parse_name_and_subgroups(name: &String) -> (String, Vec) { captures.get(2).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) { subgroups.reverse() } - (lesson_name, subgroups) + Ok((lesson_name, subgroups)) } +/// Конвертация списка пар групп в список пар преподавателей fn convert_groups_to_teachers( groups: &HashMap, ) -> HashMap { @@ -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 } -pub fn parse_xls(buffer: &Vec) -> ParseResult { +/// Чтение XLS документа из буфера и преобразование его в готовые к использованию расписания +pub fn parse_xls(buffer: &Vec) -> Result { 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 .worksheets() .first() - .expect("No worksheet found") + .ok_or(ParseError::NoWorkSheets)? .1 .to_owned(); - let (days_markup, groups_markup) = parse_skeleton(&worksheet); + let (days_markup, groups_markup) = parse_skeleton(&worksheet)?; let mut groups: HashMap = HashMap::new(); let mut days_times: Vec> = Vec::new(); @@ -631,9 +649,7 @@ pub fn parse_xls(buffer: &Vec) -> ParseResult { static TIME_RE: LazyLock 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 parse_res = TIME_RE.captures(&time).ok_or(ParseError::GlobalTime)?; let start_match = parse_res.get(1).unwrap().as_str(); let start_parts: Vec<&str> = start_match.split(".").collect(); @@ -671,7 +687,7 @@ pub fn parse_xls(buffer: &Vec) -> ParseResult { &day_times, &time, group_markup.column, - ) { + )? { Lessons(l) => day.lessons.append(l), Street(s) => day.street = Some(s.to_owned()), } @@ -683,27 +699,27 @@ pub fn parse_xls(buffer: &Vec) -> ParseResult { groups.insert(group.name.clone(), group); } - ParseResult { + Ok(ParseResult { teachers: convert_groups_to_teachers(&groups), groups, - } + }) } #[cfg(test)] pub mod tests { use super::*; - pub fn test_result() -> ParseResult { - let buffer: Vec = include_bytes!("../../schedule.xls").to_vec(); - - parse_xls(&buffer) + pub fn test_result() -> Result { + parse_xls(&include_bytes!("../../schedule.xls").to_vec()) } #[test] fn read() { let result = test_result(); - assert_ne!(result.groups.len(), 0); - assert_ne!(result.teachers.len(), 0); + assert!(result.is_ok()); + + assert_ne!(result.as_ref().unwrap().groups.len(), 0); + assert_ne!(result.as_ref().unwrap().teachers.len(), 0); } } diff --git a/src/parser/schema.rs b/src/parser/schema.rs index 09aae4a..b25c8da 100644 --- a/src/parser/schema.rs +++ b/src/parser/schema.rs @@ -1,129 +1,162 @@ 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 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 start: DateTime, + + /// Конец пары pub end: DateTime, } -#[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")] #[repr(u8)] pub enum LessonType { - Default = 0, // Обычная - Additional, // Допы - Break, // Перемена - Consultation, // Консультация - IndependentWork, // Самостоятельная работа - Exam, // Зачёт - ExamWithGrade, // Зачет с оценкой - ExamDefault, // Экзамен + /// Обычная + Default = 0, + + /// Допы + Additional, + + /// Перемена + Break, + + /// Консультация + Consultation, + + /// Самостоятельная работа + IndependentWork, + + /// Зачёт + Exam, + + /// Зачет с оценкой + ExamWithGrade, + + /// Экзамен + ExamDefault, } -#[derive( Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] +#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)] pub struct LessonSubGroup { - /** - * Номер подгруппы. - */ + /// Номер подгруппы pub number: u8, - /** - * Кабинет, если присутствует. - */ + /// Кабинет, если присутствует pub cabinet: Option, - /** - * Фио преподавателя. - */ + /// Фио преподавателя pub teacher: String, } -#[derive(Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] +#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct Lesson { - /** - * Тип занятия. - */ + /// Тип занятия #[serde(rename = "type")] pub lesson_type: LessonType, - /** - * Индексы пар, если присутствуют. - */ + /// Индексы пар, если присутствуют pub default_range: Option<[u8; 2]>, - /** - * Название занятия. - */ + /// Название занятия pub name: Option, - /** - * Начало и конец занятия. - */ + /// Начало и конец занятия pub time: LessonTime, - /** - * Подгруппы. - */ + /// Список подгрупп #[serde(rename = "subGroups")] pub subgroups: Option>, - /** - * Группа, если это расписание для преподавателей. - */ + /// Группа, если это расписание для преподавателей pub group: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)] +#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)] pub struct Day { - /** - * День недели. - */ + /// День недели pub name: String, - /** - * Адрес другого корпуса. - */ + /// Адрес другого корпуса pub street: Option, - /** - * Дата. - */ + /// Дата pub date: DateTime, - /** - * Список пар в этот день. - */ + /// Список пар в этот день pub lessons: Vec, } -#[derive(Clone, Serialize, Deserialize, Debug, utoipa::ToSchema)] +#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)] pub struct ScheduleEntry { - /** - * Название группы или ФИО преподавателя. - */ + /// Название группы или ФИО преподавателя pub name: String, - /** - * Список из шести дней. - */ + /// Список из шести дней pub days: Vec, } #[derive(Clone)] pub struct ParseResult { - /** - * Список групп. - * Ключом является название группы. - */ + /// Список групп pub groups: HashMap, - /** - * Список преподавателей. - * Ключом является ФИО преподавателя. - */ + /// Список преподавателей pub teachers: HashMap, } + +#[derive(Debug, Display, Clone, ToSchema)] +pub enum ParseError { + /// Ошибки связанные с чтением XLS файла. + #[display("{}: Failed to read XLS file.", "_0")] + #[schema(value_type = String)] + BadXLS(Arc), + + /// Не найдено ни одного листа + #[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(&self, serializer: S) -> Result + 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") + } + } + } +} diff --git a/src/routes/auth/sign_in.rs b/src/routes/auth/sign_in.rs index 6d698b6..c1cb88d 100644 --- a/src/routes/auth/sign_in.rs +++ b/src/routes/auth/sign_in.rs @@ -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::schema::user::UserResponse; use crate::routes::schema::{IntoResponseAsError, ResponseError}; -use crate::{AppState, utility}; +use crate::{utility, AppState}; use actix_web::{post, web}; use diesel::SaveChangesDsl; use std::ops::DerefMut; @@ -55,7 +55,7 @@ async fn sign_in( (status = NOT_ACCEPTABLE, body = ResponseError) ))] #[post("/sign-in")] -pub async fn sign_in_default(data: Json, app_state: web::Data) -> Response { +pub async fn sign_in_default(data: Json, app_state: web::Data) -> ServiceResponse { sign_in(Default(data.into_inner()), &app_state).await.into() } @@ -64,7 +64,7 @@ pub async fn sign_in_default(data: Json, app_state: web::Data (status = NOT_ACCEPTABLE, body = ResponseError) ))] #[post("/sign-in-vk")] -pub async fn sign_in_vk(data_json: Json, app_state: web::Data) -> Response { +pub async fn sign_in_vk(data_json: Json, app_state: web::Data) -> ServiceResponse { let data = data_json.into_inner(); match parse_vk_id(&data.access_token) { @@ -74,51 +74,57 @@ pub async fn sign_in_vk(data_json: Json, app_state: web::Data; + pub type ServiceResponse = crate::routes::schema::Response; - #[derive(Serialize, utoipa::ToSchema, Clone, IntoResponseError)] + #[derive(Serialize, ToSchema, Clone, IntoResponseError, StatusCode)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = SignIn::ErrorCode)] + #[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"] pub enum ErrorCode { + /// Некорректное имя пользователя или пароль IncorrectCredentials, + + /// Недействительный токен VK ID InvalidVkAccessToken, } - impl PartialStatusCode for ErrorCode { - fn status_code(&self) -> StatusCode { - StatusCode::NOT_ACCEPTABLE - } - } - /// Internal + /// Тип авторизации pub enum SignInData { + /// Имя пользователя и пароль Default(Request), + + /// Идентификатор привязанного аккаунта VK Vk(i32), } } @@ -136,7 +142,7 @@ mod tests { use actix_web::http::Method; use actix_web::http::StatusCode; use actix_web::test; - use sha2::{Digest, Sha256}; + use sha1::{Digest, Sha1}; use std::fmt::Write; async fn sign_in_client(data: Request) -> ServiceResponse { @@ -152,7 +158,7 @@ mod tests { fn prepare(username: String) { let id = { - let mut sha = Sha256::new(); + let mut sha = Sha1::new(); sha.update(&username); let result = sha.finalize(); diff --git a/src/routes/auth/sign_up.rs b/src/routes/auth/sign_up.rs index db53dc8..fb7c038 100644 --- a/src/routes/auth/sign_up.rs +++ b/src/routes/auth/sign_up.rs @@ -50,7 +50,10 @@ async fn sign_up( (status = NOT_ACCEPTABLE, body = ResponseError) ))] #[post("/sign-up")] -pub async fn sign_up_default(data_json: Json, app_state: web::Data) -> Response { +pub async fn sign_up_default( + data_json: Json, + app_state: web::Data, +) -> ServiceResponse { let data = data_json.into_inner(); sign_up( @@ -73,7 +76,10 @@ pub async fn sign_up_default(data_json: Json, app_state: web::Data) ))] #[post("/sign-up-vk")] -pub async fn sign_up_vk(data_json: Json, app_state: web::Data) -> Response { +pub async fn sign_up_vk( + data_json: Json, + app_state: web::Data, +) -> ServiceResponse { let data = data_json.into_inner(); match parse_vk_id(&data.access_token) { @@ -107,11 +113,9 @@ pub async fn sign_up_vk(data_json: Json, app_state: web::Data; + pub type ServiceResponse = crate::routes::schema::Response; #[derive(Clone, Serialize, utoipa::ToSchema, IntoResponseError, StatusCode)] - #[status_code = "StatusCode::NOT_ACCEPTABLE"] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[schema(as = SignUp::ErrorCode)] + #[status_code = "actix_web::http::StatusCode::NOT_ACCEPTABLE"] pub enum ErrorCode { + /// Передана роль ADMIN DisallowedRole, + + /// Неизвестное название группы InvalidGroupName, + + /// Пользователь с таким именем уже зарегистрирован UsernameAlreadyExists, + + /// Недействительный токен VK ID InvalidVkAccessToken, + + /// Пользователь с таким аккаунтом VK уже зарегистрирован VkAlreadyExists, } /// Internal + /// Данные для регистрации pub struct SignUpData { + /// Имя пользователя pub username: String, + + /// Пароль + /// + /// Должен присутствовать даже если регистрация происходит с помощью токена VK ID pub password: String, + + /// Идентификатор аккаунта VK pub vk_id: Option, + + /// Группа pub group: String, + + /// Роль pub role: UserRole, + + /// Версия установленного приложения Polytechnic+ pub version: String, } diff --git a/src/routes/schedule/get_cache_status.rs b/src/routes/schedule/get_cache_status.rs new file mode 100644 index 0000000..fe9906e --- /dev/null +++ b/src/routes/schedule/get_cache_status.rs @@ -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) -> 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() +} diff --git a/src/routes/schedule/get_group.rs b/src/routes/schedule/get_group.rs new file mode 100644 index 0000000..361f169 --- /dev/null +++ b/src/routes/schedule/get_group.rs @@ -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, + example = json!({ + "code": "NO_SCHEDULE", + "message": "Schedule not parsed yet." + }) + ), + ( + status = NOT_FOUND, + body = ResponseError, + example = json!({ + "code": "NOT_FOUND", + "message": "Required group not found." + }) + ), +))] +#[get("/group")] +pub async fn get_group( + user: SyncExtractor, + app_state: web::Data, +) -> 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; + + #[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, + + /// Устаревшая переменная + /// + /// По умолчанию начальная дата по Unix + #[deprecated = "Will be removed in future versions"] + pub updated_at: DateTime, + } + + #[allow(deprecated)] + impl From 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, + } +} diff --git a/src/routes/schedule/get_group_names.rs b/src/routes/schedule/get_group_names.rs new file mode 100644 index 0000000..0b82170 --- /dev/null +++ b/src/routes/schedule/get_group_names.rs @@ -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), +))] +#[get("/group-names")] +pub async fn get_group_names(app_state: web::Data) -> 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 = 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; + + #[derive(Serialize, ToSchema)] + #[schema(as = GetGroupNames::Response)] + pub struct Response { + /// Список названий групп отсортированный в алфавитном порядке + #[schema(examples(json!(["ИС-214/23"])))] + pub names: Vec, + } + + impl From> for Response { + fn from(names: Vec) -> Self { + Self { names } + } + } +} diff --git a/src/routes/schedule/get_schedule.rs b/src/routes/schedule/get_schedule.rs index 2283c42..4db823a 100644 --- a/src/routes/schedule/get_schedule.rs +++ b/src/routes/schedule/get_schedule.rs @@ -1,6 +1,6 @@ use self::schema::*; 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 actix_web::{get, web}; @@ -9,30 +9,17 @@ use actix_web::{get, web}; (status = SERVICE_UNAVAILABLE, body = ResponseError) ))] #[get("/")] -pub async fn get_schedule(app_state: web::Data) -> Response { - match ScheduleView::try_from(app_state.get_ref()) { +pub async fn get_schedule(app_state: web::Data) -> ServiceResponse { + match ScheduleView::try_from(&app_state) { Ok(res) => Ok(res).into(), Err(e) => match e { - Error::NoSchedule => ErrorCode::NoSchedule.into_response(), + ErrorCode::NoSchedule => ErrorCode::NoSchedule.into_response(), }, } } mod schema { - use crate::routes::schedule::schema::ScheduleView; - use actix_macros::{IntoResponseErrorNamed, StatusCode}; - use derive_more::Display; - use serde::Serialize; - use utoipa::ToSchema; + use crate::routes::schedule::schema::{ErrorCode, ScheduleView}; - pub type Response = crate::routes::schema::Response; - - #[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, - } + pub type ServiceResponse = crate::routes::schema::Response; } diff --git a/src/routes/schedule/get_teacher.rs b/src/routes/schedule/get_teacher.rs new file mode 100644 index 0000000..63bfc70 --- /dev/null +++ b/src/routes/schedule/get_teacher.rs @@ -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, + example = json!({ + "code": "NO_SCHEDULE", + "message": "Schedule not parsed yet." + }) + ), + ( + status = NOT_FOUND, + body = ResponseError, + example = json!({ + "code": "NOT_FOUND", + "message": "Required teacher not found." + }) + ), +))] +#[get("/teacher/{name}")] +pub async fn get_teacher( + name: web::Path, + app_state: web::Data, +) -> 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; + + #[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, + + /// Устаревшая переменная + /// + /// По умолчанию начальная дата по Unix + #[deprecated = "Will be removed in future versions"] + pub updated_at: DateTime, + } + + #[allow(deprecated)] + impl From 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, + } +} diff --git a/src/routes/schedule/get_teacher_names.rs b/src/routes/schedule/get_teacher_names.rs new file mode 100644 index 0000000..246400d --- /dev/null +++ b/src/routes/schedule/get_teacher_names.rs @@ -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), +))] +#[get("/teacher-names")] +pub async fn get_teacher_names(app_state: web::Data) -> 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 = 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; + + #[derive(Serialize, ToSchema)] + #[schema(as = GetTeacherNames::Response)] + pub struct Response { + /// Список имён преподавателей отсортированный в алфавитном порядке + #[schema(examples(json!(["Хомченко Н.Е."])))] + pub names: Vec, + } + + impl From> for Response { + fn from(names: Vec) -> Self { + Self { names } + } + } +} diff --git a/src/routes/schedule/mod.rs b/src/routes/schedule/mod.rs index 1536e95..d319876 100644 --- a/src/routes/schedule/mod.rs +++ b/src/routes/schedule/mod.rs @@ -1,2 +1,8 @@ +pub mod get_cache_status; pub mod get_schedule; -mod schema; \ No newline at end of file +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; diff --git a/src/routes/schedule/schema.rs b/src/routes/schedule/schema.rs index 6f34e84..6259dfa 100644 --- a/src/routes/schedule/schema.rs +++ b/src/routes/schedule/schema.rs @@ -1,47 +1,107 @@ -use crate::app_state::AppState; +use crate::app_state::{AppState, Schedule}; use crate::parser::schema::ScheduleEntry; -use chrono::{DateTime, Utc}; -use serde::Serialize; +use actix_macros::{IntoResponseErrorNamed, ResponderJson, StatusCode}; +use actix_web::web; +use chrono::{DateTime, Duration, Utc}; +use derive_more::Display; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use utoipa::ToSchema; +/// Ответ от сервера с расписаниями #[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ScheduleView { + /// ETag расписания на сервере политехникума etag: String, - replacer_id: Option, + + /// Дата обновления расписания на сайте политехникума uploaded_at: DateTime, + + /// Дата последнего скачивания расписания с сервера политехникума downloaded_at: DateTime, + + /// Расписание групп groups: HashMap, + + /// Расписание преподавателей teachers: HashMap, - updated_groups: Vec>, - updated_teachers: Vec>, } -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, } -impl TryFrom<&AppState> for ScheduleView { - type Error = Error; - - fn try_from(app_state: &AppState) -> Result { - let schedule_lock = app_state.schedule.lock().unwrap(); - - if let Some(schedule_ref) = schedule_lock.as_ref() { - let schedule = schedule_ref.clone(); +impl TryFrom<&web::Data> for ScheduleView { + type Error = ErrorCode; + fn try_from(app_state: &web::Data) -> Result { + if let Some(schedule) = app_state.schedule.lock().unwrap().clone() { Ok(Self { etag: schedule.etag, - replacer_id: None, uploaded_at: schedule.updated_at, downloaded_at: schedule.parsed_at, groups: schedule.data.groups, teachers: schedule.data.teachers, - updated_groups: vec![], - updated_teachers: vec![], }) } 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> for CacheStatus { + fn from(value: &web::Data) -> 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(), } } } diff --git a/src/routes/schedule/update_download_url.rs b/src/routes/schedule/update_download_url.rs new file mode 100644 index 0000000..6c4e7e3 --- /dev/null +++ b/src/routes/schedule/update_download_url.rs @@ -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), +))] +#[patch("/update-download-url")] +pub async fn update_download_url( + data: Json, + app_state: web::Data, +) -> 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; + + #[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(&self, serializer: S) -> Result + 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"), + } + } + } +} diff --git a/src/routes/schema.rs b/src/routes/schema.rs index 3a0bce9..15e9e00 100644 --- a/src/routes/schema.rs +++ b/src/routes/schema.rs @@ -112,27 +112,31 @@ pub mod user { use crate::database::models::{User, UserRole}; use actix_macros::ResponderJson; use serde::Serialize; - - /// UserResponse - /// - /// Uses for stripping sensitive fields (password, fcm, etc.) from response. + + /// Используется для скрытия чувствительных полей, таких как хеш пароля или FCM #[derive(Serialize, utoipa::ToSchema, ResponderJson)] #[serde(rename_all = "camelCase")] pub struct UserResponse { + /// UUID #[schema(examples("67dcc9a9507b0000772744a2"))] id: String, + /// Имя пользователя #[schema(examples("n08i40k"))] username: String, + /// Группа #[schema(examples("ИС-214/23"))] group: String, + /// Роль role: UserRole, + /// Идентификатор прявязанного аккаунта VK #[schema(examples(498094647, json!(null)))] vk_id: Option, + /// JWT токен доступа #[schema(examples( "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY" ))] diff --git a/src/test_env.rs b/src/test_env.rs index 0ad4075..0a679a3 100644 --- a/src/test_env.rs +++ b/src/test_env.rs @@ -15,9 +15,10 @@ pub(crate) mod tests { *schedule_lock = Some(Schedule { etag: "".to_string(), + fetched_at: Default::default(), updated_at: Default::default(), parsed_at: Default::default(), - data: test_result(), + data: test_result().unwrap(), }); state.clone() diff --git a/src/utility/error.rs b/src/utility/error.rs index 8ef136c..aac46fb 100644 --- a/src/utility/error.rs +++ b/src/utility/error.rs @@ -2,6 +2,7 @@ use std::fmt::{Write}; use std::fmt::Display; use serde::{Deserialize, Serialize}; +/// Ответ от сервера при ошибках внутри Middleware #[derive(Serialize, Deserialize)] pub struct ResponseErrorMessage { code: T, diff --git a/src/utility/hasher.rs b/src/utility/hasher.rs new file mode 100644 index 0000000..83f73da --- /dev/null +++ b/src/utility/hasher.rs @@ -0,0 +1,38 @@ +use sha1::Digest; +use std::hash::Hasher; + +/// Хешер возвращающий хеш из алгоритма реализующего Digest +pub struct DigestHasher { + digest: D, +} + +impl DigestHasher +where + D: Digest, +{ + /// Получение хеша + pub fn finalize(self) -> String { + hex::encode(self.digest.finalize().0) + } +} + +impl From for DigestHasher +where + D: Digest, +{ + /// Создания хешера из алгоритма реализующего Digest + fn from(digest: D) -> Self { + DigestHasher { digest } + } +} + +impl Hasher for DigestHasher { + /// Заглушка для предотвращения вызова стандартного результата Hasher + fn finish(&self) -> u64 { + unimplemented!("Do not call finish()"); + } + + fn write(&mut self, bytes: &[u8]) { + self.digest.update(bytes); + } +} diff --git a/src/utility/jwt.rs b/src/utility/jwt.rs index e758e06..0e247a5 100644 --- a/src/utility/jwt.rs +++ b/src/utility/jwt.rs @@ -9,22 +9,31 @@ use std::env; use std::mem::discriminant; use std::sync::LazyLock; +/// Ключ для верификации токена static DECODING_KEY: LazyLock = LazyLock::new(|| { let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); DecodingKey::from_secret(secret.as_bytes()) }); +/// Ключ для создания подписанного токена static ENCODING_KEY: LazyLock = LazyLock::new(|| { let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); EncodingKey::from_secret(secret.as_bytes()) }); +/// Ошибки верификации токена +#[allow(dead_code)] #[derive(Debug)] pub enum Error { + /// Токен имеет другую подпись InvalidSignature, + + /// Ошибка чтения токена InvalidToken(ErrorKind), + + /// Токен просрочен Expired, } @@ -34,18 +43,26 @@ impl PartialEq for Error { } } +/// Данные, которые хранит в себе токен #[serde_as] #[derive(Debug, Serialize, Deserialize)] struct Claims { + /// UUID аккаунта пользователя id: String, + + /// Дата создания токена #[serde_as(as = "DisplayFromStr")] iat: u64, + + /// Дата окончания действия токена #[serde_as(as = "DisplayFromStr")] exp: u64, } +/// Алгоритм подписи токенов pub(crate) const DEFAULT_ALGORITHM: Algorithm = Algorithm::HS256; +/// Проверка токена и извлечение из него UUID аккаунта пользователя pub fn verify_and_decode(token: &String) -> Result { let mut validation = Validation::new(DEFAULT_ALGORITHM); @@ -70,6 +87,7 @@ pub fn verify_and_decode(token: &String) -> Result { } } +/// Создание токена пользователя pub fn encode(id: &String) -> String { let header = Header { typ: Some(String::from("JWT")), diff --git a/src/utility/mod.rs b/src/utility/mod.rs index db9586d..22ace8a 100644 --- a/src/utility/mod.rs +++ b/src/utility/mod.rs @@ -1,2 +1,3 @@ pub mod jwt; -pub mod error; \ No newline at end of file +pub mod error; +pub mod hasher; \ No newline at end of file diff --git a/src/xls_downloader/basic_impl.rs b/src/xls_downloader/basic_impl.rs index 9d4b06e..bd15e5f 100644 --- a/src/xls_downloader/basic_impl.rs +++ b/src/xls_downloader/basic_impl.rs @@ -2,7 +2,7 @@ use crate::xls_downloader::interface::{FetchError, FetchOk, FetchResult, XLSDown use chrono::{DateTime, Utc}; pub struct BasicXlsDownloader { - url: Option, + pub url: Option, } 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; if let Ok(_) = result { - Ok(self.url = Some(url)) - } else { - Err(result.err().unwrap()) + self.url = Some(url); } + + result } } diff --git a/src/xls_downloader/interface.rs b/src/xls_downloader/interface.rs index e076cd5..9263880 100644 --- a/src/xls_downloader/interface.rs +++ b/src/xls_downloader/interface.rs @@ -1,22 +1,41 @@ use chrono::{DateTime, Utc}; +/// Ошибки получения данных XLS #[derive(PartialEq, Debug)] pub enum FetchError { + /// Не установлена ссылка на файл NoUrlProvided, + + /// Неизвестная ошибка Unknown, + + /// Сервер вернул статус код отличающийся от 200 BadStatusCode, + + /// Ссылка ведёт на файл другого типа BadContentType, + + /// Сервер не вернул ожидаемые заголовки BadHeaders, } +/// Результат получения данных XLS pub struct FetchOk { + /// ETag объекта pub etag: String, + + /// Дата загрузки файла pub uploaded_at: DateTime, + + /// Дата получения данных pub requested_at: DateTime, + + /// Данные файла pub data: Option>, } impl FetchOk { + /// Результат без контента файла pub fn head(etag: String, uploaded_at: DateTime) -> Self { FetchOk { etag, @@ -26,6 +45,7 @@ impl FetchOk { } } + /// Полный результат pub fn get(etag: String, uploaded_at: DateTime, data: Vec) -> Self { FetchOk { etag, @@ -39,6 +59,9 @@ impl FetchOk { pub type FetchResult = Result; pub trait XLSDownloader { + /// Получение данных о файле, и, опционально, его контент 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; }