• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ubccpsc / classy / 47029edf-f331-4b4d-bbff-ed4f07561520

19 Dec 2024 09:55PM UTC coverage: 87.102% (-0.2%) from 87.336%
47029edf-f331-4b4d-bbff-ed4f07561520

push

circleci

web-flow
Merge pull request #466 from ubccpsc310/cs310_24w1

Incorporate 310_24w1 improvements.

1108 of 1350 branches covered (82.07%)

Branch coverage included in aggregate %.

3950 of 4457 relevant lines covered (88.62%)

35.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.02
packages/portal/backend/src/controllers/CourseController.ts
1
import Log from "@common/Log";
1✔
2
import {CommitTarget} from "@common/types/ContainerTypes";
3

4
import {Deliverable, Grade, Person, Repository, Team} from "../Types";
5
import {DatabaseController} from "./DatabaseController";
1✔
6
import {IGitHubController} from "./GitHubController";
7
import {GradesController} from "./GradesController";
1✔
8
import {PersonController} from "./PersonController";
1✔
9
import {RepositoryController} from "./RepositoryController";
1✔
10
import {ResultsController} from "./ResultsController";
1✔
11
import {TeamController} from "./TeamController";
1✔
12

13
/**
14
 * This interface defines the extension points that courses will want to
15
 * customize based on their own preferences and needs. Courses should not
16
 * implement this interface but should instead extend CourseController.
17
 *
18
 * Courses can also of course add their own methods to their custom subclass
19
 * (e.g., see CustomCourseController), or can have minimal implementations (e.g.,
20
 * see CS310Controller).
21
 */
22
export interface ICourseController {
23

24
    /**
25
     * Given a GitHub username that is not already in the system, how should it be
26
     * handled? There are two main options here: return null (aka only accept registered
27
     * users) or create and save a new Person and return them.
28
     *
29
     * @param {string} githubUsername
30
     * @returns {Promise<Person | null>}
31
     */
32
    handleUnknownUser(githubUsername: string): Promise<Person | null>;
33

34
    /**
35
     * Given a new Grade and existing Grade for a deliverable, should the new grade be
36
     * saved? The Deliverable is included in case due dates want to be considered. The
37
     * Grade timestamp is the timestamp of the GitHub push event, not the commit event,
38
     * as this is the only time we can guarantee was not tampered with on the client side.
39
     * This will be called once-per-teammate if there are multiple people on the repo
40
     * receiving the grade.
41
     *
42
     * @param {Deliverable} deliv
43
     * @param {Grade} newGrade
44
     * * @param {Grade} existingGrade
45
     * @returns {boolean} whether the grade should be saved.
46
     */
47
    handleNewAutoTestGrade(deliv: Deliverable, newGrade: Grade, existingGrade: Grade): Promise<boolean>;
48

49
    /**
50
     * Determine how to name teams and repos for a deliverable. Should only be called
51
     * before the team or repo is provisioned. Courses should be careful about how they
52
     * call this. e.g., some courses use team_1, team_2 which will require the team to
53
     * be created after a call and before computeNames is called again.
54
     *
55
     * @param {Deliverable} deliv
56
     * @param {Person[]} people
57
     * @param {boolean} adminOverride
58
     * @returns {{teamName: string | null; repoName: string | null}}
59
     */
60
    computeNames(deliv: Deliverable, people: Person[], adminOverride?: boolean):
61
        Promise<{ teamName: string | null; repoName: string | null }>;
62

63
    /**
64
     * For adding any finishing touches to a newly made repo
65
     * e.g.: add branch protection to the master branch in 310
66
     * @param repo
67
     * @param teams
68
     */
69
    finalizeProvisionedRepo(repo: Repository, teams: Team[]): Promise<boolean>;
70

71
    /**
72
     * For forcing certain push events to the express queue
73
     * e.g.: Commits on master getting automatically graded
74
     * @param info
75
     */
76
    shouldPrioritizePushEvent(info: CommitTarget): Promise<boolean>;
77

78
    requestFeedbackDelay(info: { delivId: string; personId: string; timestamp: number }): Promise<{
79
        accepted: boolean,
80
        message: string,
81
        fullMessage?: string
82
    } | null>;
83

84
    /**
85
     * Allow a course to specialize how a grade should be presented
86
     * to the students. This is especially useful when a numeric score
87
     * is being replaced by a bucket grade.
88
     *
89
     * The frontend will render gradeTransport.custom.displayScore
90
     * if it is set.
91
     */
92
    convertGrade(grade: Grade): Promise<Grade>;
93
}
94

95
/**
96
 * This is a default course controller for courses that do not want to do anything unusual.
97
 */
