Skip to content

Commit 310cf8d

Browse files
committedFeb 19, 2024··
feat: export report card
1 parent 5546b0e commit 310cf8d

File tree

6 files changed

+387
-19
lines changed

6 files changed

+387
-19
lines changed
 

‎cmd/report-card.go

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"github.com/jedib0t/go-pretty/v6/table"
7+
"github.com/jedib0t/go-pretty/v6/text"
8+
log "github.com/sirupsen/logrus"
9+
"github.com/spf13/cobra"
10+
"lutonite.dev/gaps-cli/gaps"
11+
"lutonite.dev/gaps-cli/parser"
12+
"lutonite.dev/gaps-cli/util"
13+
"os"
14+
"strconv"
15+
)
16+
17+
type ReportCardCmdOpts struct {
18+
format string
19+
}
20+
21+
var (
22+
reportCardOpts = &ReportCardCmdOpts{}
23+
reportCardCmd = &cobra.Command{
24+
Use: "report-card",
25+
Short: "Allows to consult your report card",
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
cfg := buildTokenClientConfiguration()
28+
29+
action := gaps.NewReportCardAction(cfg)
30+
reports, err := action.FetchReportCard()
31+
util.CheckErr(err)
32+
33+
if len(reports) == 0 {
34+
log.Error("No reports found for the given parameters")
35+
return nil
36+
}
37+
38+
if reportCardOpts.format == "json" {
39+
return json.NewEncoder(os.Stdout).Encode(reports)
40+
}
41+
42+
reportCardOpts.PrintReportCardTable(reports)
43+
return nil
44+
},
45+
}
46+
)
47+
48+
func init() {
49+
reportCardCmd.Flags().StringVarP(&reportCardOpts.format, "format", "o", "table", "Output format (table, json)")
50+
51+
rootCmd.AddCommand(reportCardCmd)
52+
}
53+
54+
func (g *ReportCardCmdOpts) PrintReportCardTable(moduleReports []*parser.ModuleReport) {
55+
t := table.NewWriter()
56+
t.SetOutputMirror(os.Stdout)
57+
t.Style().Options.SeparateRows = true
58+
t.SetColumnConfigs([]table.ColumnConfig{
59+
{Number: 1, AutoMerge: true},
60+
{Number: 2, AutoMerge: true, Align: text.AlignCenter, AlignHeader: text.AlignCenter},
61+
{Number: 3, AutoMerge: true},
62+
{Number: 4, Align: text.AlignCenter, AlignHeader: text.AlignCenter},
63+
{Number: 5, Align: text.AlignCenter, AlignHeader: text.AlignCenter, AlignFooter: text.AlignRight},
64+
{Number: 6, Align: text.AlignCenter, AlignHeader: text.AlignCenter, AlignFooter: text.AlignCenter},
65+
})
66+
67+
t.AppendHeader(table.Row{"Module", "Credits", "Class", "Category", "Weight", "Grade"})
68+
69+
for _, module := range moduleReports {
70+
moduleDesc := fmt.Sprintf("%s (%s)", module.Name, module.Identifier)
71+
if module.Year > 0 {
72+
moduleDesc += fmt.Sprintf(" - %d-%d", module.Year, module.Year+1)
73+
}
74+
75+
for _, group := range module.Classes {
76+
groupDesc := fmt.Sprintf("%s (%s)", group.Name, group.Identifier)
77+
for _, grade := range group.Grades {
78+
t.AppendRow(table.Row{
79+
moduleDesc,
80+
module.Credits,
81+
groupDesc,
82+
grade.Name,
83+
fmt.Sprintf("%d%%", grade.Weight),
84+
grade.Grade,
85+
})
86+
}
87+
88+
if group.Mean != "" {
89+
t.AppendRow(table.Row{
90+
moduleDesc,
91+
module.Credits,
92+
"",
93+
"",
94+
"",
95+
fmt.Sprintf("%s (W: %d)", group.Mean, group.Weight),
96+
}, table.RowConfig{AutoMerge: true})
97+
}
98+
}
99+
100+
situation := text.Colors{text.FgGreen}.Sprint(module.Situation)
101+
t.AppendRow(table.Row{
102+
moduleDesc,
103+
module.Credits,
104+
situation,
105+
situation,
106+
situation,
107+
text.Colors{text.Bold, text.FgBlue}.Sprint(module.GlobalGrade),
108+
}, table.RowConfig{AutoMerge: true})
109+
110+
t.AppendSeparator()
111+
}
112+
113+
t.AppendFooter(table.Row{
114+
"",
115+
"",
116+
"",
117+
"",
118+
"WEIGHTED GPA",
119+
fmt.Sprintf("%.2f", computeGpa(moduleReports)),
120+
}, table.RowConfig{AutoMerge: true})
121+
122+
t.Render()
123+
}
124+
125+
func computeGpa(grades []*parser.ModuleReport) float64 {
126+
var totalCredits uint
127+
var totalPoints float64
128+
129+
for _, module := range grades {
130+
if module.Situation != "Réussite" {
131+
continue
132+
}
133+
134+
totalCredits += module.Credits
135+
gradeNumeric, _ := strconv.ParseFloat(module.GlobalGrade, 64)
136+
totalPoints += float64(module.Credits) * gradeNumeric
137+
}
138+
139+
return totalPoints / float64(totalCredits)
140+
}

