-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
296 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
package cmd | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"os" | ||
|
||
"github.com/jedib0t/go-pretty/v6/table" | ||
"github.com/jedib0t/go-pretty/v6/text" | ||
"github.com/spf13/cobra" | ||
"lutonite.dev/gaps-cli/gaps" | ||
"lutonite.dev/gaps-cli/parser" | ||
) | ||
|
||
type AbsencesPeriod string | ||
|
||
const ( | ||
ALL AbsencesPeriod = "all" | ||
ETE AbsencesPeriod = "ete" | ||
SEMESTRE_1 AbsencesPeriod = "1" | ||
SEMESTRE_2 AbsencesPeriod = "2" | ||
) | ||
|
||
type AbsencesCmdOpts struct { | ||
format string | ||
year uint | ||
semester AbsencesPeriod | ||
minRate uint | ||
} | ||
|
||
var ( | ||
absencesOpts = &AbsencesCmdOpts{} | ||
absencesCmd = &cobra.Command{ | ||
Use: "absences", | ||
Short: "Allows to consult your absences", | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
switch absencesOpts.semester { | ||
case ALL, ETE, SEMESTRE_1, SEMESTRE_2: // valid | ||
default: | ||
return fmt.Errorf("invalid semester: %s. Must be one of: all, ete, 1, 2", absencesOpts.semester) | ||
} | ||
|
||
cfg := buildTokenClientConfiguration() | ||
absencesAction := gaps.NewAbsencesAction(cfg, absencesOpts.year) | ||
|
||
absences, err := absencesAction.FetchAbsences() | ||
if err != nil { | ||
return fmt.Errorf("couldn't fetch absences: %w", err) | ||
} | ||
|
||
if absencesOpts.format == "json" { | ||
return json.NewEncoder(os.Stdout).Encode(absences) | ||
} | ||
|
||
printAbsences(absences) | ||
return nil | ||
}, | ||
} | ||
) | ||
|
||
func init() { | ||
absencesCmd.Flags().StringVarP(&absencesOpts.format, "format", "o", "table", | ||
"Output format (table, json) note that other flags do not apply on json format") | ||
absencesCmd.Flags().UintVarP(&absencesOpts.year, "year", "y", currentAcademicYear(), | ||
"Academic year (year at the start of the academic year, e.g. 2020 for 2020-2021 academic year)") | ||
absencesCmd.Flags().StringVarP((*string)(&absencesOpts.semester), "semester", "s", string(ALL), | ||
"Semester to get absences for (all, ete, 1, 2)") | ||
absencesCmd.Flags().UintVarP(&absencesOpts.minRate, "rate", "r", 0, "Minimum rate to display") | ||
rootCmd.AddCommand(absencesCmd) | ||
} | ||
|
||
func printAbsences(absences *parser.AbsenceReport) { | ||
t := table.NewWriter() | ||
t.SetOutputMirror(os.Stdout) | ||
t.Style().Options.SeparateRows = true | ||
t.SetColumnConfigs([]table.ColumnConfig{ | ||
{Number: 1, AlignHeader: text.AlignCenter}, | ||
{Number: 2, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, | ||
{Number: 3, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, | ||
{Number: 4, Align: text.AlignCenter, AlignHeader: text.AlignCenter}, | ||
}) | ||
t.AppendHeader(table.Row{"Course", "Total", "Relative rate", "Absolute rate"}) | ||
|
||
for _, a := range absences.Courses { | ||
totalAbsence := a.Total - a.Justified | ||
selected, relativePresence, absolutePresence := calculateAbsences(&a) | ||
|
||
getColoredRate := func(rate float64) string { | ||
rateStr := fmt.Sprintf("%.2f%%", rate) | ||
switch { | ||
case rate >= 15: | ||
return text.Colors{text.FgRed, text.Bold}.Sprint(rateStr) | ||
case rate >= 8: | ||
return text.Colors{text.FgYellow}.Sprint(rateStr) | ||
default: | ||
return text.Colors{text.FgGreen}.Sprint(rateStr) | ||
} | ||
} | ||
|
||
if selected && absolutePresence >= float64(absencesOpts.minRate) { | ||
t.AppendRow(table.Row{ | ||
a.Name, | ||
totalAbsence, | ||
getColoredRate(relativePresence), | ||
getColoredRate(absolutePresence), | ||
}) | ||
} | ||
} | ||
t.Render() | ||
} | ||
|
||
func calculateAbsences(a *parser.CourseAbsence) (bool, float64, float64) { | ||
selected := false | ||
var relativePresence float64 | ||
var absolutePresence float64 | ||
switch absencesOpts.semester { | ||
case ETE: | ||
if selected = (a.Periods.Ete-a.Justified > 0); !selected { | ||
return selected, 0.0, 0.0 | ||
} | ||
relativePresence = float64(a.Periods.Ete) / float64(a.RelativePeriods) | ||
absolutePresence = float64(a.Periods.Ete) / float64(a.AbsolutePeriods) | ||
|
||
case SEMESTRE_1: | ||
if selected = (a.Periods.Term1-a.Justified > 0) || (a.Periods.Term2-a.Justified > 0); !selected { | ||
return selected, 0.0, 0.0 | ||
} | ||
relativePresence = float64(a.Periods.Term1+a.Periods.Term2-a.Justified) / float64(a.RelativePeriods) | ||
absolutePresence = float64(a.Periods.Term1+a.Periods.Term2-a.Justified) / float64(a.AbsolutePeriods) | ||
|
||
case SEMESTRE_2: | ||
if selected = (a.Periods.Term3-a.Justified > 0) || (a.Periods.Term4-a.Justified > 0); !selected { | ||
return selected, 0.0, 0.0 | ||
} | ||
relativePresence = float64(a.Periods.Term3+a.Periods.Term4-a.Justified) / float64(a.RelativePeriods) | ||
absolutePresence = float64(a.Periods.Term3+a.Periods.Term4-a.Justified) / float64(a.AbsolutePeriods) | ||
|
||
default: | ||
selected = true | ||
relativePresence = float64(a.Total-a.Justified) / float64(a.RelativePeriods) | ||
absolutePresence = float64(a.Total-a.Justified) / float64(a.AbsolutePeriods) | ||
} | ||
return selected, relativePresence * 100.0, absolutePresence * 100.0 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
package gaps | ||
|
||
import ( | ||
"fmt" | ||
"net/url" | ||
|
||
"lutonite.dev/gaps-cli/parser" | ||
) | ||
|
||
type AbsencesAction struct { | ||
cfg *TokenClientConfiguration | ||
year uint | ||
} | ||
|
||
func NewAbsencesAction(config *TokenClientConfiguration, year uint) *AbsencesAction { | ||
return &AbsencesAction{ | ||
cfg: config, | ||
year: year, | ||
} | ||
} | ||
|
||
func (a *AbsencesAction) FetchAbsences() (*parser.AbsenceReport, error) { | ||
req, err := a.cfg.buildRequest("POST", "/consultation/etudiant/") | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// POST rsargs to get all absences | ||
showAllConfig := fmt.Sprintf(`["studentAbsGrid_rateSelectorId","studentAbsGrid","%s",null,null,"%d","0",%d,null]`, | ||
a.cfg.token, a.year, a.cfg.studentId) | ||
|
||
data := url.Values{} | ||
data.Add("rs", "smartReplacePart") | ||
data.Add("rsargs", showAllConfig) | ||
|
||
res, err := a.cfg.doForm(req, data) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer res.Body.Close() | ||
|
||
pres, err := parser.FromResponseBody(res.Body) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
absences, err := pres.Absences() | ||
if err != nil { | ||
return nil, err | ||
} | ||
return absences, nil | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
package parser | ||
|
||
import ( | ||
"regexp" | ||
"strconv" | ||
"strings" | ||
|
||
"github.com/PuerkitoBio/goquery" | ||
) | ||
|
||
type AbsenceReport struct { | ||
Student string `json:"student"` | ||
Orientation string `json:"orientation"` | ||
Courses []CourseAbsence `json:"courses"` | ||
} | ||
|
||
type CourseAbsence struct { | ||
Name string `json:"name"` | ||
Periods struct { | ||
Ete int `json:"ete"` | ||
Term1 int `json:"term1"` | ||
Term2 int `json:"term2"` | ||
Term3 int `json:"term3"` | ||
Term4 int `json:"term4"` | ||
} `json:"periods"` | ||
Total int `json:"total"` | ||
Justified int `json:"justified"` | ||
RelativePeriods int `json:"relativePeriods"` | ||
AbsolutePeriods int `json:"absolutePeriods"` | ||
} | ||
|
||
func (s *Parser) Absences() (*AbsenceReport, error) { | ||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(s.src)) | ||
if err != nil { | ||
return nil, err | ||
} | ||
report := &AbsenceReport{} | ||
|
||
report.Student = doc.Find(".s_cell").First().Text() | ||
report.Orientation = doc.Find(".l_cell.s_cell").First().Text() | ||
|
||
doc.Find("tr.a_r_0").Each(func(i int, row *goquery.Selection) { | ||
if row.Find("td.l_cell").Length() == 0 { | ||
return | ||
} | ||
|
||
course := CourseAbsence{} | ||
|
||
courseName := row.Find("td.l_cell:not(.s_cell)").First().Text() | ||
course.Name = strings.TrimSpace(courseName) | ||
|
||
cells := row.Find("td.b_cell") | ||
|
||
course.Periods.Ete = parseAbsenceWithJustified(cells.Eq(0).Text(), &course.Justified) | ||
course.Periods.Term1 = parseAbsenceWithJustified(cells.Eq(1).Text(), &course.Justified) | ||
course.Periods.Term2 = parseAbsenceWithJustified(cells.Eq(2).Text(), &course.Justified) | ||
course.Periods.Term3 = parseAbsenceWithJustified(cells.Eq(3).Text(), &course.Justified) | ||
course.Periods.Term4 = parseAbsenceWithJustified(cells.Eq(4).Text(), &course.Justified) | ||
course.Total = parseAbsence(cells.Eq(5).Text()) | ||
|
||
course.RelativePeriods = parseAbsence(cells.Eq(6).Text()) | ||
course.AbsolutePeriods = parseAbsence(cells.Eq(7).Text()) | ||
|
||
report.Courses = append(report.Courses, course) | ||
}) | ||
|
||
return report, nil | ||
} | ||
|
||
func parseAbsenceWithJustified(text string, totalJustified *int) int { | ||
text = strings.TrimSpace(text) | ||
if text == "" || text == " " { | ||
return 0 | ||
} | ||
|
||
// Look for pattern like "2 [2]" | ||
if matches := regexp.MustCompile(`(\d+)\s*\[(\d+)\]`).FindStringSubmatch(text); matches != nil { | ||
justified, _ := strconv.Atoi(matches[2]) | ||
*totalJustified += justified | ||
total, _ := strconv.Atoi(matches[1]) | ||
return total | ||
} | ||
|
||
num, _ := strconv.Atoi(text) | ||
return num | ||
} | ||
|
||
func parseAbsence(text string) int { | ||
text = strings.TrimSpace(text) | ||
if text == "" || text == " " { | ||
return 0 | ||
} | ||
// Remove any [x] if present and just get the main number | ||
if idx := strings.Index(text, "["); idx != -1 { | ||
text = strings.TrimSpace(text[:idx]) | ||
} | ||
num, _ := strconv.Atoi(text) | ||
return num | ||
} |