Skip to content

Commit c4a90a9

Browse files
use stream to batch create users
1 parent 2ec0e73 commit c4a90a9

17 files changed

Lines changed: 3277 additions & 2718 deletions

File tree

docs/model_relations/school_data_hub_server.svg

Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
2+
/* To generate run: "serverpod generate" */
3+
4+
// ignore_for_file: implementation_imports
5+
// ignore_for_file: library_private_types_in_public_api
6+
// ignore_for_file: non_constant_identifier_names
7+
// ignore_for_file: public_member_api_docs
8+
// ignore_for_file: type_literal_in_constant_pattern
9+
// ignore_for_file: use_super_parameters
10+
11+
// ignore_for_file: no_leading_underscores_for_library_prefixes
12+
import 'package:serverpod_client/serverpod_client.dart' as _i1;
13+
import '../../../_features/admin/models/created_user_credential.dart' as _i2;
14+
import '../../../_features/admin/models/batch_create_user_error.dart' as _i3;
15+
16+
abstract class BatchCreateUserEvent implements _i1.SerializableModel {
17+
BatchCreateUserEvent._({
18+
this.credential,
19+
this.error,
20+
});
21+
22+
factory BatchCreateUserEvent({
23+
_i2.CreatedUserCredential? credential,
24+
_i3.BatchCreateUserError? error,
25+
}) = _BatchCreateUserEventImpl;
26+
27+
factory BatchCreateUserEvent.fromJson(
28+
Map<String, dynamic> jsonSerialization) {
29+
return BatchCreateUserEvent(
30+
credential: jsonSerialization['credential'] == null
31+
? null
32+
: _i2.CreatedUserCredential.fromJson(
33+
(jsonSerialization['credential'] as Map<String, dynamic>)),
34+
error: jsonSerialization['error'] == null
35+
? null
36+
: _i3.BatchCreateUserError.fromJson(
37+
(jsonSerialization['error'] as Map<String, dynamic>)),
38+
);
39+
}
40+
41+
_i2.CreatedUserCredential? credential;
42+
43+
_i3.BatchCreateUserError? error;
44+
45+
/// Returns a shallow copy of this [BatchCreateUserEvent]
46+
/// with some or all fields replaced by the given arguments.
47+
@_i1.useResult
48+
BatchCreateUserEvent copyWith({
49+
_i2.CreatedUserCredential? credential,
50+
_i3.BatchCreateUserError? error,
51+
});
52+
@override
53+
Map<String, dynamic> toJson() {
54+
return {
55+
if (credential != null) 'credential': credential?.toJson(),
56+
if (error != null) 'error': error?.toJson(),
57+
};
58+
}
59+
60+
@override
61+
String toString() {
62+
return _i1.SerializationManager.encode(this);
63+
}
64+
}
65+
66+
class _Undefined {}
67+
68+
class _BatchCreateUserEventImpl extends BatchCreateUserEvent {
69+
_BatchCreateUserEventImpl({
70+
_i2.CreatedUserCredential? credential,
71+
_i3.BatchCreateUserError? error,
72+
}) : super._(
73+
credential: credential,
74+
error: error,
75+
);
76+
77+
/// Returns a shallow copy of this [BatchCreateUserEvent]
78+
/// with some or all fields replaced by the given arguments.
79+
@_i1.useResult
80+
@override
81+
BatchCreateUserEvent copyWith({
82+
Object? credential = _Undefined,
83+
Object? error = _Undefined,
84+
}) {
85+
return BatchCreateUserEvent(
86+
credential: credential is _i2.CreatedUserCredential?
87+
? credential
88+
: this.credential?.copyWith(),
89+
error:
90+
error is _i3.BatchCreateUserError? ? error : this.error?.copyWith(),
91+
);
92+
}
93+
}

school_data_hub_client/lib/src/protocol/client.dart

Lines changed: 421 additions & 408 deletions
Large diffs are not rendered by default.

school_data_hub_client/lib/src/protocol/protocol.dart

Lines changed: 894 additions & 879 deletions
Large diffs are not rendered by default.

school_data_hub_flutter/assets/school_data_hub_server.svg

Lines changed: 1 addition & 1 deletion
Loading

