跳转至

开发者指南

本页概述了调试和测试技巧、最佳实践和编码风格,以及有关 ZJUI-Learn 各个方面的详细信息(问题呈现、数据库模式等)。

一般来说,我们更喜欢简单。我们将 JavaScript/TypeScript (Node.js) 和 SQL (PostgreSQL) 标准化为实现语言,并尽量减少使用的复杂库或框架的数量。该网站是服务器端生成的页面,具有最小的客户端 JavaScript。

高层视图

InstructorStudentCourse git repoPL serverDatabase

  • 课程的问题和评估存储在 git 存储库中。课程讲师将其同步到数据库中,并更新或添加数据库数据以代表课程。然后,学生通过做问题与课程网站互动,结果存储在数据库中。教师可以在网站上查看学生成绩并下载包含数据的 CSV 文件。
  • 大多数课程内容和配置都是通过 git 存储库中的纯文本文件完成的,git 存储库是该数据的主要来源。
  • 所有学生数据都存储在数据库中,并且不会在任何时候推回到 git 存储库或磁盘中。

单元测试和集成测试

  • 集成测试存储在 apps/prairielearn/src/tests/ 目录中。
  • 单元测试通常位于被测文件旁边,文件名以 .test.ts 结尾。例如,foo.ts 的测试将位于同一目录中的 foo.test.ts 中。
  • 每次推送到 GitHub 时,GitHub 操作都会运行测试。
  • 测试主要是集成测试,从空白数据库开始,运行服务器初始化数据库,加载testCourse,然后模拟客户端Web浏览器回答评估问题。如果测试失败,那么通常最简单的调试方法是通过对本地运行的服务器自行执行问题来重新创建错误。
  • 如果设置了PL_KEEP_TEST_DB环境,则测试结束时不会删除测试数据库(通常为pltest_1pltest_2等)。这允许您在测试结束时检查数据库的状态。当您开始新的测试运行时,数据库将被覆盖。

调试服务器端JavaScript

  • 使用调试包来帮助跟踪JavaScript中的执行流程。要在启用调试输出的情况下运行服务器:

    DEBUG=* make dev
    
  • 要仅查看 ZJUI-Learn 的调试日志,您可以使用:

    DEBUG=prairielearn:* make dev
    
  • 要插入更多调试输出,请导入 debug 并按如下方式使用它:

    import debugfn from 'debug';
    
    const debug = debugfn('prairielearn:my-file');
    
    // in some function later
    debug('func()', 'param:', param);
    

调试客户端JavaScript

  • 确保您已在浏览器中打开 JavaScript 控制台并重新加载页面。

调试 SQL 和 PL/pgSQL

  • 使用psql命令行界面单独测试SQL。默认开发 ZJUI-Learn 安装使用 postgres 数据库,因此您应该运行:

    psql postgres
    
  • 要调试存储过程中的语法错误,请使用 psql 中的 \i filename.sql 手动导入它。
  • 要遵循 PL/pgSQL 中的执行流程,请使用 RAISE NOTICE。当从 psql 运行时,这将记录到控制台;当从 ZJUI-Learn 中运行时,这将记录到服务器日志文件。语法是:

    RAISE NOTICE 'This is logging: % and %',
    var1,
    var2;
    
  • 手动运行函数:

    SELECT
      the_sql_function (arg1, arg2);
    

HTML 页面生成

  • 所有页面都是服务器端渲染的,我们尝试尽量减少客户端 JavaScript 的数量。客户端 JS 应尽可能使用普通 JavaScript/TypeScript,但在适当的情况下也可以使用第三方库。
  • 每个网页通常将其所有文件放在一个目录中,目录、文件和 URL 的名称都相同。并非所有页面都需要所有文件。对于实际示例,请考虑用户可以接受 ZJUI-Learn 条款和条件的页面,该页面位于 apps/prairielearn/src/ee/pages/terms。该目录包含以下文件:
    • terms.ts:页面的主入口点。它运行 SQL 查询并呈现模板。
    • terms.sql:该页面的所有SQL查询。
    • terms.html.ts:页面的模板。导出返回 HTML 文档的函数。
  • 如果可能,最好将单个类型化属性显式传递给模板,而不是将属性添加到 res.locals。但是,res.locals 可用于来自将在许多页面上使用的中间件的数据。
  • 使用@prairielearn/html生成HTML页面。它使用 HTML 标记模板文字来生成 HTML,从而可以轻松进行完整的类型检查。
  • 重复使用的模板存储在apps/prairielearn/src/components/目录中。这些通常应该接受具有属性的对象,而不是传递完整的 res.locals 对象。

