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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +250 -0
  3. data/exe/edupage +7 -0
  4. data/lib/edupage/account.rb +121 -0
  5. data/lib/edupage/cache.rb +101 -0
  6. data/lib/edupage/cli/formatter.rb +56 -0
  7. data/lib/edupage/cli/table.rb +136 -0
  8. data/lib/edupage/cli.rb +543 -0
  9. data/lib/edupage/client.rb +152 -0
  10. data/lib/edupage/config.rb +96 -0
  11. data/lib/edupage/credentials/env.rb +27 -0
  12. data/lib/edupage/credentials/keychain.rb +120 -0
  13. data/lib/edupage/credentials.rb +118 -0
  14. data/lib/edupage/errors.rb +74 -0
  15. data/lib/edupage/model.rb +131 -0
  16. data/lib/edupage/models/assignment.rb +83 -0
  17. data/lib/edupage/models/classroom.rb +13 -0
  18. data/lib/edupage/models/day.rb +51 -0
  19. data/lib/edupage/models/grade.rb +100 -0
  20. data/lib/edupage/models/lesson.rb +58 -0
  21. data/lib/edupage/models/parent.rb +8 -0
  22. data/lib/edupage/models/period.rb +13 -0
  23. data/lib/edupage/models/person.rb +25 -0
  24. data/lib/edupage/models/school_class.rb +16 -0
  25. data/lib/edupage/models/student.rb +45 -0
  26. data/lib/edupage/models/subject.rb +9 -0
  27. data/lib/edupage/models/teacher.rb +25 -0
  28. data/lib/edupage/models/term.rb +36 -0
  29. data/lib/edupage/models/timeline_item.rb +87 -0
  30. data/lib/edupage/parsers/base.rb +150 -0
  31. data/lib/edupage/parsers/gcall.rb +53 -0
  32. data/lib/edupage/parsers/timeline.rb +46 -0
  33. data/lib/edupage/parsers/userhome.rb +84 -0
  34. data/lib/edupage/parsers/znamky.rb +83 -0
  35. data/lib/edupage/registry/resources.rb +175 -0
  36. data/lib/edupage/registry.rb +286 -0
  37. data/lib/edupage/relation.rb +185 -0
  38. data/lib/edupage/school.rb +358 -0
  39. data/lib/edupage/serializer.rb +53 -0
  40. data/lib/edupage/server/api.rb +113 -0
  41. data/lib/edupage/server/auth.rb +45 -0
  42. data/lib/edupage/server/mcp.rb +107 -0
  43. data/lib/edupage/server.rb +57 -0
  44. data/lib/edupage/session.rb +166 -0
  45. data/lib/edupage/session_store.rb +131 -0
  46. data/lib/edupage/version.rb +3 -0
  47. data/lib/edupage/year.rb +77 -0
  48. data/lib/edupage.rb +64 -0
  49. 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