Skip to content

Commit 4f77199

Browse files
authored
Tool for making a report of unique uploaders per month (#6289)
1 parent 4d63628 commit 4f77199

File tree

8 files changed

+241
-4
lines changed

8 files changed

+241
-4
lines changed

app/config/dartlang-pub-dev.yaml

+10
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ admins:
4242
- manageRetraction
4343
- removePackage
4444
- removeUsers
45+
- oauthUserId: '109830288482976007810'
46+
47+
permissions:
48+
- executeTool
49+
- listUsers
50+
- manageAssignedTags
51+
- managePackageOwnership
52+
- manageRetraction
53+
- removePackage
54+
- removeUsers
4555
- oauthUserId: '117672289743137340098'
4656
4757
permissions:

app/lib/account/backend.dart

+15
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:meta/meta.dart';
1313
// ignore: import_of_legacy_library_into_null_safe
1414
import 'package:neat_cache/neat_cache.dart';
1515

16+
import '../audit/models.dart';
1617
import '../service/openid/gcp_openid.dart';
1718
import '../service/openid/github_openid.dart';
1819
import '../shared/configuration.dart';
@@ -571,6 +572,20 @@ class AccountBackend {
571572
}
572573
}
573574

575+
/// Retrieves a list of all uploader events that happened between [begin] and
576+
/// [end].
577+
Stream<AuditLogRecord> getUploadEvents({DateTime? begin, DateTime? end}) {
578+
final query = _db.query<AuditLogRecord>();
579+
query.filter('kind =', AuditLogRecordKind.packagePublished);
580+
if (begin != null) {
581+
query.filter('created >=', begin);
582+
}
583+
if (end != null) {
584+
query.filter('created <', end);
585+
}
586+
return query.run();
587+
}
588+
574589
// expire all sessions of a given user from datastore and cache
575590
Future<void> _expireAllSessions(String userId) async {
576591
final query = _db.query<UserSession>()..filter('userId =', userId);

app/lib/admin/backend.dart

+2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import 'tools/recent_uploaders.dart';
4343
import 'tools/set_package_blocked.dart';
4444
import 'tools/set_secret.dart';
4545
import 'tools/set_user_blocked.dart';
46+
import 'tools/uploader_count_report.dart';
4647
import 'tools/user_merger.dart';
4748

4849
final _logger = Logger('pub.admin.backend');
@@ -72,6 +73,7 @@ final Map<String, Tool> availableTools = {
7273
'set-user-blocked': executeSetUserBlocked,
7374
'user-merger': executeUserMergerTool,
7475
'list-tools': executeListTools,
76+
'uploader-count-report': executeCountUploaderReport,
7577
};
7678

7779
/// Represents the backend for the admin handling and authentication.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'package:clock/clock.dart';
8+
import 'package:pub_dev/account/backend.dart';
9+
10+
const _monthNames = {
11+
1: 'January',
12+
2: 'February',
13+
3: 'March',
14+
4: 'April',
15+
5: 'May',
16+
6: 'June',
17+
7: 'July',
18+
8: 'August',
19+
9: 'September',
20+
10: 'October',
21+
11: 'November',
22+
12: 'December',
23+
};
24+
25+
Future<String> executeCountUploaderReport(List<String> args) async {
26+
final buckets = <DateTime, Set<String>>{};
27+
final now = clock.now();
28+
29+
await for (final record in accountBackend.getUploadEvents(
30+
begin: DateTime(now.year, now.month - 12),
31+
)) {
32+
final created = record.created;
33+
final users = record.users;
34+
if (created == null) continue;
35+
if (users == null) continue;
36+
final bucket =
37+
buckets.putIfAbsent(DateTime(created.year, created.month), () => {});
38+
for (String user in users) {
39+
bucket.add(user);
40+
}
41+
}
42+
final buffer = StringBuffer();
43+
buffer.writeln('Monthly unique uploading users:');
44+
45+
for (int i = 11; i >= 0; i--) {
46+
final month = DateTime(now.year, now.month - i);
47+
final bucket = buckets[month] ?? {};
48+
buffer
49+
.writeln('${_monthNames[month.month]} ${month.year}: ${bucket.length}');
50+
}
51+
return buffer.toString();
52+
}

app/lib/service/services.dart

+20-2
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import 'dart:async' show FutureOr, Zone;
66

77
import 'package:appengine/appengine.dart';
8+
import 'package:clock/clock.dart';
89
import 'package:fake_gcloud/mem_datastore.dart';
910
import 'package:fake_gcloud/mem_storage.dart';
1011
import 'package:gcloud/service_scope.dart';
1112
import 'package:gcloud/storage.dart';
1213
import 'package:googleapis_auth/auth_io.dart' as auth;
1314
import 'package:logging/logging.dart';
15+
import 'package:shelf/shelf.dart' as shelf;
1416
import 'package:shelf/shelf_io.dart';
1517

1618
import '../account/backend.dart';
@@ -188,8 +190,9 @@ Future<R> withFakeServices<R>({
188190
await topPackages.start();
189191
await youtubeBackend.start();
190192
if (frontendServer != null) {
191-
final handler =
192-
wrapHandler(_logger, createAppHandler(), sanitize: true);
193+
final handler = wrapHandler(
194+
_logger, _fakeClockWrapper(createAppHandler()),
195+
sanitize: true);
193196
final fsSubscription = frontendServer.server.listen((rq) async {
194197
await fork(() => handleRequest(rq, handler));
195198
});
@@ -200,6 +203,21 @@ Future<R> withFakeServices<R>({
200203
}) as R;
201204
}
202205

206+
const fakeClockHeaderName = '_fake_clock';
207+
208+
/// In the fake server a request can send a '_fake_clock' header to specify at
209+
/// what timestamp the request should be handled.
210+
shelf.Handler _fakeClockWrapper(shelf.Handler handler) {
211+
return (shelf.Request request) {
212+
final t = request.headers[fakeClockHeaderName];
213+
if (t == null) {
214+
return handler(request);
215+
} else {
216+
return withClock(Clock.fixed(DateTime.parse(t)), () => handler(request));
217+
}
218+
};
219+
}
220+
203221
/// Run [fn] with pub services that are shared between server instances, CLI
204222
/// tools and integration tests.
205223
Future<R> _withPubServices<R>(FutureOr<R> Function() fn) async {

app/lib/tool/utils/pub_api_client.dart

+57-2
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:convert';
6+
import 'dart:typed_data';
7+
58
import 'package:_pub_shared/data/package_api.dart';
9+
import 'package:clock/clock.dart';
610
import 'package:gcloud/service_scope.dart';
711
import 'package:http/http.dart' as http;
812
import 'package:meta/meta.dart';
913

1014
import '../../frontend/handlers/pubapi.client.dart';
15+
import '../../service/services.dart';
1116
import '../../shared/configuration.dart';
12-
1317
import 'http.dart';
1418

1519
/// Creates an API client with [authToken] that uses the configured HTTP endpoints.
@@ -22,10 +26,60 @@ PubApiClient createPubApiClient({String? authToken}) {
2226
registerScopeExitCallback(() async => httpClient.close());
2327
return PubApiClient(
2428
activeConfiguration.primaryApiUri!.toString(),
25-
client: httpClient,
29+
client: _FakeTimeClient(httpClient),
2630
);
2731
}
2832

33+
class _FakeTimeClient implements http.Client {
34+
final http.Client _client;
35+
_FakeTimeClient(this._client);
36+
37+
@override
38+
Future<http.StreamedResponse> send(http.BaseRequest request) async {
39+
request.headers[fakeClockHeaderName] = clock.now().toIso8601String();
40+
return _client.send(request);
41+
}
42+
43+
@override
44+
void close() => _client.close();
45+
46+
@override
47+
Future<http.Response> delete(Uri url,
48+
{Map<String, String>? headers, Object? body, Encoding? encoding}) =>
49+
_client.delete(url, headers: headers, body: body, encoding: encoding);
50+
51+
@override
52+
Future<http.Response> get(Uri url, {Map<String, String>? headers}) =>
53+
_client.get(url, headers: headers);
54+
55+
@override
56+
Future<http.Response> head(Uri url, {Map<String, String>? headers}) =>
57+
_client.head(url, headers: headers);
58+
59+
@override
60+
Future<http.Response> patch(Uri url,
61+
{Map<String, String>? headers, Object? body, Encoding? encoding}) =>
62+
_client.patch(url, headers: headers, body: body, encoding: encoding);
63+
64+
@override
65+
Future<http.Response> post(Uri url,
66+
{Map<String, String>? headers, Object? body, Encoding? encoding}) =>
67+
_client.post(url, headers: headers, body: body, encoding: encoding);
68+
69+
@override
70+
Future<http.Response> put(Uri url,
71+
{Map<String, String>? headers, Object? body, Encoding? encoding}) =>
72+
_client.put(url, headers: headers, body: body, encoding: encoding);
73+
74+
@override
75+
Future<String> read(Uri url, {Map<String, String>? headers}) =>
76+
_client.read(url, headers: headers);
77+
78+
@override
79+
Future<Uint8List> readBytes(Uri url, {Map<String, String>? headers}) =>
80+
_client.readBytes(url, headers: headers);
81+
}
82+
2983
/// Creates a pub.dev API client and executes [fn], making sure that the HTTP
3084
/// resources are freed after the callback finishes.
3185
///
@@ -55,6 +109,7 @@ extension PubApiClientExt on PubApiClient {
55109
final uploadInfo = await getPackageUploadUrl();
56110

57111
final request = http.MultipartRequest('POST', Uri.parse(uploadInfo.url))
112+
..headers[fakeClockHeaderName] = clock.now().toIso8601String()
58113
..fields.addAll(uploadInfo.fields!)
59114
..files.add(http.MultipartFile.fromBytes('file', bytes))
60115
..followRedirects = false;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'package:clock/clock.dart';
8+
import 'package:pub_dev/admin/tools/uploader_count_report.dart';
9+
import 'package:pub_dev/fake/backend/fake_auth_provider.dart';
10+
import 'package:pub_dev/tool/test_profile/models.dart';
11+
import 'package:test/test.dart';
12+
13+
import '../../package/backend_test_utils.dart';
14+
import '../../shared/test_services.dart';
15+
16+
class UserAndTime {
17+
final String uploaderEmail;
18+
final DateTime publishingTime;
19+
UserAndTime(this.uploaderEmail, this.publishingTime);
20+
}
21+
22+
void main() {
23+
withClock(Clock.fixed(DateTime(2022, 12, 13)), () {
24+
testWithProfile('uploader count report',
25+
testProfile: TestProfile.fromJson(
26+
{'defaultUser': '[email protected]', 'packages': []}), fn: () async {
27+
Future<void> uploadAtTime(UserAndTime userAndTime, int cnt) async {
28+
await withClock(Clock.fixed(userAndTime.publishingTime), () async {
29+
final token = createFakeAuthTokenForEmail(userAndTime.uploaderEmail,
30+
audience: 'fake-client-audience');
31+
final pubspecContent = '''
32+
name: 'new_package_$cnt'
33+
version: '1.2.$cnt'
34+
author: 'Hans Juergen <[email protected]>'
35+
description: 'my package description'
36+
environment:
37+
sdk: '>=2.10.0 <3.0.0'
38+
''';
39+
await createPubApiClient(authToken: token).uploadPackageBytes(
40+
await packageArchiveBytes(pubspecContent: pubspecContent),
41+
);
42+
});
43+
}
44+
45+
var i = 0;
46+
for (final t in [
47+
UserAndTime('[email protected]',
48+
DateTime(2021, 12, 2)), // Before range, should not be counted.
49+
UserAndTime('[email protected]', DateTime(2022, 1, 5)),
50+
UserAndTime('[email protected]',
51+
DateTime(2022, 1, 6)), // Same user, should not be counted.
52+
UserAndTime('[email protected]', DateTime(2022, 2, 26)),
53+
UserAndTime('[email protected]', DateTime(2022, 2, 21)),
54+
UserAndTime('[email protected]', DateTime(2022, 2, 22)),
55+
UserAndTime('[email protected]', DateTime(2022, 12, 11)),
56+
]) {
57+
await uploadAtTime(t, i);
58+
i++;
59+
}
60+
61+
final s = await executeCountUploaderReport([]);
62+
63+
expect(s, '''
64+
Monthly unique uploading users:
65+
January 2022: 1
66+
February 2022: 3
67+
March 2022: 0
68+
April 2022: 0
69+
May 2022: 0
70+
June 2022: 0
71+
July 2022: 0
72+
August 2022: 0
73+
September 2022: 0
74+
October 2022: 0
75+
November 2022: 0
76+
December 2022: 1
77+
''');
78+
});
79+
});
80+
}

index.yaml

+5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ indexes:
1818
- name: created
1919
direction: desc
2020

21+
- kind: AuditLogRecord
22+
properties:
23+
- name: kind
24+
- name: created
25+
2126
- kind: AuditLogRecord
2227
properties:
2328
- name: publishers

0 commit comments

Comments
 (0)