school_data_hub_flutter/lib/features/server_logs/presentation/widgets/session_log_card.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,18 @@ class SessionLogCard extends StatelessWidget {
3939
Clipboard.setData(
4040
ClipboardData(
4141
text:
42-
'${info.sessionLogEntry.endpoint}/n${info.sessionLogEntry.error}',
42+
'''
43+
Error: ${info.sessionLogEntry.error}
44+
Endpoint: ${info.sessionLogEntry.endpoint}
45+
Method: ${info.sessionLogEntry.method}
46+
Num Queries: ${info.sessionLogEntry.numQueries}
47+
Duration: ${info.sessionLogEntry.duration} ms
48+
Time: ${info.sessionLogEntry.time}
49+
Authenticated User ID: ${info.sessionLogEntry.authenticatedUserId}
50+
Is Open: ${info.sessionLogEntry.isOpen}
51+
Stack Trace:
52+
${info.sessionLogEntry.stackTrace ?? 'none set'}
53+
''',
4354
),
4455
);
4556
ScaffoldMessenger.of(context).showSnackBar(

school_data_hub_flutter/lib/features/user/data/user_api_service.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,11 @@ class UserApiService {
150150
errorMessage: 'Benutzer-Stapelimport',
151151
);
152152
}
153+
154+
/// Streams batch create results one-by-one (avoids HTTP timeout). No wrapper so caller can listen and handle errors.
155+
Stream<BatchCreateUserEvent> batchCreateUsersStream(
156+
List<CreateUserRequest> requests,
157+
) {
158+
return _client.adminUser.batchCreateUsersStream(requests);
159+
}
153160
}

school_data_hub_flutter/lib/features/user/domain/user_manager.dart

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,11 @@ class UserManager {
242242
);
243243
}
244244

