From 0fe6ac53a65bdde6fb859a068745e83a18566941 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 15:56:15 +0000 Subject: [PATCH 1/3] Initial plan From dd1e81256d2db3936d69e2afa2d36e857e5f0ab0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 16:04:13 +0000 Subject: [PATCH 2/3] Add alpha_numeric widget: Python fields, JS widget, CSS, manifest, views, migration Agent-Logs-Url: https://github.com/agrogers/aps_sis/sessions/82516a2f-7fc6-4250-873b-170fd375d89c Co-authored-by: agrogers <13920678+agrogers@users.noreply.github.com> --- __manifest__.py | 4 +- migrations/18.0.1.0.45/post-migration.py | 48 +++ models/submissions/model.py | 39 +++ models/submissions/overrides.py | 10 +- static/src/css/alpha_numeric_widget.css | 29 ++ static/src/js/alpha_numeric_widget.js | 363 +++++++++++++++++++++++ views/aps_resource_submission_views.xml | 13 +- 7 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 migrations/18.0.1.0.45/post-migration.py create mode 100644 static/src/css/alpha_numeric_widget.css create mode 100644 static/src/js/alpha_numeric_widget.js diff --git a/__manifest__.py b/__manifest__.py index 43bbe9b..8d35c3d 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'APEX - Academic Progress and Excellence', - 'version': '18.0.1.0.44', + 'version': '18.0.1.0.45', 'icon': '/aps_sis/static/description/icon.png', 'category': 'Tools', 'summary': 'Allow the assigning and tracking of academic tasks such as homework, exams etc.', @@ -78,6 +78,8 @@ 'aps_sis/static/src/js/recent_submissions_widget.js', 'aps_sis/static/src/js/confetti.browser.min.js', 'aps_sis/static/src/js/float_sentinel_widget.js', + 'aps_sis/static/src/js/alpha_numeric_widget.js', + 'aps_sis/static/src/css/alpha_numeric_widget.css', 'aps_sis/static/src/js/resource_links_widget.js', # 'aps_sis/static/src/js/form_notebook_default_page.js', 'aps_sis/static/src/js/submission_list_score_refresh.js', diff --git a/migrations/18.0.1.0.45/post-migration.py b/migrations/18.0.1.0.45/post-migration.py new file mode 100644 index 0000000..70c68c5 --- /dev/null +++ b/migrations/18.0.1.0.45/post-migration.py @@ -0,0 +1,48 @@ +from odoo import SUPERUSER_ID, api +import logging + +_logger = logging.getLogger(__name__) + +SENTINEL = -0.01 + + +def migrate(cr, version): + """Back-fill score_alpha and out_of_marks_alpha from existing numeric fields. + + For records that already have a meaningful numeric score (i.e. not the + sentinel -0.01), populate the new alpha Char fields so the new widget + displays the correct value without requiring a manual re-entry. + """ + # score → score_alpha + cr.execute( + """ + UPDATE aps_resource_submission + SET score_alpha = CASE + WHEN score IS NULL OR ABS(score - %s) < 0.000001 THEN NULL + WHEN score = FLOOR(score) THEN TRIM(TO_CHAR(score, 'FM999999999999990')) + ELSE TRIM(TO_CHAR(score, 'FM999999999999990.099')) + END + WHERE score_alpha IS NULL + """, + (SENTINEL,), + ) + rows = cr.rowcount + _logger.info("Migration 18.0.1.0.45: backfilled score_alpha for %d submission(s).", rows) + + # out_of_marks → out_of_marks_alpha + cr.execute( + """ + UPDATE aps_resource_submission + SET out_of_marks_alpha = CASE + WHEN out_of_marks IS NULL OR ABS(out_of_marks - %s) < 0.000001 THEN NULL + WHEN out_of_marks = FLOOR(out_of_marks) THEN TRIM(TO_CHAR(out_of_marks, 'FM999999999999990')) + ELSE TRIM(TO_CHAR(out_of_marks, 'FM999999999999990.099')) + END + WHERE out_of_marks_alpha IS NULL + """, + (SENTINEL,), + ) + rows = cr.rowcount + _logger.info( + "Migration 18.0.1.0.45: backfilled out_of_marks_alpha for %d submission(s).", rows + ) diff --git a/models/submissions/model.py b/models/submissions/model.py index 45e0dad..2b560ff 100644 --- a/models/submissions/model.py +++ b/models/submissions/model.py @@ -9,6 +9,21 @@ _logger = logging.getLogger(__name__) sentinel_zero = -0.01 + +def alpha_to_float(alpha_val, sentinel=sentinel_zero): + """Parse an alpha score string to its numeric equivalent. + + Returns the parsed float when *alpha_val* is a valid number, or *sentinel* + when it is empty, ``False``, or a non-numeric special code (e.g. "A", "-"). + """ + if not alpha_val or not str(alpha_val).strip(): + return sentinel + cleaned = str(alpha_val).replace(',', '').strip() + try: + return float(cleaned) + except ValueError: + return sentinel + class APSResourceSubmission(models.Model): _name = 'aps.resource.submission' _description = 'APEX Submission' @@ -76,7 +91,17 @@ class APSResourceSubmission(models.Model): tracking=True) date_due = fields.Date(string='Due Date', tracking=True) score = fields.Float(string='Score', digits=(16, 2), tracking=True, default=sentinel_zero) + score_alpha = fields.Char( + string='Score', + help='Score as text. Accepts a number or a special code such as A (Absent), C (Cheating), or - (Excluded). ' + 'Changing this field automatically updates the numeric Score field.', + ) out_of_marks = fields.Float(string='Out of Marks', digits=(16, 1), store=True, tracking=True) + out_of_marks_alpha = fields.Char( + string='Out of Marks', + help='Out-of-marks as text. Accepts a number or a special code. ' + 'Changing this field automatically updates the numeric Out of Marks field.', + ) result_percent = fields.Integer(string='Result %', compute='_compute_result_percent', store=True, tracking=True) due_status = fields.Selection([ ('late', 'Late'), @@ -455,6 +480,20 @@ def _compute_display_name(self): # endregion - Computed Fields +# region - Alpha-Numeric Score Helpers + + @api.onchange('score_alpha') + def _onchange_score_alpha(self): + """Keep the numeric score field in sync with score_alpha.""" + self.score = alpha_to_float(self.score_alpha) + + @api.onchange('out_of_marks_alpha') + def _onchange_out_of_marks_alpha(self): + """Keep the numeric out_of_marks field in sync with out_of_marks_alpha.""" + self.out_of_marks = alpha_to_float(self.out_of_marks_alpha) + +# endregion - Alpha-Numeric Score Helpers + def _get_faculty_for_current_user(self): """Get the faculty record for the current user""" employee = self.env['hr.employee'].search([('user_id', '=', self.env.user.id)], limit=1) diff --git a/models/submissions/overrides.py b/models/submissions/overrides.py index ac893ab..cf1f3ec 100644 --- a/models/submissions/overrides.py +++ b/models/submissions/overrides.py @@ -2,7 +2,7 @@ import ast from odoo import models, fields, api, _ -from .model import sentinel_zero +from .model import sentinel_zero, alpha_to_float import logging from lxml import etree @@ -16,6 +16,14 @@ class APSResourceSubmissionOverrides(models.Model): def write(self, vals): + # Sync alpha fields → numeric fields when only the alpha field is provided + # (e.g. when saved programmatically without the onchange firing). + # Do this before the auto_score check so that 'score in vals' is correct. + if 'score_alpha' in vals and 'score' not in vals: + vals['score'] = alpha_to_float(vals['score_alpha']) + if 'out_of_marks_alpha' in vals and 'out_of_marks' not in vals: + vals['out_of_marks'] = alpha_to_float(vals['out_of_marks_alpha']) + # Mark score and answer as manually set when either is changed without explicitly # passing auto_score=True. Our auto-calculation code always passes auto_score=True # explicitly, so this only triggers for user-initiated changes. diff --git a/static/src/css/alpha_numeric_widget.css b/static/src/css/alpha_numeric_widget.css new file mode 100644 index 0000000..dbb4730 --- /dev/null +++ b/static/src/css/alpha_numeric_widget.css @@ -0,0 +1,29 @@ +/* Alpha-Numeric Field Widget */ + +.o_field_alpha_numeric_wrap { + display: flex; + flex-direction: column; +} + +.o_alpha_numeric_input { + text-align: right; +} + +/* Readonly display */ +.o_alpha_numeric_value { + display: inline-block; +} + +.o_alpha_numeric_value.o_alpha_number { + text-align: right; + min-width: 3em; +} + +.o_alpha_numeric_value.o_alpha_code { + text-align: center; +} + +/* Validation error message */ +.o_alpha_numeric_error { + font-size: 0.75rem; +} diff --git a/static/src/js/alpha_numeric_widget.js b/static/src/js/alpha_numeric_widget.js new file mode 100644 index 0000000..a967d63 --- /dev/null +++ b/static/src/js/alpha_numeric_widget.js @@ -0,0 +1,363 @@ +/** + * APS SIS — Alpha-Numeric Field Widget + * + * A field widget for Char fields that can hold either a formatted number or + * a configurable set of special codes (e.g. "A" = Absent, "C" = Cheating, + * "-" = Excluded). + * + * When the alpha field name follows the convention "_alpha", the widget + * automatically keeps the paired numeric field "" in sync. The numeric + * field name can also be set explicitly via the `numeric_field` option. + * + * Options (set via options="{...}" in XML views): + * allowed_codes {Array|string} Special codes that may be entered. + * Array or comma-separated string. + * Default: ["-", "A", "C"] + * decimal_places {number} Decimal places for number display. Default: 2 + * use_separator {boolean} Show thousands separator. Default: false + * numeric_field {string} Name of paired numeric field. Auto-derived + * from "_alpha" suffix when not set. + * sentinel {number} Value written to the numeric field for + * empty / special-code entries. Default: -0.01 + * + * Usage example (XML view): + * + */ + +import { registry } from "@web/core/registry"; +import { Component, useState, useRef, onWillUpdateProps } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { xml } from "@odoo/owl"; + +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── + +export class AlphaNumericField extends Component { + static template = xml` + + + + +
+ + +
+ +
+
+ `; + + static props = { + ...standardFieldProps, + allowed_codes: { type: Array, optional: true }, + decimal_places: { type: Number, optional: true }, + use_separator: { type: Boolean, optional: true }, + numeric_field: { type: String, optional: true }, + sentinel: { type: Number, optional: true }, + }; + + setup() { + this.inputRef = useRef("input"); + this.state = useState({ + inputValue: this._displayValue( + this.props.record.data[this.props.name] || "", + this.props.numeric_field + ? this.props.record.data[this.props.numeric_field] + : null, + ), + error: null, + hasFocus: false, + }); + + onWillUpdateProps((next) => { + // Refresh display when the record data changes from outside (e.g. server + // recompute) but only when the input is not focused. + if (!this.state.hasFocus) { + const newAlpha = next.record.data[next.name] || ""; + const newNum = next.numeric_field + ? next.record.data[next.numeric_field] + : null; + this.state.inputValue = this._displayValue(newAlpha, newNum); + } + }); + } + + // ── Derived accessors ──────────────────────────────────────────────────── + + get allowedCodes() { + const raw = this.props.allowed_codes || ["-", "A", "C"]; + return raw.map((c) => String(c).toUpperCase()); + } + + get decimalPlaces() { + const dp = this.props.decimal_places; + return dp !== undefined && dp !== null ? dp : 2; + } + + get useSeparator() { + return this.props.use_separator ?? false; + } + + get sentinelValue() { + return this.props.sentinel ?? -0.01; + } + + /** Raw value stored in the alpha Char field. */ + get _rawAlpha() { + return this.props.record.data[this.props.name] || ""; + } + + /** Raw value stored in the paired numeric Float/Integer field (or null). */ + get _rawNumeric() { + if (!this.props.numeric_field) return null; + return this.props.record.data[this.props.numeric_field] ?? null; + } + + /** True when the current stored value is one of the allowed special codes. */ + get isSpecialCode() { + return this._isCode(this._rawAlpha); + } + + /** CSS classes for the readonly span. */ + get readonlyClass() { + if (this.isSpecialCode) { + return "o_alpha_numeric_value o_alpha_code text-muted fst-italic"; + } + return "o_alpha_numeric_value o_alpha_number"; + } + + /** CSS classes for the edit-mode input element. */ + get inputClass() { + let cls = "o_input o_alpha_numeric_input"; + if (this.state.error) cls += " border-danger"; + if (this._isCode(this.state.inputValue)) cls += " fst-italic text-muted"; + return cls; + } + + /** Placeholder text shown when the input is empty. */ + get placeholder() { + return this.allowedCodes.join(", "); + } + + /** Formatted value for readonly display. */ + get formattedValue() { + return this._displayValue(this._rawAlpha, this._rawNumeric); + } + + // ── Private helpers ────────────────────────────────────────────────────── + + _isCode(val) { + if (!val) return false; + return this.allowedCodes.includes(String(val).trim().toUpperCase()); + } + + _parseNum(val) { + if (val === null || val === undefined) return null; + const cleaned = String(val).replace(/,/g, "").trim(); + const n = parseFloat(cleaned); + return !isNaN(n) && isFinite(n) ? n : null; + } + + _formatNum(n) { + try { + return n.toLocaleString(undefined, { + minimumFractionDigits: this.decimalPlaces, + maximumFractionDigits: this.decimalPlaces, + useGrouping: this.useSeparator, + }); + } catch (_) { + return String(n); + } + } + + /** + * Compute the display string from the alpha field value with a fallback to + * the paired numeric field for records that pre-date the alpha field. + */ + _displayValue(alphaVal, numVal) { + if (alphaVal && String(alphaVal).trim()) { + const t = String(alphaVal).trim(); + if (this._isCode(t)) return t.toUpperCase(); + const n = this._parseNum(t); + if (n !== null) return this._formatNum(n); + return t; + } + // Fall back to the numeric field for backward compatibility. + if (numVal !== null && numVal !== undefined) { + const sentinel = this.props.sentinel ?? -0.01; + if (Math.abs(numVal - sentinel) > 0.000001) { + return this._formatNum(numVal); + } + } + return ""; + } + + _validate(raw) { + const val = String(raw || "").trim(); + if (!val) return { ok: true }; + if (this._isCode(val)) return { ok: true }; + if (this._parseNum(val) !== null) return { ok: true }; + return { + ok: false, + error: `Enter a number or one of: ${this.allowedCodes.join(", ")}`, + }; + } + + // ── Event handlers ─────────────────────────────────────────────────────── + + onFocus() { + this.state.hasFocus = true; + // Show the raw stored alpha value while editing (strip formatting). + this.state.inputValue = this._rawAlpha; + this.state.error = null; + } + + onInput(ev) { + this.state.inputValue = ev.target.value; + this.state.error = null; + } + + async onBlur(ev) { + this.state.hasFocus = false; + await this._commit(ev.target.value); + } + + async onKeyDown(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + await this._commit(ev.target.value); + } else if (ev.key === "Escape") { + this.state.inputValue = this._displayValue(this._rawAlpha, this._rawNumeric); + this.state.error = null; + } + } + + async _commit(rawInput) { + const trimmed = String(rawInput || "").trim(); + const validation = this._validate(trimmed); + if (!validation.ok) { + this.state.error = validation.error; + return; + } + this.state.error = null; + + let normalized = ""; + let numericValue = this.sentinelValue; + + if (trimmed) { + if (this._isCode(trimmed)) { + normalized = trimmed.toUpperCase(); + numericValue = this.sentinelValue; + } else { + const n = this._parseNum(trimmed); + if (n !== null) { + normalized = String(n); // store clean number string + numericValue = n; + } + } + } + + // Build the record update — always write the alpha field. + const updates = { [this.props.name]: normalized }; + + // Also update the paired numeric field when it is present on the record. + const numField = this.props.numeric_field; + if (numField && this.props.record.fields && numField in this.props.record.fields) { + updates[numField] = numericValue; + } + + // Reflect the formatted value in the input immediately. + this.state.inputValue = this._displayValue(normalized, numericValue); + + await this.props.record.update(updates); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Registry descriptor +// ───────────────────────────────────────────────────────────────────────────── + +export const alphaNumericField = { + component: AlphaNumericField, + supportedTypes: ["char"], + displayName: "Alpha Numeric", + supportedOptions: [ + { + label: "Allowed Codes", + name: "allowed_codes", + type: "string", + help: 'Comma-separated list or JSON array of allowed special codes, e.g. ["-","A","C"]', + }, + { + label: "Decimal Places", + name: "decimal_places", + type: "number", + }, + { + label: "Use Thousands Separator", + name: "use_separator", + type: "boolean", + }, + { + label: "Numeric Field", + name: "numeric_field", + type: "string", + help: 'Name of the paired numeric field to keep in sync. Auto-derived from the "_alpha" suffix when not set.', + }, + { + label: "Sentinel Value", + name: "sentinel", + type: "number", + help: "Value written to the numeric field when the alpha field is empty or holds a special code. Default: -0.01", + }, + ], + + extractProps({ name, options }) { + // Derive the paired numeric field name from the "_alpha" suffix convention. + const stripped = name.replace(/_alpha$/, ""); + let numericField = options.numeric_field || (stripped !== name ? stripped : null); + + // allowed_codes can arrive as a JSON array string or comma-separated. + let allowedCodes = options.allowed_codes; + if (typeof allowedCodes === "string") { + // Try JSON first, then fall back to comma-split. + try { + allowedCodes = JSON.parse(allowedCodes); + } catch (_) { + allowedCodes = allowedCodes.split(",").map((s) => s.trim()).filter(Boolean); + } + } + allowedCodes = Array.isArray(allowedCodes) ? allowedCodes : ["-", "A", "C"]; + + return { + allowed_codes: allowedCodes, + decimal_places: + options.decimal_places !== undefined + ? parseInt(options.decimal_places, 10) + : 2, + use_separator: options.use_separator ?? false, + numeric_field: numericField || undefined, + sentinel: + options.sentinel !== undefined + ? parseFloat(options.sentinel) + : -0.01, + }; + }, +}; + +registry.category("fields").add("alpha_numeric", alphaNumericField); diff --git a/views/aps_resource_submission_views.xml b/views/aps_resource_submission_views.xml index 32599d4..119d1df 100644 --- a/views/aps_resource_submission_views.xml +++ b/views/aps_resource_submission_views.xml @@ -139,7 +139,9 @@ - + + + @@ -301,12 +303,15 @@ -