edupage-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +250 -0
- data/exe/edupage +7 -0
- data/lib/edupage/account.rb +121 -0
- data/lib/edupage/cache.rb +101 -0
- data/lib/edupage/cli/formatter.rb +56 -0
- data/lib/edupage/cli/table.rb +136 -0
- data/lib/edupage/cli.rb +543 -0
- data/lib/edupage/client.rb +152 -0
- data/lib/edupage/config.rb +96 -0
- data/lib/edupage/credentials/env.rb +27 -0
- data/lib/edupage/credentials/keychain.rb +120 -0
- data/lib/edupage/credentials.rb +118 -0
- data/lib/edupage/errors.rb +74 -0
- data/lib/edupage/model.rb +131 -0
- data/lib/edupage/models/assignment.rb +83 -0
- data/lib/edupage/models/classroom.rb +13 -0
- data/lib/edupage/models/day.rb +51 -0
- data/lib/edupage/models/grade.rb +100 -0
- data/lib/edupage/models/lesson.rb +58 -0
- data/lib/edupage/models/parent.rb +8 -0
- data/lib/edupage/models/period.rb +13 -0
- data/lib/edupage/models/person.rb +25 -0
- data/lib/edupage/models/school_class.rb +16 -0
- data/lib/edupage/models/student.rb +45 -0
- data/lib/edupage/models/subject.rb +9 -0
- data/lib/edupage/models/teacher.rb +25 -0
- data/lib/edupage/models/term.rb +36 -0
- data/lib/edupage/models/timeline_item.rb +87 -0
- data/lib/edupage/parsers/base.rb +150 -0
- data/lib/edupage/parsers/gcall.rb +53 -0
- data/lib/edupage/parsers/timeline.rb +46 -0
- data/lib/edupage/parsers/userhome.rb +84 -0
- data/lib/edupage/parsers/znamky.rb +83 -0
- data/lib/edupage/registry/resources.rb +175 -0
- data/lib/edupage/registry.rb +286 -0
- data/lib/edupage/relation.rb +185 -0
- data/lib/edupage/school.rb +358 -0
- data/lib/edupage/serializer.rb +53 -0
- data/lib/edupage/server/api.rb +113 -0
- data/lib/edupage/server/auth.rb +45 -0
- data/lib/edupage/server/mcp.rb +107 -0
- data/lib/edupage/server.rb +57 -0
- data/lib/edupage/session.rb +166 -0
- data/lib/edupage/session_store.rb +131 -0
- data/lib/edupage/version.rb +3 -0
- data/lib/edupage/year.rb +77 -0
- data/lib/edupage.rb +64 -0
- metadata +201 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "sinatra/base"
|
|
3
|
+
|
|
4
|
+
module Edupage
|
|
5
|
+
module Server
|
|
6
|
+
# REST API and MCP endpoint.
|
|
7
|
+
#
|
|
8
|
+
# Routes are generated from Registry: the path comes from the resource's scope and
|
|
9
|
+
# the query parameters from its declared filters, so a resource reachable from the
|
|
10
|
+
# CLI is reachable here with the same name and the same options. Everything is GET;
|
|
11
|
+
# this tool never writes to Edupage.
|
|
12
|
+
class API < Sinatra::Base
|
|
13
|
+
configure do
|
|
14
|
+
set :show_exceptions, false
|
|
15
|
+
set :raise_errors, false
|
|
16
|
+
set :logging, false
|
|
17
|
+
# Sinatra 4 rejects unexpected Host headers by default. What actually limits
|
|
18
|
+
# exposure here is the bind address (loopback unless asked otherwise) plus the
|
|
19
|
+
# bearer token, and the default list breaks reverse proxies and test clients
|
|
20
|
+
# for no gain, so host checking is left off deliberately.
|
|
21
|
+
set :host_authorization, { permitted_hosts: [] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
attr_accessor :account_options, :mcp_server
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
before do
|
|
29
|
+
content_type :json
|
|
30
|
+
next if request.path_info == "/healthz"
|
|
31
|
+
|
|
32
|
+
halt(401, error_body("Unauthorized: send Authorization: Bearer <token>")) unless Auth.authorized?(request)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
get "/healthz" do
|
|
36
|
+
content_type :json
|
|
37
|
+
JSON.generate(status: "ok", version: VERSION)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One route per resource, with the path shape dictated by the scope.
|
|
41
|
+
Registry.each do |resource|
|
|
42
|
+
get resource.rest_path do
|
|
43
|
+
records = resolve(resource)
|
|
44
|
+
payload = Serializer.call(records)
|
|
45
|
+
|
|
46
|
+
JSON.pretty_generate(
|
|
47
|
+
data: payload,
|
|
48
|
+
meta: {
|
|
49
|
+
resource: resource.name,
|
|
50
|
+
count: payload.is_a?(Array) ? payload.size : 1
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# MCP over HTTP, so `edupage server` covers both surfaces.
|
|
57
|
+
%i[get post delete].each do |verb|
|
|
58
|
+
public_send(verb, "/mcp") do
|
|
59
|
+
transport = self.class.mcp_server&.http_transport
|
|
60
|
+
halt(503, error_body("MCP is not enabled on this server")) unless transport
|
|
61
|
+
|
|
62
|
+
status, headers, body = transport.handle_request(request)
|
|
63
|
+
headers.each { |key, value| response.headers[key] = value }
|
|
64
|
+
halt(status, Array(body).join)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Error blocks set the status and return the body; halting from inside one
|
|
69
|
+
# bypasses Sinatra's own response handling and yields the wrong status.
|
|
70
|
+
# A level of the chain was left unspecified. The path normally makes that
|
|
71
|
+
# impossible, but the answer still has to say what could have been chosen.
|
|
72
|
+
error AmbiguousScopeError do
|
|
73
|
+
error = env["sinatra.error"]
|
|
74
|
+
status 400
|
|
75
|
+
JSON.generate(error: error.message, level: error.level, candidates: error.candidates)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
error NotFoundError do
|
|
79
|
+
status 404
|
|
80
|
+
error_body(env["sinatra.error"].message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
error MissingCredentialsError, LoginError, SessionExpiredError do
|
|
84
|
+
status 401
|
|
85
|
+
error_body(env["sinatra.error"].message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
error Edupage::Error do
|
|
89
|
+
status 500
|
|
90
|
+
error_body(env["sinatra.error"].message)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def resolve(resource)
|
|
96
|
+
merged = self.class.account_options.to_h.merge(symbolize(params))
|
|
97
|
+
context = Registry::Context.new(
|
|
98
|
+
account: Edupage.account(username: merged[:username], school: merged[:school]),
|
|
99
|
+
school: merged[:school], student: merged[:student], year: merged[:year]
|
|
100
|
+
)
|
|
101
|
+
resource.call(context, merged)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def symbolize(hash)
|
|
105
|
+
hash.to_h { |key, value| [key.to_sym, value] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def error_body(message)
|
|
109
|
+
JSON.generate(error: message)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Server
|
|
3
|
+
# Bearer-token check for the HTTP surfaces.
|
|
4
|
+
#
|
|
5
|
+
# The server hands out a child's school record, so it binds to localhost and
|
|
6
|
+
# requires a token by default. Binding anywhere else has to be asked for explicitly.
|
|
7
|
+
module Auth
|
|
8
|
+
LOOPBACK = %w[127.0.0.1 ::1 localhost].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def token = Edupage.config.server_token
|
|
13
|
+
|
|
14
|
+
def loopback?(host) = LOOPBACK.include?(host.to_s)
|
|
15
|
+
|
|
16
|
+
# Refuses to expose the account to a network without a deliberate choice.
|
|
17
|
+
def check_binding!(host)
|
|
18
|
+
return if loopback?(host)
|
|
19
|
+
|
|
20
|
+
Edupage.logger.warn(
|
|
21
|
+
"Binding to #{host} makes this account reachable from the network; a token is required."
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def authorized?(request)
|
|
26
|
+
presented = bearer(request) || request.params["token"]
|
|
27
|
+
return false if presented.nil? || presented.empty?
|
|
28
|
+
|
|
29
|
+
secure_compare(presented, token)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def bearer(request)
|
|
33
|
+
header = request.env["HTTP_AUTHORIZATION"].to_s
|
|
34
|
+
header[/\ABearer\s+(.+)\z/i, 1]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Constant-time comparison; a token check that returns early leaks its prefix.
|
|
38
|
+
def secure_compare(left, right)
|
|
39
|
+
return false unless left.bytesize == right.bytesize
|
|
40
|
+
|
|
41
|
+
left.bytes.zip(right.bytes).reduce(0) { |result, (a, b)| result | (a ^ b) }.zero?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "mcp"
|
|
2
|
+
|
|
3
|
+
module Edupage
|
|
4
|
+
module Server
|
|
5
|
+
# MCP server exposing the same resources as the CLI and the REST API.
|
|
6
|
+
#
|
|
7
|
+
# Tools are generated from Registry, so the tool list, its arguments and their types
|
|
8
|
+
# cannot diverge from the other surfaces. Every tool is read-only and says so, which
|
|
9
|
+
# lets clients skip approval prompts.
|
|
10
|
+
class MCP
|
|
11
|
+
SERVER_NAME = "edupage".freeze
|
|
12
|
+
|
|
13
|
+
INSTRUCTIONS = <<~TEXT.freeze
|
|
14
|
+
Read-only access to an Edupage school account: schools, students, timetables,
|
|
15
|
+
homework, and grades.
|
|
16
|
+
|
|
17
|
+
Most data belongs to one student and one school year. Call edupage_students to
|
|
18
|
+
find the students, and edupage_years to find which years hold data - in early
|
|
19
|
+
autumn the current year is usually empty and the interesting grades are in the
|
|
20
|
+
previous one. Pass `student` as a name or id and `year` as e.g. 2025.
|
|
21
|
+
TEXT
|
|
22
|
+
|
|
23
|
+
def initialize(account_options: {})
|
|
24
|
+
@account_options = account_options
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def server
|
|
28
|
+
@server ||= ::MCP::Server.new(
|
|
29
|
+
name: SERVER_NAME,
|
|
30
|
+
title: "Edupage",
|
|
31
|
+
version: VERSION,
|
|
32
|
+
instructions: INSTRUCTIONS,
|
|
33
|
+
tools: tools
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Blocks, serving MCP on stdin/stdout for editors and desktop clients.
|
|
38
|
+
def run_stdio
|
|
39
|
+
::MCP::Server::Transports::StdioTransport.new(server).open
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Rack entry point for the /mcp endpoint of `edupage server`.
|
|
43
|
+
def http_transport
|
|
44
|
+
@http_transport ||= ::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
45
|
+
server,
|
|
46
|
+
stateless: true,
|
|
47
|
+
enable_json_response: true
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def handle_http(request) = http_transport.handle_request(request)
|
|
52
|
+
|
|
53
|
+
def tools
|
|
54
|
+
Registry.all.map { |resource| build_tool(resource) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_tool(resource)
|
|
60
|
+
options = @account_options
|
|
61
|
+
|
|
62
|
+
::MCP::Tool.define(
|
|
63
|
+
name: resource.tool_name,
|
|
64
|
+
title: resource.name.to_s.tr("_", " ").capitalize,
|
|
65
|
+
description: describe(resource),
|
|
66
|
+
input_schema: input_schema(resource),
|
|
67
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
68
|
+
) do |**arguments|
|
|
69
|
+
MCP.respond(resource, arguments, options)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Runs a resource and wraps the result the way MCP expects. Kept as a module
|
|
74
|
+
# method so the tool blocks stay tiny and testable.
|
|
75
|
+
def self.respond(resource, arguments, account_options)
|
|
76
|
+
params = account_options.merge(arguments.transform_keys(&:to_sym))
|
|
77
|
+
context = Registry::Context.new(
|
|
78
|
+
account: Edupage.account(username: params[:username], school: params[:school]),
|
|
79
|
+
school: params[:school], student: params[:student], year: params[:year]
|
|
80
|
+
)
|
|
81
|
+
payload = Serializer.call(resource.call(context, params))
|
|
82
|
+
|
|
83
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }])
|
|
84
|
+
rescue Edupage::Error => e
|
|
85
|
+
::MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }], error: true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def describe(resource)
|
|
89
|
+
scope_note =
|
|
90
|
+
case resource.scope
|
|
91
|
+
when :year then " Needs a student; year defaults to the current one."
|
|
92
|
+
when :student then " Needs a student."
|
|
93
|
+
else ""
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
"#{resource.summary}.#{scope_note}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def input_schema(resource)
|
|
100
|
+
properties = resource.all_params.to_h { |param| [param.name.to_s, param.json_schema] }
|
|
101
|
+
required = resource.all_params.select(&:required).map { |p| p.name.to_s }
|
|
102
|
+
|
|
103
|
+
{ type: "object", properties: properties, required: required }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# `edupage server`: the REST API and the MCP endpoint in one process.
|
|
3
|
+
module Server
|
|
4
|
+
DEFAULT_HOST = "127.0.0.1".freeze
|
|
5
|
+
DEFAULT_PORT = 4567
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def start(host: nil, port: nil, token: nil, account_options: {})
|
|
10
|
+
require "puma"
|
|
11
|
+
require "rack"
|
|
12
|
+
|
|
13
|
+
host ||= Edupage.config.server["host"] || DEFAULT_HOST
|
|
14
|
+
port = (port || Edupage.config.server["port"] || DEFAULT_PORT).to_i
|
|
15
|
+
|
|
16
|
+
if token
|
|
17
|
+
Edupage.config["server"] = Edupage.config.server.merge("token" => token)
|
|
18
|
+
Edupage.config.save
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Auth.check_binding!(host)
|
|
22
|
+
resolved_token = Auth.token
|
|
23
|
+
|
|
24
|
+
API.account_options = account_options
|
|
25
|
+
API.mcp_server = MCP.new(account_options: account_options)
|
|
26
|
+
|
|
27
|
+
announce(host, port, resolved_token)
|
|
28
|
+
run(API, host: host, port: port)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(app, host:, port:)
|
|
32
|
+
server = Puma::Server.new(app)
|
|
33
|
+
server.add_tcp_listener(host, port)
|
|
34
|
+
trap("INT") { server.stop }
|
|
35
|
+
trap("TERM") { server.stop }
|
|
36
|
+
server.run.join
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def announce(host, port, token)
|
|
40
|
+
base = "http://#{host}:#{port}"
|
|
41
|
+
$stderr.puts <<~BANNER
|
|
42
|
+
edupage #{VERSION}
|
|
43
|
+
|
|
44
|
+
REST #{base}/api/v1/...
|
|
45
|
+
MCP #{base}/mcp
|
|
46
|
+
health #{base}/healthz
|
|
47
|
+
|
|
48
|
+
token #{token}
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
curl -H "Authorization: Bearer #{token}" #{base}/api/v1/schools
|
|
52
|
+
|
|
53
|
+
Stored in #{Edupage.config.path}. Ctrl-C to stop.
|
|
54
|
+
BANNER
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# A live connection to one Edupage school.
|
|
3
|
+
#
|
|
4
|
+
# Two things make this more than a cookie holder:
|
|
5
|
+
#
|
|
6
|
+
# 1. Re-login. Sessions expire server-side; any request can come back as a redirect
|
|
7
|
+
# to /login/. #request notices that, logs in again and retries once.
|
|
8
|
+
#
|
|
9
|
+
# 2. Cursors. Edupage keeps the "current child" and "current school year" in the
|
|
10
|
+
# session, not in the request, so asking for Jana's 2025 grades means mutating
|
|
11
|
+
# shared server state first. #with_cursor does that under a cross-process lock.
|
|
12
|
+
class Session
|
|
13
|
+
SWITCH_CHILD_PATH = "/login/switchchild".freeze
|
|
14
|
+
SET_YEAR_PATH = "/znamky/?what=setyear".freeze
|
|
15
|
+
PING_PATH = "/login/eauth?portalping".freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :origin, :username, :userid, :role, :name
|
|
18
|
+
|
|
19
|
+
def initialize(origin:, username:, session_id: nil, credentials:, userid: nil, role: nil,
|
|
20
|
+
name: nil, store: SessionStore.new, client: nil)
|
|
21
|
+
@origin = origin
|
|
22
|
+
@username = username
|
|
23
|
+
@credentials = credentials
|
|
24
|
+
@store = store
|
|
25
|
+
@userid = userid
|
|
26
|
+
@role = role
|
|
27
|
+
@name = name
|
|
28
|
+
@client = client || Client.new(origin: origin, session_id: session_id)
|
|
29
|
+
|
|
30
|
+
# Cursor state is only trustworthy while we hold the lock, since another process
|
|
31
|
+
# sharing this session can switch it at any time. #release_lock clears it.
|
|
32
|
+
@cursor = { child: nil, year: nil }
|
|
33
|
+
@lock_depth = 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def session_id = @client.session_id
|
|
37
|
+
|
|
38
|
+
def get(path) = request(:get, path)
|
|
39
|
+
def post(path, data = {}) = request(:post, path, data)
|
|
40
|
+
|
|
41
|
+
def parent? = role.to_s.downcase.start_with?("rodic")
|
|
42
|
+
|
|
43
|
+
# Cheap liveness check: 49 bytes, versus 375 KB for /user/.
|
|
44
|
+
def valid?
|
|
45
|
+
!@client.post(PING_PATH, "gpids" => "").expired?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Logs in again and persists the new session id.
|
|
49
|
+
def refresh!
|
|
50
|
+
users = Client.mauth(school: origin, username: username, password: @credentials.password)
|
|
51
|
+
entry = users.find { |u| u[:origin] == origin }
|
|
52
|
+
raise SessionExpiredError, "Account #{username} no longer has access to #{origin}" unless entry
|
|
53
|
+
raise TwoFactorRequiredError, origin if entry[:needs_2fa]
|
|
54
|
+
|
|
55
|
+
@client.session_id = entry[:session_id]
|
|
56
|
+
@userid = entry[:userid]
|
|
57
|
+
@role = entry[:role]
|
|
58
|
+
@name = [entry[:first_name], entry[:last_name]].compact.join(" ")
|
|
59
|
+
persist!
|
|
60
|
+
# A fresh session starts with the server's own defaults, not ours.
|
|
61
|
+
@cursor = { child: nil, year: nil }
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Runs the block with the session pointing at the given child and/or school year.
|
|
66
|
+
#
|
|
67
|
+
# Holds an exclusive lock for the whole block - not just the switch - so a
|
|
68
|
+
# concurrent process cannot move the cursor between our switch and our fetch, which
|
|
69
|
+
# would silently return another child's or another year's data.
|
|
70
|
+
#
|
|
71
|
+
# Re-entrant: nested calls reuse the held lock and skip switches that are already
|
|
72
|
+
# in effect.
|
|
73
|
+
def with_cursor(child: nil, year: nil)
|
|
74
|
+
with_lock do
|
|
75
|
+
# Setting the second cursor can trigger a re-login, which resets the first one
|
|
76
|
+
# server-side. Re-apply until both actually hold before handing over control.
|
|
77
|
+
2.times do
|
|
78
|
+
set_child(child) if child
|
|
79
|
+
set_year(year) if year
|
|
80
|
+
break if cursors_match?(child, year)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless cursors_match?(child, year)
|
|
84
|
+
raise Error, "Could not pin session #{origin} to child=#{child.inspect} year=#{year.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
yield self
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def current_child = @cursor[:child]
|
|
92
|
+
def current_year = @cursor[:year]
|
|
93
|
+
|
|
94
|
+
def persist!
|
|
95
|
+
@store.store(username, origin, session_id: session_id, userid: userid, role: role, name: name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def inspect
|
|
99
|
+
"#<Edupage::Session origin=#{origin.inspect} username=#{username.inspect} role=#{role.inspect}>"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def cursors_match?(child, year)
|
|
105
|
+
(child.nil? || @cursor[:child] == child.to_s) &&
|
|
106
|
+
(year.nil? || @cursor[:year] == year.to_s)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def with_lock(&block)
|
|
110
|
+
return yield if @lock_depth.positive?
|
|
111
|
+
|
|
112
|
+
@store.with_lock do
|
|
113
|
+
@lock_depth += 1
|
|
114
|
+
begin
|
|
115
|
+
block.call
|
|
116
|
+
ensure
|
|
117
|
+
@lock_depth -= 1
|
|
118
|
+
# Outside the lock another process may move the cursors, so what we knew
|
|
119
|
+
# about them expires with the lock.
|
|
120
|
+
@cursor = { child: nil, year: nil } if @lock_depth.zero?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def set_child(student_id)
|
|
126
|
+
id = student_id.to_s
|
|
127
|
+
return if @cursor[:child] == id
|
|
128
|
+
|
|
129
|
+
response = get("#{SWITCH_CHILD_PATH}?studentid=#{id}")
|
|
130
|
+
unless response.ok? && response.body.to_s.strip.start_with?("OK")
|
|
131
|
+
raise Error, "Failed to switch to child #{id} (HTTP #{response.status})"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@cursor[:child] = id
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def set_year(year_id)
|
|
138
|
+
id = year_id.to_s
|
|
139
|
+
return if @cursor[:year] == id
|
|
140
|
+
|
|
141
|
+
response = post(SET_YEAR_PATH, "znamky_yearid" => id)
|
|
142
|
+
# The endpoint echoes the year it actually selected. That echo is the only
|
|
143
|
+
# trustworthy confirmation: /user/ reports the new selectedYear immediately most
|
|
144
|
+
# of the time, but occasionally lags a request behind (Edupage runs several app
|
|
145
|
+
# servers and the dashboard appears to be cached per server). Consumers that care
|
|
146
|
+
# about the year of a document - /user/ carries year-dependent dbi - must check
|
|
147
|
+
# it themselves and refetch; see School#document.
|
|
148
|
+
selected = response.body.to_s.strip.delete('"')
|
|
149
|
+
raise Error, "Failed to switch to year #{id} (HTTP #{response.status})" unless response.ok?
|
|
150
|
+
raise Error, "Edupage selected year #{selected} instead of #{id}" unless selected == id
|
|
151
|
+
|
|
152
|
+
@cursor[:year] = selected
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def request(method, path, data = nil, retried: false)
|
|
156
|
+
response = data.nil? ? @client.get(path) : @client.post(path, data)
|
|
157
|
+
return response unless response.expired?
|
|
158
|
+
|
|
159
|
+
raise SessionExpiredError, "Session for #{origin} expired and re-login did not help" if retried
|
|
160
|
+
|
|
161
|
+
Edupage.logger.debug("Session for #{origin} expired, logging in again")
|
|
162
|
+
refresh!
|
|
163
|
+
request(method, path, data, retried: true)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Edupage
|
|
6
|
+
# Persists one Edupage session id per (username, school) so consecutive commands
|
|
7
|
+
# reuse a session instead of logging in again.
|
|
8
|
+
#
|
|
9
|
+
# The file holds live session ids, so it is created 0600 and every read-modify-write
|
|
10
|
+
# runs under an exclusive flock - `edupage server` and a CLI invocation routinely run
|
|
11
|
+
# at the same time.
|
|
12
|
+
class SessionStore
|
|
13
|
+
FILENAME = "sessions.json".freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
def initialize(path: self.class.default_path)
|
|
18
|
+
@path = path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.default_path
|
|
22
|
+
File.join(Config.cache_dir, FILENAME)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch(username, origin)
|
|
26
|
+
data = read
|
|
27
|
+
entry = data.dig(username.to_s, origin.to_s)
|
|
28
|
+
return nil unless entry && entry["session_id"]
|
|
29
|
+
|
|
30
|
+
symbolize(entry)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def all(username)
|
|
34
|
+
read.fetch(username.to_s, {}).transform_values { |e| symbolize(e) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def store(username, origin, session_id:, userid: nil, role: nil, name: nil)
|
|
38
|
+
transaction do |data|
|
|
39
|
+
data[username.to_s] ||= {}
|
|
40
|
+
data[username.to_s][origin.to_s] = {
|
|
41
|
+
"session_id" => session_id,
|
|
42
|
+
"userid" => userid,
|
|
43
|
+
"role" => role,
|
|
44
|
+
"name" => name,
|
|
45
|
+
"saved_at" => Time.now.iso8601
|
|
46
|
+
}.compact
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Removes one school's session, or every session for the user when origin is nil.
|
|
51
|
+
def delete(username, origin = nil)
|
|
52
|
+
transaction do |data|
|
|
53
|
+
if origin
|
|
54
|
+
data[username.to_s]&.delete(origin.to_s)
|
|
55
|
+
data.delete(username.to_s) if data[username.to_s]&.empty?
|
|
56
|
+
else
|
|
57
|
+
data.delete(username.to_s)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear
|
|
63
|
+
transaction { |data| data.clear }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Exclusive advisory lock used by Session#with_cursor. The server-side cursors
|
|
67
|
+
# (current child, current year) are global to a session, so two processes sharing a
|
|
68
|
+
# session must not interleave a switch with someone else's fetch.
|
|
69
|
+
def with_lock
|
|
70
|
+
FileUtils.mkdir_p(File.dirname(lock_path))
|
|
71
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
72
|
+
file.flock(File::LOCK_EX)
|
|
73
|
+
begin
|
|
74
|
+
yield
|
|
75
|
+
ensure
|
|
76
|
+
file.flock(File::LOCK_UN)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def lock_path = "#{path}.lock"
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def read
|
|
86
|
+
return {} unless File.exist?(path)
|
|
87
|
+
|
|
88
|
+
# UTF-8 explicitly: the stored display name can contain diacritics, and a process
|
|
89
|
+
# started without LANG would otherwise read it as US-ASCII and fail to parse.
|
|
90
|
+
content = File.read(path, encoding: Encoding::UTF_8)
|
|
91
|
+
return {} if content.strip.empty?
|
|
92
|
+
|
|
93
|
+
JSON.parse(content)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
# A truncated file is a cache, not a source of truth - start over rather than
|
|
96
|
+
# making every command fail.
|
|
97
|
+
Edupage.logger.warn("Discarding unreadable session store at #{path}")
|
|
98
|
+
{}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def transaction
|
|
102
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
103
|
+
File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
104
|
+
file.set_encoding(Encoding::UTF_8)
|
|
105
|
+
file.flock(File::LOCK_EX)
|
|
106
|
+
begin
|
|
107
|
+
raw = file.read
|
|
108
|
+
data = raw.strip.empty? ? {} : (JSON.parse(raw) rescue {})
|
|
109
|
+
yield data
|
|
110
|
+
file.rewind
|
|
111
|
+
file.write(JSON.pretty_generate(data))
|
|
112
|
+
file.flush
|
|
113
|
+
file.truncate(file.pos)
|
|
114
|
+
data
|
|
115
|
+
ensure
|
|
116
|
+
file.flock(File::LOCK_UN)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def symbolize(entry)
|
|
122
|
+
{
|
|
123
|
+
session_id: entry["session_id"],
|
|
124
|
+
userid: entry["userid"],
|
|
125
|
+
role: entry["role"],
|
|
126
|
+
name: entry["name"],
|
|
127
|
+
saved_at: entry["saved_at"]
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/edupage/year.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# A school year for one student - the level at which grades and terms exist.
|
|
3
|
+
#
|
|
4
|
+
# Edupage keeps the selected year in the session rather than in the request, so this
|
|
5
|
+
# is a scope object rather than a fetched record: it remembers which (student, year)
|
|
6
|
+
# it stands for and pins both cursors around every fetch it makes.
|
|
7
|
+
class Year
|
|
8
|
+
attr_reader :school, :student, :id, :name
|
|
9
|
+
|
|
10
|
+
# term_rows are the yearterms entries for this year, already read while listing the
|
|
11
|
+
# years. Keeping them means #terms and #grade_count cost nothing.
|
|
12
|
+
def initialize(school:, student:, id:, name: nil, current: false, term_rows: nil)
|
|
13
|
+
@school = school
|
|
14
|
+
@student = student
|
|
15
|
+
@id = id.to_s
|
|
16
|
+
@name = name || "#{@id}/#{@id.to_i + 1}"
|
|
17
|
+
@current = current
|
|
18
|
+
@term_rows = term_rows
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def current? = @current
|
|
22
|
+
|
|
23
|
+
# Every grade in the year. Edupage serves one half-year per request, so this is the
|
|
24
|
+
# union of the terms rather than a single fetch.
|
|
25
|
+
def grades
|
|
26
|
+
Relation.new(-> { terms.flat_map { |term| grades_for(term) } })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def terms
|
|
30
|
+
Relation.new(-> { build_terms })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Grades of one half-year.
|
|
34
|
+
def grades_for(term)
|
|
35
|
+
document = school.znamky_for(student, year: id, term: term.id)
|
|
36
|
+
document.grades.map do |row|
|
|
37
|
+
Models::Grade.new(row, school: school, event: document.event_for(row),
|
|
38
|
+
year: self, term: term)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def term(id)
|
|
43
|
+
terms.find { |t| t.id.to_s.casecmp?(id.to_s) } or
|
|
44
|
+
raise NotFoundError, "#{student.full_name} has no term #{id} in #{name}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def grade_count = terms.sum { |t| t.grade_count.to_i }
|
|
48
|
+
|
|
49
|
+
# Year-scoped views of the school directory: dbi changes between years, so a class
|
|
50
|
+
# list from 2025 is not the same as this year's.
|
|
51
|
+
def classes = school.classes(year: id)
|
|
52
|
+
def teachers = school.teachers(year: id)
|
|
53
|
+
def subjects = school.subjects(year: id)
|
|
54
|
+
|
|
55
|
+
def match_strings = [id, name].compact
|
|
56
|
+
def matches?(query) = query.nil? || match_strings.any? { |c| c.casecmp?(query.to_s) }
|
|
57
|
+
|
|
58
|
+
def to_h = { id: id, name: name, current: current?, grade_count: grade_count }
|
|
59
|
+
def to_s = "#{name}#{current? ? " (current)" : ""}"
|
|
60
|
+
def inspect = "#<Edupage::Year #{name} student=#{student.full_name.inspect}>"
|
|
61
|
+
|
|
62
|
+
def ==(other) = other.is_a?(Year) && id == other.id && student == other.student
|
|
63
|
+
alias eql? ==
|
|
64
|
+
def hash = [Year, id, student.id].hash
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def term_rows
|
|
69
|
+
@term_rows ||= school.znamky_for(student, year: id).year_terms
|
|
70
|
+
.select { |row| row["yearid"].to_s == id }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_terms
|
|
74
|
+
term_rows.map { |row| Models::Term.new(row, school: school, year: self) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|