Model Denmark's Dronebekendtgørelsen § 5 (on top of EU 2019/947) across all three tiers: schema, API Server, and Web App. Schema (migration 1720300700_add_logbook.js): two collections — `drones` (classification inputs: mtom, is_toy, autologs, c_class, operator no.) and `flights` (§5 minimum content + category/purpose/logging-path + operational maturity + a retention_until computed as operation_date + 5y). Locked API rules; access flows through the service account like users/orgs. API Server (logbook.go, logbook_export.go): /api/drones and /api/flights CRUD with per-role scoping in Go (user→own, admin→org, superadmin→all), plus GET /api/logbook/export (CSV — the "readable electronic format" for Trafikstyrelsen / pending police disclosure). Compliance is computed server-side per flight: exemption (toy / club-area / <250 g hobby), effective logging path, and red flags (autologs-without-FDR, specific-category-without- authorisation, missing §5 fields, past retention). Manual-path saves missing a §5 field are blocked (422). Web App: BFF proxies (export preserves the CSV Content-Type/Disposition), api.js client fns, and a Logbook.vue view (Flights/Drones tabs, inline forms, compliance badges + expandable detail, Export CSV) wired into Dashboard.vue, replacing the placeholder. Includes the rebuilt embedded dist bundle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
2.6 KiB
Go
92 lines
2.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// GET /api/logbook/export — exports the caller's in-scope logbook as CSV.
|
|
//
|
|
// This is the "readable electronic format" disclosure path required by BEK 1649
|
|
// § 5: retained 5 years and producible on request from Trafikstyrelsen (and,
|
|
// under the 2026 hearing draft, the police). CSV is an open format, so it holds
|
|
// regardless of whether the source records came from a manual entry or an
|
|
// automatic FDR export.
|
|
func (s *Server) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
drones, err := s.dronesInScope(r.Context(), who)
|
|
if err != nil {
|
|
gatewayError(w, err)
|
|
return
|
|
}
|
|
var list struct {
|
|
Items []flightRecord `json:"items"`
|
|
}
|
|
if _, err := s.listRecords(r.Context(), "flights", flightScopeFilter(who),
|
|
"operation_date,start_time", &list); err != nil {
|
|
gatewayError(w, err)
|
|
return
|
|
}
|
|
|
|
filename := "pilotvault-logbook-" + time.Now().Format("2006-01-02") + ".csv"
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
cw := csv.NewWriter(w)
|
|
defer cw.Flush()
|
|
|
|
_ = cw.Write([]string{
|
|
"operation_date", "start_time", "end_time",
|
|
"drone_name", "drone_model", "drone_serial", "operator_number",
|
|
"area_or_route", "max_altitude_agl_m",
|
|
"remote_pilot", "certificate_ref",
|
|
"category", "purpose", "logging_path", "fdr_log_url", "authorisation_ref",
|
|
"weather", "airspace_ref", "observer", "incidents", "notes",
|
|
"retention_until", "logbook_required", "compliance_flags",
|
|
})
|
|
|
|
for _, f := range list.Items {
|
|
var d *droneRecord
|
|
if dr, ok := drones[f.Drone]; ok {
|
|
d = &dr
|
|
}
|
|
c := computeCompliance(f, d)
|
|
droneName, model, serial, opNo := "", "", "", ""
|
|
if d != nil {
|
|
droneName, model, serial, opNo = d.Name, d.Model, d.Serial, d.OperatorNumber
|
|
}
|
|
alt := ""
|
|
if f.MaxAltitudeAGL > 0 {
|
|
alt = strconv.FormatFloat(f.MaxAltitudeAGL, 'f', -1, 64)
|
|
}
|
|
_ = cw.Write([]string{
|
|
day(f.OperationDate), f.StartTime, f.EndTime,
|
|
droneName, model, serial, opNo,
|
|
f.AreaRoute, alt,
|
|
f.PilotName, f.CertificateRef,
|
|
f.Category, f.Purpose, c.LoggingPath, f.RawFDRLogURL, f.AuthorisationRef,
|
|
f.Weather, f.AirspaceRef, f.Observer, f.Incidents, f.Notes,
|
|
day(f.RetentionUntil), boolText(c.Required), strings.Join(c.RedFlags, "; "),
|
|
})
|
|
}
|
|
}
|
|
|
|
// day trims a PocketBase datetime string to its YYYY-MM-DD date.
|
|
func day(s string) string {
|
|
if len(s) >= 10 {
|
|
return s[:10]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func boolText(b bool) string {
|
|
if b {
|
|
return "yes"
|
|
}
|
|
return "no"
|
|
}
|