Skip to content

Commit 20bca42

Browse files
client + server: refactor pupil identities import from source
1 parent 96937f2 commit 20bca42

14 files changed

Lines changed: 249 additions & 119 deletions

File tree

school_data_hub_client/lib/src/protocol/client.dart

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,18 +185,23 @@ class EndpointAdminLogs extends _i1.EndpointRef {
185185
);
186186
}
187187

188+
/// Endpoint for admin-only pupil operations (e.g. updating backend from external source).
188189
/// {@category Endpoint}
189190
class EndpointAdminPupil extends _i1.EndpointRef {
190191
EndpointAdminPupil(_i1.EndpointCaller caller) : super(caller);
191192

192193
@override
193194
String get name => 'adminPupil';
194195

195-
_i2.Future<Set<_i7.PupilData>> updateBackendPupilDataState(String filePath) =>
196+
/// Updates backend pupil state from reduced sync content. Admin only.
197+
/// [reducedContent] is newline-separated lines, each line "internalId,afterSchoolCare"
198+
/// with afterSchoolCare as "true" or "false".
199+
_i2.Future<Set<_i7.PupilData>> updateBackendPupilDataState(
200+
String reducedContent) =>
196201
caller.callServerEndpoint<Set<_i7.PupilData>>(
197202
'adminPupil',
198203
'updateBackendPupilDataState',
199-
{'filePath': filePath},
204+
{'reducedContent': reducedContent},
200205
);
201206
}
202207

@@ -1952,10 +1957,10 @@ class EndpointPupilIdentity extends _i1.EndpointRef {
19521957
{},
19531958
);
19541959

