Assignment Submission Statement

Complete fix/assignment/submission-statement-support Generated 2026-07-27

Overview

📝

What

Adds full support for Moodle's submission statement flow to the assignment module. When an admin configures a plagiarism/ownership declaration in Moodle's site settings, the API now exposes the statement text, gates submission on the student's acceptance, waives the requirement when the admin text is empty (mirroring Moodle's own waiver logic), and fires the \mod_assign\event\statement_accepted audit event for legal-consent tracking.

When

Read path: On every GET /api/v1/assignments/{id}, the resolver runs to surface the statement text and effective requirement flag.
Write path — draft mode: On POST .../submission/submit (submit-for-grading), the resolver gates the transition from draft to submitted.
Write path — no-draft mode: On POST .../submission (save draft), when submissiondrafts=0 the save IS the final submission, so the statement gates the save itself.

🚨

Why

Assignments with requiresubmissionstatement=1 (e.g. e-Portfolio cm 85939) could not be submitted from the student dashboard: the API never exposed the statement text, so the frontend had no consent flow, and every submit was rejected with 422 / 4010. Additionally, with submissiondrafts=0 students could submit without accepting the statement at all — the inverse bug. Finally, the legal-consent audit event was never forwarded to Moodle.

📁

Where

Module: app/Modules/Assignment/ — Services, Repositories, DTOs, Requests, Resources, Events, Exceptions.
Shared: app/Shared/Models/MoodleConfigPlugin.php — new read-only model for Moodle's config_plugins table.
Providers: AppServiceProvider — interface binding.
Tests: Feature + Unit under tests/.

How

A new SubmissionStatementResolver mirrors Moodle's assign::get_submissionstatement(): it reads site admin config from config_plugins (via AdminConfigRepositoryMoodleConfigPlugin) and selects the correct statement variant based on team-submission settings. The resolver returns null for empty/missing text, waiving the requirement. AssignmentResource computes the effective flag as assignFlag AND text≠null and filters the text through the content-filter pipeline. Both SubmissionService::submitForGrading and ::saveDraft (no-draft mode) consult the resolver and throw StatementRequiredException (422/4010) when needed, then dispatch AssignmentStatementAccepted for Moodle event forwarding.

Sequence Diagram

sequenceDiagram
    participant B as Browser
    participant C as SubmissionController
    participant R as SubmitForGradingRequest
    participant S as SubmissionService
    participant SR as StatementResolver
    participant AR as AdminConfigRepository
    participant DB as MySQL (config_plugins)
    participant SR2 as SubmissionRepository
    participant E as Event Dispatcher
    participant M as ForwardEventToMoodle

    B->>C: POST /assignments/{id}/submission/submit
{accepted_statement: true} C->>R: Validate input R-->>C: SubmitForGradingDTO C->>S: submitForGrading(dto) S->>S: loadAuthorizedAssignment() Note over S: Checks enrolment, visibility,
access restrictions, team guard S->>SR: isRequired(assignment) SR->>AR: assignSettings() AR->>DB: SELECT name, value
FROM config_plugins
WHERE plugin='assign' DB-->>AR: Collection AR-->>SR: name-value map SR->>SR: resolve(): pick variant key
(single/team/team-all) SR-->>S: true (flag=1 AND text non-empty) alt Statement required AND not accepted S-->>C: throw StatementRequiredException C-->>B: 422 {code: 4010} end S->>S: guardWriteAllowed() Note over S: Cutoff date + lock check S->>SR2: findCurrent() + setStatus(submitted) SR2->>DB: UPDATE assign_submission SET status='submitted' S->>E: dispatch(AssignmentSubmittedForGrading) E->>M: Queue: forward to Moodle plugin S->>E: dispatch(AssignmentStatementAccepted) E->>M: Queue: forward statement_accepted S-->>C: MoodleAssignSubmission C-->>B: 200 {data: {...}}

Flowchart