HTML样式

  • 使用Bootstrap作为样式。截至 2025 年 1 月 1 日,我们使用 Bootstrap 5。
  • 本地 CSS 规则位于 public/stylesheets/local.css 中。尽量减少使用它并尽可能使用简单的 Bootstrap 样式。
  • 按钮在执行操作时应使用 <button> 元素,而在仅链接到其他页面时应使用 <a> 元素。我们不应该使用 <a role="button"> 来伪造按钮元素。不提交表单的按钮应始终以 <button type="button" class="btn ..."> 开头,其中 type="button" 指定它们不提交。

HTML 辅助功能

如果您要添加比基本表单页面更复杂的内容,则自动可访问性检查可能不足以检查可访问性。您应该使用 VoiceOver (macOS)NVDA (Windows) 来测试页面。我们的所有页面必须符合网页内容可访问性指南 (WCAG) 2.1 AA 标准。需要检查的一些常见事项:

  • 元素的宣告是否正确?
    • 元素是否具有适当的 aria-label / alt 属性?
    • 描述是否简洁、准确?
    • 菜单、工具栏和其他 UI 元素是否具有适当的 ARIA 角色
  • 用户可以仅使用键盘导航页面吗?
    • 选项卡应在可聚焦元素之间移动。
    • 空格键或 Enter 键应激活按钮/链接。
    • 箭头键应该在下拉列表和表格等组件内导航。
    • 需要用鼠标拖动的操作应该有键盘替代方案。
  • 焦点管理是否正确?
    • 焦点是否正确地捕获在模式和其他对话框中?
    • 重新渲染期间焦点位置是否保留?
  • 焦点指示器是否可见?
  • 页面布局是否符合逻辑且易于理解?
  • 有视力障碍的用户可以使用该页面吗?
    • 文本和背景颜色之间的对比度是否至少为 4.5:1?
    • UI 元素和背景颜色之间的对比度是否至少为 3:1?
    • 元素之间是否有适当的间距?

SQL使用

  • 编写原始 SQL,而不是使用 ORM 库。这减少了所需的框架/语言的数量。
  • 更喜欢在 TypeScript 中实现复杂的逻辑,而不是内部查询。
  • 使用 SQL 约定 snake_case 作为名称。对于与 SQL 中相同的名称,在 JavaScript 中也使用相同的约定,因此 SQL 中的 question_id 变量在 JavaScript 代码中也称为 question_id
  • SQL 保留字使用大写,例如 SELECTFROMAS 等。
  • SQL 代码不应内联在 JavaScript 文件中。相反,它应该位于一个单独的 .sql 文件中,遵循 Yesql 概念。每个 filename.js 文件通常在同一目录中都有一个对应的 filename.sql 文件。 .sql 文件应如下所示:

    -- BLOCK select_question
    SELECT
      *
    FROM
      questions
    WHERE
      id = $question_id;
    
    -- BLOCK insert_user
    INSERT INTO
      users (uid)
    VALUES
      ($uid)
    RETURNING
      *;
    

从 JavaScript 中,您可以执行以下操作:

import { loadSqlEquiv, queryRow } from '@prairielearn/postgres';

import { QuestionSchema } from './lib/db-types.js';

const sql = loadSqlEquiv(import.meta.url);

const question = await queryRow(sql.select_question, { question_id: 45 }, QuestionSchema);
  • 为了保持 SQL 代码组织有序,最好使用 CTE(WITH 查询)。这些格式如下:

    WITH
      first_preliminary_table AS (
        SELECT
          -- first preliminary query
      ),
      second_preliminary_table AS (
        SELECT
          -- second preliminary query
      )
    SELECT
      -- main query here
    FROM
      first_preliminary_table AS fpt,
      second_preliminary_table AS spt;
    

数据库存储过程 (sprocs)

Warning

