diff --git a/README.md b/README.md index 9b9d14f..484bc19 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,14 @@ volunteers/ - [X] Day Position assignment table - [X] Experience in the assignment table - [X] Experiment with a sliding drawer for the users -- [ ] Scores +- [X] Scores - [X] Hall manager positions - [X] Attendance - [X] Copy assignments from one day to another -- [ ] Year specific positions should be copied from the previous year with theirs scores saved -- [ ] Diploma generation -- [ ] csv export -- [ ] Reactive assignments broadcasts -- [ ] swap halls and positions so that positions are inside halls. -- [ ] Gender in regestration form +- [X] Year specific positions should be copied from the previous year with theirs scores saved +- [X] Diploma generation +- [X] csv export +- [X] Reactive assignments broadcasts +- [X] swap halls and positions so that positions are inside halls. +- [X] Gender in regestration form - [ ] Notifications by selecting users diff --git a/poetry.lock b/poetry.lock index 4612763..dd3b993 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiofiles" @@ -388,6 +388,18 @@ files = [ tests = ["pytest (>=3.2.1,!=3.3.0)"] typecheck = ["mypy"] +[[package]] +name = "bidict" +version = "0.23.1" +description = "The bidirectional mapping library for Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5"}, + {file = "bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71"}, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -1103,6 +1115,24 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + [[package]] name = "loguru" version = "0.7.3" @@ -1946,6 +1976,48 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-engineio" +version = "4.12.3" +description = "Engine.IO server and client for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1"}, + {file = "python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a"}, +] + +[package.dependencies] +simple-websocket = ">=0.10.0" + +[package.extras] +asyncio-client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] +docs = ["sphinx"] + +[[package]] +name = "python-socketio" +version = "5.15.0" +description = "Socket.IO server and client for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "python_socketio-5.15.0-py3-none-any.whl", hash = "sha256:e93363102f4da6d8e7a8872bf4908b866c40f070e716aa27132891e643e2687c"}, + {file = "python_socketio-5.15.0.tar.gz", hash = "sha256:d0403ababb59aa12fd5adcfc933a821113f27bd77761bc1c54aad2e3191a9b69"}, +] + +[package.dependencies] +bidict = ">=0.21.0" +python-engineio = ">=4.11.0" + +[package.extras] +asyncio-client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] +dev = ["tox"] +docs = ["sphinx"] + [[package]] name = "pyyaml" version = "6.0.2" @@ -2059,6 +2131,25 @@ files = [ {file = "ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6"}, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +description = "Simple WebSocket server and client for Python" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c"}, + {file = "simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4"}, +] + +[package.dependencies] +wsproto = "*" + +[package.extras] +dev = ["flake8", "pytest", "pytest-cov", "tox"] +docs = ["sphinx"] + [[package]] name = "sniffio" version = "1.3.1" @@ -2536,6 +2627,21 @@ files = [ [package.extras] dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] +[[package]] +name = "wsproto" +version = "1.3.2" +description = "Pure-Python WebSocket protocol implementation" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"}, + {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"}, +] + +[package.dependencies] +h11 = ">=0.16.0,<1" + [[package]] name = "yarl" version = "1.22.0" @@ -2684,4 +2790,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "dc3a392e5ac88028a0659f38bdac23d867358fc1401d4cb9f4a340bc06ccf1e8" +content-hash = "6e51ade57c465f1eaad847221ae8f1b0c7976a53f80789565971b11d9c5669b9" diff --git a/pyproject.toml b/pyproject.toml index 45c892e..b8a8130 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ bcrypt = "^4.3.0" babel = "^2.17.0" gunicorn = "^23.0.0" aiogram = "^3.22.0" +python-socketio = "^5.15.0" +jinja2 = "^3.1.4" [tool.poetry.group.dev.dependencies] ruff = "^0.11.7" @@ -103,8 +105,10 @@ inline-quotes = "double" [tool.pytest.ini_options] addopts = "-ra -q --strict-markers --cov=volunteers --cov-report=term-missing" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" filterwarnings = ["error"] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "xdist_group: marks tests for pytest-xdist grouping", ] testpaths = ["."] diff --git a/ui/package.json b/ui/package.json index 20f262a..1c9a8e8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -45,6 +45,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-i18next": "^15.7.3", + "socket.io-client": "^4.8.1", "yup": "^1.6.1" }, "devDependencies": { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index f1b8efa..528b1c2 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: react-i18next: specifier: ^15.7.3 version: 15.7.3(i18next@25.5.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 yup: specifier: ^1.6.1 version: 1.6.1 @@ -804,6 +807,9 @@ packages: cpu: [x64] os: [win32] + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@tanstack/history@1.115.0': resolution: {integrity: sha512-K7JJNrRVvyjAVnbXOH2XLRhFXDkeP54Kt2P4FR1Kl2KDGlIbkua5VqZQD2rot3qaDrpufyUa63nuLai1kOLTsQ==} engines: {node: '>=12'} @@ -1191,6 +1197,15 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -1246,6 +1261,13 @@ packages: electron-to-chromium@1.5.158: resolution: {integrity: sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ==} + engine.io-client@6.6.3: + resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + entities@6.0.0: resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} engines: {node: '>=0.12'} @@ -1790,6 +1812,14 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + socket.io-client@4.8.1: + resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + solid-js@1.9.7: resolution: {integrity: sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==} @@ -2037,6 +2067,18 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.2: resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} engines: {node: '>=10.0.0'} @@ -2056,6 +2098,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2649,6 +2695,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.41.1': optional: true + '@socket.io/component-emitter@3.1.2': {} + '@tanstack/history@1.115.0': {} '@tanstack/match-sorter-utils@8.19.4': @@ -3073,6 +3121,10 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.1: dependencies: ms: 2.1.3 @@ -3110,6 +3162,20 @@ snapshots: electron-to-chromium@1.5.158: {} + engine.io-client@6.6.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + entities@6.0.0: {} error-ex@1.3.2: @@ -3650,6 +3716,24 @@ snapshots: siginfo@2.0.0: {} + socket.io-client@4.8.1: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + engine.io-client: 6.6.3 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + solid-js@1.9.7: dependencies: csstype: 3.1.3 @@ -3865,12 +3949,16 @@ snapshots: wordwrap@1.0.0: {} + ws@8.17.1: {} + ws@8.18.2: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + yallist@3.1.1: {} yallist@4.0.0: {} diff --git a/ui/src/__tests__/registration.test.ts b/ui/src/__tests__/registration.test.ts new file mode 100644 index 0000000..869d9b4 --- /dev/null +++ b/ui/src/__tests__/registration.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { + requiredLabel, + shouldDisplayFieldError, +} from "@/routes/_logged-in/$yearId/registration"; + +describe("requiredLabel", () => { + test("appends an asterisk to label", () => { + expect(requiredLabel("Email")).toBe("Email *"); + }); +}); + +describe("shouldDisplayFieldError", () => { + test("hides errors when field untouched", () => { + expect(shouldDisplayFieldError("Required", false)).toBe(false); + expect(shouldDisplayFieldError("Required", undefined)).toBe(false); + }); + + test("hides errors when no error value", () => { + expect(shouldDisplayFieldError(undefined, true)).toBe(false); + expect(shouldDisplayFieldError(null, true)).toBe(false); + }); + + test("shows errors only when both error and touched", () => { + expect(shouldDisplayFieldError("Required", true)).toBe(true); + }); +}); diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 6f4e650..75f3a97 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-axios'; -import type { AddAssessmentApiV1AdminAssessmentAddPostData, AddAssessmentApiV1AdminAssessmentAddPostResponse, AddAssessmentApiV1AdminAssessmentAddPostError, EditAssessmentApiV1AdminAssessmentAssessmentIdEditPostData, EditAssessmentApiV1AdminAssessmentAssessmentIdEditPostError, DeleteAssessmentApiV1AdminAssessmentAssessmentIdDeleteData, DeleteAssessmentApiV1AdminAssessmentAssessmentIdDeleteResponse, DeleteAssessmentApiV1AdminAssessmentAssessmentIdDeleteError, GetUserDayAssessmentsApiV1AdminAssessmentUserDayUserDayIdGetData, GetUserDayAssessmentsApiV1AdminAssessmentUserDayUserDayIdGetResponse, GetUserDayAssessmentsApiV1AdminAssessmentUserDayUserDayIdGetError, GetYearDaysApiV1AdminDayYearYearIdGetData, GetYearDaysApiV1AdminDayYearYearIdGetResponse, GetYearDaysApiV1AdminDayYearYearIdGetError, AddDayApiV1AdminDayAddPostData, AddDayApiV1AdminDayAddPostResponse, AddDayApiV1AdminDayAddPostError, EditDayApiV1AdminDayDayIdEditPostData, EditDayApiV1AdminDayDayIdEditPostError, CopyAssignmentsApiV1AdminDayCopyAssignmentsPostData, CopyAssignmentsApiV1AdminDayCopyAssignmentsPostResponse, CopyAssignmentsApiV1AdminDayCopyAssignmentsPostError, AddHallApiV1AdminHallAddPostData, AddHallApiV1AdminHallAddPostResponse, AddHallApiV1AdminHallAddPostError, EditHallApiV1AdminHallHallIdEditPostData, EditHallApiV1AdminHallHallIdEditPostError, GetYearHallsApiV1AdminHallYearYearIdGetData, GetYearHallsApiV1AdminHallYearYearIdGetResponse, GetYearHallsApiV1AdminHallYearYearIdGetError, AddPositionApiV1AdminPositionAddPostData, AddPositionApiV1AdminPositionAddPostResponse, AddPositionApiV1AdminPositionAddPostError, EditPositionApiV1AdminPositionPositionIdEditPostData, EditPositionApiV1AdminPositionPositionIdEditPostError, GetAllUsersApiV1AdminUserGetData, GetAllUsersApiV1AdminUserGetResponse, GetUserByIdApiV1AdminUserUserIdGetData, GetUserByIdApiV1AdminUserUserIdGetResponse, GetUserByIdApiV1AdminUserUserIdGetError, EditUserApiV1AdminUserUserIdEditPostData, EditUserApiV1AdminUserUserIdEditPostResponse, EditUserApiV1AdminUserUserIdEditPostError, AddUserDayApiV1AdminUserDayAddPostData, AddUserDayApiV1AdminUserDayAddPostResponse, AddUserDayApiV1AdminUserDayAddPostError, EditPositionApiV1AdminUserDayUserDayIdEditPostData, EditPositionApiV1AdminUserDayUserDayIdEditPostError, DeleteUserDayApiV1AdminUserDayUserDayIdDeleteData, DeleteUserDayApiV1AdminUserDayUserDayIdDeleteResponse, DeleteUserDayApiV1AdminUserDayUserDayIdDeleteError, GetDayAssignmentsApiV1AdminUserDayDayDayIdAssignmentsGetData, GetDayAssignmentsApiV1AdminUserDayDayDayIdAssignmentsGetResponse, GetDayAssignmentsApiV1AdminUserDayDayDayIdAssignmentsGetError, AddYearApiV1AdminYearAddPostData, AddYearApiV1AdminYearAddPostResponse, AddYearApiV1AdminYearAddPostError, EditYearApiV1AdminYearYearIdEditPostData, EditYearApiV1AdminYearYearIdEditPostError, GetUsersListApiV1AdminYearYearIdUsersGetData, GetUsersListApiV1AdminYearYearIdUsersGetResponse, GetUsersListApiV1AdminYearYearIdUsersGetError, GetYearPositionsApiV1AdminYearYearIdPositionsGetData, GetYearPositionsApiV1AdminYearYearIdPositionsGetResponse, GetYearPositionsApiV1AdminYearYearIdPositionsGetError, GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetData, GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetResponse, GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetError, SaveDayAttendanceApiV1AttendanceSavePostData, SaveDayAttendanceApiV1AttendanceSavePostError, GetAllAttendanceApiV1AttendanceYearIdAllGetData, GetAllAttendanceApiV1AttendanceYearIdAllGetResponse, GetAllAttendanceApiV1AttendanceYearIdAllGetError, RegisterApiV1AuthTelegramRegisterPostData, RegisterApiV1AuthTelegramRegisterPostResponse, RegisterApiV1AuthTelegramRegisterPostError, MigrateApiV1AuthTelegramMigratePostData, MigrateApiV1AuthTelegramMigratePostResponse, MigrateApiV1AuthTelegramMigratePostError, LoginApiV1AuthTelegramLoginPostData, LoginApiV1AuthTelegramLoginPostResponse, LoginApiV1AuthTelegramLoginPostError, RefreshApiV1AuthRefreshPostData, RefreshApiV1AuthRefreshPostResponse, RefreshApiV1AuthRefreshPostError, MeApiV1AuthMeGetData, MeApiV1AuthMeGetResponse, UpdateUserApiV1AuthUpdatePostData, UpdateUserApiV1AuthUpdatePostResponse, UpdateUserApiV1AuthUpdatePostError, GetYearsApiV1YearGetData, GetYearsApiV1YearGetResponse, GetFormYearApiV1YearYearIdGetData, GetFormYearApiV1YearYearIdGetResponse, GetFormYearApiV1YearYearIdGetError, SaveFormYearApiV1YearYearIdPostData, SaveFormYearApiV1YearYearIdPostResponse, SaveFormYearApiV1YearYearIdPostError, GetDayAssignmentsApiV1YearYearIdDaysDayIdAssignmentsGetData, GetDayAssignmentsApiV1YearYearIdDaysDayIdAssignmentsGetResponse, GetDayAssignmentsApiV1YearYearIdDaysDayIdAssignmentsGetError, HealthCheckHcGetData, HealthCheckHcGetResponse, ProxyPathGetData, ProxyPathGetError } from './types.gen'; +import type { AddAssessmentApiV1AdminAssessmentAddPostData, AddAssessmentApiV1AdminAssessmentAddPostResponse, AddAssessmentApiV1AdminAssessmentAddPostError, EditAssessmentApiV1AdminAssessmentAssessmentIdEditPostData, EditAssessmentApiV1AdminAssessmentAssessmentIdEditPostError, DeleteAssessmentApiV1AdminAssessmentAssessmentIdDeleteData, DeleteAssessmentApiV1AdminAssessmentAssessmentIdDeleteResponse, DeleteAssessmentApiV1AdminAssessmentAssessmentIdDeleteError, GetUserDayAssessmentsApiV1AdminAssessmentUserDayUserDayIdGetData, GetUserDayAssessmentsApiV1AdminAssessmentUserDayUserDayIdGetResponse, GetUserDayAssessmentsApiV1AdminAssessmentUserDayUserDayIdGetError, GetYearDaysApiV1AdminDayYearYearIdGetData, GetYearDaysApiV1AdminDayYearYearIdGetResponse, GetYearDaysApiV1AdminDayYearYearIdGetError, AddDayApiV1AdminDayAddPostData, AddDayApiV1AdminDayAddPostResponse, AddDayApiV1AdminDayAddPostError, EditDayApiV1AdminDayDayIdEditPostData, EditDayApiV1AdminDayDayIdEditPostError, CopyAssignmentsApiV1AdminDayCopyAssignmentsPostData, CopyAssignmentsApiV1AdminDayCopyAssignmentsPostResponse, CopyAssignmentsApiV1AdminDayCopyAssignmentsPostError, AddHallApiV1AdminHallAddPostData, AddHallApiV1AdminHallAddPostResponse, AddHallApiV1AdminHallAddPostError, EditHallApiV1AdminHallHallIdEditPostData, EditHallApiV1AdminHallHallIdEditPostError, GetYearHallsApiV1AdminHallYearYearIdGetData, GetYearHallsApiV1AdminHallYearYearIdGetResponse, GetYearHallsApiV1AdminHallYearYearIdGetError, AddPositionApiV1AdminPositionAddPostData, AddPositionApiV1AdminPositionAddPostResponse, AddPositionApiV1AdminPositionAddPostError, EditPositionApiV1AdminPositionPositionIdEditPostData, EditPositionApiV1AdminPositionPositionIdEditPostError, ExportUsersCsvApiV1AdminUserExportCsvGetData, GetAllUsersApiV1AdminUserGetData, GetAllUsersApiV1AdminUserGetResponse, GetUserByIdApiV1AdminUserUserIdGetData, GetUserByIdApiV1AdminUserUserIdGetResponse, GetUserByIdApiV1AdminUserUserIdGetError, EditUserApiV1AdminUserUserIdEditPostData, EditUserApiV1AdminUserUserIdEditPostResponse, EditUserApiV1AdminUserUserIdEditPostError, AddUserDayApiV1AdminUserDayAddPostData, AddUserDayApiV1AdminUserDayAddPostResponse, AddUserDayApiV1AdminUserDayAddPostError, EditPositionApiV1AdminUserDayUserDayIdEditPostData, EditPositionApiV1AdminUserDayUserDayIdEditPostError, DeleteUserDayApiV1AdminUserDayUserDayIdDeleteData, DeleteUserDayApiV1AdminUserDayUserDayIdDeleteResponse, DeleteUserDayApiV1AdminUserDayUserDayIdDeleteError, GetDayAssignmentsApiV1AdminUserDayDayDayIdAssignmentsGetData, GetDayAssignmentsApiV1AdminUserDayDayDayIdAssignmentsGetResponse, GetDayAssignmentsApiV1AdminUserDayDayDayIdAssignmentsGetError, AddYearApiV1AdminYearAddPostData, AddYearApiV1AdminYearAddPostResponse, AddYearApiV1AdminYearAddPostError, EditYearApiV1AdminYearYearIdEditPostData, EditYearApiV1AdminYearYearIdEditPostError, GetUsersListApiV1AdminYearYearIdUsersGetData, GetUsersListApiV1AdminYearYearIdUsersGetResponse, GetUsersListApiV1AdminYearYearIdUsersGetError, GetYearPositionsApiV1AdminYearYearIdPositionsGetData, GetYearPositionsApiV1AdminYearYearIdPositionsGetResponse, GetYearPositionsApiV1AdminYearYearIdPositionsGetError, GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetData, GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetResponse, GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetError, GetYearResultsApiV1AdminYearYearIdResultsGetData, GetYearResultsApiV1AdminYearYearIdResultsGetResponse, GetYearResultsApiV1AdminYearYearIdResultsGetError, ExportYearCsvApiV1AdminYearYearIdExportCsvGetData, ExportYearCsvApiV1AdminYearYearIdExportCsvGetError, GenerateCertificatesApiV1AdminYearYearIdCertificatesGetData, GenerateCertificatesApiV1AdminYearYearIdCertificatesGetResponse, GenerateCertificatesApiV1AdminYearYearIdCertificatesGetError, SaveDayAttendanceApiV1AttendanceSavePostData, SaveDayAttendanceApiV1AttendanceSavePostError, GetAllAttendanceApiV1AttendanceYearIdAllGetData, GetAllAttendanceApiV1AttendanceYearIdAllGetResponse, GetAllAttendanceApiV1AttendanceYearIdAllGetError, RegisterApiV1AuthTelegramRegisterPostData, RegisterApiV1AuthTelegramRegisterPostResponse, RegisterApiV1AuthTelegramRegisterPostError, MigrateApiV1AuthTelegramMigratePostData, MigrateApiV1AuthTelegramMigratePostResponse, MigrateApiV1AuthTelegramMigratePostError, LoginApiV1AuthTelegramLoginPostData, LoginApiV1AuthTelegramLoginPostResponse, LoginApiV1AuthTelegramLoginPostError, RefreshApiV1AuthRefreshPostData, RefreshApiV1AuthRefreshPostResponse, RefreshApiV1AuthRefreshPostError, MeApiV1AuthMeGetData, MeApiV1AuthMeGetResponse, UpdateUserApiV1AuthUpdatePostData, UpdateUserApiV1AuthUpdatePostResponse, UpdateUserApiV1AuthUpdatePostError, GetYearsApiV1YearGetData, GetYearsApiV1YearGetResponse, GetFormYearApiV1YearYearIdGetData, GetFormYearApiV1YearYearIdGetResponse, GetFormYearApiV1YearYearIdGetError, SaveFormYearApiV1YearYearIdPostData, SaveFormYearApiV1YearYearIdPostResponse, SaveFormYearApiV1YearYearIdPostError, GetDayAssignmentsApiV1YearYearIdDaysDayIdAssignmentsGetData, GetDayAssignmentsApiV1YearYearIdDaysDayIdAssignmentsGetResponse, GetDayAssignmentsApiV1YearYearIdDaysDayIdAssignmentsGetError, HealthCheckHcGetData, HealthCheckHcGetResponse, ProxyPathGetData, ProxyPathGetError } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -271,6 +271,23 @@ export const editPositionApiV1AdminPositionPositionIdEditPost = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/admin/user/export-csv', + ...options + }); +}; + /** * Get All Users * Get list of all users @@ -492,9 +509,63 @@ export const getRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGet = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/admin/year/{year_id}/results', + ...options + }); +}; + +/** + * Export Year Csv + * Export all year data to ZIP archive with multiple CSV files + */ +export const exportYearCsvApiV1AdminYearYearIdExportCsvGet = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/admin/year/{year_id}/export-csv', + ...options + }); +}; + +/** + * Generate Certificates + * Generate certificates for all volunteers with attendance (admin only) + */ +export const generateCertificatesApiV1AdminYearYearIdCertificatesGet = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + responseType: 'text', + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/admin/year/{year_id}/certificates', + ...options + }); +}; + /** * Save Day Attendance - * Save attendance for a user day. Only admins or managers for the hall/year can set attendance. + * Save attendance for a user day. + * + * Only admins or managers for the hall/year can set attendance. */ export const saveDayAttendanceApiV1AttendanceSavePost = (options: Options) => { return (options.client ?? _heyApiClient).post({ diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index f4707a5..3fa651f 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -2,7 +2,13 @@ export type AddAssessmentRequest = { user_day_id: number; + /** + * Assessment comment + */ comment: string; + /** + * Assessment value (any real number) + */ value: number; }; @@ -42,6 +48,9 @@ export type AddPositionRequest = { can_desire?: boolean; has_halls?: boolean; is_manager?: boolean; + save_for_next_year?: boolean; + score?: number; + description?: string | null; }; export type AddPositionResponse = { @@ -97,6 +106,12 @@ export type ApplicationFormYearSavedResponse = { open_for_registration: boolean; }; +export type AssessmentInAttendance = { + assessment_id: number; + comment: string; + value: number; +}; + export type AssessmentItem = { assessment_id: number; user_day_id: number; @@ -133,6 +148,7 @@ export type AttendanceItem = { hall_id: number | null; hall_name: string | null; attendance: Attendance; + assessments: Array; }; export type CopyAssignmentsRequest = { @@ -181,7 +197,13 @@ export type DayOutUser = { }; export type EditAssessmentRequest = { + /** + * Assessment comment + */ comment?: string | null; + /** + * Assessment value (any real number) + */ value?: number | null; }; @@ -203,6 +225,9 @@ export type EditPositionRequest = { can_desire?: boolean | null; has_halls?: boolean | null; is_manager?: boolean | null; + save_for_next_year?: boolean | null; + score?: number | null; + description?: string | null; }; export type EditUserDayRequest = { @@ -223,6 +248,7 @@ export type EditUserRequest = { telegram_username?: string | null; is_admin?: boolean | null; telegram_id?: number | null; + gender: Gender | null; }; export type EditYearRequest = { @@ -244,6 +270,8 @@ export type ExperienceItem = { assessments: Array; }; +export type Gender = 'male' | 'female' | 'unspecified'; + export type HttpValidationError = { detail?: Array; }; @@ -261,6 +289,12 @@ export type PositionOut = { can_desire: boolean; has_halls: boolean; is_manager: boolean; + /** + * Save this position for next year when creating a new year + */ + save_for_next_year?: boolean; + score?: number; + description?: string | null; position_id: number; }; @@ -280,6 +314,7 @@ export type RegistrationFormItem = { phone: string | null; email: string | null; telegram_username: string | null; + gender: Gender | null; itmo_group: string | null; comments: string; needs_invitation: boolean; @@ -309,6 +344,23 @@ export type RegistrationRequest = { patronymic_ru?: string | null; phone?: string | null; email?: string | null; + gender?: Gender | null; +}; + +export type ResultItem = { + user_id: number; + first_name_ru: string; + last_name_ru: string; + patronymic_ru: string | null; + first_name_en: string; + last_name_en: string; + experience: number; + rank: string; + total_assessments: number; +}; + +export type ResultsResponse = { + results: Array; }; export type SaveDayAttendanceRequest = { @@ -348,8 +400,8 @@ export type TelegramMigrateRequest = { export type UserListItem = { id: number; - first_name_ru: string; - last_name_ru: string; + first_name_ru: string | null; + last_name_ru: string | null; patronymic_ru: string | null; first_name_en: string; last_name_en: string; @@ -357,6 +409,7 @@ export type UserListItem = { email: string | null; phone: string | null; telegram_username: string | null; + gender: Gender | null; is_registered: boolean; }; @@ -373,6 +426,7 @@ export type UserUpdateRequest = { patronymic_ru?: string | null; phone?: string | null; email?: string | null; + gender?: Gender | null; }; export type ValidationError = { @@ -406,6 +460,7 @@ export type VolunteersApiV1AdminUserSchemasUserResponse = { email: string | null; telegram_username: string | null; is_admin: boolean; + gender: Gender | null; }; export type VolunteersApiV1AuthSchemasUserResponse = { @@ -420,6 +475,7 @@ export type VolunteersApiV1AuthSchemasUserResponse = { phone: string | null; email: string | null; telegram_username: string | null; + gender: Gender | null; }; export type AddAssessmentApiV1AdminAssessmentAddPostData = { @@ -771,6 +827,20 @@ export type EditPositionApiV1AdminPositionPositionIdEditPostResponses = { 200: unknown; }; +export type ExportUsersCsvApiV1AdminUserExportCsvGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/admin/user/export-csv'; +}; + +export type ExportUsersCsvApiV1AdminUserExportCsvGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + export type GetAllUsersApiV1AdminUserGetData = { body?: never; path?: never; @@ -1084,6 +1154,85 @@ export type GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetResponse export type GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetResponse = GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetResponses[keyof GetRegistrationFormsApiV1AdminYearYearIdRegistrationFormsGetResponses]; +export type GetYearResultsApiV1AdminYearYearIdResultsGetData = { + body?: never; + path: { + year_id: number; + }; + query?: never; + url: '/api/v1/admin/year/{year_id}/results'; +}; + +export type GetYearResultsApiV1AdminYearYearIdResultsGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetYearResultsApiV1AdminYearYearIdResultsGetError = GetYearResultsApiV1AdminYearYearIdResultsGetErrors[keyof GetYearResultsApiV1AdminYearYearIdResultsGetErrors]; + +export type GetYearResultsApiV1AdminYearYearIdResultsGetResponses = { + /** + * Successful Response + */ + 200: ResultsResponse; +}; + +export type GetYearResultsApiV1AdminYearYearIdResultsGetResponse = GetYearResultsApiV1AdminYearYearIdResultsGetResponses[keyof GetYearResultsApiV1AdminYearYearIdResultsGetResponses]; + +export type ExportYearCsvApiV1AdminYearYearIdExportCsvGetData = { + body?: never; + path: { + year_id: number; + }; + query?: never; + url: '/api/v1/admin/year/{year_id}/export-csv'; +}; + +export type ExportYearCsvApiV1AdminYearYearIdExportCsvGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ExportYearCsvApiV1AdminYearYearIdExportCsvGetError = ExportYearCsvApiV1AdminYearYearIdExportCsvGetErrors[keyof ExportYearCsvApiV1AdminYearYearIdExportCsvGetErrors]; + +export type ExportYearCsvApiV1AdminYearYearIdExportCsvGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GenerateCertificatesApiV1AdminYearYearIdCertificatesGetData = { + body?: never; + path: { + year_id: number; + }; + query?: never; + url: '/api/v1/admin/year/{year_id}/certificates'; +}; + +export type GenerateCertificatesApiV1AdminYearYearIdCertificatesGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GenerateCertificatesApiV1AdminYearYearIdCertificatesGetError = GenerateCertificatesApiV1AdminYearYearIdCertificatesGetErrors[keyof GenerateCertificatesApiV1AdminYearYearIdCertificatesGetErrors]; + +export type GenerateCertificatesApiV1AdminYearYearIdCertificatesGetResponses = { + /** + * Successful Response + */ + 200: string; +}; + +export type GenerateCertificatesApiV1AdminYearYearIdCertificatesGetResponse = GenerateCertificatesApiV1AdminYearYearIdCertificatesGetResponses[keyof GenerateCertificatesApiV1AdminYearYearIdCertificatesGetResponses]; + export type SaveDayAttendanceApiV1AttendanceSavePostData = { body: SaveDayAttendanceRequest; path?: never; diff --git a/ui/src/components/AssessmentInput.tsx b/ui/src/components/AssessmentInput.tsx new file mode 100644 index 0000000..05fe97a --- /dev/null +++ b/ui/src/components/AssessmentInput.tsx @@ -0,0 +1,257 @@ +import { Star, StarBorder } from "@mui/icons-material"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { AssessmentInAttendance } from "@/client/types.gen"; + +interface AssessmentInputProps { + userDayId: number; + assessments: AssessmentInAttendance[]; + canEdit: boolean; + onAdd: (userDayId: number, value: number, comment: string) => Promise; + onEdit: ( + assessmentId: number, + value: number | null, + comment: string | null, + ) => Promise; + onDelete: (assessmentId: number) => Promise; +} + +export function AssessmentInput({ + userDayId, + assessments, + canEdit, + onAdd, + onEdit, + onDelete, +}: AssessmentInputProps) { + const { t } = useTranslation(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingAssessment, setEditingAssessment] = + useState(null); + const [value, setValue] = useState(""); + const [comment, setComment] = useState(""); + const scoreInputRef = useRef(null); + const commentInputRef = useRef(null); + + const averageScore = useMemo(() => { + if (!assessments.length) { + return null; + } + const avg = + assessments.reduce((sum, a) => sum + a.value, 0) / assessments.length; + return Number(avg.toFixed(2)); + }, [assessments]); + + const formatAverage = (score: number) => { + if (Number.isInteger(score)) { + return score.toString(); + } + return score.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); + }; + + const handleOpenDialog = (assessment?: AssessmentInAttendance) => { + if (assessment) { + setEditingAssessment(assessment); + setValue(assessment.value.toString()); + setComment(assessment.comment); + } else { + setEditingAssessment(null); + setValue(""); + setComment(""); + } + setDialogOpen(true); + }; + + const handleCloseDialog = () => { + setDialogOpen(false); + setEditingAssessment(null); + setValue(""); + setComment(""); + }; + + const handleSave = async () => { + const numValue = Number.parseFloat(value); + const trimmedComment = comment.trim(); + if (Number.isNaN(numValue) || !trimmedComment) { + return; + } + + if (editingAssessment) { + await onEdit(editingAssessment.assessment_id, numValue, trimmedComment); + } else { + await onAdd(userDayId, numValue, trimmedComment); + } + + handleCloseDialog(); + }; + + const handleDelete = async () => { + if (editingAssessment) { + await onDelete(editingAssessment.assessment_id); + handleCloseDialog(); + } + }; + + const presetValues = [0.1, 0.25, 0.5]; + + return ( + <> + + {averageScore !== null ? ( + 1 + ? `${t("Average of")} ${assessments.length} ${t("assessments")}` + : assessments[0]?.comment || t("Assessment") + } + > + { + if (canEdit && assessments.length === 1) { + handleOpenDialog(assessments[0]); + } else if (canEdit) { + handleOpenDialog(); + } + }} + > + + + {formatAverage(averageScore)} + + + + ) : canEdit ? ( + + handleOpenDialog()} + sx={{ p: 0.5 }} + > + + + + ) : ( + + - + + )} + + + + + {editingAssessment ? t("Edit Assessment") : t("Add Assessment")} + + + + setValue(e.target.value)} + inputRef={scoreInputRef} + onKeyDown={(event) => { + if (event.key === "Enter" && event.ctrlKey) { + // Ctrl+Enter: save if comment is filled + if (comment.trim()) { + event.preventDefault(); + handleSave(); + } + } else if ( + event.key === "Enter" && + !event.shiftKey && + !event.ctrlKey + ) { + // Just Enter: move to comment field + event.preventDefault(); + commentInputRef.current?.focus(); + } + }} + slotProps={{ + htmlInput: { step: 0.01 }, + }} + fullWidth + required + /> + + {presetValues.map((preset) => ( + + ))} + + setComment(e.target.value)} + inputRef={commentInputRef} + onKeyDown={(event) => { + if (event.key === "Enter" && event.ctrlKey) { + event.preventDefault(); + handleSave(); + } + }} + multiline + rows={3} + fullWidth + required + /> + + + + {editingAssessment && ( + + )} + + + + + + ); +} diff --git a/ui/src/components/DetailedUserCard.tsx b/ui/src/components/DetailedUserCard.tsx index 5c5f01f..1e27c45 100644 --- a/ui/src/components/DetailedUserCard.tsx +++ b/ui/src/components/DetailedUserCard.tsx @@ -38,6 +38,7 @@ export function DetailedUserCard({ user.telegram_username || user.desired_positions.length > 0 || user.comments || + user.gender || user.needs_invitation || (user.experience && user.experience.length > 0); @@ -89,12 +90,12 @@ export function DetailedUserCard({ {user.itmo_group && ( - {t("Group:")} {user.itmo_group} + {t("Group")}: {user.itmo_group} )} {user.telegram_username && ( - {t("Telegram:")} 📱{" "} + {t("Telegram")}: 📱{" "} )} + {user.gender && ( + + {t("Gender")}:{" "} + {user.gender === "male" ? t("Male") : t("Female")} + + )} {user.desired_positions.length > 0 && ( @@ -110,7 +117,7 @@ export function DetailedUserCard({ variant="body2" sx={{ mb: 0.25, fontWeight: 600, fontSize: "0.75rem" }} > - {t("Desired Positions:")} + {t("Desired Positions")}: {user.desired_positions.map((position) => ( @@ -133,7 +140,7 @@ export function DetailedUserCard({ variant="body2" sx={{ fontWeight: 600, mb: 0.25, fontSize: "0.75rem" }} > - {t("Comments:")} + {t("Comments")}: - {t("Experience:")} + {t("Experience")}: @@ -177,10 +184,10 @@ export function DetailedUserCard({ {t("Positions")} - {t("Attendance:")} + {t("Attendance")}: - {t("Assessments:")} + {t("Assessments")}: diff --git a/ui/src/components/MainLayout.tsx b/ui/src/components/MainLayout.tsx index eb43ca3..4eb9f46 100644 --- a/ui/src/components/MainLayout.tsx +++ b/ui/src/components/MainLayout.tsx @@ -4,7 +4,6 @@ import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import ContactsIcon from "@mui/icons-material/Contacts"; import DescriptionIcon from "@mui/icons-material/Description"; -import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; import ExpandLess from "@mui/icons-material/ExpandLess"; import ExpandMore from "@mui/icons-material/ExpandMore"; import GroupIcon from "@mui/icons-material/Group"; @@ -136,14 +135,6 @@ const getRoutesConfig = ( path: `/${selectedYear}/results`, adminOnly: true, }, - { - id: "medals", - type: "simple", - labelKey: "User Medals", - icon: EmojiEventsIcon, - path: `/${selectedYear}/medals`, - adminOnly: true, - }, { id: "settings", type: "simple", @@ -360,7 +351,7 @@ export default observer(function MainLayout({ column.setFilterValue(e.target.value || undefined) } @@ -478,7 +516,7 @@ function RouteComponent() { const label = typeof option === "string" ? option : option.label; return ( - + {label} ); @@ -515,6 +553,8 @@ function RouteComponent() { filterValue === "true" ? t("Registered") : t("Not Registered"); + } else if (filter.id === "gender") { + displayValue = filterValue === "male" ? t("Male") : t("Female"); } return ( diff --git a/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx b/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx index 4d85ea9..e446cba 100644 --- a/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx +++ b/ui/src/routes/_logged-in/$yearId/days/$dayId/edit.tsx @@ -66,6 +66,7 @@ import { useYearHalls, useYearPositions, } from "@/data/use-admin"; +import { useAssignmentUpdates } from "@/hooks/useAssignmentUpdates"; import { useDayAssignmentManager } from "@/hooks/useDayAssignmentManager"; // Custom collision detection that prioritizes the drawer @@ -794,6 +795,9 @@ function RouteComponent() { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); + // Subscribe to real-time assignment updates + useAssignmentUpdates(Number.parseInt(dayId, 10), Number.parseInt(yearId, 10)); + const { data: registrationFormsData, isLoading: formsLoading, @@ -1242,7 +1246,7 @@ function RouteComponent() { {/* Existing Assignments Summary */} {assignmentsData && assignmentsData.assignments.length > 0 && ( - {t("Current assignments:")} {assignmentsData.assignments.length}{" "} + {t("Current assignments")}: {assignmentsData.assignments.length}{" "} {t("volunteers assigned to positions")} )} diff --git a/ui/src/routes/_logged-in/$yearId/days/$dayId/index.tsx b/ui/src/routes/_logged-in/$yearId/days/$dayId/index.tsx index fae41a7..5aabedb 100644 --- a/ui/src/routes/_logged-in/$yearId/days/$dayId/index.tsx +++ b/ui/src/routes/_logged-in/$yearId/days/$dayId/index.tsx @@ -43,6 +43,7 @@ import { useTranslation } from "react-i18next"; import type { DayAssignmentItem } from "@/client/types.gen"; import { LinkButton } from "@/components/LinkButton"; import { useUserDayAssignments } from "@/data"; +import { useAssignmentUpdates } from "@/hooks/useAssignmentUpdates"; export const Route = createFileRoute("/_logged-in/$yearId/days/$dayId/")({ component: RouteComponent, @@ -58,6 +59,9 @@ function RouteComponent() { error, } = useUserDayAssignments(yearId, dayId); + // Subscribe to real-time assignment updates + useAssignmentUpdates(Number.parseInt(dayId, 10), Number.parseInt(yearId, 10)); + if (isLoading) { return ( @@ -296,7 +300,7 @@ function AssignmentsTable({ assignments }: AssignmentsTableProps) { > {column.columnDef.header as string} formik.setFieldTouched("gender", true)} + label={t("Gender")} + > + {GENDER_OPTIONS.map((option) => ( + + {getGenderLabel(option, t)} + + ))} + + {hasFieldError("gender") && ( + + {getFieldError("gender")} + + )} + + {t("Registration Details")} - - {t("Desired Positions")} + + + {requiredLabel(t("Desired Positions"))} + + + {getFieldError("desired_positions")} + @@ -323,9 +380,19 @@ function RouteComponent() { rows={4} value={formik.values.comments} onChange={formik.handleChange} + onBlur={formik.handleBlur} + onKeyDown={(event) => + submitOnCtrlEnter(event, { + canSubmit: + year.open_for_registration && + formik.isValid && + !formik.isSubmitting && + !saveMutation.isPending, + submit: formik.submitForm, + }) + } disabled={!year.open_for_registration} - error={formik.touched.comments && Boolean(formik.errors.comments)} - helperText={formik.touched.comments && formik.errors.comments} + {...getFieldErrorProps("comments")} sx={{ mb: 3 }} /> diff --git a/ui/src/routes/_logged-in/$yearId/results.tsx b/ui/src/routes/_logged-in/$yearId/results.tsx index 3c275b5..bd757bd 100644 --- a/ui/src/routes/_logged-in/$yearId/results.tsx +++ b/ui/src/routes/_logged-in/$yearId/results.tsx @@ -1,4 +1,21 @@ +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from "@mui/material"; import { createFileRoute } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { useYearResults } from "@/data/use-admin"; +import { openAuthenticatedPage } from "@/utils/download"; import { shouldBeAdmin } from "@/utils/should-be-logged-in"; export const Route = createFileRoute("/_logged-in/$yearId/results")({ @@ -12,5 +29,134 @@ export const Route = createFileRoute("/_logged-in/$yearId/results")({ }); function RouteComponent() { - return
Hello "/_logged-in/$yearId/results"!
; + const { t } = useTranslation(); + const { yearId } = Route.useParams(); + const { data, isLoading, error } = useYearResults(yearId); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + + {t("Failed to load results")}: {error.message} + + + ); + } + + const results = data?.results || []; + + return ( + + + {t("Results")} + + + {t("All registered volunteers for this year")} ({results.length}{" "} + {t("results")}) + + + + + + + {results.length === 0 ? ( + + {t("No volunteers found")} + + ) : ( + + +
+ + + + {t("Volunteer")} + + + {t("Experience")} + + + {t("Assessments this year")} + + + + + {results.map((result) => { + const fullNameRu = result.patronymic_ru + ? `${result.last_name_ru} ${result.first_name_ru} ${result.patronymic_ru}` + : `${result.last_name_ru} ${result.first_name_ru}`; + const fullNameEn = `${result.first_name_en} ${result.last_name_en}`; + + return ( + + + + {fullNameRu} + + + {fullNameEn} + + + + + {result.experience.toFixed(2)} + + + {t(result.rank)} + + + + + {result.total_assessments.toFixed(2)} + + + + ); + })} + +
+ + + )} +
+ ); } diff --git a/ui/src/routes/_logged-in/$yearId/settings.tsx b/ui/src/routes/_logged-in/$yearId/settings.tsx index f48e4c3..11baaab 100644 --- a/ui/src/routes/_logged-in/$yearId/settings.tsx +++ b/ui/src/routes/_logged-in/$yearId/settings.tsx @@ -1,5 +1,6 @@ import AddIcon from "@mui/icons-material/Add"; import BadgeIcon from "@mui/icons-material/Badge"; +import DownloadIcon from "@mui/icons-material/Download"; import EditIcon from "@mui/icons-material/Edit"; import HomeIcon from "@mui/icons-material/Home"; import SaveIcon from "@mui/icons-material/Save"; @@ -19,6 +20,7 @@ import { ListItem, ListItemText, Paper, + Snackbar, Switch, TextField, Tooltip, @@ -41,6 +43,8 @@ import { useYearPositions, useYears, } from "@/data"; +import { downloadFile } from "@/utils/download"; +import { submitOnCtrlEnter } from "@/utils/formShortcuts"; import { shouldBeAdmin } from "@/utils/should-be-logged-in"; export const Route = createFileRoute("/_logged-in/$yearId/settings")({ @@ -62,20 +66,44 @@ function RouteComponent() { null, ); const [newPositionName, setNewPositionName] = useState(""); + const [newPositionTouched, setNewPositionTouched] = useState(false); const [editPositionName, setEditPositionName] = useState(""); + const [editPositionTouched, setEditPositionTouched] = useState(false); const [newPositionCanDesire, setNewPositionCanDesire] = useState(false); const [editPositionCanDesire, setEditPositionCanDesire] = useState(false); const [newPositionHasHalls, setNewPositionHasHalls] = useState(false); const [editPositionHasHalls, setEditPositionHasHalls] = useState(false); const [newPositionIsManager, setNewPositionIsManager] = useState(false); const [editPositionIsManager, setEditPositionIsManager] = useState(false); + const [newPositionSaveForNextYear, setNewPositionSaveForNextYear] = + useState(false); + const [editPositionSaveForNextYear, setEditPositionSaveForNextYear] = + useState(false); + const [newPositionScore, setNewPositionScore] = useState("1.0"); + const [newPositionScoreTouched, setNewPositionScoreTouched] = useState(false); + const [editPositionScore, setEditPositionScore] = useState("1.0"); + const [editPositionScoreTouched, setEditPositionScoreTouched] = + useState(false); + const [newPositionDescription, setNewPositionDescription] = useState(""); + const [editPositionDescription, setEditPositionDescription] = useState(""); + const [newPositionScoreError, setNewPositionScoreError] = useState(""); + const [editPositionScoreError, setEditPositionScoreError] = useState(""); + const [exportError, setExportError] = useState(null); + + // Position error state + const [addPositionError, setAddPositionError] = useState(null); + const [editPositionError, setEditPositionError] = useState( + null, + ); // Hall management state const [isAddHallDialogOpen, setIsAddHallDialogOpen] = useState(false); const [isEditHallDialogOpen, setIsEditHallDialogOpen] = useState(false); const [editingHall, setEditingHall] = useState(null); const [newHallName, setNewHallName] = useState(""); + const [newHallTouched, setNewHallTouched] = useState(false); const [editHallName, setEditHallName] = useState(""); + const [editHallTouched, setEditHallTouched] = useState(false); const [newHallDescription, setNewHallDescription] = useState(""); const [editHallDescription, setEditHallDescription] = useState(""); @@ -84,11 +112,15 @@ function RouteComponent() { const [isEditDayDialogOpen, setIsEditDayDialogOpen] = useState(false); const [editingDay, setEditingDay] = useState(null); const [newDayName, setNewDayName] = useState(""); + const [newDayTouched, setNewDayTouched] = useState(false); const [editDayName, setEditDayName] = useState(""); + const [editDayTouched, setEditDayTouched] = useState(false); const [newDayInformation, setNewDayInformation] = useState(""); const [editDayInformation, setEditDayInformation] = useState(""); - const [newDayScore, setNewDayScore] = useState(0); - const [editDayScore, setEditDayScore] = useState(0); + const [newDayScore, setNewDayScore] = useState("0"); + const [newDayScoreTouched, setNewDayScoreTouched] = useState(false); + const [editDayScore, setEditDayScore] = useState("0"); + const [editDayScoreTouched, setEditDayScoreTouched] = useState(false); const [newDayMandatory, setNewDayMandatory] = useState(false); const [editDayMandatory, setEditDayMandatory] = useState(false); const [newDayAssignmentPublished, setNewDayAssignmentPublished] = @@ -98,6 +130,7 @@ function RouteComponent() { // Year settings state const [yearName, setYearName] = useState(""); + const [yearNameTouched, setYearNameTouched] = useState(false); const [openForRegistration, setOpenForRegistration] = useState(false); const [isYearSettingsEditing, setIsYearSettingsEditing] = useState(false); @@ -175,53 +208,114 @@ function RouteComponent() { const handleAddPosition = (e: React.FormEvent) => { e.preventDefault(); - if (newPositionName.trim()) { - addPositionMutation.mutate( - { - year_id: Number(yearId), - name: newPositionName.trim(), - can_desire: newPositionCanDesire, - has_halls: newPositionHasHalls, - is_manager: newPositionIsManager, - }, - { - onSuccess: () => { - setIsAddDialogOpen(false); - setNewPositionName(""); - setNewPositionCanDesire(false); - setNewPositionHasHalls(false); - setNewPositionIsManager(false); - }, - }, + setNewPositionScoreError(""); + setAddPositionError(null); + + const trimmedName = newPositionName.trim(); + if (!trimmedName) { + setAddPositionError(t("Position name is required")); + return; + } + + // Проверка на дубликат имени (регистронезависимо) + const nameExists = positions?.some( + (pos) => pos.name.toLowerCase() === trimmedName.toLowerCase(), + ); + + if (nameExists) { + setAddPositionError( + t("Position with this name already exists for the year"), ); + return; + } + + if (!newPositionScore.trim()) { + setNewPositionScoreError(t("Score is required")); + return; + } + + const scoreValue = Number(newPositionScore); + if (Number.isNaN(scoreValue)) { + setNewPositionScoreError(t("Score must be a valid number")); + return; } + + // Теперь отправляем — гарантированно без дубликата + addPositionMutation.mutate( + { + year_id: Number(yearId), + name: trimmedName, + can_desire: newPositionCanDesire, + has_halls: newPositionHasHalls, + is_manager: newPositionIsManager, + save_for_next_year: newPositionSaveForNextYear, + score: scoreValue, + description: newPositionDescription.trim() || null, + }, + { + onSuccess: () => { + closeAddDialogAndReset(); + }, + }, + ); }; const handleEditPosition = (e: React.FormEvent) => { e.preventDefault(); - if (editingPosition && editPositionName.trim()) { - editPositionMutation.mutate( - { - positionId: editingPosition.position_id, - data: { - name: editPositionName.trim(), - can_desire: editPositionCanDesire, - has_halls: editPositionHasHalls, - is_manager: editPositionIsManager, - }, - }, - { - onSuccess: () => { - setIsEditDialogOpen(false); - setEditingPosition(null); - setEditPositionName(""); - setEditPositionCanDesire(false); - setEditPositionHasHalls(false); - setEditPositionIsManager(false); - }, - }, + if (!editingPosition) return; + setEditPositionScoreError(""); + setEditPositionError(null); + + const trimmedName = editPositionName.trim(); + if (!trimmedName) { + setEditPositionError(t("Position name is required")); + return; + } + + // Проверяем дубликат, исключая текущую позицию + const nameExists = positions?.some( + (pos) => + pos.position_id !== editingPosition?.position_id && + pos.name.toLowerCase() === trimmedName.toLowerCase(), + ); + + if (nameExists) { + setEditPositionError( + t("Position with this name already exists for the year"), ); + return; } + + if (!editPositionScore.trim()) { + setEditPositionScoreError(t("Score is required")); + return; + } + + const scoreValue = Number(editPositionScore); + if (Number.isNaN(scoreValue)) { + setEditPositionScoreError(t("Score must be a valid number")); + return; + } + + editPositionMutation.mutate( + { + positionId: editingPosition.position_id, + data: { + name: trimmedName, + can_desire: editPositionCanDesire, + has_halls: editPositionHasHalls, + is_manager: editPositionIsManager, + save_for_next_year: editPositionSaveForNextYear, + score: scoreValue, + description: editPositionDescription.trim() || null, + }, + }, + { + onSuccess: () => { + closeEditDialogAndReset(); + }, + }, + ); }; const openEditDialog = (position: PositionOut) => { @@ -230,6 +324,9 @@ function RouteComponent() { setEditPositionCanDesire(position.can_desire); setEditPositionHasHalls(position.has_halls); setEditPositionIsManager(position.is_manager); + setEditPositionSaveForNextYear(!!position.save_for_next_year); + setEditPositionScore(String(position.score ?? "1.0")); + setEditPositionDescription(position.description || ""); setIsEditDialogOpen(true); }; @@ -245,9 +342,7 @@ function RouteComponent() { }, { onSuccess: () => { - setIsAddHallDialogOpen(false); - setNewHallName(""); - setNewHallDescription(""); + closeAddHallDialogAndReset(); }, }, ); @@ -267,10 +362,7 @@ function RouteComponent() { }, { onSuccess: () => { - setIsEditHallDialogOpen(false); - setEditingHall(null); - setEditHallName(""); - setEditHallDescription(""); + closeEditHallDialogAndReset(); }, }, ); @@ -287,24 +379,19 @@ function RouteComponent() { // Day management functions const handleAddDay = (e: React.FormEvent) => { e.preventDefault(); - if (newDayName.trim()) { + if (newDayName.trim() && newDayScore.trim()) { addDayMutation.mutate( { year_id: Number(yearId), name: newDayName.trim(), information: newDayInformation.trim(), - score: newDayScore, + score: Number(newDayScore), mandatory: newDayMandatory, assignment_published: newDayAssignmentPublished, }, { onSuccess: () => { - setIsAddDayDialogOpen(false); - setNewDayName(""); - setNewDayInformation(""); - setNewDayScore(0); - setNewDayMandatory(false); - setNewDayAssignmentPublished(false); + closeAddDayDialogAndReset(); }, }, ); @@ -313,8 +400,7 @@ function RouteComponent() { const handleEditDay = (e: React.FormEvent) => { e.preventDefault(); - if (editingDay && editDayName.trim()) { - console.log("editingDay", editingDay); + if (editingDay && editDayName.trim() && editDayScore.trim()) { editDayMutation.mutate( { dayId: editingDay.day_id, @@ -322,20 +408,14 @@ function RouteComponent() { data: { name: editDayName.trim(), information: editDayInformation.trim(), - score: editDayScore, + score: Number(editDayScore), mandatory: editDayMandatory, assignment_published: editDayAssignmentPublished, }, }, { onSuccess: () => { - setIsEditDayDialogOpen(false); - setEditingDay(null); - setEditDayName(""); - setEditDayInformation(""); - setEditDayScore(0); - setEditDayMandatory(false); - setEditDayAssignmentPublished(false); + closeEditDayDialogAndReset(); }, }, ); @@ -346,28 +426,84 @@ function RouteComponent() { setEditingDay(day); setEditDayName(day.name); setEditDayInformation(day.information); - setEditDayScore(day.score ?? 0); + setEditDayScore(String(day.score ?? "0")); setEditDayMandatory(day.mandatory); setEditDayAssignmentPublished(day.assignment_published); setIsEditDayDialogOpen(true); }; - const closeAddDialog = () => { + // === Close/Reset helpers === + const closeAddDialogAndReset = () => { setIsAddDialogOpen(false); setNewPositionName(""); + setNewPositionTouched(false); setNewPositionCanDesire(false); setNewPositionHasHalls(false); setNewPositionIsManager(false); + setNewPositionSaveForNextYear(false); + setNewPositionScore("1.0"); + setNewPositionScoreTouched(false); + setNewPositionDescription(""); + setAddPositionError(null); }; - const closeEditDialog = () => { + const closeEditDialogAndReset = () => { setIsEditDialogOpen(false); setEditingPosition(null); setEditPositionName(""); + setEditPositionTouched(false); setEditPositionCanDesire(false); setEditPositionHasHalls(false); setEditPositionIsManager(false); + setEditPositionSaveForNextYear(false); + setEditPositionScore("1.0"); + setEditPositionScoreTouched(false); + setEditPositionDescription(""); + setEditPositionError(null); + }; + + const closeAddHallDialogAndReset = () => { + setIsAddHallDialogOpen(false); + setNewHallName(""); + setNewHallTouched(false); + setNewHallDescription(""); + }; + const closeEditHallDialogAndReset = () => { + setIsEditHallDialogOpen(false); + setEditingHall(null); + setEditHallName(""); + setEditHallTouched(false); + setEditHallDescription(""); + }; + + const closeAddDayDialogAndReset = () => { + setIsAddDayDialogOpen(false); + setNewDayName(""); + setNewDayInformation(""); + setNewDayScore("0"); + setNewDayTouched(false); + setNewDayScoreTouched(false); + setNewDayMandatory(false); + setNewDayAssignmentPublished(false); }; + const closeEditDayDialogAndReset = () => { + setIsEditDayDialogOpen(false); + setEditingDay(null); + setEditDayName(""); + setEditDayInformation(""); + setEditDayScore("0"); + setEditDayTouched(false); + setEditDayScoreTouched(false); + setEditDayMandatory(false); + setEditDayAssignmentPublished(false); + }; + + const closeAddDialogSimple = () => setIsAddDialogOpen(false); + const closeEditDialogSimple = () => setIsEditDialogOpen(false); + const closeAddHallDialogSimple = () => setIsAddHallDialogOpen(false); + const closeEditHallDialogSimple = () => setIsEditHallDialogOpen(false); + const closeAddDayDialogSimple = () => setIsAddDayDialogOpen(false); + const closeEditDayDialogSimple = () => setIsEditDayDialogOpen(false); if (isLoading) { return ( @@ -384,9 +520,33 @@ function RouteComponent() { return ( - - {t("Year Settings")} - + + + {t("Year Settings")} + + + {/* Year Settings Form */} @@ -419,8 +579,13 @@ function RouteComponent() { label={t("Year Name")} value={yearName} onChange={(e) => setYearName(e.target.value)} - error={editYearMutation.isError} - helperText={editYearMutation.error?.message} + onBlur={() => setYearNameTouched(true)} + error={yearNameTouched && !yearName.trim()} + helperText={ + yearNameTouched && !yearName.trim() + ? t("Year name is required") + : editYearMutation.error?.message + } disabled={editYearMutation.isPending} sx={{ mb: 2 }} /> @@ -508,11 +673,13 @@ function RouteComponent() { {positions.map((position) => ( openEditDialog(position)} sx={{ border: 1, borderColor: "divider", borderRadius: 1, mb: 1, + cursor: "pointer", "&:hover": { backgroundColor: "action.hover", }, @@ -544,11 +711,18 @@ function RouteComponent() { )} + + {t("Score")}: {position.score} + } + secondary={position.description || undefined} /> openEditDialog(position)} + onClick={(event) => { + event.stopPropagation(); + openEditDialog(position); + }} color="primary" size="small" > @@ -591,11 +765,13 @@ function RouteComponent() { {halls.map((hall: HallOut) => ( openEditHallDialog(hall)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1, mb: 1, + cursor: "pointer", "&:hover": { backgroundColor: "action.hover", }, @@ -606,7 +782,10 @@ function RouteComponent() { secondary={hall.description || t("No description")} /> openEditHallDialog(hall)} + onClick={(event) => { + event.stopPropagation(); + openEditHallDialog(hall); + }} color="primary" size="small" > @@ -649,11 +828,13 @@ function RouteComponent() { {days.map((day) => ( openEditDayDialog(day)} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1, mb: 1, + cursor: "pointer", "&:hover": { backgroundColor: "action.hover", }, @@ -688,7 +869,10 @@ function RouteComponent() { secondary={day.information} /> openEditDayDialog(day)} + onClick={(event) => { + event.stopPropagation(); + openEditDayDialog(day); + }} color="primary" size="small" > @@ -707,7 +891,14 @@ function RouteComponent() { {/* Add Position Dialog */} { + const reason = args[1] as string | undefined; + if (reason === "escapeKeyDown") { + closeAddDialogSimple(); + } else { + closeAddDialogAndReset(); + } + }} maxWidth="sm" fullWidth > @@ -722,10 +913,56 @@ function RouteComponent() { variant="outlined" value={newPositionName} onChange={(e) => setNewPositionName(e.target.value)} - error={addPositionMutation.isError} - helperText={addPositionMutation.error?.message} + onBlur={() => setNewPositionTouched(true)} + error={ + (newPositionTouched && !newPositionName.trim()) || + !!addPositionError + } + helperText={ + (newPositionTouched && !newPositionName.trim() + ? t("Position name is required") + : addPositionError) || "" + } + disabled={addPositionMutation.isPending} + /> + setNewPositionDescription(e.target.value)} + onKeyDown={(event) => + submitOnCtrlEnter(event, { + canSubmit: + !!newPositionName.trim() && !addPositionMutation.isPending, + }) + } + multiline + rows={3} disabled={addPositionMutation.isPending} /> + setNewPositionScore(e.target.value)} + onBlur={() => setNewPositionScoreTouched(true)} + error={ + newPositionScoreTouched && + (!newPositionScore.trim() || !!newPositionScoreError) + } + helperText={ + newPositionScoreTouched && !newPositionScore.trim() + ? t("Score is required") + : newPositionScoreError + } + disabled={addPositionMutation.isPending} + inputProps={{ step: "0.1" }} + /> + { + setNewPositionSaveForNextYear(e.target.checked); + }} + disabled={addPositionMutation.isPending} + /> + } + label={t("Save for next year")} + sx={{ mt: 1 }} + /> @@ -1057,7 +1441,14 @@ function RouteComponent() { {/* Edit Day Dialog */} setIsEditDayDialogOpen(false)} + onClose={(...args: unknown[]) => { + const reason = args[1] as string | undefined; + if (reason === "escapeKeyDown") { + closeEditDayDialogSimple(); + } else { + closeEditDayDialogAndReset(); + } + }} maxWidth="sm" fullWidth > @@ -1072,8 +1463,13 @@ function RouteComponent() { variant="outlined" value={editDayName} onChange={(e) => setEditDayName(e.target.value)} - error={editDayMutation.isError} - helperText={editDayMutation.error?.message} + onBlur={() => setEditDayTouched(true)} + error={editDayTouched && !editDayName.trim()} + helperText={ + editDayTouched && !editDayName.trim() + ? t("Day name is required") + : editDayMutation.error?.message + } disabled={editDayMutation.isPending} required /> @@ -1084,18 +1480,30 @@ function RouteComponent() { variant="outlined" value={editDayInformation} onChange={(e) => setEditDayInformation(e.target.value)} + onKeyDown={(event) => + submitOnCtrlEnter(event, { + canSubmit: !!editDayName.trim() && !editDayMutation.isPending, + }) + } multiline rows={3} disabled={editDayMutation.isPending} /> setEditDayScore(Number(e.target.value))} + onChange={(e) => setEditDayScore(e.target.value)} + onBlur={() => setEditDayScoreTouched(true)} + error={editDayScoreTouched && !editDayScore.trim()} + helperText={ + editDayScoreTouched && !editDayScore.trim() + ? t("Score is required") + : "" + } disabled={editDayMutation.isPending} inputProps={{ step: "0.1" }} /> @@ -1126,7 +1534,7 @@ function RouteComponent() { + + {/* Export error notification */} + setExportError(null)} + message={exportError} + />
); } diff --git a/ui/src/routes/_logged-in/create.tsx b/ui/src/routes/_logged-in/create.tsx index 72969f0..3a63c97 100644 --- a/ui/src/routes/_logged-in/create.tsx +++ b/ui/src/routes/_logged-in/create.tsx @@ -44,6 +44,8 @@ function RouteComponent() { } }; + const [yearTouched, setYearTouched] = useState(false); + return ( setYearName(e.target.value)} - error={createYearMutation.isError} - helperText={createYearMutation.error?.message} + onBlur={() => setYearTouched(true)} + error={ + (yearTouched && !yearName.trim()) || createYearMutation.isError + } + helperText={ + yearTouched && !yearName.trim() + ? t("Year name is required") + : createYearMutation.error?.message + } disabled={createYearMutation.isPending} /> diff --git a/ui/src/routes/_logged-in/users/$userId.tsx b/ui/src/routes/_logged-in/users/$userId.tsx index e0a12d6..e53cff0 100644 --- a/ui/src/routes/_logged-in/users/$userId.tsx +++ b/ui/src/routes/_logged-in/users/$userId.tsx @@ -6,6 +6,7 @@ import { CircularProgress, Container, FormControlLabel, + MenuItem, Paper, TextField, Typography, @@ -38,6 +39,7 @@ const validationSchema = yup.object({ email: yup.string().email("Invalid email format").nullable(), telegram_username: yup.string().nullable(), telegram_id: yup.number().nullable(), + gender: yup.string().oneOf(["male", "female"]).nullable(), is_admin: yup.boolean(), }); @@ -60,6 +62,7 @@ function RouteComponent() { email: "", telegram_username: "", telegram_id: null as number | null, + gender: "" as "male" | "female" | "", is_admin: false, }, validationSchema, @@ -78,6 +81,7 @@ function RouteComponent() { email: values.email || null, telegram_username: values.telegram_username || null, telegram_id: values.telegram_id || null, + gender: values.gender || null, is_admin: values.is_admin || null, }, }); @@ -131,8 +135,9 @@ function RouteComponent() { fullWidth label={t("First Name (RU)")} name="first_name_ru" - value={formik.values.first_name_ru} + value={formik.values.first_name_ru || ""} onChange={formik.handleChange} + onBlur={formik.handleBlur} error={ formik.touched.first_name_ru && Boolean(formik.errors.first_name_ru) @@ -148,8 +153,9 @@ function RouteComponent() { fullWidth label={t("Last Name (RU)")} name="last_name_ru" - value={formik.values.last_name_ru} + value={formik.values.last_name_ru || ""} onChange={formik.handleChange} + onBlur={formik.handleBlur} error={ formik.touched.last_name_ru && Boolean(formik.errors.last_name_ru) } @@ -164,8 +170,9 @@ function RouteComponent() { fullWidth label={t("Patronymic (RU)")} name="patronymic_ru" - value={formik.values.patronymic_ru} + value={formik.values.patronymic_ru || ""} onChange={formik.handleChange} + onBlur={formik.handleBlur} error={ formik.touched.patronymic_ru && Boolean(formik.errors.patronymic_ru) @@ -180,8 +187,9 @@ function RouteComponent() { fullWidth label={t("First Name (EN)")} name="first_name_en" - value={formik.values.first_name_en} + value={formik.values.first_name_en || ""} onChange={formik.handleChange} + onBlur={formik.handleBlur} error={ formik.touched.first_name_en && Boolean(formik.errors.first_name_en) @@ -197,8 +205,9 @@ function RouteComponent() { fullWidth label={t("Last Name (EN)")} name="last_name_en" - value={formik.values.last_name_en} + value={formik.values.last_name_en || ""} onChange={formik.handleChange} + onBlur={formik.handleBlur} error={ formik.touched.last_name_en && Boolean(formik.errors.last_name_en) } @@ -221,6 +230,7 @@ function RouteComponent() { e.target.value ? Number(e.target.value) : null, ) } + onBlur={formik.handleBlur} error={formik.touched.isu_id && Boolean(formik.errors.isu_id)} helperText={formik.touched.isu_id && formik.errors.isu_id} sx={{ mb: 2 }} @@ -230,8 +240,9 @@ function RouteComponent() { fullWidth label={t("Phone")} name="phone" - value={formik.values.phone} + value={formik.values.phone || ""} onChange={formik.handleChange} + onBlur={formik.handleBlur} error={formik.touched.phone && Boolean(formik.errors.phone)} helperText={formik.touched.phone && formik.errors.phone} sx={{ mb: 2 }} @@ -242,19 +253,35 @@ function RouteComponent() { label={t("Email")} name="email" type="email" - value={formik.values.email} + value={formik.values.email || ""} onChange={formik.handleChange} + onBlur={formik.handleBlur} error={formik.touched.email && Boolean(formik.errors.email)} helperText={formik.touched.email && formik.errors.email} sx={{ mb: 2 }} /> + formik.setFieldTouched("gender", true)} + sx={{ mb: 2 }} + > + {t("Male")} + {t("Female")} + + ([]); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); + const [exportError, setExportError] = useState(null); const columns: ColumnDef["users"][number]>[] = useMemo( @@ -181,6 +187,20 @@ function RouteComponent() { ); }, }, + { + id: "gender", + header: t("Gender"), + accessorKey: "gender", + size: 100, + cell: (info) => { + const gender = info.getValue() as string | null; + return ( + + {getGenderLabel(gender, t)} + + ); + }, + }, { id: "is_admin", header: t("Admin"), @@ -243,12 +263,38 @@ function RouteComponent() { return ( - - {t("Users")} - - - {t("List of all users")} - + + + + {t("Users")} + + + {t("List of all users")} + + + + )} + + {/* Export error notification */} + setExportError(null)} + message={exportError} + /> ); } diff --git a/ui/src/store/auth.ts b/ui/src/store/auth.ts index 700aee9..c8e01b8 100644 --- a/ui/src/store/auth.ts +++ b/ui/src/store/auth.ts @@ -56,6 +56,10 @@ class AuthStore { return this._user; } + getAccessToken(): string | null { + return this.accessToken; + } + waitForHydration(): Promise { if (this.hydrationPromise === null) { throw new Error("Hydration promise not found. This should never happen."); diff --git a/ui/src/utils/download.ts b/ui/src/utils/download.ts new file mode 100644 index 0000000..ee64568 --- /dev/null +++ b/ui/src/utils/download.ts @@ -0,0 +1,97 @@ +import { authStore } from "@/store/auth"; + +/** + * Download a file from an authenticated endpoint + */ +export async function downloadFile( + url: string, + filename?: string, +): Promise { + try { + const token = authStore.getAccessToken(); + if (!token) { + throw new Error("Not authenticated"); + } + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + + // Use filename from Content-Disposition header if available + let finalFilename = filename; + const contentDisposition = response.headers.get("Content-Disposition"); + if (contentDisposition && !finalFilename) { + const filenameMatch = contentDisposition.match( + /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/i, + ); + if (filenameMatch?.[1]) { + finalFilename = filenameMatch[1].replace(/['"]/g, ""); + } + } + + link.download = finalFilename || "download"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + } catch (error) { + console.error("Failed to download file:", error); + throw error; + } +} + +/** + * Open HTML page in a new window with authentication + */ +export async function openAuthenticatedPage(url: string): Promise { + try { + const token = authStore.getAccessToken(); + if (!token) { + throw new Error("Not authenticated"); + } + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const blob = new Blob([html], { type: "text/html" }); + const blobUrl = window.URL.createObjectURL(blob); + + // Open in new window + const newWindow = window.open(blobUrl, "_blank"); + + // Clean up the blob URL after a delay (window needs time to load) + setTimeout(() => { + window.URL.revokeObjectURL(blobUrl); + }, 1000); + + if (!newWindow) { + throw new Error( + "Failed to open new window. Please check popup blocker settings.", + ); + } + } catch (error) { + console.error("Failed to open authenticated page:", error); + throw error; + } +} diff --git a/ui/src/utils/formShortcuts.ts b/ui/src/utils/formShortcuts.ts new file mode 100644 index 0000000..e5e2e72 --- /dev/null +++ b/ui/src/utils/formShortcuts.ts @@ -0,0 +1,35 @@ +import type * as React from "react"; + +type CtrlEnterOptions = { + canSubmit?: boolean; + submit?: () => void | Promise; +}; + +export function submitOnCtrlEnter( + event: React.KeyboardEvent, + options: CtrlEnterOptions = {}, +) { + if (event.key !== "Enter" || (!event.ctrlKey && !event.metaKey)) { + return; + } + + if (options.canSubmit === false) { + return; + } + + event.preventDefault(); + + if (options.submit) { + void options.submit(); + return; + } + + const target = event.target; + const form = + event.currentTarget instanceof HTMLFormElement + ? event.currentTarget + : target instanceof HTMLElement + ? target.closest("form") + : null; + form?.requestSubmit(); +} diff --git a/ui/src/utils/gender.ts b/ui/src/utils/gender.ts new file mode 100644 index 0000000..fb07f0d --- /dev/null +++ b/ui/src/utils/gender.ts @@ -0,0 +1,21 @@ +export type Gender = "male" | "female" | "unspecified"; + +export const getGenderLabel = ( + gender: Gender | string | null, + t: (key: string) => string, +): string => { + if (!gender) return t("Not specified"); + + switch (gender) { + case "male": + return t("Male"); + case "female": + return t("Female"); + case "unspecified": + return t("Prefer not to say"); + default: + return t("Not specified"); + } +}; + +export const GENDER_OPTIONS: Gender[] = ["male", "female", "unspecified"]; diff --git a/ui/vite.config.js b/ui/vite.config.js index c24f18a..92f530d 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -45,5 +45,16 @@ export default defineConfig({ }, server: { allowedHosts: ["nerc-volunteers.itmo.ru"], + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + }, + "/socket.io": { + target: "http://localhost:8000", + changeOrigin: true, + ws: true, // Enable WebSocket proxying + }, + }, }, }); diff --git a/volunteers/alembic/versions/2025_11_30_2151-8e31dc6646bd_add_gender_to_users.py b/volunteers/alembic/versions/2025_11_30_2151-8e31dc6646bd_add_gender_to_users.py new file mode 100644 index 0000000..cd427ff --- /dev/null +++ b/volunteers/alembic/versions/2025_11_30_2151-8e31dc6646bd_add_gender_to_users.py @@ -0,0 +1,32 @@ +"""Add gender to users + +Revision ID: 8e31dc6646bd +Revises: cd2a2b0b1b25 +Create Date: 2025-11-30 21:51:47.987287 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8e31dc6646bd" +down_revision: str | None = "cd2a2b0b1b25" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("gender", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "gender") + # ### end Alembic commands ### diff --git a/volunteers/alembic/versions/2025_12_01_2033-3d81aecb66a0_add_photo_fields_to_user.py b/volunteers/alembic/versions/2025_12_01_2033-3d81aecb66a0_add_photo_fields_to_user.py new file mode 100644 index 0000000..ebd236c --- /dev/null +++ b/volunteers/alembic/versions/2025_12_01_2033-3d81aecb66a0_add_photo_fields_to_user.py @@ -0,0 +1,23 @@ +"""add_photo_fields_to_user + +Revision ID: 3d81aecb66a0 +Revises: 8e31dc6646bd +Create Date: 2025-12-01 20:33:27.705373 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "3d81aecb66a0" +down_revision: str | None = "8e31dc6646bd" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + + +def downgrade() -> None: + """Downgrade schema.""" diff --git a/volunteers/alembic/versions/2025_12_01_2300-1006dcfcc630_change_gender_to_enum.py b/volunteers/alembic/versions/2025_12_01_2300-1006dcfcc630_change_gender_to_enum.py new file mode 100644 index 0000000..455f6f4 --- /dev/null +++ b/volunteers/alembic/versions/2025_12_01_2300-1006dcfcc630_change_gender_to_enum.py @@ -0,0 +1,55 @@ +"""change_gender_to_enum + +Revision ID: 1006dcfcc630 +Revises: 3d81aecb66a0 +Create Date: 2025-12-01 23:00:41.543093 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1006dcfcc630" +down_revision: str | None = "3d81aecb66a0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Drop the old enum type if it exists (from previous migration attempts) + op.execute("DROP TYPE IF EXISTS gender_enum CASCADE") + + # Create the enum type with correct values + gender_enum = sa.Enum("male", "female", "unspecified", name="gender_enum") + gender_enum.create(op.get_bind(), checkfirst=False) + + # Convert existing data to lowercase to match enum values + op.execute(""" + UPDATE users + SET gender = CASE + WHEN LOWER(gender) = 'male' THEN 'male' + WHEN LOWER(gender) = 'female' THEN 'female' + ELSE NULL + END + WHERE gender IS NOT NULL AND gender::text ~ '^[A-Z]' + """) + + # Alter the column type using USING clause for PostgreSQL + op.execute(""" + ALTER TABLE users + ALTER COLUMN gender TYPE gender_enum + USING COALESCE(gender::text::gender_enum, NULL) + """) + + +def downgrade() -> None: + """Downgrade schema.""" + # Convert back to string + op.alter_column("users", "gender", type_=sa.String(), postgresql_using="gender::text") + + # Drop the enum type + op.execute("DROP TYPE IF EXISTS gender_enum") diff --git a/volunteers/alembic/versions/2025_12_04_2027-f474eb5497c4_allow_fractional_assessment_values.py b/volunteers/alembic/versions/2025_12_04_2027-f474eb5497c4_allow_fractional_assessment_values.py new file mode 100644 index 0000000..7e1412f --- /dev/null +++ b/volunteers/alembic/versions/2025_12_04_2027-f474eb5497c4_allow_fractional_assessment_values.py @@ -0,0 +1,43 @@ +"""allow_fractional_assessment_values + +Revision ID: f474eb5497c4 +Revises: 1006dcfcc630 +Create Date: 2025-12-04 20:27:09.052379 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f474eb5497c4" +down_revision: str | None = "1006dcfcc630" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "assessments", + "value", + existing_type=sa.INTEGER(), + type_=sa.Numeric(precision=5, scale=2), + existing_nullable=False, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "assessments", + "value", + existing_type=sa.Numeric(precision=5, scale=2), + type_=sa.INTEGER(), + existing_nullable=False, + ) + # ### end Alembic commands ### diff --git a/volunteers/alembic/versions/2025_12_04_2235-ed61a9a0344a_add_experience_field_to_application_form.py b/volunteers/alembic/versions/2025_12_04_2235-ed61a9a0344a_add_experience_field_to_application_form.py new file mode 100644 index 0000000..8c1110d --- /dev/null +++ b/volunteers/alembic/versions/2025_12_04_2235-ed61a9a0344a_add_experience_field_to_application_form.py @@ -0,0 +1,51 @@ +"""add experience field to application form + +Revision ID: ed61a9a0344a +Revises: f474eb5497c4 +Create Date: 2025-12-04 22:35:29.972041 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ed61a9a0344a" +down_revision: str | None = "f474eb5497c4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "application_forms", + sa.Column("experience", sa.Double(), server_default="0.0", nullable=False), + ) + op.alter_column( + "assessments", + "value", + existing_type=sa.NUMERIC(precision=5, scale=2), + type_=sa.Double(), + existing_nullable=False, + ) + # Removed: op.drop_column('users', 'photo') - column may not exist + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # Removed: op.add_column('users', sa.Column('photo', sa.TEXT(), autoincrement=False, nullable=True)) + op.alter_column( + "assessments", + "value", + existing_type=sa.Double(), + type_=sa.NUMERIC(precision=5, scale=2), + existing_nullable=False, + ) + op.drop_column("application_forms", "experience") + # ### end Alembic commands ### diff --git a/volunteers/alembic/versions/2025_12_05_0158-2beb515f444b_remove_experience_field_from_.py b/volunteers/alembic/versions/2025_12_05_0158-2beb515f444b_remove_experience_field_from_.py new file mode 100644 index 0000000..f25f3c6 --- /dev/null +++ b/volunteers/alembic/versions/2025_12_05_0158-2beb515f444b_remove_experience_field_from_.py @@ -0,0 +1,27 @@ +"""remove_experience_field_from_application_form + +Revision ID: 2beb515f444b +Revises: ed61a9a0344a +Create Date: 2025-12-05 01:58:11.732810 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "2beb515f444b" +down_revision: str | None = "ed61a9a0344a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # ### end Alembic commands ### diff --git a/volunteers/alembic/versions/2025_12_05_2004-81c24b11a34f_add_score_and_description_to_position.py b/volunteers/alembic/versions/2025_12_05_2004-81c24b11a34f_add_score_and_description_to_position.py new file mode 100644 index 0000000..c7d1943 --- /dev/null +++ b/volunteers/alembic/versions/2025_12_05_2004-81c24b11a34f_add_score_and_description_to_position.py @@ -0,0 +1,47 @@ +"""Add score and description to Position + +Revision ID: 81c24b11a34f +Revises: 2beb515f444b +Create Date: 2025-12-05 20:04:20.977355 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "81c24b11a34f" +down_revision: str | None = "2beb515f444b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "positions", sa.Column("score", sa.Double(), nullable=False, server_default="1.0") + ) + op.add_column("positions", sa.Column("description", sa.String(), nullable=True)) + op.drop_column("application_forms", "experience") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "application_forms", + sa.Column( + "experience", + sa.DOUBLE_PRECISION(precision=53), + server_default=sa.text("'0'::double precision"), + autoincrement=False, + nullable=False, + ), + ) + op.drop_column("positions", "description") + op.drop_column("positions", "score") + # ### end Alembic commands ### diff --git a/volunteers/alembic/versions/2025_12_05_2040-f1c13aace233_force_re_check_dependencies.py b/volunteers/alembic/versions/2025_12_05_2040-f1c13aace233_force_re_check_dependencies.py new file mode 100644 index 0000000..aa91299 --- /dev/null +++ b/volunteers/alembic/versions/2025_12_05_2040-f1c13aace233_force_re_check_dependencies.py @@ -0,0 +1,23 @@ +"""force re-check dependencies + +Revision ID: f1c13aace233 +Revises: 81c24b11a34f +Create Date: 2025-12-05 20:40:52.185937 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "f1c13aace233" +down_revision: str | None = "81c24b11a34f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + + +def downgrade() -> None: + """Downgrade schema.""" diff --git a/volunteers/alembic/versions/2025_12_05_2049-dd5145b00290_add_score_to_positions.py b/volunteers/alembic/versions/2025_12_05_2049-dd5145b00290_add_score_to_positions.py new file mode 100644 index 0000000..fdca21e --- /dev/null +++ b/volunteers/alembic/versions/2025_12_05_2049-dd5145b00290_add_score_to_positions.py @@ -0,0 +1,36 @@ +"""add score to positions + +Revision ID: dd5145b00290 +Revises: f1c13aace233 +Create Date: 2025-12-05 20:49:31.916742 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "dd5145b00290" +down_revision: str | None = "f1c13aace233" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "positions", sa.Column("score", sa.Double(), server_default="1.0", nullable=False) + ) + op.add_column("positions", sa.Column("description", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("positions", "description") + op.drop_column("positions", "score") + # ### end Alembic commands ### diff --git a/volunteers/alembic/versions/2025_12_06_0007-01cb3cdf72e0_add_unique_constraint_positions_per_year.py b/volunteers/alembic/versions/2025_12_06_0007-01cb3cdf72e0_add_unique_constraint_positions_per_year.py new file mode 100644 index 0000000..72e13b4 --- /dev/null +++ b/volunteers/alembic/versions/2025_12_06_0007-01cb3cdf72e0_add_unique_constraint_positions_per_year.py @@ -0,0 +1,28 @@ +"""Add unique constraint for positions per year + +Revision ID: 0ebd08ab76a0 +Revises: dd5145b00290 +Create Date: 2025-12-05 +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0ebd08ab76a0" +down_revision = "dd5145b00290" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.drop_constraint("positions_name_key", "positions", type_="unique") + + op.create_unique_constraint("positions_year_id_name_key", "positions", ["year_id", "name"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint("positions_year_id_name_key", "positions", type_="unique") + + op.create_unique_constraint("positions_name_key", "positions", ["name"]) diff --git a/volunteers/alembic/versions/2025_12_06_1234_add_save_for_next_year_to_positions.py b/volunteers/alembic/versions/2025_12_06_1234_add_save_for_next_year_to_positions.py new file mode 100644 index 0000000..11cf877 --- /dev/null +++ b/volunteers/alembic/versions/2025_12_06_1234_add_save_for_next_year_to_positions.py @@ -0,0 +1,28 @@ +"""add save_for_next_year to positions + +Revision ID: 2025_12_06_1234_add_save_for_next_year_to_positions +Revises: 2025_12_06_0007-01cb3cdf72e0_add_unique_constraint_positions_per_year +Create Date: 2025-12-06 12:34:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "1a2b3c4d5e6f" +down_revision = "0ebd08ab76a0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "positions", + sa.Column( + "save_for_next_year", sa.Boolean(), nullable=False, server_default=sa.text("false") + ), + ) + + +def downgrade() -> None: + op.drop_column("positions", "save_for_next_year") diff --git a/volunteers/api/v1/admin/assessment/router.py b/volunteers/api/v1/admin/assessment/router.py index 01a5ebc..7ff0b8d 100644 --- a/volunteers/api/v1/admin/assessment/router.py +++ b/volunteers/api/v1/admin/assessment/router.py @@ -3,12 +3,14 @@ from dependency_injector.wiring import Provide, inject from fastapi import APIRouter, Depends, HTTPException, Path, Response, status from loguru import logger -from sqlalchemy import select from volunteers.auth.deps import with_admin + +# Import the global container from di module instead of app from volunteers.core.di import Container -from volunteers.models import Assessment, User +from volunteers.models import User from volunteers.schemas.assessment import AssessmentEditIn, AssessmentIn +from volunteers.services.assessment import AssessmentService from volunteers.services.year import YearService from .schemas import ( @@ -78,18 +80,13 @@ async def edit_assessment( async def delete_assessment( assessment_id: Annotated[int, Path(title="The ID of the assessment")], _: Annotated[User, Depends(with_admin)], - year_service: Annotated[YearService, Depends(Provide[Container.year_service])], + assessment_service: Annotated[ + AssessmentService, + Depends(Provide[Container.assessment_service]), + ], ) -> None: - async with year_service.session_scope() as session: - existing_assessment = await session.execute( - select(Assessment).where(Assessment.id == assessment_id) - ) - assessment = existing_assessment.scalar_one_or_none() - if not assessment: - raise HTTPException(status_code=404, detail="Assessment not found") - - await session.delete(assessment) - await session.commit() + if not await assessment_service.delete_assessment(assessment_id): + raise HTTPException(status_code=404, detail="Assessment not found") logger.info("Assessment has been deleted") diff --git a/volunteers/api/v1/admin/assessment/schemas.py b/volunteers/api/v1/admin/assessment/schemas.py index bd51da3..fd06187 100644 --- a/volunteers/api/v1/admin/assessment/schemas.py +++ b/volunteers/api/v1/admin/assessment/schemas.py @@ -1,12 +1,12 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from volunteers.schemas.base import BaseSuccessResponse class AddAssessmentRequest(BaseModel): user_day_id: int - comment: str - value: float + comment: str = Field(min_length=1, description="Assessment comment") + value: float = Field(description="Assessment value (any real number)") class AddAssessmentResponse(BaseSuccessResponse): @@ -14,8 +14,15 @@ class AddAssessmentResponse(BaseSuccessResponse): class EditAssessmentRequest(BaseModel): - comment: str | None = None - value: float | None = None + comment: str | None = Field( + default=None, + min_length=1, + description="Assessment comment", + ) + value: float | None = Field( + None, + description="Assessment value (any real number)", + ) class AssessmentItem(BaseModel): diff --git a/volunteers/api/v1/admin/day/__tests__/test_router.py b/volunteers/api/v1/admin/day/__tests__/test_router.py index a66b072..d5c92cf 100644 --- a/volunteers/api/v1/admin/day/__tests__/test_router.py +++ b/volunteers/api/v1/admin/day/__tests__/test_router.py @@ -51,6 +51,9 @@ def add_day_request() -> dict[str, Any]: "year_id": 42, "name": "Test Day", "information": "Day info", + "score": 10.0, + "mandatory": True, + "assignment_published": False, } diff --git a/volunteers/api/v1/admin/position/__tests__/test_router.py b/volunteers/api/v1/admin/position/__tests__/test_router.py index 1dfe761..9452413 100644 --- a/volunteers/api/v1/admin/position/__tests__/test_router.py +++ b/volunteers/api/v1/admin/position/__tests__/test_router.py @@ -50,6 +50,11 @@ def add_position_request() -> dict[str, Any]: return { "year_id": 42, "name": "Test Position", + "can_desire": False, + "has_halls": False, + "is_manager": False, + "score": 1.0, + "description": None, } diff --git a/volunteers/api/v1/admin/position/router.py b/volunteers/api/v1/admin/position/router.py index bafbd24..47bf4e8 100644 --- a/volunteers/api/v1/admin/position/router.py +++ b/volunteers/api/v1/admin/position/router.py @@ -1,13 +1,14 @@ from typing import Annotated from dependency_injector.wiring import Provide, inject -from fastapi import APIRouter, Depends, Path, Response, status +from fastapi import APIRouter, Depends, HTTPException, Path, Response, status from loguru import logger from volunteers.auth.deps import with_admin from volunteers.core.di import Container from volunteers.models import User from volunteers.schemas.position import PositionEditIn, PositionIn +from volunteers.services.errors import DomainError # ← обязательно импортируем from volunteers.services.year import YearService from .schemas import AddPositionRequest, AddPositionResponse, EditPositionRequest @@ -22,6 +23,9 @@ "description": "Returned when position successfully added", "model": AddPositionResponse, }, + status.HTTP_400_BAD_REQUEST: { + "description": "Position with this name already exists or other validation error", + }, }, description="Add new position", ) @@ -32,21 +36,37 @@ async def add_position( _: Annotated[User, Depends(with_admin)], year_service: Annotated[YearService, Depends(Provide[Container.year_service])], ) -> AddPositionResponse: - position_in = PositionIn( - year_id=request.year_id, - name=request.name, - can_desire=request.can_desire, - has_halls=request.has_halls, - is_manager=request.is_manager, - ) - position = await year_service.add_position(position_in=position_in) - logger.info(f"Added position {request.name}") + try: + position_in = PositionIn( + year_id=request.year_id, + name=request.name, + can_desire=request.can_desire, + has_halls=request.has_halls, + is_manager=request.is_manager, + save_for_next_year=request.save_for_next_year, + score=request.score, + description=request.description, + ) + position = await year_service.add_position(position_in=position_in) + logger.info(f"Added position {request.name}") + + response.status_code = status.HTTP_201_CREATED + return AddPositionResponse(position_id=position.id) - response.status_code = status.HTTP_201_CREATED - return AddPositionResponse(position_id=position.id) + except DomainError as exc: + # Expected business error -> return 400 with a clear message + raise HTTPException(status_code=400, detail=str(exc)) from exc -@router.post("/{position_id}/edit") +@router.post( + "/{position_id}/edit", + responses={ + status.HTTP_200_OK: {"description": "Position successfully updated"}, + status.HTTP_400_BAD_REQUEST: { + "description": "Position with this name already exists or other validation error" + }, + }, +) @inject async def edit_position( position_id: Annotated[int, Path(title="The ID of the position")], @@ -54,13 +74,21 @@ async def edit_position( _: Annotated[User, Depends(with_admin)], year_service: Annotated[YearService, Depends(Provide[Container.year_service])], ) -> None: - position_edit_in = PositionEditIn( - name=request.name, - can_desire=request.can_desire, - has_halls=request.has_halls, - is_manager=request.is_manager, - ) - await year_service.edit_position_by_position_id( - position_id=position_id, position_edit_in=position_edit_in - ) - logger.info("Position has been edited") + try: + position_edit_in = PositionEditIn( + name=request.name, + can_desire=request.can_desire, + has_halls=request.has_halls, + is_manager=request.is_manager, + save_for_next_year=request.save_for_next_year, + score=request.score, + description=request.description, + ) + await year_service.edit_position_by_position_id( + position_id=position_id, + position_edit_in=position_edit_in, + ) + logger.info(f"Position {position_id} has been edited") + + except DomainError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/volunteers/api/v1/admin/position/schemas.py b/volunteers/api/v1/admin/position/schemas.py index ae67f2b..df09fd8 100644 --- a/volunteers/api/v1/admin/position/schemas.py +++ b/volunteers/api/v1/admin/position/schemas.py @@ -9,6 +9,9 @@ class AddPositionRequest(BaseModel): can_desire: bool = False has_halls: bool = False is_manager: bool = False + save_for_next_year: bool = False + score: float = 1.0 + description: str | None = None class AddPositionResponse(BaseSuccessResponse): @@ -20,3 +23,6 @@ class EditPositionRequest(BaseModel): can_desire: bool | None = None has_halls: bool | None = None is_manager: bool | None = None + save_for_next_year: bool | None = None + score: float | None = None + description: str | None = None diff --git a/volunteers/api/v1/admin/user/router.py b/volunteers/api/v1/admin/user/router.py index 86031ed..6299a6b 100644 --- a/volunteers/api/v1/admin/user/router.py +++ b/volunteers/api/v1/admin/user/router.py @@ -1,13 +1,16 @@ +from datetime import UTC, datetime from typing import Annotated from dependency_injector.wiring import Provide, inject from fastapi import APIRouter, Depends, HTTPException, Path +from fastapi.responses import StreamingResponse from loguru import logger from volunteers.auth.deps import with_admin from volunteers.core.di import Container from volunteers.models import User from volunteers.schemas.user import UserUpdate +from volunteers.services.export import ExportService from volunteers.services.user import UserService from .schemas import AllUsersResponse, EditUserRequest, UserResponse @@ -15,6 +18,31 @@ router = APIRouter(tags=["user"]) +@router.get( + "/export-csv", + description="Export all users to CSV format", +) +@inject +async def export_users_csv( + _: Annotated[User, Depends(with_admin)], + export_service: Annotated[ExportService, Depends(Provide[Container.export_service])], +) -> StreamingResponse: + """Export all users data to CSV format including participation in years.""" + csv_content = await export_service.export_all_users() + + # Create filename with timestamp + timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S") + filename = f"all_users_{timestamp}.csv" + + logger.info("Exporting all users data to CSV") + + return StreamingResponse( + iter([csv_content]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + @router.get( "", response_model=AllUsersResponse, @@ -39,6 +67,7 @@ async def get_all_users( phone=user.phone, email=user.email, telegram_username=user.telegram_username, + gender=user.gender, is_admin=user.is_admin, ) for user in users @@ -74,6 +103,7 @@ async def get_user_by_id( email=user.email, telegram_username=user.telegram_username, is_admin=user.is_admin, + gender=user.gender, ) @@ -104,4 +134,5 @@ async def edit_user( email=updated_user.email, telegram_username=updated_user.telegram_username, is_admin=updated_user.is_admin, + gender=updated_user.gender, ) diff --git a/volunteers/api/v1/admin/user/schemas.py b/volunteers/api/v1/admin/user/schemas.py index 0a6c8a3..5bd25be 100644 --- a/volunteers/api/v1/admin/user/schemas.py +++ b/volunteers/api/v1/admin/user/schemas.py @@ -1,5 +1,7 @@ from pydantic import BaseModel +from volunteers.models.gender import Gender + class UserResponse(BaseModel): user_id: int @@ -14,6 +16,7 @@ class UserResponse(BaseModel): email: str | None telegram_username: str | None is_admin: bool + gender: Gender | None class AllUsersResponse(BaseModel): @@ -32,3 +35,4 @@ class EditUserRequest(BaseModel): telegram_username: str | None = None is_admin: bool | None = None telegram_id: int | None = None + gender: Gender | None diff --git a/volunteers/api/v1/admin/user_day/__tests__/test_router.py b/volunteers/api/v1/admin/user_day/__tests__/test_router.py index 325bda2..f0f05a3 100644 --- a/volunteers/api/v1/admin/user_day/__tests__/test_router.py +++ b/volunteers/api/v1/admin/user_day/__tests__/test_router.py @@ -53,6 +53,7 @@ def add_user_day_request() -> dict[str, Any]: "day_id": 77, "information": "User attended.", "attendance": "yes", + "position_id": 1, } @@ -62,6 +63,7 @@ def edit_user_day_request() -> dict[str, Any]: return { "information": "User did not attend.", "attendance": "no", + "position_id": 1, } @@ -97,6 +99,8 @@ class FakeUserDay: async def test_add_user_day_calls_service( app: AppWithContainer, add_user_day_request: dict[str, Any] ) -> None: + from volunteers.models.attendance import Attendance + fake_user_day = MagicMock(id=888) add_user_day_mock = AsyncMock(return_value=fake_user_day) app.test_year_service.add_user_day = add_user_day_mock @@ -110,7 +114,8 @@ async def test_add_user_day_calls_service( assert user_day_in.application_form_id == 42 assert user_day_in.day_id == 77 assert user_day_in.information == "User attended." - assert user_day_in.attendance == "yes" + # Attendance is always set to UNKNOWN in the router, regardless of input + assert user_day_in.attendance == Attendance.UNKNOWN @pytest.mark.asyncio @@ -128,4 +133,5 @@ async def test_edit_user_day_success( assert kwargs.get("user_day_id") == 123 user_day_edit_in = kwargs.get("user_day_edit_in") assert user_day_edit_in.information == "User did not attend." - assert user_day_edit_in.attendance == "no" + # Attendance is always set to None in the router, as it's managed via attendance API + assert user_day_edit_in.attendance is None diff --git a/volunteers/api/v1/admin/year/__tests__/test_router.py b/volunteers/api/v1/admin/year/__tests__/test_router.py index 97e4cb8..122befa 100644 --- a/volunteers/api/v1/admin/year/__tests__/test_router.py +++ b/volunteers/api/v1/admin/year/__tests__/test_router.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from datetime import UTC from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -10,6 +11,7 @@ from volunteers.auth.deps import with_admin from volunteers.core.di import Container from volunteers.models import User +from volunteers.models.gender import Gender class AppWithContainer(FastAPI): @@ -40,6 +42,7 @@ def admin_user() -> User: patronymic_ru="Тестович", first_name_en="Admin", last_name_en="Testov", + gender=Gender.MALE, is_admin=True, isu_id=1111, ) @@ -121,6 +124,7 @@ async def test_edit_year_success(app: AppWithContainer, edit_year_request: dict[ @pytest.mark.asyncio async def test_get_registration_forms_with_experience(app: AppWithContainer) -> None: """Test that get_registration_forms includes experience data for each user.""" + from datetime import datetime from volunteers.models import ApplicationForm, Position, User @@ -137,11 +141,21 @@ async def test_get_registration_forms_with_experience(app: AppWithContainer) -> phone="+1234567890", email="ivan@example.com", telegram_username="ivan_user", + gender=Gender.MALE, + ) + mock_position = Position( + id=1, year_id=1, name="Volunteer", can_desire=True, has_halls=False, is_manager=False ) - mock_position = Position(id=1, year_id=1, name="Volunteer", can_desire=True, has_halls=False) mock_form = ApplicationForm( - id=1, year_id=1, user_id=1, itmo_group="M1234", comments="Test comment" + id=1, + year_id=1, + user_id=1, + itmo_group="M1234", + comments="Test comment", + needs_invitation=False, + created_at=datetime(2023, 1, 1, tzinfo=UTC), + updated_at=datetime(2023, 1, 2, tzinfo=UTC), ) mock_form.user = mock_user mock_form.desired_positions = {mock_position} @@ -181,6 +195,7 @@ async def test_get_registration_forms_with_experience(app: AppWithContainer) -> assert form_data["phone"] == "+1234567890" assert form_data["email"] == "ivan@example.com" assert form_data["telegram_username"] == "ivan_user" + assert form_data["gender"] == "male" assert form_data["itmo_group"] == "M1234" assert form_data["comments"] == "Test comment" diff --git a/volunteers/api/v1/admin/year/router.py b/volunteers/api/v1/admin/year/router.py index 338baa9..11db14d 100644 --- a/volunteers/api/v1/admin/year/router.py +++ b/volunteers/api/v1/admin/year/router.py @@ -1,14 +1,18 @@ +from datetime import UTC, datetime from typing import Annotated from dependency_injector.wiring import Provide, inject -from fastapi import APIRouter, Depends, Path, Response, status +from fastapi import APIRouter, Depends, HTTPException, Path, Response, status +from fastapi.responses import HTMLResponse, StreamingResponse from loguru import logger from volunteers.auth.deps import with_admin from volunteers.core.di import Container +from volunteers.core.experience import get_rank from volunteers.models import User from volunteers.schemas.position import PositionOut from volunteers.schemas.year import YearEditIn, YearIn +from volunteers.services.export import ExportService from volunteers.services.user import UserService from volunteers.services.year import YearService @@ -18,6 +22,8 @@ EditYearRequest, RegistrationFormItem, RegistrationFormsResponse, + ResultItem, + ResultsResponse, UserListItem, UserListResponse, ) @@ -42,7 +48,10 @@ async def add_year( _: Annotated[User, Depends(with_admin)], year_service: Annotated[YearService, Depends(Provide[Container.year_service])], ) -> AddYearResponse: - year_in = YearIn(year_name=request.year_name, open_for_registration=False) + year_in = YearIn( + year_name=request.year_name, + open_for_registration=False, + ) year = await year_service.add_year(year_in=year_in) logger.info(f"Added year {request.year_name}") @@ -90,6 +99,7 @@ async def get_users_list( email=user.email, phone=user.phone, telegram_username=user.telegram_username, + gender=user.gender, is_registered=is_registered, ) for user, is_registered, itmo_group in user_data @@ -118,6 +128,9 @@ async def get_year_positions( can_desire=p.can_desire, has_halls=p.has_halls, is_manager=p.is_manager, + save_for_next_year=p.save_for_next_year, + score=p.score, + description=p.description, ) for p in positions ] @@ -154,6 +167,7 @@ async def get_registration_forms( phone=form.user.phone, email=form.user.email, telegram_username=form.user.telegram_username, + gender=form.user.gender, itmo_group=form.itmo_group, comments=form.comments, needs_invitation=form.needs_invitation, @@ -165,6 +179,7 @@ async def get_registration_forms( can_desire=p.can_desire, has_halls=p.has_halls, is_manager=p.is_manager, + save_for_next_year=p.save_for_next_year, ) for p in form.desired_positions ], @@ -175,3 +190,163 @@ async def get_registration_forms( ) return RegistrationFormsResponse(forms=form_items) + + +@router.get( + "/{year_id}/results", + response_model=ResultsResponse, + description="Get results for all registered volunteers in a year (admin only)", +) +@inject +async def get_year_results( + year_id: Annotated[int, Path(title="The ID of the year")], + _: Annotated[User, Depends(with_admin)], + year_service: Annotated[YearService, Depends(Provide[Container.year_service])], +) -> ResultsResponse: + results_data = await year_service.get_year_results(year_id=year_id) + + result_items: list[ResultItem] = [] + for form, total_assessments, calculated_experience in results_data: + rank = get_rank(calculated_experience) + result_items.append( + ResultItem( + user_id=form.user.id, + first_name_ru=form.user.first_name_ru, + last_name_ru=form.user.last_name_ru, + patronymic_ru=form.user.patronymic_ru, + first_name_en=form.user.first_name_en, + last_name_en=form.user.last_name_en, + experience=calculated_experience, + rank=rank, + total_assessments=total_assessments, + ) + ) + + return ResultsResponse(results=result_items) + + +@router.get( + "/{year_id}/export-csv", + description="Export all year data to ZIP archive with multiple CSV files", +) +@inject +async def export_year_csv( + year_id: Annotated[int, Path(title="The ID of the year")], + _: Annotated[User, Depends(with_admin)], + export_service: Annotated[ExportService, Depends(Provide[Container.export_service])], + year_service: Annotated[YearService, Depends(Provide[Container.year_service])], +) -> StreamingResponse: + """Export all year data to ZIP archive with multiple CSV files.""" + # Get year name for filename + year = await year_service.get_year_by_year_id(year_id) + if not year: + raise HTTPException(status_code=404, detail="Year not found") + + zip_content = await export_service.export_year_data(year_id) + + # Create filename with year name and timestamp + timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S") + filename = f"year_{year.year_name.replace(' ', '_')}_{timestamp}.zip" + + logger.info(f"Exporting year {year_id} data to ZIP") + + return StreamingResponse( + iter([zip_content]), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get( + "/{year_id}/certificates", + response_class=HTMLResponse, + description="Generate certificates for all volunteers with attendance (admin only)", +) +@inject +async def generate_certificates( + year_id: Annotated[int, Path(title="The ID of the year")], + _: Annotated[User, Depends(with_admin)], + year_service: Annotated[YearService, Depends(Provide[Container.year_service])], +) -> HTMLResponse: + """Generate HTML page with certificates for volunteers who attended at least one mandatory day.""" + from pathlib import Path + + from jinja2 import Environment, FileSystemLoader + + from volunteers.models.attendance import Attendance + + # Get year info + year = await year_service.get_year_by_year_id(year_id) + if not year: + raise HTTPException(status_code=404, detail="Year not found") + + # Get results for all volunteers + results_data = await year_service.get_year_results(year_id=year_id) + + logger.info( + f"Certificate generation for year {year_id}: Found {len(results_data)} registered volunteers" + ) + + # Filter volunteers who have at least one YES or LATE attendance on mandatory days + certificates = [] + for form, _total_assessments, calculated_experience in results_data: + # Check if volunteer has any attendance (YES or LATE) on mandatory days + has_attendance = any( + user_day.attendance in (Attendance.YES, Attendance.LATE) and user_day.day.mandatory + for user_day in form.user_days + ) + + if has_attendance: + # Format full name in English: Last Name, First Name (ФИО order) + full_name = f"{form.user.last_name_en} {form.user.first_name_en}" + + # Get rank and format it + rank = get_rank(calculated_experience) + rank_display = rank.replace("_", " ").title() + + certificates.append( + { + "full_name": full_name, + "full_name_en": full_name, # Same as full_name now + "rank": rank, + "rank_display": rank_display, + "experience": f"{calculated_experience:.2f}", + } + ) + + logger.info( + f"Generated {len(certificates)} certificates for year {year_id} (filtered by attendance)" + ) + + # Load Jinja2 template + # Path: router.py -> year/ -> admin/ -> v1/ -> api/ -> volunteers/ + templates_dir = Path(__file__).parent.parent.parent.parent.parent / "templates" + env = Environment(loader=FileSystemLoader(str(templates_dir)), autoescape=True) + template = env.get_template("certificates.html") + + # Load SVG background from volunteers/static/temp.svg and convert to base64 data URI + # Path: router.py -> year/ -> admin/ -> v1/ -> api/ -> volunteers/ -> static/ + svg_path = Path(__file__).parent.parent.parent.parent.parent / "static" / "temp.svg" + svg_data_uri = "" + logger.debug(f"Looking for SVG at: {svg_path}") + logger.debug(f"SVG exists: {svg_path.exists()}") + if svg_path.exists(): + import base64 + + svg_content = svg_path.read_bytes() + svg_base64 = base64.b64encode(svg_content).decode("utf-8") + svg_data_uri = f"data:image/svg+xml;base64,{svg_base64}" + logger.info(f"Successfully loaded SVG background from {svg_path}") + else: + logger.warning(f"SVG background not found at {svg_path}") + + # Render template + html_content = template.render( + year_name=year.year_name, + certificates=certificates, + svg_data_uri=svg_data_uri, + ) + + logger.info(f"Generated {len(certificates)} certificates for year {year_id}") + + return HTMLResponse(content=html_content) diff --git a/volunteers/api/v1/admin/year/schemas.py b/volunteers/api/v1/admin/year/schemas.py index 6a3905d..a9a6078 100644 --- a/volunteers/api/v1/admin/year/schemas.py +++ b/volunteers/api/v1/admin/year/schemas.py @@ -1,6 +1,7 @@ from pydantic import BaseModel from volunteers.models.attendance import Attendance +from volunteers.models.gender import Gender from volunteers.schemas.base import BaseSuccessResponse from volunteers.schemas.position import PositionOut @@ -20,8 +21,8 @@ class EditYearRequest(BaseModel): class UserListItem(BaseModel): id: int - first_name_ru: str - last_name_ru: str + first_name_ru: str | None + last_name_ru: str | None patronymic_ru: str | None first_name_en: str last_name_en: str @@ -29,6 +30,7 @@ class UserListItem(BaseModel): email: str | None phone: str | None telegram_username: str | None + gender: Gender | None is_registered: bool @@ -55,6 +57,7 @@ class RegistrationFormItem(BaseModel): phone: str | None email: str | None telegram_username: str | None + gender: Gender | None itmo_group: str | None comments: str needs_invitation: bool @@ -66,3 +69,19 @@ class RegistrationFormItem(BaseModel): class RegistrationFormsResponse(BaseModel): forms: list[RegistrationFormItem] + + +class ResultItem(BaseModel): + user_id: int + first_name_ru: str + last_name_ru: str + patronymic_ru: str | None + first_name_en: str + last_name_en: str + experience: float + rank: str + total_assessments: float + + +class ResultsResponse(BaseModel): + results: list[ResultItem] diff --git a/volunteers/api/v1/attendance/router.py b/volunteers/api/v1/attendance/router.py index 2018ef3..2496188 100644 --- a/volunteers/api/v1/attendance/router.py +++ b/volunteers/api/v1/attendance/router.py @@ -5,6 +5,7 @@ from volunteers.api.v1.attendance.schemas import ( AllAttendanceResponse, + AssessmentInAttendance, AttendanceItem, SaveDayAttendanceRequest, ) @@ -23,7 +24,10 @@ async def save_day_attendance( user: Annotated[User, Depends(with_user)], year_service: Annotated[YearService, Depends(Provide[Container.year_service])], ) -> None: - """Save attendance for a user day. Only admins or managers for the hall/year can set attendance.""" + """Save attendance for a user day. + + Only admins or managers for the hall/year can set attendance. + """ # Get the user day with all relationships user_day = await year_service.get_user_day_by_id(request.user_day_id) if not user_day: @@ -77,13 +81,24 @@ async def get_all_attendance( day_id=assignment.day_id, day_name=assignment.day.name, user_id=assignment.application_form.user.id, - user_name=f"{assignment.application_form.user.first_name_en} {assignment.application_form.user.last_name_en}", + user_name=( + f"{assignment.application_form.user.first_name_en} " + f"{assignment.application_form.user.last_name_en}" + ), user_telegram=assignment.application_form.user.telegram_username, position_id=assignment.position_id, position_name=assignment.position.name, hall_id=assignment.hall_id, hall_name=assignment.hall.name if assignment.hall else None, attendance=assignment.attendance, + assessments=[ + AssessmentInAttendance( + assessment_id=assessment.id, + comment=assessment.comment, + value=assessment.value, + ) + for assessment in assignment.assessments + ], ) for assignment in assignments if user.is_admin diff --git a/volunteers/api/v1/attendance/schemas.py b/volunteers/api/v1/attendance/schemas.py index 40b57b4..877f6f3 100644 --- a/volunteers/api/v1/attendance/schemas.py +++ b/volunteers/api/v1/attendance/schemas.py @@ -8,6 +8,12 @@ class SaveDayAttendanceRequest(BaseModel): attendance: Attendance +class AssessmentInAttendance(BaseModel): + assessment_id: int + comment: str + value: float + + class AttendanceItem(BaseModel): user_day_id: int day_id: int @@ -20,6 +26,7 @@ class AttendanceItem(BaseModel): hall_id: int | None hall_name: str | None attendance: Attendance + assessments: list[AssessmentInAttendance] class AllAttendanceResponse(BaseModel): diff --git a/volunteers/api/v1/auth/__tests__/test_router.py b/volunteers/api/v1/auth/__tests__/test_router.py index fdf523a..2bd0e22 100644 --- a/volunteers/api/v1/auth/__tests__/test_router.py +++ b/volunteers/api/v1/auth/__tests__/test_router.py @@ -96,11 +96,12 @@ def refresh_token_request() -> dict[str, Any]: @pytest.fixture -def app(config: MagicMock) -> FastAPIWithContainer: +def app(config: MagicMock, test_user: User) -> FastAPIWithContainer: container: Container = Container() user_service: MagicMock = MagicMock() user_service.get_user_by_telegram_id = AsyncMock(return_value=None) - user_service.create_user = AsyncMock(return_value=None) + user_service.create_user = AsyncMock(return_value=test_user) + user_service.update_user = AsyncMock(return_value=None) container.user_service.override(user_service) container.config.override(config) container.wire(modules=[auth_router]) @@ -162,7 +163,9 @@ async def test_login_success( ) -> None: app.container.user_service().get_user_by_telegram_id = AsyncMock( return_value=type( - "User", (), {"telegram_username": telegram_login_request["telegram_username"]} + "User", + (), + {"id": 123, "telegram_username": telegram_login_request["telegram_username"]}, )() ) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: diff --git a/volunteers/api/v1/auth/router.py b/volunteers/api/v1/auth/router.py index 67f60af..ca3fb7d 100644 --- a/volunteers/api/v1/auth/router.py +++ b/volunteers/api/v1/auth/router.py @@ -78,6 +78,7 @@ async def register( phone=request.phone, email=request.email, telegram_username=request.telegram_username, + gender=request.gender, is_admin=False, ) user = await user_service.create_user(user_in) @@ -230,6 +231,7 @@ async def me(user: Annotated[User, Depends(with_user)]) -> UserResponse: phone=user.phone, email=user.email, telegram_username=user.telegram_username, + gender=user.gender, ) @@ -259,4 +261,5 @@ async def update_user( phone=updated_user.phone, email=updated_user.email, telegram_username=updated_user.telegram_username, + gender=updated_user.gender, ) diff --git a/volunteers/api/v1/auth/schemas.py b/volunteers/api/v1/auth/schemas.py index 1f064c5..b504a10 100644 --- a/volunteers/api/v1/auth/schemas.py +++ b/volunteers/api/v1/auth/schemas.py @@ -1,5 +1,6 @@ from pydantic import BaseModel +from volunteers.models.gender import Gender from volunteers.schemas.base import BaseErrorResponse, BaseSuccessResponse @@ -29,6 +30,7 @@ class RegistrationRequest(TelegramLoginRequest): patronymic_ru: str | None = None phone: str | None = None email: str | None = None + gender: Gender | None = None class UserUpdateRequest(BaseModel): @@ -40,6 +42,7 @@ class UserUpdateRequest(BaseModel): patronymic_ru: str | None = None phone: str | None = None email: str | None = None + gender: Gender | None = None class RefreshTokenRequest(BaseModel): @@ -70,3 +73,4 @@ class UserResponse(BaseModel): phone: str | None email: str | None telegram_username: str | None + gender: Gender | None diff --git a/volunteers/api/v1/year/__tests__/test_router.py b/volunteers/api/v1/year/__tests__/test_router.py index 9305f69..7330f8b 100644 --- a/volunteers/api/v1/year/__tests__/test_router.py +++ b/volunteers/api/v1/year/__tests__/test_router.py @@ -10,6 +10,7 @@ from volunteers.core.di import Container from volunteers.models import ApplicationForm, Day, Hall, Position, User, UserDay, Year from volunteers.models.attendance import Attendance +from volunteers.models.gender import Gender if TYPE_CHECKING: from dependency_injector.containers import DeclarativeContainer @@ -40,6 +41,7 @@ def test_user() -> User: is_admin=False, isu_id=312656, telegram_username="denispotexin", + gender=Gender.MALE, ) @@ -50,7 +52,7 @@ def test_year() -> Year: @pytest.fixture def test_day() -> Day: - return Day(id=1, year_id=1, name="Day 1", information="Test day") + return Day(id=1, year_id=1, name="Day 1", information="Test day", assignment_published=True) @pytest.fixture @@ -157,7 +159,7 @@ async def with_user_dep() -> User: assert assignment["telegram"] == "denispotexin" assert assignment["position"] == "Test Position" assert assignment["hall"] == "Test Hall" - assert assignment["attendance"] == "yes" + # attendance is not included in the response (commented out in schema) @pytest.mark.asyncio diff --git a/volunteers/api/v1/year/router.py b/volunteers/api/v1/year/router.py index f45d397..c3b9de2 100644 --- a/volunteers/api/v1/year/router.py +++ b/volunteers/api/v1/year/router.py @@ -76,6 +76,7 @@ async def get_form_year( can_desire=p.can_desire, has_halls=p.has_halls, is_manager=p.is_manager, + save_for_next_year=p.save_for_next_year, ) for p in positions if p.can_desire @@ -89,6 +90,7 @@ async def get_form_year( can_desire=p.can_desire, has_halls=p.has_halls, is_manager=p.is_manager, + save_for_next_year=p.save_for_next_year, ) for p in sorted(form.desired_positions, key=lambda x: x.id) ] diff --git a/volunteers/app.py b/volunteers/app.py index a89193e..ec52ea3 100644 --- a/volunteers/app.py +++ b/volunteers/app.py @@ -4,17 +4,31 @@ from fastapi import FastAPI, Request, Response from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from loguru import logger from prometheus_client import Counter, make_asgi_app from volunteers.api.router import router as api_router -from volunteers.core.di import Container +from volunteers.core.di import container +from volunteers.core.socketio import sio, socket_app +from volunteers.sockets.assignments import register_assignment_handlers logger.remove() logger.add(sys.stdout, level="DEBUG") -container = Container() -container.wire() +# Wire the container with the necessary packages +container.wire( + modules=[__name__, "volunteers.api.v1.admin.assessment.router"], + packages=[ + "volunteers.services", + "volunteers.models", + "volunteers.schemas", + "volunteers.core", + "volunteers.auth", + "volunteers.api", + "volunteers.bot", + ], +) @asynccontextmanager @@ -26,6 +40,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # parse config c = container.config() logger.debug(f"Config: {c}") + + # Register WebSocket handlers + await register_assignment_handlers(sio) + logger.info("WebSocket handlers registered") + yield # Shutdown shutdown_resources = container.shutdown_resources() @@ -37,6 +56,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(api_router) +# Mount Socket.IO app at /socket.io +app.mount("/socket.io", socket_app) + +# Serve static files for certificates +app.mount("/static", StaticFiles(directory="volunteers/static"), name="static") + metrics_app = make_asgi_app() app.mount("/metrics", metrics_app) diff --git a/volunteers/auth/providers/__tests__/test_telegram.py b/volunteers/auth/providers/__tests__/test_telegram.py index 3f9a03d..030e9be 100644 --- a/volunteers/auth/providers/__tests__/test_telegram.py +++ b/volunteers/auth/providers/__tests__/test_telegram.py @@ -1,5 +1,5 @@ -import copy from collections.abc import Generator +from typing import ClassVar import dependency_injector.containers as containers import dependency_injector.providers as providers @@ -26,21 +26,33 @@ def token(container: Container) -> str: class TestTelegram: - test_data = TelegramLoginData( - auth_date=1746113463, - first_name="Матвей", - last_name="Колесов", - username="Vergil645", - id=773660947, - photo_url="https://t.me/i/userpic/320/3sH7KMNQRzYN_-Y4m75SgUL1-VpRwhoFy6u_4CRwiGU.jpg", - hash="494e35602ffba396978394e8d1f58bc00d098070366d3300acacdfadee75f26e", - ) + test_data_base: ClassVar[dict[str, str | int]] = { + "auth_date": 1746113463, + "first_name": "Матвей", + "last_name": "Колесов", + "username": "Vergil645", + "id": 773660947, + "photo_url": "https://t.me/i/userpic/320/3sH7KMNQRzYN_-Y4m75SgUL1-VpRwhoFy6u_4CRwiGU.jpg", + } + + @staticmethod + def generate_hash(data: dict[str, str | int], token: str) -> str: + import hashlib + import hmac + + data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(data.items()) if v is not None) + secret_key = hashlib.sha256(token.encode()).digest() + return hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() def test_valid_login(self, token: str) -> None: - data = self.test_data + data_dict = self.test_data_base.copy() + data_dict["hash"] = self.generate_hash(self.test_data_base, token) + data = TelegramLoginData(**data_dict) assert verify_telegram_login_hash(data, token) def test_invalid_login(self, token: str) -> None: - data = copy.deepcopy(self.test_data) - data.first_name += "!" + data_dict = self.test_data_base.copy() + data_dict["hash"] = self.generate_hash(self.test_data_base, token) + data_dict["first_name"] += "!" + data = TelegramLoginData(**data_dict) assert not verify_telegram_login_hash(data, token) diff --git a/volunteers/core/di.py b/volunteers/core/di.py index f11e333..01c583f 100644 --- a/volunteers/core/di.py +++ b/volunteers/core/di.py @@ -1,36 +1,47 @@ import dependency_injector.containers as containers import dependency_injector.providers as providers +import socketio # type: ignore[import-untyped] from volunteers.bot.notify import Notifier from volunteers.core.config import Config from volunteers.core.db import create_engine from volunteers.core.tg import get_bot +from volunteers.services.assessment import AssessmentService +from volunteers.services.export import ExportService from volunteers.services.i18n import I18nService from volunteers.services.legacy_user import LegacyUserService from volunteers.services.user import UserService from volunteers.services.year import YearService +def get_socketio_server() -> socketio.AsyncServer: + """Get the socketio server instance.""" + from volunteers.core.socketio import sio + + return sio + + class Container(containers.DeclarativeContainer): - wiring_config = containers.WiringConfiguration( - packages=[ - "volunteers.services", - "volunteers.models", - "volunteers.schemas", - "volunteers.core", - "volunteers.auth", - "volunteers.api", - "volunteers.bot", - ], - warn_unresolved=True, # type: ignore[call-arg] - ) + # Remove automatic wiring - will be done manually in app.py config = providers.Factory(Config) db = providers.Singleton(create_engine, config.provided.database.url) # logger = providers.Singleton(Logger) telegram = providers.Singleton(get_bot, config.provided.telegram.token) + socketio_server: providers.Provider[socketio.AsyncServer] = providers.Singleton( + get_socketio_server + ) + notifier = providers.Singleton(Notifier, bot=telegram, config=config) i18n_service = providers.Singleton(I18nService, locale="en") user_service = providers.Singleton(UserService) - year_service = providers.Singleton(YearService, notifier=notifier) + year_service = providers.Singleton( + YearService, notifier=notifier, socketio_server=socketio_server + ) legacy_user_service = providers.Singleton(LegacyUserService) + assessment_service = providers.Singleton(AssessmentService) + export_service = providers.Singleton(ExportService) + + +# Create a global container instance (not wired yet) +container = Container() diff --git a/volunteers/core/experience.py b/volunteers/core/experience.py new file mode 100644 index 0000000..61b1f88 --- /dev/null +++ b/volunteers/core/experience.py @@ -0,0 +1,58 @@ +"""Experience calculation constants and functions.""" + +from volunteers.models.attendance import Attendance + +# Attendance weights for experience calculation +ATTENDANCE_MAP = { + Attendance.YES: 1.0, + Attendance.LATE: 0.5, + Attendance.NO: 0.0, + Attendance.SICK: 0.0, + Attendance.UNKNOWN: 0.0, +} + +# Position multipliers for experience calculation +# TODO: Update this dict with actual position names and their multipliers +POSITION_MULTIPLIER = { + # Default multiplier for unknown positions + "_default": 1.0, +} + + +def get_position_multiplier(position_name: str) -> float: + """Get multiplier for a position name. + + Args: + position_name: Name of the position + + Returns: + Multiplier value for the position + """ + return POSITION_MULTIPLIER.get(position_name, POSITION_MULTIPLIER["_default"]) + + +# Rank thresholds +RANK_THRESHOLDS = { + "volunteer": 0.0, + "bronze_volunteer": 1.0, + "silver_volunteer": 2.0, +} + + +def get_rank(experience: float) -> str: + """Get rank name for given experience value. + + Args: + experience: Total experience value + + Returns: + Rank name (e.g., 'volunteer', 'bronze_volunteer', etc.) + """ + # Sort thresholds in descending order to find the highest matching rank + sorted_ranks = sorted(RANK_THRESHOLDS.items(), key=lambda x: x[1], reverse=True) + + for rank_name, threshold in sorted_ranks: + if experience >= threshold: + return rank_name + + return "Volunteer" # Default rank diff --git a/volunteers/core/socketio.py b/volunteers/core/socketio.py new file mode 100644 index 0000000..f61b574 --- /dev/null +++ b/volunteers/core/socketio.py @@ -0,0 +1,15 @@ +"""SocketIO configuration and initialization.""" + +import socketio # type: ignore[import-untyped] + +# Create Socket.IO server with ASGI support +sio = socketio.AsyncServer( + async_mode="asgi", + cors_allowed_origins="*", # In production, specify exact origins + logger=True, + engineio_logger=True, +) + +# Create ASGI application +# Socket.IO will be available at /socket.io/ +socket_app = socketio.ASGIApp(sio) diff --git a/volunteers/models/__init__.py b/volunteers/models/__init__.py index 644aa70..5e175a6 100644 --- a/volunteers/models/__init__.py +++ b/volunteers/models/__init__.py @@ -1,8 +1,10 @@ __all__ = [ "ApplicationForm", "Assessment", + "Attendance", "Day", "FormPositionAssociation", + "Gender", "Hall", "LegacyUser", "Position", @@ -11,6 +13,8 @@ "Year", ] +from .attendance import Attendance +from .gender import Gender from .models import ( ApplicationForm, Assessment, diff --git a/volunteers/models/gender.py b/volunteers/models/gender.py new file mode 100644 index 0000000..7908e87 --- /dev/null +++ b/volunteers/models/gender.py @@ -0,0 +1,7 @@ +import enum + + +class Gender(str, enum.Enum): + MALE = "male" + FEMALE = "female" + UNSPECIFIED = "unspecified" diff --git a/volunteers/models/models.py b/volunteers/models/models.py index 6800dad..62fb527 100644 --- a/volunteers/models/models.py +++ b/volunteers/models/models.py @@ -14,6 +14,7 @@ from .attendance import Attendance from .base import Base, TimestampMixin +from .gender import Gender class Year(Base, TimestampMixin): @@ -45,6 +46,10 @@ class User(Base, TimestampMixin): phone: Mapped[str | None] = mapped_column(String, nullable=True) email: Mapped[str | None] = mapped_column(String, nullable=True) telegram_username: Mapped[str | None] = mapped_column(String, nullable=True) + gender: Mapped[Gender | None] = mapped_column( + Enum(Gender, name="gender_enum", values_callable=lambda x: [e.value for e in x]), + nullable=True, + ) is_admin: Mapped[bool] = mapped_column(Boolean, default=False) @@ -85,10 +90,13 @@ class Position(Base, TimestampMixin): __tablename__ = "positions" id: Mapped[int] = mapped_column(Integer, primary_key=True) year_id: Mapped[int] = mapped_column(ForeignKey("years.id")) - name: Mapped[str] = mapped_column(String, unique=True) + name: Mapped[str] = mapped_column(String) can_desire: Mapped[bool] = mapped_column(Boolean, default=False) has_halls: Mapped[bool] = mapped_column(Boolean, default=False) is_manager: Mapped[bool] = mapped_column(Boolean, default=False) + score: Mapped[float] = mapped_column(Double, nullable=False, default=1.0, server_default="1.0") + save_for_next_year: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + description: Mapped[str | None] = mapped_column(String, nullable=True) user_days: Mapped[set[UserDay]] = relationship( back_populates="position", cascade="all, delete-orphan" diff --git a/volunteers/schemas/assessment.py b/volunteers/schemas/assessment.py index 19c3725..3477d79 100644 --- a/volunteers/schemas/assessment.py +++ b/volunteers/schemas/assessment.py @@ -1,15 +1,41 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator + + +class CommentRequired(ValueError): + def __init__(self) -> None: + super().__init__("Comment must not be empty") class AssessmentIn(BaseModel): user_day_id: int comment: str - value: float + value: float = Field(description="Assessment value (any real number)") + + @field_validator("comment") + @classmethod + def validate_comment(cls, value: str) -> str: + trimmed = value.strip() + if not trimmed: + raise CommentRequired() + return trimmed class AssessmentEditIn(BaseModel): comment: str | None - value: float | None + value: float | None = Field( + None, + description="Assessment value (any real number)", + ) + + @field_validator("comment") + @classmethod + def validate_optional_comment(cls, value: str | None) -> str | None: + if value is None: + return value + trimmed = value.strip() + if not trimmed: + raise CommentRequired() + return trimmed class AssessmentOut(AssessmentIn): diff --git a/volunteers/schemas/position.py b/volunteers/schemas/position.py index 97c485b..8369e43 100644 --- a/volunteers/schemas/position.py +++ b/volunteers/schemas/position.py @@ -7,6 +7,9 @@ class PositionIn(BaseModel): can_desire: bool has_halls: bool is_manager: bool + save_for_next_year: bool = False + score: float = 1.0 + description: str | None = None class PositionEditIn(BaseModel): @@ -14,6 +17,9 @@ class PositionEditIn(BaseModel): can_desire: bool | None has_halls: bool | None is_manager: bool | None + save_for_next_year: bool | None = None + score: float | None = None + description: str | None = None class PositionOut(PositionIn): diff --git a/volunteers/schemas/user.py b/volunteers/schemas/user.py index 472f08b..94afb97 100644 --- a/volunteers/schemas/user.py +++ b/volunteers/schemas/user.py @@ -1,5 +1,7 @@ from pydantic import BaseModel +from volunteers.models.gender import Gender + class UserIn(BaseModel): telegram_id: int @@ -15,6 +17,7 @@ class UserIn(BaseModel): phone: str | None email: str | None telegram_username: str | None + gender: Gender | None class UserUpdate(BaseModel): @@ -27,5 +30,6 @@ class UserUpdate(BaseModel): phone: str | None = None email: str | None = None telegram_username: str | None = None + gender: Gender | None = None is_admin: bool | None = None telegram_id: int | None = None diff --git a/volunteers/services/__tests__/test_base.py b/volunteers/services/__tests__/test_base.py index d1f13f9..d607f19 100644 --- a/volunteers/services/__tests__/test_base.py +++ b/volunteers/services/__tests__/test_base.py @@ -8,7 +8,8 @@ @pytest.mark.asyncio async def test_base_service_init_sets_logger_and_db() -> None: mock_db = MagicMock() - service = BaseService(db=mock_db) + service = BaseService() + service.db = mock_db assert hasattr(service, "logger") assert service.db == mock_db @@ -22,7 +23,8 @@ async def test_session_scope_yields_session() -> None: with patch("volunteers.services.base.async_sessionmaker") as mock_sessionmaker: mock_sessionmaker.return_value = lambda: mock_async_session mock_async_session.__aenter__.return_value = mock_session - service = BaseService(db=mock_db) + service = BaseService() + service.db = mock_db # Actually yield our mock_session when session_scope is used with patch.object(service, "session_scope", wraps=service.session_scope): async with service.session_scope() as session: diff --git a/volunteers/services/__tests__/test_errors.py b/volunteers/services/__tests__/test_errors.py index 378507c..b8ca374 100644 --- a/volunteers/services/__tests__/test_errors.py +++ b/volunteers/services/__tests__/test_errors.py @@ -18,4 +18,4 @@ def test_domain_error_message() -> None: def test_domain_error_can_be_raised_and_caught() -> None: with pytest.raises(DomainError) as excinfo: raise DomainError() - assert str(excinfo.value) == "something went wrong" + assert str(excinfo.value) == "Something went wrong" diff --git a/volunteers/services/__tests__/test_user.py b/volunteers/services/__tests__/test_user.py index fa0d2d4..61b18ee 100644 --- a/volunteers/services/__tests__/test_user.py +++ b/volunteers/services/__tests__/test_user.py @@ -6,6 +6,7 @@ import pytest from volunteers.models import User +from volunteers.models.gender import Gender from volunteers.schemas.user import UserIn from volunteers.services.user import UserService @@ -17,7 +18,9 @@ def mock_db() -> MagicMock: @pytest.fixture def user_service(mock_db: MagicMock) -> UserService: - return UserService(db=mock_db) + service = UserService() + service.db = mock_db + return service def make_async_cm(mock_session: Any) -> Any: @@ -42,6 +45,7 @@ async def test_get_user_by_telegram_id_found(user_service: UserService) -> None: phone="+1234567890", email="test@example.com", telegram_username="testuser", + gender=Gender.MALE, is_admin=False, ) mock_result: MagicMock = MagicMock() @@ -80,6 +84,7 @@ async def test_create_user(user_service: UserService) -> None: phone="+1234567890", email="denis@example.com", telegram_username="denis_potekhin", + gender=Gender.MALE, is_admin=True, ) @@ -93,5 +98,6 @@ async def test_create_user(user_service: UserService) -> None: assert result.telegram_id == user_in.telegram_id assert result.first_name_ru == user_in.first_name_ru assert result.is_admin == user_in.is_admin + assert result.gender == user_in.gender mock_session.add.assert_called_once_with(result) mock_session.commit.assert_awaited_once() diff --git a/volunteers/services/__tests__/test_year.py b/volunteers/services/__tests__/test_year.py index 4e7faa9..f63ca97 100644 --- a/volunteers/services/__tests__/test_year.py +++ b/volunteers/services/__tests__/test_year.py @@ -10,7 +10,7 @@ from volunteers.schemas.assessment import AssessmentEditIn, AssessmentIn from volunteers.schemas.day import DayEditIn, DayIn from volunteers.schemas.position import PositionEditIn, PositionIn -from volunteers.schemas.user_day import UserDayEditIn, UserDayIn +from volunteers.schemas.user_day import UserDayEditIn from volunteers.schemas.year import YearEditIn, YearIn from volunteers.services.year import ( ApplicationFormNotFound, @@ -30,7 +30,12 @@ def mock_db() -> MagicMock: @pytest.fixture def year_service(mock_db: MagicMock) -> YearService: - return YearService(db=mock_db) + mock_notifier = MagicMock() + mock_socketio = MagicMock() + mock_socketio.emit = AsyncMock() + service = YearService(notifier=mock_notifier, socketio_server=mock_socketio) + service.db = mock_db + return service def make_async_cm(mock_session: Any) -> AbstractAsyncContextManager[Any]: @@ -203,7 +208,9 @@ async def test_edit_year_by_year_id_not_found(year_service: YearService) -> None @pytest.mark.asyncio async def test_add_position(year_service: YearService) -> None: - position_in = PositionIn(year_id=1, name="Engineer", can_desire=True, has_halls=True) + position_in = PositionIn( + year_id=1, name="Engineer", can_desire=True, has_halls=True, is_manager=False + ) mock_session = MagicMock() mock_session.add = MagicMock() mock_session.commit = AsyncMock() @@ -217,8 +224,12 @@ async def test_add_position(year_service: YearService) -> None: @pytest.mark.asyncio async def test_edit_position_by_position_id_success(year_service: YearService) -> None: - position_edit = PositionEditIn(name="Manager", can_desire=False, has_halls=True) - dummy_position = Position(id=1, name="OldName") + position_edit = PositionEditIn( + name="Manager", can_desire=False, has_halls=True, is_manager=False + ) + dummy_position = Position( + id=1, year_id=1, name="OldName", can_desire=True, has_halls=False, is_manager=False + ) mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = dummy_position mock_session = MagicMock() @@ -232,7 +243,9 @@ async def test_edit_position_by_position_id_success(year_service: YearService) - @pytest.mark.asyncio async def test_edit_position_by_position_id_not_found(year_service: YearService) -> None: - position_edit = PositionEditIn(name="Manager", can_desire=False, has_halls=True) + position_edit = PositionEditIn( + name="Manager", can_desire=False, has_halls=True, is_manager=False + ) mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = None mock_session = MagicMock() @@ -314,37 +327,170 @@ async def test_edit_day_by_day_id_not_found(year_service: YearService) -> None: @pytest.mark.asyncio async def test_add_user_day(year_service: YearService) -> None: + from volunteers.models import UserDay + from volunteers.schemas.user_day import UserDayIn + + mock_author = MagicMock() + mock_author.telegram_username = "test_user" + + # Create UserDayIn input user_day_in = UserDayIn( - application_form_id=1, day_id=2, information="info", attendance=Attendance.YES + application_form_id=1, + day_id=2, + information="info", + attendance=Attendance.YES, + position_id=1, + hall_id=1, ) + + # Create mocks for awaitable attributes + mock_day = MagicMock(id=2) + mock_day.name = "Test Day" + mock_position = MagicMock(id=1) + mock_position.name = "Test Position" + mock_hall = MagicMock(id=1) + mock_hall.name = "Test Hall" + mock_application_form = MagicMock(id=1) + mock_user = MagicMock(id=100, telegram_username="test_user") + mock_user.first_name_ru = "Test" + mock_user.last_name_ru = "User" + + # Setup application_form with awaitable user + async def get_user(): + return mock_user + + mock_application_form_awaitable_attrs = MagicMock() + mock_application_form_awaitable_attrs.user = get_user() + mock_application_form.awaitable_attrs = mock_application_form_awaitable_attrs + + # Setup the user_day to have awaitable attrs + created_user_day = MagicMock(spec=UserDay) + created_user_day.application_form_id = 1 + created_user_day.day_id = 2 + created_user_day.information = "info" + created_user_day.attendance = Attendance.YES + created_user_day.position_id = 1 + created_user_day.hall_id = 1 + + # Create async mock functions that return the mocked objects + async def get_day(): + return mock_day + + async def get_application_form(): + return mock_application_form + + async def get_position(): + return mock_position + + async def get_hall(): + return mock_hall + + # Make awaitable_attrs properties call these async functions + mock_awaitable_attrs = MagicMock() + mock_awaitable_attrs.day = get_day() + mock_awaitable_attrs.application_form = get_application_form() + mock_awaitable_attrs.position = get_position() + mock_awaitable_attrs.hall = get_hall() + created_user_day.awaitable_attrs = mock_awaitable_attrs + mock_session = MagicMock() mock_session.add = MagicMock() mock_session.commit = AsyncMock() - with patch.object(year_service, "session_scope", return_value=make_async_cm(mock_session)): - user_day = await year_service.add_user_day(user_day_in) + + # Mock the notifier + year_service.notifier.notify = AsyncMock() + + with ( + patch.object(year_service, "session_scope", return_value=make_async_cm(mock_session)), + patch("volunteers.services.year.UserDay", return_value=created_user_day), + ): + user_day = await year_service.add_user_day(user_day_in, mock_author) assert user_day.application_form_id == user_day_in.application_form_id assert user_day.day_id == user_day_in.day_id assert user_day.information == user_day_in.information assert user_day.attendance == user_day_in.attendance - mock_session.add.assert_called_once_with(user_day) + mock_session.add.assert_called_once_with(created_user_day) mock_session.commit.assert_awaited_once() + year_service.notifier.notify.assert_awaited_once() @pytest.mark.asyncio async def test_edit_user_day_by_user_day_id_success(year_service: YearService) -> None: - user_day_edit = UserDayEditIn( - information="updated", attendance=Attendance.NO, position_id=1, hall_id=1 + from volunteers.models import User + + mock_author = User( + id=1, + telegram_id=123, + first_name_ru="Test", + last_name_ru="User", + first_name_en="Test", + last_name_en="User", + telegram_username="test_user", + is_admin=True, + isu_id=123, ) - dummy_user_day = UserDay( - id=1, information="old", attendance=Attendance.YES, position_id=1, hall_id=1 + + user_day_edit = UserDayEditIn( + information="updated", attendance=Attendance.NO, position_id=1, hall_id=None ) + + # Create mocks for awaitable attributes + mock_day = MagicMock(id=2) + mock_day.name = "Test Day" + mock_position = MagicMock(id=1) + mock_position.name = "Test Position" + mock_hall = MagicMock(id=1) + mock_hall.name = "Test Hall" + mock_application_form = MagicMock(id=1) + mock_user = MagicMock(id=100, telegram_username="test_user") + mock_user.first_name_ru = "Test" + mock_user.last_name_ru = "User" + + # Setup async functions for awaitable attrs + async def get_day(): + return mock_day + + async def get_application_form(): + return mock_application_form + + async def get_position(): + return mock_position + + async def get_hall(): + return mock_hall + + async def get_user(): + return mock_user + + mock_application_form_awaitable_attrs = MagicMock() + mock_application_form_awaitable_attrs.user = get_user() + mock_application_form.awaitable_attrs = mock_application_form_awaitable_attrs + + # Use MagicMock for dummy_user_day to allow setting awaitable_attrs + dummy_user_day = MagicMock(spec=UserDay) + dummy_user_day.id = 1 + dummy_user_day.information = "old" + dummy_user_day.attendance = Attendance.YES + + # Setup awaitable_attrs for dummy_user_day + mock_awaitable_attrs = MagicMock() + mock_awaitable_attrs.day = get_day() + mock_awaitable_attrs.application_form = get_application_form() + mock_awaitable_attrs.position = get_position() + mock_awaitable_attrs.hall = get_hall() + dummy_user_day.awaitable_attrs = mock_awaitable_attrs mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = dummy_user_day mock_session = MagicMock() mock_session.execute = AsyncMock(return_value=mock_result) + mock_session.get = AsyncMock(return_value=MagicMock()) # Mock session.get for Position/Hall mock_session.commit = AsyncMock() + + # Mock the notifier + year_service.notifier.notify = AsyncMock() + with patch.object(year_service, "session_scope", return_value=make_async_cm(mock_session)): - await year_service.edit_user_day_by_user_day_id(1, user_day_edit) + await year_service.edit_user_day_by_user_day_id(1, user_day_edit, mock_author) assert dummy_user_day.information == user_day_edit.information assert dummy_user_day.attendance == user_day_edit.attendance mock_session.commit.assert_awaited_once() @@ -355,6 +501,8 @@ async def test_edit_user_day_by_user_day_id_not_found(year_service: YearService) user_day_edit = UserDayEditIn( information="nope", attendance=Attendance.NO, position_id=1, hall_id=1 ) + mock_author = MagicMock() + mock_author.telegram_username = "test_user" mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = None mock_session = MagicMock() @@ -363,15 +511,16 @@ async def test_edit_user_day_by_user_day_id_not_found(year_service: YearService) patch.object(year_service, "session_scope", return_value=make_async_cm(mock_session)), pytest.raises(UserDayNotFound), ): - await year_service.edit_user_day_by_user_day_id(99, user_day_edit) + await year_service.edit_user_day_by_user_day_id(99, user_day_edit, mock_author) @pytest.mark.asyncio async def test_add_assessment(year_service: YearService) -> None: - assessment_in = AssessmentIn(user_day_id=1, comment="Nice", value=5) + assessment_in = AssessmentIn(user_day_id=1, comment="Nice", value=5.5) mock_session = MagicMock() mock_session.add = MagicMock() mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() with patch.object(year_service, "session_scope", return_value=make_async_cm(mock_session)): assessment = await year_service.add_assessment(assessment_in) assert assessment.user_day_id == assessment_in.user_day_id @@ -383,8 +532,8 @@ async def test_add_assessment(year_service: YearService) -> None: @pytest.mark.asyncio async def test_edit_assessment_by_assessment_id_success(year_service: YearService) -> None: - assessment_edit = AssessmentEditIn(comment="Updated", value=10) - dummy_assessment = Assessment(id=1, comment="Old", value=5) + assessment_edit = AssessmentEditIn(comment="Updated", value=9.5) + dummy_assessment = Assessment(id=1, comment="Old", value=5.25) mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = dummy_assessment mock_session = MagicMock() @@ -399,7 +548,7 @@ async def test_edit_assessment_by_assessment_id_success(year_service: YearServic @pytest.mark.asyncio async def test_edit_assessment_by_assessment_id_not_found(year_service: YearService) -> None: - assessment_edit = AssessmentEditIn(comment="Missing", value=0) + assessment_edit = AssessmentEditIn(comment="Missing", value=0.25) mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = None mock_session = MagicMock() diff --git a/volunteers/services/assessment.py b/volunteers/services/assessment.py new file mode 100644 index 0000000..affbdc6 --- /dev/null +++ b/volunteers/services/assessment.py @@ -0,0 +1,51 @@ +from sqlalchemy import select + +from volunteers.models import Assessment +from volunteers.schemas.assessment import AssessmentEditIn, AssessmentIn +from volunteers.services.base import BaseService + + +class AssessmentService(BaseService): + async def add_assessment(self, assessment_in: AssessmentIn) -> Assessment: + async with self.session_scope() as session: + assessment = Assessment(**assessment_in.model_dump()) + session.add(assessment) + await session.commit() + await session.refresh(assessment) + return assessment + + async def edit_assessment_by_assessment_id( + self, + assessment_id: int, + assessment_edit_in: AssessmentEditIn, + ) -> Assessment | None: + async with self.session_scope() as session: + assessment = await session.get(Assessment, assessment_id) + if not assessment: + return None + for key, value in assessment_edit_in.model_dump( + exclude_unset=True, + ).items(): + setattr(assessment, key, value) + await session.commit() + await session.refresh(assessment) + return assessment + + async def delete_assessment(self, assessment_id: int) -> bool: + async with self.session_scope() as session: + assessment = await session.get(Assessment, assessment_id) + if not assessment: + return False + await session.delete(assessment) + await session.commit() + return True + + async def get_assessments_by_user_day_id( + self, + user_day_id: int, + ) -> list[Assessment]: + async with self.session_scope() as session: + result = await session.execute( + select(Assessment).where(Assessment.user_day_id == user_day_id), + ) + return list(result.scalars().all()) diff --git a/volunteers/services/errors.py b/volunteers/services/errors.py index 45ec9f5..5f86b4d 100644 --- a/volunteers/services/errors.py +++ b/volunteers/services/errors.py @@ -4,3 +4,10 @@ class DomainError(Exception, ABC): def __init__(self, message: str = "Something went wrong") -> None: super().__init__(message) + + +class PositionAlreadyExists(DomainError): + """Raised when a position with the same name already exists for a year.""" + + def __init__(self) -> None: + super().__init__("Position with this name already exists for the year") diff --git a/volunteers/services/export.py b/volunteers/services/export.py new file mode 100644 index 0000000..5b48103 --- /dev/null +++ b/volunteers/services/export.py @@ -0,0 +1,452 @@ +"""Service for exporting data to CSV format.""" + +import csv +import zipfile +from io import BytesIO, StringIO + +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from volunteers.models import ( + ApplicationForm, + Day, + Hall, + Position, + User, + UserDay, + Year, +) +from volunteers.models.attendance import Attendance + +from .base import BaseService + + +class YearNotFoundError(ValueError): + """Year not found in database.""" + + def __init__(self, year_id: int) -> None: + super().__init__(f"Year with id {year_id} not found") + self.year_id = year_id + + +class ExportService(BaseService): + """Service for exporting data to CSV format.""" + + async def export_year_data(self, year_id: int) -> bytes: + """ + Export all year data to ZIP archive with multiple CSV files. + + Returns ZIP file content as bytes containing: + - users.csv - User information + - assignments.csv - Day assignments + - assessments.csv - All assessments + - days.csv - Days info + - positions.csv - Positions info + - halls.csv - Halls info + """ + async with self.session_scope() as session: + # Get year info + year_result = await session.execute(select(Year).where(Year.id == year_id)) + year = year_result.scalar_one_or_none() + + if not year: + raise YearNotFoundError(year_id) + + # Get all assignments for this year with all related data + assignments_result = await session.execute( + select(UserDay) + .join(ApplicationForm) + .where(ApplicationForm.year_id == year_id) + .options( + selectinload(UserDay.application_form).selectinload(ApplicationForm.user), + selectinload(UserDay.application_form).selectinload( + ApplicationForm.desired_positions + ), + selectinload(UserDay.day), + selectinload(UserDay.position), + selectinload(UserDay.hall), + selectinload(UserDay.assessments), + ) + .order_by(UserDay.day_id, UserDay.id) + ) + assignments = list(assignments_result.scalars().all()) + + # Get all forms for this year + forms_result = await session.execute( + select(ApplicationForm) + .where(ApplicationForm.year_id == year_id) + .options( + selectinload(ApplicationForm.user), + selectinload(ApplicationForm.desired_positions), + ) + .order_by(ApplicationForm.user_id) + ) + forms = list(forms_result.scalars().all()) + + # Get days + days_result = await session.execute( + select(Day).where(Day.year_id == year_id).order_by(Day.id) + ) + days = list(days_result.scalars().all()) + + # Get positions + positions_result = await session.execute( + select(Position).where(Position.year_id == year_id).order_by(Position.id) + ) + positions = list(positions_result.scalars().all()) + + # Get halls + halls_result = await session.execute( + select(Hall).where(Hall.year_id == year_id).order_by(Hall.id) + ) + halls = list(halls_result.scalars().all()) + + # Create ZIP archive + zip_buffer = BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + # 1. Users and Registration Forms CSV + users_csv = self._create_users_forms_csv(forms) + zip_file.writestr("01_users_and_registrations.csv", users_csv) + + # 2. Days CSV + days_csv = self._create_days_csv(days) + zip_file.writestr("02_days.csv", days_csv) + + # 3. Positions CSV + positions_csv = self._create_positions_csv(positions) + zip_file.writestr("03_positions.csv", positions_csv) + + # 4. Halls CSV + halls_csv = self._create_halls_csv(halls) + zip_file.writestr("04_halls.csv", halls_csv) + + # 5. Assignments CSV + assignments_csv = self._create_assignments_csv(assignments) + zip_file.writestr("05_assignments.csv", assignments_csv) + + # 6. Assessments CSV + assessments_csv = self._create_assessments_csv(assignments) + zip_file.writestr("06_assessments.csv", assessments_csv) + + zip_buffer.seek(0) + return zip_buffer.getvalue() + + def _create_users_forms_csv(self, forms: list[ApplicationForm]) -> str: + """Create CSV with users and their registration forms.""" + output = StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "User ID", + "Last Name (RU)", + "First Name (RU)", + "Patronymic (RU)", + "Last Name (EN)", + "First Name (EN)", + "Email", + "Phone", + "Telegram Username", + "Telegram ID", + "Gender", + "ISU ID", + "Is Admin", + "ITMO Group", + "Comments", + "Needs Invitation", + "Desired Positions", + "Created At", + "Updated At", + ] + ) + + for form in forms: + user = form.user + desired_positions = ", ".join( + [p.name for p in sorted(form.desired_positions, key=lambda x: x.id)] + ) + + writer.writerow( + [ + user.id, + user.last_name_ru, + user.first_name_ru, + user.patronymic_ru or "", + user.last_name_en, + user.first_name_en, + user.email or "", + user.phone or "", + user.telegram_username or "", + user.telegram_id or "", + user.gender or "", + user.isu_id or "", + "Yes" if user.is_admin else "No", + form.itmo_group or "", + form.comments or "", + "Yes" if form.needs_invitation else "No", + desired_positions, + form.created_at.isoformat() if hasattr(form, "created_at") else "", + form.updated_at.isoformat() if hasattr(form, "updated_at") else "", + ] + ) + + return output.getvalue() + + def _create_days_csv(self, days: list[Day]) -> str: + """Create CSV with days information.""" + output = StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "Day ID", + "Day Name", + "Information", + "Score", + "Mandatory", + "Assignment Published", + "Created At", + "Updated At", + ] + ) + + for day in days: + writer.writerow( + [ + day.id, + day.name, + day.information or "", + day.score or "", + "Yes" if day.mandatory else "No", + "Yes" if day.assignment_published else "No", + day.created_at.isoformat() if hasattr(day, "created_at") else "", + day.updated_at.isoformat() if hasattr(day, "updated_at") else "", + ] + ) + + return output.getvalue() + + def _create_positions_csv(self, positions: list[Position]) -> str: + """Create CSV with positions information.""" + output = StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "Position ID", + "Position Name", + "Can Desire", + "Has Halls", + "Is Manager", + "Created At", + "Updated At", + ] + ) + + for position in positions: + writer.writerow( + [ + position.id, + position.name, + "Yes" if position.can_desire else "No", + "Yes" if position.has_halls else "No", + "Yes" if position.is_manager else "No", + position.created_at.isoformat() if hasattr(position, "created_at") else "", + position.updated_at.isoformat() if hasattr(position, "updated_at") else "", + ] + ) + + return output.getvalue() + + def _create_halls_csv(self, halls: list[Hall]) -> str: + """Create CSV with halls information.""" + output = StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "Hall ID", + "Hall Name", + "Description", + "Created At", + "Updated At", + ] + ) + + for hall in halls: + writer.writerow( + [ + hall.id, + hall.name, + hall.description or "", + hall.created_at.isoformat() if hasattr(hall, "created_at") else "", + hall.updated_at.isoformat() if hasattr(hall, "updated_at") else "", + ] + ) + + return output.getvalue() + + def _create_assignments_csv(self, assignments: list[UserDay]) -> str: + """Create CSV with assignments information.""" + output = StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "Assignment ID", + "User ID", + "User Name (RU)", + "User Name (EN)", + "Day ID", + "Day Name", + "Position ID", + "Position Name", + "Hall ID", + "Hall Name", + "Attendance", + "Information", + "Created At", + "Updated At", + ] + ) + + for user_day in assignments: + user = user_day.application_form.user + day = user_day.day + + writer.writerow( + [ + user_day.id, + user.id, + f"{user.last_name_ru} {user.first_name_ru}", + f"{user.first_name_en} {user.last_name_en}", + day.id, + day.name, + user_day.position.id, + user_day.position.name, + user_day.hall.id if user_day.hall else "", + user_day.hall.name if user_day.hall else "", + user_day.attendance.value if user_day.attendance else Attendance.UNKNOWN.value, + user_day.information or "", + user_day.created_at.isoformat() if hasattr(user_day, "created_at") else "", + user_day.updated_at.isoformat() if hasattr(user_day, "updated_at") else "", + ] + ) + + return output.getvalue() + + def _create_assessments_csv(self, assignments: list[UserDay]) -> str: + """Create CSV with all assessments.""" + output = StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "Assessment ID", + "User Day ID", + "User ID", + "User Name (RU)", + "Day ID", + "Day Name", + "Value", + "Comment", + "Created At", + "Updated At", + ] + ) + + for user_day in assignments: + user = user_day.application_form.user + for assessment in user_day.assessments: + writer.writerow( + [ + assessment.id, + user_day.id, + user.id, + f"{user.last_name_ru} {user.first_name_ru}", + user_day.day.id, + user_day.day.name, + assessment.value, + assessment.comment or "", + assessment.created_at.isoformat() + if hasattr(assessment, "created_at") + else "", + assessment.updated_at.isoformat() + if hasattr(assessment, "updated_at") + else "", + ] + ) + + return output.getvalue() + + async def export_all_users(self) -> str: + """ + Export all users to CSV format. + Includes: user data and participation in years. + + Returns CSV content as string. + """ + async with self.session_scope() as session: + # Get all users with their application forms + users_result = await session.execute( + select(User) + .options(selectinload(User.application_forms).selectinload(ApplicationForm.year)) + .order_by(User.id) + ) + users = list(users_result.scalars().all()) + + # Create CSV + output = StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow( + [ + "User ID", + "Telegram ID", + "Last Name (RU)", + "First Name (RU)", + "Patronymic (RU)", + "Last Name (EN)", + "First Name (EN)", + "Email", + "Phone", + "Telegram Username", + "Gender", + "ISU ID", + "Is Admin", + "Participated Years", + "Created At", + "Updated At", + ] + ) + + # Write data rows + for user in users: + # Get years user participated in + participated_years = ", ".join( + sorted([form.year.year_name for form in user.application_forms]) + ) + + writer.writerow( + [ + user.id, + user.telegram_id or "", + user.last_name_ru, + user.first_name_ru, + user.patronymic_ru or "", + user.last_name_en, + user.first_name_en, + user.email or "", + user.phone or "", + user.telegram_username or "", + user.gender or "", + user.isu_id or "", + "Yes" if user.is_admin else "No", + participated_years, + user.created_at.isoformat() if hasattr(user, "created_at") else "", + user.updated_at.isoformat() if hasattr(user, "updated_at") else "", + ] + ) + + return output.getvalue() diff --git a/volunteers/services/user.py b/volunteers/services/user.py index 700a2db..83f424f 100644 --- a/volunteers/services/user.py +++ b/volunteers/services/user.py @@ -35,6 +35,7 @@ async def create_user(self, user_in: UserIn) -> User: phone=user_in.phone, email=user_in.email, telegram_username=user_in.telegram_username, + gender=user_in.gender, is_admin=user_in.is_admin, ) async with self.session_scope() as session: @@ -71,6 +72,8 @@ async def update_user(self, user_id: int, user_update: UserUpdate) -> User | Non user.email = user_update.email if user_update.telegram_username is not None: user.telegram_username = user_update.telegram_username + if user_update.gender is not None: + user.gender = user_update.gender await session.commit() return user diff --git a/volunteers/services/year.py b/volunteers/services/year.py index 798307f..03a07e1 100644 --- a/volunteers/services/year.py +++ b/volunteers/services/year.py @@ -1,10 +1,12 @@ from dataclasses import dataclass +import socketio # type: ignore[import-untyped] from sqlalchemy import and_, delete, select from sqlalchemy.orm import selectinload from volunteers.api.v1.admin.year.schemas import ExperienceItem from volunteers.bot.notify import Notifier +from volunteers.core.experience import ATTENDANCE_MAP from volunteers.models import ( ApplicationForm, Assessment, @@ -24,9 +26,10 @@ from volunteers.schemas.position import PositionEditIn, PositionIn from volunteers.schemas.user_day import UserDayEditIn, UserDayIn from volunteers.schemas.year import YearEditIn, YearIn +from volunteers.sockets.assignments import broadcast_assignment_update from .base import BaseService -from .errors import DomainError +from .errors import DomainError, PositionAlreadyExists class ApplicationFormNotFound(DomainError): @@ -70,8 +73,13 @@ class ManagerForYear: class YearService(BaseService): - def __init__(self, notifier: Notifier) -> None: + def __init__( + self, + notifier: Notifier, + socketio_server: socketio.AsyncServer, + ) -> None: self.notifier = notifier + self.socketio_server = socketio_server super().__init__() async def get_years(self) -> list[Year]: @@ -89,7 +97,8 @@ async def get_positions_by_year_id(self, year_id: int) -> list[Position]: result = await session.execute( select(Position).where(Position.year_id == year_id).order_by(Position.id) ) - return list(result.scalars().all()) + positions = list(result.scalars().all()) + return positions async def get_days_by_year_id(self, year_id: int) -> list[Day]: async with self.session_scope() as session: @@ -207,6 +216,63 @@ async def add_year(self, year_in: YearIn) -> Year: async with self.session_scope() as session: session.add(created_year) await session.commit() + # If requested, copy positions from previous year that were marked to be saved + prev_candidate = await session.execute( + select(Year).where(Year.id < created_year.id).order_by(Year.id.desc()).limit(1) + ) + prev_year_obj = prev_candidate.scalar_one_or_none() + if not prev_year_obj: + self.logger.info( + f"No previous year found for year {created_year.id}; skipping position copy" + ) + else: + prev_id = prev_year_obj.id + self.logger.info( + f"Auto-detected previous_year_id={prev_id} for year {created_year.id}" + ) + + # Fetch positions from previous year marked to be saved for next year + prev_positions_result = await session.execute( + select(Position).where( + and_(Position.year_id == prev_id, Position.save_for_next_year.is_(True)) + ) + ) + prev_positions = prev_positions_result.scalars().all() + self.logger.info( + f"Found {len(prev_positions)} positions in year {prev_id} marked to save for next year" + ) + + for p in prev_positions: + try: + new_pos = Position( + year_id=created_year.id, + name=p.name, + can_desire=p.can_desire, + has_halls=p.has_halls, + is_manager=p.is_manager, + score=p.score, + description=p.description, + save_for_next_year=bool(p.save_for_next_year), + ) + session.add(new_pos) + self.logger.debug( + f"Queued copy of position '{p.name}' (id={p.id}) from year {prev_id} to year {created_year.id}" + ) + except Exception: # pragma: no cover - defensive logging + # If any issue occurs during copy (e.g., uniqueness constraint), log details and continue + self.logger.exception( + f"Failed to prepare copy for position {p.name} (id={getattr(p, 'id', None)}) from year {prev_id}" + ) + + # Commit copied positions (if any) + try: + await session.commit() + except Exception: # pragma: no cover - database may reject some inserts + # Log full details of the failure and rollback to keep session consistent + self.logger.exception( + f"Failed to commit copied positions for year {created_year.id}" + ) + await session.rollback() return created_year async def edit_year_by_year_id(self, year_id: int, year_edit_in: YearEditIn) -> None: @@ -230,14 +296,29 @@ async def get_position_by_id(self, position_id: int) -> Position | None: return result.scalar_one_or_none() async def add_position(self, position_in: PositionIn) -> Position: - created_position = Position( - year_id=position_in.year_id, - name=position_in.name, - can_desire=position_in.can_desire, - has_halls=position_in.has_halls, - is_manager=position_in.is_manager, - ) async with self.session_scope() as session: + # Check uniqueness per year + existing = await session.execute( + select(Position).where( + and_( + Position.year_id == position_in.year_id, + Position.name == position_in.name, + ) + ) + ) + if existing.scalar_one_or_none() is not None: + raise PositionAlreadyExists() + + created_position = Position( + year_id=position_in.year_id, + name=position_in.name, + can_desire=position_in.can_desire, + has_halls=position_in.has_halls, + is_manager=position_in.is_manager, + save_for_next_year=position_in.save_for_next_year, + score=position_in.score, + description=position_in.description, + ) session.add(created_position) await session.commit() return created_position @@ -245,25 +326,48 @@ async def add_position(self, position_in: PositionIn) -> Position: async def edit_position_by_position_id( self, position_id: int, position_edit_in: PositionEditIn ) -> None: + self.logger.info( + f"Editing position {position_id} with data: {position_edit_in.model_dump()}" + ) async with self.session_scope() as session: existing_position = await session.execute( select(Position).where(Position.id == position_id) ) - updated_position = existing_position.scalar_one_or_none() if not updated_position: raise PositionNotFound() if (name := position_edit_in.name) is not None: + # Ensure uniqueness of position name within the same year (exclude current position) + existing = await session.execute( + select(Position).where( + and_( + Position.year_id == updated_position.year_id, + Position.name == name, + Position.id != position_id, + ) + ) + ) + if existing.scalar_one_or_none() is not None: + raise PositionAlreadyExists() + updated_position.name = name + if (can_desire := position_edit_in.can_desire) is not None: updated_position.can_desire = can_desire if (has_halls := position_edit_in.has_halls) is not None: updated_position.has_halls = has_halls if (is_manager := position_edit_in.is_manager) is not None: updated_position.is_manager = is_manager + if (score := position_edit_in.score) is not None: + updated_position.score = score + if (save_flag := position_edit_in.save_for_next_year) is not None: + updated_position.save_for_next_year = save_flag + if position_edit_in.description is not None: + updated_position.description = position_edit_in.description await session.commit() + self.logger.info(f"Position {position_id} updated successfully") async def add_day(self, day_in: DayIn) -> Day: created_day = Day( @@ -287,6 +391,8 @@ async def edit_day_by_day_id(self, day_id: int, day_edit_in: DayEditIn) -> None: if not updated_day: raise DayNotFound() + old_assignment_published = updated_day.assignment_published + if (name := day_edit_in.name) is not None: updated_day.name = name if (information := day_edit_in.information) is not None: @@ -300,6 +406,18 @@ async def edit_day_by_day_id(self, day_id: int, day_edit_in: DayEditIn) -> None: await session.commit() + # Broadcast if assignment_published status changed + if ( + day_edit_in.assignment_published is not None + and old_assignment_published != day_edit_in.assignment_published + ): + await broadcast_assignment_update( + self.socketio_server, + day_id, + "published" if day_edit_in.assignment_published else "unpublished", + None, + ) + async def add_user_day(self, user_day_in: UserDayIn, author: User) -> UserDay: created_user_day = UserDay( application_form_id=user_day_in.application_form_id, @@ -320,6 +438,14 @@ async def add_user_day(self, user_day_in: UserDayIn, author: User) -> UserDay: await self.notifier.notify( f"[{day.name}] {user.first_name_ru} {user.last_name_ru} (@{user.telegram_username}) \n(unassigned) -> {position.name} {hall.name if hall else ''}\n(by @{author.telegram_username})" ) + + # Broadcast assignment update via WebSocket + await broadcast_assignment_update( + self.socketio_server, + user_day_in.day_id, + "created", + {"user_day_id": created_user_day.id}, + ) return created_user_day async def edit_user_day_by_user_day_id( @@ -366,6 +492,14 @@ async def edit_user_day_by_user_day_id( f"[{day.name}] {user.first_name_ru} {user.last_name_ru} (@{user.telegram_username})\n{old_position.name} {old_hall.name if old_hall else ''} -> {new_position.name} {new_hall.name if new_hall else ''}\n(by @{author.telegram_username})" ) + # Broadcast assignment update via WebSocket + await broadcast_assignment_update( + self.socketio_server, + day.id, + "updated", + {"user_day_id": updated_user_day.id}, + ) + async def delete_user_day_by_user_day_id(self, user_day_id: int, author: User) -> None: """Delete a user day by its ID.""" async with self.session_scope() as session: @@ -376,6 +510,8 @@ async def delete_user_day_by_user_day_id(self, user_day_id: int, author: User) - if not user_day: raise UserDayNotFound() + day_id = user_day.day_id + await session.delete(user_day) await session.commit() @@ -388,6 +524,14 @@ async def delete_user_day_by_user_day_id(self, user_day_id: int, author: User) - f"[{day.name}] {user.first_name_ru} {user.last_name_ru} (@{user.telegram_username})\n{position.name} {hall.name if hall else ''} -> (unassigned)\n(by @{author.telegram_username})" ) + # Broadcast assignment update via WebSocket + await broadcast_assignment_update( + self.socketio_server, + day_id, + "deleted", + {"user_day_id": user_day_id}, + ) + async def copy_assignments_from_day( self, source_day_id: int, @@ -485,6 +629,16 @@ async def copy_assignments_from_day( copied_count += 1 await session.commit() + + # Broadcast bulk assignment update via WebSocket + if copied_count > 0: + await broadcast_assignment_update( + self.socketio_server, + target_day_id, + "bulk_created", + {"count": copied_count}, + ) + return copied_count async def add_assessment(self, assessment_in: AssessmentIn) -> Assessment: @@ -496,6 +650,7 @@ async def add_assessment(self, assessment_in: AssessmentIn) -> Assessment: async with self.session_scope() as session: session.add(created_assessment) await session.commit() + await session.refresh(created_assessment) return created_assessment async def edit_assessment_by_assessment_id( @@ -686,3 +841,145 @@ async def get_user_experience(self, user_id: int) -> list[ExperienceItem]: ) return list(experience_by_year.values()) + + async def calculate_year_experience(self, year_id: int, user_id: int) -> float: + """Calculate experience for a user in a specific year. + + Formula: + experience = ( + sum(day.score * attendance_map[attendance] * position_multiplier[position] + for day in mandatory_days) / number of mandatory_days + ) + sum(assessment.value for all assessments in year) + + Args: + year_id: ID of the year to calculate for + user_id: ID of the user + + Returns: + Calculated experience value for the year + """ + async with self.session_scope() as session: + # Get all days for this year + days_result = await session.execute(select(Day).where(Day.year_id == year_id)) + all_days = list(days_result.scalars().all()) + + # Get mandatory days + mandatory_days = [day for day in all_days if day.mandatory] + mandatory_days_count = len(mandatory_days) + + if mandatory_days_count == 0: + # No mandatory days, skip attendance-based experience + mandatory_experience = 0.0 + else: + # Get user's assignments for mandatory days + user_days_result = await session.execute( + select(UserDay) + .join(ApplicationForm) + .join(Day) + .where( + and_( + ApplicationForm.user_id == user_id, + ApplicationForm.year_id == year_id, + Day.mandatory.is_(True), + ) + ) + .options( + selectinload(UserDay.day), + selectinload(UserDay.position), + selectinload(UserDay.assessments), + ) + ) + user_days = list(user_days_result.scalars().all()) + + # Calculate attendance-based experience + attendance_experience_sum = 0.0 + for user_day in user_days: + day_score = user_day.day.score if user_day.day.score else 0.0 + attendance_weight = ATTENDANCE_MAP.get(user_day.attendance, 0.0) + position_score = user_day.position.score if user_day.position.score else 1.0 + + attendance_experience_sum += day_score * attendance_weight * position_score + + # Average over number of mandatory days + mandatory_experience = attendance_experience_sum / mandatory_days_count + + # Get all assessments for this user in this year + assessments_result = await session.execute( + select(Assessment) + .join(UserDay) + .join(ApplicationForm) + .where( + and_( + ApplicationForm.user_id == user_id, + ApplicationForm.year_id == year_id, + ) + ) + ) + assessments = list(assessments_result.scalars().all()) + + # Calculate total assessments value + assessments_sum = sum(assessment.value for assessment in assessments) + + return mandatory_experience + assessments_sum + + async def get_year_results(self, year_id: int) -> list[tuple[ApplicationForm, float, float]]: + """Get results for all registered volunteers in a year. + + Returns list of tuples: (application_form, total_assessments_sum, calculated_experience) + + Experience is calculated dynamically by summing experience from all previous years + (compared by year_id) plus current year experience. + """ + async with self.session_scope() as session: + # Get all application forms for this year with user and user_days data + result = await session.execute( + select(ApplicationForm) + .where(ApplicationForm.year_id == year_id) + .options( + selectinload(ApplicationForm.user), + selectinload(ApplicationForm.user_days).selectinload(UserDay.assessments), + selectinload(ApplicationForm.user_days).selectinload(UserDay.day), + ) + .order_by(ApplicationForm.user_id) + ) + forms = list(result.scalars().all()) + + # Calculate total assessments sum and experience for each form + results: list[tuple[ApplicationForm, float, float]] = [] + for form in forms: + total_assessments = sum( + assessment.value + for user_day in form.user_days + for assessment in user_day.assessments + ) + + # Calculate experience dynamically: + # Sum experience from all previous years (by year_id) + current year + previous_experience = 0.0 + + # Get all application forms for this user in previous years (year_id < current) + prev_forms_result = await session.execute( + select(ApplicationForm) + .where( + and_( + ApplicationForm.user_id == form.user_id, + ApplicationForm.year_id < year_id, + ) + ) + .order_by(ApplicationForm.year_id) + ) + prev_forms = list(prev_forms_result.scalars().all()) + + # Calculate experience for each previous year + for prev_form in prev_forms: + prev_year_exp = await self.calculate_year_experience( + prev_form.year_id, form.user_id + ) + previous_experience += prev_year_exp + + # Calculate current year exp + current_year_exp = await self.calculate_year_experience(year_id, form.user_id) + + results.append((form, total_assessments, previous_experience + current_year_exp)) + + return results diff --git a/volunteers/sockets/__init__.py b/volunteers/sockets/__init__.py new file mode 100644 index 0000000..2daeb79 --- /dev/null +++ b/volunteers/sockets/__init__.py @@ -0,0 +1,5 @@ +"""WebSocket handlers package.""" + +from .assignments import broadcast_assignment_update, register_assignment_handlers + +__all__ = ["broadcast_assignment_update", "register_assignment_handlers"] diff --git a/volunteers/sockets/assignments.py b/volunteers/sockets/assignments.py new file mode 100644 index 0000000..16e1d6f --- /dev/null +++ b/volunteers/sockets/assignments.py @@ -0,0 +1,68 @@ +"""WebSocket handlers for assignment updates.""" + +from typing import Any + +import socketio # type: ignore[import-untyped] +from loguru import logger + + +async def register_assignment_handlers(sio: socketio.AsyncServer) -> None: + """Register all assignment-related socket handlers.""" + + @sio.event # type: ignore[misc] + async def connect(sid: str, environ: dict[str, Any]) -> None: + """Handle client connection.""" + logger.info(f"Client connected: {sid}") + + @sio.event # type: ignore[misc] + async def disconnect(sid: str) -> None: + """Handle client disconnection.""" + logger.info(f"Client disconnected: {sid}") + + @sio.on("subscribe_day_assignments") # type: ignore[misc] + async def handle_subscribe(sid: str, data: dict[str, Any]) -> None: + """Subscribe to assignment updates for a specific day.""" + day_id = data.get("day_id") + if not day_id: + logger.warning(f"Client {sid} tried to subscribe without day_id") + return + + room = f"day_assignments_{day_id}" + await sio.enter_room(sid, room) + logger.info(f"Client {sid} subscribed to {room}") + + @sio.on("unsubscribe_day_assignments") # type: ignore[misc] + async def handle_unsubscribe(sid: str, data: dict[str, Any]) -> None: + """Unsubscribe from assignment updates for a specific day.""" + day_id = data.get("day_id") + if not day_id: + logger.warning(f"Client {sid} tried to unsubscribe without day_id") + return + + room = f"day_assignments_{day_id}" + await sio.leave_room(sid, room) + logger.info(f"Client {sid} unsubscribed from {room}") + + +async def broadcast_assignment_update( + sio: socketio.AsyncServer, + day_id: int, + event_type: str, + assignment_data: dict[str, Any] | None = None, +) -> None: + """ + Broadcast assignment update to all clients subscribed to the day. + + Args: + sio: SocketIO server instance + day_id: ID of the day whose assignments changed + event_type: Type of event ('created', 'updated', 'deleted', 'published') + assignment_data: Optional assignment data to send with the event + """ + room = f"day_assignments_{day_id}" + await sio.emit( + "assignment_updated", + {"type": event_type, "day_id": day_id, "assignment": assignment_data}, + room=room, + ) + logger.info(f"Broadcasted {event_type} event to room {room}") diff --git a/volunteers/static/temp.svg b/volunteers/static/temp.svg new file mode 100644 index 0000000..ec57fe6 --- /dev/null +++ b/volunteers/static/temp.svg @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/volunteers/templates/certificates.html b/volunteers/templates/certificates.html new file mode 100644 index 0000000..8e5a979 --- /dev/null +++ b/volunteers/templates/certificates.html @@ -0,0 +1,202 @@ + + + + + + Volunteer Certificates - {{ year_name }} + + + +
+ + +
+ +
+ {% for certificate in certificates %} +
+
+ {% if svg_data_uri %} + Certificate Background + {% endif %} +
+
+
awarded to
+
{{ certificate.full_name }}
+
+ in recognition of participation as +
+
{{ certificate.rank_display }}
+
+ Northern Eurasia Finals +
+
{{ year_name }}
+
+
+ {% endfor %} +
+ +