flowchart TD
    A([POST /submission/submit
or POST /submission]) --> B{Which endpoint?} B -->|submit-for-grading| C[Load assignment + auth guard] B -->|save-draft| D[Load assignment + auth guard] C --> E{statementResolver
.isRequired?} D --> F{submissiondrafts = 0?} F -->|Yes: save == submit| G{statementResolver
.isRequired?} F -->|No: normal draft| H[Save plugin payloads] H --> I[Set status = draft] I --> J[Dispatch DraftSaved events] J --> Z([200 OK]) G -->|Yes| G2{accepted_statement?} G2 -->|No| REJECT([422 / 4010
STATEMENT_REQUIRED]) G2 -->|Yes| H2[Save plugin payloads] H2 --> I2[Set status = submitted] I2 --> J2[Dispatch SubmittedForGrading] J2 --> K2[Dispatch StatementAccepted] K2 --> Z G -->|No: waived| H2 E -->|Yes| E2{accepted_statement?} E2 -->|No| REJECT E2 -->|Yes| L[guardWriteAllowed] E -->|No: waived/off| L L --> M{Cutoff passed?} M -->|Yes| CUTOFF([403 Cutoff]) M -->|No| N{Locked?} N -->|Yes| LOCKED([403 Locked]) N -->|No| O[Set status = submitted] O --> P[Dispatch SubmittedForGrading] P --> Q{Statement accepted?} Q -->|Yes| R[Dispatch StatementAccepted] R --> Z Q -->|No| Z style REJECT fill:#7f1d1d,stroke:#ef4444,color:#fca5a5 style CUTOFF fill:#7f1d1d,stroke:#ef4444,color:#fca5a5 style LOCKED fill:#7f1d1d,stroke:#ef4444,color:#fca5a5 style Z fill:#064e3b,stroke:#10b981,color:#6ee7b7

Files Changed

File PathLayerDescription
app/Shared/Models/MoodleConfigPlugin.php Model New read-only Eloquent model for Moodle's config_plugins table; provides access to site-level admin settings keyed by (plugin, name).
app/Modules/Assignment/Repositories/AdminConfigRepositoryInterface.php Repository New contract defining assignSettings(): Collection — returns assign plugin admin config as a name→value map.
app/Modules/Assignment/Repositories/AdminConfigRepository.php Repository Concrete implementation; queries MoodleConfigPlugin for plugin='assign' rows and maps them to a keyed collection.
app/Modules/Assignment/Services/SubmissionStatementResolver.php Service New service mirroring assign::get_submissionstatement(): picks the correct config key based on team-submission settings, returns null for empty text (waiver), exposes isRequired().
app/Modules/Assignment/Events/AssignmentStatementAccepted.php Event New domain event mapping to \mod_assign\event\statement_accepted; carries submissionId and cmId for the legal-consent audit record.
app/Modules/Assignment/Exceptions/StatementRequiredException.php Exception New exception thrown when student submits without accepting the required statement. Maps to HTTP 422 with error code 4010.
app/Modules/Assignment/Services/SubmissionService.php Service Modified: injected SubmissionStatementResolver; added statement gate + AssignmentStatementAccepted dispatch in both submitForGrading() and saveDraft() (no-draft mode).
app/Modules/Assignment/Services/AssignmentService.php Service Modified: injected SubmissionStatementResolver; showFull() resolves statement text into the context bag as 'submission_statement'.
app/Modules/Assignment/Resources/AssignmentResource.php Resource Modified: added withSubmissionStatement() setter; toArray() emits require_submission_statement (effective: flag AND text≠null) and submission_statement (filtered via content pipeline).
app/Modules/Assignment/DTOs/SaveDraftSubmissionDTO.php DTO Modified: added acceptedStatement: bool property for statement acceptance in no-draft mode.
app/Modules/Assignment/DTOs/SubmitForGradingDTO.php DTO Modified: added acceptedStatement: bool property for statement acceptance on submit.
app/Modules/Assignment/Requests/SaveDraftSubmissionRequest.php Request Modified: added 'accepted_statement' => ['sometimes', 'boolean'] validation rule; maps to DTO via $this->boolean().
app/Modules/Assignment/Requests/SubmitForGradingRequest.php Request Modified: added 'accepted_statement' => ['sometimes', 'boolean'] validation rule; maps to DTO.
app/Providers/AppServiceProvider.php Provider Modified: added binding AdminConfigRepositoryInterface → AdminConfigRepository.
tests/Unit/Services/SubmissionStatementResolverTest.php Test New: 7 unit tests covering single/team/all-submit statement variants, missing config, blank text waiver, and effective requirement logic.
tests/Feature/Assignment/SubmissionStatementTest.php Test New: 10 feature tests covering submit rejection/acceptance, event dispatch, empty-text waiver, no-draft mode gate, draft mode bypass, and API response shape with statement text.

