From 160451b3db56900576f9e99f19f80a0dbce38da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 22 Jan 2026 17:23:22 +0100 Subject: [PATCH 1/4] Fixing swagger generator (problem with quotes in annotations). --- app/helpers/Swagger/AnnotationData.php | 7 +++ docs/swagger.yaml | 74 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php index 0738d15dd..5888eabaf 100644 --- a/app/helpers/Swagger/AnnotationData.php +++ b/app/helpers/Swagger/AnnotationData.php @@ -65,6 +65,13 @@ public function __construct( $this->responseDataList = $responseDataList; $this->endpointDescription = $endpointDescription; $this->deprecated = $deprecated; + + if ($this->endpointDescription) { + $this->endpointDescription = str_replace('"', "'", $this->endpointDescription); + } + if ($this->deprecated) { + $this->deprecated = str_replace('"', "'", $this->deprecated); + } } private function getSummary(): ?string diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d3a6c55b0..c6a124ccd 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2850,6 +2850,49 @@ paths: code: { description: 'HTTP response code.', type: integer, example: '0', nullable: false } payload: { description: 'The payload of the response.', properties: { id: { description: 'An identifier of the group', type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000, nullable: false }, externalId: { description: 'An informative, human readable identifier of the group', type: string, example: text, nullable: true }, organizational: { description: 'Whether the group is organizational (no assignments nor students).', type: boolean, example: 'true', nullable: false }, exam: { description: 'Whether the group is an exam group.', type: boolean, example: 'true', nullable: false }, archived: { description: 'Whether the group is archived', type: boolean, example: 'true', nullable: false }, public: { description: 'Should the group be visible to all student?', type: boolean, example: 'true', nullable: false }, directlyArchived: { description: 'Whether the group was explicitly marked as archived', type: boolean, example: 'true', nullable: false }, localizedTexts: { description: 'Localized names and descriptions', type: array, items: { }, nullable: false }, primaryAdminsIds: { description: 'IDs of users which are explicitly listed as direct admins of this group', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, parentGroupId: { description: 'Identifier of the parent group (absent for a top-level group)', type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000, nullable: false }, parentGroupsIds: { description: 'Identifications of groups in descending order.', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, childGroups: { description: 'Identifications of child groups.', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, privateData: { description: '', properties: { admins: { description: 'IDs of all users that have admin privileges to this group (including inherited)', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, supervisors: { description: 'IDs of all group supervisors', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, observers: { description: 'IDs of all group observers', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, students: { description: 'IDs of the students of this group', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, instanceId: { description: 'ID of an instance in which the group belongs', type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000, nullable: false }, hasValidLicence: { description: 'Whether the instance where the group belongs has a valid license', type: boolean, example: 'true', nullable: false }, assignments: { description: 'IDs of all group assignments', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, shadowAssignments: { description: 'IDs of all group shadow assignments', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, publicStats: { description: "Whether the student's results are visible to other students", type: boolean, example: 'true', nullable: false }, detaining: { description: 'Whether the group detains the students (so they can be released only by the teacher)', type: boolean, example: 'true', nullable: false }, threshold: { description: 'A relative number of points a student must receive from assignments to fulfill the requirements of the group', type: number, example: '0.1', nullable: false }, pointsLimit: { description: "A minimal number of points that a student must receive to fulfill the group's requirements", type: integer, example: '0', nullable: false }, bindings: { description: 'Entities bound to the group', type: array, items: { }, nullable: false }, examBegin: { description: 'The time when the exam starts if there is an exam scheduled', type: integer, example: '1740135333', nullable: false }, examEnd: { description: 'The time when the exam ends if there is an exam scheduled', type: integer, example: '1740135333', nullable: false }, examLockStrict: { description: 'Whether the scheduled exam requires a strict access lock', type: boolean, example: 'true', nullable: false }, exams: { description: 'All past exams (with at least one student locked)', type: array, items: { }, nullable: false } }, type: object, nullable: false }, permissionHints: { description: '', type: array, items: { }, nullable: false } }, type: object, nullable: false } type: object + '/v1/groups/{id}/exam': + post: + summary: "Change the group 'exam' indicator. If denotes that the group should be listed in exam groups instead of regular groups and the assignments should have 'isExam' flag set by default." + description: "Change the group 'exam' indicator. If denotes that the group should be listed in exam groups instead of regular groups and the assignments should have 'isExam' flag set by default." + operationId: groupsPresenterActionSetExam + parameters: + - + name: id + in: path + description: 'An identifier of the updated group' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + requestBody: + content: + application/json: + schema: + required: + - value + properties: + value: + description: 'The value of the flag' + type: boolean + example: 'true' + nullable: false + type: object + responses: + '200': + description: 'Response data' + content: + application/json: + schema: + required: + - success + - code + - payload + properties: + success: { description: 'Whether the request was processed successfully.', type: boolean, example: 'true', nullable: false } + code: { description: 'HTTP response code.', type: integer, example: '0', nullable: false } + payload: { description: 'The payload of the response.', properties: { id: { description: 'An identifier of the group', type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000, nullable: false }, externalId: { description: 'An informative, human readable identifier of the group', type: string, example: text, nullable: true }, organizational: { description: 'Whether the group is organizational (no assignments nor students).', type: boolean, example: 'true', nullable: false }, exam: { description: 'Whether the group is an exam group.', type: boolean, example: 'true', nullable: false }, archived: { description: 'Whether the group is archived', type: boolean, example: 'true', nullable: false }, public: { description: 'Should the group be visible to all student?', type: boolean, example: 'true', nullable: false }, directlyArchived: { description: 'Whether the group was explicitly marked as archived', type: boolean, example: 'true', nullable: false }, localizedTexts: { description: 'Localized names and descriptions', type: array, items: { }, nullable: false }, primaryAdminsIds: { description: 'IDs of users which are explicitly listed as direct admins of this group', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, parentGroupId: { description: 'Identifier of the parent group (absent for a top-level group)', type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000, nullable: false }, parentGroupsIds: { description: 'Identifications of groups in descending order.', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, childGroups: { description: 'Identifications of child groups.', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, privateData: { description: '', properties: { admins: { description: 'IDs of all users that have admin privileges to this group (including inherited)', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, supervisors: { description: 'IDs of all group supervisors', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, observers: { description: 'IDs of all group observers', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, students: { description: 'IDs of the students of this group', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, instanceId: { description: 'ID of an instance in which the group belongs', type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000, nullable: false }, hasValidLicence: { description: 'Whether the instance where the group belongs has a valid license', type: boolean, example: 'true', nullable: false }, assignments: { description: 'IDs of all group assignments', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, shadowAssignments: { description: 'IDs of all group shadow assignments', type: array, items: { type: string, pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', example: 10000000-2000-4000-8000-160000000000 }, nullable: false }, publicStats: { description: "Whether the student's results are visible to other students", type: boolean, example: 'true', nullable: false }, detaining: { description: 'Whether the group detains the students (so they can be released only by the teacher)', type: boolean, example: 'true', nullable: false }, threshold: { description: 'A relative number of points a student must receive from assignments to fulfill the requirements of the group', type: number, example: '0.1', nullable: false }, pointsLimit: { description: "A minimal number of points that a student must receive to fulfill the group's requirements", type: integer, example: '0', nullable: false }, bindings: { description: 'Entities bound to the group', type: array, items: { }, nullable: false }, examBegin: { description: 'The time when the exam starts if there is an exam scheduled', type: integer, example: '1740135333', nullable: false }, examEnd: { description: 'The time when the exam ends if there is an exam scheduled', type: integer, example: '1740135333', nullable: false }, examLockStrict: { description: 'Whether the scheduled exam requires a strict access lock', type: boolean, example: 'true', nullable: false }, exams: { description: 'All past exams (with at least one student locked)', type: array, items: { }, nullable: false } }, type: object, nullable: false }, permissionHints: { description: '', type: array, items: { }, nullable: false } }, type: object, nullable: false } + type: object '/v1/groups/{id}/examPeriod': post: summary: 'Set an examination period (in the future) when the group will be secured for submitting. Only locked students may submit solutions in the group during this period. This endpoint is also used to update already planned exam period, but only dates in the future can be edited (e.g., once an exam begins, the beginning may no longer be updated).' @@ -5745,6 +5788,37 @@ paths: responses: '200': description: 'Placeholder response' + '/v1/users/{id}/allowed': + post: + summary: "Set 'isAllowed' flag of the given user. The flag determines whether a user may perform any operation of the API." + description: "Set 'isAllowed' flag of the given user. The flag determines whether a user may perform any operation of the API." + operationId: usersPresenterActionSetAllowed + parameters: + - + name: id + in: path + description: 'Identifier of the user' + required: true + schema: + type: string + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + nullable: false + requestBody: + content: + application/json: + schema: + required: + - isAllowed + properties: + isAllowed: + description: 'Whether the user is allowed (active) or not.' + type: boolean + example: 'true' + nullable: false + type: object + responses: + '200': + description: 'Placeholder response' '/v1/users/{id}/external-login/{service}': post: summary: 'Add or update existing external ID of given authentication service.' From 3ffbe19538079f630cd28533c351b2b9d96840b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 22 Jan 2026 17:47:02 +0100 Subject: [PATCH 2/4] Fixing annotations of some endpoints and generating new OAPI docs. --- .../presenters/GroupInvitationsPresenter.php | 1 + app/V1Module/presenters/SecurityPresenter.php | 2 + app/V1Module/presenters/SisPresenter.php | 12 ++++ docs/swagger.yaml | 60 ++++++++++++------- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/app/V1Module/presenters/GroupInvitationsPresenter.php b/app/V1Module/presenters/GroupInvitationsPresenter.php index 3980311ca..3d51a8733 100644 --- a/app/V1Module/presenters/GroupInvitationsPresenter.php +++ b/app/V1Module/presenters/GroupInvitationsPresenter.php @@ -103,6 +103,7 @@ public function checkRemove($id) } /** + * Remove the invitation. * @DELETE */ #[Path("id", new VUuid(), "Identifier of the group invitation", required: true)] diff --git a/app/V1Module/presenters/SecurityPresenter.php b/app/V1Module/presenters/SecurityPresenter.php index cd3dd3662..0d6c7931b 100644 --- a/app/V1Module/presenters/SecurityPresenter.php +++ b/app/V1Module/presenters/SecurityPresenter.php @@ -26,6 +26,8 @@ class SecurityPresenter extends BasePresenter public $router; /** + * A preflight test whether given URL (and HTTP method) would be allowed by internal ACL checks + * (for the current user). * @POST */ #[Post("url", new VMixed(), "URL of the resource that we are checking", required: true, nullable: true)] diff --git a/app/V1Module/presenters/SisPresenter.php b/app/V1Module/presenters/SisPresenter.php index f864d2b32..391b45d66 100644 --- a/app/V1Module/presenters/SisPresenter.php +++ b/app/V1Module/presenters/SisPresenter.php @@ -88,6 +88,8 @@ class SisPresenter extends BasePresenter /** + * Check SIS status for the current user. + * @deprecated Use the new SIS extension instead * @GET * @throws ForbiddenRequestException */ @@ -124,6 +126,7 @@ public function checkGetTerms() /** * Get a list of all registered SIS terms + * @deprecated Use the new SIS extension instead * @GET */ public function actionGetTerms() @@ -140,6 +143,7 @@ public function checkRegisterTerm() /** * Register a new term + * @deprecated Use the new SIS extension instead * @POST * @throws InvalidApiArgumentException * @throws ForbiddenRequestException @@ -176,6 +180,7 @@ public function checkEditTerm(string $id) /** * Set details of a term + * @deprecated Use the new SIS extension instead * @POST * @throws InvalidApiArgumentException * @throws NotFoundException @@ -225,6 +230,7 @@ public function checkDeleteTerm(string $id) /** * Delete a term + * @deprecated Use the new SIS extension instead * @DELETE * @throws NotFoundException */ @@ -252,6 +258,7 @@ public function checkSubscribedGroups($userId, $year, $term) * Organizational and archived groups are filtered out from the result. * Each course holds bound group IDs and group objects are returned in a separate array. * Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names. + * @deprecated Use the new SIS extension instead * @GET * @throws InvalidApiArgumentException * @throws BadRequestException @@ -319,6 +326,7 @@ public function checkSupervisedCourses($userId, $year, $term) * Get supervised SIS courses and corresponding ReCodEx groups. * Each course holds bound group IDs and group objects are returned in a separate array. * Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names. + * @deprecated Use the new SIS extension instead * @GET * @throws InvalidApiArgumentException * @throws NotFoundException @@ -411,6 +419,7 @@ private function makeCaptionsUnique(array &$captions, Group $parentGroup) /** * Create a new group based on a SIS group + * @deprecated Use the new SIS extension instead * @POST * @throws BadRequestException * @throws ForbiddenRequestException @@ -488,6 +497,7 @@ public function actionCreateGroup($courseId) /** * Bind an existing local group to a SIS group + * @deprecated Use the new SIS extension instead * @POST * @throws ApiException * @throws ForbiddenRequestException @@ -524,6 +534,7 @@ public function actionBindGroup($courseId) /** * Delete a binding between a local group and a SIS group + * @deprecated Use the new SIS extension instead * @DELETE * @throws BadRequestException * @throws ForbiddenRequestException @@ -554,6 +565,7 @@ public function actionUnbindGroup($courseId, $groupId) /** * Find groups that can be chosen as parents of a group created from given SIS group by current user + * @deprecated Use the new SIS extension instead * @GET * @throws ApiException * @throws ForbiddenRequestException diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c6a124ccd..50a631cd1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -6,7 +6,8 @@ info: paths: /v1/security/check: post: - summary: '* @OA\Post(path="/v1/security/check", operationId="securityPresenterActionCheck", @OA\RequestBody(@OA\MediaType(mediaType="application/json",@OA\Schema(@OA\Property(property="url", type="string", nullable=true, description="URL of the resource that we are checking"), @OA\Property(property="method", type="string", nullable=true, description="The HTTP method"), required={"url","method"}))), @OA\Response(response="200",description="Placeholder response"))' + summary: 'A preflight test whether given URL (and HTTP method) would be allowed by internal ACL checks (for the current user).' + description: 'A preflight test whether given URL (and HTTP method) would be allowed by internal ACL checks (for the current user).' operationId: securityPresenterActionCheck requestBody: content: @@ -3482,7 +3483,8 @@ paths: '200': description: 'Placeholder response' delete: - summary: '* @OA\Delete(path="/v1/group-invitations/{id}", operationId="groupInvitationsPresenterActionRemove", @OA\Parameter(name="id", in="path", required=true, description="Identifier of the group invitation", @OA\Schema(type="string", nullable=false, pattern="^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")), @OA\Response(response="200",description="Placeholder response"))' + summary: 'Remove the invitation.' + description: 'Remove the invitation.' operationId: groupInvitationsPresenterActionRemove parameters: - @@ -6465,22 +6467,25 @@ paths: description: 'Placeholder response' /v1/extensions/sis/status/: get: - summary: '* @OA\Get(path="/v1/extensions/sis/status/", operationId="sisPresenterActionStatus", @OA\Response(response="200",description="Placeholder response"))' + summary: 'Check SIS status for the current user. [DEPRECATED]' + description: "Check SIS status for the current user.\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionStatus responses: '200': description: 'Placeholder response' + deprecated: true /v1/extensions/sis/terms/: get: - summary: 'Get a list of all registered SIS terms' - description: 'Get a list of all registered SIS terms' + summary: 'Get a list of all registered SIS terms [DEPRECATED]' + description: "Get a list of all registered SIS terms\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionGetTerms responses: '200': description: 'Placeholder response' + deprecated: true post: - summary: 'Register a new term' - description: 'Register a new term' + summary: 'Register a new term [DEPRECATED]' + description: "Register a new term\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionRegisterTerm requestBody: content: @@ -6502,10 +6507,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/extensions/sis/terms/{id}': post: - summary: 'Set details of a term' - description: 'Set details of a term' + summary: 'Set details of a term [DEPRECATED]' + description: "Set details of a term\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionEditTerm parameters: - @@ -6544,9 +6550,10 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true delete: - summary: 'Delete a term' - description: 'Delete a term' + summary: 'Delete a term [DEPRECATED]' + description: "Delete a term\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionDeleteTerm parameters: - @@ -6560,10 +6567,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/extensions/sis/users/{userId}/subscribed-groups/{year}/{term}/as-student': get: - summary: 'Get all courses subscirbed by a student and corresponding ReCodEx groups. Organizational and archived groups are filtered out from the result. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names.' - description: 'Get all courses subscirbed by a student and corresponding ReCodEx groups. Organizational and archived groups are filtered out from the result. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names.' + summary: 'Get all courses subscirbed by a student and corresponding ReCodEx groups. Organizational and archived groups are filtered out from the result. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names. [DEPRECATED]' + description: "Get all courses subscirbed by a student and corresponding ReCodEx groups. Organizational and archived groups are filtered out from the result. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names.\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionSubscribedCourses parameters: - @@ -6593,10 +6601,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/extensions/sis/users/{userId}/supervised-courses/{year}/{term}': get: - summary: 'Get supervised SIS courses and corresponding ReCodEx groups. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names.' - description: 'Get supervised SIS courses and corresponding ReCodEx groups. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names.' + summary: 'Get supervised SIS courses and corresponding ReCodEx groups. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names. [DEPRECATED]' + description: "Get supervised SIS courses and corresponding ReCodEx groups. Each course holds bound group IDs and group objects are returned in a separate array. Whole ancestral closure of groups is returned, so the webapp may properly assemble hiarichial group names.\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionSupervisedCourses parameters: - @@ -6626,10 +6635,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/extensions/sis/remote-courses/{courseId}/possible-parents': get: - summary: 'Find groups that can be chosen as parents of a group created from given SIS group by current user' - description: 'Find groups that can be chosen as parents of a group created from given SIS group by current user' + summary: 'Find groups that can be chosen as parents of a group created from given SIS group by current user [DEPRECATED]' + description: "Find groups that can be chosen as parents of a group created from given SIS group by current user\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionPossibleParents parameters: - @@ -6643,10 +6653,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/extensions/sis/remote-courses/{courseId}/create': post: - summary: 'Create a new group based on a SIS group' - description: 'Create a new group based on a SIS group' + summary: 'Create a new group based on a SIS group [DEPRECATED]' + description: "Create a new group based on a SIS group\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionCreateGroup parameters: - @@ -6672,10 +6683,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/extensions/sis/remote-courses/{courseId}/bind': post: - summary: 'Bind an existing local group to a SIS group' - description: 'Bind an existing local group to a SIS group' + summary: 'Bind an existing local group to a SIS group [DEPRECATED]' + description: "Bind an existing local group to a SIS group\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionBindGroup parameters: - @@ -6701,10 +6713,11 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true '/v1/extensions/sis/remote-courses/{courseId}/bindings/{groupId}': delete: - summary: 'Delete a binding between a local group and a SIS group' - description: 'Delete a binding between a local group and a SIS group' + summary: 'Delete a binding between a local group and a SIS group [DEPRECATED]' + description: "Delete a binding between a local group and a SIS group\n[DEPRECATED]: Use the new SIS extension instead" operationId: sisPresenterActionUnbindGroup parameters: - @@ -6726,6 +6739,7 @@ paths: responses: '200': description: 'Placeholder response' + deprecated: true /v1/emails: post: summary: 'Sends an email with provided subject and message to all ReCodEx users.' From 82c9b2bbbe4ead1f40e3c1258de7372499b94929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Thu, 22 Jan 2026 23:54:09 +0100 Subject: [PATCH 3/4] Changing group-begin index on group exams into unique constraint. --- app/model/entity/GroupExam.php | 2 +- migrations/Version20260122225237.php | 31 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260122225237.php diff --git a/app/model/entity/GroupExam.php b/app/model/entity/GroupExam.php index ce53ce489..bcbdfd3ca 100644 --- a/app/model/entity/GroupExam.php +++ b/app/model/entity/GroupExam.php @@ -8,7 +8,7 @@ /** * @ORM\Entity - * @ORM\Table(indexes={@ORM\Index(name="group_begin_idx", columns={"group_id", "begin"})}) + * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(columns={"group_id", "begin"})}) * Holds history record of an exam that took place in a group. * The `examBegin`, `examEnd` fields are copied from group to `begin`, `end` fields here, * `examLockStrict` is copied to `lockStrict` field. diff --git a/migrations/Version20260122225237.php b/migrations/Version20260122225237.php new file mode 100644 index 000000000..d896d8815 --- /dev/null +++ b/migrations/Version20260122225237.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE group_exam DROP INDEX group_begin_idx, ADD UNIQUE INDEX UNIQ_11E1FDB6FE54D9477A859515 (group_id, begin)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE group_exam DROP INDEX UNIQ_11E1FDB6FE54D9477A859515, ADD INDEX group_begin_idx (group_id, begin)'); + } +} From a221a57bec783a537fd3d464c00361d875102f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Kruli=C5=A1?= Date: Fri, 23 Jan 2026 00:05:59 +0100 Subject: [PATCH 4/4] Handling possible race condition in exam creation by retry mechanism. --- app/model/repository/GroupExams.php | 56 +++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/app/model/repository/GroupExams.php b/app/model/repository/GroupExams.php index 60d46c7d4..48c0c3d92 100644 --- a/app/model/repository/GroupExams.php +++ b/app/model/repository/GroupExams.php @@ -5,6 +5,7 @@ use App\Model\Entity\Group; use App\Model\Entity\GroupExam; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use DateTime; use Exception; @@ -38,6 +39,44 @@ public function findPendingForGroup(Group $group): ?GroupExam return $exam ? reset($exam) : null; } + /** + * Internal helper that tries to find the exam or create it if not present. + * It returns null if creation failed due to race condition (expects retry by caller). + * @param Group $group + * @param DateTime $begin + * @param DateTime $end + * @param bool $strict + * @return GroupExam|null + */ + private function tryFindOrCreate(Group $group, DateTime $begin, DateTime $end, bool $strict): ?GroupExam + { + $exam = $this->findBy(["group" => $group, "begin" => $begin]); + if (count($exam) > 1) { + throw new Exception("Data corruption, there is more than one group exam starting at the same time."); + } + + if (!$exam) { + try { + $this->em->getConnection()->executeQuery( + "INSERT INTO group_exam (group_id, begin, end, lock_strict) VALUES (:gid, :begin, :end, :strict)", + [ + 'gid' => $group->getId(), + 'begin' => $begin->format('Y-m-d H:i:s'), + 'end' => $end->format('Y-m-d H:i:s'), + 'strict' => $strict ? 1 : 0 + ] + ); + } catch (UniqueConstraintViolationException) { + // race condition, another transaction created the entity meanwhile + } + return null; // signal caller to retry + } else { + $exam = reset($exam); + } + + return $exam; + } + /** * Fetch group exam entity by group-begin index. If not present, new entity is created. * @param Group $group @@ -56,18 +95,13 @@ public function findOrCreate( $end = $end ?? $group->getExamEnd(); $strict = $strict === null ? $group->isExamLockStrict() : $strict; - $exam = $this->findBy(["group" => $group, "begin" => $begin]); - if (count($exam) > 1) { - throw new Exception("Data corruption, there is more than one group exam starting at the same time."); + for ($retries = 0; $retries < 3; $retries++) { + $exam = $this->tryFindOrCreate($group, $begin, $end, $strict); + if ($exam !== null) { + return $exam; + } } - if (!$exam) { - $exam = new GroupExam($group, $begin, $end, $strict); - $this->persist($exam); - } else { - $exam = reset($exam); - } - - return $exam; + throw new Exception("Failed to find or create group exam after multiple attempts."); } }