我们正在放弃使用 sprocs。更喜欢在 TypeScript 中编写逻辑。

  • 存储过程由 sprocs/ 中的文件创建。要从 JavaScript 调用存储过程,请使用如下代码:

    const workspace_id = 1342;
    const message = 'Startup successful';
    await sqldb.callAsync('workspaces_message_update', [workspace_id, message]);
    
  • 存储过程全部包含在一个单独的数据库模式 中,其名称类似于server_2021-07-07T20:25:04.779Z_T75V6Y。要查看模式列表,请使用 psql 中的 \dn 命令。
  • 为了能够从 psql 命令行使用存储过程,必须使用 \dn 获取最新的架构名称,并将 search_path 设置为使用此 quoted 架构名称和 public 架构:

    set search_path to "server_2021-07-07T20:25:04.779Z_T75V6Y", public;
    
  • 在启动过程中,我们最初没有使用非公共模式。我们首先运行迁移来更新 public 模式中的所有表,然后调用 sqldb.setRandomSearchSchemaAsync() 来激活随机的每次执行模式,然后运行存储过程创建代码来生成该模式中的所有存储过程。这意味着 ZJUI-Learn 的每次调用都将拥有其自己的本地范围的存储过程副本,这些副本是其代码的正确版本。这让我们可以一次升级一台 ZJUI-Learn 服务器,而旧服务器仍然以其自己的存储过程副本运行。当 ZJUI-Learn 第一次启动时,它具有 search_path = public,但稍后它将具有 search_path = "server_2021-07-07T20:25:04.779Z_T75V6Y",public,因此它将首先搜索随机模式,然后回退到 public。随机模式的命名约定使用本地实例名称、日期和随机字符串。请注意,在 psql 中,模式名称需要使用双引号引起来,因为它们包含连字符等字符。
  • 有关更多详细信息,请参阅 sprocs/array_and_number.sqlserver.js 中调用 sqldb.setRandomSearchSchemaAsync() 附近的注释。

数据库架构(简化概述)

  • 数据库中最重要的表如下图所示:

userscoursescourse_instancesquestionsassessmentsassessment_questionsassessment_instancesinstance_questionsvariantssubmissionslegendGenerated by student activityMade by instructorsMade by administrator

  • 每个表都有一个 id 编号,用于交叉引用。例如,questions 表中的每一行都有一个 id,其他表将将此称为 question_id。由于遗留原因,此规则有两个例外:
    • users 表具有 user_id 主键,而不是 id
  • 每个用户都作为一行存储在 users 表中。
  • courses 表中的每门课程占一行,例如 TAM 212
  • course_instances 表每学期 ("instance") 占一行,其中 course_id 表示它属于哪门课程。
  • 每个问题都是 questions 表中的一行,course_id 显示它属于哪个课程。一门课程的所有问题都可以被视为该课程的“问题库”。所有学期(所有课程实例)都使用同一个池。
  • 评估存储在 assessments 表中,每个评估行都有一个 course_instance_id 来指示它属于哪个课程实例(以及哪个课程)。评估类似于“作业 1”或“考试 3”。为了确定这一点,我们可以使用每个评估行的 assessment_set_idnumber
  • 每个评估都有一个与其相关的问题列表。该列表存储在 assessment_questions 表中,其中每行都有一个 assessment_idquestion_id 来指示哪些问题属于哪个评估。例如,“考试 1”可能有 20 个不同的问题,并且每个学生可能会随机选择其中 5 个问题。
  • 每个学生都有自己的评估副本,存储在 assessment_instances 表中,每行都有 user_idassessment_id。这是存储学生该评估的分数的位置。
  • 每个学生在每次评估中选择的问题都在 instance_questions 表中。这里,每一行都有一个 assessment_question_id 和一个 assessment_instance_id 来指示相应的问题位于该评估实例上。该行还将存储学生在该特定问题上的分数。
  • 问题可以随机化其参数,因此每个问题都有许多可能的变体。这些存储在 variants 表中,其中 instance_question_id 指示变体属于哪个实例问题。
  • 对于学生看到的问题的每个变体,他们将提交零个或多个 submissions 以及 variant_id 以显示其所属内容。提交行还包含提交的答案及其是否正确的信息。
  • assessment_tools 表存储用于评估的工具配置(例如计算器)。每行引用 zone_idassessment_id,允许在评估级别配置工具或按区域覆盖工具。

???提示“模式和数据探索”