Rules Applied

Read-Only Moodle Tables

MoodleConfigPlugin extends MoodleModel (the shared read-only base) with bare table name 'config_plugins' and no prefix — the DB prefix is applied automatically. No write operations exist on this model.

architecture.md — Shared Database Rules / Moodle Tables (Read Access)

Service Layer Owns Business Logic

All statement resolution, gate enforcement, and event dispatching live in SubmissionService and SubmissionStatementResolver — never in controllers or requests. The controller remains thin (<10 lines per method).

architecture.md — Layer Responsibilities

FormRequest for All Input Validation

accepted_statement is validated as ['sometimes', 'boolean'] in both SaveDraftSubmissionRequest and SubmitForGradingRequest. No inline validation in controllers or services.

security.md — Input Validation

Constructor Injection + Interface Binding

SubmissionStatementResolver depends on AdminConfigRepositoryInterface (not the concrete class). The binding is registered in AppServiceProvider. No app() or resolve() calls anywhere.

architecture.md — Dependency Injection

Custom Exception Classes per Domain

StatementRequiredException is a dedicated exception with a typed AssignmentErrorCode enum value (4010), not a generic \Exception.

coding-style.md — Error Handling

Content Filters Pipeline on Moodle-Sourced Text

AssignmentResource passes submission_statement through $this->applyFilters() with TextFormat::Moodle, matching the FORMAT_MOODLE (0) format used by Moodle's statement config.

architecture.md — Content Filters

Event System + Moodle Forwarding

AssignmentStatementAccepted extends BaseEvent with full Moodle metadata (crud, edulevel, component, target, action, objectTable, contextLevel) and rides the existing ForwardEventToMoodle listener via the queue.

architecture.md — Events System

TDD Workflow — Red → Green → Refactor

Both test files (SubmissionStatementTest, SubmissionStatementResolverTest) were written first (tasks.md confirms red phase), covering: gate rejection, acceptance, event dispatch, waiver, no-draft mode, and API response shape. 17 total test methods.

testing.md — TDD Workflow

No Factories for Moodle Tables

Feature tests seed data via DB::table()->insert() in setUp/helper methods following the canonical pattern established in LessonNavigateTest. No MoodleConfigPluginFactory created.

Project feedback — feedback_no_factories.md

Final + Readonly + Typed Everywhere

All new classes are final. DTOs are final readonly with typed constructor-promoted properties. All methods have explicit return types. match expression used in the resolver for variant selection (not switch).

coding-style.md — PHP 8.3 Required Features, Class Design, Type Safety

PHPDoc on Every Class and Public Method

Every new and modified class has a class-level docblock; every public method has a brief docblock with @throws annotations. @param/@return included only when the type signature alone is not self-explanatory.

coding-style.md — PHPDoc

Laravel First — No Raw PHP

Uses Str::of()->trim() for string handling, Collection::mapWithKeys for transformations, Event::dispatch() for events, DB::transaction() for atomicity. No raw PHP string functions or array functions.

coding-style.md — Laravel First