‎gaps/report-card.go

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package gaps
2+
3+
import (
4+
"fmt"
5+
"golang.org/x/net/html/charset"
6+
"lutonite.dev/gaps-cli/parser"
7+
)
8+
9+
type ReportCardAction struct {
10+
cfg *TokenClientConfiguration
11+
}
12+
13+
func NewReportCardAction(config *TokenClientConfiguration) *ReportCardAction {
14+
return &ReportCardAction{
15+
cfg: config,
16+
}
17+
}
18+
19+
func (a *ReportCardAction) FetchReportCard() ([]*parser.ModuleReport, error) {
20+
req, err := a.cfg.buildRequest("GET", fmt.Sprintf("/consultation/notes/bulletin.php?id=%d", a.cfg.studentId))
21+
if err != nil {
22+
return nil, err
23+
}
24+
25+
res, err := a.cfg.doForm(req, nil)
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
defer res.Body.Close()
31+
utfBody, err := charset.NewReader(res.Body, "iso-8859-1")
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
pres, err := parser.FromResponseBody(utfBody)
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
return pres.ReportCard()
42+
}

‎go.mod

+4-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ require (
1010
github.com/spf13/cobra v1.6.1
1111
github.com/spf13/viper v1.15.0
1212
github.com/zalando/go-keyring v0.2.3
13-
golang.org/x/term v0.5.0
13+
golang.org/x/net v0.7.0
14+
golang.org/x/term v0.16.0
1415
)
1516

