Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Program Configuration (Set Live Phases / Banner Message) #358

Merged
merged 20 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/main/java/edu/byu/cs/controller/ConfigController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package edu.byu.cs.controller;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
import edu.byu.cs.dataAccess.ConfigurationDao;
import edu.byu.cs.dataAccess.DaoService;
import edu.byu.cs.dataAccess.DataAccessException;
import edu.byu.cs.model.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import spark.Route;

import java.util.ArrayList;

public class ConfigController {

private static final Logger LOGGER = LoggerFactory.getLogger(SubmissionController.class);

private static void logConfigChange(String changeMessage, String adminNetId) {
LOGGER.info("[CONFIG] Admin %s has %s".formatted(adminNetId, changeMessage));
}

public static final Route getConfigAdmin = (req, res) -> {
JsonObject response = getPublicConfig();

res.status(200);
return response.toString();
};

public static final Route getConfigStudent = (req, res) -> {
String response = getPublicConfig().toString();

res.status(200);
return response;
};

private static JsonObject getPublicConfig() throws DataAccessException {
ConfigurationDao dao = DaoService.getConfigurationDao();

JsonObject response = new JsonObject();

response.addProperty("bannerMessage", dao.getConfiguration(ConfigurationDao.Configuration.BANNER_MESSAGE, String.class));
response.addProperty("phases", dao.getConfiguration(ConfigurationDao.Configuration.STUDENT_SUBMISSIONS_ENABLED, String.class));

return response;
}

public static final Route updateLivePhases = (req, res) -> {
ConfigurationDao dao = DaoService.getConfigurationDao();

JsonObject jsonObject = new Gson().fromJson(req.body(), JsonObject.class);
ArrayList phasesArray = new Gson().fromJson(jsonObject.get("phases"), ArrayList.class);

dao.setConfiguration(ConfigurationDao.Configuration.STUDENT_SUBMISSIONS_ENABLED, phasesArray, ArrayList.class);

User user = req.session().attribute("user");
logConfigChange("set the following phases as live: %s".formatted(phasesArray), user.netId());

res.status(200);
return "";
};

public static final Route updateBannerMessage = (req, res) -> {
ConfigurationDao dao = DaoService.getConfigurationDao();

JsonObject jsonObject = new Gson().fromJson(req.body(), JsonObject.class);
String message = new Gson().fromJson(jsonObject.get("bannerMessage"), String.class);
dao.setConfiguration(ConfigurationDao.Configuration.BANNER_MESSAGE, message, String.class);

User user = req.session().attribute("user");
if (message.isEmpty()) {
logConfigChange("cleared the banner message", user.netId());
} else {
logConfigChange("set the banner message to: '%s'".formatted(message), user.netId());
}

res.status(200);
return "";
};
}
26 changes: 19 additions & 7 deletions src/main/java/edu/byu/cs/controller/SubmissionController.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,8 @@ public class SubmissionController {

User user = req.session().attribute("user");

Boolean submissionsEnabled = getSubmissionsEnabledConfig();
if (submissionsEnabled == null) return null;

if (!submissionsEnabled) {
halt(400, "Student submission is disabled");
return null;
if (!phaseIsEnabled(request.phase())) {
halt(400, "Student submission is disabled for " + request.phase());
}

updateRepoFromCanvas(user, req);
Expand All @@ -56,11 +52,27 @@ public class SubmissionController {
return "";
};

private static boolean phaseIsEnabled(Phase phase) {
boolean phaseEnabled;

try {
phaseEnabled = DaoService.getConfigurationDao()
.getConfiguration(ConfigurationDao.Configuration.STUDENT_SUBMISSIONS_ENABLED, String.class)
.contains(phase.toString());
} catch (DataAccessException e) {
LOGGER.error("Error getting configuration for live phase", e);
halt(500);
return false;
}

return phaseEnabled;
}

private static Boolean getSubmissionsEnabledConfig() {
boolean submissionsEnabled;
try {
submissionsEnabled = DaoService.getConfigurationDao().getConfiguration(
ConfigurationDao.Configuration.STUDENT_SUBMISSION_ENABLED,
ConfigurationDao.Configuration.STUDENT_SUBMISSIONS_ENABLED,
Boolean.class);
} catch (Exception e) {
LOGGER.error("Error getting configuration", e);
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/edu/byu/cs/dataAccess/ConfigREADME.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Autograder Config Info

The autograder has a bunch of settings that change from time to time, but need to persist across reboots. For instance, whether a phase is enabled for student submissions, or the Canvas Course ID. Thats where the Autograder Config system comes in.

The `Configuration` table in the database is a set of key value pairs. This gives us a lot of flexibility, but also the need to define how it will be used. That is what this file is for.

### Live Phases
The `STUDENT_SUBMISSION_ENABLED` Configuration enum has a value of an array of phases. Each phase in the array is enabled, the rest are not.
3 changes: 2 additions & 1 deletion src/main/java/edu/byu/cs/dataAccess/ConfigurationDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public interface ConfigurationDao {
<T> T getConfiguration(Configuration key, Class<T> type) throws DataAccessException;

enum Configuration {
STUDENT_SUBMISSION_ENABLED
STUDENT_SUBMISSIONS_ENABLED,
BANNER_MESSAGE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public ConfigurationSqlDao() {
@Override
public <T> void setConfiguration(Configuration key, T value, Class<T> type) throws DataAccessException {
try (var connection = SqlDb.getConnection()) {
var statement = connection.prepareStatement("INSERT INTO configuration (config_key, value) VALUES (?, ?)");
var statement = connection.prepareStatement("""
INSERT INTO configuration (config_key, value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value);
""");
statement.setString(1, key.toString());
statement.setString(2, value.toString());
statement.executeUpdate();
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/edu/byu/cs/server/Server.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static edu.byu.cs.controller.AdminController.*;
import static edu.byu.cs.controller.AuthController.*;
import static edu.byu.cs.controller.CasController.*;
import static edu.byu.cs.controller.ConfigController.*;
import static edu.byu.cs.controller.SubmissionController.*;
import static spark.Spark.*;

Expand Down Expand Up @@ -65,6 +66,8 @@ public static int setupEndpoints(int port) {

get("/me", meGet);

get("/config", getConfigStudent);

path("/admin", () -> {
before("/*", (req, res) -> {
if (!req.requestMethod().equals("OPTIONS"))
Expand Down Expand Up @@ -100,6 +103,13 @@ public static int setupEndpoints(int port) {
get("/honorChecker/zip/:section", honorCheckerZipGet);

get("/sections", sectionsGet);

path("/config", () -> {
get("", getConfigAdmin);

post("/phases", updateLivePhases);
post("/banner", updateBannerMessage);
});
});
});

Expand Down
23 changes: 22 additions & 1 deletion src/main/resources/frontend/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup lang="ts">

import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import {useAuthStore} from "@/stores/auth";
import { logoutPost } from '@/services/authService'
import router from '@/router'
import '@/assets/fontawesome/css/fontawesome.css'
import '@/assets/fontawesome/css/solid.css'
import { useAppConfigStore } from '@/stores/appConfig'

const greeting = computed(() => {
if (useAuthStore().isLoggedIn) {
Expand All @@ -22,6 +23,16 @@ const logOut = async () => {
}
router.push({name: "login"})
}

const bannerMessage = computed(() => {
if (useAuthStore().isLoggedIn) {
return useAppConfigStore().bannerMessage
}
});

onMounted( async () => {
await useAppConfigStore().updateConfig();
})
</script>

<template>
Expand All @@ -30,13 +41,23 @@ const logOut = async () => {
<h3>This is where you can submit your assignments and view your scores.</h3>
<p>{{ greeting }} <a v-if="useAuthStore().isLoggedIn" @click="logOut">Logout</a></p>
<p>{{ useAuthStore().user?.repoUrl }}</p>
<div v-if="bannerMessage" id="bannerMessage">
<span v-text="bannerMessage"/>
</div>
</header>
<main>
<router-view/>
</main>
</template>

<style scoped>
#bannerMessage {
width: 100%;
background-color: #4fa0ff;
border-radius: 3px;
padding: 7px;
margin-top: 15px;
}
header {
text-align: center;

Expand Down
5 changes: 5 additions & 0 deletions src/main/resources/frontend/src/assets/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ button {
letter-spacing: 0.02857em;
}

button.small {
font-size: 0.9rem;
padding: 5px;
}

button:hover {
filter: brightness(1.1);
cursor: pointer;
Expand Down
61 changes: 61 additions & 0 deletions src/main/resources/frontend/src/services/configService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { type Config, useAppConfigStore } from '@/stores/appConfig'
import { Phase } from '@/types/types'
import { useAuthStore } from '@/stores/auth'

export const getConfig = async ():Promise<Config> => {
let path = "/api"
if (useAuthStore().user?.role == 'ADMIN') {
path += "/admin"
}
path += "/config"

try {
const response = await fetch(useAppConfigStore().backendUrl + path, {
method: 'GET',
credentials: 'include'
});

return await response.json();
} catch (e) {
console.error('Failed to get configuration: ', e);
throw "Failed to get configuration"
}
}

export const setBannerMessage = async (message: String): Promise<void> => {
const response = await fetch(useAppConfigStore().backendUrl + '/api/admin/config/banner', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
"bannerMessage": message,
})
});

if (!response.ok) {
console.error(response);
throw new Error(await response.text());
}
await useAppConfigStore().updateConfig();
}

export const setLivePhases = async (phases: Array<Phase>): Promise<void> => {
const response = await fetch(useAppConfigStore().backendUrl + '/api/admin/config/phases', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
"phases": phases,
})
});

if (!response.ok) {
console.error(response);
throw new Error(await response.text());
}
await useAppConfigStore().updateConfig();
}
24 changes: 22 additions & 2 deletions src/main/resources/frontend/src/stores/appConfig.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import { ref, computed } from 'vue'
import { ref, type Ref } from 'vue'
import { defineStore } from 'pinia'
import { listOfPhases, Phase } from '@/types/types'
import { getConfig } from '@/services/configService'

type ImportMeta = {
VITE_APP_BACKEND_URL: string
}

export type Config = {
bannerMessage: string
phases: Array<Phase>
}

// @ts-ignore
const env: ImportMeta = import.meta.env;
export const useAppConfigStore = defineStore('appConfig', () => {
const backendUrl = ref<string>(env.VITE_APP_BACKEND_URL);

return { backendUrl }
const updateConfig = async () => {
const latestConfig = await getConfig();

bannerMessage.value = latestConfig.bannerMessage
for (const phase of listOfPhases() as Phase[]) {
activePhaseList.value[phase] = latestConfig.phases.includes(phase);
}
}

const bannerMessage: Ref<string> = ref<string>("")
// using the enum, if phaseActivationList[phase] == true, then that phase is active
const activePhaseList: Ref<boolean[]> = ref<Array<boolean>>([])

return { updateConfig, backendUrl, bannerMessage, phaseActivationList: activePhaseList }
})
11 changes: 11 additions & 0 deletions src/main/resources/frontend/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ export enum Phase {
GitHub
}

export const listOfPhases = ():Array<Phase> => {
let result = []
for (var phase in Phase) {
var isValueProperty = Number(phase) >= 0
if (isValueProperty) {
result.push(Phase[phase] as unknown as Phase);
}
}
return result
}

export type TestNode = {
testName: string,
passed: boolean,
Expand Down
Loading