1955-
_i2.Future<DateTime?> updateLastPupilIdentitiesUpdate(DateTime date) =>
1960+
_i2.Future<DateTime?> insertLastPupilIdentitiesUpdate(DateTime date) =>
19561961
caller.callServerEndpoint<DateTime?>(
19571962
'pupilIdentity',
1958-
'updateLastPupilIdentitiesUpdate',
1963+
'insertLastPupilIdentitiesUpdate',
19591964
{'date': date},
19601965
);
19611966

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import 'dart:io';
2+
3+
import 'package:excel/excel.dart';
4+
import 'package:file_picker/file_picker.dart';
5+
import 'package:intl/intl.dart';
6+
7+
/// Picks a .txt or .xlsx file and returns pupil identity content as a single
8+
/// string: newline-separated lines, each line 20 comma-separated fields
9+
/// in the order expected by [decodePupilIdentityFromTextLine].
10+
Future<String?> pickPupilIdentityFileContent() async {
11+
final result = await FilePicker.platform.pickFiles(
12+
type: FileType.custom,
13+
allowedExtensions: ['txt', 'xlsx'],
14+
);
15+
if (result == null ||
16+
result.files.isEmpty ||
17+
result.files.single.path == null) {
18+
return null;
19+
}
20+
21+
final path = result.files.single.path!;
22+
final extension = path.toLowerCase().endsWith('.xlsx') ? 'xlsx' : 'txt';
23+
24+
if (extension == 'txt') {
25+
return await File(path).readAsString();
26+
}
27+
28+
final bytes = await File(path).readAsBytes();
29+
return _xlsxToPupilIdentityLines(bytes);
30+
}
31+
32+
/// SchILD export column indices (0-based). Mapping by position, not header names.
33+
/// Hijacked: Ausweisnummer (14) -> family; Externe ID-Nummer (15) -> familyLanguageLessonsSince.
34+
/// Förderschwerpunkt 1/2 (20, 21) -> specialNeeds columns 6 and 7.
35+
class SchildExportColumns {
36+
SchildExportColumns._();
37+
38+
static const int id = 0; // Schulnummer / l.d.A.Schildnummer
39+
static const int group = 1; // Klasse
40+
static const int lastName = 2; // Nachname
41+
static const int firstName = 3; // Vorname
42+
static const int gender = 5; // Geschlecht
43+
static const int birthday = 6; // Geburtsdatum
44+
static const int religion = 8; // Rel.
45+
static const int family = 14; // Ausweisnummer (hijacked for family code)
46+
static const int familyLanguageLessonsSince =
47+
15; // Externe ID-Nummer (hijacked for family language lessons since)
48+
static const int specialNeeds1 = 20; // Förderschwerpunkt 1
49+
static const int specialNeeds2 = 21; // Förderschwerpunkt 2
50+
static const int groupTutor = 31; // Klassenleiter (if present)
51+
static const int schoolGrade = 29; // Jahrgang (if present)
52+
static const int pupilSince = 30; // Aufnahmedatum
53+
static const int leavingDate =
54+
32; // Datum Abgang (if present; column may vary)
55+
}
56+
57+
String _xlsxToPupilIdentityLines(List<int> bytes) {
58+
final excel = Excel.decodeBytes(bytes);
59+
if (excel.tables.isEmpty) return '';
60+
61+
final table = excel.tables.values.first;
62+
final rows = table.rows;
63+
if (rows.isEmpty) return '';
64+
65+
final lines = <String>[];
66+
final dateFormat = DateFormat('yyyy-MM-dd');
67+
68+
for (var rowIndex = 1; rowIndex < rows.length; rowIndex++) {
69+
final row = rows[rowIndex];
70+
final line = _rowToCanonicalLine(row, dateFormat, rowIndex);
71+
if (line != null) lines.add(line);
72+
}
73+
74+
return lines.join('\n');
75+
}
76+
77+
String? _rowToCanonicalLine(
78+
List<Data?> row,
79+
DateFormat dateFormat,
80+
int rowIndex,
81+
) {
82+
String cellStr(int colIndex) {
83+
if (colIndex >= row.length) return '';
84+
final data = row[colIndex];
85+
final v = data?.value;
86+
if (v == null) return '';
87+
return _cellValueToCanonicalString(v, dateFormat);
88+
}
89+
90+
int? idVal;
91+
try {
92+
final s = cellStr(SchildExportColumns.id).trim();
93+
if (s.isEmpty) return null;
94+
idVal = int.tryParse(s);
95+
if (idVal == null) return null;
96+
} catch (_) {
97+
return null;
98+
}
99+
100+
final schoolGradeStr = cellStr(SchildExportColumns.schoolGrade).trim();
101+
final grade = schoolGradeStr.isNotEmpty ? schoolGradeStr : 'E1';
102+
103+
final parts = <String>[
104+
idVal.toString(),
105+
cellStr(SchildExportColumns.firstName),
106+
cellStr(SchildExportColumns.lastName),
107+
cellStr(SchildExportColumns.group),
108+
cellStr(SchildExportColumns.groupTutor),
109+
grade,
110+
cellStr(SchildExportColumns.specialNeeds1),
111+
cellStr(SchildExportColumns.specialNeeds2),
112+
cellStr(SchildExportColumns.gender),
113+
'', // language - not mapped from template
114+
cellStr(SchildExportColumns.family),
115+
_normalizeDateCell(cellStr(SchildExportColumns.birthday), dateFormat),
116+
'', // migrationSupportEnds
117+
_normalizeDateCell(cellStr(SchildExportColumns.pupilSince), dateFormat),
118+
'', // afterSchoolCare -> empty if not in export
119+
cellStr(SchildExportColumns.religion),
120+
'', // religionLessonsSince
121+
'', // religionLessonsCancelledAt
122+
cellStr(SchildExportColumns.familyLanguageLessonsSince),
123+
_normalizeDateCell(cellStr(SchildExportColumns.leavingDate), dateFormat),
124+
];
125+
126+
return parts.map((p) => p.replaceAll(',', ' ')).join(',');
127+
}
128+
129+
String _cellValueToCanonicalString(dynamic value, DateFormat dateFormat) {
130+
if (value == null) return '';
131+
if (value is TextCellValue) return value.value.toString();
132+
if (value is IntCellValue) return value.value.toString();
133+
if (value is DoubleCellValue) {
134+
final d = value.value;
135+
if (d == d.roundToDouble()) return d.toInt().toString();
136+
return d.toString();
137+
}
138+
if (value is BoolCellValue) return value.value ? 'true' : 'false';
139+
if (value is DateCellValue) {
140+
return dateFormat.format(DateTime(value.year, value.month, value.day));
141+
}
142+
if (value is FormulaCellValue) return value.formula.toString();
143+
return value.toString();
144+
}
145+
146+
String _normalizeDateCell(String raw, DateFormat dateFormat) {
147+
final s = raw.trim();
148+
if (s.isEmpty) return '';
149+
final parsed = DateTime.tryParse(s);
150+
if (parsed != null) return dateFormat.format(parsed);
151+
try {
152+
final ddMMyyyy = DateFormat('dd.MM.yyyy').parse(s);
153+
return dateFormat.format(ddMMyyyy);
154+
} catch (_) {
155+
return s;
156+
}
157+
}

school_data_hub_flutter/lib/features/_pupil/data/pupil_data_api_service.dart

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import 'dart:io';
22

3+
import 'package:flutter_it/flutter_it.dart';
34
import 'package:school_data_hub_client/school_data_hub_client.dart';
45
import 'package:school_data_hub_flutter/common/data/file_upload_service.dart';
56
import 'package:school_data_hub_flutter/common/services/notification_service.dart';
67
import 'package:school_data_hub_flutter/core/client/client_helper.dart';
78
import 'package:school_data_hub_flutter/core/session/hub_session_manager.dart';
8-
import 'package:flutter_it/flutter_it.dart';
99