245-
/// Batch-creates users from import rows. Passwords are generated on the client;
246-
/// server validates and skips duplicate userName/email. Refreshes user list at the end.
247-
Future<BatchCreateResult> batchCreateUsersFromImportRows(
245+
/// Builds [CreateUserRequest] list from import rows (shared by batch and stream).
246+
List<CreateUserRequest> buildCreateUserRequestsFromImportRows(
248247
List<StaffImportRow> rows, {
249248
String Function(StaffImportRow)? generatePassword,
250-
}) async {
249+
}) {
251250
final gen = generatePassword ?? (_) => generateRandomStaffPassword();
252251
final requests = <CreateUserRequest>[];
253252
for (final row in rows) {
@@ -273,6 +272,32 @@ class UserManager {
273272
),
274273
);
275274
}
275+
return requests;
276+
}
277+
278+
/// Streams batch create results one-by-one (avoids HTTP timeout). Caller listens and accumulates.
279+
Stream<BatchCreateUserEvent> batchCreateUsersStreamFromImportRows(
280+
List<StaffImportRow> rows, {
281+
String Function(StaffImportRow)? generatePassword,
282+
}) {
283+
final requests = buildCreateUserRequestsFromImportRows(
284+
rows,
285+
generatePassword: generatePassword,
286+
);
287+
return _apiService.batchCreateUsersStream(requests);
288+
}
289+
290+
/// Batch-creates users from import rows. Passwords are generated on the client;
291+
/// server validates and skips duplicate userName/email. Refreshes user list at the end.
292+
/// Prefer [batchCreateUsersStreamFromImportRows] for large batches to avoid timeout.
293+
Future<BatchCreateResult> batchCreateUsersFromImportRows(
294+
List<StaffImportRow> rows, {
295+
String Function(StaffImportRow)? generatePassword,
296+
}) async {
297+
final requests = buildCreateUserRequestsFromImportRows(
298+
rows,
299+
generatePassword: generatePassword,
300+
);
276301
final response = await _apiService.batchCreateUsers(requests);
277302
if (response == null) {
278303
throw Exception('Benutzer-Stapelimport fehlgeschlagen.');

school_data_hub_flutter/lib/features/user/presentation/batch_import_users/batch_import_users_page.dart

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:typed_data';
23

34
import 'package:flutter/material.dart';
@@ -7,8 +8,10 @@ import 'package:printing/printing.dart';
78
import 'package:school_data_hub_flutter/app_utils/pdf_viewer_page.dart';
89
import 'package:school_data_hub_flutter/common/theme/app_colors.dart';
910
import 'package:school_data_hub_flutter/common/theme/styles.dart';
11+
import 'package:school_data_hub_flutter/common/widgets/bottom_nav_bar/generic_bottom_nav_bar.dart';
1012
import 'package:school_data_hub_flutter/common/widgets/generic_components/generic_app_bar.dart';
1113
import 'package:school_data_hub_flutter/features/user/data/staff_excel_import_parser.dart';
14+
import 'package:school_data_hub_client/school_data_hub_client.dart';
1215
import 'package:school_data_hub_flutter/features/user/domain/batch_create_result.dart';
1316
import 'package:school_data_hub_flutter/features/user/domain/user_manager.dart';
1417
import 'package:school_data_hub_flutter/features/user/presentation/batch_import_users/staff_credentials_pdf_service.dart';
@@ -24,6 +27,15 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
2427
StaffImportParseResult? _parseResult;
2528
BatchCreateResult? _batchResult;
2629
bool _isCreating = false;
30+
int _progressCreated = 0;
31+
int _progressErrors = 0;
32+
StreamSubscription<BatchCreateUserEvent>? _streamSubscription;
33+
34+
@override
35+
void dispose() {
36+
_streamSubscription?.cancel();
37+
super.dispose();
38+
}
2739

2840
Future<void> _pickFile() async {
2941
final result = await StaffExcelImportParser.pickAndParse();
@@ -38,16 +50,63 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
3850
Future<void> _createUsers() async {
3951
final rows = _parseResult?.rows ?? [];
4052
if (rows.isEmpty) return;
41-
setState(() => _isCreating = true);
53+
setState(() {
54+
_isCreating = true;
55+
_batchResult = null;
56+
_progressCreated = 0;
57+
_progressErrors = 0;
58+
});
59+
final userManager = di<UserManager>();
60+
final credentials = <StaffCredentialEntry>[];
61+
final errors = <BatchCreateError>[];
62+
4263
try {
43-
final userManager = di<UserManager>();
44-
final result = await userManager.batchCreateUsersFromImportRows(rows);
45-
if (mounted) {
46-
setState(() {
47-
_batchResult = result;
48-
_isCreating = false;
49-
});
50-
}
64+
final stream = userManager.batchCreateUsersStreamFromImportRows(rows);
65+
_streamSubscription = stream.listen(
66+
(event) {
67+
if (!mounted) return;
68+
if (event.credential != null) {
69+
final c = event.credential!;
70+
credentials.add(StaffCredentialEntry(
71+
userName: c.userName,
72+
fullName: c.fullName,
73+
email: c.email,
74+
password: c.password,
75+
));
76+
setState(() => _progressCreated = credentials.length);
77+
} else if (event.error != null) {
78+
final e = event.error!;
79+
errors.add(BatchCreateError(
80+
rowIndex: e.rowIndex,
81+
userNameOrKurzel: e.userNameOrKurzel,
82+
message: e.message,
83+
));
84+
setState(() => _progressErrors = errors.length);
85+
}
86+
},
87+
onError: (Object e) {
88+
if (mounted) {
89+
setState(() => _isCreating = false);
90+
ScaffoldMessenger.of(context).showSnackBar(
91+
SnackBar(content: Text('Fehler: $e')),
92+
);
93+
}
94+
},
95+
onDone: () async {
96+
if (!mounted) return;
97+
await userManager.fetchUsersCommand.runAsync();
98+
setState(() {
99+
_batchResult = BatchCreateResult(
100+
credentials: credentials,
101+
errors: errors,
102+
);
103+
_isCreating = false;
104+
_progressCreated = 0;
105+
_progressErrors = 0;
106+
});
107+
},
108+
cancelOnError: false,
109+
);
51110
} catch (e) {
52111
if (mounted) {
53112
setState(() => _isCreating = false);
@@ -61,7 +120,9 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
61120
Future<void> _printCredentials() async {
62121
final credentials = _batchResult?.credentials ?? [];
63122
if (credentials.isEmpty) return;
64-
final bytes = await StaffCredentialsPdfService.generatePdfBytes(credentials);
123+
final bytes = await StaffCredentialsPdfService.generatePdfBytes(
124+
credentials,
125+
);
65126
if (bytes.isEmpty || !mounted) return;
66127
await Printing.layoutPdf(
67128
onLayout: (_) async => Uint8List.fromList(bytes),
@@ -75,9 +136,7 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
75136
final file = await StaffCredentialsPdfService.generatePdfFile(credentials);
76137
if (file == null || !mounted) return;
77138
await Navigator.of(context).push<void>(
78-
MaterialPageRoute(
79-
builder: (context) => PdfViewerPage(pdfFile: file),
80-
),
139+
MaterialPageRoute(builder: (context) => PdfViewerPage(pdfFile: file)),
81140
);
82141
}
83142

@@ -98,10 +157,7 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
98157
crossAxisAlignment: CrossAxisAlignment.stretch,
99158
children: [
100159
// Step 1: Pick file
101-
const Text(
102-
'1. Datei auswählen',
103-
style: AppStyles.subtitle,
104-
),
160+
const Text('1. Datei auswählen', style: AppStyles.subtitle),
105161
const Gap(8),
106162
ElevatedButton.icon(
107163
style: AppStyles.actionButtonStyle,
@@ -119,7 +175,12 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
119175
child: Column(
120176
crossAxisAlignment: CrossAxisAlignment.start,
121177
children: _parseResult!.errors
122-
.map((e) => Text(e, style: const TextStyle(fontSize: 12)))
178+
.map(
179+
(e) => Text(
180+
e,
181+
style: const TextStyle(fontSize: 12),
182+
),
183+
)
123184
.toList(),
124185
),
125186
),
@@ -135,7 +196,9 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
135196
scrollDirection: Axis.horizontal,
136197
child: SingleChildScrollView(
137198
child: DataTable(
138-
headingRowColor: WidgetStateProperty.all(Colors.grey.shade300),
199+
headingRowColor: WidgetStateProperty.all(
200+
Colors.grey.shade300,
201+
),
139202
columns: const [
140203
DataColumn(label: Text('Vorname')),
141204
DataColumn(label: Text('Nachname')),
@@ -165,7 +228,10 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
165228
),
166229
const Gap(16),
167230
// Step 2: Create users
168-
const Text('2. Benutzer anlegen', style: AppStyles.subtitle),
231+
const Text(
232+
'2. Benutzer anlegen',
233+
style: AppStyles.subtitle,
234+
),
169235
const Gap(8),
170236
ElevatedButton.icon(
171237
style: AppStyles.actionButtonStyle,
@@ -177,7 +243,11 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
177243
child: CircularProgressIndicator(strokeWidth: 2),
178244
)
179245
: const Icon(Icons.person_add),
180-
label: Text(_isCreating ? 'Wird erstellt…' : 'Benutzer anlegen'),
246+
label: Text(
247+
_isCreating
248+
? 'Wird erstellt… ($_progressCreated / ${_parseResult!.rows.length}, $_progressErrors Fehler)'
249+
: 'Benutzer anlegen',
250+
),
181251
),
182252
],
183253
],
@@ -196,14 +266,20 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
196266
padding: const EdgeInsets.only(bottom: 4),
197267
child: Text(
198268
'Zeile ${e.rowIndex} (${e.userNameOrKurzel}): ${e.message}',
199-
style: TextStyle(fontSize: 12, color: Colors.red.shade800),
269+
style: TextStyle(
270+
fontSize: 12,
271+
color: Colors.red.shade800,
272+
),
200273
),
201274
),
202275
),
203276
],
204277
if (_batchResult!.credentials.isNotEmpty) ...[
205278
const Gap(16),
206-
const Text('3. Zugangsdaten drucken', style: AppStyles.subtitle),
279+
const Text(
280+
'3. Zugangsdaten drucken',
281+
style: AppStyles.subtitle,
282+
),
207283
const Gap(8),
208284
Row(
209285
children: [
@@ -228,6 +304,7 @@ class _BatchImportUsersPageState extends State<BatchImportUsersPage> {
228304
),
229305
),
230306
),
307+
bottomNavigationBar: const GenericBottomNavBar(),
231308
);
232309
}
233310
}

0 commit comments

Comments
 (0)