This commit is contained in:
Dominic Grimm 2022-12-12 17:54:07 +01:00
commit 2b65b6a1c5
No known key found for this signature in database
GPG key ID: 6F294212DEAAC530
26 changed files with 5911 additions and 0 deletions

25
.drone.yml Normal file
View file

@ -0,0 +1,25 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: ameba
image: veelenga/ameba
commands:
- cd bvplan
- ameba src
- name: build
image: tmaier/docker-compose
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- docker-compose build --build-arg BUILD_ENV=development bvplan
depends_on:
- ameba
volumes:
- name: dockersock
host:
path: /var/run/docker.sock

11
.env.example Normal file
View file

@ -0,0 +1,11 @@
POSTGRES_USER="bvplan"
POSTGRES_PASSWORD="bvplan"
RABBITMQ_USER="bvplan"
RABBITMQ_PASSWORD="bvplan"
BACKEND_UNTIS_API_URL="https://mese.webuntis.com/WebUntis/api/"
BACKEND_UNTIS_RPC_URL="https://mese.webuntis.com/WebUntis/jsonrpc.do"
BACKEND_UNTIS_SCHOOL=
BACKEND_UNTIS_USERNAME=
BACKEND_UNTIS_PASSWORD=

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.env

9
Makefile Normal file
View file

@ -0,0 +1,9 @@
.PHONY: all dev prod
all: prod
dev:
docker-compose build --build-arg BUILD_ENV=development
prod:
docker-compose build

21
backend/.dockerignore Normal file
View file

@ -0,0 +1,21 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
dist/
Dockerfile
.gitignore
.dockerignore
vendor/
.editorconfig

9
backend/.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*.rs]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

3337
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

40
backend/Cargo.toml Normal file
View file

@ -0,0 +1,40 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "api"
[[bin]]
name = "worker"
[profile.release]
codegen-units = 1
lto = "fat"
strip = true
panic = "abort"
[dependencies]
actix-cors = "0.6.4"
actix-web = "4.2.1"
anyhow = { version = "1.0.66", features = ["backtrace"] }
celery = { git = "https://github.com/rusty-celery/rusty-celery.git", branch = "main" }
chrono = { version = "0.4.23", features = ["serde"] }
cookie = "0.16.1"
diesel = { version = "2.0.2", features = ["i-implement-a-third-party-backend-and-opt-into-breaking-changes", "postgres", "chrono", "r2d2"] }
env_logger = "0.10.0"
envconfig = "0.10.0"
juniper = "0.15.10"
juniper_actix = "0.4.0"
lazy_static = "1.4.0"
log = "0.4.17"
now = "0.1.3"
reqwest = { version = "0.11.13", features = ["json"] }
serde = "1.0.148"
serde_json = "1.0.89"
tokio = { version = "1.22.0", features = ["full"] }
url = "2.3.1"
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = "0.5"

31
backend/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM lukemathwalker/cargo-chef:latest-rust-1.65.0 as chef
WORKDIR /usr/src/backend
FROM chef as planner
RUN mkdir src && touch src/main.rs
COPY ./Cargo.toml .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder
RUN cargo install diesel_cli --no-default-features --features postgres
COPY --from=planner /usr/src/backend/recipe.json .
RUN cargo chef cook --release --recipe-path recipe.json
COPY ./src ./src
RUN cargo build --release
FROM debian:buster-slim as runner
RUN apt update
RUN apt install -y libpq5
RUN apt install -y ca-certificates
RUN apt-get clean
RUN apt-get autoremove --yes
RUN rm -rf /var/lib/{apt,dpkg,cache,log}/
WORKDIR /usr/local/bin
COPY --from=builder /usr/local/cargo/bin/diesel .
WORKDIR /usr/src/backend
COPY ./run.sh .
RUN chmod +x ./run.sh
COPY ./migrations ./migrations
COPY --from=builder /usr/src/backend/target/release/api /usr/src/backend/target/release/worker ./bin/
EXPOSE 80
ENTRYPOINT [ "./run.sh" ]

View file

@ -0,0 +1,39 @@
DROP TABLE substitution_query_results;
DROP TABLE substitution_queries;
DROP TABLE substitution_planned_queries;
DROP TABLE substitution_room;
DROP TABLE substitution_subject;
DROP TABLE substitution_teacher;
DROP TABLE substitution_class;
DROP TABLE substitutions;
DROP TYPE substitution_type;
DROP TABLE timegrid_time_unit;
DROP TABLE timegrid_days;
DROP TABLE timegrids;
DROP TABLE holidays;
DROP TABLE departments;
DROP TABLE rooms;
DROP TABLE subjects;
DROP TABLE classes;
DROP TABLE teachers;
DROP TABLE tenants;
DROP TABLE schoolyears;

View file