The [`ms-ossdata.vscode-pgsql` VSCode extension](https://marketplace.visualstudio.com/items?itemName=ms-ossdata.vscode-pgsql) can help you explore the database schema and data in your editor.

![Setup Postgres VSCode](./postgres-vscode-setup.png)

![Using Postgres VSCode](./postgres-vscode-select-1000.png)

???提示“清理旧模式”

The following query will remove all schemas except `public`. You can then restart the server to recreate the sprocs.

```sql
DO $$
DECLARE
  r RECORD;
BEGIN
  FOR r IN
    SELECT nspname
    FROM pg_namespace
    WHERE nspname NOT IN ('public', 'information_schema', 'pg_catalog')
      AND nspname NOT LIKE 'pg_toast%'
      AND nspname NOT LIKE 'pg_temp_%'
  LOOP
    EXECUTE format('DROP SCHEMA IF EXISTS %I CASCADE;', r.nspname);
    COMMIT;  -- avoid shared memory exhaustion
  END LOOP;
END $$;
```

数据库架构(完整数据)

usersadministratorscourses*course_permissionsexamsreservationscourse_instances*course_instance_access_rulesassessment_access_rulesquestions*question_tagstagsassessments*assessment_questions*assessment_setstopicsalternative_groupszonesassessment_toolsissuesenrollmentsassessment_instancesinstance_questionsvariantssubmissionsgrading_jobsjob_sequencesjobsassessment_state_logsassessment_score_logsquestion_score_logsassessment_view_logsLegendPrairieSchedule tables.Logging objects.Not needed for operation.Cannot be deleted.Generated by useractivity.Cannot be deleted.Made by instructorsfrom course git repo.'*' means soft deletes.Made by superuser.'*' means soft deletes.

数据库模式约定

  • 表具有复数名称(例如 assessments),并且始终有一个名为 id 的主键。指向该表的外键是非复数的,例如assessment_id。引用此内容时,请使用每个单词的第一个字母的缩写,例如本例中的 ai。唯一的例外是 assessment_setsaset(以避免与 SQL AS 关键字冲突)、topicstoptagstag(以避免冲突)。这给出了如下代码:

    -- select all active assessment_instances for a given assessment
    SELECT
      ai.*
    FROM
      assessments AS a
      JOIN assessment_instances AS ai ON (ai.assessment_id = a.id)
    WHERE
      a.id = 45
      AND ai.deleted_at IS NULL;
    
  • 我们(almost)永远不会从数据库中删除学生数据。为了避免外键损坏或丢失的行,课程配置表(例如 assessments)实际上无法删除。相反,通过将 deleted_at 列设置为非 NULL 来“软删除”它们。这意味着当使用任何软删除表时,我们需要有一个 WHERE deleted_at IS NULL 来仅获取活动行。

数据库架构修改

请参阅迁移文档

数据库访问

  • 数据库访问是通过 @prairielearn/postgres 包进行的。这包装了 node-postgres 库。
  • 对于单个查询,我们通常使用以下模式,该模式自动使用来自node-postgres的连接池以及带有命名参数和准备好的语句的安全变量插值:

    const questions = await queryRows(
      sql.select_questions_by_course,
      { course_id: 45 },
      QuestionSchema,
    );
    

其中对应的filename.sql文件包含:

-- BLOCK select_questions_by_course
SELECT
  *
FROM
  questions
WHERE
  course_id = $course_id;
  • 对于不精确返回一个结果行将导致错误的查询:

    const question = await queryRow(sql.block_name, QuestionSchema);
    
  • 每当修改与评估相关的学生数据时,请使用显式行锁定。这必须在事务中完成。规则是我们锁定变体(如果没有相应的评估实例)或评估实例(如果有)。在单个事务中重复锁定同一行是可以的,因此涉及修改评估元素的所有函数(例如,添加提交、评分等)都应该在启动时调用锁定函数。可以使用如下查询来执行锁定:
SELECT
  *
FROM
  assessment_instances
WHERE
  id = $assessment_instance_id
FOR NO KEY UPDATE;
  • 要将参数数组传递给 SQL 代码,请使用以下模式,该模式允许数组中存在零个或多个元素。这会将 SQL 中的 $points_list 替换为 ARRAY[10, 5, 1]。如果数组为空,则需要指定数组的类型:

    await sqldb.execute(sql.insert_assessment_question, {
      points_list: [10, 5, 1],
    });
    
    -- BLOCK insert_assessment_question
    INSERT INTO
      assessment_questions (points_list)
    VALUES
      ($points_list::INTEGER[]);
    
  • 要在 SQL 中使用 JavaScript 数组进行成员资格测试,请使用 = ANY ($array) (或其负形式 != ALL ($array)),例如:

    const questions = await sqldb.queryRows(
      sql.select_questions,
      { id_list: [7, 12, 45] },
      QuestionSchema,
    );
    
    -- BLOCK select_questions
    SELECT
      *
    FROM
      questions
    WHERE
      id = ANY ($id_list::BIGINT[]);
    
  • 要将大量数据传递给 SQL,一种有用的模式是发送 JSON 对象数组并在 SQL 中将其解压为相当于表的形式。这是“同步”代码使用的模式,例如 sprocs/sync_questions.sql。例如:

    let data = [
      { a: 5, b: 'foo' },
      { a: 9, b: 'bar' },
    ];
    await sqldb.execute(sql.insert_data, {
      data: JSON.stringify(data),
    });
    
    -- BLOCK insert_data
    INSERT INTO
      my_table (a, b)
    SELECT
      *
    FROM
      jsonb_to_recordset($data) AS (a INTEGER, b TEXT);
    
  • 要以上述方式使用 JSON 对象数组,但行的顺序很重要,请使用 ROWS FROM () WITH ORDINALITY 生成如下行索引:

    -- BLOCK insert_data
    INSERT INTO
      my_table (a, b, order_by)
    SELECT
      *
    FROM
      ROWS
    FROM
      (jsonb_to_recordset($data) AS (a INTEGER, b TEXT)) WITH ORDINALITY AS data (a, b, order_by);
    

JavaScript中的异步控制流程

  • ZJUI-Learn 中的新代码应尽可能使用 async/await
  • 使用 异步库 进行复杂的控制流或混合基于 Promise 和基于回调的代码时。

将异步路由处理程序与 ExpressJS 结合使用

  • Express 不能直接使用异步路由处理程序。相反,我们使用 express-async-handler,如下所示:

    import asyncHandler from 'express-async-handler';
    router.get(
      '/',
      asyncHandler(async (req, res, next) => {
        // can use "await" here
      }),
    );
    

安全模型

  • 我们区分认证和授权。身份验证作为服务器响应的第一阶段进行,经过身份验证的用户数据存储为 res.locals.authn_user
  • 认证流程为:

    1. 我们首先重定向到远程身份验证服务(例如 SAML SSO、Google、Microsoft)。

    2. 远程身份验证服务重定向回回调 URL,例如/pl/oauth2callback 代表 Google。这些端点确认身份验证,如有必要,在 users 表中创建用户,使用经过身份验证的 user_id 在浏览器中设置签名的 pl_authn cookie,然后重定向到主 PL 主页。该 cookie 使用 HttpOnly 属性设置,可防止客户端 JavaScript 读取该 cookie。

    3. 所有其他页面都使用签名的浏览器 pl_authn cookie 进行身份验证。这是由 middlewares/authn.ts 读取的,它检查签名,然后使用 user_id 从数据库加载用户数据,并将其存储为 res.locals.authn_user

  • 与unix类似,我们区分真实用户和有效用户。真实用户存储为 res.locals.authn_user,并且是经过身份验证的用户。有效用户存储为res.locals.user。只有role = TA或更高版本的用户才能设置与其真实用户不同的有效用户。此外,role = TA或更高版本的用户还可以设置与实际值不同的有效rolemode
  • 授权发生在多个级别:

    • course_instance根据authn_user进行授权检查。
    • course_instance 授权与有效的 user 进行检查。
    • assessment根据有效的userrolemodedate进行授权检查。
  • 所有状态修改请求必须为 (normally) 为 POST,并且所有关联数据必须位于正文中。 GET 请求可以使用查询参数,仅用于查看选项。

权限检查

几乎每个页面都在处理数据库数据,因此了解如何安全地与数据库交互并进行适当的权限检查非常重要。

角色层次结构

有 4 种不重叠的角色类型:“系统角色”、“学生课程实例角色”、“讲师课程实例角色”和“课程角色”。在层次结构中拥有较高的角色意味着拥有其下方角色的所有权限,但这并不意味着拥有其他列中的任何权限。

角色层次

与数据库安全交互

Note

该模式目前正在逐个模型的基础上作为现有代码的逐步重构而推出。

对于大多数 API/POST 处理程序,我们希望根据未经验证的查询参数或请求正文字段查找或修改数据。很容易忘记使用正确的授权级别验证这些字段。特定于路由的角色检查通常应该存在于中间件或处理程序中;模型功能应侧重于所有权和记录归属检查。为了解决这个问题,我们进行了两项检查:

  1. 模型函数应接受完整的类型化行对象作为参数。拥有该对象意味着调用者有权读取该记录。例如,注册状态的更新应要求调用者传入完整的注册行对象。我们希望仅使用注册 ID 来更新注册状态变得困难(例如,通过向 /api/enrollments/<enrollment_id>/status 发送 POST 请求,并使用 req.params.enrollment_id 执行未经验证的更新)。

  2. 所有权敏感的模型函数应要求调用者传递证明所有权或记录归属所需的上下文。在下面的示例中,selectEnrollment 函数要求调用者传入 courseInstanceauthzData 参数,因此它可以断言注册属于用户,并且位于正确的课程实例中。

const enrollment = await selectEnrollment({
  id: enrollment_id,
  // This serves to require the caller to be aware of the role they want to authorize as.
  // We want to require the user to have at least the Student Data Viewer role.
  requiredRole: ['Student Data Viewer'],
  // Information to prove we are authorized to read the record.
  // E.g. we need to prove that we have access to the course instance it's in,
  // and that we have the correct permissions to read the enrollment record.
  courseInstance,
  authzData: res.locals.authz_data,
});

单角色

!!! note 《模型函数示例》

这是一个示例模型函数,演示了 `src/models/enrollment.ts` 中的模式。

```typescript
export async function selectEnrollmentById({
  // The ID of the enrollment to look up. This ID is unvalidated and comes from the request body.
  id,
  // The course instance from res.locals
  courseInstance,
  requiredRole,
  // The authorization data from res.locals
  authzData,
}: {
  id: string;
  courseInstance: CourseInstanceContext;
  // The type of `requiredRole` is used to restrict the set of roles that can call this function.
  requiredRole: ('Student' | 'Student Data Viewer' | 'Student Data Editor')[];
  authzData: AuthzData;
}) {
  assertHasRole(authzData, requiredRole);
  const enrollment = await queryRow(sql.select_enrollment_by_id, { id }, EnrollmentSchema);
  assertEnrollmentInCourseInstance(enrollment, courseInstance);
  if (requiredRole === 'Student') {
    assertEnrollmentBelongsToUser(enrollment, authzData);
  }
  return enrollment;
}
```

在上面的示例中,selectEnrollment 函数要求调用者传入 courseInstanceauthzData 参数,因此它可以断言注册属于用户,并且位于正确的课程实例中。如果调用者无权访问注册,它将抛出错误(如果是 selectOptionalEnrollment,则为 null)。这也迫使调用者证明对课程实例的访问权限才能读取注册记录。

这很好,因为为了执行更新,您需要传入完整的行对象,而请求正文将没有足够的信息。获取行的模型函数要求调用者传入所需的信息以执行正确的授权检查。

一旦您拥有完整的行对象,您就断言调用者有权读取该记录。您还需要断言调用者有权_写入_记录。在下面的示例中,我们将 requiredRole 设置为 ['Student'],因此调用者必须是学生才能更新注册状态。

await updateEnrollmentStatus({
  enrollment: myEnrollment,
  status: 'joined',
  // What role are we requiring the user to have?
  requiredRole: ['Student'],
  // The user's authorization data, needed to prove we are authorized to write the record
  authzData: res.locals.authz_data,
});

在此示例中,不允许教师加入学生的课程实例。模型函数会注意到 requiredRole 参数是 ['Student'],但当前用户是讲师,因此会抛出错误。

绕过授权检查

在某些情况下,您可能无法访问 authzData,例如如果您从队列中提取数据,或者深入内部代码。在这种情况下,您可以使用 dangerousFullSystemAuthz 函数构建一个虚拟 authzData 对象,该对象允许您像系统一样执行操作。应谨慎使用。

await updateEnrollmentStatus({
  enrollment: myEnrollment,
  status: 'joined',
  // We are requiring the user to have the System role, any non-System role will throw an error.
  requiredRole: ['System'],
  authzData: dangerousFullSystemAuthz(),
});

多重角色

在某些情况下,您可能希望允许用户执行该操作(如果他们具有任何所需的角色)。例如,如果您要更新注册状态,您可能希望允许用户在具有 Viewer 角色的情况下执行该操作,但您可能还希望允许用户在具有 Student Data Viewer 角色的情况下执行该操作。这是一种常见模式,在课程实例上下文中执行操作时,您检查课程中是否有 Previewer 或课程实例中是否有 Student Data Viewer

await updateEnrollmentStatus({
  enrollment: myEnrollment,
  status: 'joined',
  requiredRole: ['Viewer', 'Student Data Viewer'],
});

多重角色

模式的例外情况

课程实例和课程的模型函数是该模式的一个显着例外。

获取课程实例或课程的完整行对象的唯一方法通常是通过 res.locals.authz_data

因此,select* 功能未经过验证。在大多数情况下,使用这些是一个危险信号,因为您应该能够从 res.locals.authz_data 中选择信息来执行操作。

或者,如果您想检查您是否有权执行某项操作,您可以将 buildAuthzData 与课程/实例 ID 一起使用来获取可用于数据修改操作的 authzData 对象。

状态修改 POST 请求

笔记

This section is outdated. It is now preferred to do the following things:

  1. Use a Zod schema to validate the request body.
  2. Call model functions instead of directly executing SQL (see above).
  • 使用 Post/Redirect/Get 模式进行所有状态修改。这意味着初始 GET 应该呈现带有未设置 action<form> 的页面,因此它将提交回当前页面。这应该由 POST 处理程序处理,该处理程序执行状态修改,然后发出重定向回与 GET 相同的页面:

    router.post(
      '/',
      asyncHandler(async (req, res) => {
        if (req.body.__action == 'enroll') {
          await execute(sql.enroll, {
            course_instance_id: req.body.course_instance_id,
            user_id: res.locals.authn_user.id,
          });
          res.redirect(req.originalUrl);
        } else {
          throw new error.HttpStatusError(400, `unknown __action: ${req.body.__action}`);
        }
      }),
    );
    
  • 所有数据修改请求应来自 form 元素,例如:

    <form name="enroll-form" method="POST">
      <input type="hidden" name="__action" value="enroll" />
      <input type="hidden" name="__csrf_token" value="${__csrf_token}" />
      <input type="hidden" name="course_instance_id" value="56" />
      <button type="submit" class="btn btn-info">Enroll in course instance 56</button>
    </form>
    
  • res.locals.__csrf_token 变量由早期中间件设置和检查,因此不需要在每个页面上执行显式操作。

记录错误

  • 我们使用 Winston 记录到控制台和文件:

    import { logger } from '@prairielearn/logger';
    
    logger.info('This is an info message');
    logger.error('This is an error message');
    
    // This will be logged to the log file, but not to the console:
    logger.verbose('This is a verbose message');
    
  • 所有 logger 函数都有一个强制的第一个参数(字符串)和一个可选的第二个参数(包含有用信息的对象)。始终提供字符串作为第一个参数非常重要。

编码风格

ESLintPrettier 用于在整个代码库中强制执行一致的代码约定和格式。请参阅 ZJUI-Learn 存储库根目录中的 .eslintrc.js.prettierrc.json 以查看我们的具体配置。该存储库包含一个 .editorconfig 文件,大多数编辑器都会检测并使用该文件来自动配置缩进等内容。如果您的编辑器本身不支持 EditorConfig 文件,则有适用于大多数其他编辑器的 插件

对于 Python 文件,ruff 用于自动格式化和强制执行代码约定,Pyright 用于静态类型检查。请参阅 ZJUI-Learn 存储库根目录中的 pyproject.toml 以查看我们的具体配置。我们鼓励所有新的 Python 代码包含与静态类型检查器一起使用的类型提示,因为这样可以更轻松地阅读、审查和验证贡献。

要检查代码,请使用 make lint。这也是由 CI 测试运行的。

要自动修复 lint 和格式错误,请运行 make format

要与 HEAD 相比格式化所有更改的文件(暂存 + 未暂存 + 未跟踪),请运行 make format-changed。这比格式化整个代码库要快。

问题呈现控制流程

  • 上述文件均由需要呈现问题的每个顶级页面(例如,pages/instructorQuestionPreviewpages/studentInstanceQuestion 等)调用/包含。不幸的是,控制流很复杂,因为我们需要在页面数据加载期间调用 lib/question-render.ts,存储它生成的数据,然后包含 components/QuestionContainer.html.ts 模板来实际渲染该数据。
  • 例如,pages/instructorQuestion 的确切控制流程为:

    1. 顶层页面pages/instructorQuestion/instructorQuestion.js代码调用lib/question-render.getAndRenderVariant()

    2. getAndRenderVariant() 将数据插入到 res.locals 中,供 components/QuestionContainer.html.ts 稍后使用。

    3. 顶级页面代码呈现顶级模板 pages/instructorQuestion/instructorQuestion.html.ts,然后包含 components/QuestionContainer.html.ts

    4. components/QuestionContainer.html.ts 呈现之前由 lib/question-render.ts 生成的数据。

问题开放状态

  • 跟踪“打开”状态的级别分为三个级别,如下所示。如果任何对象为 open = false,那么它将阻止在其下面创建新对象。例如,要创建新提交,相应的变体、instance_question 和assessment_instance 必须全部打开。

    变量 允许新的instance_questions 允许新的variants 允许新的submissions
    assessment_instance.open
    instance_question.open
    variant.open

问题处理中的错误

  • 我们区分两种不同类型的学生错误:

    1. 答案可能是不可评分(submission.gradable = false)。这可能是由于答案缺失、格式无效(例如,在数字输入中输入字符串)或答案未通过某些基本检查(例如,提交的代码未编译)。这可以在解析或分级阶段发现。在这种情况下,submission.format_errors 对象应该存储有关错误的信息,以便学生更正他们的答案。使用 gradable = false 提交不会导致该问题的分数更新。也就是说,它的作用类似于已保存但未评分的提交,因为它被记录但对问题没有影响。如果是 gradable = false,则 scorefeedback 将不会向学生显示。

    2. 答案可能是可评分的,但不正确。在本例中为 submission.gradable = true 但为 submission.score = 0(或部分分数小于 1)。如果需要,可以设置 submission.feedback 对象来向学生提供有关其答案错误的信息。然而,这不是必需的。如果设置了 submission.feedback,那么一旦问题评分,它将与他们的 submission.score 一起显示给学生。

  • 在创建、回答和评分问题的过程中可能会出现三个级别的错误:

    错误级别 造成 已存储 报道 效果
    系统错误 内部 ZJUI-Learn 错误 磁盘日志 错误页面 操作被阻止。数据不保存到数据库中。
    问题错误 问题代码错误 issues Issue panels on the question page variant.broken_at != nullsubmission.broken == true。操作已完成,但未来的操作被阻止。
    学生错误 学生提交的数据无效(无法解析或无法评分) submission.gradable 设置为 false,详细信息存储在 submission.format_errors 在渲染的提交面板内 提交的内容不会被分配分数,也不会采取进一步的操作(例如,实例问题的分数发生变化)。学生可以重新提交以更正错误。
  • 跟踪问题错误涉及的重要变量是:

    变量 错误级别 描述
    variant.broken_at 问题错误 如果生成变体时出现问题代码错误,则设置为 NOW()。此类变体不会调用 render() 函数,而是显示为 This question is broken
    submission.broken 问题错误 如果在解析或分级变体时出现问题代码错误,则设置为 truesubmission.broken 变为 true 后,提交后将不再采取进一步操作。
    issues 问题错误 插入行以记录导致 variant.broken != nullsubmission.broken == true 设置为 true 的错误的详细信息。
    submission.gradable 学生错误 是否可以给这个提交打分。如果在解析或评分期间遇到 submitted_answer 中的格式错误,则设置为 false
    submission.format_errors 学生错误 有关解析或评分期间任何错误的详细信息。如果 gradable = false 应该设置为有意义的内容来解释提交的答案有什么问题。
    submission.graded_at 如果尚未评分,则为 NULL,否则为时间戳。
    submission.score 提交的最终分数。仅当 gradable = truegraded_at 不为 NULL 时才使用。
    submission.feedback 评分期间生成的反馈。仅当 gradable = truegraded_at 不为 NULL 时才使用。
  • 请注意,submission.format_errors 存储有关学生错误的信息,而 issues 表存储有关问题代码错误的信息。
  • 提问流程如下图所示:

Startupdate issues tabledisplay issue code to studentquestion displayed to studentwith submit buttonquestion displayed to studentwithout submit buttonsubmission receivedquestion points updategenerate() codeprepare() codeUneditable RenderEditable RenderBroken variantparse() codegrade() codeOpen variantClosed variantBroken submissionUngradable grading jobUngradable submissionGradable grading jobGradable submissiongraded_submission
render() code
editable = false
render() code
editable = true
variant.broken_at != null
variant.open = true
variant.broken_at = null
variant.open = true
variant.broken_at = null
variant.open = false
submission.broken = true

No grading job inserted

grading_job.gradable = false
grading_job.date = <value>
grading_job.score = null
grading_job.partial_scores = null
grading_job.correct = null
submission.broken = false
submission.gradable = false
submission.score = null
submission.partial_scores = null
grading_job.gradable = true
grading_job.date = <value>
grading_job.score = <value>
grading_job.partial_scores = <value>
grading_job.correct = <value>
submission.broken = false
submission.gradable = true

Graded submission:

submission.broken = false
submission.gradable = true
submission.graded_at = <value>
submission.score = <value>
submission.feedback = <value>
questioncode errorsuccessquestioncode errorsuccessquestioncode errorsuccessstudent submitsan answerquestioncode errorinvalid format forsubmitted_answersuccessinvalid format forsubmitted_answersuccessmore submissions areallowed for this variantsubmissions are no longerallowed for this variantquestioncode errorsuccess

断言

根据上下文,我们使用不同类型的断言。

  • 在测试中,我们使用从 vitest 导出的助手,例如assert.okassert.isDefined
  • 在服务器代码中,为了强制执行不变量(例如,永远不应该发生的事情),我们使用 node:assert 中的 assert
  • 用于在客户端或实用函数中断言结果,例如.querySelector.pop 等,请考虑使用 ! 运算符来断言某个值不是 nullundefined

JavaScript 相等运算符

您几乎应该始终使用 === 运算符进行比较;这是通过 ESLint 规则强制执行的。

== 运算符经常有用的唯一情况是比较可能来自客户端/数据库等的实体 ID。这些可能是字符串或数字,具体取决于它们的来源或获取方式。为了清楚地表明正在比较 ID,您应该使用 idsEqual 实用程序:

import { idsEqual } from './lib/id';

console.log(idsEqual(12345, '12345'));
// > true

使用 Zod 验证的“现代”查询会自动将所有 ID 强制转换为字符串。如果您确信比较双方的数据都来自经过 Zod 验证的查询,则可以直接使用 === 运算符。