98
export class CourseController implements ICourseController {
1✔
99

100
    protected dbc = DatabaseController.getInstance();
61✔
101
    protected pc = new PersonController();
61✔
102
    protected rc = new RepositoryController();
61✔
103
    protected tc = new TeamController();
61✔
104
    protected gc = new GradesController();
61✔
105
    protected resC = new ResultsController();
61✔
106

107
    protected gh: IGitHubController = null;
61✔
108

109
    constructor(ghController: IGitHubController) {
110
        Log.trace("CourseController::<init>");
61✔
111
        this.gh = ghController;
61✔
112
    }
113

114
    /**
115
     * This endpoint just lets subclasses change the behaviour for when users are unknown.
116
     *
117
     * The default behaviour (returning null) effecively disallows any non-registered student,
118
     * although any user registered on the GitHub admin or staff team will bypass this.
119
     *
120
     * @param {string} githubUsername
121
     * @returns {Promise<Person | null>}
122
     */
123
    public async handleUnknownUser(githubUsername: string): Promise<Person | null> {
124
        Log.warn("CourseController::handleUnknownUser( " + githubUsername + " ) - person unknown; returning null");
2✔
125
        return null;
2✔
126
    }
127

128
    /**
129
     * Default behaviour is that if the deadline has not passed, and the grade is higher, accept it.
130
     *
131
     * @param {Deliverable} deliv
132
     * @param {Grade} newGrade
133
     * @param {Grade} existingGrade
134
     * @returns {boolean}
135
     */
136
    public handleNewAutoTestGrade(deliv: Deliverable, newGrade: Grade, existingGrade: Grade): Promise<boolean> {
137
        const LOGPRE = "CourseController::handleNewAutoTestGrade( " + deliv.id + ", " +
11✔
138
            newGrade.personId + ", " + newGrade.score + ", ... ) - start - ";
139

140
        Log.trace(LOGPRE + "start");
11✔
141

142
        if (newGrade.timestamp < deliv.openTimestamp) {
11✔
143
            // too early
144
            Log.trace(LOGPRE + "not recorded; deliverable not yet open");
1✔
145
            return Promise.resolve(false);
1✔
146
        }
147

148
        if (newGrade.timestamp > deliv.closeTimestamp) {
10✔
149
            // too late
150
            Log.trace(LOGPRE + "not recorded; deliverable closed");
3✔
151
            return Promise.resolve(false);
3✔
152
        }
153

154
        // >= on purpose so "last highest" is used
155
        const gradeIsLarger = (existingGrade === null || newGrade.score >= existingGrade.score);
7✔
156

157
        if (gradeIsLarger === true) {
7✔
158
            Log.info(LOGPRE + "recorded; deliv open and grade increased");
6✔
159
            return Promise.resolve(true);
6✔
160
        } else {
161
            Log.info(LOGPRE + "not recorded; deliverable open but grade not increased");
1✔
162
            return Promise.resolve(false);
1✔
163
        }
164
    }
165

166
    public async computeNames(deliv: Deliverable, people: Person[]): Promise<{
167
        teamName: string | null;
168
        repoName: string | null
169
    }> {
170
        if (deliv === null) {
25✔
171
            throw new Error("CourseController::computeNames( ... ) - null Deliverable");
1✔
172
        }
173

174
        Log.trace("CourseController::computeNames( " + deliv.id + ", ... ) - start");
24✔
175
        if (people.length < 1) {
24✔
176
            throw new Error("CourseController::computeNames( ... ) - must provide people");
1✔
177
        }
178

179
        // sort people alph by their id
180
        people = people.sort(function compare(p1: Person, p2: Person) {
23✔
181
                return p1.id.localeCompare(p2.id);
10✔
182
            }
183
        );
184

185
        let postfix = "";
23✔
186
        for (const person of people) {
23✔
187
            // NOTE: use CSID here to be more resilient if CWLs change
188
            // TODO: this would be even better if it was person.id
189
            postfix = postfix + "_" + person.csId;
33✔
190
        }
191

192
        let tName = "";
23✔
193
        if (deliv.teamPrefix.length > 0) {
23!
194
            tName = deliv.teamPrefix + "_" + deliv.id + postfix;
23✔
195
        } else {
196
            tName = deliv.id + postfix;
×
197
        }
198

199
        let rName = "";
23✔
200
        if (deliv.repoPrefix.length > 0) {
23!
201
            rName = deliv.repoPrefix + "_" + deliv.id + postfix;
×
202
        } else {
203
            rName = deliv.id + postfix;
23✔
204
        }
205

206
        const db = DatabaseController.getInstance();
23✔
207
        const team = await db.getTeam(tName);
23✔
208
        const repo = await db.getRepository(rName);
23✔
209

210
        if (team === null && repo === null) {
23✔
211
            Log.trace("CourseController::computeNames( ... ) - done; t: " + tName);
11✔
212
            return {teamName: tName, repoName: rName};
11✔
213
            // return tName;
214
        } else {
215
            // TODO: should really verify that the existing teams contain the right people already
216
            return {teamName: tName, repoName: rName};
12✔
217
            // return tName;
218
        }
219
    }
220

221
    public async finalizeProvisionedRepo(repo: Repository, teams: Team[]): Promise<boolean> {
222
        Log.warn("CourseController::finalizeProvisionedRepo( " + repo.id + " ) - default impl; returning true");
5✔
223
        return true;
5✔
224
    }
225

226
    public async shouldPrioritizePushEvent(info: CommitTarget): Promise<boolean> {
227
        Log.warn(`CourseController::shouldPrioritizePushEvent(${info.commitSHA}) - Default impl; returning false`);
1✔
228
        return false;
1✔
229
    }
230

231
    public async requestFeedbackDelay(info: { delivId: string; personId: string; timestamp: number }): Promise<{
232
        accepted: boolean,
233
        message: string,
234
        fullMessage?: string
235
    } | null> {
236
        Log.warn(`CourseController::requestFeedbackDelay(${info}) - Default impl; returning null`);
×
237
        return null;
×
238
    }
239

240
    /**
241
     * By default, nothing is needed here.
242
     *
243
     * @param grade
244
     */
245
    public async convertGrade(grade: Grade): Promise<Grade> {
246
        Log.info(`CourseController::convertGrade(${grade}) - Default impl; returning original grade`);
5✔
247
        return grade;
5✔
248
    }
249

250
    // NOTE: the default implementation is currently broken; do not use it.
251
    /**
252
     * This is a method that subtypes can call from computeNames if they do not want to implement it themselves.
253
     *
254
     * @param {Deliverable} deliv
255
     * @param {Person[]} people
256
     * @returns {Promise<{teamName: string | null; repoName: string | null}>}
257
     */
258
    // public async computeNames(deliv: Deliverable, people: Person[]): Promise<{teamName: string | null, repoName: string | null}> {
259
    //     Log.info("AdminController::computeNames(..) - start; # people: " + people.length);
260
    //
261
    //     // TODO: this code has a fatal flaw; if the team/repo exists already for the specified people,
262
    //     // it is correct to return those.
263
    //
264
    //     let repoPrefix = "";
265
    //     if (deliv.repoPrefix.length > 0) {
266
    //         repoPrefix = deliv.repoPrefix;
267
    //     } else {
268
    //         repoPrefix = deliv.id;
269
    //     }
270
    //
271
    //     let teamPrefix = "";
272
    //     if (deliv.teamPrefix.length > 0) {
273
    //         teamPrefix = deliv.teamPrefix;
274
    //     } else {
275
    //         teamPrefix = deliv.id;
276
    //     }
277
    //     // the repo name and the team name should be the same, so just use the repo name
278
    //     const repos = await this.dbc.getRepositories();
279
    //     let repoCount = 0;
280
    //     for (const repo of repos) {
281
    //         if (repo.id.startsWith(repoPrefix)) {
282
    //             repoCount++;
283
    //         }
284
    //     }
285
    //     let repoName = "";
286
    //     let teamName = "";
287
    //
288
    //     let ready = false;
289
    //     while (!ready) {
290
    //         repoName = repoPrefix + "_" + repoCount;
291
    //         teamName = teamPrefix + "_" + repoCount;
292
    //         const r = await this.dbc.getRepository(repoName);
293
    //         const t = await this.dbc.getTeam(teamName);
294
    //         if (r === null && t === null) {
295
    //             ready = true;
296
    //         } else {
297
    //             Log.warn("AdminController::computeNames(..) - name not available; r: " + repoName + "; t: " + teamName);
298
    //             repoCount++; // try the next one
299
    //         }
300
    //     }
301
    //     Log.info("AdminController::computeNames(..) - done; r: " + repoName + "; t: " + teamName);
302
    //     return {teamName: teamName, repoName: repoName};
303
    // }
304

305
    // public static validateProvisionTransport(obj: ProvisionTransport) {
306
    //     if (typeof obj === "undefined" || obj === null) {
307
    //         const msg = "Transport not populated.";
308
    //         Log.error("AdminController::validateProvisionTransport(..) - ERROR: " + msg);
309
    //         throw new Error(msg);
310
    //     }
311
    //
312
    //     // noinspection SuspiciousTypeOfGuard
313
    //     if (typeof obj.delivId !== "string") {
314
    //         const msg = "Provision.id not specified";
315
    //         Log.error("AdminController::validateProvisionTransport(..) - ERROR: " + msg);
316
    //         throw new Error(msg);
317
    //     }
318
    //
319
    //     // noinspection SuspiciousTypeOfGuard
320
    //     if (typeof obj.formSingle !== "boolean") {
321
    //         const msg = "formSingle not specified";
322
    //         Log.error("AdminController::validateProvisionTransport(..) - ERROR: " + msg);
323
    //         return msg;
324
    //     }
325
    //
326
    //     // const dc = new DeliverablesController();
327
    //     // const deliv = await dc.getDeliverable(obj.delivId);
328
    //     // if (deliv === null && deliv.shouldProvision === true) {
329
    //     //     const msg = "delivId does not correspond to a real deliverable or that deliverable is not provisionable";
330
    //     //     Log.error("AdminController::validateProvisionTransport(..) - ERROR: " + msg);
331
    //     //     return msg;
332
    //     // }
333
    //
334
    //     return null;
335
    // }
336
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc