Assignment Submission Statement
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 AdminConfigRepository → MoodleConfigPlugin) 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 Path | Layer | Description |
|---|---|---|
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.
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).
FormRequest for All Input Validation
accepted_statement is validated as ['sometimes', 'boolean'] in both SaveDraftSubmissionRequest and SubmitForGradingRequest. No inline validation in controllers or services.
Constructor Injection + Interface Binding
SubmissionStatementResolver depends on AdminConfigRepositoryInterface (not the concrete class). The binding is registered in AppServiceProvider. No app() or resolve() calls anywhere.
Custom Exception Classes per Domain
StatementRequiredException is a dedicated exception with a typed AssignmentErrorCode enum value (4010), not a generic \Exception.
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.
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.
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.
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.
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).
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.
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.