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,46 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Edupage
|
|
4
|
+
module Parsers
|
|
5
|
+
# The timeline is the school's activity feed: messages, homework, absences, canteen
|
|
6
|
+
# menus, sign-ups, substitutions.
|
|
7
|
+
#
|
|
8
|
+
# For a parent it is a single feed covering every child, not one feed per child, so
|
|
9
|
+
# nothing needs switching to read it - but each item has to be attributed. That is
|
|
10
|
+
# what childGroups is for: it lists every userstring that stands for a given child
|
|
11
|
+
# ("Student113506", "Trieda-2", "CustPlan8503", ...), and an item belongs to the
|
|
12
|
+
# child whose list contains its recipient.
|
|
13
|
+
class Timeline
|
|
14
|
+
PATH = "/timeline/?akcia=getData".freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :items, :child_groups
|
|
17
|
+
|
|
18
|
+
def initialize(items, child_groups: {})
|
|
19
|
+
@items = Array(items)
|
|
20
|
+
@child_groups = child_groups
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# /user/ already carries the recent feed, so the common case needs no extra fetch.
|
|
24
|
+
def self.from_userhome(document)
|
|
25
|
+
new(document.items, child_groups: document.child_groups)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.parse(json, child_groups: {})
|
|
29
|
+
payload = json.is_a?(String) ? JSON.parse(json) : json
|
|
30
|
+
new(payload["items"] || payload["timelineItems"] || [], child_groups: child_groups)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Userstrings that stand for the given student.
|
|
34
|
+
def groups_for(student_id)
|
|
35
|
+
Array(child_groups[student_id.to_s]).to_set
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def for_student(student_id)
|
|
39
|
+
groups = groups_for(student_id)
|
|
40
|
+
return items if groups.empty?
|
|
41
|
+
|
|
42
|
+
items.select { |item| groups.include?(item["user"].to_s) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Parsers
|
|
3
|
+
# Reads /user/, which is the workhorse page: one fetch carries the whole school
|
|
4
|
+
# directory (dbi), four days of timetable (dp), the timeline feed (items) and the
|
|
5
|
+
# per-child group membership needed to attribute those items.
|
|
6
|
+
#
|
|
7
|
+
# Three separate payloads live in that HTML and all three matter:
|
|
8
|
+
# .userhome({...}) the data above
|
|
9
|
+
# .edubar({...}) session state - selected year, current child
|
|
10
|
+
# ASC.x = y; school identity and the gsecHash needed by /gcall
|
|
11
|
+
class Userhome
|
|
12
|
+
def self.parse(html)
|
|
13
|
+
new(
|
|
14
|
+
Base.extract_blob!(html, ".userhome"),
|
|
15
|
+
edubar: Base.extract_blob(html, ".edubar") || {},
|
|
16
|
+
asc: Base.extract_assignments(html, "ASC")
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :data, :edubar, :asc
|
|
21
|
+
|
|
22
|
+
def initialize(data, edubar: {}, asc: {})
|
|
23
|
+
@data = data
|
|
24
|
+
@edubar = edubar
|
|
25
|
+
@asc = asc
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# --- school directory ---------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def dbi = data["dbi"] || {}
|
|
31
|
+
|
|
32
|
+
# dbi collections are keyed by id, except periods which arrive as an array.
|
|
33
|
+
def collection(name) = normalize(dbi[name])
|
|
34
|
+
|
|
35
|
+
# --- timetable ----------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def dates = data.dig("dp", "dates") || {}
|
|
38
|
+
|
|
39
|
+
# --- timeline -----------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def items = Array(data["items"])
|
|
42
|
+
|
|
43
|
+
# Maps a child id to every userstring that stands for them ("Student113506",
|
|
44
|
+
# "Trieda-2", "CustPlan8503", ...). Timeline items are addressed to one of these,
|
|
45
|
+
# so this is how a shared parent feed is split per child.
|
|
46
|
+
def child_groups = data["childGroups"] || {}
|
|
47
|
+
|
|
48
|
+
def parent_student_ids = Array(data["parentStudentids"]).map(&:to_s)
|
|
49
|
+
|
|
50
|
+
# --- identity and session state -----------------------------------------------
|
|
51
|
+
|
|
52
|
+
def user_id = data["userid"] || edubar["loggedUser"]
|
|
53
|
+
def user_row = data["userrow"] || {}
|
|
54
|
+
def user_name = edubar["loggedUserName"] || [user_row["p_meno"], user_row["p_priezvisko"]].compact.join(" ")
|
|
55
|
+
def login = user_row["p_www_login"]
|
|
56
|
+
def email = user_row["p_mail"]
|
|
57
|
+
|
|
58
|
+
def school_name = asc["school_name"]
|
|
59
|
+
def origin = asc["edupage"] || edubar["edupage"]
|
|
60
|
+
def language = asc["lang"]
|
|
61
|
+
def gsec_hash = asc["gsechash"]
|
|
62
|
+
|
|
63
|
+
# The year the session is pointed at, which is not necessarily the current one.
|
|
64
|
+
def selected_year = edubar["selectedYear"]&.to_s
|
|
65
|
+
|
|
66
|
+
# The real current year, unaffected by the cursor.
|
|
67
|
+
def current_year = edubar["autoYear"]&.to_s || selected_year
|
|
68
|
+
|
|
69
|
+
def logged_child = edubar["loggedChild"]&.to_s
|
|
70
|
+
|
|
71
|
+
def teaching_days = Array(edubar["vyucovacieDni"])
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def normalize(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash then value.values
|
|
78
|
+
when Array then value
|
|
79
|
+
else []
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
module Parsers
|
|
3
|
+
# Reads /znamky/, which carries a whole school year for the currently selected
|
|
4
|
+
# child: the grades themselves, the events they hang off, and the list of years and
|
|
5
|
+
# terms the child has data for.
|
|
6
|
+
#
|
|
7
|
+
# The page takes its year from the session cursor (see Session#set_year), so the
|
|
8
|
+
# caller pins the year and this just reads whatever came back.
|
|
9
|
+
class Znamky
|
|
10
|
+
PATH = "/znamky/?barNoSkin=1".freeze
|
|
11
|
+
VIEWER = "znamkyStudentViewer".freeze
|
|
12
|
+
SETTINGS = "initZnamkovanieSettings".freeze
|
|
13
|
+
|
|
14
|
+
# One request only ever returns one half-year, selected by `nadobdobie`. Asking
|
|
15
|
+
# for a year therefore means asking for each of its terms and merging.
|
|
16
|
+
def self.path_for(term: nil)
|
|
17
|
+
term ? "#{PATH}&nadobdobie=#{term}" : PATH
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.parse(html)
|
|
21
|
+
new(Base.extract_blob!(html, VIEWER), settings: Base.extract_blob(html, SETTINGS) || {})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :data, :settings
|
|
25
|
+
|
|
26
|
+
def initialize(data, settings: {})
|
|
27
|
+
@data = data
|
|
28
|
+
@settings = settings
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def student_id = data["studentid"]&.to_s
|
|
32
|
+
def year_id = data["yearid"]&.to_s
|
|
33
|
+
def year_name = data["skRok"]
|
|
34
|
+
|
|
35
|
+
# The half-year this response covers.
|
|
36
|
+
def term_id = data["nadobdobie"]&.to_s
|
|
37
|
+
|
|
38
|
+
def grades = Array(data["vsetkyZnamky"])
|
|
39
|
+
|
|
40
|
+
# Grades carry an event id but none of the interesting metadata; the title,
|
|
41
|
+
# weight, class average and maximum points all live on the event.
|
|
42
|
+
# Shape: { provider => { event_id => event } }
|
|
43
|
+
def events
|
|
44
|
+
@events ||= (data["vsetkyUdalosti"] || {}).each_with_object({}) do |(provider, rows), result|
|
|
45
|
+
result[provider] = normalize(rows).to_h { |event| [event["UdalostID"].to_s, event] }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def event_for(grade)
|
|
50
|
+
events.dig(grade["provider"].to_s, grade["udalostid"].to_s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Every (year, term) pair the child has, each with how many grades are in it -
|
|
54
|
+
# the cheapest way to find where the data actually is.
|
|
55
|
+
def year_terms = Array(data["yearterms"])
|
|
56
|
+
|
|
57
|
+
# Period definitions for the year, keyed by id (P1, KL1, V1, P2, ...).
|
|
58
|
+
#
|
|
59
|
+
# A grade's `mesiac` is one of these sub-periods, not the half-year itself: the
|
|
60
|
+
# P2 response contains grades marked P2 and V2 ("vysvedčenie"). `nadobdobie`
|
|
61
|
+
# points a sub-period at the half-year it belongs to.
|
|
62
|
+
def periods = settings["obdobia"] || {}
|
|
63
|
+
|
|
64
|
+
# Half-year a sub-period rolls up into; a half-year maps to itself.
|
|
65
|
+
def parent_period(id)
|
|
66
|
+
periods.dig(id.to_s, "nadobdobie") || id.to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Subjects this child is graded in, which is a subset of the school's subjects.
|
|
70
|
+
def subjects = normalize(data["predmety"])
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize(value)
|
|
75
|
+
case value
|
|
76
|
+
when Hash then value.values
|
|
77
|
+
when Array then value
|
|
78
|
+
else []
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# Every resource this tool exposes, declared once.
|
|
3
|
+
#
|
|
4
|
+
# Adding a resource here gives it a CLI command, a REST route and an MCP tool with no
|
|
5
|
+
# further work; leaving one out of any surface is impossible by construction.
|
|
6
|
+
class Registry
|
|
7
|
+
# --- account ----------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
resource :account do
|
|
10
|
+
scope :account
|
|
11
|
+
summary "The logged-in account"
|
|
12
|
+
table_fields :username, :name
|
|
13
|
+
resolve ->(account, _) { account }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
resource :schools do
|
|
17
|
+
scope :account
|
|
18
|
+
summary "Schools this account can reach"
|
|
19
|
+
table_fields :origin, :name, :role
|
|
20
|
+
resolve ->(account, _) { account.schools }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --- school directory -------------------------------------------------------------
|
|
24
|
+
#
|
|
25
|
+
# These all read one /user/ document, so asking for several in a row costs one fetch.
|
|
26
|
+
|
|
27
|
+
resource :students do
|
|
28
|
+
scope :school
|
|
29
|
+
summary "Students visible to the account (a parent sees their own children)"
|
|
30
|
+
param :name, desc: "Filter by name"
|
|
31
|
+
param :class, desc: "Filter by class, e.g. 2.A"
|
|
32
|
+
table_fields :full_name, :class_name, :number_in_class
|
|
33
|
+
resolve lambda { |school, p|
|
|
34
|
+
school.students(year: p[:year]).where(name: p[:name], school_class: p[:class])
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
resource :teachers do
|
|
39
|
+
scope :school
|
|
40
|
+
summary "Teachers"
|
|
41
|
+
param :name, desc: "Filter by name or short code"
|
|
42
|
+
table_fields :short, :full_name_with_titles
|
|
43
|
+
resolve ->(school, p) { school.teachers(year: p[:year]).where(name: p[:name]) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
resource :parents do
|
|
47
|
+
scope :school
|
|
48
|
+
summary "Parents"
|
|
49
|
+
param :name, desc: "Filter by name"
|
|
50
|
+
table_fields :full_name
|
|
51
|
+
resolve ->(school, p) { school.parents(year: p[:year]).where(name: p[:name]) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
resource :classes do
|
|
55
|
+
scope :school
|
|
56
|
+
summary "Classes"
|
|
57
|
+
param :name, desc: "Filter by name"
|
|
58
|
+
table_fields :name, :grade, :teacher
|
|
59
|
+
resolve ->(school, p) { school.classes(year: p[:year]).where(name: p[:name]) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
resource :classrooms do
|
|
63
|
+
scope :school
|
|
64
|
+
summary "Rooms"
|
|
65
|
+
param :name, desc: "Filter by name"
|
|
66
|
+
table_fields :short, :name
|
|
67
|
+
resolve ->(school, p) { school.classrooms(year: p[:year]).where(name: p[:name]) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
resource :subjects do
|
|
71
|
+
scope :school
|
|
72
|
+
summary "Subjects"
|
|
73
|
+
param :name, desc: "Filter by name or short code"
|
|
74
|
+
table_fields :short, :name
|
|
75
|
+
resolve ->(school, p) { school.subjects(year: p[:year]).where(name: p[:name]) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
resource :periods do
|
|
79
|
+
scope :school
|
|
80
|
+
summary "The bell schedule"
|
|
81
|
+
table_fields :name, :start_time, :end_time
|
|
82
|
+
resolve ->(school, p) { school.periods(year: p[:year]) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- student ----------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
resource :timetable do
|
|
88
|
+
scope :student
|
|
89
|
+
summary "Timetable days"
|
|
90
|
+
param :date, type: :date, desc: "A single day (defaults to today)"
|
|
91
|
+
param :from, type: :date, desc: "Start of a range"
|
|
92
|
+
param :to, type: :date, desc: "End of a range"
|
|
93
|
+
table_fields :date, :lesson_summary
|
|
94
|
+
resolve lambda { |student, p|
|
|
95
|
+
if p[:from] || p[:to]
|
|
96
|
+
from = p[:from] || Date.today
|
|
97
|
+
student.timetable(from..(p[:to] || from + 6))
|
|
98
|
+
else
|
|
99
|
+
Relation.wrap([student.timetable(p[:date])].compact)
|
|
100
|
+
end
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
resource :lessons do
|
|
105
|
+
scope :student
|
|
106
|
+
summary "Lessons on one day"
|
|
107
|
+
param :date, type: :date, desc: "Defaults to today"
|
|
108
|
+
param :subject, desc: "Filter by subject"
|
|
109
|
+
table_fields :period_number, :subject, :teachers, :classrooms, :start_time, :end_time
|
|
110
|
+
resolve ->(student, p) { student.lessons(p[:date]).where(subject: p[:subject]) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
resource :assignments do
|
|
114
|
+
scope :student
|
|
115
|
+
summary "Homework, tests, exams and projects"
|
|
116
|
+
param :type, desc: "Filter by type, e.g. hw or test"
|
|
117
|
+
param :subject, desc: "Filter by subject"
|
|
118
|
+
param :due, type: :date, desc: "Only items due on or after this date"
|
|
119
|
+
table_fields :due_on, :type, :subject, :title
|
|
120
|
+
resolve lambda { |student, p|
|
|
121
|
+
scope = student.assignments.where(type: p[:type], subject: p[:subject])
|
|
122
|
+
scope = scope.where(due_on: p[:due]..) if p[:due]
|
|
123
|
+
scope.order(:due_on)
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
resource :homeworks do
|
|
128
|
+
scope :student
|
|
129
|
+
summary "Homework only, including tasks set to one pupil rather than the class"
|
|
130
|
+
param :subject, desc: "Filter by subject"
|
|
131
|
+
param :due, type: :date, desc: "Only items due on or after this date"
|
|
132
|
+
table_fields :due_on, :subject, :title, :class_wide?
|
|
133
|
+
resolve lambda { |student, p|
|
|
134
|
+
scope = student.homeworks.where(subject: p[:subject])
|
|
135
|
+
scope = scope.where(due_on: p[:due]..) if p[:due]
|
|
136
|
+
scope.order(:due_on)
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
resource :timeline do
|
|
141
|
+
scope :student
|
|
142
|
+
summary "The school feed for this student"
|
|
143
|
+
param :type, desc: "Filter by item type, e.g. message or homework"
|
|
144
|
+
table_fields :created_at, :type, :name
|
|
145
|
+
resolve ->(student, p) { student.timeline.where(type: p[:type]).order(created_at: :desc) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
resource :years do
|
|
149
|
+
scope :student
|
|
150
|
+
summary "School years this student has data for"
|
|
151
|
+
table_fields :id, :name, :current?, :grade_count
|
|
152
|
+
resolve ->(student, _) { student.years }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- year -------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
resource :terms do
|
|
158
|
+
scope :year
|
|
159
|
+
summary "Terms of a school year, with how many grades each holds"
|
|
160
|
+
table_fields :id, :name, :starts_on, :ends_on, :grade_count
|
|
161
|
+
resolve ->(year, _) { year.terms }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
resource :grades do
|
|
165
|
+
scope :year
|
|
166
|
+
summary "Grades for a school year"
|
|
167
|
+
param :term, type: :enum, values: %w[P1 P2], desc: "Half-year"
|
|
168
|
+
param :subject, desc: "Filter by subject"
|
|
169
|
+
table_fields :created_at, :subject, :value, :weight, :title
|
|
170
|
+
resolve lambda { |year, p|
|
|
171
|
+
year.grades.where(term: p[:term], subject: p[:subject]).order(:created_at)
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
# The single description of what this tool can read.
|
|
3
|
+
#
|
|
4
|
+
# The library is hand-written and idiomatic; the CLI, the REST API and the MCP server
|
|
5
|
+
# are all generated from the declarations here. Nothing about a resource - its name,
|
|
6
|
+
# its filters, their types, what it returns - is written down more than once, so the
|
|
7
|
+
# three surfaces cannot drift apart. spec/registry_parity_spec.rb enforces that.
|
|
8
|
+
#
|
|
9
|
+
# Registry.resource :grades do
|
|
10
|
+
# scope :year
|
|
11
|
+
# summary "Grades for a school year"
|
|
12
|
+
# param :term, type: :enum, values: %w[P1 P2], desc: "Half-year"
|
|
13
|
+
# resolve ->(year, p) { year.grades.where(term: p[:term]) }
|
|
14
|
+
# end
|
|
15
|
+
class Registry
|
|
16
|
+
# Which session cursors a resource needs, and therefore which object resolves it.
|
|
17
|
+
#
|
|
18
|
+
# The scopes nest: a year implies a student, a student implies a school. Surfaces
|
|
19
|
+
# read this to build their own shape - URL segments for REST, options for the CLI,
|
|
20
|
+
# input schema properties for MCP - so a resource can never be wired up to one
|
|
21
|
+
# surface and forgotten in another.
|
|
22
|
+
# `chain` is the levels a resource actually stands on; `params` additionally carries
|
|
23
|
+
# the optional year filter that school- and student-scoped resources accept. The two
|
|
24
|
+
# differ on purpose: `students` takes `--year` to pick which year's directory to
|
|
25
|
+
# read, but it is not a year-scoped resource and must never require a student.
|
|
26
|
+
SCOPES = {
|
|
27
|
+
account: { receiver: :account, chain: [], params: [] },
|
|
28
|
+
school: { receiver: :school, chain: %i[school], params: %i[school year] },
|
|
29
|
+
student: { receiver: :student, chain: %i[school student], params: %i[school student year] },
|
|
30
|
+
year: { receiver: :year, chain: %i[school student year], params: %i[school student year] }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Parameters implied by the scope rather than declared by the resource.
|
|
34
|
+
#
|
|
35
|
+
# school and student are required wherever they apply: no level of the chain may be
|
|
36
|
+
# skipped, and a caller that omits one is told what it could have chosen. The year is
|
|
37
|
+
# the exception - "now" is unambiguous, so it defaults to the current year and the
|
|
38
|
+
# CLI shows which one it used.
|
|
39
|
+
SCOPE_PARAMS = {
|
|
40
|
+
school: { type: :string, desc: "School origin, e.g. zsdemo", required: true },
|
|
41
|
+
student: { type: :string, desc: "Student name or id", required: true },
|
|
42
|
+
year: { type: :string, desc: "School year, e.g. 2025 (defaults to the current one)",
|
|
43
|
+
required: false }
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
Param = Struct.new(:name, :type, :desc, :values, :required, keyword_init: true) do
|
|
47
|
+
def enum? = type == :enum
|
|
48
|
+
|
|
49
|
+
def json_schema
|
|
50
|
+
base = case type
|
|
51
|
+
when :integer then { "type" => "integer" }
|
|
52
|
+
when :boolean then { "type" => "boolean" }
|
|
53
|
+
when :date then { "type" => "string", "format" => "date" }
|
|
54
|
+
else { "type" => "string" }
|
|
55
|
+
end
|
|
56
|
+
base["enum"] = values if enum? && values
|
|
57
|
+
base["description"] = desc if desc
|
|
58
|
+
base
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One readable thing, described once.
|
|
63
|
+
class Resource
|
|
64
|
+
attr_reader :name
|
|
65
|
+
|
|
66
|
+
def initialize(name)
|
|
67
|
+
@name = name.to_sym
|
|
68
|
+
@scope = :account
|
|
69
|
+
@summary = nil
|
|
70
|
+
@params = {}
|
|
71
|
+
@table_fields = []
|
|
72
|
+
@resolver = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# --- DSL ----------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def scope(value = nil)
|
|
78
|
+
return @scope if value.nil?
|
|
79
|
+
|
|
80
|
+
unless SCOPES.key?(value)
|
|
81
|
+
raise ArgumentError, "Unknown scope #{value.inspect}; expected one of #{SCOPES.keys.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@scope = value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def summary(value = nil)
|
|
88
|
+
value.nil? ? @summary : (@summary = value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def param(name, type: :string, desc: nil, values: nil, required: false)
|
|
92
|
+
@params[name.to_sym] = Param.new(name: name.to_sym, type: type, desc: desc,
|
|
93
|
+
values: values, required: required)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def table_fields(*fields)
|
|
97
|
+
fields.empty? ? @table_fields : (@table_fields = fields.flatten.map(&:to_sym))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def resolve(callable = nil, &block)
|
|
101
|
+
@resolver = callable || block
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- reading ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def own_params = @params.values
|
|
107
|
+
|
|
108
|
+
# Scope parameters first, then the resource's own filters.
|
|
109
|
+
def all_params
|
|
110
|
+
scope_params + own_params
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def scope_params
|
|
114
|
+
SCOPES.fetch(@scope)[:params].map do |key|
|
|
115
|
+
Param.new(name: key, **SCOPE_PARAMS.fetch(key))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def receiver_kind = SCOPES.fetch(@scope)[:receiver]
|
|
120
|
+
|
|
121
|
+
# Levels of the chain this resource actually stands on, outermost first. Surfaces
|
|
122
|
+
# use it to show which school, student and year an answer came from.
|
|
123
|
+
def scope_chain = SCOPES.fetch(@scope)[:chain]
|
|
124
|
+
|
|
125
|
+
def singular? = @table_fields.empty? && name.to_s.end_with?("account")
|
|
126
|
+
|
|
127
|
+
# REST path, derived from the scope so it cannot disagree with the CLI or MCP.
|
|
128
|
+
def rest_path
|
|
129
|
+
case @scope
|
|
130
|
+
when :account then "/api/v1/#{name}"
|
|
131
|
+
when :school then "/api/v1/schools/:school/#{name}"
|
|
132
|
+
when :student then "/api/v1/schools/:school/students/:student/#{name}"
|
|
133
|
+
when :year then "/api/v1/schools/:school/students/:student/years/:year/#{name}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def tool_name = "edupage_#{name}"
|
|
138
|
+
|
|
139
|
+
def call(context, params = {})
|
|
140
|
+
raise Error, "Resource #{name} has no resolver" unless @resolver
|
|
141
|
+
|
|
142
|
+
@resolver.call(context.receiver_for(self), normalize(params))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Drops unknown keys and blank values so surfaces can pass their raw input.
|
|
146
|
+
def normalize(params)
|
|
147
|
+
known = all_params.map(&:name)
|
|
148
|
+
params.to_h.each_with_object({}) do |(key, value), result|
|
|
149
|
+
key = key.to_sym
|
|
150
|
+
next unless known.include?(key)
|
|
151
|
+
next if value.nil? || value.to_s.empty?
|
|
152
|
+
|
|
153
|
+
result[key] = cast(key, value)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate!
|
|
158
|
+
raise ArgumentError, "Resource #{name} needs a summary" if @summary.nil?
|
|
159
|
+
raise ArgumentError, "Resource #{name} needs a resolver" if @resolver.nil?
|
|
160
|
+
|
|
161
|
+
self
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def cast(key, value)
|
|
167
|
+
param = all_params.find { |p| p.name == key }
|
|
168
|
+
case param&.type
|
|
169
|
+
when :integer then Integer(value, exception: false) || value
|
|
170
|
+
when :boolean then [true, "true", "1", 1].include?(value)
|
|
171
|
+
when :date then value.is_a?(Date) ? value : (Date.parse(value.to_s) rescue value)
|
|
172
|
+
else value
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Resolves the object a resource should be called on, from surface-level strings.
|
|
178
|
+
#
|
|
179
|
+
# This is where "--student Jana" or ".../students/113506/..." becomes a Student,
|
|
180
|
+
# once, for every surface.
|
|
181
|
+
class Context
|
|
182
|
+
def initialize(account:, school: nil, student: nil, year: nil)
|
|
183
|
+
@account = account
|
|
184
|
+
@school_ref = school
|
|
185
|
+
@student_ref = student
|
|
186
|
+
@year_ref = year
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def account = @account
|
|
190
|
+
|
|
191
|
+
# Each level resolves the same way: an explicit choice wins, a single option is
|
|
192
|
+
# taken silently, and anything else is refused rather than guessed.
|
|
193
|
+
#
|
|
194
|
+
# Config defaults deliberately do not count as a choice. `default_school` is the
|
|
195
|
+
# login anchor - which *.edupage.org host to authenticate against - and letting it
|
|
196
|
+
# also decide which school's data you are reading is how you end up looking at the
|
|
197
|
+
# wrong child's school without noticing.
|
|
198
|
+
def school
|
|
199
|
+
@school ||= @account.school(@school_ref)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def student
|
|
203
|
+
@student ||= begin
|
|
204
|
+
ref = @student_ref
|
|
205
|
+
candidates = school.students
|
|
206
|
+
|
|
207
|
+
if ref.nil?
|
|
208
|
+
raise AmbiguousScopeError.new(:student, student_candidates(candidates)) if candidates.count > 1
|
|
209
|
+
|
|
210
|
+
candidates.first or raise NotFoundError, "#{school.origin} has no students for this account"
|
|
211
|
+
else
|
|
212
|
+
resolve_student(ref, candidates) or
|
|
213
|
+
raise NotFoundError, "No student #{ref.inspect}. Available: #{candidates.map(&:full_name).join(", ")}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def year
|
|
219
|
+
@year ||= explicit_year? ? student.year(@year_ref) : student.current_year
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Whether the year came from the caller or was filled in as "now". The CLI says so
|
|
223
|
+
# in its output, because in September the current year is usually empty and the
|
|
224
|
+
# interesting data is in the previous one.
|
|
225
|
+
def year_defaulted? = !explicit_year?
|
|
226
|
+
|
|
227
|
+
def explicit_year?
|
|
228
|
+
!@year_ref.nil? && @year_ref.to_s != "current"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def receiver_for(resource)
|
|
232
|
+
public_send(resource.receiver_kind)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
# Exact match on id or full name first, then a unique partial name match, so
|
|
238
|
+
# "--student Jana" works without typing the surname. An ambiguous prefix is an
|
|
239
|
+
# error rather than an arbitrary pick.
|
|
240
|
+
def resolve_student(ref, candidates)
|
|
241
|
+
exact = candidates.find { |student| student.matches?(ref) }
|
|
242
|
+
return exact if exact
|
|
243
|
+
|
|
244
|
+
needle = ref.to_s.downcase
|
|
245
|
+
partial = candidates.select { |student| student.full_name.downcase.include?(needle) }
|
|
246
|
+
return partial.first if partial.size == 1
|
|
247
|
+
|
|
248
|
+
if partial.size > 1
|
|
249
|
+
raise NotFoundError,
|
|
250
|
+
"#{ref.inspect} matches several students: #{partial.map(&:full_name).join(", ")}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def student_candidates(students)
|
|
257
|
+
students.map do |student|
|
|
258
|
+
{ id: student.id, label: [student.full_name, student.class_name].compact.join(", ") }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
class << self
|
|
264
|
+
def resources = @resources ||= {}
|
|
265
|
+
|
|
266
|
+
def resource(name, &block)
|
|
267
|
+
entry = Resource.new(name)
|
|
268
|
+
entry.instance_eval(&block)
|
|
269
|
+
resources[entry.name] = entry.validate!
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def [](name) = resources[name.to_sym]
|
|
273
|
+
def all = resources.values
|
|
274
|
+
def names = resources.keys
|
|
275
|
+
def each(&block) = all.each(&block)
|
|
276
|
+
|
|
277
|
+
def fetch(name)
|
|
278
|
+
self[name] or raise NotFoundError, "Unknown resource #{name.inspect}; known: #{names.join(", ")}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Declarations reopen Registry rather than defining a constant of their own, so they sit
|
|
285
|
+
# outside Zeitwerk's file-to-constant mapping and are loaded here instead.
|
|
286
|
+
require_relative "registry/resources"
|