diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7e54fc..b040539 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,10 +74,10 @@ jobs: uses: actions/checkout@v4 - name: Setup Java - uses: oracle-actions/setup-java@v1 + uses: actions/setup-java@v4 with: - release: 19 - version: 19.0.2 + distribution: oracle + java-version: 19 - name: View Java status run: java --version diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 916f2ff..68c3eff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,16 @@ permissions: jobs: checkout: name: Checkout repository - if: ${{ github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' }} + if: | + ( + github.event_name == 'push' && + github.actor != 'dependabot[bot]' + ) || ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.actor != 'dependabot[bot]' + ) + runs-on: ubuntu-latest outputs: number: ${{ steps.pr-info-output.outputs.number }} @@ -52,7 +61,7 @@ jobs: path: . python: - name: Test web application + name: Web application test needs: checkout runs-on: ubuntu-latest strategy: @@ -89,23 +98,24 @@ jobs: - name: Run tests run: coverage run -m pytest -v . - - name: Combine coverage reports + - name: Collect coverage data run: coverage combine - name: Report coverage run: coverage report -m - - name: Generate HTML coverage report - run: coverage html -d htmlcov + - name: Rename coverage report + run: mv .coverage .coverage.python-${{ matrix.python-version }} - - name: Upload HTML coverage report + - name: Upload coverage report uses: actions/upload-artifact@v4 with: - name: coverage-html-${{ matrix.python-version }} - path: htmlcov + name: coverage-python-${{ matrix.python-version }} + path: .coverage.python-${{ matrix.python-version }} + include-hidden-files: true flutter: - name: Test client application + name: Client application test needs: checkout runs-on: ${{ matrix.os }} strategy: @@ -113,8 +123,38 @@ jobs: matrix: os: [macos-latest, ubuntu-latest, windows-latest] + steps: + - name: Download repository + uses: actions/download-artifact@v4 + with: + name: repository + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.24.3 + channel: stable + + - name: View Flutter status + run: | + flutter --version + flutter doctor -v + + - name: Run tests + working-directory: app/resident_manager + run: flutter test + + flutter-integration: + name: Client integration test + needs: checkout + runs-on: ubuntu-latest + env: + ODBC_CONNECTION_STRING: ${{ secrets.ODBC_CONNECTION_STRING}} + VNPAY_TMN_CODE: ${{ secrets.VNPAY_TMN_CODE }} + VNPAY_SECRET_KEY: ${{ secrets.VNPAY_SECRET_KEY }} PRIVATE_KEY_SEED: ${{ secrets.PRIVATE_KEY_SEED }} + PORT: 8000 steps: - name: Download repository @@ -128,22 +168,116 @@ jobs: flutter-version: 3.24.3 channel: stable + - name: Install extra apt dependencies + run: sudo apt-get install -y ninja-build libgtk-3-dev + - name: View Flutter status run: | flutter --version flutter doctor -v - - name: Run tests + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install -r dev-requirements.txt + + - name: Install ODBC driver 18 + run: | + chmod +x scripts/odbc.sh + scripts/odbc.sh + + - name: Start API server + run: | + uvicorn main:app --host 0.0.0.0 --port $PORT & + echo $! > /tmp/serverpid.txt + + - name: Run integration tests + timeout-minutes: 30 working-directory: app/resident_manager - run: flutter test + run: xvfb-run flutter test integration_test + + - name: Stop API server + run: | + kill $(cat /tmp/serverpid.txt) + sleep 5 + + - name: Collect coverage data + run: coverage combine + + - name: Report coverage + run: coverage report -m + + - name: Rename coverage report + run: mv .coverage .coverage.flutter-integration + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-flutter-integration + path: .coverage.flutter-integration + include-hidden-files: true + + python-coverage: + name: Combine coverage reports + needs: [python, flutter-integration] + runs-on: ubuntu-latest + + steps: + - name: Download repository + uses: actions/download-artifact@v4 + with: + name: repository + + - name: Download coverage reports + uses: actions/download-artifact@v4 + with: + pattern: coverage-* + path: . + merge-multiple: true + + - name: Install dependencies + run: pip install -r dev-requirements.txt + + - name: Combine coverage reports + run: coverage combine + + - name: Report coverage + run: coverage report -m + + - name: Save coverage report + run: coverage report -m > textcov.txt + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-txt + path: textcov.txt + + - name: Generate HTML coverage report + run: coverage html -d htmlcov + + - name: Upload HTML coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: htmlcov notification: name: Comment in pull request - needs: [checkout, python, flutter] + needs: [checkout, python, flutter, flutter-integration, python-coverage] if: ${{ always() && needs.checkout.result == 'success' && github.event_name == 'workflow_run' }} runs-on: ubuntu-latest steps: + - name: Download coverage report + if: ${{ needs.python-coverage.result == 'success' }} + uses: actions/download-artifact@v4 + with: + name: coverage-txt + - name: Create comment uses: actions/github-script@v7 with: @@ -152,9 +286,18 @@ jobs: const url = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"; const sha = "${{ needs.checkout.outputs.sha}}".substr(0, 7); let body = null; - if (${{ needs.python.result == 'success' && needs.flutter.result == 'success' }}) + if (${{ needs.python.result == 'success' && needs.flutter.result == 'success' && needs.flutter-integration.result == 'success' }}) { body = `🎉 [All tests](${url}) of \`${sha}\` passed successfully.`; + + if (${{ needs.python-coverage.result == 'success' }}) + { + const fs = require("fs/promises"); + const data = await fs.readFile("textcov.txt", { encoding: "utf8" }); + + const wrapped = `\`\`\`\n${data}\`\`\``; + body += `\n
\nCoverage report\n\n${wrapped}\n\n
`; + } } else { diff --git a/README.md b/README.md index c1e5bd2..cd3a9de 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # resident-manager -[![Azure deployment](https://github.com/Serious-senpai/resident-manager/actions/workflows/deploy.yml/badge.svg)](https://github.com/Serious-senpai/resident-manager/actions/workflows/deploy.yml) -[![Flutter build](https://github.com/Serious-senpai/resident-manager/actions/workflows/build.yml/badge.svg)](https://github.com/Serious-senpai/resident-manager/actions/workflows/build.yml) -[![Lint](https://github.com/Serious-senpai/resident-manager/actions/workflows/lint.yml/badge.svg)](https://github.com/Serious-senpai/resident-manager/actions/workflows/lint.yml) -[![Run tests](https://github.com/Serious-senpai/resident-manager/actions/workflows/tests.yml/badge.svg)](https://github.com/Serious-senpai/resident-manager/actions/workflows/tests.yml) +[![Azure deployment](https://github.com/Serious-senpai/resident-manager/actions/workflows/deploy.yml/badge.svg?branch=main&event=push)](https://github.com/Serious-senpai/resident-manager/actions/workflows/deploy.yml) +[![Flutter build](https://github.com/Serious-senpai/resident-manager/actions/workflows/build.yml/badge.svg?branch=main&event=push)](https://github.com/Serious-senpai/resident-manager/actions/workflows/build.yml) +[![Lint](https://github.com/Serious-senpai/resident-manager/actions/workflows/lint.yml/badge.svg?branch=main&event=push)](https://github.com/Serious-senpai/resident-manager/actions/workflows/lint.yml) +[![Run tests](https://github.com/Serious-senpai/resident-manager/actions/workflows/tests.yml/badge.svg?branch=main&event=push)](https://github.com/Serious-senpai/resident-manager/actions/workflows/tests.yml) Management system for residents sharing an apartment. diff --git a/app/resident_manager/assets/landscape.png b/app/resident_manager/assets/landscape.png new file mode 100644 index 0000000..79ccb64 Binary files /dev/null and b/app/resident_manager/assets/landscape.png differ diff --git a/app/resident_manager/integration_test/widget_test.dart b/app/resident_manager/integration_test/widget_test.dart new file mode 100644 index 0000000..457701a --- /dev/null +++ b/app/resident_manager/integration_test/widget_test.dart @@ -0,0 +1,287 @@ +import "dart:math"; + +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; +import "package:resident_manager/main.dart"; +import "package:resident_manager/src/config.dart"; +import "package:resident_manager/src/state.dart"; +import "package:resident_manager/src/widgets/home.dart"; +import "package:resident_manager/src/widgets/login.dart"; +import "package:resident_manager/src/widgets/register.dart"; +import "package:resident_manager/src/widgets/admin/reg_queue.dart"; +import "package:resident_manager/src/widgets/admin/residents.dart"; +import "package:resident_manager/src/widgets/admin/rooms.dart"; + +final rng = Random(); +const WAIT_DURATION = Duration(seconds: 10); + +String randomString(int length) { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(rng.nextInt(chars.length)))); +} + +String randomDigits(int length) { + const chars = "0123456789"; + return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(rng.nextInt(chars.length)))); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + "Administrator login", + (tester) async { + final state = ApplicationState(); + await state.prepare(); + await state.deauthorize(); // Start integration test without existing authorization data + + await tester.pumpWidget(MainApplication(state: state)); + await tester.pumpAndSettle(); + + // Authorization fields + final fields = find.byWidgetPredicate((widget) => widget is TextField); + expect(fields, findsExactly(2)); + + await tester.enterText(fields.at(0), DEFAULT_ADMIN_USERNAME); + await tester.enterText(fields.at(1), DEFAULT_ADMIN_PASSWORD); + + // Press the "Login as administrator" button + await tester.tap(find.byIcon(Icons.admin_panel_settings_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + expect(find.byWidgetPredicate((widget) => widget is RegisterQueuePage), findsOneWidget); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // Open residents list + await tester.tap(find.byIcon(Icons.people_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + expect(find.byWidgetPredicate((widget) => widget is ResidentsPage), findsOneWidget); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // Open rooms list + await tester.tap(find.byIcon(Icons.room_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + expect(find.byWidgetPredicate((widget) => widget is RoomsPage), findsOneWidget); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // Logout + await tester.tap(find.byIcon(Icons.logout_outlined)); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is LoginPage), findsOneWidget); + }, + ); + + testWidgets( + "Resident registration", + (tester) async { + final state = ApplicationState(); + await state.prepare(); + await state.deauthorize(); // Start integration test without existing authorization data + + await tester.pumpWidget(MainApplication(state: state)); + await tester.pumpAndSettle(); + + // Press the "Register as resident" button + await tester.tap(find.byIcon(Icons.how_to_reg_outlined)); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is RegisterPage), findsOneWidget); + + // Registration form fields + final registrationFields = find.byWidgetPredicate((widget) => widget is TextFormField); + expect(registrationFields, findsExactly(8)); + + final fullname = randomString(20); + final room = rng.nextInt(32767); + final phone = randomDigits(10); + final email = "$fullname@test.com"; + final username = randomString(12); + final password = randomString(12); + + // Fill in registration fields + await tester.enterText(registrationFields.at(0), fullname); + await tester.enterText(registrationFields.at(1), room.toString()); + // Skip birthday + await tester.enterText(registrationFields.at(3), phone); + await tester.enterText(registrationFields.at(4), email); + await tester.enterText(registrationFields.at(5), username); + await tester.enterText(registrationFields.at(6), password); + await tester.enterText(registrationFields.at(7), password); + + // Tap the "Register" button + await tester.tap(find.byIcon(Icons.how_to_reg_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // Open login menu + await tester.tap(find.byIcon(Icons.lock_outlined)); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is LoginPage), findsOneWidget); + + // Login as administrator + final adminLoginFields = find.byWidgetPredicate((widget) => widget is TextField); + expect(adminLoginFields, findsExactly(2)); + + await tester.enterText(adminLoginFields.at(0), DEFAULT_ADMIN_USERNAME); + await tester.enterText(adminLoginFields.at(1), DEFAULT_ADMIN_PASSWORD); + + // Press the "Login as administrator" button + await tester.tap(find.byIcon(Icons.admin_panel_settings_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + expect(find.byWidgetPredicate((widget) => widget is RegisterQueuePage), findsOneWidget); + + // Open search interface + await tester.tap(find.byIcon(Icons.search_outlined)); + await tester.pumpAndSettle(); + + final searchDialog = find.byWidgetPredicate((widget) => widget is SimpleDialog); + expect(searchDialog, findsOneWidget); + + final searchFields = find.descendant( + of: searchDialog, + matching: find.byWidgetPredicate((widget) => widget is TextFormField), + ); + expect(searchFields, findsExactly(3)); + + // Fill in search fields + await tester.enterText(searchFields.at(0), fullname); + await tester.enterText(searchFields.at(1), room.toString()); + await tester.enterText(searchFields.at(2), username); + await tester.tap(find.descendant(of: searchDialog, matching: find.byIcon(Icons.done_outlined))); + await tester.pumpAndSettle(WAIT_DURATION); + + // Exactly 2 checkboxes: 1 for "Select all", 1 for our search result + final checkboxes = find.byWidgetPredicate((widget) => widget is Checkbox); + expect(checkboxes, findsExactly(2)); + + // Toggle 3 times + await tester.tap(checkboxes.first); + await tester.tap(checkboxes.last); + await tester.tap(checkboxes.first); + await tester.pumpAndSettle(); + + // Approve the registration request + await tester.tap(find.byIcon(Icons.done_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // Logout + await tester.tap(find.byIcon(Icons.logout_outlined)); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is LoginPage), findsOneWidget); + + // Login as the newly created resident user + final loginFields = find.byWidgetPredicate((widget) => widget is TextField); + expect(loginFields, findsExactly(2)); + + await tester.enterText(loginFields.at(0), username); + await tester.enterText(loginFields.at(1), password); + + // Press the "Login as resident" button + await tester.tap(find.byIcon(Icons.login_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + // Reach the home page + expect(find.byWidgetPredicate((widget) => widget is HomePage), findsOneWidget); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // Logout + await tester.tap(find.byIcon(Icons.logout_outlined)); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is LoginPage), findsOneWidget); + + // Login as administrator + final adminLoginFields2 = find.byWidgetPredicate((widget) => widget is TextField); + expect(adminLoginFields2, findsExactly(2)); + + await tester.enterText(adminLoginFields2.at(0), DEFAULT_ADMIN_USERNAME); + await tester.enterText(adminLoginFields2.at(1), DEFAULT_ADMIN_PASSWORD); + + // Press the "Login as administrator" button + await tester.tap(find.byIcon(Icons.admin_panel_settings_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + expect(find.byWidgetPredicate((widget) => widget is RegisterQueuePage), findsOneWidget); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // View resident list + await tester.tap(find.byIcon(Icons.people_outlined)); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is ResidentsPage), findsOneWidget); + + // Open search interface + await tester.tap(find.byIcon(Icons.search_outlined)); + await tester.pumpAndSettle(); + + final searchDialog2 = find.byWidgetPredicate((widget) => widget is SimpleDialog); + expect(searchDialog2, findsOneWidget); + + final searchFields2 = find.descendant( + of: searchDialog2, + matching: find.byWidgetPredicate((widget) => widget is TextFormField), + ); + expect(searchFields2, findsExactly(3)); + + // Fill in search fields + await tester.enterText(searchFields2.at(0), fullname); + await tester.enterText(searchFields2.at(1), room.toString()); + await tester.enterText(searchFields2.at(2), username); + await tester.tap(find.descendant(of: searchDialog, matching: find.byIcon(Icons.done_outlined))); + await tester.pumpAndSettle(WAIT_DURATION); + + // Exactly 2 checkboxes: 1 for "Select all", 1 for our search result + final checkboxes2 = find.byWidgetPredicate((widget) => widget is Checkbox); + expect(checkboxes2, findsExactly(2)); + + // Toggle 3 times + await tester.tap(checkboxes2.first); + await tester.tap(checkboxes2.last); + await tester.tap(checkboxes2.first); + await tester.pumpAndSettle(); + + // Delete the created account + await tester.tap(find.byIcon(Icons.delete_outlined)); + await tester.pumpAndSettle(WAIT_DURATION); + + // Open drawer + await tester.tap(find.byIcon(Icons.menu_outlined)); + await tester.pumpAndSettle(); + + // Logout + await tester.tap(find.byIcon(Icons.logout_outlined)); + await tester.pumpAndSettle(); + + expect(find.byWidgetPredicate((widget) => widget is LoginPage), findsOneWidget); + }, + ); +} diff --git a/app/resident_manager/lib/main.dart b/app/resident_manager/lib/main.dart index 8cdb38f..b74be75 100644 --- a/app/resident_manager/lib/main.dart +++ b/app/resident_manager/lib/main.dart @@ -2,8 +2,8 @@ import "package:flutter/material.dart"; import "package:flutter_localization/flutter_localization.dart"; import "src/routes.dart"; -import "src/core/state.dart"; -import "src/core/translations.dart"; +import "src/state.dart"; +import "src/translations.dart"; import "src/widgets/state.dart"; import "src/widgets/common.dart"; import "src/widgets/home.dart"; @@ -11,6 +11,7 @@ import "src/widgets/login.dart"; import "src/widgets/register.dart"; import "src/widgets/admin/reg_queue.dart"; import "src/widgets/admin/residents.dart"; +import "src/widgets/admin/rooms.dart"; class MainApplication extends StateAwareWidget { const MainApplication({super.key, required super.state}); @@ -42,6 +43,7 @@ class MainApplicationState extends AbstractCommonState { ApplicationRoute.home: (context) => HomePage(state: state), ApplicationRoute.adminRegisterQueue: (context) => RegisterQueuePage(state: state), ApplicationRoute.adminResidentsPage: (context) => ResidentsPage(state: state), + ApplicationRoute.adminRoomsPage: (context) => RoomsPage(state: state), }, initialRoute: initialRoute, localizationsDelegates: state.localization.localizationsDelegates, diff --git a/app/resident_manager/lib/src/core/config.dart b/app/resident_manager/lib/src/config.dart similarity index 72% rename from app/resident_manager/lib/src/core/config.dart rename to app/resident_manager/lib/src/config.dart index 56f0b3c..ecff93f 100644 --- a/app/resident_manager/lib/src/core/config.dart +++ b/app/resident_manager/lib/src/config.dart @@ -5,3 +5,5 @@ import "package:pinenacl/x25519.dart"; final epoch = DateTime.utc(2024, 1, 1); const int DB_PAGINATION_QUERY = 50; final serverKey = PublicKey(base64.decode("FUgK7Fi7O7eSDi5Ekd/hbmjIN3k/WcLevFTgZqmn9Bo=")); +const DEFAULT_ADMIN_USERNAME = "admin"; +const DEFAULT_ADMIN_PASSWORD = "NgaiLongGey"; diff --git a/app/resident_manager/lib/src/core/models/residents.dart b/app/resident_manager/lib/src/core/models/residents.dart deleted file mode 100644 index 80743b9..0000000 --- a/app/resident_manager/lib/src/core/models/residents.dart +++ /dev/null @@ -1,85 +0,0 @@ -import "dart:convert"; - -import "info.dart"; -import "../state.dart"; - -/// Represents a resident. -class Resident extends PublicInfo { - /// Constructs a [Resident] object with the given [id], [name], [room], [birthday], [phone], and [email]. - Resident({ - required super.id, - required super.name, - required super.room, - super.birthday, - super.phone, - super.email, - super.username, - super.hashedPassword, - }); - - Resident.fromJson(super.data) : super.fromJson(); - - Map toJson() => { - "id": id, - "name": name, - "room": room, - "birthday": birthday?.toIso8601String(), - "phone": phone, - "email": email, - }; - - static Future> query({ - required ApplicationState state, - required int offset, - int? id, - String? name, - int? room, - String? username, - String? orderBy, - bool? ascending, - }) async { - final result = []; - final authorization = state.authorization; - if (authorization == null) { - return result; - } - - final params = {"offset": offset.toString()}; - if (id != null) { - params["id"] = id.toString(); - } - if (name != null && name.isNotEmpty) { - params["name"] = name; - } - if (room != null) { - params["room"] = room.toString(); - } - if (username != null && username.isNotEmpty) { - params["username"] = username; - } - if (orderBy != null && orderBy.isNotEmpty) { - params["order_by"] = orderBy; - } - if (ascending != null) { - params["ascending"] = ascending.toString(); - } - - final response = await state.get("/api/v1/admin/residents", queryParameters: params); - if (response.statusCode == 200) { - final data = json.decode(utf8.decode(response.bodyBytes)) as List; - result.addAll(data.map(Resident.fromJson)); - } - - return result; - } - - /// Count the number of residents. - static Future count({required ApplicationState state}) async { - final response = await state.get("/api/v1/admin/residents/count"); - if (response.statusCode == 200) { - return json.decode(utf8.decode(response.bodyBytes)); - } - - return null; - } -} diff --git a/app/resident_manager/lib/src/core/errors.dart b/app/resident_manager/lib/src/errors.dart similarity index 100% rename from app/resident_manager/lib/src/core/errors.dart rename to app/resident_manager/lib/src/errors.dart diff --git a/app/resident_manager/lib/src/core/http.dart b/app/resident_manager/lib/src/http.dart similarity index 100% rename from app/resident_manager/lib/src/core/http.dart rename to app/resident_manager/lib/src/http.dart diff --git a/app/resident_manager/lib/src/core/models/auth.dart b/app/resident_manager/lib/src/models/auth.dart similarity index 100% rename from app/resident_manager/lib/src/core/models/auth.dart rename to app/resident_manager/lib/src/models/auth.dart diff --git a/app/resident_manager/lib/src/core/models/info.dart b/app/resident_manager/lib/src/models/info.dart similarity index 91% rename from app/resident_manager/lib/src/core/models/info.dart rename to app/resident_manager/lib/src/models/info.dart index 5c20071..09ff9fe 100644 --- a/app/resident_manager/lib/src/core/models/info.dart +++ b/app/resident_manager/lib/src/models/info.dart @@ -58,10 +58,10 @@ class PublicInfo extends PersonalInfo with Snowflake { @override final int id; - /// The username for the registration request, this data is only available from the admin endpoint. + /// The username information, this data is only available from the admin endpoint. String? username; - /// The hashed password for the registration request, this data is only available from the admin endpoint. + /// The hashed password information, this data is only available from the admin endpoint. String? hashedPassword; /// Constructs a [PublicInfo] object with the given [id], [name], [room], [birthday], [phone], and [email]. diff --git a/app/resident_manager/lib/src/core/models/reg_request.dart b/app/resident_manager/lib/src/models/reg_request.dart similarity index 75% rename from app/resident_manager/lib/src/core/models/reg_request.dart rename to app/resident_manager/lib/src/models/reg_request.dart index a3df578..4103ee8 100644 --- a/app/resident_manager/lib/src/core/models/reg_request.dart +++ b/app/resident_manager/lib/src/models/reg_request.dart @@ -43,27 +43,18 @@ class RegisterRequest extends PublicInfo { return result; } - final params = {"offset": offset.toString()}; - if (id != null) { - params["id"] = id.toString(); - } - if (name != null && name.isNotEmpty) { - params["name"] = name; - } - if (room != null) { - params["room"] = room.toString(); - } - if (username != null && username.isNotEmpty) { - params["username"] = username; - } - if (orderBy != null && orderBy.isNotEmpty) { - params["order_by"] = orderBy; - } - if (ascending != null) { - params["ascending"] = ascending.toString(); - } - - final response = await state.get("/api/v1/admin/reg-request", queryParameters: params); + final response = await state.get( + "/api/v1/admin/reg-request", + queryParameters: { + "offset": offset.toString(), + if (id != null) "id": id.toString(), + if (name != null && name.isNotEmpty) "name": name, + if (room != null) "room": room.toString(), + if (username != null && username.isNotEmpty) "username": username, + if (orderBy != null && orderBy.isNotEmpty) "order_by": orderBy, + if (ascending != null) "ascending": ascending.toString(), + }, + ); if (response.statusCode == 200) { final data = json.decode(utf8.decode(response.bodyBytes)) as List; result.addAll(data.map(RegisterRequest.fromJson)); @@ -122,8 +113,22 @@ class RegisterRequest extends PublicInfo { } /// Count the number of registration requests. - static Future count({required ApplicationState state}) async { - final response = await state.get("/api/v1/admin/reg-request/count"); + static Future count({ + required ApplicationState state, + int? id, + String? name, + int? room, + String? username, + }) async { + final response = await state.get( + "/api/v1/admin/reg-request/count", + queryParameters: { + if (id != null) "id": id.toString(), + if (name != null && name.isNotEmpty) "name": name, + if (room != null) "room": room.toString(), + if (username != null && username.isNotEmpty) "username": username, + }, + ); if (response.statusCode == 200) { return json.decode(utf8.decode(response.bodyBytes)); } diff --git a/app/resident_manager/lib/src/models/residents.dart b/app/resident_manager/lib/src/models/residents.dart new file mode 100644 index 0000000..8c670cb --- /dev/null +++ b/app/resident_manager/lib/src/models/residents.dart @@ -0,0 +1,102 @@ +import "dart:convert"; + +import "info.dart"; +import "snowflake.dart"; +import "../state.dart"; + +/// Represents a resident. +class Resident extends PublicInfo { + /// Constructs a [Resident] object with the given [id], [name], [room], [birthday], [phone], and [email]. + Resident({ + required super.id, + required super.name, + required super.room, + super.birthday, + super.phone, + super.email, + super.username, + super.hashedPassword, + }); + + Resident.fromJson(super.data) : super.fromJson(); + + Map toJson() => { + "id": id, + "name": name, + "room": room, + "birthday": birthday?.toIso8601String(), + "phone": phone, + "email": email, + }; + + static Future delete({ + required ApplicationState state, + required Iterable objects, + }) async { + final headers = {"content-type": "application/json"}; + final data = List>.from(objects.map((o) => {"id": o.id})); + + final response = await state.post("/api/v1/admin/residents/delete", headers: headers, body: json.encode(data)); + return response.statusCode == 204; + } + + static Future> query({ + required ApplicationState state, + required int offset, + int? id, + String? name, + int? room, + String? username, + String? orderBy, + bool? ascending, + }) async { + final result = []; + final authorization = state.authorization; + if (authorization == null) { + return result; + } + + final response = await state.get( + "/api/v1/admin/residents", + queryParameters: { + "offset": offset.toString(), + if (id != null) "id": id.toString(), + if (name != null && name.isNotEmpty) "name": name, + if (room != null) "room": room.toString(), + if (username != null && username.isNotEmpty) "username": username, + if (orderBy != null && orderBy.isNotEmpty) "order_by": orderBy, + if (ascending != null) "ascending": ascending.toString(), + }, + ); + if (response.statusCode == 200) { + final data = json.decode(utf8.decode(response.bodyBytes)) as List; + result.addAll(data.map(Resident.fromJson)); + } + + return result; + } + + /// Count the number of residents. + static Future count({ + required ApplicationState state, + int? id, + String? name, + int? room, + String? username, + }) async { + final response = await state.get( + "/api/v1/admin/residents/count", + queryParameters: { + if (id != null) "id": id.toString(), + if (name != null && name.isNotEmpty) "name": name, + if (room != null) "room": room.toString(), + if (username != null && username.isNotEmpty) "username": username, + }, + ); + if (response.statusCode == 200) { + return json.decode(utf8.decode(response.bodyBytes)); + } + + return null; + } +} diff --git a/app/resident_manager/lib/src/core/models/rooms.dart b/app/resident_manager/lib/src/models/rooms.dart similarity index 53% rename from app/resident_manager/lib/src/core/models/rooms.dart rename to app/resident_manager/lib/src/models/rooms.dart index f685bd2..1d09828 100644 --- a/app/resident_manager/lib/src/core/models/rooms.dart +++ b/app/resident_manager/lib/src/models/rooms.dart @@ -2,24 +2,41 @@ import "dart:convert"; import "../state.dart"; -class Room { +class RoomData { final int room; final double area; final int motorbike; final int car; + RoomData({ + required this.room, + required this.area, + required this.motorbike, + required this.car, + }); +} + +class Room { + final int room; + final double? area; + final int? motorbike; + final int? car; + final int residents; + Room({ required this.room, required this.area, required this.motorbike, required this.car, + required this.residents, }); Room.fromJson(dynamic data) : room = data["room"], area = data["area"], motorbike = data["motorbike"], - car = data["car"]; + car = data["car"], + residents = data["residents"]; int get floor => room ~/ 100; @@ -35,15 +52,14 @@ class Room { return result; } - final params = {"offset": offset.toString()}; - if (room != null) { - params["room"] = room.toString(); - } - if (floor != null) { - params["floor"] = floor.toString(); - } - - final response = await state.get("/api/v1/admin/rooms", queryParameters: params); + final response = await state.get( + "/api/v1/admin/rooms", + queryParameters: { + "offset": offset.toString(), + if (room != null) "room": room.toString(), + if (floor != null) "floor": floor.toString(), + }, + ); if (response.statusCode == 200) { final data = json.decode(utf8.decode(response.bodyBytes)) as List; @@ -54,8 +70,18 @@ class Room { } /// Count the number of rooms. - static Future count({required ApplicationState state}) async { - final response = await state.get("/api/v1/admin/rooms/count"); + static Future count({ + required ApplicationState state, + int? room, + int? floor, + }) async { + final response = await state.get( + "/api/v1/admin/rooms/count", + queryParameters: { + if (room != null) "room": room.toString(), + if (floor != null) "floor": floor.toString(), + }, + ); if (response.statusCode == 200) { return json.decode(utf8.decode(response.bodyBytes)); } diff --git a/app/resident_manager/lib/src/core/models/snowflake.dart b/app/resident_manager/lib/src/models/snowflake.dart similarity index 86% rename from app/resident_manager/lib/src/core/models/snowflake.dart rename to app/resident_manager/lib/src/models/snowflake.dart index 20557a5..199f69d 100644 --- a/app/resident_manager/lib/src/core/models/snowflake.dart +++ b/app/resident_manager/lib/src/models/snowflake.dart @@ -1,4 +1,4 @@ -import "../../utils.dart"; +import "../utils.dart"; mixin Snowflake { /// The model's unique ID. diff --git a/app/resident_manager/lib/src/routes.dart b/app/resident_manager/lib/src/routes.dart index 4ccf13d..ae8bdcb 100644 --- a/app/resident_manager/lib/src/routes.dart +++ b/app/resident_manager/lib/src/routes.dart @@ -4,4 +4,5 @@ class ApplicationRoute { static const String home = "/home"; static const String adminRegisterQueue = "/admin/register-queue"; static const String adminResidentsPage = "/admin/residents"; + static const String adminRoomsPage = "/admin/rooms"; } diff --git a/app/resident_manager/lib/src/core/state.dart b/app/resident_manager/lib/src/state.dart similarity index 82% rename from app/resident_manager/lib/src/core/state.dart rename to app/resident_manager/lib/src/state.dart index b1471d9..346f026 100644 --- a/app/resident_manager/lib/src/core/state.dart +++ b/app/resident_manager/lib/src/state.dart @@ -43,14 +43,16 @@ class _Authorization extends PublicAuthorization { ); final result = response.statusCode < 400; + if (result) { + if (!isAdmin) { + resident = Resident.fromJson(json.decode(utf8.decode(response.bodyBytes))); + } - if (result && remember) { - final data = json.decode(utf8.decode(response.bodyBytes)); - resident = Resident.fromJson(data); - - await _withLoginFile((file) => file.writeAsString(json.encode(toJson()))); - } else { - await removeAuthData(); + if (remember) { + await _withLoginFile((file) => file.writeAsString(json.encode(toJson()))); + } else { + await removeAuthData(); + } } return result; @@ -59,8 +61,9 @@ class _Authorization extends PublicAuthorization { Future removeAuthData() async { await _withLoginFile( (file) async { - // Do not change to `await file.exists()`: https://github.com/flutter/flutter/issues/75249 - if (file.existsSync()) { + // `await file.exists()` hang in flutter test: https://github.com/flutter/flutter/issues/75249 + final exists = Platform.environment.containsKey("FLUTTER_TEST") ? file.existsSync() : await file.exists(); + if (exists) { await file.delete(); } }, @@ -84,8 +87,9 @@ class _Authorization extends PublicAuthorization { static Future<_Authorization?> prepare({required ApplicationState state}) async { final auth = await _withLoginFile( (file) async { - // Do not change to `await file.exists()`: https://github.com/flutter/flutter/issues/75249 - if (file.existsSync()) { + // `await file.exists()` hang in flutter test: https://github.com/flutter/flutter/issues/75249 + final exists = Platform.environment.containsKey("FLUTTER_TEST") ? file.existsSync() : await file.exists(); + if (exists) { final data = json.decode(await file.readAsString()); final username = data["username"]; final password = data["password"]; @@ -110,17 +114,20 @@ class _Authorization extends PublicAuthorization { } class ApplicationState { - static final Uri baseUrl = kDebugMode ? Uri.http("localhost:8000") : Uri.https("resident-manager.azurewebsites.net"); + static final baseUrl = kDebugMode ? Uri.http("localhost:8000") : Uri.https("resident-manager.azurewebsites.net"); final HTTPClient http; - final FlutterLocalization localization = FlutterLocalization.instance; - final List _onTranslationCallbacks = []; + final localization = FlutterLocalization.instance; + final _onTranslationCallbacks = []; + final extras = {}; _Authorization? _authorization; PublicAuthorization? get authorization => _authorization; ApplicationState({Client? client}) : http = HTTPClient(client: client ?? Client()) { + print("Using base API URL $baseUrl"); // ignore: avoid_print + localization.init( mapLocales: [ const MapLocale("en", AppLocale.EN), diff --git a/app/resident_manager/lib/src/core/translations.dart b/app/resident_manager/lib/src/translations.dart similarity index 86% rename from app/resident_manager/lib/src/core/translations.dart rename to app/resident_manager/lib/src/translations.dart index 6b5295b..bc7a8b3 100644 --- a/app/resident_manager/lib/src/core/translations.dart +++ b/app/resident_manager/lib/src/translations.dart @@ -49,8 +49,19 @@ class AppLocale { static const String Area1 = "Area1"; static const String MotorbikesCount = "MotorbikesCount"; static const String CarsCount = "CarsCount"; + static const String ResidentsCount = "ResidentsCount"; static const String ClearAll = "ClearAll"; static const String RememberMe = "RememberMe"; + static const String Floor = "Floor"; + static const String RoomsList = "RoomsList"; + static const String Confirm = "Confirm"; + static const String Cancel = "Cancel"; + static const String Option = "Option"; + static const String DeleteAccount = "DeleteAccount"; + static const String Welcome = "Welcome"; + static const String PersonalInfo = "PersonalInfo"; + static const String Settings = "Settings"; + static const String ComingSoon = "ComingSoon"; static const Map EN = { Login: "Login", @@ -103,8 +114,19 @@ class AppLocale { Area1: "Area", MotorbikesCount: "Motorbikes count", CarsCount: "Cars count", + ResidentsCount: "Residents count", ClearAll: "Clear all", RememberMe: "Remember me", + Floor: "Floor", + RoomsList: "Rooms list", + Confirm: "Confirm", + Cancel: "Cancel", + Option: "Option", + DeleteAccount: "Delete account", + Welcome: "Welcome", + PersonalInfo: "Personal information", + Settings: "Settings", + ComingSoon: "Coming soon", }; static const Map VI = { @@ -158,7 +180,18 @@ class AppLocale { Area1: "Diện tích", MotorbikesCount: "Số lượng xe máy", CarsCount: "Số lượng ô tô", + ResidentsCount: "Số lượng cư dân", ClearAll: "Xóa tất cả", RememberMe: "Ghi nhớ thông tin đăng nhập", + Floor: "Tầng", + RoomsList: "Danh sách phòng", + Confirm: "Xác nhận", + Cancel: "Hủy bỏ", + Option: "Tùy chọn", + DeleteAccount: "Xóa tài khoản", + Welcome: "Chào mừng", + PersonalInfo: "Thông tin cá nhân", + Settings: "Cài đặt", + ComingSoon: "Sắp ra mắt", }; } diff --git a/app/resident_manager/lib/src/utils.dart b/app/resident_manager/lib/src/utils.dart index 0d48338..e6e80fd 100644 --- a/app/resident_manager/lib/src/utils.dart +++ b/app/resident_manager/lib/src/utils.dart @@ -4,8 +4,17 @@ import "package:flutter/material.dart"; import "package:flutter_localization/flutter_localization.dart"; import "package:fluttertoast/fluttertoast.dart"; -import "core/config.dart"; -import "core/translations.dart"; +import "config.dart"; +import "translations.dart"; + +/// Screen width breakpoints from https://getbootstrap.com/docs/5.0/layout/breakpoints/ +class ScreenWidth { + static const SMALL = 576; + static const MEDIUM = 768; + static const LARGE = 992; + static const EXTRA_LARGE = 1200; + static const EXTRA_EXTRA_LARGE = 1400; +} Future showToastSafe({ required String msg, diff --git a/app/resident_manager/lib/src/widgets/admin/reg_queue.dart b/app/resident_manager/lib/src/widgets/admin/reg_queue.dart index ef0ebec..35de8e8 100644 --- a/app/resident_manager/lib/src/widgets/admin/reg_queue.dart +++ b/app/resident_manager/lib/src/widgets/admin/reg_queue.dart @@ -9,12 +9,12 @@ import "package:flutter_localization/flutter_localization.dart"; import "../common.dart"; import "../state.dart"; import "../utils.dart"; +import "../../config.dart"; +import "../../state.dart"; +import "../../translations.dart"; import "../../utils.dart"; -import "../../core/config.dart"; -import "../../core/state.dart"; -import "../../core/translations.dart"; -import "../../core/models/reg_request.dart"; -import "../../core/models/snowflake.dart"; +import "../../models/reg_request.dart"; +import "../../models/snowflake.dart"; class RegisterQueuePage extends StateAwareWidget { const RegisterQueuePage({super.key, required super.state}); @@ -27,10 +27,10 @@ class RegisterQueuePageState extends AbstractCommonState with List _requests = []; Future? _queryFuture; - Future? _countFuture; + Future? _countFuture; Widget _notification = const SizedBox.square(dimension: 0); - final _selectedRequests = {}; + final _selected = {}; final _actionLock = Lock(); final _nameSearch = TextEditingController(); @@ -63,7 +63,7 @@ class RegisterQueuePageState extends AbstractCommonState with var success = false; try { - success = await coro(state: state, objects: _selectedRequests); + success = await coro(state: state, objects: _selected); } catch (e) { if (e is SocketException || e is TimeoutException) { await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); @@ -74,7 +74,7 @@ class RegisterQueuePageState extends AbstractCommonState with if (success) { _notification = const SizedBox.square(dimension: 0); - _selectedRequests.clear(); + _selected.clear(); offset = 0; } else { _notification = TranslatedText( @@ -88,7 +88,7 @@ class RegisterQueuePageState extends AbstractCommonState with ); } - Future queryRegistrationRequests() async { + Future query() async { try { _requests = await RegisterRequest.query( state: state, @@ -102,27 +102,49 @@ class RegisterQueuePageState extends AbstractCommonState with refresh(); return true; - } catch (_) { - await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); - return false; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + return false; + } + + rethrow; } } + Future count() async { + try { + final value = await RegisterRequest.count( + state: state, + name: _nameSearch.text, + room: int.tryParse(_roomSearch.text), + username: _usernameSearch.text, + ); + if (value == null) { + _offsetLimit = offset; + return false; + } + + _offsetLimit = (value + DB_PAGINATION_QUERY - 1) ~/ DB_PAGINATION_QUERY - 1; + return true; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + _offsetLimit = offset; + return false; + } + + rethrow; + } + } + + final _horizontalController = ScrollController(); + @override Scaffold buildScaffold(BuildContext context) { final mediaQuery = MediaQuery.of(context); - _queryFuture ??= queryRegistrationRequests(); - _countFuture ??= RegisterRequest.count(state: state).then( - (value) { - if (value != null) { - _offsetLimit = (value + DB_PAGINATION_QUERY - 1) ~/ DB_PAGINATION_QUERY - 1; - } else { - _offsetLimit = offset; - } - - return _offsetLimit; - }, - ); + _queryFuture ??= query(); + _countFuture ??= count(); return Scaffold( key: scaffoldKey, @@ -157,7 +179,7 @@ class RegisterQueuePageState extends AbstractCommonState with case ConnectionState.done: final success = snapshot.data ?? false; if (success) { - TableCell header(String text, [String? newOrderBy]) { + TableCell headerCeil(String text, [String? newOrderBy]) { if (newOrderBy != null) { if (orderBy == newOrderBy) { text += ascending ? " ▴" : " ▾"; @@ -188,26 +210,19 @@ class RegisterQueuePageState extends AbstractCommonState with ); } - TableCell row(String text) => TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(text), - ), - ); - final rows = [ TableRow( decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), children: [ TableCell( child: Checkbox.adaptive( - value: _selectedRequests.containsAll(_requests), + value: _selected.containsAll(_requests), onChanged: (state) { if (state != null) { if (state) { - _selectedRequests.addAll(_requests); + _selected.addAll(_requests); } else { - _selectedRequests.removeAll(_requests); + _selected.removeAll(_requests); } } @@ -215,13 +230,13 @@ class RegisterQueuePageState extends AbstractCommonState with }, ), ), - header(AppLocale.Fullname.getString(context), "name"), - header(AppLocale.Room.getString(context), "room"), - header(AppLocale.DateOfBirth.getString(context)), - header(AppLocale.Phone.getString(context)), - header(AppLocale.Email.getString(context)), - header(AppLocale.CreationTime.getString(context), "request_id"), - header(AppLocale.Username.getString(context), "username"), + headerCeil(AppLocale.Fullname.getString(context), "name"), + headerCeil(AppLocale.Room.getString(context), "room"), + headerCeil(AppLocale.DateOfBirth.getString(context)), + headerCeil(AppLocale.Phone.getString(context)), + headerCeil(AppLocale.Email.getString(context)), + headerCeil(AppLocale.CreationTime.getString(context), "request_id"), + headerCeil(AppLocale.Username.getString(context), "username"), ], ), ]; @@ -231,26 +246,61 @@ class RegisterQueuePageState extends AbstractCommonState with TableRow( children: [ Checkbox.adaptive( - value: _selectedRequests.contains(request), + value: _selected.contains(request), onChanged: (state) { if (state != null) { if (state) { - _selectedRequests.add(request); + _selected.add(request); } else { - _selectedRequests.remove(request); + _selected.remove(request); } } refresh(); }, ), - row(request.name), - row(request.room.toString()), - row(request.birthday?.toLocal().formatDate() ?? "---"), - row(request.phone ?? "---"), - row(request.email ?? "---"), - row(request.createdAt.toLocal().toString()), - row(request.username ?? "---"), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(request.name), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(request.room.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(request.birthday?.toLocal().formatDate() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(request.phone ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(request.email ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(request.createdAt.toLocal().toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(request.username ?? "---"), + ), + ), ], ), ); @@ -263,13 +313,13 @@ class RegisterQueuePageState extends AbstractCommonState with children: [ TextButton.icon( icon: const Icon(Icons.done_outlined), - label: Text("${AppLocale.Approve.getString(context)} (${_selectedRequests.length})"), - onPressed: _actionLock.locked ? null : () => _approveOrReject(RegisterRequest.approve), + label: Text("${AppLocale.Approve.getString(context)} (${_selected.length})"), + onPressed: _actionLock.locked || _selected.isEmpty ? null : () => _approveOrReject(RegisterRequest.approve), ), TextButton.icon( icon: const Icon(Icons.close_outlined), - label: Text("${AppLocale.Reject.getString(context)} (${_selectedRequests.length})"), - onPressed: _actionLock.locked ? null : () => _approveOrReject(RegisterRequest.reject), + label: Text("${AppLocale.Reject.getString(context)} (${_selected.length})"), + onPressed: _actionLock.locked || _selected.isEmpty ? null : () => _approveOrReject(RegisterRequest.reject), ), ], ), @@ -315,7 +365,10 @@ class RegisterQueuePageState extends AbstractCommonState with style: TextStyle(decoration: searching ? TextDecoration.underline : null), ), onPressed: () async { - await showDialog( + final nameSearch = _nameSearch.text; + final roomSearch = _roomSearch.text; + final usernameSearch = _usernameSearch.text; + final submitted = await showDialog( context: context, builder: (context) => SimpleDialog( title: Text(AppLocale.Search.getString(context)), @@ -334,7 +387,7 @@ class RegisterQueuePageState extends AbstractCommonState with label: Text(AppLocale.Fullname.getString(context)), ), onFieldSubmitted: (_) { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, validator: (value) => nameValidator(context, required: false, value: value), @@ -348,7 +401,7 @@ class RegisterQueuePageState extends AbstractCommonState with label: Text(AppLocale.Room.getString(context)), ), onFieldSubmitted: (_) { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, validator: (value) => roomValidator(context, required: false, value: value), @@ -358,11 +411,11 @@ class RegisterQueuePageState extends AbstractCommonState with controller: _usernameSearch, decoration: InputDecoration( contentPadding: const EdgeInsets.all(8.0), - icon: const Icon(Icons.person_outline), + icon: const Icon(Icons.person_outlined), label: Text(AppLocale.Username.getString(context)), ), onFieldSubmitted: (_) { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, ), @@ -375,7 +428,7 @@ class RegisterQueuePageState extends AbstractCommonState with icon: const Icon(Icons.done_outlined), label: Text(AppLocale.Search.getString(context)), onPressed: () { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, ), @@ -389,7 +442,7 @@ class RegisterQueuePageState extends AbstractCommonState with _roomSearch.clear(); _usernameSearch.clear(); - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, ), @@ -403,6 +456,13 @@ class RegisterQueuePageState extends AbstractCommonState with ], ), ); + + if (submitted == null) { + // Dialog dismissed. Restore field values + _nameSearch.text = nameSearch; + _roomSearch.text = roomSearch; + _usernameSearch.text = usernameSearch; + } }, ), ], @@ -411,14 +471,19 @@ class RegisterQueuePageState extends AbstractCommonState with _notification, const SizedBox.square(dimension: 5), Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + child: Scrollbar( + controller: _horizontalController, + thumbVisibility: true, child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Container( - width: max(mediaQuery.size.width, 1000), - padding: const EdgeInsets.all(5), - child: Table(children: rows), + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Container( + width: max(mediaQuery.size.width, 1000), + padding: const EdgeInsets.all(5), + child: Table(children: rows), + ), ), ), ), diff --git a/app/resident_manager/lib/src/widgets/admin/residents.dart b/app/resident_manager/lib/src/widgets/admin/residents.dart index b529b50..95eba1e 100644 --- a/app/resident_manager/lib/src/widgets/admin/residents.dart +++ b/app/resident_manager/lib/src/widgets/admin/residents.dart @@ -1,14 +1,19 @@ +import "dart:async"; +import "dart:io"; import "dart:math"; +import "package:async_locks/async_locks.dart"; import "package:flutter/material.dart"; import "package:flutter_localization/flutter_localization.dart"; import "../common.dart"; import "../state.dart"; +import "../utils.dart"; +import "../../config.dart"; +import "../../translations.dart"; import "../../utils.dart"; -import "../../core/config.dart"; -import "../../core/translations.dart"; -import "../../core/models/residents.dart"; +import "../../models/residents.dart"; +import "../../models/rooms.dart"; class ResidentsPage extends StateAwareWidget { const ResidentsPage({super.key, required super.state}); @@ -21,7 +26,11 @@ class ResidentsPageState extends AbstractCommonState with CommonS List _residents = []; Future? _queryFuture; - Future? _countFuture; + Future? _countFuture; + Widget _notification = const SizedBox.square(dimension: 0); + + final _selected = {}; + final _actionLock = Lock(); final _nameSearch = TextEditingController(); final _roomSearch = TextEditingController(); @@ -41,7 +50,7 @@ class ResidentsPageState extends AbstractCommonState with CommonS bool get searching => _nameSearch.text.isNotEmpty || _roomSearch.text.isNotEmpty || _usernameSearch.text.isNotEmpty; - Future queryResidents() async { + Future query() async { try { _residents = await Resident.query( state: state, @@ -55,27 +64,97 @@ class ResidentsPageState extends AbstractCommonState with CommonS refresh(); return true; - } catch (_) { - await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); - return false; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + return false; + } + + rethrow; } } - @override - Scaffold buildScaffold(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - _queryFuture ??= queryResidents(); - _countFuture ??= Resident.count(state: state).then( - (value) { - if (value != null) { - _offsetLimit = (value + DB_PAGINATION_QUERY - 1) ~/ DB_PAGINATION_QUERY - 1; - } else { - _offsetLimit = offset; + Future count() async { + try { + final value = await Resident.count( + state: state, + name: _nameSearch.text, + room: int.tryParse(_roomSearch.text), + username: _usernameSearch.text, + ); + if (value == null) { + _offsetLimit = offset; + return false; + } + + _offsetLimit = (value + DB_PAGINATION_QUERY - 1) ~/ DB_PAGINATION_QUERY - 1; + return true; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + _offsetLimit = offset; + return false; + } + + rethrow; + } + } + + Future _deleteAccounts() async { + await _actionLock.run( + () async { + _notification = TranslatedText( + (ctx) => AppLocale.Loading.getString(ctx), + state: state, + style: const TextStyle(color: Colors.blue), + ); + refresh(); + + var success = false; + try { + success = await Resident.delete(state: state, objects: _selected); + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + } else { + rethrow; + } } - return _offsetLimit; + if (success) { + _notification = const SizedBox.square(dimension: 0); + _selected.clear(); + offset = 0; + } else { + _notification = TranslatedText( + (ctx) => AppLocale.UnknownError.getString(ctx), + state: state, + style: const TextStyle(color: Colors.red), + ); + refresh(); + } }, ); + } + + @override + void initState() { + final room = state.extras["room-search"] as Room?; + if (room != null) { + _roomSearch.text = room.room.toString(); + state.extras["room-search"] = null; + } + + super.initState(); + } + + final _horizontalController = ScrollController(); + + @override + Scaffold buildScaffold(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + _queryFuture ??= query(); + _countFuture ??= count(); return Scaffold( key: scaffoldKey, @@ -111,7 +190,7 @@ class ResidentsPageState extends AbstractCommonState with CommonS case ConnectionState.done: final success = snapshot.data ?? false; if (success) { - TableCell header(String text, [String? newOrderBy]) { + TableCell headerCeil(String text, [String? newOrderBy]) { if (newOrderBy != null) { if (orderBy == newOrderBy) { text += ascending ? " ▴" : " ▾"; @@ -142,23 +221,34 @@ class ResidentsPageState extends AbstractCommonState with CommonS ); } - TableCell row(String text) => TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(text), - ), - ); - final rows = [ TableRow( decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), children: [ - header(AppLocale.Fullname.getString(context), "name"), - header(AppLocale.Room.getString(context), "room"), - header(AppLocale.DateOfBirth.getString(context)), - header(AppLocale.Phone.getString(context)), - header(AppLocale.Email.getString(context)), - header(AppLocale.CreationTime.getString(context), "resident_id"), + TableCell( + child: Checkbox.adaptive( + value: _selected.containsAll(_residents), + onChanged: (state) { + if (state != null) { + if (state) { + _selected.addAll(_residents); + } else { + _selected.removeAll(_residents); + } + } + + refresh(); + }, + ), + ), + headerCeil(AppLocale.Fullname.getString(context), "name"), + headerCeil(AppLocale.Room.getString(context), "room"), + headerCeil(AppLocale.DateOfBirth.getString(context)), + headerCeil(AppLocale.Phone.getString(context)), + headerCeil(AppLocale.Email.getString(context)), + headerCeil(AppLocale.CreationTime.getString(context), "resident_id"), + headerCeil(AppLocale.Username.getString(context), "username"), + headerCeil(AppLocale.Option.getString(context)), ], ), ]; @@ -167,12 +257,78 @@ class ResidentsPageState extends AbstractCommonState with CommonS rows.add( TableRow( children: [ - row(resident.name), - row(resident.room.toString()), - row(resident.birthday?.toLocal().formatDate() ?? "---"), - row(resident.phone ?? "---"), - row(resident.email ?? "---"), - row(resident.createdAt.toLocal().toString()), + Checkbox.adaptive( + value: _selected.contains(resident), + onChanged: (state) { + if (state != null) { + if (state) { + _selected.add(resident); + } else { + _selected.remove(resident); + } + } + + refresh(); + }, + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.name), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.room.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.birthday?.toLocal().formatDate() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.phone ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.email ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.createdAt.toLocal().toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.username ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [ + IconButton( + color: Colors.red, + icon: const Icon(Icons.edit_outlined), + onPressed: () { + // TODO: Implement this + }, + ), + ], + ), + ), + ) ], ), ); @@ -180,6 +336,17 @@ class ResidentsPageState extends AbstractCommonState with CommonS return Column( children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + icon: const Icon(Icons.delete_outlined), + label: Text("${AppLocale.DeleteAccount.getString(context)} (${_selected.length})"), + onPressed: _actionLock.locked || _selected.isEmpty ? null : _deleteAccounts, + ), + ], + ), + const SizedBox.square(dimension: 10), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -221,7 +388,10 @@ class ResidentsPageState extends AbstractCommonState with CommonS style: TextStyle(decoration: searching ? TextDecoration.underline : null), ), onPressed: () async { - await showDialog( + final nameSearch = _nameSearch.text; + final roomSearch = _roomSearch.text; + final usernameSearch = _usernameSearch.text; + final submitted = await showDialog( context: context, builder: (context) => SimpleDialog( title: Text(AppLocale.Search.getString(context)), @@ -240,7 +410,7 @@ class ResidentsPageState extends AbstractCommonState with CommonS label: Text(AppLocale.Fullname.getString(context)), ), onFieldSubmitted: (_) { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, validator: (value) => nameValidator(context, required: false, value: value), @@ -254,7 +424,7 @@ class ResidentsPageState extends AbstractCommonState with CommonS label: Text(AppLocale.Room.getString(context)), ), onFieldSubmitted: (_) { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, validator: (value) => roomValidator(context, required: false, value: value), @@ -264,11 +434,11 @@ class ResidentsPageState extends AbstractCommonState with CommonS controller: _usernameSearch, decoration: InputDecoration( contentPadding: const EdgeInsets.all(8.0), - icon: const Icon(Icons.person_outline), + icon: const Icon(Icons.person_outlined), label: Text(AppLocale.Username.getString(context)), ), onFieldSubmitted: (_) { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, ), @@ -281,7 +451,7 @@ class ResidentsPageState extends AbstractCommonState with CommonS icon: const Icon(Icons.done_outlined), label: Text(AppLocale.Search.getString(context)), onPressed: () { - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, ), @@ -295,7 +465,7 @@ class ResidentsPageState extends AbstractCommonState with CommonS _roomSearch.clear(); _usernameSearch.clear(); - Navigator.pop(context); + Navigator.pop(context, true); offset = 0; }, ), @@ -309,20 +479,34 @@ class ResidentsPageState extends AbstractCommonState with CommonS ], ), ); + + if (submitted == null) { + // Dialog dismissed. Restore field values + _nameSearch.text = nameSearch; + _roomSearch.text = roomSearch; + _usernameSearch.text = usernameSearch; + } }, ), ], ), const SizedBox.square(dimension: 5), + _notification, + const SizedBox.square(dimension: 5), Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + child: Scrollbar( + controller: _horizontalController, + thumbVisibility: true, child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Container( - width: max(mediaQuery.size.width, 1000), - padding: const EdgeInsets.all(5), - child: Table(children: rows), + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Container( + width: max(mediaQuery.size.width, 1000), + padding: const EdgeInsets.all(5), + child: Table(children: rows), + ), ), ), ), diff --git a/app/resident_manager/lib/src/widgets/admin/rooms.dart b/app/resident_manager/lib/src/widgets/admin/rooms.dart new file mode 100644 index 0000000..8d9faa0 --- /dev/null +++ b/app/resident_manager/lib/src/widgets/admin/rooms.dart @@ -0,0 +1,387 @@ +import "dart:async"; +import "dart:io"; +import "dart:math"; + +import "package:flutter/material.dart"; +import "package:flutter_localization/flutter_localization.dart"; + +import "../common.dart"; +import "../state.dart"; +import "../utils.dart"; +import "../../config.dart"; +import "../../routes.dart"; +import "../../translations.dart"; +import "../../utils.dart"; +import "../../models/rooms.dart"; + +class RoomsPage extends StateAwareWidget { + const RoomsPage({super.key, required super.state}); + + @override + RoomsPageState createState() => RoomsPageState(); +} + +class RoomsPageState extends AbstractCommonState with CommonStateMixin { + List _rooms = []; + + Future? _queryFuture; + Future? _countFuture; + + final _roomSearch = TextEditingController(); + final _floorSearch = TextEditingController(); + + int _offset = 0; + int _offsetLimit = 0; + int get offset => _offset; + set offset(int value) { + _offset = value; + _queryFuture = null; + _countFuture = null; + refresh(); + } + + bool get searching => _roomSearch.text.isNotEmpty || _floorSearch.text.isNotEmpty; + + Future query() async { + try { + _rooms = await Room.query( + state: state, + offset: DB_PAGINATION_QUERY * offset, + room: int.tryParse(_roomSearch.text), + floor: int.tryParse(_floorSearch.text), + ); + + refresh(); + return true; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + return false; + } + + rethrow; + } + } + + Future count() async { + try { + final value = await Room.count( + state: state, + room: int.tryParse(_roomSearch.text), + floor: int.tryParse(_floorSearch.text), + ); + if (value == null) { + _offsetLimit = offset; + return false; + } + + _offsetLimit = (value + DB_PAGINATION_QUERY - 1) ~/ DB_PAGINATION_QUERY - 1; + return true; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + _offsetLimit = offset; + return false; + } + + rethrow; + } + } + + final _horizontalController = ScrollController(); + + @override + Scaffold buildScaffold(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + _queryFuture ??= query(); + _countFuture ??= count(); + + return Scaffold( + key: scaffoldKey, + appBar: AppBar( + backgroundColor: Colors.blue, + leading: IconButton( + onPressed: openDrawer, + icon: const Icon(Icons.menu_outlined), + ), + title: Text(AppLocale.RoomsList.getString(context)), + ), + body: FutureBuilder( + future: _queryFuture, + builder: (context, snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.none: + case ConnectionState.waiting: + case ConnectionState.active: + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox.square( + dimension: 50, + child: CircularProgressIndicator(), + ), + const SizedBox.square(dimension: 5), + Text(AppLocale.Loading.getString(context)), + ], + ), + ); + + case ConnectionState.done: + final success = snapshot.data ?? false; + if (success) { + TableCell headerCeil(String text) { + return TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ); + } + + final rows = [ + TableRow( + decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), + children: [ + headerCeil(AppLocale.Room.getString(context)), + headerCeil(AppLocale.Floor.getString(context)), + headerCeil(AppLocale.Area1.getString(context)), + headerCeil(AppLocale.MotorbikesCount.getString(context)), + headerCeil(AppLocale.CarsCount.getString(context)), + headerCeil(AppLocale.ResidentsCount.getString(context)), + headerCeil(AppLocale.Search.getString(context)), + ], + ), + ]; + + for (final room in _rooms) { + rows.add( + TableRow( + children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.room.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.floor.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.area?.toString() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.motorbike?.toString() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.car?.toString() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.residents.toString()), + ), + ), + TableCell( + child: HoverContainer( + onHover: Colors.grey.shade200, + child: GestureDetector( + onTap: () async { + state.extras["room-search"] = room; + await Navigator.pushReplacementNamed(context, ApplicationRoute.adminResidentsPage); + }, + child: const Padding( + padding: EdgeInsets.all(5), + child: Text("→"), + ), + ), + ), + ), + ], + ), + ); + } + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left_outlined), + onPressed: () { + if (offset > 0) { + offset--; + } + refresh(); + }, + ), + FutureBuilder( + future: _countFuture, + builder: (context, _) { + return Text("${offset + 1}/${max(_offset, _offsetLimit) + 1}"); + }, + ), + IconButton( + icon: const Icon(Icons.chevron_right_outlined), + onPressed: () { + if (_offset < _offsetLimit) { + offset++; + } + refresh(); + }, + ), + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () { + offset = 0; + refresh(); + }, + ), + TextButton.icon( + icon: const Icon(Icons.search_outlined), + label: Text( + searching ? AppLocale.Searching.getString(context) : AppLocale.Search.getString(context), + style: TextStyle(decoration: searching ? TextDecoration.underline : null), + ), + onPressed: () async { + final roomSearch = _roomSearch.text; + final floorSearch = _floorSearch.text; + final submitted = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text(AppLocale.Search.getString(context)), + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: Form( + child: Column( + children: [ + TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + controller: _roomSearch, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.room_outlined), + label: Text(AppLocale.Room.getString(context)), + ), + onFieldSubmitted: (_) { + Navigator.pop(context, true); + offset = 0; + }, + validator: (value) => roomValidator(context, required: false, value: value), + ), + TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + controller: _floorSearch, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.apartment_outlined), + label: Text(AppLocale.Floor.getString(context)), + ), + onFieldSubmitted: (_) { + Navigator.pop(context, true); + offset = 0; + }, + ), + const SizedBox.square(dimension: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.done_outlined), + label: Text(AppLocale.Search.getString(context)), + onPressed: () { + Navigator.pop(context, true); + offset = 0; + }, + ), + ), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.clear_outlined), + label: Text(AppLocale.ClearAll.getString(context)), + onPressed: () { + _roomSearch.clear(); + _floorSearch.clear(); + + Navigator.pop(context, true); + offset = 0; + }, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + + if (submitted == null) { + // Dialog dismissed. Restore field values + _roomSearch.text = roomSearch; + _floorSearch.text = floorSearch; + } + }, + ), + ], + ), + const SizedBox.square(dimension: 5), + Expanded( + child: Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Container( + width: max(mediaQuery.size.width, 1000), + padding: const EdgeInsets.all(5), + child: Table(children: rows), + ), + ), + ), + ), + ), + ], + ); + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox.square( + dimension: 50, + child: Icon(Icons.highlight_off_outlined), + ), + const SizedBox.square(dimension: 5), + Text(AppLocale.ConnectionError.getString(context)), + ], + ), + ); + } + }, + ), + drawer: createDrawer(context), + ); + } +} diff --git a/app/resident_manager/lib/src/widgets/common.dart b/app/resident_manager/lib/src/widgets/common.dart index fa0b9d4..a867b96 100644 --- a/app/resident_manager/lib/src/widgets/common.dart +++ b/app/resident_manager/lib/src/widgets/common.dart @@ -4,8 +4,8 @@ import "package:meta/meta.dart"; import "state.dart"; import "../routes.dart"; -import "../core/state.dart"; -import "../core/translations.dart"; +import "../state.dart"; +import "../translations.dart"; abstract class AbstractCommonState extends State { ApplicationState get state => widget.state; @@ -108,6 +108,19 @@ mixin CommonStateMixin on AbstractCommonState { ApplicationRoute.adminResidentsPage, ), ), + ListTile( + leading: const Icon(Icons.room_outlined), + title: Text( + AppLocale.RoomsList.getString(context), + style: currentRoute == ApplicationRoute.adminRoomsPage ? const TextStyle(color: Colors.blue) : null, + ), + onTap: () => currentRoute == ApplicationRoute.adminRoomsPage + ? Navigator.pop(context) + : Navigator.pushReplacementNamed( + context, + ApplicationRoute.adminRoomsPage, + ), + ), ], ); } else { diff --git a/app/resident_manager/lib/src/widgets/home.dart b/app/resident_manager/lib/src/widgets/home.dart index 886531c..b417df4 100644 --- a/app/resident_manager/lib/src/widgets/home.dart +++ b/app/resident_manager/lib/src/widgets/home.dart @@ -1,9 +1,12 @@ +import "dart:math"; + import "package:flutter/material.dart"; import "package:flutter_localization/flutter_localization.dart"; +import "package:resident_manager/src/utils.dart"; import "common.dart"; import "state.dart"; -import "../core/translations.dart"; +import "../translations.dart"; class HomePage extends StateAwareWidget { const HomePage({super.key, required super.state}); @@ -15,6 +18,8 @@ class HomePage extends StateAwareWidget { class HomePageState extends AbstractCommonState with CommonStateMixin { @override Scaffold buildScaffold(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + return Scaffold( key: scaffoldKey, appBar: AppBar( @@ -24,6 +29,104 @@ class HomePageState extends AbstractCommonState with CommonStateMixin< ), title: Text(AppLocale.Home.getString(context)), ), + body: Padding( + padding: const EdgeInsets.all(5), + child: Column( + children: [ + LayoutBuilder( + builder: (context, constraints) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + image: DecorationImage( + colorFilter: ColorFilter.mode(Colors.black.withOpacity(0.5), BlendMode.darken), + image: const AssetImage("assets/landscape.png"), + fit: BoxFit.cover, + ), + ), + height: min(0.5 * mediaQuery.size.height, 9 / 16 * constraints.maxWidth), + width: constraints.maxWidth, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + // Alignment hack: https://stackoverflow.com/a/54174185 + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Builder( + builder: (context) { + return Text( + "${AppLocale.Welcome.getString(context)}, ${state.authorization?.resident?.name ?? "---"}!", + style: TextStyle( + color: Colors.white, + fontSize: mediaQuery.size.width < ScreenWidth.SMALL ? 24 : 48, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + ); + }, + ), + const SizedBox.square(dimension: 10), + Builder( + builder: (context) { + final items = [ + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.person_outlined), + label: Text(AppLocale.PersonalInfo.getString(context)), + onPressed: () { + // TODO: Implement this + }, + ), + ), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.settings_outlined), + label: Text(AppLocale.Settings.getString(context)), + onPressed: () { + // TODO: Implement this + }, + ), + ), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.construction_outlined), + label: Text(AppLocale.ComingSoon.getString(context)), + onPressed: null, + ), + ), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.construction_outlined), + label: Text(AppLocale.ComingSoon.getString(context)), + onPressed: null, + ), + ), + ]; + + final itemsPerRow = mediaQuery.size.width < ScreenWidth.SMALL + ? 1 + : mediaQuery.size.width < ScreenWidth.MEDIUM + ? 2 + : 4; + + return Column( + children: [ + for (var i = 0; i < items.length; i += itemsPerRow) Row(children: items.sublist(i, i + itemsPerRow)), + ], + ); + }, + ), + ], + ), + ), drawer: createDrawer(context), ); } diff --git a/app/resident_manager/lib/src/widgets/login.dart b/app/resident_manager/lib/src/widgets/login.dart index 0ec4681..db062d4 100644 --- a/app/resident_manager/lib/src/widgets/login.dart +++ b/app/resident_manager/lib/src/widgets/login.dart @@ -9,8 +9,8 @@ import "common.dart"; import "state.dart"; import "utils.dart"; import "../routes.dart"; +import "../translations.dart"; import "../utils.dart"; -import "../core/translations.dart"; class LoginPage extends StateAwareWidget { const LoginPage({super.key, required super.state}); @@ -57,9 +57,9 @@ class LoginPageState extends AbstractCommonState with CommonStateMixi refresh(); return; - } else { - rethrow; } + + rethrow; } if (authorized) { diff --git a/app/resident_manager/lib/src/widgets/register.dart b/app/resident_manager/lib/src/widgets/register.dart index 81a90e6..0303103 100644 --- a/app/resident_manager/lib/src/widgets/register.dart +++ b/app/resident_manager/lib/src/widgets/register.dart @@ -8,11 +8,11 @@ import "package:flutter_localization/flutter_localization.dart"; import "common.dart"; import "state.dart"; import "utils.dart"; +import "../translations.dart"; import "../utils.dart"; -import "../core/translations.dart"; -import "../core/models/auth.dart"; -import "../core/models/info.dart"; -import "../core/models/reg_request.dart"; +import "../models/auth.dart"; +import "../models/info.dart"; +import "../models/reg_request.dart"; class RegisterPage extends StateAwareWidget { const RegisterPage({super.key, required super.state}); diff --git a/app/resident_manager/lib/src/widgets/state.dart b/app/resident_manager/lib/src/widgets/state.dart index 9673b20..a6930ee 100644 --- a/app/resident_manager/lib/src/widgets/state.dart +++ b/app/resident_manager/lib/src/widgets/state.dart @@ -1,6 +1,6 @@ import "package:flutter/material.dart"; -import "../core/state.dart"; +import "../state.dart"; abstract class StateAwareWidget extends StatefulWidget { final ApplicationState state; diff --git a/app/resident_manager/lib/src/widgets/utils.dart b/app/resident_manager/lib/src/widgets/utils.dart index 4716128..0dc9080 100644 --- a/app/resident_manager/lib/src/widgets/utils.dart +++ b/app/resident_manager/lib/src/widgets/utils.dart @@ -60,3 +60,31 @@ class TranslatedTextState extends AbstractCommonState { selectionColor: widget.selectionColor, ); } + +class HoverContainer extends StatefulWidget { + final Color onHover; + final Widget child; + + const HoverContainer({super.key, required this.onHover, required this.child}); + + @override + HoverContainerState createState() => HoverContainerState(); +} + +class HoverContainerState extends State { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + decoration: BoxDecoration( + color: _hovered ? widget.onHover : null, + ), + child: widget.child, + ), + ); + } +} diff --git a/app/resident_manager/pubspec.lock b/app/resident_manager/pubspec.lock index 176078c..ed8ec33 100644 --- a/app/resident_manager/pubspec.lock +++ b/app/resident_manager/pubspec.lock @@ -198,6 +198,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -245,6 +250,11 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.8" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -277,6 +287,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: transitive description: @@ -461,6 +476,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" pub_semver: dependency: transitive description: @@ -570,6 +593,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -598,10 +629,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_android: dependency: transitive description: @@ -690,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/app/resident_manager/pubspec.yaml b/app/resident_manager/pubspec.yaml index 6fdea87..57ed086 100644 --- a/app/resident_manager/pubspec.yaml +++ b/app/resident_manager/pubspec.yaml @@ -39,7 +39,7 @@ dependencies: meta: ^1.15.0 path: ^1.9.0 path_provider: ^2.1.4 - url_launcher: ^6.3.0 + url_launcher: ^6.3.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -49,6 +49,9 @@ dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter + flutter_launcher_icons: ^0.14.1 mockito: ^5.4.4 diff --git a/app/resident_manager/test/widget_test.dart b/app/resident_manager/test/widget_test.dart index 86a65de..7d18c8d 100644 --- a/app/resident_manager/test/widget_test.dart +++ b/app/resident_manager/test/widget_test.dart @@ -1,49 +1,14 @@ -import "dart:convert"; -import "dart:io"; - import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_test/flutter_test.dart"; import "package:http/http.dart"; import "package:http/testing.dart"; -import "package:pinenacl/x25519.dart"; import "package:resident_manager/main.dart"; -import "package:resident_manager/src/core/state.dart"; -import "package:resident_manager/src/widgets/admin/reg_queue.dart"; - -final serverKey = PrivateKey.fromSeed(base64.decode(Platform.environment["PRIVATE_KEY_SEED"]!)); -const adminUsername = "admin"; -const adminPassword = "password"; +import "package:resident_manager/src/state.dart"; final client = MockClient( (request) async { // print("Received request $request (path: ${request.url.path})"); - if (request.method == "POST") { - if (request.url.path == "/api/v1/admin/login") { - final username = request.headers["username"]; - final encrypted = request.headers["encrypted"]; - final pkey = request.headers["pkey"]; - - if (username == null || encrypted == null || pkey == null) { - return Response("", 422); - } - - if (username != adminUsername) { - return Response("", 403); - } - - // https://github.com/ilap/pinenacl-dart/blob/master/example/box.dart - final box = Box(myPrivateKey: serverKey, theirPublicKey: PublicKey(base64.decode(pkey))); - final password = utf8.decode(box.decrypt(EncryptedMessage.fromList(base64.decode(encrypted)))); - - if (password != adminPassword) { - return Response("", 403); - } - - return Response("", 204); - } - } else if (request.method == "GET") {} - return Response("Not found", 404); }, ); @@ -53,13 +18,13 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( const MethodChannel("plugins.flutter.io/path_provider"), (MethodCall methodCall) async { - return ".test"; + return "/tmp"; }, ); testWidgets( "Drawer open", - (WidgetTester tester) async { + (tester) async { final state = ApplicationState(client: client); await state.prepare(); @@ -82,28 +47,4 @@ void main() { expect(find.image(const AssetImage("assets/flags/vi.png")), findsOneWidget); }, ); - - testWidgets( - "Administrator login", - (WidgetTester tester) async { - final state = ApplicationState(client: client); - await state.prepare(); - - await tester.pumpWidget(MainApplication(state: state)); - await tester.pumpAndSettle(); - - // Authorization fields - final fields = find.byWidgetPredicate((widget) => widget is TextField); - expect(fields, findsExactly(2)); - - await tester.enterText(fields.at(0), adminUsername); - await tester.enterText(fields.at(1), adminPassword); - - // Press the "Login as admin" button - await tester.tap(find.byIcon(Icons.admin_panel_settings_outlined)); - await tester.pumpAndSettle(); - - expect(find.byWidgetPredicate((widget) => widget is RegisterQueuePage), findsOneWidget); - }, - ); } diff --git a/dev-requirements.txt b/dev-requirements.txt index 80c301d..dadab5b 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,8 +6,8 @@ certifi==2024.8.30 cffi==1.17.1 click==8.1.7 colorama==0.4.6 -coverage==7.6.1 -dnspython==2.6.1 +coverage==7.6.2 +dnspython==2.7.0 email_validator==2.2.0 exceptiongroup==1.2.2 fastapi==0.115.0 @@ -21,7 +21,7 @@ idna==3.10 iniconfig==2.0.0 Jinja2==3.1.4 markdown-it-py==3.0.0 -MarkupSafe==2.1.5 +MarkupSafe==3.0.1 mccabe==0.7.0 mdurl==0.1.2 mypy==1.11.2 @@ -40,14 +40,14 @@ pytest==8.3.3 python-dotenv==1.0.1 python-multipart==0.0.12 PyYAML==6.0.2 -rich==13.9.1 +rich==13.9.2 shellingham==1.5.4 sniffio==1.3.1 starlette==0.38.5 tomli==2.0.2 typer==0.12.5 typing_extensions==4.12.2 -uvicorn==0.31.0 +uvicorn==0.31.1 uvloop==0.20.0 watchfiles==0.24.0 websockets==13.1 diff --git a/main.py b/main.py index 25de1e1..e1d77ad 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,19 @@ from __future__ import annotations import asyncio +import sys from contextlib import AbstractAsyncContextManager from types import TracebackType from typing import Final, Optional, Type, TYPE_CHECKING from fastapi import FastAPI from fastapi.responses import RedirectResponse + +try: + import coverage # dev-dependency only +except ImportError: + pass + try: import uvloop # type: ignore except ImportError: @@ -14,18 +21,25 @@ else: asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -from server import api_v1, Database +from server import api_v1, CI, Database class ApplicationLifespan(AbstractAsyncContextManager): - __slots__ = ("app",) + __slots__ = ("app", "cov") if TYPE_CHECKING: app: Final[FastAPI] + cov: Optional[coverage.Coverage] def __init__(self, app: FastAPI) -> None: self.app = app + self.cov = None + if CI: + self.cov = coverage.Coverage(data_suffix=True, config_file=True) + self.cov.start() + print("Measuring code coverage...", file=sys.stderr) + async def __aenter__(self) -> None: await Database.instance.prepare() @@ -36,6 +50,10 @@ async def __aexit__( exc_tb: Optional[TracebackType], ) -> bool: await Database.instance.close() + if self.cov is not None: + self.cov.stop() + self.cov.save() + return True @@ -55,6 +73,12 @@ async def root() -> RedirectResponse: return RedirectResponse("/api/v1/docs") +@app.get("/loop", include_in_schema=False) +async def loop() -> str: + """Return current asyncio event loop""" + return str(asyncio.get_event_loop()) + + @app.get("/docs", include_in_schema=False) async def docs() -> RedirectResponse: """Redirect to API documentation""" diff --git a/requirements.txt b/requirements.txt index cf8c324..4ed0b2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ certifi==2024.8.30 cffi==1.17.1 click==8.1.7 colorama==0.4.6 -dnspython==2.6.1 +dnspython==2.7.0 email_validator==2.2.0 exceptiongroup==1.2.2 fastapi==0.115.0 @@ -17,7 +17,7 @@ httpx==0.27.2 idna==3.10 Jinja2==3.1.4 markdown-it-py==3.0.0 -MarkupSafe==2.1.5 +MarkupSafe==3.0.1 mdurl==0.1.2 pycparser==2.22 pydantic==2.9.2 @@ -28,13 +28,13 @@ pyodbc==5.1.0 python-dotenv==1.0.1 python-multipart==0.0.12 PyYAML==6.0.2 -rich==13.9.1 +rich==13.9.2 shellingham==1.5.4 sniffio==1.3.1 starlette==0.38.5 typer==0.12.5 typing_extensions==4.12.2 -uvicorn==0.31.0 +uvicorn==0.31.1 uvloop==0.20.0 watchfiles==0.24.0 websockets==13.1 diff --git a/scripts/odbc.sh b/scripts/odbc.sh old mode 100644 new mode 100755 diff --git a/server/config.py b/server/config.py index 64f5928..8870c44 100644 --- a/server/config.py +++ b/server/config.py @@ -6,6 +6,7 @@ __all__ = ( + "CI", "ODBC_CONNECTION_STRING", "VNPAY_TMN_CODE", "VNPAY_SECRET_KEY", @@ -18,6 +19,7 @@ ) +CI = bool("CI" in os.environ) ODBC_CONNECTION_STRING = os.environ["ODBC_CONNECTION_STRING"] VNPAY_TMN_CODE = os.environ["VNPAY_TMN_CODE"] VNPAY_SECRET_KEY = os.environ["VNPAY_SECRET_KEY"] diff --git a/server/database.py b/server/database.py index 8534f82..c884c3a 100644 --- a/server/database.py +++ b/server/database.py @@ -63,7 +63,7 @@ async def prepare(self) -> None: async with pool.acquire() as connection: async with connection.cursor() as cursor: await cursor.execute(""" - IF NOT EXISTS (SELECT * FROM sysobjects WHERE name = 'residents' AND type = 'U') + IF NOT EXISTS (SELECT 1 FROM sysobjects WHERE name = 'residents' AND type = 'U') CREATE TABLE residents ( resident_id BIGINT PRIMARY KEY, name NVARCHAR(255) COLLATE Vietnamese_100_CS_AS_KS_WS_SC_UTF8 NOT NULL, @@ -76,7 +76,7 @@ async def prepare(self) -> None: ) """) await cursor.execute(""" - IF NOT EXISTS (SELECT * FROM sysobjects WHERE name = 'register_queue' AND type = 'U') + IF NOT EXISTS (SELECT 1 FROM sysobjects WHERE name = 'register_queue' AND type = 'U') CREATE TABLE register_queue ( request_id BIGINT PRIMARY KEY, name NVARCHAR(255) COLLATE Vietnamese_100_CS_AS_KS_WS_SC_UTF8 NOT NULL, @@ -90,7 +90,7 @@ async def prepare(self) -> None: """) await cursor.execute( """ - IF NOT EXISTS (SELECT * FROM sysobjects WHERE name = 'config' AND type = 'U') + IF NOT EXISTS (SELECT 1 FROM sysobjects WHERE name = 'config' AND type = 'U') BEGIN CREATE TABLE config ( name NVARCHAR(255) PRIMARY KEY, @@ -106,7 +106,7 @@ async def prepare(self) -> None: # Fee lower, upper = [VND] * 100 await cursor.execute(""" - IF NOT EXISTS (SELECT * FROM sysobjects WHERE name = 'fee' AND type = 'U') + IF NOT EXISTS (SELECT 1 FROM sysobjects WHERE name = 'fee' AND type = 'U') CREATE TABLE fee ( fee_id BIGINT PRIMARY KEY, name NVARCHAR(255) COLLATE Vietnamese_100_CS_AS_KS_WS_SC_UTF8 NOT NULL, @@ -121,7 +121,7 @@ async def prepare(self) -> None: # Room area = [area in m2] * 100 await cursor.execute(""" - IF NOT EXISTS (SELECT * FROM sysobjects WHERE name = 'rooms' AND type = 'U') + IF NOT EXISTS (SELECT 1 FROM sysobjects WHERE name = 'rooms' AND type = 'U') CREATE TABLE rooms ( room SMALLINT PRIMARY KEY, area INT NOT NULL, @@ -132,7 +132,7 @@ async def prepare(self) -> None: # Payment amount = [VND] * 100 await cursor.execute(""" - IF NOT EXISTS (SELECT * FROM sysobjects WHERE name = 'payments' AND type = 'U') + IF NOT EXISTS (SELECT 1 FROM sysobjects WHERE name = 'payments' AND type = 'U') CREATE TABLE payments ( payment_id BIGINT PRIMARY KEY, room SMALLINT NOT NULL, diff --git a/server/models/reg_request.py b/server/models/reg_request.py index cd360d1..b66552c 100644 --- a/server/models/reg_request.py +++ b/server/models/reg_request.py @@ -46,10 +46,48 @@ def from_row(cls, row: Any) -> RegisterRequest: ) @staticmethod - async def count() -> int: + async def count( + *, + id: Optional[int] = None, + name: Optional[str] = None, + room: Optional[int] = None, + username: Optional[str] = None, + ) -> int: + where: List[str] = [] + params: List[Any] = [] + + if id is not None: + where.append("request_id = ?") + params.append(id) + + if name is not None: + if not validate_name(name): + return 0 + + where.append("CHARINDEX(?, name) > 0") + params.append(name) + + if room is not None: + if not validate_room(room): + return 0 + + where.append("room = ?") + params.append(room) + + if username is not None: + if not validate_username(username): + return 0 + + where.append("username = ?") + params.append(username) + + query = ["SELECT COUNT(request_id) FROM register_queue"] + if len(where) > 0: + query.append("WHERE " + " AND ".join(where)) + async with Database.instance.pool.acquire() as connection: async with connection.cursor() as cursor: - await cursor.execute("SELECT COUNT(*) FROM register_queue") + await cursor.execute("\n".join(query), *params) return await cursor.fetchval() @classmethod @@ -155,9 +193,9 @@ async def create( await cursor.execute( """ IF NOT EXISTS ( - SELECT username FROM residents WHERE username = ? + SELECT 1 FROM residents WHERE username = ? UNION - SELECT username FROM register_queue WHERE username = ? + SELECT 1 FROM register_queue WHERE username = ? ) INSERT INTO register_queue OUTPUT INSERTED.* VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, diff --git a/server/models/residents.py b/server/models/residents.py index b627973..401ad47 100644 --- a/server/models/residents.py +++ b/server/models/residents.py @@ -8,6 +8,7 @@ from ..config import DB_PAGINATION_QUERY from ..database import Database from ..utils import ( + validate_name, validate_room, validate_username, ) @@ -103,8 +104,46 @@ async def delete_many(cls, objects: List[Snowflake]) -> None: await cursor.execute(f"DELETE FROM residents WHERE resident_id IN ({temp_fmt})", *[o.id for o in objects]) @staticmethod - async def count() -> int: + async def count( + *, + id: Optional[int] = None, + name: Optional[str] = None, + room: Optional[int] = None, + username: Optional[str] = None, + ) -> int: + where: List[str] = [] + params: List[Any] = [] + + if id is not None: + where.append("resident_id = ?") + params.append(id) + + if name is not None: + if not validate_name(name): + return 0 + + where.append("CHARINDEX(?, name) > 0") + params.append(name) + + if room is not None: + if not validate_room(room): + return 0 + + where.append("room = ?") + params.append(room) + + if username is not None: + if not validate_username(username): + return 0 + + where.append("username = ?") + params.append(username) + + query = ["SELECT COUNT(resident_id) FROM residents"] + if len(where) > 0: + query.append("WHERE " + " AND ".join(where)) + async with Database.instance.pool.acquire() as connection: async with connection.cursor() as cursor: - await cursor.execute("SELECT COUNT(*) FROM residents") + await cursor.execute("\n".join(query), *params) return await cursor.fetchval() diff --git a/server/models/rooms.py b/server/models/rooms.py index 06999da..4a875c8 100644 --- a/server/models/rooms.py +++ b/server/models/rooms.py @@ -8,34 +8,143 @@ from ..database import Database -__all__ = ("Room",) +__all__ = ("RoomData", "Room") -class Room(pydantic.BaseModel): +class RoomData(pydantic.BaseModel): """Data model for objects holding room information. Each object of this class corresponds to a database row. """ - room: int area: float motorbike: int car: int + @staticmethod + async def update_many(rooms: List[RoomData]) -> None: + """This function is a coroutine. + + Update room information in the database. + + Parameters + ----- + rooms: `List[RoomData]` + A list of room information objects to update. + """ + if len(rooms) == 0: + return + + async with Database.instance.pool.acquire() as connection: + async with connection.cursor() as cursor: + params = [ + ( + room.room, + int(100 * room.area), room.motorbike, room.car, + room.room, + room.room, int(100 * room.area), room.motorbike, room.car, + ) for room in rooms + ] + + # https://github.com/aio-libs/aioodbc/issues/423 + cursor._impl.fast_executemany = True + await cursor.executemany( + """ + IF EXISTS (SELECT 1 FROM rooms WHERE room = ?) + UPDATE rooms + SET area = ?, motorbike = ?, car = ? + WHERE room = ? + ELSE + INSERT INTO rooms + VALUES (?, ?, ?, ?) + """, + params, + ) + + @staticmethod + async def delete_many(rooms: List[int]) -> None: + """This function is a coroutine. + + Delete room information from the database. + + Parameters + ----- + rooms: `List[int]` + A list of room numbers to delete. + """ + if len(rooms) == 0: + return + + temp_fmt = ", ".join("?" for _ in rooms) + async with Database.instance.pool.acquire() as connection: + async with connection.cursor() as cursor: + await cursor.executemany( + f"DELETE FROM rooms WHERE room IN {temp_fmt}", + *rooms, + ) + + +class Room(pydantic.BaseModel): + """Data model for objects holding room information. + + Each object of this class does not correspond to a database row, but instead corresponds to + a record obtained from a JOIN query. + """ + + room: int + area: Optional[float] + motorbike: Optional[int] + car: Optional[int] + residents: int + @classmethod def from_row(cls, row: Any) -> Room: return cls( room=row[0], - area=row[1] / 100, - motorbike=row[2], - car=row[3], + area=None if row[1] is None else row[1] / 100, + motorbike=None if row[2] is None else row[2], + car=None if row[3] is None else row[3], + residents=row[4], ) @staticmethod - async def count() -> int: + async def count( + *, + room: Optional[int] = None, + floor: Optional[int] = None, + ) -> int: + where: List[str] = [] + params: List[Any] = [] + + if room is not None: + where.append("room = ?") + params.append(room) + + if floor is not None: + where.append("room / 100 = ?") + params.append(floor) + + query = [ + """ + WITH rooms_union AS ( + SELECT r1.room FROM rooms r1 + UNION ALL + SELECT DISTINCT r2.room FROM residents r2 + WHERE NOT EXISTS ( + SELECT 1 + FROM rooms + WHERE rooms.room = r2.room + ) + ) + SELECT count(room) FROM rooms_union + """, + ] + if len(where) > 0: + query.append("WHERE " + " AND ".join(where)) + async with Database.instance.pool.acquire() as connection: async with connection.cursor() as cursor: - await cursor.execute("SELECT COUNT(*) FROM rooms") + await cursor.execute("\n".join(query), *params) return await cursor.fetchval() @classmethod @@ -76,75 +185,33 @@ async def query( where.append("room / 100 = ?") params.append(floor) - query = ["SELECT * FROM rooms"] + query = [ + """ + WITH rooms_union AS ( + SELECT r1.room, r1.area, r1.motorbike, r1.car + FROM rooms r1 + UNION ALL + SELECT DISTINCT r2.room, NULL AS area, NULL AS motorbike, NULL AS car + FROM residents r2 + WHERE NOT EXISTS ( + SELECT 1 + FROM rooms + WHERE rooms.room = r2.room + ) + ) + SELECT ru.room, ru.area, ru.motorbike, ru.car, COUNT(residents.resident_id) AS residents + FROM rooms_union ru + LEFT JOIN residents ON ru.room = residents.room + GROUP BY ru.room, ru.area, ru.motorbike, ru.car + """, + ] + if len(where) > 0: query.append("WHERE " + " AND ".join(where)) - query.append("ORDER BY room DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY") + query.append("ORDER BY ru.room OFFSET ? ROWS FETCH NEXT ? ROWS ONLY") await cursor.execute("\n".join(query), *params, offset, DB_PAGINATION_QUERY) rows = await cursor.fetchall() return [cls.from_row(row) for row in rows] - - @staticmethod - async def update_many(rooms: List[Room]) -> None: - """This function is a coroutine. - - Update room information in the database. - - Parameters - ----- - rooms: `List[Room]` - A list of room information objects to update. - """ - if len(rooms) == 0: - return - - async with Database.instance.pool.acquire() as connection: - async with connection.cursor() as cursor: - params = [ - ( - room.room, - int(100 * room.area), room.motorbike, room.car, - room.room, int(100 * room.area), room.motorbike, room.car, - ) for room in rooms - ] - - # https://github.com/aio-libs/aioodbc/issues/423 - cursor._impl.fast_executemany = True - await cursor.executemany( - """ - IF EXISTS (SELECT * FROM rooms WHERE room = ?) - UPDATE rooms - SET area = ?, motorbike = ?, car = ? - WHERE room = ? - ELSE - INSERT INTO rooms - VALUES (?, ?, ?, ?) - """, - params, - ) - - @staticmethod - async def delete_many(rooms: List[int]) -> None: - """This function is a coroutine. - - Delete room information from the database. - - Parameters - ----- - rooms: `List[int]` - A list of room numbers to delete. - """ - if len(rooms) == 0: - return - - async with Database.instance.pool.acquire() as connection: - temp_fmt = ", ".join("?" for _ in rooms) - - async with connection.cursor() as cursor: - await cursor.executemany( - f"DELETE FROM rooms WHERE room IN {temp_fmt}", - *rooms, - ) diff --git a/server/routes/api/v1/admin/__init__.py b/server/routes/api/v1/admin/__init__.py index 40cd99d..5bafe3c 100644 --- a/server/routes/api/v1/admin/__init__.py +++ b/server/routes/api/v1/admin/__init__.py @@ -1,4 +1,3 @@ -from .delete import * from .login import * from .reg_request import * from .residents import * diff --git a/server/routes/api/v1/admin/reg_request/count.py b/server/routes/api/v1/admin/reg_request/count.py index e339d1d..655b28c 100644 --- a/server/routes/api/v1/admin/reg_request/count.py +++ b/server/routes/api/v1/admin/reg_request/count.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Optional + from fastapi import status from ......apps import api_v1 @@ -19,6 +21,12 @@ responses=register_error(AuthenticationRequired, PasswordDecryptionError), status_code=status.HTTP_200_OK, ) -async def admin_reg_request_count(headers: AuthorizationHeader) -> int: +async def admin_reg_request_count( + headers: AuthorizationHeader, + id: Optional[int] = None, + name: Optional[str] = None, + room: Optional[int] = None, + username: Optional[str] = None, +) -> int: await Database.instance.verify_admin(headers.username, headers.decrypt_password()) - return await RegisterRequest.count() + return await RegisterRequest.count(id=id, name=name, room=room, username=username) diff --git a/server/routes/api/v1/admin/residents/__init__.py b/server/routes/api/v1/admin/residents/__init__.py index f640c90..636328a 100644 --- a/server/routes/api/v1/admin/residents/__init__.py +++ b/server/routes/api/v1/admin/residents/__init__.py @@ -1,2 +1,3 @@ from .count import * +from .delete import * from .root import * diff --git a/server/routes/api/v1/admin/residents/count.py b/server/routes/api/v1/admin/residents/count.py index ac5717d..8c146fd 100644 --- a/server/routes/api/v1/admin/residents/count.py +++ b/server/routes/api/v1/admin/residents/count.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Optional + from fastapi import status from ......apps import api_v1 @@ -19,6 +21,12 @@ responses=register_error(AuthenticationRequired, PasswordDecryptionError), status_code=status.HTTP_200_OK, ) -async def admin_residents_count(headers: AuthorizationHeader) -> int: +async def admin_residents_count( + headers: AuthorizationHeader, + id: Optional[int] = None, + name: Optional[str] = None, + room: Optional[int] = None, + username: Optional[str] = None, +) -> int: await Database.instance.verify_admin(headers.username, headers.decrypt_password()) - return await Resident.count() + return await Resident.count(id=id, name=name, room=room, username=username) diff --git a/server/routes/api/v1/admin/delete.py b/server/routes/api/v1/admin/residents/delete.py similarity index 55% rename from server/routes/api/v1/admin/delete.py rename to server/routes/api/v1/admin/residents/delete.py index 18985da..542e4dd 100644 --- a/server/routes/api/v1/admin/delete.py +++ b/server/routes/api/v1/admin/residents/delete.py @@ -4,17 +4,17 @@ from fastapi import status -from .....apps import api_v1 -from .....database import Database -from .....errors import AuthenticationRequired, PasswordDecryptionError, register_error -from .....models import AuthorizationHeader, Resident, Snowflake +from ......apps import api_v1 +from ......database import Database +from ......errors import AuthenticationRequired, PasswordDecryptionError, register_error +from ......models import AuthorizationHeader, Resident, Snowflake -__all__ = ("admin_delete",) +__all__ = ("admin_residents_delete",) @api_v1.post( - "/admin/delete", + "/admin/residents/delete", name="Account deletion", description="Delete one or more resident accounts", tags=["admin"], @@ -22,6 +22,6 @@ responses=register_error(AuthenticationRequired, PasswordDecryptionError), status_code=status.HTTP_204_NO_CONTENT, ) -async def admin_delete(headers: AuthorizationHeader, objects: List[Snowflake]) -> None: +async def admin_residents_delete(headers: AuthorizationHeader, objects: List[Snowflake]) -> None: await Database.instance.verify_admin(headers.username, headers.decrypt_password()) await Resident.delete_many(objects) diff --git a/server/routes/api/v1/admin/rooms/count.py b/server/routes/api/v1/admin/rooms/count.py index 1f31d03..d36b161 100644 --- a/server/routes/api/v1/admin/rooms/count.py +++ b/server/routes/api/v1/admin/rooms/count.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Optional + from fastapi import status from ......apps import api_v1 @@ -19,6 +21,10 @@ responses=register_error(AuthenticationRequired, PasswordDecryptionError), status_code=status.HTTP_200_OK, ) -async def admin_rooms_count(headers: AuthorizationHeader) -> int: +async def admin_rooms_count( + headers: AuthorizationHeader, + room: Optional[int] = None, + floor: Optional[int] = None, +) -> int: await Database.instance.verify_admin(headers.username, headers.decrypt_password()) - return await Room.count() + return await Room.count(room=room, floor=floor) diff --git a/server/routes/api/v1/admin/rooms/delete.py b/server/routes/api/v1/admin/rooms/delete.py index aa7ba1c..4f967b8 100644 --- a/server/routes/api/v1/admin/rooms/delete.py +++ b/server/routes/api/v1/admin/rooms/delete.py @@ -7,7 +7,7 @@ from ......apps import api_v1 from ......database import Database from ......errors import AuthenticationRequired, PasswordDecryptionError, register_error -from ......models import AuthorizationHeader, Room +from ......models import AuthorizationHeader, RoomData __all__ = ("admin_rooms_delete",) @@ -27,4 +27,4 @@ async def admin_rooms_delete( rooms: List[int], ) -> None: await Database.instance.verify_admin(headers.username, headers.decrypt_password()) - await Room.delete_many(rooms) + await RoomData.delete_many(rooms) diff --git a/server/routes/api/v1/admin/rooms/update.py b/server/routes/api/v1/admin/rooms/update.py index aa1f615..c5794bd 100644 --- a/server/routes/api/v1/admin/rooms/update.py +++ b/server/routes/api/v1/admin/rooms/update.py @@ -7,7 +7,7 @@ from ......apps import api_v1 from ......database import Database from ......errors import AuthenticationRequired, PasswordDecryptionError, register_error -from ......models import AuthorizationHeader, Room +from ......models import AuthorizationHeader, RoomData __all__ = ("admin_rooms_update",) @@ -24,7 +24,7 @@ ) async def admin_rooms_update( headers: AuthorizationHeader, - rooms: List[Room], + rooms: List[RoomData], ) -> None: await Database.instance.verify_admin(headers.username, headers.decrypt_password()) - await Room.update_many(rooms) + await RoomData.update_many(rooms) diff --git a/setup.cfg b/setup.cfg index 9015591..4b972b3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,6 +8,8 @@ concurrency = multiprocessing, thread omit = scripts/* [coverage:report] +omit = + test_*.py exclude_also = if TYPE_CHECKING: def __repr__ diff --git a/test_main.py b/test_main.py index 818f78b..e4d74a7 100644 --- a/test_main.py +++ b/test_main.py @@ -165,7 +165,7 @@ def test_register_main_flow(get_client: TestClient) -> None: assert_match(data, name=name, room=room, birthday=birthday, phone=phone, email=email) response = get_client.post( - "/api/v1/admin/delete", + "/api/v1/admin/residents/delete", json=[data], headers=generate_auth_headers(username="admin", password=DEFAULT_ADMIN_PASSWORD).model_dump(), )