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,185 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# Lazy, chainable collection.
|
|
3
|
+
#
|
|
4
|
+
# Nothing is fetched until the relation is enumerated, so `student.homeworks` costs
|
|
5
|
+
# nothing and `student.homeworks.where(...).first` fetches once. Filters are applied
|
|
6
|
+
# in Ruby because Edupage has no query interface - the payload always arrives whole.
|
|
7
|
+
class Relation
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def initialize(loader = nil, records: nil)
|
|
11
|
+
@loader = loader
|
|
12
|
+
@records = records
|
|
13
|
+
@conditions = []
|
|
14
|
+
@order = []
|
|
15
|
+
@limit_value = nil
|
|
16
|
+
@offset_value = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.wrap(records) = new(records: Array(records))
|
|
20
|
+
|
|
21
|
+
def each(&block) = resolved.each(&block)
|
|
22
|
+
|
|
23
|
+
# Conditions are ANDed. Values may be:
|
|
24
|
+
# exact where(short: "MAT") - case-insensitive
|
|
25
|
+
# regexp where(name: /Jana/)
|
|
26
|
+
# range where(due: Date.today..)
|
|
27
|
+
# array where(type: %i[hw test]) - any of
|
|
28
|
+
# model where(subject: subject)
|
|
29
|
+
# predicate where(due: ->(d) { d.monday? })
|
|
30
|
+
def where(**conditions)
|
|
31
|
+
chain { |copy| copy.conditions.concat(conditions.to_a) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# order(:due) or order(due: :desc) or order(:subject, :due)
|
|
35
|
+
def order(*keys, **directions)
|
|
36
|
+
pairs = keys.map { |k| [k, :asc] } + directions.to_a
|
|
37
|
+
chain { |copy| copy.order_keys.concat(pairs) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def limit(count) = chain { |copy| copy.limit_value = count }
|
|
41
|
+
def offset(count) = chain { |copy| copy.offset_value = count }
|
|
42
|
+
|
|
43
|
+
def find_by(**conditions) = where(**conditions).first
|
|
44
|
+
|
|
45
|
+
# find(id) looks a record up by id, like ActiveRecord. With a block it stays
|
|
46
|
+
# Enumerable#find, so `relation.find { |r| ... }` keeps working.
|
|
47
|
+
def find(id = nil, &block)
|
|
48
|
+
return super(&block) if block
|
|
49
|
+
|
|
50
|
+
resolved.find { |record| record.respond_to?(:id) && record.id.to_s == id.to_s }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def first(count = nil) = count ? resolved.first(count) : resolved.first
|
|
54
|
+
def last(count = nil) = count ? resolved.last(count) : resolved.last
|
|
55
|
+
# count, count(value) and count { ... } all behave as Enumerable defines them;
|
|
56
|
+
# the bare form is the common "how many records" case.
|
|
57
|
+
def count(*args, &block)
|
|
58
|
+
return resolved.size if args.empty? && block.nil?
|
|
59
|
+
|
|
60
|
+
resolved.count(*args, &block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def size = resolved.size
|
|
64
|
+
def empty? = resolved.empty?
|
|
65
|
+
def to_a = resolved.dup
|
|
66
|
+
def to_ary = to_a
|
|
67
|
+
|
|
68
|
+
def inspect
|
|
69
|
+
"#<Edupage::Relation #{loaded? ? "#{resolved.size} records" : "not loaded"}>"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def loaded? = !@resolved.nil?
|
|
73
|
+
|
|
74
|
+
protected
|
|
75
|
+
|
|
76
|
+
attr_accessor :limit_value, :offset_value
|
|
77
|
+
attr_reader :conditions
|
|
78
|
+
|
|
79
|
+
def order_keys = @order
|
|
80
|
+
|
|
81
|
+
def initialize_copy(source)
|
|
82
|
+
super
|
|
83
|
+
@conditions = source.conditions.dup
|
|
84
|
+
@order = source.order_keys.dup
|
|
85
|
+
@resolved = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def chain
|
|
91
|
+
copy = dup
|
|
92
|
+
yield copy
|
|
93
|
+
copy
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolved
|
|
97
|
+
@resolved ||= begin
|
|
98
|
+
records = @records || Array(@loader.call)
|
|
99
|
+
records = records.select { |record| matches_all?(record) } unless @conditions.empty?
|
|
100
|
+
records = sort(records) unless @order.empty?
|
|
101
|
+
records = records.drop(@offset_value || 0)
|
|
102
|
+
limit = @limit_value
|
|
103
|
+
limit ? records.first(limit) : records
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def matches_all?(record)
|
|
108
|
+
@conditions.all? { |key, query| matches?(record, key, query) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# `name` is the one filter that searches the record rather than a single attribute:
|
|
112
|
+
# asking for a subject by "MAT" should find "Matematika", and a teacher by their
|
|
113
|
+
# short code should find them too. Every other key compares its attribute directly.
|
|
114
|
+
NAME_KEY = :name
|
|
115
|
+
|
|
116
|
+
def matches?(record, key, query)
|
|
117
|
+
return true if query.nil?
|
|
118
|
+
|
|
119
|
+
if key == NAME_KEY && record.respond_to?(:match_strings)
|
|
120
|
+
return Array(query).any? { |q| record.matches?(q) } if query.is_a?(Array)
|
|
121
|
+
|
|
122
|
+
return record.matches?(query)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
return false unless record.respond_to?(key)
|
|
126
|
+
|
|
127
|
+
value = record.public_send(key)
|
|
128
|
+
|
|
129
|
+
case query
|
|
130
|
+
when Proc then query.call(value)
|
|
131
|
+
when Array then query.any? { |q| matches_value?(value, q) }
|
|
132
|
+
else matches_value?(value, query)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def matches_value?(value, query)
|
|
137
|
+
case query
|
|
138
|
+
when Range then !value.nil? && query.cover?(value)
|
|
139
|
+
when Regexp then matchable(value).any? { |candidate| candidate.match?(query) }
|
|
140
|
+
else
|
|
141
|
+
# Delegating to the model lets `where(subject: "MAT")` hit either the short
|
|
142
|
+
# code or the full name without the caller knowing which.
|
|
143
|
+
return value.matches?(query) if value.is_a?(Model)
|
|
144
|
+
return value == query if query.is_a?(Model)
|
|
145
|
+
|
|
146
|
+
matchable(value).any? { |candidate| candidate.casecmp?(query.to_s) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def matchable(value)
|
|
151
|
+
case value
|
|
152
|
+
when Model then value.match_strings
|
|
153
|
+
when Array then value.flat_map { |v| matchable(v) }
|
|
154
|
+
when nil then []
|
|
155
|
+
else [value.to_s]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def sort(records)
|
|
160
|
+
records.sort do |a, b|
|
|
161
|
+
@order.reduce(0) do |result, (key, direction)|
|
|
162
|
+
next result unless result.zero?
|
|
163
|
+
|
|
164
|
+
comparison = compare(read(a, key), read(b, key))
|
|
165
|
+
direction.to_sym == :desc ? -comparison : comparison
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def read(record, key) = record.respond_to?(key) ? record.public_send(key) : nil
|
|
171
|
+
|
|
172
|
+
# nil sorts last, so an unset due date does not lead the list.
|
|
173
|
+
def compare(left, right)
|
|
174
|
+
return 0 if left.nil? && right.nil?
|
|
175
|
+
return 1 if left.nil?
|
|
176
|
+
return -1 if right.nil?
|
|
177
|
+
|
|
178
|
+
# Models sort by their display name; mixed types fall back to string order.
|
|
179
|
+
left = left.name.to_s if left.is_a?(Model)
|
|
180
|
+
right = right.name.to_s if right.is_a?(Model)
|
|
181
|
+
|
|
182
|
+
(left <=> right) || (left.to_s <=> right.to_s)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# One school reachable by the account, and the owner of everything fetched from it.
|
|
3
|
+
#
|
|
4
|
+
# Fetching is centralised here for two reasons:
|
|
5
|
+
#
|
|
6
|
+
# * /user/ is one 375 KB request that backs roughly ten different collections, so it
|
|
7
|
+
# is fetched once per (child, year) and shared.
|
|
8
|
+
# * The child and year cursors live in the session, so every fetch has to be wrapped
|
|
9
|
+
# in Session#with_cursor. Doing that in one place keeps models from having to know
|
|
10
|
+
# about it.
|
|
11
|
+
class School
|
|
12
|
+
USER_PATH = "/user/".freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :session, :cache
|
|
15
|
+
|
|
16
|
+
def initialize(session, cache: Cache.new)
|
|
17
|
+
@session = session
|
|
18
|
+
@cache = cache
|
|
19
|
+
@documents = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def origin = session.origin
|
|
23
|
+
def role = session.role
|
|
24
|
+
def parent? = session.parent?
|
|
25
|
+
def name = document.school_name
|
|
26
|
+
def id = origin
|
|
27
|
+
def short = origin
|
|
28
|
+
|
|
29
|
+
def match_strings = [origin, name].compact
|
|
30
|
+
def matches?(query)
|
|
31
|
+
return true if query.nil?
|
|
32
|
+
|
|
33
|
+
case query
|
|
34
|
+
when Regexp then match_strings.any? { |c| c.match?(query) }
|
|
35
|
+
else match_strings.any? { |c| c.casecmp?(query.to_s) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# --- school directory -----------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def students(year: nil)
|
|
42
|
+
scope = parent? ? own_children(year: year) : build(Models::Student, "students", year: year)
|
|
43
|
+
Relation.wrap(scope)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def teachers(year: nil) = Relation.wrap(build(Models::Teacher, "teachers", year: year))
|
|
47
|
+
def parents(year: nil) = Relation.wrap(build(Models::Parent, "parents", year: year))
|
|
48
|
+
def classes(year: nil) = Relation.wrap(build(Models::SchoolClass, "classes", year: year))
|
|
49
|
+
def classrooms(year: nil) = Relation.wrap(build(Models::Classroom, "classrooms", year: year))
|
|
50
|
+
def subjects(year: nil) = Relation.wrap(build(Models::Subject, "subjects", year: year))
|
|
51
|
+
def periods(year: nil) = Relation.wrap(build(Models::Period, "periods", year: year))
|
|
52
|
+
|
|
53
|
+
# id lookups, memoized per year so resolving a lesson's teacher is not O(n) each time
|
|
54
|
+
# Looks through every pupil, not only a parent's own children, because timeline
|
|
55
|
+
# items and grades can reference classmates.
|
|
56
|
+
def student(id)
|
|
57
|
+
return nil if id.nil? || id.to_s.empty?
|
|
58
|
+
|
|
59
|
+
(@student_index ||= all_students.to_h { |s| [s.id, s] })[id.to_s]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def teacher(id) = lookup(:teachers, id)
|
|
63
|
+
def parent(id) = lookup(:parents, id)
|
|
64
|
+
def school_class(id) = lookup(:classes, id)
|
|
65
|
+
def classroom(id) = lookup(:classrooms, id)
|
|
66
|
+
def subject(id) = lookup(:subjects, id)
|
|
67
|
+
def period(id) = lookup(:periods, id)
|
|
68
|
+
|
|
69
|
+
# --- years ----------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
# The real current school year, regardless of where the session cursor happens to
|
|
72
|
+
# be pointing.
|
|
73
|
+
def current_year
|
|
74
|
+
@current_year ||= begin
|
|
75
|
+
# Bootstrap: the year cursor is shared and persists across processes, so a
|
|
76
|
+
# previous command may have left it in the past. Read the page once without
|
|
77
|
+
# pinning anything, take autoYear from it - that value ignores the cursor - and
|
|
78
|
+
# keep the document if it happens to already be the right year.
|
|
79
|
+
doc = fetch_document(child: nil, year: nil)
|
|
80
|
+
@documents[[nil, doc.current_year]] ||= doc if doc.selected_year == doc.current_year
|
|
81
|
+
doc.current_year
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Every school year the student has data for, newest first.
|
|
86
|
+
#
|
|
87
|
+
# yearterms lists them all in one response, together with each term's date span and
|
|
88
|
+
# grade count, so the listing needs exactly one fetch - pinned to the current year
|
|
89
|
+
# so repeated runs agree. Asking Edupage about each year separately would be both
|
|
90
|
+
# slower and unreliable: switching to a year the child was never enrolled in is
|
|
91
|
+
# silently ignored, and the page trails the switch by a request or two.
|
|
92
|
+
def years_for(student)
|
|
93
|
+
@years ||= {}
|
|
94
|
+
@years[student.id] ||= begin
|
|
95
|
+
rows = znamky_for(student, year: current_year).year_terms
|
|
96
|
+
names = rows.to_h { |row| [row["yearid"].to_s, row["yearName"]] }
|
|
97
|
+
ids = (rows.map { |row| row["yearid"].to_s } + [current_year.to_s])
|
|
98
|
+
.uniq.reject(&:empty?).sort.reverse
|
|
99
|
+
|
|
100
|
+
Relation.wrap(
|
|
101
|
+
ids.map do |id|
|
|
102
|
+
Year.new(school: self, student: student, id: id, name: names[id],
|
|
103
|
+
current: id == current_year.to_s,
|
|
104
|
+
term_rows: rows.select { |row| row["yearid"].to_s == id })
|
|
105
|
+
end
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The parsed grades page for a student, year and half-year.
|
|
111
|
+
#
|
|
112
|
+
# Child and year are session cursors; the half-year is a query parameter. Omitting
|
|
113
|
+
# the term yields whichever half Edupage considers current, which is what the year
|
|
114
|
+
# and term listings are read from.
|
|
115
|
+
def znamky_for(student, year: nil, term: nil)
|
|
116
|
+
year = (year || current_year).to_s
|
|
117
|
+
key = [student.id, year, term&.to_s]
|
|
118
|
+
|
|
119
|
+
(@znamky ||= {})[key] ||= settle({ year: year, student: student.id },
|
|
120
|
+
"grades for #{student.full_name}") do
|
|
121
|
+
parsed = fetch_znamky(student, year, term)
|
|
122
|
+
[parsed, { year: parsed.year_id, student: parsed.student_id }]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- timetable ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
# range may be nil (today), a Date, or a Date range.
|
|
129
|
+
def timetable_for(student, range = nil)
|
|
130
|
+
case range
|
|
131
|
+
when nil then day_for(student, Date.today)
|
|
132
|
+
when Date then day_for(student, range)
|
|
133
|
+
when Range then Relation.new(-> { days_for(student, range.first, range.last) })
|
|
134
|
+
else raise ArgumentError, "Expected a Date or a Date range, got #{range.class}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def lessons_for(student, date = nil)
|
|
139
|
+
day = timetable_for(student, date || Date.today)
|
|
140
|
+
day ? day.lessons : Relation.wrap([])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- timeline and assignments ---------------------------------------------------
|
|
144
|
+
|
|
145
|
+
# The whole feed, or just one student's share of it.
|
|
146
|
+
#
|
|
147
|
+
# For a parent the feed is shared across children, so one fetch serves everyone and
|
|
148
|
+
# the split happens locally via childGroups.
|
|
149
|
+
def timeline_for(student = nil)
|
|
150
|
+
Relation.new(lambda {
|
|
151
|
+
rows = student ? feed.for_student(student.id) : feed.items
|
|
152
|
+
rows.map { |row| build_timeline_item(row, student) }
|
|
153
|
+
})
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def assignments_for(student = nil)
|
|
157
|
+
Relation.new(lambda {
|
|
158
|
+
rows = student ? feed.for_student(student.id) : feed.items
|
|
159
|
+
rows.select { |row| Models::Assignment.assignment?(row) }
|
|
160
|
+
.map { |row| Models::Assignment.new(row, school: self, student: student) }
|
|
161
|
+
})
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Turns an Edupage userstring ("Student113506", "Ucitel37135", "Rodic-20079") into
|
|
165
|
+
# the record it names, when we have one.
|
|
166
|
+
def resolve_user_string(value)
|
|
167
|
+
return nil if value.nil? || value.to_s.empty?
|
|
168
|
+
|
|
169
|
+
match = value.to_s.match(/\A(Student|StudentOnly|Ucitel|Rodic)(-?\d+)\z/)
|
|
170
|
+
return nil unless match
|
|
171
|
+
|
|
172
|
+
case match[1]
|
|
173
|
+
when "Student", "StudentOnly" then student(match[2])
|
|
174
|
+
when "Ucitel" then teacher(match[2])
|
|
175
|
+
when "Rodic" then parent(match[2])
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --- fetching -------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
# The parsed /user/ document for a given child and year.
|
|
182
|
+
#
|
|
183
|
+
# Edupage usually applies a year switch immediately but occasionally serves a
|
|
184
|
+
# dashboard from before it (several app servers, each with its own cache), so the
|
|
185
|
+
# year the document reports is verified and the fetch retried once. Without this,
|
|
186
|
+
# `school.year(2025).classes` can silently return this year's class list.
|
|
187
|
+
def document(child: nil, year: nil)
|
|
188
|
+
# Never fall back to whatever year the shared session was left on: pin it.
|
|
189
|
+
year ||= current_year
|
|
190
|
+
key = [child&.to_s, year&.to_s]
|
|
191
|
+
|
|
192
|
+
# Both cursors are verified, not just the year: the dashboard trails a child
|
|
193
|
+
# switch the same way, and an unverified fetch hands back one child's timetable
|
|
194
|
+
# under the other child's name.
|
|
195
|
+
@documents[key] ||= settle({ year: year, child: child }, "dashboard") do
|
|
196
|
+
parsed = fetch_document(child: child, year: year)
|
|
197
|
+
[parsed, { year: parsed.selected_year, child: parsed.logged_child }]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def reload!
|
|
202
|
+
@documents.clear
|
|
203
|
+
@lookups = nil
|
|
204
|
+
@built = nil
|
|
205
|
+
@years = nil
|
|
206
|
+
@znamky = nil
|
|
207
|
+
@current_year = nil
|
|
208
|
+
@feed = nil
|
|
209
|
+
@student_index = nil
|
|
210
|
+
self
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def to_h
|
|
214
|
+
{ origin: origin, name: name, role: role, current_year: current_year }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def inspect = "#<Edupage::School #{origin.inspect} role=#{role.inspect}>"
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
|
|
221
|
+
# How many times to refetch while waiting for a year switch to show up.
|
|
222
|
+
SETTLE_ATTEMPTS = 4
|
|
223
|
+
|
|
224
|
+
# Refetches until the page reports the child and year that were asked for.
|
|
225
|
+
#
|
|
226
|
+
# Both cursor switches are acknowledged immediately but the pages trail them by one
|
|
227
|
+
# or two requests. Serving that lagging page would be the worst possible outcome -
|
|
228
|
+
# one child's timetable or last year's grades presented as the other's - so the
|
|
229
|
+
# response is checked against what was requested and refused if it never catches up.
|
|
230
|
+
#
|
|
231
|
+
# A year the student was never enrolled in never takes effect at all, and surfaces
|
|
232
|
+
# here as NotFoundError rather than as somebody else's data.
|
|
233
|
+
def settle(expected, what)
|
|
234
|
+
wanted = expected.compact.transform_values(&:to_s)
|
|
235
|
+
return yield.first if wanted.empty?
|
|
236
|
+
|
|
237
|
+
seen = nil
|
|
238
|
+
|
|
239
|
+
SETTLE_ATTEMPTS.times do
|
|
240
|
+
parsed, actual = yield
|
|
241
|
+
actual = actual.transform_values { |v| v&.to_s }
|
|
242
|
+
return parsed if wanted.all? { |key, value| actual[key] == value }
|
|
243
|
+
|
|
244
|
+
seen = actual
|
|
245
|
+
Edupage.logger.debug("#{origin}: #{what} came back as #{actual.inspect}, waiting for #{wanted.inspect}")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
raise NotFoundError,
|
|
249
|
+
"#{origin} never returned #{what} for #{describe(wanted)} " \
|
|
250
|
+
"(it keeps answering with #{describe(seen)}). The student may not have been enrolled then."
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def describe(values)
|
|
254
|
+
return "nothing" if values.nil?
|
|
255
|
+
|
|
256
|
+
values.map { |key, value| "#{key}=#{value}" }.join(" ")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def feed
|
|
260
|
+
@feed ||= Parsers::Timeline.from_userhome(document)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Assignment rows become Assignments; everything else stays a plain item.
|
|
264
|
+
def build_timeline_item(row, student)
|
|
265
|
+
klass = Models::Assignment.assignment?(row) ? Models::Assignment : Models::TimelineItem
|
|
266
|
+
klass.new(row, school: self, student: student)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Every pupil in the school, as opposed to #students which narrows to a parent's
|
|
270
|
+
# own children.
|
|
271
|
+
def all_students(year: nil)
|
|
272
|
+
build(Models::Student, "students", year: year)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Cached payloads are the parsed blobs rather than the raw HTML: a tenth of the
|
|
276
|
+
# size, and no need to re-scan 375 KB on every read.
|
|
277
|
+
def fetch_znamky(student, year, term)
|
|
278
|
+
blob = cache.fetch(origin, year, student.id, "znamky-#{term || "current"}",
|
|
279
|
+
kind: :znamky, immutable: closed_year?(year)) do
|
|
280
|
+
html = session.with_cursor(child: student.id, year: year) do |s|
|
|
281
|
+
s.get(Parsers::Znamky.path_for(term: term)).body
|
|
282
|
+
end
|
|
283
|
+
parsed = Parsers::Znamky.parse(html)
|
|
284
|
+
{ "data" => parsed.data, "settings" => parsed.settings }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
Parsers::Znamky.new(blob["data"], settings: blob["settings"] || {})
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def fetch_document(child:, year:)
|
|
291
|
+
blob = cache.fetch(origin, year, child || "default", "user",
|
|
292
|
+
kind: :document, immutable: closed_year?(year)) do
|
|
293
|
+
html = session.with_cursor(child: child, year: year) { |s| s.get(USER_PATH).body }
|
|
294
|
+
parsed = Parsers::Userhome.parse(html)
|
|
295
|
+
{ "data" => parsed.data, "edubar" => parsed.edubar, "asc" => parsed.asc }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
Parsers::Userhome.new(blob["data"], edubar: blob["edubar"] || {}, asc: blob["asc"] || {})
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# A year that has ended can no longer change, so its pages are cached indefinitely.
|
|
302
|
+
def closed_year?(year)
|
|
303
|
+
year && @current_year && year.to_s < @current_year.to_s
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# For a parent account, `students` means the account's own children rather than
|
|
307
|
+
# every pupil in the school.
|
|
308
|
+
def own_children(year: nil)
|
|
309
|
+
doc = document(year: year)
|
|
310
|
+
ids = doc.parent_student_ids
|
|
311
|
+
all = build(Models::Student, "students", year: year)
|
|
312
|
+
return all if ids.empty?
|
|
313
|
+
|
|
314
|
+
ids.filter_map { |id| all.find { |student| student.id == id } }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def build(model, collection, year: nil)
|
|
318
|
+
cache_key = [model, collection, year&.to_s]
|
|
319
|
+
(@built ||= {})[cache_key] ||=
|
|
320
|
+
document(year: year).collection(collection).map { |row| model.new(row, school: self) }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Lookups always use the currently loaded year: a lesson fetched for 2025 must
|
|
324
|
+
# resolve its teacher against 2025's directory.
|
|
325
|
+
def lookup(collection, id)
|
|
326
|
+
return nil if id.nil? || id.to_s.empty?
|
|
327
|
+
|
|
328
|
+
@lookups ||= {}
|
|
329
|
+
@lookups[collection] ||= public_send(collection).to_h { |record| [record.id, record] }
|
|
330
|
+
@lookups[collection][id.to_s]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def day_for(student, date)
|
|
334
|
+
days_for(student, date, date).first
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def days_for(student, from, to)
|
|
338
|
+
doc = document(child: student.id)
|
|
339
|
+
dates = doc.dates
|
|
340
|
+
|
|
341
|
+
missing = (from..to).map(&:iso8601) - dates.keys
|
|
342
|
+
dates = dates.merge(fetch_range(student, from, to)) unless missing.empty?
|
|
343
|
+
|
|
344
|
+
(from..to).filter_map do |date|
|
|
345
|
+
row = dates[date.iso8601]
|
|
346
|
+
Models::Day.new(row, school: self, date: date) if row
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# /user/ only ever carries about four days; anything wider comes from /gcall.
|
|
351
|
+
def fetch_range(student, from, to)
|
|
352
|
+
cache.fetch(origin, current_year, student.id, "tt-#{from.iso8601}-#{to.iso8601}",
|
|
353
|
+
kind: :timetable) do
|
|
354
|
+
Parsers::Gcall.new(self).load(student: student, from: from, to: to)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# Turns whatever a resolver returned into plain JSON-ready data.
|
|
3
|
+
#
|
|
4
|
+
# Every surface goes through here, so `edupage grades --json`, the REST response and
|
|
5
|
+
# the MCP tool result are the same bytes. Models decide their own shape in #to_h;
|
|
6
|
+
# this only deals with collections, dates and stray objects.
|
|
7
|
+
module Serializer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(result)
|
|
11
|
+
case result
|
|
12
|
+
when Relation, Array then result.map { |item| one(item) }
|
|
13
|
+
else one(result)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def one(object)
|
|
18
|
+
case object
|
|
19
|
+
when nil then nil
|
|
20
|
+
when Hash then object.transform_keys(&:to_sym).transform_values { |v| one(v) }
|
|
21
|
+
when Array then object.map { |item| one(item) }
|
|
22
|
+
when Date, Time then object.iso8601
|
|
23
|
+
when String, Numeric, TrueClass, FalseClass then object
|
|
24
|
+
else
|
|
25
|
+
object.respond_to?(:to_h) ? one(object.to_h) : object.to_s
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Value for one column of a table, addressed by method name. Predicate names are
|
|
30
|
+
# allowed so table_fields can say :class_wide? without a shadow accessor.
|
|
31
|
+
def field(object, name)
|
|
32
|
+
return nil unless object.respond_to?(name)
|
|
33
|
+
|
|
34
|
+
display(object.public_send(name))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def header(name) = name.to_s.delete_suffix("?").tr("_", " ")
|
|
38
|
+
|
|
39
|
+
def display(value)
|
|
40
|
+
case value
|
|
41
|
+
when nil then ""
|
|
42
|
+
when true then "yes"
|
|
43
|
+
when false then "no"
|
|
44
|
+
when Date then value.iso8601
|
|
45
|
+
when Time then value.strftime("%Y-%m-%d %H:%M")
|
|
46
|
+
when Array then value.map { |v| display(v) }.reject(&:empty?).join(", ")
|
|
47
|
+
when Relation then display(value.to_a)
|
|
48
|
+
when Model then value.name.to_s
|
|
49
|
+
else value.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|