소스 검색

Add Readme. And add Per-student limits to admin section.

Gregory Huxley 1 주 전
부모
커밋
f9bcfa822a
2개의 변경된 파일과 427개의 추가작업 그리고 0개의 파일을 삭제
  1. 187 0
      README.md
  2. 240 0
      admin_prefs.php

+ 187 - 0
README.md

@@ -0,0 +1,187 @@
+# TimeKeeper
+
+An e107 plugin that makes screen time something students earn. Students answer
+questions (maths, spelling, anagrams, the periodic table); correct answers earn play
+minutes. A separate program on each student's computer
+([TimekeeperEnforcement](https://git.pfip.au/gregory/TimekeeperEnforcement)) asks this
+server how many minutes are left and closes games when there are none.
+
+- **Students** answer questions at `/e107_plugins/timekeeper/timekeeper.php`.
+- **Parents/admins** get a daily report email, PDF/HTML reports, a stats page, and admin
+  screens for scores, answers and the spelling word list.
+- **The enforcement client** talks to `timekeeperClientServices/timer.php`.
+
+## Requirements
+
+- e107 2.x (`compatibility="2.0"` in `plugin.xml`)
+- PHP 7.1 or newer, MySQL/MariaDB
+- Apache, if you want the `.htaccess`-style blocking described under Security
+- Optional: `wkhtmltopdf` on the server (PDF reports), a text-to-speech server for spelling audio
+  (see Spelling audio), e107 mail configured (daily report emails)
+
+## Install
+
+1. Put the folder at `e107_plugins/timekeeper/` (lower case: the URLs assume it).
+2. Admin → Plugin Manager → install **TimeKeeper**. This creates the tables from
+   `timekeeper_sql.php`.
+3. Put each student in the `TimeKeeper_Student` user class (and any teacher in
+   `TimeKeeper_Teacher`). `plugin.xml` declares both; check Admin → User Classes that they exist.
+   The code checks them as `TIMEKEEPER_STUDENT` and `TIMEKEEPER_TEACHER`.
+4. Admin → TimeKeeper → **Preferences**: set the *Stats / summary key* (see Security).
+5. Make `audio/` writable by the web server user. The spelling questions save their spoken
+   audio there.
+6. Add words under Admin → TimeKeeper → **Spelling Words → Add Words**. Each needs a word, a
+   usage sentence and a year level (1–13). Words without a usage sentence are never asked.
+7. Optional: Menu Manager → enable the TimeKeeper menu (`timekeeper_menu.php`) for the sidebar
+   score box, and Admin → Scheduled Tasks (cron) → enable **Send Reports** for the daily email.
+
+### Per-student limits
+
+Each student has one row in the `timekeeper` table. Edit them at Admin → TimeKeeper →
+**Student Limits**: one form with a row per student, plus an *Add a student* box (the student
+needs an e107 account). A row with an invalid value is left unchanged and the problem is
+shown; the other rows still save. `timer.php` also creates the row, with the starting values
+below, the first time the enforcement client asks about a student who has an e107 account.
+
+| Column | Default | Meaning |
+|---|---|---|
+| `enabled` | `1` | `0` = no limit for this student (the server replies 1440) |
+| `freeMins` | `10` | Play minutes every day that don't need earned minutes |
+| `enableTime` / `disableTime` | `10.00` / `22.00` | Allowed play window as `HH.MM` (`22.30` = 10:30 pm). Write minutes as two digits: `9.5` means 9:50, not 9:30 |
+| `minQuestionCount` | `100` | Questions to answer **today** before earned minutes can be used |
+| `maxDailyPlayTime` | `1440` | Most minutes of play in a day, free and earned |
+| `usedMinutes`, `lastDate` | `0` | Counters kept by the server (the screen shows today's `usedMinutes` for information) |
+
+The window and day boundaries use the time zone set in `timekeeperClientServices/timer.php`
+(`Australia/Perth`). Change it there if you're elsewhere.
+
+## How it works
+
+**Questions.** `TK2.php` picks a subject from the list in `$subjectsList`, based on the
+student's *difficulty* (all-time question count × last month's accuracy %), then asks the
+generator in `questionGenerators/`. A subject unlocks at a difficulty threshold:
+
+| Subject | Unlocks at |
+|---|---|
+| numbers (missing number), spelling | 0 |
+| addition | 10,000 |
+| subtraction | 20,000 |
+| elements (periodic table) | 150,000 |
+| multiplication | 200,000 |
+| anagrams | 4,000,000 |
+
+Division, rounding, worded problems and the geography/history/health subjects exist or are
+stubbed but are switched off (weight 0 in `$subjectsList`).
+
+**Answers.** The expected answer never goes to the browser. The generator stores it in the
+`tkPendingAnswer` table (`TkAnswerStore.php`) and the form carries a random single-use token
+(`checkerData`). Answering consumes the token, so a changed token, a stale browser tab, a
+double submit or an old question gets "That question has expired or was already answered".
+Tokens last 2 hours.
+
+**Scoring.** A correct answer adds `round(last month's accuracy % ÷ 10)` minutes (at least
+1). A wrong answer takes one minute off, never below zero. Every answer is written to
+`tkReport`.
+
+**Play time.** The enforcement client calls `timer.php` once a minute while a game is
+running. The reply is a number (minutes remaining) or a message explaining why play isn't
+allowed. The server uses today's free minutes first, then earned minutes (only if
+`minQuestionCount` questions were answered today).
+
+## Pages and URLs
+
+| URL (under `/e107_plugins/timekeeper/`) | Who | What |
+|---|---|---|
+| `timekeeper.php` | students, teachers | The questions page |
+| `timekeeper.php?mode=Generate%20PDF&user=NAME&output=pdf` (or `html`) `&range=today` | logged in: admin, or that student | Report of answered questions. `range`: `today`, `yesterday`, `pastWeek`, `pastMonth`, `pastQuarter`, `pastYear` |
+| `timekeeper.php?mode=short&user=NAME&range=today` | same, or with `&key=` | Question count and accuracy |
+| `tk_stats_page.php?user=NAME&key=KEY` | anyone with the key (anyone at all if no key is set) | Per-subject scores and minutes left; refreshes every minute |
+| `timekeeperClientServices/timer.php?user=NAME&game=PROCESS` | the enforcement client | Minutes remaining; `&peek=1` doesn't use a minute |
+| `timekeeperClientServices/games.json` | the enforcement client | Process names to treat as games |
+
+## Admin area
+
+Admin → TimeKeeper (needs the Plugin Manager permission): Student Limits, Score, Report (every answer),
+Spelling Words (list, add, and a "Spelling Score" summary showing how many words still need a
+usage sentence), Geography (data only: unused so far), Preferences. *Questions*, *Pending* and
+*Report (Old)* are screens for tables from an earlier version.
+
+## Database
+
+| Table | Holds |
+|---|---|
+| `timekeeper` | Per-student limits (above) |
+| `timekeeperScore` | Lifetime counts and earned minutes |
+| `tkReport` | Every answer given |
+| `tkLastQuestion` | The question currently on screen, per student |
+| `tkPendingAnswer` | The expected answer behind it (single use) |
+| `timekeeperStatistics` | Minutes played per student, game and day |
+| `tk_wordList` | Spelling and anagram words |
+| `tk_countries` | Country data for the unfinished geography subject |
+| `timekeeperPending`, `timekeeperQuestions`, `timekeeperReport`, `timekeeperStats` | Earlier versions; still listed in `plugin.xml` |
+
+Tables from an earlier install: add any that are missing from `timekeeper_sql.php` and run
+`SELECT user, COUNT(*) FROM e107_tkLastQuestion GROUP BY user;`. If a student has more than one
+row, `DELETE FROM e107_tkLastQuestion;` clears them: the next question regenerates.
+
+## Spelling audio
+
+`questionGenerators/english/spelling2.php` asks a text-to-speech server for each word, saves it
+to `audio/<md5 of the word>.mp3`, and reuses it. The server address is the `TTS_URL` constant at
+the top of that file (an OpenAI-style `/v1/audio/speech` endpoint). It needs `allow_url_fopen`.
+If audio can't be produced, the sentence is shown with the word blanked out instead.
+
+## Security
+
+- **Stats key.** Set it under Preferences. Without one, `tk_stats_page.php` shows any student's
+  scores to anyone who knows the username.
+- **Reports** need a login. A student can only see their own.
+- **Keep `obsolete/` unreachable.** It holds old code that no longer ships. If you deploy by copying
+  the repo, block it in the Apache vhost:
+  ```apache
+  <Directory /path/to/e107_plugins/timekeeper/obsolete>
+      Require all denied
+  </Directory>
+  ```
+  and check a file in it returns 403. Also consider `Options -Indexes` for the site root.
+- **Nothing the student types or the URL contains is trusted:** user names and game names are
+  validated, SQL is escaped, and output is HTML-escaped.
+- **This repo is readable without signing in.** `e_cron.php` contains the report email addresses
+  and student account names: don't commit anything private.
+
+## Daily report email
+
+`e_cron.php` emails each student's previous day (questions, accuracy, active study time) to the
+parent address and the student. The names and addresses are in one block at the top of the file:
+edit them there. The reporting window is "yesterday, midnight to midnight" (`$windowStart`,
+`$windowEnd`).
+
+## PDF and Excel
+
+PDF reports run `wkhtmltopdf`; install it and make sure the web server user can run it. The Excel
+worksheet generator in `PreHeaderFunctions.php` needs `lib/PHPExcel.phar`, which isn't in the repo,
+and isn't linked from any page at the moment.
+
+## The enforcement client
+
+[TimekeeperEnforcement](https://git.pfip.au/gregory/TimekeeperEnforcement) runs as root on each
+student's computer and needs Java 11+. Its README covers its settings file and a systemd unit.
+
+`games.json` is a list of names (an array, or an object whose values are the names). Each name is
+looked for in the full command line of the student's processes: check what a game looks like first
+with `ps -eo user,args | grep -i NAME`. It's a deny-list: anything not listed isn't enforced.
+
+Deploy `timer.php` before the client. If you run the old PHP client (`obsolete/`), it still works
+against the new `timer.php`, but it can't update itself any more.
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| "That question has expired or was already answered" | Stale tab, double click, or a question older than 2 hours: answer the new question |
+| Spelling shows "Audio is unavailable right now" | `audio/` missing or not writable, TTS server unreachable, or `allow_url_fopen` off |
+| `tk_stats_page.php` is blank | `user` isn't a plain username, the student has no `timekeeper` row yet, or the `key` is wrong (403) |
+| Report URL gives "Forbidden" | Not logged in, or logged in as a different student |
+| Student always gets "You have not answered enough questions to play" | *Questions needed* (`minQuestionCount`) on the Student Limits screen is higher than they answer in a day (default is 100) |
+| Games close at exactly 0 minutes | That's intended: free minutes are used first, then earned ones |
+| Nothing is closed on the student's machine | Check the client's log, and that the game name in `games.json` really appears in `ps` output |

+ 240 - 0
admin_prefs.php

@@ -23,6 +23,9 @@
  *    query loops are one GROUP BY query per user, and "next word" no longer
  *    prints blanks (or warns) when every word already has a usage
  *  - removed the leftover rss_url*() methods copied from the RSS plugin
+ *  - new "Student Limits" screen: edit each student's row in the `timekeeper` table
+ *    (play window, free minutes, questions needed, daily maximum) and add a student,
+ *    instead of editing the table in phpMyAdmin
  */
 
 
@@ -81,11 +84,18 @@ class timekeeper_admin extends e_admin_dispatcher {
             'ui' => 'tk_geography_form_ui',
             'uipath' => null
         ),
+        'students' => array(
+            'controller' => 'tk_Students_ui',
+            'path' => null,
+            'ui' => 'tk_Students_form_ui',
+            'uipath' => null
+        ),
     );
     protected $adminMenu = array(
         'main/list' => array('caption' => 'List Questions', 'perm' => '0'),
         'main/create' => array('caption' => LAN_CREATE, 'perm' => '0'),
         'main/prefs' => array('caption' => LAN_PREFS, 'perm' => '0'),
+        'Students/custom' => array('caption' => 'Student Limits', 'perm' => '0'),
         'other0' => array('divider' => true),
         //'subjects/list'		=> array('caption'=> 'List Subjects', 'perm' => '0'),
         //'subjects/create'	=> array('caption'=> 'Add Subjects', 'perm' => '0'),
@@ -396,6 +406,236 @@ class tk_geography_form_ui extends e_admin_form_ui {
 
 }
 
+/*
+  =========================================================================
+  =====	Student limits (timekeeper table) interface
+  =========================================================================
+ */
+
+class tk_Students_ui extends e_admin_ui {
+
+    protected $pluginTitle = 'TimeKeeper';
+    protected $pluginName = 'timekeeper';
+    protected $table = 'timekeeper';
+    protected $pid = 'username';
+    protected $perPage = 10;
+    protected $fields = array(
+        'username' => array('title' => "User Name", 'type' => 'text', 'data' => 'str', 'readonly' => 1, 'width' => 'auto', 'class' => 'left', 'thclass' => 'left',),
+        'enabled' => array('title' => "Limits on", 'type' => 'bool', 'data' => 'int', 'width' => 'auto', 'class' => 'left', 'thclass' => 'left',),
+        'freeMins' => array('title' => "Free minutes", 'type' => 'number', 'data' => 'int', 'width' => 'auto', 'class' => 'left', 'thclass' => 'left',),
+        'enableTime' => array('title' => "Play from", 'type' => 'text', 'data' => 'str', 'width' => 'auto', 'class' => 'left', 'thclass' => 'left',),
+        'disableTime' => array('title' => "Play until", 'type' => 'text', 'data' => 'str', 'width' => 'auto', 'class' => 'left', 'thclass' => 'left',),
+        'minQuestionCount' => array('title' => "Questions needed", 'type' => 'number', 'data' => 'int', 'width' => 'auto', 'class' => 'left', 'thclass' => 'left',),
+        'maxDailyPlayTime' => array('title' => "Max minutes a day", 'type' => 'number', 'data' => 'int', 'width' => 'auto', 'class' => 'left', 'thclass' => 'left',),
+    );
+    protected $fieldpref = array('username', 'enabled', 'freeMins', 'enableTime', 'disableTime', 'minQuestionCount', 'maxDailyPlayTime');
+
+    private function esc($value) {
+        return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
+    }
+
+    /**
+     * "Student Limits" page: edit every student's row in one form, and add a student.
+     * Reached from the menu as Students/custom.
+     */
+    public function customPage() {
+
+        $sql = e107::getDb();
+        $mes = e107::getMessage();
+
+        $action = filter_input(INPUT_POST, 'tk_action');
+        if ($action === 'save') {
+            $this->saveStudents($sql, $mes);
+        } elseif ($action === 'add') {
+            $this->addStudent($sql, $mes);
+        }
+
+        $text = $this->studentsForm($sql) . $this->addForm();
+
+        e107::getRender()->tablerender('Student Limits', $mes->render() . $text);
+    }
+
+    private function loadStudents($sql) {
+        $rows = array();
+        $sql->gen("SELECT * FROM `#timekeeper` ORDER BY `username`");
+        while ($row = $sql->fetch()) {
+            $rows[] = $row;
+        }
+        return $rows;
+    }
+
+    private function intValue($entry, $key, $min, $max, $label, &$errors) {
+        $value = isset($entry[$key]) ? trim((string) $entry[$key]) : '';
+        if (!ctype_digit($value) || (int) $value < $min || (int) $value > $max) {
+            $errors[] = $label . ' must be a whole number from ' . $min . ' to ' . $max . '.';
+            return null;
+        }
+        return (int) $value;
+    }
+
+    private function timeValue($entry, $key, $label, &$errors) {
+        $value = isset($entry[$key]) ? trim((string) $entry[$key]) : '';
+        if (!preg_match('/^(\d{1,2})\.(\d{2})$/', $value, $match) || (int) $match[1] > 24 || (int) $match[2] > 59 || ((int) $match[1] === 24 && (int) $match[2] > 0)) {
+            $errors[] = $label . ' must look like 9.30 or 22.00 (hours, a dot, then two digits for the minutes).';
+            return null;
+        }
+        return (float) $value;
+    }
+
+    /**
+     * Save the edited rows. A row with any invalid field is left unchanged
+     * (and reported); the others are saved.
+     */
+    private function saveStudents($sql, $mes) {
+        $posted = (isset($_POST['tk']) && is_array($_POST['tk'])) ? $_POST['tk'] : array();
+
+        $existing = array();
+        foreach ($this->loadStudents($sql) as $row) {
+            $existing[$row['username']] = true;
+        }
+
+        $saved = 0;
+        foreach ($posted as $entry) {
+            if (!is_array($entry) || !isset($entry['username'])) {
+                continue;
+            }
+            $username = (string) $entry['username'];
+            if (!isset($existing[$username])) {
+                continue; // only students that are already in the table
+            }
+
+            $errors = array();
+            $freeMins = $this->intValue($entry, 'freeMins', 0, 1440, 'Free minutes', $errors);
+            $minQuestionCount = $this->intValue($entry, 'minQuestionCount', 0, 100000, 'Questions needed', $errors);
+            $maxDailyPlayTime = $this->intValue($entry, 'maxDailyPlayTime', 0, 1440, 'Max minutes a day', $errors);
+            $enableTime = $this->timeValue($entry, 'enableTime', 'Play from', $errors);
+            $disableTime = $this->timeValue($entry, 'disableTime', 'Play until', $errors);
+
+            if (!empty($errors)) {
+                foreach ($errors as $error) {
+                    $mes->addError($this->esc($username) . ': ' . $error . ' Not saved.');
+                }
+                continue;
+            }
+
+            $sql->update('timekeeper', array(
+                'enabled' => isset($entry['enabled']) ? 1 : 0,
+                'freeMins' => $freeMins,
+                'enableTime' => $enableTime,
+                'disableTime' => $disableTime,
+                'minQuestionCount' => $minQuestionCount,
+                'maxDailyPlayTime' => $maxDailyPlayTime,
+                'WHERE' => "username = '" . $sql->escape($username) . "'",
+            ));
+            $saved++;
+        }
+
+        if ($saved > 0) {
+            $mes->addSuccess('Saved the limits for ' . $saved . ($saved === 1 ? ' student.' : ' students.'));
+        }
+    }
+
+    /**
+     * Add a student with the same starting limits timer.php uses.
+     */
+    private function addStudent($sql, $mes) {
+        $username = trim((string) filter_input(INPUT_POST, 'tk_new_user'));
+
+        if (!preg_match('/^[A-Za-z0-9_.-]{1,50}$/', $username)) {
+            $mes->addError('Enter an e107 login name (letters, numbers, . _ - only).');
+            return;
+        }
+        if ($sql->count('user', '(*)', 'user_loginname="' . $sql->escape($username) . '"') < 1) {
+            $mes->addError('There is no e107 user with the login name "' . $this->esc($username) . '".');
+            return;
+        }
+        if ($sql->count('timekeeper', '(*)', 'username="' . $sql->escape($username) . '"') > 0) {
+            $mes->addError($this->esc($username) . ' already has limits.');
+            return;
+        }
+
+        $sql->insert('timekeeper', array(
+            'username' => $username,
+            'enabled' => 1,
+            'freeMins' => 10,
+            'enableTime' => 10.00,
+            'disableTime' => 22.00,
+            'minQuestionCount' => 100,
+            'usedMinutes' => 0,
+            'lastDate' => 0,
+            'maxDailyPlayTime' => 1440,
+        ));
+        $mes->addSuccess('Added ' . $this->esc($username) . ' with the starting limits. Change them below.');
+    }
+
+    private function studentsForm($sql) {
+        $rows = $this->loadStudents($sql);
+        if (empty($rows)) {
+            return '<p>No students have limits yet. They are created automatically the first time the enforcement client asks about a student, or you can add one below.</p>';
+        }
+
+        $frm = e107::getForm();
+
+        $text = $frm->open('tkStudents', 'post');
+        $text .= $frm->hidden('tk_action', 'save');
+        $text .= '<table class="table table-striped table-bordered"><thead><tr>'
+                . '<th>Student</th><th>Limits on</th><th>Free minutes a day</th><th>Play from</th><th>Play until</th>'
+                . '<th>Questions needed today</th><th>Max minutes a day</th><th>Used today</th><th>Earned minutes</th>'
+                . '</tr></thead><tbody>';
+
+        foreach ($rows as $i => $row) {
+            $name = $row['username'];
+            $key = 'tk[' . $i . ']';
+
+            $usedToday = ((int) $row['lastDate'] > 0 && date('Ymd', (int) $row['lastDate']) === date('Ymd')) ? (int) $row['usedMinutes'] : 0;
+            $earned = (int) $sql->retrieve('timekeeperScore', 'timekeeper_minutes', 'timekeeper_username="' . $sql->escape($name) . '"');
+
+            $text .= '<tr>'
+                    . '<td>' . $this->esc($name) . $frm->hidden($key . '[username]', $name) . '</td>'
+                    . '<td>' . $frm->checkbox($key . '[enabled]', 1, !empty($row['enabled'])) . '</td>'
+                    . '<td>' . $frm->text($key . '[freeMins]', (int) $row['freeMins'], 6, array('size' => 'mini')) . '</td>'
+                    . '<td>' . $frm->text($key . '[enableTime]', number_format((float) $row['enableTime'], 2, '.', ''), 5, array('size' => 'mini')) . '</td>'
+                    . '<td>' . $frm->text($key . '[disableTime]', number_format((float) $row['disableTime'], 2, '.', ''), 5, array('size' => 'mini')) . '</td>'
+                    . '<td>' . $frm->text($key . '[minQuestionCount]', (int) $row['minQuestionCount'], 6, array('size' => 'mini')) . '</td>'
+                    . '<td>' . $frm->text($key . '[maxDailyPlayTime]', (int) $row['maxDailyPlayTime'], 6, array('size' => 'mini')) . '</td>'
+                    . '<td>' . $usedToday . '</td>'
+                    . '<td>' . $earned . '</td>'
+                    . '</tr>';
+        }
+
+        $text .= '</tbody></table>';
+        $text .= '<div class="buttons-bar center">' . $frm->button('tk_save_button', 'Save changes') . '</div>';
+        $text .= $frm->close();
+
+        $text .= '<p class="smalltext">'
+                . '<b>Limits on</b> off = no limit for that student. '
+                . '<b>Free minutes</b> are available every day without earning them. '
+                . '<b>Play from / until</b> are hours.minutes, e.g. 9.30 or 22.00 (always two digits for the minutes: 9.5 means 9:50). '
+                . '<b>Questions needed</b> is how many questions must be answered today before earned minutes can be used. '
+                . '<b>Max minutes a day</b> is the most play per day, free and earned (0 = no play). '
+                . '<b>Used today</b> and <b>Earned minutes</b> are shown for information; change earned minutes on the Score screen.'
+                . '</p>';
+
+        return $text;
+    }
+
+    private function addForm() {
+        $frm = e107::getForm();
+
+        return '<h4>Add a student</h4>'
+                . $frm->open('tkAddStudent', 'post')
+                . $frm->hidden('tk_action', 'add')
+                . $frm->text('tk_new_user', '', 50, array('placeholder' => 'e107 login name'))
+                . ' ' . $frm->button('tk_add_button', 'Add student')
+                . $frm->close();
+    }
+
+}
+
+class tk_Students_form_ui extends e_admin_form_ui {
+
+}
+
 new timekeeper_admin();
 
 require_once(e_ADMIN . "auth.php");