Split the automatic logging path by the app that logged the flight
logging_path is now manual, automatic_flyapp or automatic_flyagentapp. The Fly App's entries are backed by the aircraft's flight recorder and keep the missing-FDR red flag; the Fly Agent App's come from Remote ID, which is not a flight recorder, so they must carry the § 5 fields like a manual entry. The API maps the old "automatic" onto automatic_flyapp so Fly App builds already on devices keep filing. The migration has been applied to the remote PocketBase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3dd31b257a
commit
bc41d8b128
12 files changed
+242
-50
No files matched your search
@@ -175,7 +175,7 @@ type compliance struct {
|
||||
Required bool `json:"required"` // does § 5 require a logbook entry?
|
||||
Exempt bool `json:"exempt"` // exempt from the logbook obligation
|
||||
ExemptReason string `json:"exemptReason"` // why, when exempt
|
||||
LoggingPath string `json:"loggingPath"` // automatic | manual
|
||||
LoggingPath string `json:"loggingPath"` // manual | automatic_flyapp | automatic_flyagentapp
|
||||
RedFlags []string `json:"redFlags"` // compliance gaps to surface
|
||||
}
|
||||
|
||||
@@ -205,16 +205,49 @@ func (f flightRecord) view(drones map[string]droneRecord) flightView {
|
||||
// Compliance logic (BEK 1649 § 5 + the checklist's red flags).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Logging paths: who produced the entry.
|
||||
//
|
||||
// manual: typed in by the pilot (Web App form).
|
||||
//
|
||||
// automatic_flyapp: recorded by the Fly App from the aircraft's own telemetry
|
||||
// over the DJI SDK; the FDR log backs it.
|
||||
//
|
||||
// automatic_flyagentapp: recorded by the Fly Agent App from the aircraft's
|
||||
// Remote ID broadcast. That is not a flight data recorder, so the entry must
|
||||
// carry the § 5 minimum like a manual one.
|
||||
const (
|
||||
loggingManual = "manual"
|
||||
loggingFlyApp = "automatic_flyapp"
|
||||
loggingFlyAgentApp = "automatic_flyagentapp"
|
||||
loggingLegacyAutomatic = "automatic" // pre-split value, still sent by older Fly App builds
|
||||
)
|
||||
|
||||
// normalizeLoggingPath maps the pre-split "automatic" onto the Fly App path:
|
||||
// it was the only app that logged automatically before the split.
|
||||
func normalizeLoggingPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == loggingLegacyAutomatic {
|
||||
return loggingFlyApp
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// effectiveLoggingPath is the stored path, or — when blank — derived from the
|
||||
// drone's capability (autologging → automatic, else manual).
|
||||
// drone's capability (autologging → the Fly App path, else manual).
|
||||
func effectiveLoggingPath(f flightRecord, d *droneRecord) string {
|
||||
if p := strings.TrimSpace(f.LoggingPath); p != "" {
|
||||
if p := normalizeLoggingPath(f.LoggingPath); p != "" {
|
||||
return p
|
||||
}
|
||||
if d != nil && d.AutologsFlights {
|
||||
return "automatic"
|
||||
return loggingFlyApp
|
||||
}
|
||||
return "manual"
|
||||
return loggingManual
|
||||
}
|
||||
|
||||
// needsManualFields reports whether the path has no flight data recorder behind
|
||||
// it, so the entry itself must hold every § 5 field.
|
||||
func needsManualFields(path string) bool {
|
||||
return path == loggingManual || path == loggingFlyAgentApp
|
||||
}
|
||||
|
||||
// manualRequired are the § 5 minimum fields a manual-path entry must carry.
|
||||
@@ -263,13 +296,13 @@ func computeCompliance(f flightRecord, d *droneRecord) compliance {
|
||||
c.LoggingPath = effectiveLoggingPath(f, d)
|
||||
|
||||
// 3. Red flags (compliance gaps, not just missing data).
|
||||
if d != nil && d.AutologsFlights && c.LoggingPath == "automatic" && strings.TrimSpace(f.RawFDRLogURL) == "" {
|
||||
if d != nil && d.AutologsFlights && c.LoggingPath == loggingFlyApp && strings.TrimSpace(f.RawFDRLogURL) == "" {
|
||||
c.RedFlags = append(c.RedFlags, "Automatic-logging drone but no FDR log stored for this operation")
|
||||
}
|
||||
if f.Category == "specific" && strings.TrimSpace(f.AuthorisationRef) == "" {
|
||||
c.RedFlags = append(c.RedFlags, "Specific-category flight with no linked authorisation reference")
|
||||
}
|
||||
if c.Required && c.LoggingPath == "manual" {
|
||||
if c.Required && needsManualFields(c.LoggingPath) {
|
||||
for _, m := range missingManualFields(f) {
|
||||
c.RedFlags = append(c.RedFlags, "Missing § 5 field: "+m)
|
||||
}
|
||||
@@ -786,7 +819,7 @@ func (in flightInput) asRecord() flightRecord {
|
||||
AreaRoute: strings.TrimSpace(in.AreaRoute), MaxAltitudeAGL: in.MaxAltitudeAGL,
|
||||
PilotName: strings.TrimSpace(in.PilotName), CertificateRef: strings.TrimSpace(in.CertificateRef),
|
||||
Category: strings.TrimSpace(in.Category), Purpose: strings.TrimSpace(in.Purpose),
|
||||
LoggingPath: strings.TrimSpace(in.LoggingPath), RawFDRLogURL: strings.TrimSpace(in.RawFDRLogURL),
|
||||
LoggingPath: normalizeLoggingPath(in.LoggingPath), RawFDRLogURL: strings.TrimSpace(in.RawFDRLogURL),
|
||||
AuthorisationRef: strings.TrimSpace(in.AuthorisationRef),
|
||||
}
|
||||
}
|
||||
@@ -804,7 +837,7 @@ func (in flightInput) payload(remotePilot, org, retentionUntil string) map[strin
|
||||
"certificate_ref": strings.TrimSpace(in.CertificateRef),
|
||||
"category": strings.TrimSpace(in.Category),
|
||||
"purpose": strings.TrimSpace(in.Purpose),
|
||||
"logging_path": strings.TrimSpace(in.LoggingPath),
|
||||
"logging_path": normalizeLoggingPath(in.LoggingPath),
|
||||
"raw_fdr_log_url": strings.TrimSpace(in.RawFDRLogURL),
|
||||
"authorisation_ref": strings.TrimSpace(in.AuthorisationRef),
|
||||
"weather": strings.TrimSpace(in.Weather),
|
||||
@@ -822,8 +855,9 @@ func (in flightInput) payload(remotePilot, org, retentionUntil string) map[strin
|
||||
}
|
||||
|
||||
// validateFlight enforces the § 5 minimum for the effective logging path. It
|
||||
// returns an error message (and false) when a manual-path entry is incomplete —
|
||||
// callers must block the save rather than store a silent partial record.
|
||||
// returns an error message (and false) when an entry with no FDR behind it is
|
||||
// incomplete — callers must block the save rather than store a silent partial
|
||||
// record.
|
||||
func validateFlight(rec flightRecord, d *droneRecord) (string, bool) {
|
||||
if strings.TrimSpace(rec.OperationDate) == "" {
|
||||
return "operation date is required", false
|
||||
@@ -831,9 +865,14 @@ func validateFlight(rec flightRecord, d *droneRecord) (string, bool) {
|
||||
if strings.TrimSpace(rec.Drone) == "" {
|
||||
return "a drone must be selected", false
|
||||
}
|
||||
if effectiveLoggingPath(rec, d) == "manual" {
|
||||
switch effectiveLoggingPath(rec, d) {
|
||||
case loggingManual, loggingFlyApp, loggingFlyAgentApp:
|
||||
default:
|
||||
return "logging path must be manual, automatic_flyapp or automatic_flyagentapp", false
|
||||
}
|
||||
if path := effectiveLoggingPath(rec, d); needsManualFields(path) {
|
||||
if missing := missingManualFields(rec); len(missing) > 0 {
|
||||
return "manual logbook entry is missing required § 5 field(s): " + strings.Join(missing, ", "), false
|
||||
return path + " logbook entry is missing required § 5 field(s): " + strings.Join(missing, ", "), false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func completeFlight(path string) flightRecord {
|
||||
return flightRecord{
|
||||
OperationDate: "2026-10-22", StartTime: "14:05", EndTime: "14:17", Drone: "d1",
|
||||
AreaRoute: "Field north of the hangar", MaxAltitudeAGL: 80, PilotName: "pilot@example.com",
|
||||
Category: "open", Purpose: "commercial", LoggingPath: path,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLoggingPath(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"automatic": loggingFlyApp,
|
||||
" automatic ": loggingFlyApp,
|
||||
"automatic_flyapp": loggingFlyApp,
|
||||
"automatic_flyagentapp": loggingFlyAgentApp,
|
||||
"manual": loggingManual,
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeLoggingPath(in); got != want {
|
||||
t.Errorf("normalizeLoggingPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveLoggingPathDefaults(t *testing.T) {
|
||||
autolog := &droneRecord{AutologsFlights: true}
|
||||
if got := effectiveLoggingPath(flightRecord{}, autolog); got != loggingFlyApp {
|
||||
t.Errorf("autologging drone, blank path = %q, want %q", got, loggingFlyApp)
|
||||
}
|
||||
if got := effectiveLoggingPath(flightRecord{}, &droneRecord{}); got != loggingManual {
|
||||
t.Errorf("plain drone, blank path = %q, want %q", got, loggingManual)
|
||||
}
|
||||
if got := effectiveLoggingPath(flightRecord{LoggingPath: "automatic"}, nil); got != loggingFlyApp {
|
||||
t.Errorf("legacy automatic = %q, want %q", got, loggingFlyApp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFlightByPath(t *testing.T) {
|
||||
// The Fly App path is backed by the FDR, so a sparse entry is accepted.
|
||||
sparse := flightRecord{OperationDate: "2026-10-22", Drone: "d1", LoggingPath: loggingFlyApp}
|
||||
if msg, ok := validateFlight(sparse, nil); !ok {
|
||||
t.Errorf("sparse automatic_flyapp entry rejected: %s", msg)
|
||||
}
|
||||
// Manual and Fly Agent entries have no FDR behind them: § 5 fields are required.
|
||||
for _, path := range []string{loggingManual, loggingFlyAgentApp} {
|
||||
sparse.LoggingPath = path
|
||||
msg, ok := validateFlight(sparse, nil)
|
||||
if ok || !strings.Contains(msg, path) {
|
||||
t.Errorf("sparse %s entry: ok=%v msg=%q, want a rejection naming the path", path, ok, msg)
|
||||
}
|
||||
if msg, ok := validateFlight(completeFlight(path), nil); !ok {
|
||||
t.Errorf("complete %s entry rejected: %s", path, msg)
|
||||
}
|
||||
}
|
||||
if _, ok := validateFlight(completeFlight("companion"), nil); ok {
|
||||
t.Error("unknown logging path accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplianceRedFlagsByPath(t *testing.T) {
|
||||
autolog := &droneRecord{AutologsFlights: true}
|
||||
hasFDRFlag := func(c compliance) bool {
|
||||
for _, f := range c.RedFlags {
|
||||
if strings.Contains(f, "no FDR log") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if c := computeCompliance(completeFlight(loggingFlyApp), autolog); !hasFDRFlag(c) {
|
||||
t.Error("automatic_flyapp without an FDR url should be flagged")
|
||||
}
|
||||
if c := computeCompliance(completeFlight(loggingFlyAgentApp), autolog); hasFDRFlag(c) {
|
||||
t.Error("automatic_flyagentapp never has an FDR; it must not be flagged for one")
|
||||
}
|
||||
agent := completeFlight(loggingFlyAgentApp)
|
||||
agent.AreaRoute = ""
|
||||
c := computeCompliance(agent, nil)
|
||||
if c.LoggingPath != loggingFlyAgentApp || len(c.RedFlags) == 0 {
|
||||
t.Errorf("automatic_flyagentapp missing area: path=%q flags=%v, want a missing-field flag", c.LoggingPath, c.RedFlags)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user