summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--courses.go7
-rw-r--r--frontend/style.css10
-rw-r--r--index.go62
-rw-r--r--main.go4
-rw-r--r--newcourses.go325
-rw-r--r--tmpl/staff.html63
6 files changed, 432 insertions, 39 deletions
diff --git a/courses.go b/courses.go
index dc31946..4fdda19 100644
--- a/courses.go
+++ b/courses.go
@@ -105,9 +105,9 @@ const staffDepartment = "Staff"
* Read course information from the database. This should be called during
* setup.
*/
-func setupCourses() error {
+func setupCourses(ctx context.Context) error {
rows, err := db.Query(
- context.Background(),
+ ctx,
"SELECT id, nmax, title, ctype, cgroup, teacher, location FROM courses",
)
if err != nil {
@@ -155,7 +155,8 @@ func setupCourses() error {
currentCourse.Group,
)
}
- err := db.QueryRow(context.Background(),
+ err := db.QueryRow(
+ ctx,
"SELECT COUNT (*) FROM choices WHERE courseid = $1",
currentCourse.ID,
).Scan(&currentCourse.Selected)
diff --git a/frontend/style.css b/frontend/style.css
index 3bf6a51..6edbf60 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -31,6 +31,8 @@
--theme-contrast: white;
--box: #f2f2f2;
--box-contrast: var(--primary-fg);
+ --button: #e2e2e2;
+ --button-contrast: var(--primary-fg);
--danger: #d32535;
--danger-contrast: white;
--white: white;
@@ -50,6 +52,8 @@
--theme-contrast: #f8f9fa;
--box: #30363B;
--box-contrast: #f8f9fa;
+ --button: #40464B;
+ --button-contrast: #f8f9fa;
--danger: #d32535;
--danger-contrast: #f8f9fa;
--white: #202020;
@@ -259,12 +263,14 @@ th.tdinput input[type=password] {
background: var(--white);
color: var(--white-contrast);
border: var(--border) 1px solid;
+ font-weight: bold;
}
.btn-normal,
input[type=file]::file-selector-button {
- background: var(--box);
+ background: var(--button);
border: var(--border) 1px solid !important;
- color: var(--box-contrast);
+ color: var(--button-contrast);
+ font-weight: bold;
}
.btn,
input[type=submit],
diff --git a/index.go b/index.go
index 2722aff..512fe1d 100644
--- a/index.go
+++ b/index.go
@@ -108,16 +108,46 @@ func handleIndex(w http.ResponseWriter, req *http.Request) {
return
}
+ /* TODO: The below should be completed on-update. */
+ type groupT struct {
+ Handle courseGroupT
+ Name string
+ Courses *map[int]*courseT
+ }
+ _groups := make(map[courseGroupT]groupT)
+ for k, v := range courseGroups {
+ _coursemap := make(map[int]*courseT)
+ _groups[k] = groupT{
+ Handle: k,
+ Name: v,
+ Courses: &_coursemap,
+ }
+ }
+ courses.Range(func(key, value interface{}) bool {
+ courseID, ok := key.(int)
+ if !ok {
+ panic("courses map has non-\"int\" keys")
+ }
+ course, ok := value.(*courseT)
+ if !ok {
+ panic("courses map has non-\"*courseT\" items")
+ }
+ (*_groups[course.Group].Courses)[courseID] = course
+ return true
+ })
+
if userDepartment == staffDepartment {
err := tmpl.ExecuteTemplate(
w,
"staff",
struct {
- Name string
- State uint32
+ Name string
+ State uint32
+ Groups *map[courseGroupT]groupT
}{
userName,
state,
+ &_groups,
},
)
if err != nil {
@@ -144,34 +174,6 @@ func handleIndex(w http.ResponseWriter, req *http.Request) {
return
}
- /* TODO: The below should be completed on-update. */
- type groupT struct {
- Handle courseGroupT
- Name string
- Courses *map[int]*courseT
- }
- _groups := make(map[courseGroupT]groupT)
- for k, v := range courseGroups {
- _coursemap := make(map[int]*courseT)
- _groups[k] = groupT{
- Handle: k,
- Name: v,
- Courses: &_coursemap,
- }
- }
- courses.Range(func(key, value interface{}) bool {
- courseID, ok := key.(int)
- if !ok {
- panic("courses map has non-\"int\" keys")
- }
- course, ok := value.(*courseT)
- if !ok {
- panic("courses map has non-\"*courseT\" items")
- }
- (*_groups[course.Group].Courses)[courseID] = course
- return true
- })
-
err = tmpl.ExecuteTemplate(
w,
"student",
diff --git a/main.go b/main.go
index 981302f..c5b43ee 100644
--- a/main.go
+++ b/main.go
@@ -21,6 +21,7 @@
package main
import (
+ "context"
"crypto/tls"
"embed"
"flag"
@@ -120,6 +121,7 @@ func main() {
http.HandleFunc("/auth", handleAuth)
http.HandleFunc("/ws", handleWs)
http.HandleFunc("/state/{s}", handleState)
+ http.HandleFunc("/newcourses", handleNewCourses)
var l net.Listener
@@ -183,7 +185,7 @@ func main() {
}
log.Println("Setting up courses")
- err = setupCourses()
+ err = setupCourses(context.Background())
if err != nil {
log.Fatal(err)
}
diff --git a/newcourses.go b/newcourses.go
new file mode 100644
index 0000000..48580f6
--- /dev/null
+++ b/newcourses.go
@@ -0,0 +1,325 @@
+/*
+ * Overwrite courses with uploaded CSV
+ *
+ * Copyright (C) 2024 Runxi Yu <https://runxiyu.org>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package main
+
+import (
+ "context"
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sync/atomic"
+
+ "github.com/jackc/pgx/v5"
+)
+
+func handleNewCourses(w http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPost {
+ wstr(
+ w,
+ http.StatusMethodNotAllowed,
+ "Only POST is allowed here",
+ )
+ return
+ }
+
+ sessionCookie, err := req.Cookie("session")
+ if errors.Is(err, http.ErrNoCookie) {
+ wstr(
+ w,
+ http.StatusUnauthorized,
+ "No session cookie, which is required for this endpoint",
+ )
+ return
+ } else if err != nil {
+ wstr(w, http.StatusBadRequest, "Error: Unable to check cookie.")
+ return
+ }
+
+ var userDepartment string
+ err = db.QueryRow(
+ req.Context(),
+ "SELECT department FROM users WHERE session = $1",
+ sessionCookie.Value,
+ ).Scan(&userDepartment)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ wstr(
+ w,
+ http.StatusForbidden,
+ "Invalid session cookie",
+ )
+ return
+ }
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ fmt.Sprintf(
+ "Error: Unexpected database error: %s",
+ err,
+ ),
+ )
+ return
+ }
+
+ if userDepartment != staffDepartment {
+ wstr(
+ w,
+ http.StatusForbidden,
+ "You are not authorized to view this page",
+ )
+ return
+ }
+
+ if atomic.LoadUint32(&state) != 0 {
+ wstr(
+ w,
+ http.StatusBadRequest,
+ "Uploading the course table is only supported when student-access is disabled",
+ )
+ return
+ }
+ /* TODO: Potential race. The global state may need to be write-locked. */
+
+ file, fileHeader, err := req.FormFile("coursecsv")
+ if err != nil {
+ wstr(
+ w,
+ http.StatusBadRequest,
+ "Failed loading file from request... did you select a file before hitting that red button?",
+ )
+ return
+ }
+
+ if fileHeader.Header.Get("Content-Type") != "text/csv" {
+ wstr(
+ w,
+ http.StatusBadRequest,
+ "Does not look like a proper CSV file",
+ )
+ return
+ }
+
+ csvReader := csv.NewReader(file)
+ titleLine, err := csvReader.Read()
+ if err != nil {
+ wstr(
+ w,
+ http.StatusBadRequest,
+ "Error reading CSV",
+ )
+ return
+ }
+ if titleLine == nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected nil titleLine slice",
+ )
+ return
+ }
+ if len(titleLine) != 6 {
+ wstr(
+ w,
+ http.StatusBadRequest,
+ "First line has more than 6 elements",
+ )
+ return
+ }
+ var titleIndex, maxIndex, teacherIndex, locationIndex,
+ typeIndex, groupIndex int = -1, -1, -1, -1, -1, -1
+ for i, v := range titleLine {
+ switch v {
+ case "Title":
+ titleIndex = i
+ case "Max":
+ maxIndex = i
+ case "Teacher":
+ teacherIndex = i
+ case "Location":
+ locationIndex = i
+ case "Type":
+ typeIndex = i
+ case "Group":
+ groupIndex = i
+ }
+ }
+
+ {
+ check := func(indexName string, indexNum int) bool {
+ if indexNum == -1 {
+ wstr(
+ w,
+ http.StatusBadRequest,
+ fmt.Sprintf(
+ "Missing column \"%s\"",
+ indexName,
+ ),
+ )
+ return true
+ }
+ return false
+ }
+
+ if check("Title", titleIndex) {
+ return
+ }
+ if check("Max", maxIndex) {
+ return
+ }
+ if check("Teacher", teacherIndex) {
+ return
+ }
+ if check("Location", locationIndex) {
+ return
+ }
+ if check("Type", typeIndex) {
+ return
+ }
+ if check("Group", groupIndex) {
+ return
+ }
+ }
+
+ lineNumber := 1
+ ok := func(ctx context.Context) bool {
+ tx, err := db.Begin(ctx)
+ if err != nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected database error",
+ )
+ }
+ defer func() {
+ err := tx.Rollback(ctx)
+ if err != nil && (!errors.Is(err, pgx.ErrTxClosed)) {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected database error",
+ )
+ return
+ }
+ }()
+ _, err = tx.Exec(
+ ctx,
+ "DELETE FROM choices",
+ )
+ if err != nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected database error",
+ )
+ }
+ _, err = tx.Exec(
+ ctx,
+ "DELETE FROM courses",
+ )
+ if err != nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected database error",
+ )
+ }
+
+ for {
+ lineNumber++
+ line, err := csvReader.Read()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Error reading CSV",
+ )
+ return false
+ }
+ if line == nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected nil line",
+ )
+ return false
+ }
+ if len(line) != 6 {
+ wstr(
+ w,
+ http.StatusBadRequest,
+ fmt.Sprintf(
+ "Line %d has insufficient items",
+ lineNumber,
+ ),
+ )
+ return false
+ }
+ _, err = tx.Exec(
+ ctx,
+ "INSERT INTO courses(nmax, title, teacher, location, ctype, cgroup) VALUES ($1, $2, $3, $4, $5, $6)",
+ line[maxIndex],
+ line[titleIndex],
+ line[teacherIndex],
+ line[locationIndex],
+ line[typeIndex],
+ line[groupIndex],
+ )
+ if err != nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected database error",
+ )
+ return false
+ }
+ }
+ courses.Clear()
+ err = setupCourses(ctx)
+ if err != nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Error setting up course table again",
+ )
+ return false
+ }
+ err = tx.Commit(ctx)
+ if err != nil {
+ wstr(
+ w,
+ http.StatusInternalServerError,
+ "Unexpected database error",
+ )
+ return false
+ }
+ return true
+ }(req.Context())
+ if !ok {
+ return
+ }
+
+ http.Redirect(w, req, "/", http.StatusSeeOther)
+}
diff --git a/tmpl/staff.html b/tmpl/staff.html
index 12cc3f0..5d79b4c 100644
--- a/tmpl/staff.html
+++ b/tmpl/staff.html
@@ -3,7 +3,7 @@
<html lang="en">
<head>
<title>
- {{ .Name }} (Staff) &ndash; CCA Selection System
+ Staff Home &ndash; CCA Selection System
</title>
<link rel="stylesheet" href="/static/style.css" />
<meta charset="utf-8" />
@@ -48,7 +48,7 @@
</p>
</div>
<div class="reading-width">
- <p><a href="./export" class="btn-primary btn">Export all choices as a spreadsheet</a></p>
+ <p><a href="./export" class="btn-normal btn">Export all choices as a spreadsheet</a></p>
{{- if ge .State 1 }}
<p><a href="./state/0" class="btn-danger btn">Disable student access</a></p>
{{- if ge .State 2 }}
@@ -59,7 +59,64 @@
{{- else }}
<p><a href="./state/1" class="btn-primary btn">Enable student access</a></p>
{{- end }}
- </p>
+ <table class="table-of-courses">
+ <thead>
+ <tr>
+ <th scope="col">ID</th>
+ <th scope="col">Selected</th>
+ <th scope="col">Max</th>
+ <th scope="col">Name</th>
+ <th scope="col">Type</th>
+ <th scope="col">Teacher</th>
+ <th scope="col">Location</th>
+ </tr>
+ <tr>
+ <th colspan="7" class="tdinput">
+ <input type="text" id="search" placeholder="Search..." />
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {{- range .Groups }}
+ <tr><th colspan="7">{{ .Name }}</th></tr>
+ {{- range .Courses }}
+ <tr class="courseitem" id="course{{.ID}}" data-group="{{.Group}}">
+ <th scope="row">
+ {{.ID}}
+ </th>
+ <td>
+ <span id="selected{{.ID}}">{{.Selected}}</span>
+ </td>
+ <td>
+ <span id="max{{.ID}}">{{.Max}}</span>
+ </td>
+ <td>{{.Title}}</td>
+ <td id="type{{.ID}}">{{.Type}}</td>
+ <td>{{.Teacher}}</td>
+ <td>{{.Location}}</td>
+ </tr>
+ {{- end }}
+ {{- end }}
+ </tbody>
+ {{- if eq .State 0 }}
+ <tfoot>
+ <tr>
+ <td class="th-like" colspan="7">
+ <form method="POST" enctype="multipart/form-data" action="/newcourses">
+ <div class="flex-justify">
+ <div class="left">
+ </div>
+ <div class="right">
+ <input title="Upload course list (CSV)" type="file" id="coursecsv" name="coursecsv" accept=".csv" />
+ <input type="submit" value="Delete all choices and reset courses" class="btn btn-danger" />
+ </div>
+ </div>
+ </form>
+ </td>
+ </tr>
+ </tfoot>
+ {{- end }}
+ </table>
</div>
</body>
</html>