@ -0,0 +1,199 @@
CREATE TABLE schoolyears(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
name VARCHAR NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE tenants(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
name VARCHAR NOT NULL,
active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE UNIQUE INDEX ON tenants(active)
WHERE
active;
CREATE TABLE teachers(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
name VARCHAR NOT NULL,
forename VARCHAR,
display_name VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE classes(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
name VARCHAR NOT NULL,
long_name VARCHAR NOT NULL,
active BOOLEAN NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE subjects(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
name VARCHAR NOT NULL,
long_name VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE rooms(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
name VARCHAR NOT NULL,
long_name VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE departments(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
name VARCHAR NOT NULL,
long_name VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE holidays(
id SERIAL PRIMARY KEY,
untis_id INTEGER NOT NULL UNIQUE,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
name VARCHAR NOT NULL,
long_name VARCHAR NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE timegrids(
id SERIAL PRIMARY KEY,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE timegrid_days(
id SERIAL PRIMARY KEY,
timegrid_id INTEGER NOT NULL REFERENCES timegrids(id),
day_index SMALLINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE timegrid_time_unit(
id SERIAL PRIMARY KEY,
timegrid_day_id INTEGER NOT NULL REFERENCES timegrid_days(id),
start_time TIME NOT NULL,
end_time TIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TYPE substitution_type AS ENUM (
'cancel',
'subst',
'add',
'shift',
'rmchg',
'rmlk',
'bs',
'oh',
'sb',
'other',
'free',
'exam',
'ac',
'holi',
'stxt'
);
CREATE TABLE substitutions(
id SERIAL PRIMARY KEY,
subst_type substitution_type NOT NULL,
lesson_id INTEGER NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
text VARCHAR,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE substitution_classes(
id SERIAL PRIMARY KEY,
substitution_id INTEGER NOT NULL REFERENCES substitutions(id),
class_id INTEGER NOT NULL REFERENCES classes(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE substitution_teachers(
id SERIAL PRIMARY KEY,
substitution_id INTEGER NOT NULL REFERENCES substitutions(id),
teacher_id INTEGER NOT NULL REFERENCES teachers(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE substitution_subjects(
id SERIAL PRIMARY KEY,
substitution_id INTEGER NOT NULL REFERENCES substitutions(id),
subject_id INTEGER NOT NULL REFERENCES subjects(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE substitution_rooms(
id SERIAL PRIMARY KEY,
substitution_id INTEGER NOT NULL REFERENCES substitutions(id),
room_id INTEGER NOT NULL REFERENCES rooms(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE substitution_planned_queries(
id SERIAL PRIMARY KEY,
schoolyear_id INTEGER NOT NULL REFERENCES schoolyears(id),
date DATE NOT NULL UNIQUE,
active BOOLEAN NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE INDEX ON substitution_planned_queries(active);
CREATE TABLE substitution_queries(
id SERIAL PRIMARY KEY,
substitution_planned_query_id INTEGER NOT NULL REFERENCES substitution_planned_queries(id),
queried_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE TABLE substitution_query_results(
id SERIAL PRIMARY KEY,
substitution_query_id INTEGER NOT NULL REFERENCES substitution_queries(id),
substitution_id INTEGER NOT NULL REFERENCES substitutions(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);

6
backend/run.sh Normal file
View file

@ -0,0 +1,6 @@
#!/bin/bash
DATABASE_URL="$BACKEND_DB_URL" diesel setup \
--migration-dir ./migrations \
--locked-schema &&
RUST_BACKTRACE=full "./bin/$1"

55
backend/src/bin/api.rs Normal file
View file

@ -0,0 +1,55 @@
#[cfg(not(target_env = "msvc"))]
use tikv_jemallocator::Jemalloc;
#[cfg(not(target_env = "msvc"))]
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;
async fn graphql_route(
req: actix_web::HttpRequest,
payload: actix_web::web::Payload,
schema: actix_web::web::Data<backend::graphql::Schema>,
) -> Result<actix_web::HttpResponse, actix_web::Error> {
juniper_actix::graphql_handler(
&schema,
&backend::graphql::Context {
pool: backend::db::POOL.clone(),
},
req,
payload,
)
.await
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "info");
env_logger::init();
let server = actix_web::HttpServer::new(move || {
actix_web::App::new()
.app_data(actix_web::web::Data::new(backend::graphql::schema()))
.wrap(
actix_cors::Cors::default()
.allow_any_origin()
.allowed_methods(vec!["POST", "GET"])
.allowed_headers(vec![
actix_web::http::header::AUTHORIZATION,
actix_web::http::header::ACCEPT,
])
.allowed_header(actix_web::http::header::CONTENT_TYPE)
.supports_credentials()
.max_age(3600),
)
.wrap(actix_web::middleware::Compress::default())
.wrap(actix_web::middleware::Logger::default())
.service(
actix_web::web::resource("/")
.route(actix_web::web::post().to(graphql_route))
.route(actix_web::web::get().to(graphql_route)),
)
});
println!("Starting server on port 80!");
server.bind("0.0.0.0:80")?.run().await
}

37
backend/src/bin/worker.rs Normal file
View file

@ -0,0 +1,37 @@
use std::thread;
#[cfg(not(target_env = "msvc"))]
use tikv_jemallocator::Jemalloc;
#[cfg(not(target_env = "msvc"))]
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;
#[tokio::main]
async fn main() {
std::env::set_var("RUST_LOG", "info");
env_logger::init();
println!("Starting worker!");
backend::worker::init_blocking();
thread::spawn(|| {
tokio::runtime::Runtime::new().unwrap().block_on(async {
backend::worker::beat()
.await
.unwrap()
.start()
.await
.unwrap();
println!("asdnkjndfkjlewnflj");
});
});
let app = backend::worker::APP.lock().unwrap();
let app = app.as_ref().unwrap();
app.display_pretty().await;
app.consume_from(&[backend::worker::QUEUE_NAME])
.await
.unwrap();
}

30
backend/src/config.rs Normal file
View file

@ -0,0 +1,30 @@
use envconfig::Envconfig;
use lazy_static::lazy_static;
#[derive(Envconfig, Debug)]
pub struct Config {
#[envconfig(from = "BACKEND_DB_URL")]
pub db_url: String,
#[envconfig(from = "BACKEND_AMQP_URL")]
pub amqp_url: String,
#[envconfig(from = "BACKEND_UNTIS_API_URL")]
pub untis_api_url: String,
#[envconfig(from = "BACKEND_UNTIS_RPC_URL")]
pub untis_rpc_url: String,
#[envconfig(from = "BACKEND_UNTIS_SCHOOL")]
pub untis_school: String,
#[envconfig(from = "BACKEND_UNTIS_USERNAME")]
pub untis_username: String,
#[envconfig(from = "BACKEND_UNTIS_PASSWORD")]
pub untis_password: String,
}
lazy_static! {
pub static ref CONFIG: Config = Config::init_from_env().unwrap();
}

28
backend/src/db/mod.rs Normal file
View file

@ -0,0 +1,28 @@
use anyhow::Result;
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use lazy_static::lazy_static;
use crate::config;
pub mod models;
pub mod schema;
pub type DbPool = Pool<ConnectionManager<PgConnection>>;
pub fn establish_connection() -> ConnectionResult<PgConnection> {
PgConnection::establish(&config::CONFIG.db_url)
}
pub fn pool() -> Result<DbPool> {
Ok(
Pool::builder().build(ConnectionManager::<PgConnection>::new(
&config::CONFIG.db_url,
))?,
)
}
lazy_static! {
pub static ref POOL: DbPool = pool().unwrap();
}

446
backend/src/db/models.rs Normal file
View file

@ -0,0 +1,446 @@
use chrono::prelude::*;
use diesel::prelude::*;
use std::io::Write;
use crate::db::schema;
use crate::untis;
#[derive(Identifiable, Queryable, Debug)]
#[diesel(table_name = schema::schoolyears)]
pub struct Schoolyear {
pub id: i32,
pub untis_id: i32,
pub name: String,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::schoolyears)]
pub struct NewSchoolyear<'a> {
pub untis_id: i32,
pub name: &'a str,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::tenants)]
#[diesel(belongs_to(Schoolyear))]
pub struct Tenant {
pub id: i32,
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: String,
pub active: bool,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::tenants)]
pub struct NewTenant<'a> {
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: &'a str,
pub active: bool,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::teachers)]
#[diesel(belongs_to(Schoolyear))]
pub struct Teacher {
pub id: i32,
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: String,
pub forename: Option<String>,
pub display_name: String,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::teachers)]
pub struct NewTeacher<'a> {
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: &'a str,
pub forename: Option<&'a str>,
pub display_name: &'a str,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::classes)]
#[diesel(belongs_to(Schoolyear))]
pub struct Class {
pub id: i32,
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: String,
pub long_name: String,
pub active: bool,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::classes)]
pub struct NewClass<'a> {
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: &'a str,
pub long_name: &'a str,
pub active: bool,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::subjects)]
#[diesel(belongs_to(Schoolyear))]
pub struct Subject {
pub id: i32,
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: String,
pub long_name: String,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::subjects)]
pub struct NewSubject<'a> {
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: &'a str,
pub long_name: &'a str,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::rooms)]
#[diesel(belongs_to(Schoolyear))]
pub struct Room {
pub id: i32,
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: String,
pub long_name: String,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::rooms)]
pub struct NewRoom<'a> {
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: &'a str,
pub long_name: &'a str,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::departments)]
#[diesel(belongs_to(Schoolyear))]
pub struct Department {
pub id: i32,
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: String,
pub long_name: String,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::departments)]
pub struct NewDepartment<'a> {
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: &'a str,
pub long_name: &'a str,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::holidays)]
#[diesel(belongs_to(Schoolyear))]
pub struct Holiday {
pub id: i32,
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: String,
pub long_name: String,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::holidays)]
pub struct NewHoliday<'a> {
pub untis_id: i32,
pub schoolyear_id: i32,
pub name: &'a str,
pub long_name: &'a str,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
}
#[derive(diesel::FromSqlRow, diesel::AsExpression, PartialEq, Eq, Debug)]
#[diesel(sql_type = schema::sql_types::SubstitutionType)]
pub enum SubstitutionType {
Cancel,
Substitution,
Additional,
Shifted,
RoomChange,
Locked,
BreakSupervision,
OfficeHour,
Standby,
Other,
Free,
Exam,
Activity,
Holiday,
Text,
}
impl diesel::serialize::ToSql<schema::sql_types::SubstitutionType, diesel::pg::Pg>
for SubstitutionType
{
fn to_sql<'b>(
&'b self,
out: &mut diesel::serialize::Output<'b, '_, diesel::pg::Pg>,
) -> diesel::serialize::Result {
match *self {
Self::Cancel => out.write_all(b"cancel")?,
Self::Substitution => out.write_all(b"subst")?,
Self::Additional => out.write_all(b"add")?,
Self::Shifted => out.write_all(b"shift")?,
Self::RoomChange => out.write_all(b"rmchg")?,
Self::Locked => out.write_all(b"rmlk")?,
Self::BreakSupervision => out.write_all(b"bs")?,
Self::OfficeHour => out.write_all(b"oh")?,
Self::Standby => out.write_all(b"sb")?,
Self::Other => out.write_all(b"other")?,
Self::Free => out.write_all(b"free")?,
Self::Exam => out.write_all(b"exam")?,
Self::Activity => out.write_all(b"ac")?,
Self::Holiday => out.write_all(b"holi")?,
Self::Text => out.write_all(b"stxt")?,
}
Ok(diesel::serialize::IsNull::No)
}
}
impl diesel::deserialize::FromSql<schema::sql_types::SubstitutionType, diesel::pg::Pg>
for SubstitutionType
{
fn from_sql(
bytes: diesel::backend::RawValue<'_, diesel::pg::Pg>,
) -> diesel::deserialize::Result<Self> {
match bytes.as_bytes() {
b"cancel" => Ok(Self::Cancel),
b"subst" => Ok(Self::Substitution),
b"add" => Ok(Self::Additional),
b"shift" => Ok(Self::Shifted),
b"rmchg" => Ok(Self::RoomChange),
b"rmlk" => Ok(Self::Locked),
b"bs" => Ok(Self::BreakSupervision),
b"oh" => Ok(Self::OfficeHour),
b"sb" => Ok(Self::Standby),
b"other" => Ok(Self::Other),
b"free" => Ok(Self::Free),
b"exam" => Ok(Self::Exam),
b"ac" => Ok(Self::Activity),
b"holi" => Ok(Self::Holiday),
b"stxt" => Ok(Self::Text),
_ => Err("Unrecognized enum variant".into()),
}
}
}
impl From<untis::RpcSubstitionType> for SubstitutionType {
fn from(x: untis::RpcSubstitionType) -> Self {
match x {
untis::RpcSubstitionType::Cancel => Self::Cancel,
untis::RpcSubstitionType::Substitution => Self::Substitution,
untis::RpcSubstitionType::Additional => Self::Additional,
untis::RpcSubstitionType::Shifted => Self::Shifted,
untis::RpcSubstitionType::RoomChange => Self::RoomChange,
untis::RpcSubstitionType::Locked => Self::Locked,
untis::RpcSubstitionType::BreakSupervision => Self::BreakSupervision,
untis::RpcSubstitionType::OfficeHour => Self::OfficeHour,
untis::RpcSubstitionType::Standby => Self::Standby,
untis::RpcSubstitionType::Other => Self::Other,
untis::RpcSubstitionType::Free => Self::Free,
untis::RpcSubstitionType::Exam => Self::Exam,
untis::RpcSubstitionType::Activity => Self::Activity,
untis::RpcSubstitionType::Holiday => Self::Holiday,
untis::RpcSubstitionType::Text => Self::Text,
}
}
}
#[derive(Identifiable, Queryable, Debug)]
#[diesel(table_name = schema::substitutions)]
pub struct Substitution {
pub id: i32,
pub substitution_query_id: i32,
pub subst_type: SubstitutionType,
pub lesson_id: i32,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
pub text: Option<String>,
pub active: bool,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitutions)]
pub struct NewSubstitution<'a> {
pub subst_type: SubstitutionType,
pub lesson_id: i32,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
pub text: Option<&'a str>,
pub active: bool,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::substitution_classes)]
#[diesel(belongs_to(Substitution))]
#[diesel(belongs_to(Class))]
pub struct SubstitutionClass {
pub id: i32,
pub substitution_id: i32,
pub class_id: i32,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitution_classes)]
pub struct NewSubstitutionClass {
pub substitution_id: i32,
pub class_id: i32,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::substitution_teachers)]
#[diesel(belongs_to(Substitution))]
#[diesel(belongs_to(Teacher))]
pub struct SubstitutionTeacher {
pub id: i32,
pub substitution_id: i32,
pub teacher_id: i32,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitution_teachers)]
pub struct NewSubstitutionTeacher {
pub substitution_id: i32,
pub teacher_id: i32,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::substitution_subjects)]
#[diesel(belongs_to(Substitution))]
#[diesel(belongs_to(Subject))]
pub struct SubstitutionSubject {
pub id: i32,
pub substitution_id: i32,
pub subject_id: i32,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitution_subjects)]
pub struct NewSubstitutionSubject {
pub substitution_id: i32,
pub subject_id: i32,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::substitution_rooms)]
#[diesel(belongs_to(Substitution))]
#[diesel(belongs_to(Room))]
pub struct SubstitutionRoom {
pub id: i32,
pub substitution_id: i32,
pub room_id: i32,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitution_rooms)]
pub struct NewSubstitutionRoom {
pub substitution_id: i32,
pub room_id: i32,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::substitution_planned_queries)]
#[diesel(belongs_to(Schoolyear))]
pub struct SubstitutionPlannedQuery {
pub id: i32,
pub schoolyear_id: i32,
pub date: NaiveDate,
pub active: bool,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitution_planned_queries)]
pub struct NewSubstitutionPlannedQuery {
pub schoolyear_id: i32,
pub date: NaiveDate,
pub active: bool,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::substitution_queries)]
#[diesel(belongs_to(SubstitutionPlannedQuery))]
pub struct SubstitutionQuery {
pub id: i32,
pub substitution_planned_query_id: i32,
pub queried_at: NaiveDateTime,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitution_queries)]
pub struct NewSubstitutionQuery {
pub substitution_planned_query_id: i32,
pub queried_at: NaiveDateTime,
}
#[derive(Identifiable, Queryable, Associations, Debug)]
#[diesel(table_name = schema::substitution_query_results)]
#[diesel(belongs_to(SubstitutionQuery))]
#[diesel(belongs_to(Substitution))]
pub struct SubstitutionQueryResult {
pub id: i32,
pub substitution_query_id: i32,
pub substitution_id: i32,
pub created_at: NaiveDateTime,
pub updated_at: Option<NaiveDateTime>,
}
#[derive(Insertable, Debug)]
#[diesel(table_name = schema::substitution_query_results)]
pub struct NewSubstitutionQueryResult {
pub substitution_query_id: i32,
pub substitution_id: i32,
}