1010
class PupilDataApiService {
1111
// Private constructor
@@ -24,11 +24,13 @@ class PupilDataApiService {
2424
Client get _client => di<Client>();
2525
// - update backend pupil database
2626

27+
/// [reducedContent] is newline-separated lines, each line "internalId,afterSchoolCare" (true/false).
2728
Future<List<PupilData>?> updateBackendPupilsDatabase({
28-
required String filePath,
29+
required String reducedContent,
2930
}) async {
3031
final pupils = await ClientHelper.apiCall(
31-
call: () => _client.adminPupil.updateBackendPupilDataState(filePath),
32+
call: () =>
33+
_client.adminPupil.updateBackendPupilDataState(reducedContent),
3234
errorMessage: 'Die Schüler konnten nicht aktualisiert werden',
3335
);
3436
return pupils?.toList();
@@ -147,12 +149,9 @@ class PupilDataApiService {
147149
required KindergardenInfo? kindergardenInfo,
148150
}) async {
149151
final updatedPupil = await ClientHelper.apiCall(
150-
call: () => _client.pupilUpdate.updateKindergardenData(
151-
pupilId,
152-
kindergardenInfo,
153-
),
154-
errorMessage:
155-
'Der Kindergartenbesuch konnte nicht aktualisiert werden',
152+
call: () =>
153+
_client.pupilUpdate.updateKindergardenData(pupilId, kindergardenInfo),
154+
errorMessage: 'Der Kindergartenbesuch konnte nicht aktualisiert werden',
156155
);
157156
return updatedPupil;
158157
}
@@ -298,9 +297,9 @@ class PupilDataApiService {
298297
return lastUpdate;
299298
}
300299

301-
Future<DateTime?> updateLastIdentitiesUpdate(DateTime date) async {
300+
Future<DateTime?> insertLastIdentitiesUpdate(DateTime date) async {
302301
final updated = await ClientHelper.apiCall(
303-
call: () => _client.pupilIdentity.updateLastPupilIdentitiesUpdate(date),
302+
call: () => _client.pupilIdentity.insertLastPupilIdentitiesUpdate(date),
304303
errorMessage:
305304
'Die letzte Abgleich-Zeitstempel konnte nicht aktualisiert werden',
306305
);

school_data_hub_flutter/lib/features/_pupil/domain/pupil_identity_helper.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,25 @@ class PupilIdentityHelper {
8989

9090
//- OBJECT HELPERS
9191

92+
/// Builds the reduced sync content (id,afterSchoolCare per line) from full 20-column
93+
/// newline-separated text. Column 14: OFFGANZ or non-empty -> true, else false.
94+
/// Used when sending to backend via string transport.
95+
static String buildReducedPupilSyncContent(String fullCsvContent) {
96+
final lines = fullCsvContent.split('\n');
97+
final reduced = <String>[];
98+
for (final textLine in lines) {
99+
if (textLine.isEmpty) continue;
100+
final parts = textLine.split(',');
101+
if (parts.isEmpty) continue;
102+
final id = int.tryParse(parts[0].trim());
103+
if (id == null) continue;
104+
final afterSchoolCare =
105+
parts.length > 14 && (parts[14] == 'OFFGANZ' || parts[14].trim().isNotEmpty);
106+
reduced.add('$id,$afterSchoolCare');
107+
}
108+
return reduced.join('\n');
109+
}
110+
92111
static PupilIdentity decodePupilIdentityFromTextLine(String textLine) {
93112
final List<String> pupilIdentityStringItems = textLine.split(',');
94113

school_data_hub_flutter/lib/features/_pupil/domain/pupil_identity_manager.dart

Lines changed: 13 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import 'dart:async';
22
import 'dart:convert';
3-
import 'dart:io';
43

54
import 'package:flutter/foundation.dart';
65
import 'package:flutter_it/flutter_it.dart';
76
import 'package:logging/logging.dart';
87
import 'package:school_data_hub_client/school_data_hub_client.dart';
98
import 'package:school_data_hub_flutter/app_utils/custom_encrypter.dart';
109
import 'package:school_data_hub_flutter/app_utils/secure_storage.dart';
11-
import 'package:school_data_hub_flutter/common/data/file_upload_service.dart';
1210
import 'package:school_data_hub_flutter/common/services/notification_service.dart';
1311
import 'package:school_data_hub_flutter/core/env/env_manager.dart';
1412
import 'package:school_data_hub_flutter/core/models/datetime_extensions.dart';
@@ -201,90 +199,32 @@ class PupilIdentityManager {
201199
Future<void> updateServerFromPupilIdentityExternalSource(
202200
String textFileContent,
203201
) async {
204-
// The pupils in the string are separated by a line break - let's split them out
205-
List<String> pupilIdentityTextLines = textFileContent.split('\n');
206-
// Wer prepare a string with the pupils that are going to be updated later in the server
207-
String pupilListTxtFileContentForBackendUpdate = '';
208-
// The properties are separated by commas, let's build the pupilbase objects with them
209-
List<PupilIdentity> importedPupilIdentityList = [];
210-
211-
for (String textLine in pupilIdentityTextLines) {
212-
if (textLine != '') {
213-
PupilIdentity pupilIdentity =
214-
PupilIdentityHelper.decodePupilIdentityFromTextLine(textLine);
215-
216-
importedPupilIdentityList.add(pupilIdentity);
217-
218-
final bool afterSchoolCareStatus = textLine.split(',')[14] == 'OFFGANZ'
219-
? true
220-
: false;
202+
final reducedContent =
203+
PupilIdentityHelper.buildReducedPupilSyncContent(textFileContent);
221204

222-
final internalIdAndAfterSchoolCareData =
223-
'${int.parse(textLine.split(',')[0])},$afterSchoolCareStatus';
224-
225-
pupilListTxtFileContentForBackendUpdate +=
226-
'$internalIdAndAfterSchoolCareData\n';
227-
}
228-
}
229-
// We have the latest dataset from the school database.
230-
// Now let's update the pupils in the server with a txt file
231-
// First we generate a txt file with updatedPupils
232-
// The server will automatically archive pupils that are not in the list,
233-
// update the ones that are in the list,
234-
// and create the ones that are not in the server.
235-
236-
final textFile = File('temp.txt')
237-
..writeAsStringSync(pupilListTxtFileContentForBackendUpdate);
238-
239-
final fileResponse = await ClientFileUpload.uploadFile(
240-
file: textFile,
241-
storageId: StorageId.private,
242-
folder: ServerStorageFolder.temp,
243-
);
244-
if (fileResponse.success == false) {
245-
_notificationService.showSnackBar(
246-
NotificationType.error,
247-
'Die Datei konnte nicht hochgeladen werden!',
248-
);
249-
return;
250-
}
251-
252-
// The backend successfuly updated the pupils, let's get the new update timestamp
253-
final updateTimestamp = await PupilDataApiService()
254-
.fetchLastIdentitiesUpdate();
255-
256-
// Now we need to store the updated pupil identities in storage
257-
updatePupilIdentitiesFromUnencryptedSource(
258-
pupilIdentityTextLines: textFileContent,
259-
updateTimestamp: updateTimestamp,
260-
);
205+
// Update backend with reduced content (id,afterSchoolCare per line). Server accepts string; no file upload.
261206
final List<PupilData>? updatedPupilDataRepository =
262207
await PupilDataApiService().updateBackendPupilsDatabase(
263-
filePath: fileResponse.path!,
208+
reducedContent: reducedContent,
264209
);
265210
if (updatedPupilDataRepository == null) {
266211
return;
267212
}
213+
214+
// Only update local state after successful server update.
215+
final newLastIdentitiesUpdate = DateTime.now().toUtc();
216+
updatePupilIdentitiesFromUnencryptedSource(
217+
pupilIdentityTextLines: textFileContent,
218+
updateTimestamp: newLastIdentitiesUpdate,
219+
);
268220
for (PupilData pupil in updatedPupilDataRepository) {
269221
di<PupilProxyManager>().updatePupilProxyWithPupilData(pupil);
270222
}
271-
// We don't need the temp file any more, let's delete it
272-
textFile.delete();
273-
274-
for (PupilIdentity element in importedPupilIdentityList) {
275-
_pupilIdentities[element.id] = element;
276-
}
277223

278-
// This is the new reference data for all the clients to adopt.
279-
// We need to update the active environment and the backend with the new last identities update.
280-
final newLastIdentitiesUpdate = DateTime.now().toUtc();
281-
await di<EnvManager>().updateActiveEnv(
282-
lastIdentitiesUpdate: newLastIdentitiesUpdate,
283-
);
284-
await PupilDataApiService().updateLastIdentitiesUpdate(
224+
// Update server timestamp for last identities (env and fetchAllPupils already done by updatePupilIdentitiesFromUnencryptedSource).
225+
await PupilDataApiService().insertLastIdentitiesUpdate(
285226
newLastIdentitiesUpdate,
286227
);
287-
await di<PupilProxyManager>().fetchAllPupils();
288228

289229
_notificationService.showSnackBar(
290230
NotificationType.success,

0 commit comments

Comments
 (0)