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,96 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Edupage
|
|
6
|
+
# User configuration in ~/.config/edupage-cli/config.yml.
|
|
7
|
+
#
|
|
8
|
+
# Holds only non-secret settings. The password never lives here - see Credentials.
|
|
9
|
+
class Config
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
# Which *.edupage.org host to authenticate against. It is a login anchor only:
|
|
12
|
+
# it does not choose which school's data is read - see Registry::Context.
|
|
13
|
+
"default_school" => nil,
|
|
14
|
+
"default_username" => nil,
|
|
15
|
+
"server" => {
|
|
16
|
+
"host" => "127.0.0.1",
|
|
17
|
+
"port" => 4567,
|
|
18
|
+
"token" => nil
|
|
19
|
+
}
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def load(path = self.path)
|
|
24
|
+
# Read as UTF-8 rather than through the locale, so a process started without
|
|
25
|
+
# LANG (an MCP client, launchd) does not choke on a non-ASCII value.
|
|
26
|
+
raw = File.exist?(path) ? (YAML.safe_load(File.read(path, encoding: Encoding::UTF_8)) || {}) : {}
|
|
27
|
+
new(deep_merge(DEFAULTS, raw), path: path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def path
|
|
31
|
+
ENV["EDUPAGE_CONFIG"] || File.join(config_home, "edupage-cli", "config.yml")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def config_home
|
|
35
|
+
ENV["XDG_CONFIG_HOME"] || File.expand_path("~/.config")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cache_home
|
|
39
|
+
ENV["XDG_CACHE_HOME"] || File.expand_path("~/.cache")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cache_dir
|
|
43
|
+
ENV["EDUPAGE_CACHE_DIR"] || File.join(cache_home, "edupage-cli")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def deep_merge(base, other)
|
|
47
|
+
base.merge(other) do |_key, a, b|
|
|
48
|
+
a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : (b.nil? ? a : b)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :path
|
|
54
|
+
|
|
55
|
+
def initialize(data, path: self.class.path)
|
|
56
|
+
@data = data
|
|
57
|
+
@path = path
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def [](key)
|
|
61
|
+
@data[key.to_s]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def []=(key, value)
|
|
65
|
+
@data[key.to_s] = value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def default_school = self["default_school"]
|
|
69
|
+
def default_username = self["default_username"]
|
|
70
|
+
|
|
71
|
+
def server = self["server"] || {}
|
|
72
|
+
|
|
73
|
+
# Lazily minted on first use so `edupage server` works without setup, but the token
|
|
74
|
+
# is only generated once and then persisted.
|
|
75
|
+
def server_token
|
|
76
|
+
server["token"] || begin
|
|
77
|
+
token = SecureRandom.urlsafe_base64(32)
|
|
78
|
+
@data["server"] = server.merge("token" => token)
|
|
79
|
+
save
|
|
80
|
+
token
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_h = @data
|
|
85
|
+
|
|
86
|
+
def save
|
|
87
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
88
|
+
# Written 0600: the file carries the server bearer token.
|
|
89
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
90
|
+
f.set_encoding(Encoding::UTF_8)
|
|
91
|
+
f.write(YAML.dump(@data))
|
|
92
|
+
end
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
class Credentials
|
|
3
|
+
# Highest-priority provider: plain environment variables.
|
|
4
|
+
class Env
|
|
5
|
+
VARS = {
|
|
6
|
+
username: "EDUPAGE_USERNAME",
|
|
7
|
+
password: "EDUPAGE_PASSWORD",
|
|
8
|
+
school: "EDUPAGE_SCHOOL"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(env = ENV)
|
|
12
|
+
@env = env
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def username(**) = @env[VARS[:username]]
|
|
16
|
+
def password(**) = @env[VARS[:password]]
|
|
17
|
+
|
|
18
|
+
# Accepts either "zsdemo" or "zsdemo.edupage.org".
|
|
19
|
+
def school(**)
|
|
20
|
+
value = @env[VARS[:school]]
|
|
21
|
+
value&.sub(/\.edupage\.org\z/, "")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def source_for(field) = "ENV: #{VARS[field]}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "rbconfig"
|
|
3
|
+
|
|
4
|
+
module Edupage
|
|
5
|
+
class Credentials
|
|
6
|
+
# Secondary provider: the macOS keychain, driven through /usr/bin/security.
|
|
7
|
+
#
|
|
8
|
+
# The password is never passed in argv (where `ps` would expose it). `security`
|
|
9
|
+
# reads it from stdin instead, prompting twice, so writes send the value twice.
|
|
10
|
+
#
|
|
11
|
+
# `security add-generic-password` exits 0 even when the two reads disagree, storing
|
|
12
|
+
# an empty password - so #store always reads the value back before reporting success.
|
|
13
|
+
class Keychain
|
|
14
|
+
SERVICE = "edupage-cli".freeze
|
|
15
|
+
SECURITY = "/usr/bin/security".freeze
|
|
16
|
+
# Runs a command in a new session, without a controlling terminal. macOS ships no
|
|
17
|
+
# setsid(1), so Ruby itself does the setsid before exec.
|
|
18
|
+
DETACH = [RbConfig.ruby, "-e", "Process.setsid; exec(*ARGV)"].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def available?
|
|
22
|
+
RUBY_PLATFORM.include?("darwin") && File.executable?(SECURITY)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(service: SERVICE)
|
|
27
|
+
@service = service
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Only the password lives in the keychain; the other fields come from ENV or config.
|
|
31
|
+
def username(**) = nil
|
|
32
|
+
def school(**) = nil
|
|
33
|
+
|
|
34
|
+
def password(username: nil, **)
|
|
35
|
+
return nil if username.nil? || username.empty?
|
|
36
|
+
return nil unless self.class.available?
|
|
37
|
+
|
|
38
|
+
# -g, not -w: for non-ASCII passwords `-w` prints bare hex, which is
|
|
39
|
+
# indistinguishable from an ASCII password that happens to look like hex
|
|
40
|
+
# ("deadbeef"). -g prefixes the hex form with 0x, so it can be told apart.
|
|
41
|
+
_out, err, status = Open3.capture3(
|
|
42
|
+
SECURITY, "find-generic-password", "-s", @service, "-a", username, "-g"
|
|
43
|
+
)
|
|
44
|
+
return nil unless status.success?
|
|
45
|
+
|
|
46
|
+
value = parse_password(err)
|
|
47
|
+
value && !value.empty? ? value : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def store(username:, password:)
|
|
51
|
+
ensure_available!
|
|
52
|
+
secret = password.to_s
|
|
53
|
+
raise ArgumentError, "password must not be empty" if secret.empty?
|
|
54
|
+
|
|
55
|
+
# -w last with no value: `security` prompts on stdin rather than taking argv.
|
|
56
|
+
# It prefers /dev/tty over stdin when it has a terminal, so it runs detached
|
|
57
|
+
# from ours; otherwise it would ignore stdin_data and prompt the user instead.
|
|
58
|
+
_out, err, status = Open3.capture3(
|
|
59
|
+
*DETACH, SECURITY, "add-generic-password", "-s", @service, "-a", username,
|
|
60
|
+
"-l", "#{@service} (#{username})", "-U", "-w",
|
|
61
|
+
stdin_data: "#{secret}\n#{secret}\n"
|
|
62
|
+
)
|
|
63
|
+
raise Error, "Failed to store password in keychain: #{err.strip}" unless status.success?
|
|
64
|
+
|
|
65
|
+
# Guard against the silent-empty-write path described above.
|
|
66
|
+
unless password(username: username) == secret
|
|
67
|
+
raise Error, "Keychain reported success but stored a different password"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def delete(username:)
|
|
74
|
+
ensure_available!
|
|
75
|
+
|
|
76
|
+
_out, _err, status = Open3.capture3(
|
|
77
|
+
SECURITY, "delete-generic-password", "-s", @service, "-a", username
|
|
78
|
+
)
|
|
79
|
+
status.success?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stored?(username:)
|
|
83
|
+
!password(username: username).nil?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def source_for(_field) = "keychain"
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# `security -g` writes one of these to stderr:
|
|
91
|
+
# password: "plain-ascii"
|
|
92
|
+
# password: 0x73336372 "s3cr3t-\303\241"
|
|
93
|
+
# The hex form is exact bytes, so it is preferred whenever present.
|
|
94
|
+
def parse_password(stderr)
|
|
95
|
+
line = stderr.lines.find { |l| l.start_with?("password:") }
|
|
96
|
+
return nil unless line
|
|
97
|
+
|
|
98
|
+
if (hex = line[/password: 0x([0-9A-Fa-f]+)/, 1])
|
|
99
|
+
[hex].pack("H*").force_encoding(Encoding::UTF_8)
|
|
100
|
+
elsif (quoted = line[/password: "(.*)"\s*\z/m, 1])
|
|
101
|
+
unescape(quoted)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def unescape(str)
|
|
106
|
+
str.gsub(/\\(\d{3}|.)/) do
|
|
107
|
+
esc = Regexp.last_match(1)
|
|
108
|
+
esc.match?(/\A\d{3}\z/) ? esc.to_i(8).chr : esc
|
|
109
|
+
end.force_encoding(Encoding::UTF_8)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ensure_available!
|
|
113
|
+
return if self.class.available?
|
|
114
|
+
|
|
115
|
+
raise UnsupportedPlatformError,
|
|
116
|
+
"The macOS keychain is not available on #{RUBY_PLATFORM}. Use EDUPAGE_PASSWORD instead."
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# Resolves username / password / school from an ordered list of providers.
|
|
3
|
+
#
|
|
4
|
+
# ENV always wins over the keychain. That ordering is deliberate but surprising
|
|
5
|
+
# (an `edupage login` looks like a no-op while EDUPAGE_PASSWORD is exported), so
|
|
6
|
+
# #password_source is reported by `edupage auth status`.
|
|
7
|
+
class Credentials
|
|
8
|
+
Resolved = Struct.new(:value, :source, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :username_override, :school_override
|
|
11
|
+
|
|
12
|
+
def initialize(username: nil, school: nil, providers: nil, config: Edupage.config)
|
|
13
|
+
@username_override = username
|
|
14
|
+
@school_override = school
|
|
15
|
+
@config = config
|
|
16
|
+
@providers = providers || default_providers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def default_providers
|
|
20
|
+
[Credentials::Env.new].tap do |list|
|
|
21
|
+
list << Credentials::Keychain.new if Credentials::Keychain.available?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def username
|
|
26
|
+
resolved_username.value or
|
|
27
|
+
raise MissingCredentialsError,
|
|
28
|
+
"No username. Pass --username, set EDUPAGE_USERNAME, or set default_username in #{@config.path}."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def password
|
|
32
|
+
resolved_password.value or
|
|
33
|
+
raise MissingCredentialsError, missing_password_message
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def school
|
|
37
|
+
resolved_school.value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def username_source = resolved_username.source
|
|
41
|
+
def password_source = resolved_password.source
|
|
42
|
+
def school_source = resolved_school.source
|
|
43
|
+
|
|
44
|
+
# True when ENV supplies the password while a lower-priority provider also holds
|
|
45
|
+
# one, i.e. a stored password is being silently shadowed. Providers are compared by
|
|
46
|
+
# the source they report rather than by class, so any provider chain works.
|
|
47
|
+
def password_shadowed?
|
|
48
|
+
winning = resolved_password.source
|
|
49
|
+
return false unless winning&.start_with?("ENV:")
|
|
50
|
+
|
|
51
|
+
user = resolved_username.value
|
|
52
|
+
@providers.any? do |provider|
|
|
53
|
+
next false if provider.source_for(:password) == winning
|
|
54
|
+
|
|
55
|
+
value = provider.password(username: user)
|
|
56
|
+
value && !value.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{ username: resolved_username.value, school: resolved_school.value }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Never let a password reach a log line, a backtrace or `p`.
|
|
65
|
+
def inspect = "#<Edupage::Credentials username=#{resolved_username.value.inspect}>"
|
|
66
|
+
alias to_s inspect
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def resolved_username
|
|
71
|
+
@resolved_username ||= begin
|
|
72
|
+
from_providers(:username) ||
|
|
73
|
+
wrap(@username_override, "--username") ||
|
|
74
|
+
wrap(@config.default_username, "config") ||
|
|
75
|
+
Resolved.new(value: nil, source: nil)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolved_password
|
|
80
|
+
@resolved_password ||=
|
|
81
|
+
from_providers(:password, username: resolved_username.value) ||
|
|
82
|
+
Resolved.new(value: nil, source: nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolved_school
|
|
86
|
+
@resolved_school ||= begin
|
|
87
|
+
from_providers(:school) ||
|
|
88
|
+
wrap(@school_override, "--school") ||
|
|
89
|
+
wrap(@config.default_school, "config") ||
|
|
90
|
+
Resolved.new(value: nil, source: nil)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# An override beats a lower-priority provider but never beats ENV, so overrides are
|
|
95
|
+
# only consulted after the provider chain comes up empty.
|
|
96
|
+
def from_providers(field, **args)
|
|
97
|
+
@providers.each do |provider|
|
|
98
|
+
value = provider.public_send(field, **args)
|
|
99
|
+
return Resolved.new(value: value, source: provider.source_for(field)) if value && !value.empty?
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def wrap(value, source)
|
|
105
|
+
return nil if value.nil? || value.to_s.empty?
|
|
106
|
+
|
|
107
|
+
Resolved.new(value: value.to_s, source: source)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def missing_password_message
|
|
111
|
+
if Credentials::Keychain.available?
|
|
112
|
+
"No password. Run `edupage login`, or set EDUPAGE_PASSWORD."
|
|
113
|
+
else
|
|
114
|
+
"No password and the macOS keychain is unavailable on this platform. Set EDUPAGE_PASSWORD."
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# Base for everything this library raises on purpose.
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
|
|
5
|
+
# No usable username/password could be assembled from any provider.
|
|
6
|
+
class MissingCredentialsError < Error; end
|
|
7
|
+
|
|
8
|
+
# Edupage rejected the credentials.
|
|
9
|
+
class LoginError < Error; end
|
|
10
|
+
|
|
11
|
+
# The login matched several accounts and none was selected.
|
|
12
|
+
class AmbiguousAccountError < LoginError
|
|
13
|
+
attr_reader :candidates
|
|
14
|
+
|
|
15
|
+
def initialize(candidates)
|
|
16
|
+
@candidates = candidates
|
|
17
|
+
list = candidates.map { |c| "#{c[:userid]} (#{c[:origin]})" }.join(", ")
|
|
18
|
+
super("Login matches multiple accounts: #{list}. Pass school: to pick one.")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The stored session is no longer valid and could not be renewed.
|
|
23
|
+
class SessionExpiredError < Error; end
|
|
24
|
+
|
|
25
|
+
# The school demands two-factor approval for this login. Edupage still hands out a
|
|
26
|
+
# session id and answers the liveness ping, but every page redirects to /login/, so
|
|
27
|
+
# logging in again cannot help.
|
|
28
|
+
class TwoFactorRequiredError < LoginError
|
|
29
|
+
attr_reader :origin
|
|
30
|
+
|
|
31
|
+
def initialize(origin)
|
|
32
|
+
@origin = origin
|
|
33
|
+
super("#{origin} requires two-factor approval for this login, which edupage-cli " \
|
|
34
|
+
"cannot complete yet. Data from this school is unavailable.")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Edupage answered, but not with anything we can read.
|
|
39
|
+
class ParseError < Error
|
|
40
|
+
attr_reader :snippet
|
|
41
|
+
|
|
42
|
+
def initialize(message, snippet: nil)
|
|
43
|
+
@snippet = snippet
|
|
44
|
+
super(snippet ? "#{message} (got: #{snippet.to_s[0, 200].inspect})" : message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Transport-level failure that survived the retries in Client.
|
|
49
|
+
class RequestError < Error; end
|
|
50
|
+
|
|
51
|
+
# Asked for a school/student/year that this account cannot see.
|
|
52
|
+
class NotFoundError < Error; end
|
|
53
|
+
|
|
54
|
+
# A level of the account > school > student > year chain was left unspecified while
|
|
55
|
+
# several options existed.
|
|
56
|
+
#
|
|
57
|
+
# Deliberately not a NotFoundError: "you did not choose" and "it does not exist" are
|
|
58
|
+
# different situations, and the surfaces answer them differently - the CLI lists the
|
|
59
|
+
# options, the REST API answers 400 rather than 404.
|
|
60
|
+
class AmbiguousScopeError < Error
|
|
61
|
+
# level: :school or :student
|
|
62
|
+
# candidates: [{ id:, label: }, ...] - enough for any surface to render a choice
|
|
63
|
+
attr_reader :level, :candidates
|
|
64
|
+
|
|
65
|
+
def initialize(level, candidates)
|
|
66
|
+
@level = level
|
|
67
|
+
@candidates = candidates
|
|
68
|
+
super("No #{level} selected; #{candidates.size} to choose from")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The keychain provider was asked for something this platform cannot do.
|
|
73
|
+
class UnsupportedPlatformError < Error; end
|
|
74
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Edupage
|
|
5
|
+
# Base for every value object parsed out of an Edupage payload.
|
|
6
|
+
#
|
|
7
|
+
# Models are thin wrappers over the raw hash Edupage sent. The `attribute` DSL maps
|
|
8
|
+
# Edupage's Slovak/abbreviated keys onto Ruby names and casts once, in one place,
|
|
9
|
+
# instead of scattering `data["numberinclass"].to_i` through the codebase. The raw
|
|
10
|
+
# hash stays reachable via #data for the fields nobody has modelled yet.
|
|
11
|
+
class Model
|
|
12
|
+
CASTS = {
|
|
13
|
+
string: ->(v) { v.nil? ? nil : v.to_s },
|
|
14
|
+
integer: ->(v) { v.nil? || v.to_s.empty? ? nil : Integer(v, exception: false) },
|
|
15
|
+
float: ->(v) { v.nil? || v.to_s.empty? ? nil : Float(v, exception: false) },
|
|
16
|
+
boolean: ->(v) { [true, "1", 1, "true"].include?(v) },
|
|
17
|
+
date: ->(v) { parse_date(v) },
|
|
18
|
+
time: ->(v) { parse_time(v) },
|
|
19
|
+
raw: ->(v) { v }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def attributes = @attributes ||= superclass.respond_to?(:attributes) ? superclass.attributes.dup : {}
|
|
24
|
+
|
|
25
|
+
# Declares a reader backed by +key+ in the raw payload.
|
|
26
|
+
#
|
|
27
|
+
# attribute :first_name, "firstname"
|
|
28
|
+
# attribute :number, "numberinclass", cast: :integer
|
|
29
|
+
#
|
|
30
|
+
# A block receives the raw hash for values that need assembling rather than
|
|
31
|
+
# looking up.
|
|
32
|
+
def attribute(name, key = name.to_s, cast: nil, &block)
|
|
33
|
+
# A block usually assembles an object (a Subject, a list of Teachers), so it
|
|
34
|
+
# defaults to no cast; a plain key lookup defaults to a string.
|
|
35
|
+
cast ||= block ? :raw : :string
|
|
36
|
+
attributes[name.to_sym] = { key: key.to_s, cast: cast, block: block }
|
|
37
|
+
|
|
38
|
+
define_method(name) do
|
|
39
|
+
@computed.fetch(name) { @computed[name] = read_attribute(name) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_date(value)
|
|
44
|
+
return value if value.is_a?(Date) && !value.is_a?(DateTime)
|
|
45
|
+
return nil if value.nil? || value.to_s.empty?
|
|
46
|
+
|
|
47
|
+
Date.parse(value.to_s)
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_time(value)
|
|
53
|
+
return value if value.is_a?(Time)
|
|
54
|
+
return nil if value.nil? || value.to_s.empty?
|
|
55
|
+
|
|
56
|
+
# Edupage sends local wall-clock timestamps with no zone.
|
|
57
|
+
Time.parse(value.to_s)
|
|
58
|
+
rescue ArgumentError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
attr_reader :data, :school
|
|
64
|
+
|
|
65
|
+
def initialize(data, school: nil)
|
|
66
|
+
@data = data || {}
|
|
67
|
+
@school = school
|
|
68
|
+
@computed = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Most Edupage records key off one of these.
|
|
72
|
+
def id
|
|
73
|
+
@id ||= (data["id"] || data["studentid"] || data["ucitelid"] || data["classid"]).then do |value|
|
|
74
|
+
value.nil? ? nil : value.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def name = respond_to?(:full_name) ? full_name : data["name"]
|
|
79
|
+
|
|
80
|
+
# Strings a human might use to refer to this record on the command line.
|
|
81
|
+
# `where(subject: "MAT")` should match the short code as readily as the full name.
|
|
82
|
+
def match_strings
|
|
83
|
+
[id, (name if respond_to?(:name)), (short if respond_to?(:short))]
|
|
84
|
+
.compact.map(&:to_s).reject(&:empty?)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def matches?(query)
|
|
88
|
+
return true if query.nil?
|
|
89
|
+
|
|
90
|
+
case query
|
|
91
|
+
when Model then id == query.id
|
|
92
|
+
when Regexp then match_strings.any? { |candidate| candidate.match?(query) }
|
|
93
|
+
else match_strings.any? { |candidate| candidate.casecmp?(query.to_s) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
self.class.attributes.keys.to_h { |name| [name, serialize(public_send(name))] }
|
|
99
|
+
.merge(id: id).compact
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ==(other) = other.is_a?(self.class) && !id.nil? && id == other.id
|
|
103
|
+
alias eql? ==
|
|
104
|
+
|
|
105
|
+
def hash = [self.class, id].hash
|
|
106
|
+
|
|
107
|
+
def inspect
|
|
108
|
+
shown = self.class.attributes.keys.first(4)
|
|
109
|
+
.to_h { |a| [a, public_send(a)] }.compact
|
|
110
|
+
.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
|
|
111
|
+
"#<#{self.class.name.split("::").last} id=#{id.inspect} #{shown}>"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def read_attribute(name)
|
|
117
|
+
spec = self.class.attributes.fetch(name)
|
|
118
|
+
raw = spec[:block] ? instance_exec(data, &spec[:block]) : data[spec[:key]]
|
|
119
|
+
CASTS.fetch(spec[:cast]).call(raw)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def serialize(value)
|
|
123
|
+
case value
|
|
124
|
+
when Model then value.id
|
|
125
|
+
when Array then value.map { |v| serialize(v) }
|
|
126
|
+
when Date, Time then value.iso8601
|
|
127
|
+
else value
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# Something the student has to do: homework, a test, an exam, a project.
|
|
4
|
+
#
|
|
5
|
+
# Edupage announces these as timeline items, in two shapes that the upstream JS
|
|
6
|
+
# library treats very differently and which are unified here:
|
|
7
|
+
#
|
|
8
|
+
# class-wide recipient is a plan ("CustPlan8562"), the whole class gets it
|
|
9
|
+
# personal recipient is the pupil ("Student113506")
|
|
10
|
+
#
|
|
11
|
+
# Both carry the real content in the embedded payload, so both are read the same
|
|
12
|
+
# way. Only handling the first shape loses every individually assigned task - two
|
|
13
|
+
# thirds of them on the account this was built against.
|
|
14
|
+
class Assignment < TimelineItem
|
|
15
|
+
HOMEWORK_TYPES = %w[hw etesthw].freeze
|
|
16
|
+
EXAM_TYPES = %w[bexam sexam oexam rexam exam testing].freeze
|
|
17
|
+
TEST_TYPES = %w[test etest etestprint].freeze
|
|
18
|
+
PROJECT_TYPES = %w[pexam projekt].freeze
|
|
19
|
+
|
|
20
|
+
# Timeline items that represent an assignment of some kind.
|
|
21
|
+
TIMELINE_TYPES = %w[homework testpridelenie].freeze
|
|
22
|
+
|
|
23
|
+
def self.assignment?(row)
|
|
24
|
+
TIMELINE_TYPES.include?(row["typ"].to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# "hw", "test", ... - the payload's own type, falling back to homework, which is
|
|
28
|
+
# what a plain `homework` timeline item is.
|
|
29
|
+
def assignment_type
|
|
30
|
+
value = payload["typ"] || payload["etype"]
|
|
31
|
+
value.to_s.empty? ? "hw" : value.to_s.split("|").first
|
|
32
|
+
end
|
|
33
|
+
alias type assignment_type
|
|
34
|
+
|
|
35
|
+
def title = presence(payload["nazov"])
|
|
36
|
+
def description = presence(payload["popis"])
|
|
37
|
+
|
|
38
|
+
def due_on = Model.parse_date(payload["date"])
|
|
39
|
+
def assigned_on = created_at&.to_date
|
|
40
|
+
|
|
41
|
+
def subject = school&.subject(payload["predmetid"])
|
|
42
|
+
def school_class = school&.school_class(payload["triedaid"])
|
|
43
|
+
def teacher = owner.is_a?(Teacher) ? owner : nil
|
|
44
|
+
|
|
45
|
+
def plan_id = payload["planid"]
|
|
46
|
+
def super_id = payload["superid"] || payload["e_superid"]
|
|
47
|
+
|
|
48
|
+
# True when the task went to a whole class rather than to one pupil.
|
|
49
|
+
def class_wide? = recipient_user_string.to_s.start_with?("CustPlan", "Trieda", "Plan")
|
|
50
|
+
|
|
51
|
+
def homework? = HOMEWORK_TYPES.include?(assignment_type)
|
|
52
|
+
def exam? = EXAM_TYPES.include?(assignment_type)
|
|
53
|
+
def test? = TEST_TYPES.include?(assignment_type)
|
|
54
|
+
def project? = PROJECT_TYPES.include?(assignment_type)
|
|
55
|
+
|
|
56
|
+
def overdue? = due_on ? due_on < Date.today : false
|
|
57
|
+
|
|
58
|
+
def name = title
|
|
59
|
+
def match_strings = [id, title, subject&.short, subject&.name, assignment_type].compact.map(&:to_s)
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
[
|
|
63
|
+
due_on ? "due #{due_on}" : nil,
|
|
64
|
+
subject&.short,
|
|
65
|
+
title
|
|
66
|
+
].compact.join(" | ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{
|
|
71
|
+
id: id, type: assignment_type, title: title, description: description,
|
|
72
|
+
subject: subject&.name, teacher: teacher&.full_name,
|
|
73
|
+
assigned_on: assigned_on&.iso8601, due_on: due_on&.iso8601,
|
|
74
|
+
class_wide: class_wide?, student: student&.full_name
|
|
75
|
+
}.compact
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def presence(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Models
|
|
3
|
+
# A room. Edupage also models floors and buildings as classrooms, which is why
|
|
4
|
+
# some entries are hidden from the class book.
|
|
5
|
+
class Classroom < Model
|
|
6
|
+
attribute :name
|
|
7
|
+
attribute :short
|
|
8
|
+
attribute :hidden_in_classbook, "cb_hidden", cast: :boolean
|
|
9
|
+
|
|
10
|
+
alias hidden_in_classbook? hidden_in_classbook
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|