225
backend/src/db/schema.rs Normal file
View file

@ -0,0 +1,225 @@
pub mod sql_types {
#[derive(diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "substitution_type"))]
pub struct SubstitutionType;
}
diesel::table! {
schoolyears {
id -> Integer,
untis_id -> Integer,
name -> VarChar,
start_date -> Date,
end_date -> Date,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
tenants {
id -> Integer,
untis_id -> Integer,
schoolyear_id -> Integer,
name -> VarChar,
active -> Bool,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
teachers {
id -> Integer,
untis_id -> Integer,
schoolyear_id -> Integer,
name -> VarChar,
forename -> Nullable<VarChar>,
display_name -> VarChar,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
classes {
id -> Integer,
untis_id -> Integer,
schoolyear_id -> Integer,
name -> VarChar,
long_name -> VarChar,
active -> Bool,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
subjects {
id -> Integer,
untis_id -> Integer,
schoolyear_id -> Integer,
name -> VarChar,
long_name -> VarChar,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
rooms {
id -> Integer,
untis_id -> Integer,
schoolyear_id -> Integer,
name -> VarChar,
long_name -> VarChar,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
departments {
id -> Integer,
untis_id -> Integer,
schoolyear_id -> Integer,
name -> VarChar,
long_name -> VarChar,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
holidays {
id -> Integer,
untis_id -> Integer,
schoolyear_id -> Integer,
name -> VarChar,
long_name -> VarChar,
start_date -> Date,
end_date -> Date,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
timegrids {
id -> Integer,
schoolyear_id -> Integer,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
timegrid_days {
id -> Integer,
timegrid_id -> Integer,
#[sql_name = "day_index"]
day -> SmallInt,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
timegrid_time_unit {
id -> Integer,
timegrid_id -> Integer,
start_time -> Time,
end_time -> Time,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::SubstitutionType;
substitutions {
id -> Integer,
subst_type -> SubstitutionType,
lesson_id -> Integer,
start_time -> Time,
end_time -> Time,
text -> Nullable<VarChar>,
active -> Bool,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
substitution_classes {
id -> Integer,
substitution_id -> Integer,
class_id -> Integer,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
substitution_teachers {
id -> Integer,
substitution_id -> Integer,
teacher_id -> Integer,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
substitution_subjects {
id -> Integer,
substitution_id -> Integer,
subject_id -> Integer,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
substitution_rooms {
id -> Integer,
substitution_id -> Integer,
room_id -> Integer,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
substitution_planned_queries {
id -> Integer,
schoolyear_id -> Integer,
date -> Date,
active -> Bool,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
substitution_queries {
id -> Integer,
substitution_planned_query_id -> Integer,
queried_at -> Timestamp,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}
diesel::table! {
substitution_query_results {
id -> Integer,
substitution_query_id -> Integer,
substitution_id -> Integer,
created_at -> Timestamp,
updated_at -> Nullable<Timestamp>,
}
}

75
backend/src/graphql.rs Normal file
View file

@ -0,0 +1,75 @@
use juniper::{graphql_object, EmptyMutation, EmptySubscription, FieldResult, RootNode};
use crate::config;
use crate::db;
#[derive(Clone, Debug)]
pub struct Context {
pub pool: db::DbPool,
}
impl juniper::Context for Context {}
#[derive(Clone, Debug)]
pub struct Config;
#[graphql_object(context = Context)]
impl Config {
fn name(&self) -> &str {
"BVplan"
}
fn school(&self) -> &str {
&config::CONFIG.untis_school
}
fn version(&self) -> &str {
env!("CARGO_PKG_VERSION")
}
}
#[derive(Clone, Debug)]
pub struct Teacher {
pub id: i32,
pub display_name: String,
}
#[graphql_object(context = Context)]
impl Teacher {
fn id(&self) -> i32 {
self.id
}
fn display_name(&self) -> &str {
&self.display_name
}
}
#[derive(Clone, Copy, Debug)]
pub struct Query;
#[graphql_object(context = Context)]
impl Query {
fn ping() -> &str {
"pong"
}
fn config() -> Config {
Config
}
async fn teachers() -> Vec<Teacher> {
vec![]
}
}
pub type Schema = RootNode<'static, Query, EmptyMutation<Context>, EmptySubscription<Context>>;
pub fn schema() -> Schema {
Schema::new(
Query,
EmptyMutation::<Context>::new(),
EmptySubscription::<Context>::new(),
)
}

5
backend/src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod config;
pub mod db;
pub mod graphql;
pub mod untis;
pub mod worker;

717
backend/src/untis.rs Normal file
View file

@ -0,0 +1,717 @@
use anyhow::{bail, Context, Result};
use chrono::Datelike;
use cookie::Cookie;
use serde::Deserialize;
use serde_json::json;
pub const CLIENT_NAME: &str = "BVplan@OHG-Furtwangen";
fn deserialize_date<'de, D>(deserializer: D) -> Result<chrono::NaiveDate, D::Error>
where
D: serde::de::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = chrono::NaiveDate;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a naive date serialized as an unsigned integer")
}
fn visit_u64<E>(self, mut v: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let y = (v / 10000) as i32;
v %= 10000;
let m = (v / 100) as u32;
v %= 100;
match chrono::NaiveDate::from_ymd_opt(y, m, v as u32) {
Some(x) => Ok(x),
None => Err(E::custom(format!("No such date: {}-{}-{}", y, m, v))),
}
}
}
deserializer.deserialize_u64(Visitor)
}
fn serialize_date(date: chrono::NaiveDate) -> u64 {
date.year() as u64 * 10000 + date.month() as u64 * 100 + date.day() as u64
}
fn deserialize_time<'de, D>(deserializer: D) -> Result<chrono::NaiveTime, D::Error>
where
D: serde::de::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = chrono::NaiveTime;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a naive time serialized as an unsigned integer")
}
fn visit_u64<E>(self, mut v: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let h = v / 100;
v %= 100;
match chrono::NaiveTime::from_hms_opt(h as u32, v as u32, 0) {
Some(x) => Ok(x),
None => Err(E::custom(format!("No such time: {}:{}", h, v))),
}
}
}
deserializer.deserialize_u64(Visitor)
}
#[derive(Deserialize, Debug)]
pub struct RpcError {
pub message: String,
pub code: i32,
}
#[derive(Deserialize, Debug)]
pub struct RpcResponse<T> {
pub jsonrpc: String,
pub id: String,
pub result: Option<T>,
pub error: Option<RpcError>,
}
#[derive(Deserialize, Debug)]
struct RpcEmptyResult;
#[derive(Deserialize, Debug)]
pub struct RpcLogin {
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(rename = "personType")]
pub person_type: i32,
#[serde(rename = "personId")]
pub person_id: i32,
#[serde(rename = "klasseId")]
pub class_id: i32,
}
#[derive(Deserialize, Debug)]
pub struct RpcClass {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
pub active: bool,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubject {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
}
#[derive(Deserialize, Debug)]
pub struct RpcRoom {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
}
#[derive(Deserialize, Debug)]
pub struct RpcDepartment {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
}
#[derive(Deserialize, Debug)]
pub struct RpcHoliday {
pub id: i32,
pub name: String,
#[serde(rename = "longName")]
pub long_name: String,
#[serde(rename = "startDate", deserialize_with = "deserialize_date")]
pub start_date: chrono::NaiveDate,
#[serde(rename = "endDate", deserialize_with = "deserialize_date")]
pub end_date: chrono::NaiveDate,
}
#[derive(Deserialize, Debug)]
pub struct RpcTimegridDayTimeUnit {
#[serde(rename = "startTime", deserialize_with = "deserialize_time")]
pub start_time: chrono::NaiveTime,
#[serde(rename = "endTime", deserialize_with = "deserialize_time")]
pub end_time: chrono::NaiveTime,
}
#[derive(Deserialize, Debug)]
pub struct RpcTimegridDay {
pub day: u8,
#[serde(rename = "timeUnits")]
pub time_units: Vec<RpcTimegridDayTimeUnit>,
}
#[derive(Deserialize, Debug)]
pub struct RpcSchoolyear {
pub id: i32,
pub name: String,
#[serde(rename = "startDate", deserialize_with = "deserialize_date")]
pub start_date: chrono::NaiveDate,
#[serde(rename = "endDate", deserialize_with = "deserialize_date")]
pub end_date: chrono::NaiveDate,
}
#[derive(Deserialize, Clone, Copy, Debug)]
pub enum RpcSubstitionType {
#[serde(rename = "cancel")]
Cancel,
#[serde(rename = "subst")]
Substitution,
#[serde(rename = "add")]
Additional,
#[serde(rename = "shift")]
Shifted,
#[serde(rename = "rmchg")]
RoomChange,
#[serde(rename = "rmlk")]
Locked,
#[serde(rename = "bs")]
BreakSupervision,
#[serde(rename = "oh")]
OfficeHour,
#[serde(rename = "sb")]
Standby,
#[serde(rename = "other")]
Other,
#[serde(rename = "free")]
Free,
#[serde(rename = "exam")]
Exam,
#[serde(rename = "ac")]
Activity,
#[serde(rename = "holi")]
Holiday,
#[serde(rename = "stxt")]
Text,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubstitutionId {
pub id: i32,
pub name: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubstitutionReschedule {
#[serde(deserialize_with = "deserialize_date")]
pub date: chrono::NaiveDate,
#[serde(rename = "startTime", deserialize_with = "deserialize_time")]
pub start_time: chrono::NaiveTime,
#[serde(rename = "endTime", deserialize_with = "deserialize_time")]
pub end_time: chrono::NaiveTime,
}
#[derive(Deserialize, Debug)]
pub struct RpcSubstitution {
#[serde(rename = "type")]
pub subst_type: RpcSubstitionType,
#[serde(rename = "lsid")]
pub lesson_id: i32,
#[serde(rename = "startTime", deserialize_with = "deserialize_time")]
pub start_time: chrono::NaiveTime,
#[serde(rename = "endTime", deserialize_with = "deserialize_time")]
pub end_time: chrono::NaiveTime,
#[serde(rename = "txt")]
pub text: Option<String>,
#[serde(rename = "kl")]
pub classes: Vec<RpcSubstitutionId>,
#[serde(rename = "te")]
pub teachers: Vec<RpcSubstitutionId>,
#[serde(rename = "su")]
pub subjects: Vec<RpcSubstitutionId>,
#[serde(rename = "ro")]
pub rooms: Vec<RpcSubstitutionId>,
pub reschedule: Option<RpcSubstitutionReschedule>,
}
#[derive(Deserialize, Debug)]
pub struct ApiTenant {
pub id: i32,
#[serde(rename = "displayName")]
pub display_name: String,
}
#[derive(Deserialize, Debug)]
struct ApiDataResponse {
tenant: ApiTenant,
}
#[derive(Deserialize, Debug)]
pub struct ApiTeacher {
pub id: i32,
pub name: String,
pub forename: String,
#[serde(rename = "longName")]
pub long_name: String,
#[serde(rename = "displayname")]
pub display_name: String,
}
#[derive(Deserialize, Debug)]
struct ApiTeachersResponseData {
elements: Vec<ApiTeacher>,
}
#[derive(Deserialize, Debug)]
struct ApiTeachersResponse {
data: ApiTeachersResponseData,
}
#[derive(Debug)]
pub struct Client {
pub api_url: url::Url,
pub rpc_url: url::Url,
pub username: String,
pub password: String,
pub session: Option<String>,
pub authorization: Option<String>,
}
impl Client {
pub fn gen_rpc_url(mut endpoint: url::Url, school: &str) -> url::Url {
endpoint.query_pairs_mut().append_pair("school", school);
endpoint
}
pub async fn login_rpc(&mut self) -> Result<RpcLogin> {
let resp: RpcResponse<RpcLogin> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.json(&json!({
"id": "ID",
"method": "authenticate",
"params": {
"user": self.username,
"password": self.password,
"client": CLIENT_NAME,
},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
self.session = Some(
Cookie::new(
"JSESSIONID",
&resp
.result
.as_ref()
.context("Result null event though error not")?
.session_id,
)
.to_string(),
);
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn login_api(&mut self) -> Result<String> {
let jwt = reqwest::Client::new()
.get(self.api_url.join("token/new")?)
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.send()
.await?
.text()
.await?;
self.authorization = Some(jwt.to_string());
Ok(jwt)
}
pub async fn login(&mut self) -> Result<()> {
self.login_rpc().await?;
self.login_api().await?;
Ok(())
}
pub async fn logout(&mut self) -> Result<()> {
let resp: RpcResponse<RpcEmptyResult> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "logout",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
self.session = None;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
Ok(())
}
pub async fn classes(&self) -> Result<Vec<RpcClass>> {
let resp: RpcResponse<Vec<RpcClass>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getKlassen",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn subjects(&self) -> Result<Vec<RpcSubject>> {
let resp: RpcResponse<Vec<RpcSubject>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getSubjects",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn rooms(&self) -> Result<Vec<RpcRoom>> {
let resp: RpcResponse<Vec<RpcRoom>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getRooms",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn departments(&self) -> Result<Vec<RpcDepartment>> {
let resp: RpcResponse<Vec<RpcDepartment>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getDepartments",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn holidays(&self) -> Result<Vec<RpcHoliday>> {
let resp: RpcResponse<Vec<RpcHoliday>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getHolidays",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn timegrid(&self) -> Result<Vec<RpcTimegridDay>> {
let resp: RpcResponse<Vec<RpcTimegridDay>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getTimegridUnits",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn current_schoolyear(&self) -> Result<RpcSchoolyear> {
let resp: RpcResponse<RpcSchoolyear> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getCurrentSchoolyear",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn schoolyears(&self) -> Result<Vec<RpcSchoolyear>> {
let resp: RpcResponse<Vec<RpcSchoolyear>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getSchoolyears",
"params": {},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub async fn substitutions(
&self,
from: &chrono::NaiveDate,
to: &chrono::NaiveDate,
department: Option<i32>,
) -> Result<Vec<RpcSubstitution>> {
let resp: RpcResponse<Vec<RpcSubstitution>> = reqwest::Client::new()
.get(self.rpc_url.as_str())
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::ACCEPT, "application/json-rpc")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.json(&json!({
"id": "ID",
"method": "getSubstitutions",
"params": {
"startDate": serialize_date(*from),
"endDate": serialize_date(*to),
"departmentId": department.map_or(0, |d| d)
},
"jsonrpc": "2.0"
}))
.send()
.await?
.json()
.await?;
if let Some(e) = resp.error {
bail!("RPC error: {:?}", e);
}
if let Some(x) = resp.result {
Ok(x)
} else {
bail!("RPC result is null");
}
}
pub fn construct_bearer(auth: &str) -> String {
format!("Bearer {}", auth)
}
pub async fn current_tenant(&self) -> Result<ApiTenant> {
let resp: ApiDataResponse = reqwest::Client::new()
.get(self.api_url.join("rest/view/v1/app/data")?)
.header(reqwest::header::ACCEPT, "application/json")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.header(
reqwest::header::AUTHORIZATION,
Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?),
)
.send()
.await?
.json()
.await?;
Ok(resp.tenant)
}
pub async fn teachers(&self) -> Result<Vec<ApiTeacher>> {
let mut url = self.api_url.join("public/timetable/weekly/pageconfig")?;
url.query_pairs_mut().append_pair("type", "2");
let resp: ApiTeachersResponse = reqwest::Client::new()
.get(url)
.header(reqwest::header::ACCEPT, "application/json")
.header(
reqwest::header::COOKIE,
self.session.as_ref().context("Not logged in")?,
)
.header(
reqwest::header::AUTHORIZATION,
Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?),
)
.send()
.await?
.json()
.await?;
Ok(resp.data.elements)
}
pub async fn exams(&self) -> Result<()> {
Ok(())
}
}

76
backend/src/worker/mod.rs Normal file
View file

@ -0,0 +1,76 @@
use celery::beat::DeltaSchedule;
use celery::broker::AMQPBroker;
use lazy_static::lazy_static;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time;
use crate::config;
pub mod update_info;
pub const QUEUE_NAME: &str = "celery";
lazy_static! {
pub static ref APP: Mutex<Option<Arc<celery::Celery<AMQPBroker>>>> = Mutex::new(None);
}
#[tokio::main]
pub async fn init() {
*APP.lock().unwrap() = Some(
celery::app!(
broker = AMQPBroker { &config::CONFIG.amqp_url },
tasks = [
update_info::update_info,
],
task_routes = [
"*" => QUEUE_NAME,
],
prefetch_count = 2,
heartbeat = Some(10)
)
.await
.unwrap(),
);
}
pub fn init_blocking() {
thread::spawn(|| {
tokio::runtime::Runtime::new().unwrap().spawn_blocking(init);
});
let dur = time::Duration::from_secs(1);
let mut i = 0;
loop {
if APP.lock().unwrap().is_some() {
break;
}
thread::sleep(dur);
i += 1;
if i >= 10 {
panic!("Worker not initialized after 10 seconds!");
}
}
}
pub fn beat() -> impl std::future::Future<
Output = Result<
celery::beat::Beat<AMQPBroker, celery::beat::LocalSchedulerBackend>,
celery::error::BeatError,
>,
> {
celery::beat!(
broker = AMQPBroker { &config::CONFIG.amqp_url },
tasks = [
"update_info_" => {
update_info::update_info,
schedule = DeltaSchedule::new(time::Duration::from_secs(60)),
args = (),
}
],
task_routes = [
"*" => QUEUE_NAME,
]
)
}

View file

@ -0,0 +1,356 @@
use anyhow::Result;
use celery::error::TaskError;
use celery::task::TaskResult;
use chrono::prelude::*;
use diesel::prelude::*;
use now::DateTimeNow;
use crate::config;
use crate::db;
use crate::untis;
async fn fetch_schoolyears(client: &untis::Client, conn: &mut PgConnection) -> Result<i32> {
let existing_schoolyears = db::schema::schoolyears::table
.select(db::schema::schoolyears::untis_id)
.load::<i32>(conn)?;
diesel::insert_into(db::schema::schoolyears::table)
.values(
&client
.schoolyears()
.await?
.iter()
.filter(|y| !existing_schoolyears.contains(&y.id))
.map(|y| db::models::NewSchoolyear {
untis_id: y.id,
name: &y.name,
start_date: y.start_date,
end_date: y.end_date,
})
.collect::<Vec<db::models::NewSchoolyear>>(),
)
.execute(conn)?;
Ok(db::schema::schoolyears::table
.filter(db::schema::schoolyears::untis_id.eq(client.current_schoolyear().await?.id))
.select(db::schema::schoolyears::id)
.first(conn)?)
}
async fn fetch_current_tenant(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let tenant = client.current_tenant().await?;
if diesel::select(diesel::dsl::not(diesel::expression::exists::exists(
db::schema::tenants::table.filter(db::schema::tenants::untis_id.eq(tenant.id)),
)))
.get_result::<bool>(conn)?
{
diesel::update(db::schema::tenants::table)
.filter(db::schema::tenants::active)
.set(db::schema::tenants::active.eq(false))
.execute(conn)?;
diesel::insert_into(db::schema::tenants::table)
.values(db::models::NewTenant {
untis_id: tenant.id,
schoolyear_id,
name: &tenant.display_name,
active: true,
})
.execute(conn)?;
} else if diesel::select(diesel::expression::exists::exists(
db::schema::tenants::table
.filter(db::schema::tenants::untis_id.eq(tenant.id))
.filter(db::schema::tenants::active.eq(false)),
))
.get_result::<bool>(conn)?
{
diesel::update(db::schema::tenants::table)
.filter(db::schema::tenants::active)
.set((
db::schema::tenants::active.eq(false),
db::schema::tenants::updated_at.eq(diesel::dsl::now),
))
.execute(conn)?;
diesel::update(db::schema::tenants::table)
.filter(db::schema::tenants::untis_id.eq(tenant.id))
.set((
db::schema::tenants::active.eq(true),
db::schema::tenants::updated_at.eq(diesel::dsl::now),
))
.execute(conn)?;
}
Ok(())
}
async fn fetch_teachers(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let existing_teachers = db::schema::teachers::table
.select(db::schema::teachers::untis_id)
.filter(db::schema::teachers::schoolyear_id.eq(schoolyear_id))
.load::<i32>(conn)?;
diesel::insert_into(db::schema::teachers::table)
.values(
&client
.teachers()
.await?
.iter()
.filter(|t| !existing_teachers.contains(&t.id))
.map(|t| db::models::NewTeacher {
untis_id: t.id,
schoolyear_id,
name: &t.name,
forename: if t.forename.is_empty() {
None
} else {
Some(&t.forename)
},
display_name: &t.display_name,
})
.collect::<Vec<db::models::NewTeacher>>(),
)
.execute(conn)?;
Ok(())
}
async fn fetch_classes(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let existing_classes = db::schema::classes::table
.select(db::schema::classes::untis_id)
.filter(db::schema::classes::schoolyear_id.eq(schoolyear_id))
.load::<i32>(conn)?;
diesel::insert_into(db::schema::classes::table)
.values(
&client
.classes()
.await?
.iter()
.filter(|c| !existing_classes.contains(&c.id))
.map(|c| db::models::NewClass {
untis_id: c.id,
schoolyear_id,
name: &c.name,
long_name: &c.long_name,
active: c.active,
})
.collect::<Vec<db::models::NewClass>>(),
)
.execute(conn)?;
Ok(())
}
async fn fetch_subjects(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let existing_classes = db::schema::subjects::table
.select(db::schema::subjects::untis_id)
.filter(db::schema::subjects::schoolyear_id.eq(schoolyear_id))
.load::<i32>(conn)?;
diesel::insert_into(db::schema::subjects::table)
.values(
&client
.subjects()
.await?
.iter()
.filter(|c| !existing_classes.contains(&c.id))
.map(|c| db::models::NewSubject {
untis_id: c.id,
schoolyear_id,
name: &c.name,
long_name: &c.long_name,
})
.collect::<Vec<db::models::NewSubject>>(),
)
.execute(conn)?;
Ok(())
}
async fn fetch_rooms(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let existing_classes = db::schema::rooms::table
.select(db::schema::rooms::untis_id)
.filter(db::schema::rooms::schoolyear_id.eq(schoolyear_id))
.load::<i32>(conn)?;
diesel::insert_into(db::schema::rooms::table)
.values(
&client
.rooms()
.await?
.iter()
.filter(|c| !existing_classes.contains(&c.id))
.map(|c| db::models::NewRoom {
untis_id: c.id,
schoolyear_id,
name: &c.name,
long_name: &c.long_name,
})
.collect::<Vec<db::models::NewRoom>>(),
)
.execute(conn)?;
Ok(())
}
async fn fetch_departments(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let existing_classes = db::schema::departments::table
.select(db::schema::departments::untis_id)
.filter(db::schema::departments::schoolyear_id.eq(schoolyear_id))
.load::<i32>(conn)?;
diesel::insert_into(db::schema::departments::table)
.values(
&client
.departments()
.await?
.iter()
.filter(|c| !existing_classes.contains(&c.id))
.map(|c| db::models::NewDepartment {
untis_id: c.id,
schoolyear_id,
name: &c.name,
long_name: &c.long_name,
})
.collect::<Vec<db::models::NewDepartment>>(),
)
.execute(conn)?;
Ok(())
}
async fn fetch_holidays(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let existing_classes = db::schema::holidays::table
.select(db::schema::holidays::untis_id)
.filter(db::schema::holidays::schoolyear_id.eq(schoolyear_id))
.load::<i32>(conn)?;
diesel::insert_into(db::schema::holidays::table)
.values(
&client
.holidays()
.await?
.iter()
.filter(|c| !existing_classes.contains(&c.id))
.map(|c| db::models::NewHoliday {
untis_id: c.id,
schoolyear_id,
name: &c.name,
long_name: &c.long_name,
start_date: c.start_date,
end_date: c.end_date,
})
.collect::<Vec<db::models::NewHoliday>>(),
)
.execute(conn)?;
Ok(())
}
async fn fetch_substitutions(
client: &untis::Client,
conn: &mut PgConnection,
schoolyear_id: i32,
) -> Result<()> {
let now = Utc::now();
let beginning_of_week = now.beginning_of_week().date_naive();
let end_of_week = now.end_of_week().date_naive();
// if diesel::select(diesel::dsl::not(diesel::expression::exists::exists(
// db::schema::substitution_queries::table
// .filter(db::schema::substitution_queries::active)
// .filter(db::schema::substitution_queries::end_date.eq(end_of_week)),
// )))
// .get_result::<bool>(conn)?
// {}
// for query in db::schema::substitution_queries::table
// .filter(db::schema::substitution_queries::active)
// .load::<db::models::SubstitutionQuery>(conn)?
// {
// dbg!(&query);
// }
Ok(())
}
#[celery::task]
pub async fn update_info() -> TaskResult<()> {
let mut client = untis::Client {
api_url: match url::Url::parse(&config::CONFIG.untis_api_url) {
Ok(x) => x,
Err(e) => return Err(TaskError::UnexpectedError(e.to_string())),
},
rpc_url: match url::Url::parse(&config::CONFIG.untis_rpc_url) {
Ok(x) => untis::Client::gen_rpc_url(x, &config::CONFIG.untis_school),
Err(e) => return Err(TaskError::UnexpectedError(e.to_string())),
},
username: config::CONFIG.untis_username.to_owned(),
password: config::CONFIG.untis_password.to_owned(),
session: None,
authorization: None,
};
if let Err(e) = client.login().await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
let conn = &mut match db::POOL.get() {
Ok(x) => x,
Err(e) => return Err(TaskError::UnexpectedError(e.to_string())),
};
let schoolyear_id = match fetch_schoolyears(&client, conn).await {
Ok(x) => x,
Err(e) => return Err(TaskError::UnexpectedError(e.to_string())),
};
if let Err(e) = fetch_current_tenant(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = fetch_teachers(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = fetch_classes(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = fetch_subjects(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = fetch_rooms(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = fetch_departments(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = fetch_holidays(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = fetch_substitutions(&client, conn, schoolyear_id).await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
if let Err(e) = client.logout().await {
return Err(TaskError::UnexpectedError(e.to_string()));
}
Ok(())
}

51
config/nginx/nginx.conf Normal file
View file

@ -0,0 +1,51 @@
events {
worker_connections 1024;
}
http {
server_tokens off;
more_clear_headers Server;
server {
# location / {
# proxy_pass http://frontend/;
# }
location /graphql {
proxy_pass http://api/;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /adminer {
proxy_pass http://adminer:8080/;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* /rabbitmq/api/(.*?)/(.*) {
proxy_pass http://rabbitmq:15672/api/$1/%2F/$2?$query_string;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* /rabbitmq/(.*) {
rewrite ^/rabbitmq/(.*)$ /$1 break;
proxy_pass http://rabbitmq:15672;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

81
docker-compose.yml Normal file
View file

@ -0,0 +1,81 @@
version: "3"
services:
nginx:
image: docker.io/byjg/nginx-extras
restart: always
volumes:
- ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- 80:80
depends_on:
- adminer
- rabbitmq
- api
postgres:
image: docker.io/postgres:alpine
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres:/var/lib/postgresql/data
adminer:
image: docker.io/adminer:standalone
restart: always
depends_on:
- postgres
rabbitmq:
image: docker.io/rabbitmq:3-management-alpine
restart: always
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD}
volumes:
- rabbitmq:/var/lib/rabbitmq
worker:
image: git.dergrimm.net/dergrimm/bvplan_backend:latest
build:
context: ./backend
restart: always
command: worker
depends_on:
- postgres
- rabbitmq
environment:
BACKEND_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_USER}
BACKEND_AMQP_URL: amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
BACKEND_UNTIS_API_URL: ${BACKEND_UNTIS_API_URL}
BACKEND_UNTIS_RPC_URL: ${BACKEND_UNTIS_RPC_URL}
BACKEND_UNTIS_SCHOOL: ${BACKEND_UNTIS_SCHOOL}
BACKEND_UNTIS_USERNAME: ${BACKEND_UNTIS_USERNAME}
BACKEND_UNTIS_PASSWORD: ${BACKEND_UNTIS_PASSWORD}
api:
image: git.dergrimm.net/dergrimm/bvplan_backend:latest
build:
context: ./backend
restart: always
command: api
depends_on:
- postgres
- rabbitmq
- worker
environment:
BACKEND_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_USER}
BACKEND_AMQP_URL: amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@rabbitmq:5672
BACKEND_UNTIS_API_URL: ${BACKEND_UNTIS_API_URL}
BACKEND_UNTIS_RPC_URL: ${BACKEND_UNTIS_RPC_URL}
BACKEND_UNTIS_SCHOOL: ${BACKEND_UNTIS_SCHOOL}
BACKEND_UNTIS_USERNAME: ${BACKEND_UNTIS_USERNAME}
BACKEND_UNTIS_PASSWORD: ${BACKEND_UNTIS_PASSWORD}
volumes:
postgres:
rabbitmq: