Skip to content

Commit

Permalink
Implemented absences command (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
BasileBux authored Dec 13, 2024
1 parent 337e812 commit d559eb0
Show file tree
Hide file tree
Showing 3 changed files with 296 additions and 0 deletions.
144 changes: 144 additions & 0 deletions cmd/absences.go
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
}
53 changes: 53 additions & 0 deletions gaps/absences.go
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

}
99 changes: 99 additions & 0 deletions parser/absences.go
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 == "&nbsp" {
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 == "&nbsp" {
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
}

0 comments on commit d559eb0

Please # to comment.