1617
require (
@@ -22,7 +23,7 @@ require (
2223
github.com/hashicorp/hcl v1.0.0 // indirect
2324
github.com/inconshreveable/mousetrap v1.0.1 // indirect
2425
github.com/magiconair/properties v1.8.7 // indirect
25-
github.com/mattn/go-runewidth v0.0.13 // indirect
26+
github.com/mattn/go-runewidth v0.0.15 // indirect
2627
github.com/mitchellh/mapstructure v1.5.0 // indirect
2728
github.com/pelletier/go-toml/v2 v2.0.6 // indirect
2829
github.com/rivo/uniseg v0.2.0 // indirect
@@ -31,8 +32,7 @@ require (
3132
github.com/spf13/jwalterweatherman v1.1.0 // indirect
3233
github.com/spf13/pflag v1.0.5 // indirect
3334
github.com/subosito/gotenv v1.4.2 // indirect
34-
golang.org/x/net v0.7.0 // indirect
35-
golang.org/x/sys v0.8.0 // indirect
35+
golang.org/x/sys v0.16.0 // indirect
3636
golang.org/x/text v0.7.0 // indirect
3737
gopkg.in/ini.v1 v1.67.0 // indirect
3838
gopkg.in/yaml.v3 v3.0.1 // indirect

‎go.sum

+6-4
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
148148
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
149149
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
150150
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
151-
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
152151
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
152+
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
153+
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
153154
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
154155
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
155156
github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU=
@@ -346,12 +347,13 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
346347
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
347348
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
348349
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
349-
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
350-
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
350+
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
351+
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
351352
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
352353
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
353-
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
354354
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
355+
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
356+
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
355357
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
356358
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
357359
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

‎parser/grades.go

+11-11
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ type gradesParser struct {
4141
}
4242

4343
const (
44-
classHeader gradeRowType = iota
45-
groupHeader
46-
gradeRow
47-
unknownRow
44+
gradesClassHeader gradeRowType = iota
45+
gradesGroupHeader
46+
gradesGradeRow
47+
gradesUnknownRow = -1
4848
)
4949

5050
func (s *Parser) Grades() ([]*ClassGrades, error) {
@@ -64,15 +64,15 @@ func (p gradesParser) parse() ([]*ClassGrades, error) {
6464
var globalErr error
6565
p.doc.Find("table.displayArray tbody tr").Each(func(i int, s *goquery.Selection) {
6666
switch p.getRowType(s) {
67-
case classHeader:
67+
case gradesClassHeader:
6868
class, err := p.parseClassHeader(s)
6969
if err != nil {
7070
globalErr = err
7171
return
7272
}
7373

7474
classes = append(classes, class)
75-
case groupHeader:
75+
case gradesGroupHeader:
7676
group, err := p.parseGroupHeader(s)
7777
if err != nil {
7878
globalErr = err
@@ -82,7 +82,7 @@ func (p gradesParser) parse() ([]*ClassGrades, error) {
8282
classOff := len(classes) - 1
8383

8484
classes[classOff].GradeGroups = append(classes[classOff].GradeGroups, group)
85-
case gradeRow:
85+
case gradesGradeRow:
8686
grade, err := p.parseGradeRow(s)
8787
if err != nil {
8888
globalErr = err
@@ -111,13 +111,13 @@ func (p gradesParser) parse() ([]*ClassGrades, error) {
111111

112112
func (p gradesParser) getRowType(row *goquery.Selection) gradeRowType {
113113
if row.Has("td.bigheader").Length() > 0 {
114-
return classHeader
114+
return gradesClassHeader
115115
} else if row.Has("td[rowspan]").Length() > 0 {
116-
return groupHeader
116+
return gradesGroupHeader
117117
} else if row.Find("td").Size() == 5 {
118-
return gradeRow
118+
return gradesGradeRow
119119
} else {
120-
return unknownRow
120+
return gradesUnknownRow
121121
}
122122
}
123123

‎parser/report-card.go

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package parser
2+
3+
import (
4+
"errors"
5+
"github.com/PuerkitoBio/goquery"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
type ModuleReport struct {
11+
Identifier string `json:"id"`
12+
Name string `json:"name"`
13+
Year uint `json:"year"`
14+
PassingGrade string `json:"passingGrade"`
15+
GlobalGrade string `json:"grade"`
16+
Credits uint `json:"credits"`
17+
Situation string `json:"situation"`
18+
Classes []*ModuleClass `json:"classes"`
19+
}
20+
21+
type ModuleClass struct {
22+
Identifier string `json:"id"`
23+
Name string `json:"name"`
24+
Grades []*ClassGrade `json:"grades"`
25+
Mean string `json:"mean"`
26+
Weight uint `json:"weight"`
27+
}
28+
29+
type ClassGrade struct {
30+
Name string `json:"name"`
31+
Weight uint `json:"weight"`
32+
Grade string `json:"grade"`
33+
}
34+
35+
type reportCardRowType int
36+
type reportCardParser struct {
37+
Parser
38+
39+
doc *goquery.Document
40+
}
41+
42+
const (
43+
reportCardTableHeader reportCardRowType = iota
44+
reportCardModuleRow
45+
reportCardUnitRow
46+
reportCardCreditsRow
47+
reportCardUnknownRow = -1
48+
)
49+
50+
var (
51+
UnknownReportCardStructure = errors.New("unknown report card structure")
52+
)
53+
54+
func (s *Parser) ReportCard() ([]*ModuleReport, error) {
55+
doc, err := goquery.NewDocumentFromReader(strings.NewReader(s.src))
56+
if err != nil {
57+
return nil, err
58+
}
59+
60+
return reportCardParser{
61+
Parser: *s,
62+
doc: doc,
63+
}.parse()
64+
}
65+
66+
func (p reportCardParser) parse() ([]*ModuleReport, error) {
67+
var reports []*ModuleReport
68+
var globalErr error
69+
70+
p.doc.Find("table#record_table tr").Each(func(i int, s *goquery.Selection) {
71+
if globalErr != nil {
72+
return
73+
}
74+
75+
switch p.getRowType(s) {
76+
case reportCardTableHeader:
77+
if s.Children().Length() != 7 {
78+
globalErr = UnknownReportCardStructure
79+
return
80+
}
81+
case reportCardModuleRow:
82+
module, err := p.parseModuleRow(s)
83+
if err != nil {
84+
globalErr = err
85+
return
86+
}
87+
88+
reports = append(reports, module)
89+
case reportCardUnitRow:
90+
class, err := p.parseUnitRow(s)
91+
if err != nil {
92+
globalErr = err
93+
return
94+
}
95+
96+
moduleOff := len(reports) - 1
97+
reports[moduleOff].Classes = append(reports[moduleOff].Classes, class)
98+
}
99+
})
100+
101+
return reports, globalErr
102+
}
103+
104+
func (p reportCardParser) getRowType(row *goquery.Selection) reportCardRowType {
105+
if row.HasClass("bulletin_header_row") {
106+
return reportCardTableHeader
107+
} else if row.HasClass("bulletin_module_row") {
108+
if row.HasClass("total-credits-row") {
109+
return reportCardCreditsRow
110+
}
111+
return reportCardModuleRow
112+
} else if row.HasClass("bulletin_unit_row") {
113+
return reportCardUnitRow
114+
} else {
115+
return reportCardUnknownRow
116+
}
117+
}
118+
119+
func (p reportCardParser) parseModuleRow(row *goquery.Selection) (*ModuleReport, error) {
120+
id := row.Find("td.module-code").Text()
121+
122+
nameText := row.Find("td").Eq(1).Text()
123+
nameSplit := strings.SplitN(nameText, id, 2)
124+
name := strings.TrimSpace(nameSplit[0][:len(nameSplit[0])-1])
125+
passingGrade := strings.TrimSpace(nameSplit[1][len(") [seuil : ") : len(nameSplit[1])-1])
126+
127+
situation := row.Find("td").Eq(2).Text()
128+
129+
year, _ := strconv.ParseUint(strings.SplitN(row.Find("td").Eq(3).Text(), " - ", 2)[0], 10, 32)
130+
131+
grade := row.Find("td").Eq(4).Text()
132+
133+
credits, err := strconv.ParseUint(row.Find("td").Eq(6).Text(), 10, 32)
134+
if err != nil {
135+
return nil, err
136+
}
137+
138+
return &ModuleReport{
139+
Identifier: id,
140+
Name: name,
141+
Year: uint(year),
142+
PassingGrade: passingGrade,
143+
GlobalGrade: grade,
144+
Credits: uint(credits),
145+
Situation: situation,
146+
}, nil
147+
}
148+
149+
func (p reportCardParser) parseUnitRow(row *goquery.Selection) (*ModuleClass, error) {
150+
id := row.Find("td").Eq(0).Text()
151+
152+
classContents := row.Find("td").Eq(1).Contents()
153+
className := strings.TrimSpace(classContents.First().Text())
154+
155+
var grades []*ClassGrade
156+
for i := 2; i < classContents.Length(); i += 2 {
157+
if strings.TrimSpace(classContents.Eq(i).Text()) == "" {
158+
break
159+
}
160+
161+
gradeText := strings.SplitN(classContents.Eq(i).Text(), "(", 2)
162+
name := strings.TrimSpace(gradeText[0])
163+
weight, _ := strconv.ParseUint(strings.TrimSpace(gradeText[1][:strings.Index(gradeText[1], "%")]), 10, 32)
164+
grade := strings.TrimSpace(classContents.Eq(i + 1).Text())
165+
166+
grades = append(grades, &ClassGrade{
167+
Name: name,
168+
Weight: uint(weight),
169+
Grade: grade,
170+
})
171+
}
172+
173+
mean := row.Find("td").Eq(4).Text()
174+
175+
weight, _ := strconv.ParseUint(row.Find("td").Eq(5).Text(), 10, 32)
176+
177+
return &ModuleClass{
178+
Identifier: id,
179+
Name: className,
180+
Grades: grades,
181+
Mean: mean,
182+
Weight: uint(weight),
183+
}, nil
184+
}

0 commit comments

Comments
 (0)
Please sign in to comment.