diff --git a/.github/workflows/IOnIntegration_Production.yml b/.github/workflows/IOnIntegration_Production.yml index 79fd55ce..46794ed7 100644 --- a/.github/workflows/IOnIntegration_Production.yml +++ b/.github/workflows/IOnIntegration_Production.yml @@ -2,18 +2,47 @@ name: I-On Integration Production on: push: + branches: ['master'] tags: - v* +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: push_production: runs-on: ubuntu-latest if: github.event_name == 'push' && contains(github.ref, 'tags') + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v2 - - name: Build image - id: production_build_image - run: ./gradlew buildDockerImage \ No newline at end of file + - name: Login to Container Registry + uses: docker/login-action@v1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/IOnIntegration_Staging.yml b/.github/workflows/IOnIntegration_Staging.yml index ec64ed51..4cf589dd 100644 --- a/.github/workflows/IOnIntegration_Staging.yml +++ b/.github/workflows/IOnIntegration_Staging.yml @@ -51,7 +51,7 @@ jobs: heroku_app_name: ${{secrets.HEROKU_APP_NAME}} heroku_email: ${{secrets.HEROKU_EMAIL}} usedocker: true - docker_heroku_process_type: worker + docker_heroku_process_type: web - run: | echo "::add-mask::$HD_SQL_HOST" echo "::add-mask::$HD_SQL_USER" @@ -72,4 +72,4 @@ jobs: if: ${{ env.ACT }} run: | echo GIT_SERVER_ADDRESS=$GIT_SERVER_ADDRESS - echo GIT_BRANCH=$GIT_BRANCH \ No newline at end of file + echo GIT_BRANCH=$GIT_BRANCH diff --git a/Dockerfile b/Dockerfile index ae19102e..f05ac6f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,5 @@ ARG EXTRACT_DEPENDENCY_PATH=/src/build/dependency COPY --from=build-env ${EXTRACT_DEPENDENCY_PATH}/BOOT-INF/classes /app COPY --from=build-env ${EXTRACT_DEPENDENCY_PATH}/BOOT-INF/lib /app/lib -EXPOSE ${SERVER_PORT} -ENTRYPOINT [ "java", "-cp", "app:app/lib/*", "org.ionproject.integration.IOnIntegrationApplicationKt", "-XX:+UseContainerSupport" ] +ENTRYPOINT [ "java", "-cp", "app:app/lib/*", "org.ionproject.integration.IOnIntegrationApplicationKt", "-XX:+UseContainerSupport", "echo --server.port=$PORT" ] diff --git a/README.md b/README.md index 8dda6032..98cc345c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Academic information such as class schedules or academic calendars is often scat ![I-On Integration Architecture](img/ion_integration_architecture.png) -**I-On Integration** uses *batch processing* techniques to acquire and process all unstructured data and write it to the common **File Repository** (a Git repo whose sole purpose is to host this data). +**I-On Integration** uses *batch processing* techniques to acquire and process all unstructured data and write it to the common **File Repository** (a Git repo whose sole purpose is to host this data). The data is available for anyone that wishes to use this data on their own projects. It is available at [GitHub i-on Integration Data repository](https://github.com/i-on-project/integration-data). The **Scheduler** component, as the name suggests, is responsible for periodically triggering job executions through Integration's Web API (not yet available). @@ -62,10 +62,6 @@ By **default** the app containers will be accessible via the following ports: If you wish to change these values please refer to the [Customizing Containers](#customizing-containers) section. -On Windows 10 you may see a **warning** about poor performance due to filesharing to a WSL container. This is due to the fact that we make use of [Docker Bind Mounts](https://docs.docker.com/storage/bind-mounts/) to share the necessary server files. - -![Warning](img/bindmountwarning.jpg) - #### Using the containerized database While the Integration app will be able to connect to the database out of the box without any human intervention you might also be interested in connecting directly to run your own queries. @@ -89,6 +85,8 @@ The Git server currently deployed for local development is based on the [GitBuck It's Web front-end can be accessed by pointing your browser to `localhost:8080` (default port, see the [Customizing Containers section](#customizing-containers) if you need to use a different port value). +On startup a repository named **"integration-data"** is created and initialized to mirror the existing production repository. + ##### Git Credentials The server is deployed with a single **account** that can be accessed by entering `root` for both username and password fields. diff --git a/docker-compose.yml b/docker-compose.yml index 8deda1a9..38f78914 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: - GIT_USER=${GIT_USER} - GIT_PASSWORD=${GIT_PASSWORD} - GIT_BRANCH=${GIT_BRANCH} - - SERVER_PORT=${SERVER_PORT} + - PORT=${SERVER_PORT} volumes: - db-data:/var/lib/postgresql/data ports: @@ -23,8 +23,8 @@ services: depends_on: ion-db: condition: service_started - git-server: - condition: service_healthy + git-setup: + condition: service_started ion-db: image: postgres:13.2-alpine ports: @@ -38,11 +38,19 @@ services: ports: - "${GIT_PORT}:8080" volumes: - - "./git-server:/gitbucket" + - git-data:/gitbucket healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:${GIT_PORT}" ] interval: 30s timeout: 10s retries: 5 + git-setup: + image: gitbucket/gitbucket:4.35.3 + depends_on: + git-server: + condition: service_healthy + restart: "no" + entrypoint: [ "curl", "-X", "POST", "-u", "root:root", "git-server:${GIT_PORT}/api/v3/user/repos", "-d", "{\"name\":\"integration-data\", \"auto_init\": true}" ] volumes: - db-data: \ No newline at end of file + db-data: + git-data: \ No newline at end of file diff --git a/docs/infrastructure/ArgumentException.md b/docs/infrastructure/ArgumentException.md new file mode 100644 index 00000000..38b69c09 --- /dev/null +++ b/docs/infrastructure/ArgumentException.md @@ -0,0 +1,3 @@ +This **error** will occur in Integration's Web API associated with the status code [400: Bad Request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400) and signals that either an expected argument is missing or incorrect. + +The response body will be in the `application/json+problem` mediatype and its `detail` field will include more context-specific information about the error cause. \ No newline at end of file diff --git a/docs/infrastructure/JobNotFoundException.md b/docs/infrastructure/JobNotFoundException.md new file mode 100644 index 00000000..8c8fd553 --- /dev/null +++ b/docs/infrastructure/JobNotFoundException.md @@ -0,0 +1,3 @@ +This **error** will occur in Integration's Web API associated with the status code [404: Not Found](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404) and indicates that the request Job ID does not exist. + +The response body will be in the `application/json+problem` mediatype and its `detail` field will include the given Job ID. The `instance` field will contain the requested path. \ No newline at end of file diff --git a/git-server/data.mv.db b/git-server/data.mv.db deleted file mode 100644 index 3b0d531a..00000000 Binary files a/git-server/data.mv.db and /dev/null differ diff --git a/git-server/database.conf b/git-server/database.conf deleted file mode 100644 index 6c0e521f..00000000 --- a/git-server/database.conf +++ /dev/null @@ -1,10 +0,0 @@ -db { - url = "jdbc:h2:${DatabaseHome};MVCC=true" - user = "sa" - password = "sa" -# connectionTimeout = 30000 -# idleTimeout = 600000 -# maxLifetime = 1800000 -# minimumIdle = 10 -# maximumPoolSize = 10 -} diff --git a/git-server/repositories/root/integration-data.git/HEAD b/git-server/repositories/root/integration-data.git/HEAD deleted file mode 100644 index cb089cd8..00000000 --- a/git-server/repositories/root/integration-data.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/master diff --git a/git-server/repositories/root/integration-data.git/config b/git-server/repositories/root/integration-data.git/config deleted file mode 100644 index 18907d3e..00000000 --- a/git-server/repositories/root/integration-data.git/config +++ /dev/null @@ -1,7 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true - logallrefupdates = false -[http] - receivepack = true diff --git a/git-server/repositories/root/integration-data.git/objects/0b/3deb0e860b57f2b580eecd18ee484fe113aadf b/git-server/repositories/root/integration-data.git/objects/0b/3deb0e860b57f2b580eecd18ee484fe113aadf deleted file mode 100644 index 1588d280..00000000 Binary files a/git-server/repositories/root/integration-data.git/objects/0b/3deb0e860b57f2b580eecd18ee484fe113aadf and /dev/null differ diff --git a/git-server/repositories/root/integration-data.git/objects/59/06ae9e97313fb554f5836167f8738af35a9a07 b/git-server/repositories/root/integration-data.git/objects/59/06ae9e97313fb554f5836167f8738af35a9a07 deleted file mode 100644 index bd94450c..00000000 Binary files a/git-server/repositories/root/integration-data.git/objects/59/06ae9e97313fb554f5836167f8738af35a9a07 and /dev/null differ diff --git a/git-server/repositories/root/integration-data.git/objects/8d/2e77da69377b86eb13232829ed054d601c9f38 b/git-server/repositories/root/integration-data.git/objects/8d/2e77da69377b86eb13232829ed054d601c9f38 deleted file mode 100644 index b0d4a782..00000000 Binary files a/git-server/repositories/root/integration-data.git/objects/8d/2e77da69377b86eb13232829ed054d601c9f38 and /dev/null differ diff --git a/git-server/repositories/root/integration-data.git/refs/heads/experimental b/git-server/repositories/root/integration-data.git/refs/heads/experimental deleted file mode 100644 index 8b855e75..00000000 --- a/git-server/repositories/root/integration-data.git/refs/heads/experimental +++ /dev/null @@ -1 +0,0 @@ -5906ae9e97313fb554f5836167f8738af35a9a07 diff --git a/git-server/repositories/root/integration-data.git/refs/heads/master b/git-server/repositories/root/integration-data.git/refs/heads/master deleted file mode 100644 index 8b855e75..00000000 --- a/git-server/repositories/root/integration-data.git/refs/heads/master +++ /dev/null @@ -1 +0,0 @@ -5906ae9e97313fb554f5836167f8738af35a9a07 diff --git a/git-server/repositories/root/integration-data.wiki.git/HEAD b/git-server/repositories/root/integration-data.wiki.git/HEAD deleted file mode 100644 index cb089cd8..00000000 --- a/git-server/repositories/root/integration-data.wiki.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/master diff --git a/git-server/repositories/root/integration-data.wiki.git/config b/git-server/repositories/root/integration-data.wiki.git/config deleted file mode 100644 index 18907d3e..00000000 --- a/git-server/repositories/root/integration-data.wiki.git/config +++ /dev/null @@ -1,7 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true - logallrefupdates = false -[http] - receivepack = true diff --git a/git-server/repositories/root/integration-data.wiki.git/objects/02/ade229702f567984e9695cc6e30aed9d35d236 b/git-server/repositories/root/integration-data.wiki.git/objects/02/ade229702f567984e9695cc6e30aed9d35d236 deleted file mode 100644 index 50d6ef16..00000000 Binary files a/git-server/repositories/root/integration-data.wiki.git/objects/02/ade229702f567984e9695cc6e30aed9d35d236 and /dev/null differ diff --git a/git-server/repositories/root/integration-data.wiki.git/objects/78/f09183f4fcdb126c2c680af728650e1f12705e b/git-server/repositories/root/integration-data.wiki.git/objects/78/f09183f4fcdb126c2c680af728650e1f12705e deleted file mode 100644 index 24d4dbfe..00000000 Binary files a/git-server/repositories/root/integration-data.wiki.git/objects/78/f09183f4fcdb126c2c680af728650e1f12705e and /dev/null differ diff --git a/git-server/repositories/root/integration-data.wiki.git/objects/8f/646090cb47397c2e4ff528b6df203950ce0429 b/git-server/repositories/root/integration-data.wiki.git/objects/8f/646090cb47397c2e4ff528b6df203950ce0429 deleted file mode 100644 index ecf23065..00000000 Binary files a/git-server/repositories/root/integration-data.wiki.git/objects/8f/646090cb47397c2e4ff528b6df203950ce0429 and /dev/null differ diff --git a/git-server/repositories/root/integration-data.wiki.git/refs/heads/master b/git-server/repositories/root/integration-data.wiki.git/refs/heads/master deleted file mode 100644 index 9eeee13c..00000000 --- a/git-server/repositories/root/integration-data.wiki.git/refs/heads/master +++ /dev/null @@ -1 +0,0 @@ -8f646090cb47397c2e4ff528b6df203950ce0429 diff --git a/img/bindmountwarning.jpg b/img/bindmountwarning.jpg deleted file mode 100644 index 211f8496..00000000 Binary files a/img/bindmountwarning.jpg and /dev/null differ diff --git a/img/ion_integration_architecture.png b/img/ion_integration_architecture.png index 94d97efb..8c6c1865 100644 Binary files a/img/ion_integration_architecture.png and b/img/ion_integration_architecture.png differ diff --git a/src/main/kotlin/org/ionproject/integration/application/JobEngine.kt b/src/main/kotlin/org/ionproject/integration/application/JobEngine.kt index bb3ce9c8..5a3ebe1a 100644 --- a/src/main/kotlin/org/ionproject/integration/application/JobEngine.kt +++ b/src/main/kotlin/org/ionproject/integration/application/JobEngine.kt @@ -3,13 +3,14 @@ package org.ionproject.integration.application import org.ionproject.integration.application.config.AppProperties import org.ionproject.integration.application.config.LAUNCHER_NAME import org.ionproject.integration.application.job.CALENDAR_JOB_NAME -import org.ionproject.integration.infrastructure.file.OutputFormat -import org.ionproject.integration.domain.common.InstitutionModel -import org.ionproject.integration.domain.common.ProgrammeModel -import org.ionproject.integration.infrastructure.repository.IIntegrationJobRepository +import org.ionproject.integration.application.job.EVALUATIONS_JOB_NAME import org.ionproject.integration.application.job.JobType import org.ionproject.integration.application.job.TIMETABLE_JOB_NAME +import org.ionproject.integration.domain.common.InstitutionModel +import org.ionproject.integration.domain.common.ProgrammeModel import org.ionproject.integration.infrastructure.exception.JobNotFoundException +import org.ionproject.integration.infrastructure.file.OutputFormat +import org.ionproject.integration.infrastructure.repository.IIntegrationJobRepository import org.slf4j.LoggerFactory import org.springframework.batch.core.Job import org.springframework.batch.core.JobParameters @@ -49,6 +50,7 @@ class JobEngine( fun runJob(request: AbstractJobRequest): JobStatus { return when (request) { is TimetableJobRequest -> runTimetableJob(request) + is EvaluationsJobRequest -> runEvaluationsJob(request) is CalendarJobRequest -> runCalendarJob(request) } } @@ -62,6 +64,11 @@ class JobEngine( return runJob(TIMETABLE_JOB_NAME, jobParams) } + private fun runEvaluationsJob(request: EvaluationsJobRequest): JobStatus { + val jobParams = getJobParameters(request, EVALUATIONS_JOB_NAME) + return runJob(EVALUATIONS_JOB_NAME, jobParams) + } + private fun runCalendarJob(request: CalendarJobRequest): JobStatus { val jobParams = getJobParameters(request, CALENDAR_JOB_NAME) return runJob(CALENDAR_JOB_NAME, jobParams) @@ -72,7 +79,11 @@ class JobEngine( val uri = when (request) { is TimetableJobRequest -> - request.programme.timetableUri.also { + request.programme.resources.timetableUri.also { + parametersBuilder.addString(PROGRAMME_PARAMETER, request.programme.acronym) + } + is EvaluationsJobRequest -> + request.programme.resources.evaluationsUri.also { parametersBuilder.addString(PROGRAMME_PARAMETER, request.programme.acronym) } is CalendarJobRequest -> request.institution.academicCalendarUri @@ -154,6 +165,30 @@ class JobEngine( } } + class EvaluationsJobRequest( + format: OutputFormat, + institution: InstitutionModel, + val programme: ProgrammeModel + ) : AbstractJobRequest(format, institution, JobType.EXAM_SCHEDULE) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as EvaluationsJobRequest + + if (programme != other.programme) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + programme.hashCode() + return result + } + } + data class IntegrationJob( val type: JobType, val status: JobStatus, @@ -162,7 +197,8 @@ class JobEngine( data class IntegrationJobParameters( val creationDate: LocalDateTime, - val startDate: LocalDateTime, + val startDate: LocalDateTime? = null, + val endDate: LocalDateTime? = null, val format: OutputFormat, val institution: InstitutionModel, val programme: ProgrammeModel? = null, diff --git a/src/main/kotlin/org/ionproject/integration/application/dto/ParsedData.kt b/src/main/kotlin/org/ionproject/integration/application/dto/ParsedData.kt index f20162c1..a4d3b214 100644 --- a/src/main/kotlin/org/ionproject/integration/application/dto/ParsedData.kt +++ b/src/main/kotlin/org/ionproject/integration/application/dto/ParsedData.kt @@ -1,8 +1,10 @@ package org.ionproject.integration.application.dto +import org.ionproject.integration.domain.calendar.AcademicCalendarDto +import org.ionproject.integration.domain.common.Term +import org.ionproject.integration.domain.evaluations.EvaluationsDto import org.ionproject.integration.domain.timetable.dto.TimetableDto import org.ionproject.integration.infrastructure.file.Filepath -import org.ionproject.integration.domain.calendar.AcademicCalendarDto /** * ParsedData will be used to "transport" the final data along with the required metadata. @@ -74,4 +76,54 @@ data class AcademicCalendarData( return staging + segments } + + data class EvaluationsData( + val programme: ProgrammeMetadata, + val term: CalendarTerm, + private val dto: EvaluationsDto + ) : ParsedData(dto) { + companion object Factory { + private const val ACADEMIC_YEAR_LENGTH = 9 + private const val ACADEMIC_YEARS_FOLDER_NAME = "academic_years" + + fun from(evaluationsDto: EvaluationsDto, identifier: String): EvaluationsData = + EvaluationsData( + ProgrammeMetadata( + InstitutionMetadata( + evaluationsDto.school.name, + evaluationsDto.school.acr, + identifier + ), + evaluationsDto.programme.name, + evaluationsDto.programme.acr + ), + CalendarTerm( + evaluationsDto.calendarTerm.take(4).toInt(), + when (evaluationsDto.calendarTerm.takeLast(1).toInt()) { + 1 -> Term.FALL + 2 -> Term.SPRING + else -> throw IllegalArgumentException("Invalid Term ${evaluationsDto.calendarTerm}") + } + ), + evaluationsDto + ) + } + + private val PROGRAMMES = "programmes" + + override val identifier: String + get() = "${javaClass.simpleName}:${programme.acronym}:$term" + + override fun getDirectory(repositoryName: String, staging: Filepath): Filepath { + val segments = listOf( + repositoryName, + programme.institution.domain, + PROGRAMMES, + programme.acronym, + term.toString() + ) + + return staging + segments + } + } } diff --git a/src/main/kotlin/org/ionproject/integration/application/job/ISELAcademicCalendarJob.kt b/src/main/kotlin/org/ionproject/integration/application/job/ISELAcademicCalendarJob.kt index 601cb695..c3985a2a 100644 --- a/src/main/kotlin/org/ionproject/integration/application/job/ISELAcademicCalendarJob.kt +++ b/src/main/kotlin/org/ionproject/integration/application/job/ISELAcademicCalendarJob.kt @@ -6,7 +6,9 @@ import org.ionproject.integration.application.dispatcher.DispatchResult import org.ionproject.integration.application.dispatcher.IDispatcher import org.ionproject.integration.application.dto.AcademicCalendarData import org.ionproject.integration.application.job.tasklet.DownloadAndCompareTasklet +import org.ionproject.integration.domain.calendar.AcademicCalendarDto import org.ionproject.integration.domain.calendar.RawCalendarData +import org.ionproject.integration.domain.common.InstitutionModel import org.ionproject.integration.infrastructure.Try import org.ionproject.integration.infrastructure.file.FileComparatorImpl import org.ionproject.integration.infrastructure.file.FileDigestImpl @@ -17,12 +19,13 @@ import org.ionproject.integration.infrastructure.orThrow import org.ionproject.integration.infrastructure.pdfextractor.AcademicCalendarExtractor import org.ionproject.integration.infrastructure.pdfextractor.ITextPdfExtractor import org.ionproject.integration.infrastructure.pdfextractor.PDFBytesFormatChecker +import org.ionproject.integration.infrastructure.repository.IInstitutionRepository import org.ionproject.integration.model.external.calendar.AcademicCalendar -import org.ionproject.integration.domain.calendar.AcademicCalendarDto import org.springframework.batch.core.ExitStatus import org.springframework.batch.core.configuration.annotation.JobBuilderFactory import org.springframework.batch.core.configuration.annotation.StepBuilderFactory import org.springframework.batch.core.configuration.annotation.StepScope +import org.springframework.batch.core.scope.context.ChunkContext import org.springframework.batch.core.step.tasklet.Tasklet import org.springframework.batch.core.step.tasklet.TaskletStep import org.springframework.batch.repeat.RepeatStatus @@ -42,20 +45,22 @@ class ISELAcademicCalendarJob( val properties: AppProperties, val downloader: IFileDownloader, val dispatcher: IDispatcher, + val institutionRepository: IInstitutionRepository, @Autowired val ds: DataSource ) { @Bean(name = [CALENDAR_JOB_NAME]) fun calendarJob() = jobBuilderFactory.get(CALENDAR_JOB_NAME) - .start(taskletStep("Download And Compare", downloadCalendarPDFAlternateTasklet())) + .start(taskletStep("Download And Compare", downloadCalendarPDFTasklet())) .on("STOPPED").end() .next(extractCalendarPDFTasklet()) .next(createCalendarPDFBusinessObjectsTasklet()) .next(createCalendarPDFDtoTasklet()) .next(writeCalendarDTOToGitTasklet()) - .next(sendNotificationsForCalendarJobTasklet()) - .build().build() + .build() + .listener(NotificationListener()) + .build() private fun taskletStep(name: String, tasklet: Tasklet): TaskletStep { return stepBuilderFactory @@ -66,21 +71,12 @@ class ISELAcademicCalendarJob( @StepScope @Bean - fun downloadCalendarPDFAlternateTasklet(): DownloadAndCompareTasklet { + fun downloadCalendarPDFTasklet(): DownloadAndCompareTasklet { val pdfChecker = PDFBytesFormatChecker() val fileComparator = FileComparatorImpl(FileDigestImpl(), HashRepositoryImpl(ds)) return DownloadAndCompareTasklet(downloader, pdfChecker, fileComparator) } - @Bean - fun downloadCalendarPDFTasklet() = stepBuilderFactory.get("Download Calendar PDF") - .tasklet { stepContribution, chunkContext -> - val pdfChecker = PDFBytesFormatChecker() - val fileComparator = FileComparatorImpl(FileDigestImpl(), HashRepositoryImpl(ds)) - DownloadAndCompareTasklet(downloader, pdfChecker, fileComparator).execute(stepContribution, chunkContext) - } - .build() - @Bean fun extractCalendarPDFTasklet() = stepBuilderFactory.get("Extract Calendar PDF Raw Data") .tasklet { stepContribution, _ -> @@ -116,12 +112,17 @@ class ISELAcademicCalendarJob( @Bean fun createCalendarPDFBusinessObjectsTasklet() = stepBuilderFactory.get("Create Business Objects from Calendar Raw Data") - .tasklet { _, _ -> - State.academicCalendar = AcademicCalendar.from(State.rawCalendarData) + .tasklet { _, context -> + State.academicCalendar = AcademicCalendar.from(State.rawCalendarData, getJobInstitution(context)) RepeatStatus.FINISHED } .build() + private fun getJobInstitution(context: ChunkContext): InstitutionModel = + institutionRepository.getInstitutionByIdentifier( + context.stepContext.jobParameters[JobEngine.INSTITUTION_PARAMETER] as String + ) + @Bean fun createCalendarPDFDtoTasklet() = stepBuilderFactory.get("Create DTO from Calendar Business Objects") .tasklet { _, _ -> @@ -147,14 +148,6 @@ class ISELAcademicCalendarJob( } .build() - @Bean - fun sendNotificationsForCalendarJobTasklet() = stepBuilderFactory.get("Send Calendar Job Notifications") - .tasklet { _, _ -> - // TODO - RepeatStatus.FINISHED - } - .build() - @Component object State { lateinit var rawCalendarData: RawCalendarData diff --git a/src/main/kotlin/org/ionproject/integration/application/job/ISELEvaluationsJob.kt b/src/main/kotlin/org/ionproject/integration/application/job/ISELEvaluationsJob.kt new file mode 100644 index 00000000..804282a4 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/application/job/ISELEvaluationsJob.kt @@ -0,0 +1,165 @@ +package org.ionproject.integration.application.job + +import org.ionproject.integration.application.JobEngine +import org.ionproject.integration.application.config.AppProperties +import org.ionproject.integration.application.dispatcher.DispatchResult +import org.ionproject.integration.application.dispatcher.IDispatcher +import org.ionproject.integration.application.dto.AcademicCalendarData +import org.ionproject.integration.application.job.tasklet.DownloadAndCompareTasklet +import org.ionproject.integration.domain.common.InstitutionModel +import org.ionproject.integration.domain.common.ProgrammeModel +import org.ionproject.integration.domain.evaluations.Evaluations +import org.ionproject.integration.domain.evaluations.EvaluationsDto +import org.ionproject.integration.domain.evaluations.RawEvaluationsData +import org.ionproject.integration.infrastructure.Try +import org.ionproject.integration.infrastructure.file.FileComparatorImpl +import org.ionproject.integration.infrastructure.file.FileDigestImpl +import org.ionproject.integration.infrastructure.file.OutputFormat +import org.ionproject.integration.infrastructure.hash.HashRepositoryImpl +import org.ionproject.integration.infrastructure.http.IFileDownloader +import org.ionproject.integration.infrastructure.orThrow +import org.ionproject.integration.infrastructure.pdfextractor.EvaluationsExtractor +import org.ionproject.integration.infrastructure.pdfextractor.ITextPdfExtractor +import org.ionproject.integration.infrastructure.pdfextractor.PDFBytesFormatChecker +import org.ionproject.integration.infrastructure.repository.IInstitutionRepository +import org.ionproject.integration.infrastructure.repository.IProgrammeRepository +import org.springframework.batch.core.ExitStatus +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory +import org.springframework.batch.core.configuration.annotation.StepScope +import org.springframework.batch.core.scope.context.ChunkContext +import org.springframework.batch.core.step.tasklet.Tasklet +import org.springframework.batch.core.step.tasklet.TaskletStep +import org.springframework.batch.repeat.RepeatStatus +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.stereotype.Component +import java.io.File +import javax.sql.DataSource + +const val EVALUATIONS_JOB_NAME = "evaluations" + +@Configuration +class ISELEvaluationsJob( + val jobBuilderFactory: JobBuilderFactory, + val stepBuilderFactory: StepBuilderFactory, + val properties: AppProperties, + val downloader: IFileDownloader, + val dispatcher: IDispatcher, + val institutionRepository: IInstitutionRepository, + val programmeRepository: IProgrammeRepository, + @Autowired + val ds: DataSource +) { + + @Bean(name = [EVALUATIONS_JOB_NAME]) + fun calendarJob() = jobBuilderFactory.get(EVALUATIONS_JOB_NAME) + .start(taskletStep("Download And Compare", downloadEvaluationsPDFTasklet())) + .on("STOPPED").end() + .next(extractEvaluationsPDFTasklet()) + .next(createEvaluationsPDFBusinessObjectsTasklet()) + .next(createEvaluationsDtoTasklet()) + .next(writeEvaluationsDTOToGitTasklet()) + .build().listener(NotificationListener()) + .build() + + private fun taskletStep(name: String, tasklet: Tasklet): TaskletStep { + return stepBuilderFactory + .get(name) + .tasklet(tasklet) + .build() + } + + @StepScope + @Bean + fun downloadEvaluationsPDFTasklet(): DownloadAndCompareTasklet { + val pdfChecker = PDFBytesFormatChecker() + val fileComparator = FileComparatorImpl(FileDigestImpl(), HashRepositoryImpl(ds)) + return DownloadAndCompareTasklet(downloader, pdfChecker, fileComparator) + } + + @Bean + fun extractEvaluationsPDFTasklet() = stepBuilderFactory.get("Extract Evaluations PDF Raw Data") + .tasklet { stepContribution, _ -> + val path = stepContribution.stepExecution.jobExecution.executionContext.get("file-path").toString() + State.rawEvaluationsData = extractEvaluationsPDF(path) + RepeatStatus.FINISHED + } + .build() + + fun extractEvaluationsPDF(path: String): RawEvaluationsData { + try { + val itext = ITextPdfExtractor() + + val headerText = itext.extract(path) + val evaluationsTable = EvaluationsExtractor.evaluationsTable.extract(path) + + return Try.map( + headerText, + evaluationsTable + ) { (text, evaluationsTable) -> + RawEvaluationsData( + text.dropLast(1), + evaluationsTable.first().replace("\\r", " "), + text.last() + ) + }.orThrow() + } finally { + if (!path.contains("test")) + File(path).delete() + } + } + + @Bean + fun createEvaluationsPDFBusinessObjectsTasklet() = + stepBuilderFactory.get("Create Business Objects from Evaluations Raw Data") + .tasklet { _, context -> + State.evaluations = Evaluations.from(State.rawEvaluationsData, getJobProgramme(context)) + RepeatStatus.FINISHED + } + .build() + + private fun getJobProgramme(context: ChunkContext): ProgrammeModel = + programmeRepository.getProgrammeByAcronymAndInstitution( + context.stepContext.jobParameters[JobEngine.PROGRAMME_PARAMETER] as String, + getJobInstitution(context) + ) + + private fun getJobInstitution(context: ChunkContext): InstitutionModel = + institutionRepository.getInstitutionByIdentifier( + context.stepContext.jobParameters[JobEngine.INSTITUTION_PARAMETER] as String + ) + + @Bean + fun createEvaluationsDtoTasklet() = stepBuilderFactory.get("Create DTO from Evaluations Business Objects") + .tasklet { _, _ -> + State.evaluationsDto = EvaluationsDto.from(State.evaluations) + RepeatStatus.FINISHED + } + .build() + + @Bean + fun writeEvaluationsDTOToGitTasklet() = stepBuilderFactory.get("Write Evaluations DTO to Git") + .tasklet { stepContribution, context -> + val formatParam = context.stepContext.jobParameters[JobEngine.FORMAT_PARAMETER] as String + val identifier = context.stepContext.jobParameters[JobEngine.INSTITUTION_PARAMETER] as String + val format = OutputFormat.of(formatParam) + val evaluationsData = AcademicCalendarData.EvaluationsData.from(ISELEvaluationsJob.State.evaluationsDto, identifier) + + val dispatchResult = dispatcher.dispatch(evaluationsData, EVALUATIONS_JOB_NAME, format) + + if (dispatchResult == DispatchResult.FAILURE) { + stepContribution.exitStatus = ExitStatus.FAILED + } + RepeatStatus.FINISHED + } + .build() + + @Component + object State { + lateinit var rawEvaluationsData: RawEvaluationsData + lateinit var evaluations: Evaluations + lateinit var evaluationsDto: EvaluationsDto + } +} diff --git a/src/main/kotlin/org/ionproject/integration/application/job/ISELTimetableJob.kt b/src/main/kotlin/org/ionproject/integration/application/job/ISELTimetableJob.kt index 307b348b..a7e10b37 100644 --- a/src/main/kotlin/org/ionproject/integration/application/job/ISELTimetableJob.kt +++ b/src/main/kotlin/org/ionproject/integration/application/job/ISELTimetableJob.kt @@ -54,7 +54,9 @@ class ISELTimetableJob( .next(taskletStep("RawData to Business Object", mappingTasklet())) .next(writeLocalStep()) .next(taskletStep("PostUpload", postUploadTasklet())) - .build().build() + .build() + .listener(NotificationListener()) + .build() private fun taskletStep(name: String, tasklet: Tasklet): TaskletStep { return stepBuilderFactory diff --git a/src/main/kotlin/org/ionproject/integration/application/job/JobType.kt b/src/main/kotlin/org/ionproject/integration/application/job/JobType.kt index 6a96a17f..73a49804 100644 --- a/src/main/kotlin/org/ionproject/integration/application/job/JobType.kt +++ b/src/main/kotlin/org/ionproject/integration/application/job/JobType.kt @@ -2,7 +2,7 @@ package org.ionproject.integration.application.job enum class JobType(val identifier: String) { TIMETABLE("timetable"), - EXAM_SCHEDULE("evaluation"), + EXAM_SCHEDULE("evaluations"), ACADEMIC_CALENDAR("calendar"); companion object Factory { diff --git a/src/main/kotlin/org/ionproject/integration/application/job/NotificationListener.kt b/src/main/kotlin/org/ionproject/integration/application/job/NotificationListener.kt new file mode 100644 index 00000000..99123d45 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/application/job/NotificationListener.kt @@ -0,0 +1,24 @@ +package org.ionproject.integration.application.job + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.batch.core.ExitStatus +import org.springframework.batch.core.JobExecution +import org.springframework.batch.core.JobExecutionListener + +class NotificationListener : JobExecutionListener { + + private val log: Logger = LoggerFactory.getLogger(NotificationListener::class.java) + + override fun beforeJob(jobExecution: JobExecution) { + log.info("Job ID ${jobExecution.jobId} starting") + } + + override fun afterJob(jobExecution: JobExecution) { + when (jobExecution.exitStatus) { + ExitStatus.FAILED -> log.error("Job ID ${jobExecution.jobId} failed") + ExitStatus.COMPLETED -> log.info("Job ID ${jobExecution.jobId} completed") + else -> log.debug("Job ID ${jobExecution.jobId} exited with status = ${jobExecution.status}") + } + } +} diff --git a/src/main/kotlin/org/ionproject/integration/application/job/tasklet/DownloadAndCompareTasklet.kt b/src/main/kotlin/org/ionproject/integration/application/job/tasklet/DownloadAndCompareTasklet.kt index 487b1ccf..304d5f63 100644 --- a/src/main/kotlin/org/ionproject/integration/application/job/tasklet/DownloadAndCompareTasklet.kt +++ b/src/main/kotlin/org/ionproject/integration/application/job/tasklet/DownloadAndCompareTasklet.kt @@ -63,7 +63,7 @@ class DownloadAndCompareTasklet( throw DownloadAndCompareTaskletException("File already exists in $localFileDestination") } - val path = downloader.download(targetUri, localFileDestination) + val path = downloader.download(targetUri, localFileDestination, appProperties.timeoutInSeconds) .match( { it }, { diff --git a/src/main/kotlin/org/ionproject/integration/domain/calendar/BusinessObjects.kt b/src/main/kotlin/org/ionproject/integration/domain/calendar/BusinessObjects.kt index 0e1ce052..ce9454f9 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/calendar/BusinessObjects.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/calendar/BusinessObjects.kt @@ -3,6 +3,7 @@ package org.ionproject.integration.model.external.calendar import org.ionproject.integration.domain.common.Language import org.ionproject.integration.domain.common.School import org.ionproject.integration.domain.calendar.RawCalendarData +import org.ionproject.integration.domain.common.InstitutionModel import org.ionproject.integration.infrastructure.DateUtils import org.ionproject.integration.infrastructure.text.RegexUtils import java.time.LocalDate @@ -21,15 +22,15 @@ data class AcademicCalendar( private const val TABLE_DELIMITER = "extraction" private const val PT_INTERRUPTION_REGEX = "\\b(?:Interrupção|Férias)\\b" private const val PT_EVALUATION_REGEX = "\\b(?:Exames|Testes)\\b" - private const val PT_DETAILS_REGEX = "\\b(?:Turmas)\\b" + private const val PT_LECTURES_REGEX = "\\b(?:Turmas)\\b" - fun from(rawCalendarData: RawCalendarData): AcademicCalendar = + fun from(rawCalendarData: RawCalendarData, jobInstitution: InstitutionModel): AcademicCalendar = AcademicCalendar( creationDateTime = rawCalendarData.creationDate, retrievalDateTime = DateUtils.formatToISO8601(ZonedDateTime.now()), School( - "Instituto Superior de Engenharia de Lisboa", - "ISEL" + jobInstitution.name, + jobInstitution.acronym ), Language.PT, buildTerms(rawCalendarData) @@ -53,18 +54,18 @@ data class AcademicCalendar( private fun buildTerm(events: List, pdfRawText: String, term: CalendarTerm): Term { val interruptions = mutableListOf() val evaluations = mutableListOf() - val details = mutableListOf() - val lectures = mutableListOf() + val lectures = mutableListOf() + val otherEvents = mutableListOf() val (descriptions, dates) = events.withIndex().partition { it.index % 2 == 0 } descriptions.forEachIndexed { index, _ -> val intervalDate = DateUtils.getDateRange(dates[index].value) - when (getEventType(events[index])) { + when (getEventType(descriptions[index].value)) { EventType.EVALUATION -> { evaluations.add( Evaluation( - events[index], + descriptions[index].value, intervalDate.from, intervalDate.to, false @@ -74,16 +75,16 @@ data class AcademicCalendar( EventType.INTERRUPTION -> { interruptions.add( Event( - events[index], + descriptions[index].value, intervalDate.from, intervalDate.to, ) ) } - EventType.DETAILS -> { - details.add( - Detail( - events[index], + EventType.LECTURES -> { + lectures.add( + Lectures( + descriptions[index].value, listOf(), intervalDate.from, intervalDate.to, @@ -91,9 +92,9 @@ data class AcademicCalendar( ) } EventType.OTHER -> { - lectures.add( + otherEvents.add( Event( - events[index], + descriptions[index].value, intervalDate.from, intervalDate.to, ) @@ -110,8 +111,8 @@ data class AcademicCalendar( .plus("-${getTermNumber(term)}"), interruptions, evaluations, - details, - lectures + lectures, + otherEvents ) } @@ -125,7 +126,7 @@ data class AcademicCalendar( return when { event.contains(PT_INTERRUPTION_REGEX.toRegex(RegexOption.IGNORE_CASE)) -> EventType.INTERRUPTION event.contains(PT_EVALUATION_REGEX.toRegex(RegexOption.IGNORE_CASE)) -> EventType.EVALUATION - event.contains(PT_DETAILS_REGEX.toRegex(RegexOption.IGNORE_CASE)) -> EventType.DETAILS + event.contains(PT_LECTURES_REGEX.toRegex(RegexOption.IGNORE_CASE)) -> EventType.LECTURES else -> EventType.OTHER } } @@ -143,8 +144,8 @@ data class Term( val calendarTerm: String = "", val interruptions: List, val evaluations: List, - val details: List, - val lectures: List + val lectures: List, + val otherEvents: List ) data class Event( @@ -160,7 +161,7 @@ data class Evaluation( val duringLectures: Boolean ) -data class Detail( +data class Lectures( val name: String, val curricularTerm: List, val startDate: LocalDate, @@ -175,6 +176,6 @@ enum class CalendarTerm { enum class EventType { INTERRUPTION, EVALUATION, - DETAILS, + LECTURES, OTHER } diff --git a/src/main/kotlin/org/ionproject/integration/domain/calendar/OutputRepresentations.kt b/src/main/kotlin/org/ionproject/integration/domain/calendar/OutputRepresentations.kt index d072e6e5..f81916d2 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/calendar/OutputRepresentations.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/calendar/OutputRepresentations.kt @@ -1,9 +1,9 @@ package org.ionproject.integration.domain.calendar -import org.ionproject.integration.domain.timetable.dto.SchoolDto +import org.ionproject.integration.domain.common.dto.SchoolDto import org.ionproject.integration.infrastructure.DateUtils import org.ionproject.integration.model.external.calendar.AcademicCalendar -import org.ionproject.integration.model.external.calendar.Detail +import org.ionproject.integration.model.external.calendar.Lectures import org.ionproject.integration.model.external.calendar.Evaluation import org.ionproject.integration.model.external.calendar.Event import org.ionproject.integration.model.external.calendar.Term @@ -32,8 +32,8 @@ data class TermDto( val calendarTerm: String, val interruptions: List, val evaluations: List, - val details: List, - val lectures: List + val lectures: List, + val otherEvents: List ) { companion object { fun from(terms: List): List = terms.map { @@ -41,8 +41,8 @@ data class TermDto( it.calendarTerm, EventDto.from(it.interruptions), EvaluationDto.from(it.evaluations), - DetailDto.from(it.details), - EventDto.from(it.lectures) + LecturesDto.from(it.lectures), + EventDto.from(it.otherEvents) ) } } @@ -82,15 +82,15 @@ data class EvaluationDto( } } -data class DetailDto( +data class LecturesDto( val name: String, val curricularTerm: List, val startDate: String, val endDate: String, ) { companion object { - fun from(details: List): List = details.map { - DetailDto( + fun from(lectures: List): List = lectures.map { + LecturesDto( it.name, it.curricularTerm.map { id -> IdDto(id) }, DateUtils.formatToCalendarDate(it.startDate), diff --git a/src/main/kotlin/org/ionproject/integration/domain/timetable/model/Programme.kt b/src/main/kotlin/org/ionproject/integration/domain/common/Programme.kt similarity index 57% rename from src/main/kotlin/org/ionproject/integration/domain/timetable/model/Programme.kt rename to src/main/kotlin/org/ionproject/integration/domain/common/Programme.kt index c33fe3d7..fed6be98 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/timetable/model/Programme.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/common/Programme.kt @@ -1,4 +1,4 @@ -package org.ionproject.integration.domain.timetable.model +package org.ionproject.integration.domain.common data class Programme( var name: String = "", diff --git a/src/main/kotlin/org/ionproject/integration/domain/common/ProgrammeModel.kt b/src/main/kotlin/org/ionproject/integration/domain/common/ProgrammeModel.kt index fcbcec3e..8ab21646 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/common/ProgrammeModel.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/common/ProgrammeModel.kt @@ -1,10 +1,8 @@ package org.ionproject.integration.domain.common -import java.net.URI - data class ProgrammeModel( val institutionModel: InstitutionModel, val name: String, val acronym: String, - val timetableUri: URI + val resources: ProgrammeResources ) diff --git a/src/main/kotlin/org/ionproject/integration/domain/common/ProgrammeResources.kt b/src/main/kotlin/org/ionproject/integration/domain/common/ProgrammeResources.kt new file mode 100644 index 00000000..77062270 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/domain/common/ProgrammeResources.kt @@ -0,0 +1,8 @@ +package org.ionproject.integration.domain.common + +import java.net.URI + +data class ProgrammeResources( + val timetableUri: URI, + val evaluationsUri: URI +) diff --git a/src/main/kotlin/org/ionproject/integration/domain/common/dto/ProgrammeDto.kt b/src/main/kotlin/org/ionproject/integration/domain/common/dto/ProgrammeDto.kt new file mode 100644 index 00000000..c7b82e46 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/domain/common/dto/ProgrammeDto.kt @@ -0,0 +1,6 @@ +package org.ionproject.integration.domain.common.dto + +data class ProgrammeDto( + val name: String, + val acr: String, +) diff --git a/src/main/kotlin/org/ionproject/integration/domain/common/dto/SchoolDto.kt b/src/main/kotlin/org/ionproject/integration/domain/common/dto/SchoolDto.kt new file mode 100644 index 00000000..95cc7bd2 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/domain/common/dto/SchoolDto.kt @@ -0,0 +1,6 @@ +package org.ionproject.integration.domain.common.dto + +data class SchoolDto( + val name: String, + val acr: String, +) diff --git a/src/main/kotlin/org/ionproject/integration/domain/evaluations/BusinessObjects.kt b/src/main/kotlin/org/ionproject/integration/domain/evaluations/BusinessObjects.kt new file mode 100644 index 00000000..14a0c832 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/domain/evaluations/BusinessObjects.kt @@ -0,0 +1,210 @@ +package org.ionproject.integration.domain.evaluations + +import com.squareup.moshi.Types +import org.ionproject.integration.domain.common.Programme +import org.ionproject.integration.domain.common.ProgrammeModel +import org.ionproject.integration.domain.common.School +import org.ionproject.integration.infrastructure.DateUtils +import org.ionproject.integration.infrastructure.Try +import org.ionproject.integration.infrastructure.orThrow +import org.ionproject.integration.infrastructure.pdfextractor.tabula.Table +import org.ionproject.integration.infrastructure.text.JsonUtils +import org.ionproject.integration.infrastructure.text.RegexUtils +import java.time.ZonedDateTime + +data class Evaluations( + val creationDateTime: String = "", + val retrievalDateTime: String = "", + val school: School, + val programme: Programme, + val calendarTerm: String = "", + val exams: List +) { + companion object { + private const val CALENDAR_TERM_REGEX = "(verão|inverno) \\d{4}\\/\\d{4}" + private const val SUMMER_TERM = "verão" + private const val WINTER_TERM = "inverno" + + fun from(rawEvaluationsData: RawEvaluationsData, jobProgramme: ProgrammeModel): Evaluations { + val calendarTerm = buildCalendarTerm(rawEvaluationsData) + + return Evaluations( + rawEvaluationsData.creationDate, + DateUtils.formatToISO8601(ZonedDateTime.now()), + School( + jobProgramme.institutionModel.name, + jobProgramme.institutionModel.acronym + ), + Programme( + jobProgramme.name, + jobProgramme.acronym + ), + calendarTerm, + buildExamList(rawEvaluationsData, jobProgramme, getCalendarYear(calendarTerm)) + ) + } + + private fun getCalendarYear(calendarTerm: String) = + when (val termNumber = calendarTerm.last()) { + '1' -> calendarTerm.take(4) + '2' -> calendarTerm.substring(5, 9) + else -> throw IllegalArgumentException("Invalid term description: $termNumber") + } + + private fun buildCalendarTerm(rawEvaluationsData: RawEvaluationsData): String { + val calendarTerm = + RegexUtils.findMatches(CALENDAR_TERM_REGEX, rawEvaluationsData.textData.toString()).first() + .replace("/", "-") + + val termNumber = when (val termType = calendarTerm.substringBefore(" ").lowercase()) { + WINTER_TERM -> 1 + SUMMER_TERM -> 2 + else -> throw IllegalArgumentException("Invalid term description: $termType") + } + + return calendarTerm.substringAfter(" ") + "-" + termNumber + } + + private fun buildExamList( + rawEvaluationsData: RawEvaluationsData, + jobProgramme: ProgrammeModel, + year: String + ): List = + rawEvaluationsData.table.toTableList().map { getExamsFromTable(it, jobProgramme.acronym, year) }.orThrow() + + private fun String.toTableList(): Try> = + JsonUtils.fromJson(this, Types.newParameterizedType(List::class.java, Table::class.java)) + + private fun getExamsFromTable( + tableList: List, + programmeAcronym: String, + year: String + ): List { + val examList = mutableListOf() + for (table in tableList) { + for (line in table.data) { + val cleanedLine = line.dropWhile { it.text.isBlank() } + val courseAcronym = trimCourse(cleanedLine[TableColumn.COURSE.ordinal].text) + if (cleanedLine[TableColumn.SUMMER_EXAM_PROGRAMME.ordinal].text.contains(programmeAcronym)) { + val intervalDateTimeNormal = + DateUtils.getEvaluationDateTimeFrom( + year, + cleanedLine[TableColumn.NORMAL_EXAM_DATE.ordinal].text, + cleanedLine[TableColumn.NORMAL_EXAM_TIME.ordinal].text, + cleanedLine[TableColumn.NORMAL_EXAM_DURATION.ordinal].text + ) + examList.add( + Exam( + courseAcronym, + intervalDateTimeNormal.from, + intervalDateTimeNormal.to, + ExamCategory.EXAM_NORMAL, + "" + ) + ) + val intervalDateTimeAltern = + DateUtils.getEvaluationDateTimeFrom( + year, + cleanedLine[TableColumn.ALTERN_EXAM_DATE.ordinal].text, + cleanedLine[TableColumn.ALTERN_EXAM_TIME.ordinal].text, + cleanedLine[TableColumn.ALTERN_EXAM_DURATION.ordinal].text + ) + examList.add( + Exam( + courseAcronym, + intervalDateTimeAltern.from, + intervalDateTimeAltern.to, + ExamCategory.EXAM_ALTERN, + "" + ) + ) + val intervalDateTimeSpecial = + DateUtils.getEvaluationDateTimeFrom( + year, + cleanedLine[TableColumn.SPECIAL_EXAM_DATE.ordinal].text, + cleanedLine[TableColumn.SPECIAL_EXAM_TIME.ordinal].text, + cleanedLine[TableColumn.SPECIAL_EXAM_DURATION.ordinal].text + ) + examList.add( + Exam( + courseAcronym, + intervalDateTimeSpecial.from, + intervalDateTimeSpecial.to, + ExamCategory.EXAM_SPECIAL, + "" + ) + ) + } + if (cleanedLine[TableColumn.WINTER_EXAM_PROGRAMME.ordinal].text.contains(programmeAcronym) && + !cleanedLine[TableColumn.SUMMER_EXAM_PROGRAMME.ordinal].text.contains(programmeAcronym) + ) { + val intervalDateTimeSpecial = + DateUtils.getEvaluationDateTimeFrom( + year, + cleanedLine[TableColumnWinterCourse.SPECIAL_EXAM_DATE.ordinal].text, + cleanedLine[TableColumnWinterCourse.SPECIAL_EXAM_TIME.ordinal].text, + cleanedLine[TableColumnWinterCourse.SPECIAL_EXAM_DURATION.ordinal].text + ) + examList.add( + Exam( + courseAcronym, + intervalDateTimeSpecial.from, + intervalDateTimeSpecial.to, + ExamCategory.EXAM_SPECIAL, + "" + ) + ) + } + } + } + return examList.toList() + } + + /** + * Removes any substring from the course acronum that starts with hyphen. + * Examples: SO-leect-leirt, SS-leetc + */ + private fun trimCourse(course: String) = + course.substringBefore("-") + } +} + +data class Exam( + val course: String, + val startDate: ZonedDateTime, + val endDate: ZonedDateTime, + val category: ExamCategory, + val location: String +) + +enum class ExamCategory { + TEST, + EXAM_NORMAL, + EXAM_ALTERN, + EXAM_SPECIAL +} + +private enum class TableColumn { + COURSE, + WINTER_EXAM_PROGRAMME, + SUMMER_EXAM_PROGRAMME, + NORMAL_EXAM_DATE, + NORMAL_EXAM_TIME, + NORMAL_EXAM_DURATION, + ALTERN_EXAM_DATE, + ALTERN_EXAM_TIME, + ALTERN_EXAM_DURATION, + SPECIAL_EXAM_DATE, + SPECIAL_EXAM_TIME, + SPECIAL_EXAM_DURATION +} + +private enum class TableColumnWinterCourse { + COURSE, + WINTER_EXAM_PROGRAMME, + SUMMER_EXAM_PROGRAMME, + WINTER_COURSE, + SPECIAL_EXAM_DATE, + SPECIAL_EXAM_TIME, + SPECIAL_EXAM_DURATION +} diff --git a/src/main/kotlin/org/ionproject/integration/domain/evaluations/OutputRepresentations.kt b/src/main/kotlin/org/ionproject/integration/domain/evaluations/OutputRepresentations.kt new file mode 100644 index 00000000..1ebd991c --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/domain/evaluations/OutputRepresentations.kt @@ -0,0 +1,53 @@ +package org.ionproject.integration.domain.evaluations + +import org.ionproject.integration.domain.common.dto.ProgrammeDto +import org.ionproject.integration.domain.common.dto.SchoolDto +import org.ionproject.integration.infrastructure.DateUtils + +data class EvaluationsDto( + val creationDateTime: String = "", + val retrievalDateTime: String = "", + val school: SchoolDto, + val programme: ProgrammeDto, + val calendarTerm: String, + val exams: List +) { + companion object { + fun from(evaluations: Evaluations): EvaluationsDto { + return EvaluationsDto( + evaluations.creationDateTime, + evaluations.retrievalDateTime, + SchoolDto( + evaluations.school.name, + evaluations.school.acr + ), + ProgrammeDto( + evaluations.programme.name, + evaluations.programme.acr + ), + evaluations.calendarTerm, + ExamDto.from(evaluations.exams) + ) + } + } +} + +data class ExamDto( + val course: String, + val startDate: String, + val endDate: String, + val category: String, + val location: String +) { + companion object { + fun from(exams: List): List = exams.map { + ExamDto( + it.course, + DateUtils.formatToISO8601(it.startDate), + DateUtils.formatToISO8601(it.endDate), + it.category.name, + it.location + ) + } + } +} diff --git a/src/main/kotlin/org/ionproject/integration/domain/evaluations/RawEvaluationsData.kt b/src/main/kotlin/org/ionproject/integration/domain/evaluations/RawEvaluationsData.kt new file mode 100644 index 00000000..01d85b61 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/domain/evaluations/RawEvaluationsData.kt @@ -0,0 +1,7 @@ +package org.ionproject.integration.domain.evaluations + +data class RawEvaluationsData( + val textData: List, + val table: String, + val creationDate: String +) diff --git a/src/main/kotlin/org/ionproject/integration/domain/timetable/IselTimetableTeachersBuilder.kt b/src/main/kotlin/org/ionproject/integration/domain/timetable/IselTimetableTeachersBuilder.kt index bc86badc..057ce711 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/timetable/IselTimetableTeachersBuilder.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/timetable/IselTimetableTeachersBuilder.kt @@ -1,30 +1,30 @@ package org.ionproject.integration.domain.timetable import com.squareup.moshi.Types -import org.ionproject.integration.domain.exception.TimetableTeachersBuilderException -import org.ionproject.integration.domain.timetable.model.ClassDetail -import org.ionproject.integration.domain.timetable.model.Course -import org.ionproject.integration.domain.timetable.model.CourseTeacher -import org.ionproject.integration.domain.timetable.model.Faculty -import org.ionproject.integration.domain.timetable.model.Label import org.ionproject.integration.domain.common.Language -import org.ionproject.integration.domain.timetable.model.Programme -import org.ionproject.integration.domain.timetable.model.RecurrentEvent +import org.ionproject.integration.domain.common.Programme import org.ionproject.integration.domain.common.School import org.ionproject.integration.domain.common.Weekday +import org.ionproject.integration.domain.exception.TimetableTeachersBuilderException import org.ionproject.integration.domain.timetable.dto.FacultyDTO import org.ionproject.integration.domain.timetable.dto.FacultyRawData import org.ionproject.integration.domain.timetable.dto.RawTimetableData import org.ionproject.integration.domain.timetable.dto.toDto +import org.ionproject.integration.domain.timetable.model.ClassDetail +import org.ionproject.integration.domain.timetable.model.Course +import org.ionproject.integration.domain.timetable.model.CourseTeacher +import org.ionproject.integration.domain.timetable.model.Faculty +import org.ionproject.integration.domain.timetable.model.Label +import org.ionproject.integration.domain.timetable.model.RecurrentEvent +import org.ionproject.integration.infrastructure.DateUtils +import org.ionproject.integration.infrastructure.Try +import org.ionproject.integration.infrastructure.orThrow import org.ionproject.integration.infrastructure.pdfextractor.tabula.Cell import org.ionproject.integration.infrastructure.pdfextractor.tabula.Table -import org.ionproject.integration.infrastructure.DateUtils import org.ionproject.integration.infrastructure.text.IgnoredWords import org.ionproject.integration.infrastructure.text.JsonUtils import org.ionproject.integration.infrastructure.text.RegexUtils -import org.ionproject.integration.infrastructure.Try import org.ionproject.integration.infrastructure.text.generateAcronym -import org.ionproject.integration.infrastructure.orThrow import java.time.Duration import java.time.LocalTime import java.time.ZonedDateTime diff --git a/src/main/kotlin/org/ionproject/integration/domain/timetable/Timetable.kt b/src/main/kotlin/org/ionproject/integration/domain/timetable/Timetable.kt index 9a9994ba..c53c0bb3 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/timetable/Timetable.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/timetable/Timetable.kt @@ -1,7 +1,7 @@ package org.ionproject.integration.domain.timetable import org.ionproject.integration.domain.timetable.model.Course -import org.ionproject.integration.domain.timetable.model.Programme +import org.ionproject.integration.domain.common.Programme import org.ionproject.integration.domain.common.School data class Timetable( diff --git a/src/main/kotlin/org/ionproject/integration/domain/timetable/dto/OutputRepresentations.kt b/src/main/kotlin/org/ionproject/integration/domain/timetable/dto/OutputRepresentations.kt index 03fe383d..5cb245ae 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/timetable/dto/OutputRepresentations.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/timetable/dto/OutputRepresentations.kt @@ -1,12 +1,14 @@ package org.ionproject.integration.domain.timetable.dto import com.fasterxml.jackson.annotation.JsonInclude +import org.ionproject.integration.domain.common.dto.ProgrammeDto +import org.ionproject.integration.domain.common.dto.SchoolDto +import org.ionproject.integration.domain.timetable.TimetableTeachers import org.ionproject.integration.domain.timetable.model.Course import org.ionproject.integration.domain.timetable.model.CourseTeacher import org.ionproject.integration.domain.timetable.model.EventCategory import org.ionproject.integration.domain.timetable.model.Instructor import org.ionproject.integration.domain.timetable.model.RecurrentEvent -import org.ionproject.integration.domain.timetable.TimetableTeachers data class TimetableDto( val creationDateTime: String, @@ -83,16 +85,6 @@ data class TimetableDto( } } -data class SchoolDto( - val name: String, - val acr: String, -) - -data class ProgrammeDto( - val name: String, - val acr: String, -) - data class ClassDto( val acr: String, val sections: List diff --git a/src/main/kotlin/org/ionproject/integration/domain/timetable/model/CourseTeacher.kt b/src/main/kotlin/org/ionproject/integration/domain/timetable/model/CourseTeacher.kt index 05d62a23..72463632 100644 --- a/src/main/kotlin/org/ionproject/integration/domain/timetable/model/CourseTeacher.kt +++ b/src/main/kotlin/org/ionproject/integration/domain/timetable/model/CourseTeacher.kt @@ -1,5 +1,6 @@ package org.ionproject.integration.domain.timetable.model +import org.ionproject.integration.domain.common.Programme import org.ionproject.integration.domain.common.School data class CourseTeacher( diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/DateUtils.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/DateUtils.kt index 04039eab..5a77c8a0 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/DateUtils.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/DateUtils.kt @@ -1,7 +1,10 @@ package org.ionproject.integration.infrastructure import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime import java.time.Month +import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -56,6 +59,11 @@ object DateUtils { fun formatToISO8601(zonedDateTime: ZonedDateTime): String = zonedDateTime.format(DateTimeFormatter.ofPattern(CALENDAR_ISO8601_FORMAT)) + fun formatToISO8601(localDateTime: LocalDateTime): String { + val zonedDateTime = ZonedDateTime.of(localDateTime, ZoneId.systemDefault()) + return formatToISO8601(zonedDateTime) + } + fun isDateRange(eventDateString: String): Boolean = eventDateString.contains(PT_DATA_RANGE_DELIMITERS_REGEX.toRegex()) @@ -84,6 +92,32 @@ object DateUtils { return IntervalDate(fromDate, toDate) } + fun getEvaluationDateTimeFrom( + year: String, + dayMonth: String, // Format: 15 Jul.(Quinta) + time: String, + duration: String + ): IntervalDateTime { + val timeFormat = DateTimeFormatter.ofPattern("HH'h'mm") + val durationFormat = DateTimeFormatter.ofPattern("H'h'mm") + val dateFormat = DateTimeFormatter.ofPattern("d MMM yyyy", localePT) + + val timeParsed = LocalTime.parse(time, timeFormat) + val durationParsed = LocalTime.parse(duration, durationFormat) + + // Format: d MMM yyyy - Ex.: 15 Jul 2021 + val rawDate = "${dayMonth.split(".").first()} $year" + val date = LocalDate.parse(rawDate, dateFormat) + + val startDateTime = ZonedDateTime.of(LocalDateTime.of(date, timeParsed), ZoneId.systemDefault()) + val endDateTime = + ZonedDateTime.of(LocalDateTime.of(date, addToStartTime(timeParsed, durationParsed)), ZoneId.systemDefault()) + return IntervalDateTime(startDateTime, endDateTime) + } + + private fun addToStartTime(time: LocalTime, duration: LocalTime): LocalTime = + time.plusHours(duration.hour.toLong()).plusMinutes(duration.minute.toLong()) + private fun buildBeginDate(string: String, month: Month, year: Int): LocalDate { if (isMonthAndYearUnavailable(string)) return LocalDate.of(year, month, string.toInt()) @@ -97,6 +131,11 @@ object DateUtils { private fun isMonthAndYearUnavailable(string: String): Boolean = string.length <= 2 } +data class IntervalDateTime( + val from: ZonedDateTime, + val to: ZonedDateTime +) + data class IntervalDate( val from: LocalDate, val to: LocalDate diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/file/OutputFormat.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/file/OutputFormat.kt index a58e364f..916b079c 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/file/OutputFormat.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/file/OutputFormat.kt @@ -1,14 +1,23 @@ package org.ionproject.integration.infrastructure.file import org.ionproject.integration.infrastructure.exception.ArgumentException +import org.ionproject.integration.infrastructure.text.containsCaseInsensitive -enum class OutputFormat(val extension: String) { - YAML(".yml"), +internal const val INVALID_FORMAT_ERROR = "Invalid format: %s" + +enum class OutputFormat(val extension: String, private val alternativeNames: List = emptyList()) { + YAML(".yml", listOf("yml")), JSON(".json"); companion object { - fun of(name: String): OutputFormat = - values().firstOrNull { it.name.equals(name.trim(), ignoreCase = true) } - ?: throw ArgumentException("Invalid format: $name") + fun of(name: String): OutputFormat { + val trimName = name.trim() + return values().firstOrNull { format -> + format.nameEquals(trimName) || format.alternativeNames.containsCaseInsensitive(trimName) + } + ?: throw ArgumentException(INVALID_FORMAT_ERROR.format(trimName)) + } } + + private fun nameEquals(name: String): Boolean = this.name.equals(name, ignoreCase = true) } diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/http/DownloaderImpl.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/http/DownloaderImpl.kt index 83c85bbf..73abb781 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/http/DownloaderImpl.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/http/DownloaderImpl.kt @@ -2,19 +2,42 @@ package org.ionproject.integration.infrastructure.http import org.ionproject.integration.infrastructure.Try import org.springframework.stereotype.Service +import java.io.File +import java.lang.IllegalStateException +import java.net.HttpURLConnection import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse import java.nio.file.Path +import java.time.Duration @Service -class DownloaderImpl(val channelProvider: ChannelProvider) : IFileDownloader { - override fun download(uri: URI, localDestination: Path): Try { - channelProvider.getRemoteChannel(uri).use { remoteChannel -> - channelProvider.getLocalChannel(localDestination).use { fileChannel -> - return Try.of { - fileChannel.transferFrom(remoteChannel, 0, Long.MAX_VALUE) - localDestination - } - } - } +class DownloaderImpl : IFileDownloader { + private val client by lazy { HttpClient.newHttpClient() } + + override fun download(uri: URI, localDestination: Path, timeoutInSeconds: Int): Try = Try.of { + val response = sendRequest(uri, timeoutInSeconds) + + if (response.isError()) + throw IllegalStateException("Server responded with error code ${response.statusCode()}") + + val file = response.writeToFile(localDestination) + file.toPath() + } + + private fun sendRequest(uri: URI, timeoutInSeconds: Int): HttpResponse { + val request = HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.ofSeconds(timeoutInSeconds.toLong())) + .build() + + return client.send(request, HttpResponse.BodyHandlers.ofByteArray()) } + + private fun HttpResponse.isError(): Boolean = + statusCode() in HttpURLConnection.HTTP_INTERNAL_ERROR..HttpURLConnection.HTTP_VERSION + + private fun HttpResponse.writeToFile(localDestination: Path): File = + localDestination.toFile().also { file -> file.writeBytes(body()) } } diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/http/IFileDownloader.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/http/IFileDownloader.kt index 3cd23b4e..815fcac1 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/http/IFileDownloader.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/http/IFileDownloader.kt @@ -5,5 +5,5 @@ import java.nio.file.Path import org.ionproject.integration.infrastructure.Try interface IFileDownloader { - fun download(uri: URI, localDestination: Path): Try + fun download(uri: URI, localDestination: Path, timeoutInSeconds: Int = 30): Try } diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/EvaluationsExtractor.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/EvaluationsExtractor.kt new file mode 100644 index 00000000..efc808ab --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/EvaluationsExtractor.kt @@ -0,0 +1,17 @@ +package org.ionproject.integration.infrastructure.pdfextractor + +import org.ionproject.integration.infrastructure.Try + +object EvaluationsExtractor { + // Arguments to pass to tabula + // -g = Guess the portion of the page to analyze per page + // -t = Force PDF to be extracted using stream-mode + // -p = Page range + // -f = Output format JSON + // -a top, left, bottom, right + + val evaluationsTable = object : IPdfExtractor { + override fun extract(pdfPath: String): Try> = + PdfUtils.processPdf(pdfPath, "-g", "-l", "-p", "all", "-f", "JSON") + } +} diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/ITextPdfExtractor.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/ITextPdfExtractor.kt index 71d77a13..8a73cf76 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/ITextPdfExtractor.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/ITextPdfExtractor.kt @@ -5,9 +5,9 @@ import com.itextpdf.kernel.pdf.PdfDocument import com.itextpdf.kernel.pdf.PdfName import com.itextpdf.kernel.pdf.PdfReader import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor -import org.ionproject.integration.infrastructure.exception.PdfExtractorException import org.ionproject.integration.infrastructure.DateUtils import org.ionproject.integration.infrastructure.Try +import org.ionproject.integration.infrastructure.exception.PdfExtractorException import java.time.ZoneId import java.time.ZonedDateTime import java.util.Calendar diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/tabula/Table.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/tabula/Table.kt index bdec57f7..28a91c00 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/tabula/Table.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/pdfextractor/tabula/Table.kt @@ -10,6 +10,10 @@ data class Table( val bottom: Double, val data: Array> ) { + companion object { + private const val HASH_PRIME_NUMBER = 31 + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -30,13 +34,13 @@ data class Table( override fun hashCode(): Int { var result = extraction_method.hashCode() - result = 31 * result + top.hashCode() - result = 31 * result + left.hashCode() - result = 31 * result + width.hashCode() - result = 31 * result + height.hashCode() - result = 31 * result + right.hashCode() - result = 31 * result + bottom.hashCode() - result = 31 * result + data.contentDeepHashCode() + result = HASH_PRIME_NUMBER * result + top.hashCode() + result = HASH_PRIME_NUMBER * result + left.hashCode() + result = HASH_PRIME_NUMBER * result + width.hashCode() + result = HASH_PRIME_NUMBER * result + height.hashCode() + result = HASH_PRIME_NUMBER * result + right.hashCode() + result = HASH_PRIME_NUMBER * result + bottom.hashCode() + result = HASH_PRIME_NUMBER * result + data.contentDeepHashCode() return result } } diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/repository/InstitutionRepositoryImpl.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/repository/InstitutionRepositoryImpl.kt index b432c151..9f101008 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/repository/InstitutionRepositoryImpl.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/repository/InstitutionRepositoryImpl.kt @@ -6,6 +6,7 @@ import org.ionproject.integration.application.config.AppProperties import org.ionproject.integration.domain.common.InstitutionModel import org.ionproject.integration.domain.common.Language import org.ionproject.integration.domain.common.ProgrammeModel +import org.ionproject.integration.domain.common.ProgrammeResources import org.ionproject.integration.infrastructure.exception.ArgumentException import org.ionproject.integration.infrastructure.text.IgnoredWords import org.ionproject.integration.infrastructure.text.generateAcronym @@ -39,7 +40,7 @@ class InstitutionRepositoryImpl : IInstitutionRepository { internal fun findInstitution(identifier: String, institutions: List): InstitutionDto = institutions .firstOrNull { it.identifier.equals(identifier, ignoreCase = true) } - ?: throw ArgumentException("Institution with $identifier not found") + ?: throw ArgumentException("Institution with ID '$identifier' not found") } internal data class InstitutionDto( @@ -68,7 +69,8 @@ internal data class ResourceDto( internal enum class ResourceType(val identifier: String) { CALENDAR("academic_calendar"), - TIMETABLE("timetable") + TIMETABLE("timetable"), + EVALUATIONS("evaluations") } internal data class ProgrammeDto( @@ -79,12 +81,18 @@ internal data class ProgrammeDto( fun toModel(institution: InstitutionModel): ProgrammeModel { val acronym = generateAcronym(name, IgnoredWords.of(Language.PT)) val timetableUri = resources.first { it.type == ResourceType.TIMETABLE.identifier } + val evaluationsUri = resources.first { it.type == ResourceType.EVALUATIONS.identifier } + + val resources = ProgrammeResources( + timetableUri = URI(timetableUri.uri), + evaluationsUri = URI(evaluationsUri.uri) + ) return ProgrammeModel( institutionModel = institution, name = this.name, acronym = acronym, - timetableUri = URI(timetableUri.uri) + resources = resources ) } } diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/repository/IntegrationJobRepository.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/repository/IntegrationJobRepository.kt index af65ce62..e7e44632 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/repository/IntegrationJobRepository.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/repository/IntegrationJobRepository.kt @@ -50,16 +50,16 @@ class IntegrationJobRepository( } private fun getJobDataFromResultSet(resultSet: ResultSet): JobEngine.IntegrationJob { - // TODO: create vars for indexes - val id = resultSet.getLong(1) - val jobIdentifier = resultSet.getString(2) - val creationDate = resultSet.getTimestamp(3) - val startDate = resultSet.getTimestamp(4) - val status = resultSet.getString(5) - val format = resultSet.getString(6) - val institutionIdentifier = resultSet.getString(7) - val programme = resultSet.getString(8) - val uri = resultSet.getString(9) + val id = resultSet.getLong(JobQueryFields.ID.index) + val jobIdentifier = resultSet.getString(JobQueryFields.NAME.index) + val creationDate = resultSet.getTimestamp(JobQueryFields.CREATION_DATE.index) + val startDate = resultSet.getTimestamp(JobQueryFields.START_DATE.index) + val endDate = resultSet.getTimestamp(JobQueryFields.END_DATE.index) + val status = resultSet.getString(JobQueryFields.STATUS.index) + val format = resultSet.getString(JobQueryFields.FORMAT.index) + val institutionIdentifier = resultSet.getString(JobQueryFields.INSTITUTION.index) + val programme = resultSet.getString(JobQueryFields.PROGRAMME.index) + val uri = resultSet.getString(JobQueryFields.URI.index) val jobType = JobType.of(jobIdentifier) ?: throw IllegalArgumentException("Invalid job: $jobIdentifier") @@ -67,7 +67,8 @@ class IntegrationJobRepository( val parameters = JobEngine.IntegrationJobParameters( creationDate.toLocalDateTime(), - startDate.toLocalDateTime(), + startDate?.toLocalDateTime(), + endDate?.toLocalDateTime(), OutputFormat.of(format), institution, if (jobType != JobType.ACADEMIC_CALENDAR) diff --git a/src/main/kotlin/org/ionproject/integration/infrastructure/repository/Queries.kt b/src/main/kotlin/org/ionproject/integration/infrastructure/repository/Queries.kt index ea6a3ec6..4b4a1002 100644 --- a/src/main/kotlin/org/ionproject/integration/infrastructure/repository/Queries.kt +++ b/src/main/kotlin/org/ionproject/integration/infrastructure/repository/Queries.kt @@ -1,14 +1,33 @@ package org.ionproject.integration.infrastructure.repository +/** + * Ordering on this enum MUST match ordering on CREATE_JOBS_VIEW_QUERY + */ +internal enum class JobQueryFields { + ID, + NAME, + CREATION_DATE, + START_DATE, + END_DATE, + STATUS, + FORMAT, + INSTITUTION, + PROGRAMME, + URI; + + val index = ordinal + 1 +} + const val CREATE_JOBS_VIEW_QUERY = """create or replace view vw_job_detail as SELECT bje.job_instance_id as id , bji.job_name as name - , bje.create_time as creation_date - , bje.start_time as start_date + , bje.create_time at time zone 'utc' as creation_date + , bje.start_time at time zone 'utc' as start_date + , bje.end_time at time zone 'utc' as end_time ,CASE WHEN bje.STATUS IN ('STARTED','STARTING') - AND bje.create_time < (CURRENT_TIMESTAMP - interval '1 hour') + AND (bje.create_time at time zone 'utc') < ((CURRENT_TIMESTAMP at time zone 'utc') - interval '1 hours') THEN 'FAILED' ELSE bje.status END as status diff --git a/src/main/kotlin/org/ionproject/integration/ui/controller/ControllerConfig.kt b/src/main/kotlin/org/ionproject/integration/ui/controller/ControllerConfig.kt index 77036251..e4194f7f 100644 --- a/src/main/kotlin/org/ionproject/integration/ui/controller/ControllerConfig.kt +++ b/src/main/kotlin/org/ionproject/integration/ui/controller/ControllerConfig.kt @@ -2,27 +2,36 @@ package org.ionproject.integration.ui.controller import org.ionproject.integration.infrastructure.exception.ArgumentException import org.ionproject.integration.infrastructure.exception.JobNotFoundException -import org.springframework.http.HttpHeaders +import org.ionproject.integration.ui.dto.Problem import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.context.request.ServletWebRequest import org.springframework.web.context.request.WebRequest import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler @ControllerAdvice class ControllerConfig : ResponseEntityExceptionHandler() { - // TODO: Use json+problem (?) @ExceptionHandler(value = [ArgumentException::class]) - fun handle(exception: ArgumentException, request: WebRequest): ResponseEntity { + fun handle(exception: ArgumentException, request: WebRequest): ResponseEntity { logger.error("Error processing request $request: ${exception.message}") - return handleExceptionInternal(exception, exception.message, HttpHeaders(), HttpStatus.BAD_REQUEST, request) + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(Problem.of(exception, (request as ServletWebRequest).request)) } @ExceptionHandler(value = [JobNotFoundException::class]) - fun handleNotFound(exception: JobNotFoundException, request: WebRequest): ResponseEntity { + fun handleNotFound(exception: JobNotFoundException, request: WebRequest): ResponseEntity { logger.error("Error processing request $request: ${exception.message}") - return handleExceptionInternal(exception, exception.message, HttpHeaders(), HttpStatus.NOT_FOUND, request) + + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(Problem.of(exception, (request as ServletWebRequest).request)) } } diff --git a/src/main/kotlin/org/ionproject/integration/ui/controller/JobController.kt b/src/main/kotlin/org/ionproject/integration/ui/controller/JobController.kt index 9b5dccfa..7f00af24 100644 --- a/src/main/kotlin/org/ionproject/integration/ui/controller/JobController.kt +++ b/src/main/kotlin/org/ionproject/integration/ui/controller/JobController.kt @@ -3,45 +3,80 @@ package org.ionproject.integration.ui.controller import org.ionproject.integration.application.JobEngine import org.ionproject.integration.ui.dto.CreateJobDto import org.ionproject.integration.ui.dto.InputProcessor +import org.ionproject.integration.ui.dto.JobDetailDto +import org.ionproject.integration.ui.dto.PostResponse import org.slf4j.LoggerFactory -import org.springframework.batch.core.explore.JobExplorer +import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +internal const val JOBS_URI = "/jobs" +private const val HTTP_PORT = 80 @RestController -@RequestMapping("/jobs") +@RequestMapping(JOBS_URI) class JobController( val jobEngine: JobEngine, val inputProcessor: InputProcessor, - val jobExplorer: JobExplorer ) { private val logger = LoggerFactory.getLogger(JobController::class.java) @PostMapping(consumes = ["application/json"]) - fun createTimetableJob(@RequestBody body: CreateJobDto): String { + fun createJob( + @RequestBody body: CreateJobDto, + servletRequest: HttpServletRequest, + response: HttpServletResponse + ): PostResponse { val request = inputProcessor.getJobRequest(body) val requestResult = jobEngine.runJob(request) return when (requestResult.result) { - JobEngine.JobExecutionResult.CREATED -> "Created ${request.javaClass.simpleName} job with ID ${requestResult.jobId}" - else -> "FAILED: ${requestResult.result}" + JobEngine.JobExecutionResult.CREATED -> { + PostResponse( + location = servletRequest.getLocationForJobRequest(requestResult), + status = HttpStatus.CREATED, + response = response + ) + } + else -> { + logger.error("Job creation failed: $body") + PostResponse( + status = HttpStatus.BAD_REQUEST, + response = response + ) + } } } @GetMapping - fun getJobs(): List { - val jobs = jobEngine.getRunningJobs() - return jobs - } + fun getJobs(servletRequest: HttpServletRequest): List = + jobEngine.getRunningJobs() + .map { job -> + val url = servletRequest.getLocationForJobRequest(job.status) + JobDetailDto.of(job, url, JobDetailDto.DetailType.METADATA_ONLY) + } @GetMapping("/{id}") - fun getJobDetails(@PathVariable id: Long): JobEngine.IntegrationJob { + fun getJobDetails( + @PathVariable id: Long, + servletRequest: HttpServletRequest + ): JobDetailDto { val job = jobEngine.getJob(id) - return job + val url = servletRequest.getLocationForJobRequest(job.status) + + return JobDetailDto.of(job, url, JobDetailDto.DetailType.FULL) + } + + private fun HttpServletRequest.getLocationForJobRequest(jobStatus: JobEngine.JobStatus): String { + val portField = if (serverPort != HTTP_PORT) ":$serverPort" else "" + + return "$scheme://$serverName$portField$contextPath$JOBS_URI/${jobStatus.jobId}" } } diff --git a/src/main/kotlin/org/ionproject/integration/ui/dto/CreateJobDto.kt b/src/main/kotlin/org/ionproject/integration/ui/dto/CreateJobDto.kt index c1163433..bffb89b3 100644 --- a/src/main/kotlin/org/ionproject/integration/ui/dto/CreateJobDto.kt +++ b/src/main/kotlin/org/ionproject/integration/ui/dto/CreateJobDto.kt @@ -1,7 +1,6 @@ package org.ionproject.integration.ui.dto import org.ionproject.integration.application.job.JobType -import java.lang.UnsupportedOperationException data class CreateJobDto( val institution: String? = null, @@ -13,7 +12,7 @@ data class CreateJobDto( return when (type) { JobType.TIMETABLE -> SafeTimetableJobDto(institution!!, programme!!, format!!) JobType.ACADEMIC_CALENDAR -> SafeCalendarJobDto(institution!!, format!!) - JobType.EXAM_SCHEDULE -> throw UnsupportedOperationException("Exam Schedule not yet supported") + JobType.EXAM_SCHEDULE -> SafeEvaluationsJobDto(institution!!, programme!!, format!!) } } } diff --git a/src/main/kotlin/org/ionproject/integration/ui/dto/InputProcessor.kt b/src/main/kotlin/org/ionproject/integration/ui/dto/InputProcessor.kt index 8c555d0e..6d2c5b02 100644 --- a/src/main/kotlin/org/ionproject/integration/ui/dto/InputProcessor.kt +++ b/src/main/kotlin/org/ionproject/integration/ui/dto/InputProcessor.kt @@ -7,6 +7,12 @@ import org.ionproject.integration.infrastructure.repository.IInstitutionReposito import org.ionproject.integration.infrastructure.repository.IProgrammeRepository import org.springframework.stereotype.Component +internal const val INVALID_JOB_TYPE_ERROR = "Invalid Job Type: %s" +internal const val MISSING_PARAMETER_ERROR = "Parameter '%s' missing" +internal const val FORMAT = "format" +internal const val INSTITUTION = "institution" +internal const val PROGRAMME = "programme" + @Component class InputProcessor( val institutionRepo: IInstitutionRepository, @@ -18,7 +24,7 @@ class InputProcessor( } private fun getValidatedDto(dto: CreateJobDto): SafeJobDto { - val type = JobType.of(dto.type) ?: throw ArgumentException("Invalid Job Type: ${dto.type}") + val type = JobType.of(dto.type) ?: throw ArgumentException(INVALID_JOB_TYPE_ERROR.format(dto.type?.trim())) validateDto(dto, type) return dto.toSafeDto(type) @@ -26,11 +32,11 @@ class InputProcessor( private fun validateDto(dto: CreateJobDto, jobType: JobType) { runCatching { - requireNotNull(dto.format) { "format" } - requireNotNull(dto.institution) { "institution" } + require(dto.format?.isNotBlank() ?: false) { FORMAT } + require(dto.institution?.isNotBlank() ?: false) { INSTITUTION } if (jobType == JobType.TIMETABLE || jobType == JobType.EXAM_SCHEDULE) - requireNotNull(dto.programme) { "programme" } - }.onFailure { throw ArgumentException("Parameter '${it.message}' missing") } + require(dto.programme?.isNotBlank() ?: false) { PROGRAMME } + }.onFailure { throw ArgumentException(MISSING_PARAMETER_ERROR.format(it.message)) } } } diff --git a/src/main/kotlin/org/ionproject/integration/ui/dto/JobDetailDto.kt b/src/main/kotlin/org/ionproject/integration/ui/dto/JobDetailDto.kt new file mode 100644 index 00000000..677a3a15 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/ui/dto/JobDetailDto.kt @@ -0,0 +1,99 @@ +package org.ionproject.integration.ui.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import org.ionproject.integration.application.JobEngine +import org.ionproject.integration.domain.common.InstitutionModel +import org.ionproject.integration.domain.common.ProgrammeModel +import org.ionproject.integration.infrastructure.DateUtils +import java.lang.IllegalStateException +import java.net.URI +import java.time.LocalDateTime + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class JobDetailDto( + val type: String, + val id: Long, + val status: String, + val createdOn: String, + val startedOn: String? = null, + val endedOn: String? = null, + val links: Links? = null, + val parameters: Parameters? = null +) { + + companion object Factory { + fun of( + job: JobEngine.IntegrationJob, + requestURL: String, + detailType: DetailType = DetailType.FULL + ): JobDetailDto { + + val parameters = if (detailType == DetailType.FULL) Parameters.from(job) else null + + return JobDetailDto( + type = job.type.identifier, + id = job.status.jobId ?: throw IllegalStateException("Job $job cannot have an empty ID field"), + status = job.status.result.name, + createdOn = job.parameters.creationDate.toOutputFormat(), + startedOn = job.parameters.startDate?.toOutputFormat(), + endedOn = job.parameters.endDate?.toOutputFormat(), + links = Links(requestURL), + parameters = parameters + ) + } + + private fun LocalDateTime.toOutputFormat(): String = DateUtils.formatToISO8601(this) + } + + enum class DetailType { + FULL, METADATA_ONLY + } + + data class Links( + val self: String + ) + + @JsonInclude(JsonInclude.Include.NON_NULL) + data class Parameters( + val format: String, + val institution: Institution, + val programme: Programme? = null, + val sourceUris: List + ) { + + companion object Factory { + fun from(job: JobEngine.IntegrationJob): Parameters { + return Parameters( + format = job.parameters.format.name, + institution = Institution.from(job.parameters.institution), + programme = job.parameters.programme?.let { Programme.from(it) }, + sourceUris = listOf(job.parameters.uri) + ) + } + } + + data class Institution( + val name: String, + val acronym: String, + val identifier: String + ) { + companion object Factory { + fun from(model: InstitutionModel): Institution = + Institution( + name = model.name, + acronym = model.acronym, + identifier = model.identifier + ) + } + } + + data class Programme( + val name: String, + val acronym: String + ) { + companion object Factory { + fun from(model: ProgrammeModel): Programme = Programme(model.name, model.acronym) + } + } + } +} diff --git a/src/main/kotlin/org/ionproject/integration/ui/dto/PostResponse.kt b/src/main/kotlin/org/ionproject/integration/ui/dto/PostResponse.kt new file mode 100644 index 00000000..468f36fa --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/ui/dto/PostResponse.kt @@ -0,0 +1,19 @@ +package org.ionproject.integration.ui.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import org.springframework.http.HttpStatus +import javax.servlet.http.HttpServletResponse + +@JsonInclude(JsonInclude.Include.NON_NULL) +class PostResponse( + val location: String? = null, + val status: HttpStatus = HttpStatus.OK, + response: HttpServletResponse +) { + init { + response.status = status.value() + location?.let { + response.addHeader("Location", it) + } + } +} diff --git a/src/main/kotlin/org/ionproject/integration/ui/dto/Problem.kt b/src/main/kotlin/org/ionproject/integration/ui/dto/Problem.kt new file mode 100644 index 00000000..b32f7fb5 --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/ui/dto/Problem.kt @@ -0,0 +1,40 @@ +package org.ionproject.integration.ui.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import org.ionproject.integration.infrastructure.exception.ArgumentException +import org.ionproject.integration.infrastructure.exception.JobNotFoundException +import org.springframework.http.HttpStatus +import javax.servlet.http.HttpServletRequest + +private const val ARGUMENT_EX_URI = "https://github.com/i-on-project/integration/blob/master/docs/infrastructure/ArgumentException.md" +private const val JOB_NOT_FOUND_EX_URI = "https://github.com/i-on-project/integration/blob/master/docs/infrastructure/JobNotFoundException.md" + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Problem( + val type: String, + val title: String, + val status: Int? = null, + val detail: String? = null, + val instance: String? = null +) { + companion object Factory { + fun of(argumentException: ArgumentException, request: HttpServletRequest): Problem = + Problem( + title = HttpStatus.BAD_REQUEST.reasonPhrase, + status = HttpStatus.BAD_REQUEST.value(), + detail = argumentException.message, + type = ARGUMENT_EX_URI, + instance = request.requestURI + ) + + fun of(jobNotFoundException: JobNotFoundException, request: HttpServletRequest): Problem { + return Problem( + title = HttpStatus.NOT_FOUND.reasonPhrase, + status = HttpStatus.NOT_FOUND.value(), + detail = jobNotFoundException.message, + type = JOB_NOT_FOUND_EX_URI, + instance = request.requestURI + ) + } + } +} diff --git a/src/main/kotlin/org/ionproject/integration/ui/dto/SafeEvaluationsJobDto.kt b/src/main/kotlin/org/ionproject/integration/ui/dto/SafeEvaluationsJobDto.kt new file mode 100644 index 00000000..e199ebcd --- /dev/null +++ b/src/main/kotlin/org/ionproject/integration/ui/dto/SafeEvaluationsJobDto.kt @@ -0,0 +1,23 @@ +package org.ionproject.integration.ui.dto + +import org.ionproject.integration.application.JobEngine +import org.ionproject.integration.infrastructure.file.OutputFormat +import org.ionproject.integration.infrastructure.repository.IInstitutionRepository +import org.ionproject.integration.infrastructure.repository.IProgrammeRepository + +data class SafeEvaluationsJobDto( + override val institution: String, + val programme: String, + override val format: String, +) : SafeJobDto { + override fun toJobRequest( + institutionRepo: IInstitutionRepository, + programmeRepo: IProgrammeRepository + ): JobEngine.AbstractJobRequest { + val format = OutputFormat.of(format) + val institution = institutionRepo.getInstitutionByIdentifier(institution) + val programme = programmeRepo.getProgrammeByAcronymAndInstitution(programme, institution) + + return JobEngine.EvaluationsJobRequest(format, institution, programme) + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d49c2228..18807a69 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,6 +1,10 @@ #spring spring.main.banner-mode=console +#spring web +server.servlet.context-path=/integration +server.port=${PORT} + #spring batch spring.datasource.driverClassName=org.postgresql.Driver spring.datasource.url=${SQL_HOST} @@ -11,7 +15,6 @@ spring.datasource.initialization-mode=always spring.datasource.hikari.initialization-fail-timeout=50000 spring.batch.initialize-schema=always spring.batch.job.enabled=false -server.port=${SERVER_PORT} #i-on ion.config-file=${CONFIG_FILE} diff --git a/src/main/resources/config/supported-institutions.yml b/src/main/resources/config/supported-institutions.yml index 72c194cc..ae4eda75 100644 --- a/src/main/resources/config/supported-institutions.yml +++ b/src/main/resources/config/supported-institutions.yml @@ -8,23 +8,35 @@ resources: - type: timetable uri: https://www.isel.pt/media/uploads/ADEETC_LEETC_210322.pdf + - type: evaluations + uri: https://www.isel.pt/media/uploads/tinymce/ADEETC_EN_ER_EE_210526.pdf - name: Licenciatura em Engenharia Informática e de Computadores resources: - type: timetable uri: https://www.isel.pt/media/uploads/ADEETC_LEIC_210322.pdf + - type: evaluations + uri: https://www.isel.pt/media/uploads/tinymce/ADEETC_EN_ER_EE_210526.pdf - name: Licenciatura em Engenharia Informática e Multimédia resources: - type: timetable uri: https://www.isel.pt/media/uploads/ADEETC_LEIM_210228.pdf + - type: evaluations + uri: https://www.isel.pt/media/uploads/tinymce/ADEETC_EN_ER_EE_210526.pdf - name: Licenciatura em Engenharia Informática, Redes e Telecomunicações resources: - type: timetable uri: https://www.isel.pt/media/uploads/ADEETC_LEIRT_210318.pdf + - type: evaluations + uri: https://www.isel.pt/media/uploads/tinymce/ADEETC_EN_ER_EE_210526.pdf - name: Mestrado em Engenharia Informática e de Computadores resources: - type: timetable uri: https://www.isel.pt/media/uploads/ADEETC_MEIC_210301.pdf + - type: evaluations + uri: https://www.isel.pt/media/uploads/tinymce/ADEETC_EN_ER_EE_210526.pdf - name: Mestrado em Engenharia Informática e Multimédia resources: - type: timetable - uri: https://www.isel.pt/media/uploads/ADEETC_MEIM_210228.pdf \ No newline at end of file + uri: https://www.isel.pt/media/uploads/ADEETC_MEIM_210228.pdf + - type: evaluations + uri: https://www.isel.pt/media/uploads/tinymce/ADEETC_EN_ER_EE_210526.pdf \ No newline at end of file diff --git a/src/test/kotlin/org/ionproject/integration/dispatcher/FileWriterTests.kt b/src/test/kotlin/org/ionproject/integration/dispatcher/FileWriterTests.kt index 43b4b404..c4c8c027 100644 --- a/src/test/kotlin/org/ionproject/integration/dispatcher/FileWriterTests.kt +++ b/src/test/kotlin/org/ionproject/integration/dispatcher/FileWriterTests.kt @@ -6,15 +6,15 @@ import org.ionproject.integration.application.dto.InstitutionMetadata import org.ionproject.integration.application.dto.ProgrammeMetadata import org.ionproject.integration.application.dto.TimetableData import org.ionproject.integration.domain.common.Term +import org.ionproject.integration.domain.common.dto.ProgrammeDto +import org.ionproject.integration.domain.common.dto.SchoolDto import org.ionproject.integration.domain.timetable.dto.ClassDto import org.ionproject.integration.domain.timetable.dto.EventDto import org.ionproject.integration.domain.timetable.dto.InstructorDto -import org.ionproject.integration.domain.timetable.dto.ProgrammeDto -import org.ionproject.integration.domain.timetable.dto.SchoolDto import org.ionproject.integration.domain.timetable.dto.SectionDto import org.ionproject.integration.domain.timetable.dto.TimetableDto -import org.ionproject.integration.infrastructure.file.OutputFormat import org.ionproject.integration.infrastructure.file.FileWriter +import org.ionproject.integration.infrastructure.file.OutputFormat import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired diff --git a/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarBusinessObjFormatCheckerTest.kt b/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarBusinessObjFormatCheckerTest.kt deleted file mode 100644 index 2301bed0..00000000 --- a/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarBusinessObjFormatCheckerTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.ionproject.integration.format.implementations - -import org.ionproject.integration.domain.calendar.RawCalendarData -import org.junit.jupiter.api.Test - -internal class AcademicCalendarBusinessObjFormatCheckerTest { - - private val tableData = - """[{"extraction_method":"lattice","top":300.81226,"left":56.926014,"width":479.52911376953125,"height":183.77505493164062,"right":536.45514,"bottom":484.5873,"data":[[{"top":300.81226,"left":56.926014,"width":247.92564392089844,"height":15.077880859375,"text":"Divulgação de horários"},{"top":300.81226,"left":304.85165,"width":231.60348510742188,"height":15.077880859375,"text":"9 de setembro de 2020"}],[{"top":315.89014,"left":56.926014,"width":247.92564392089844,"height":15.23968505859375,"text":"Abertura das atividades letivas 2020/2021"},{"top":315.89014,"left":304.85165,"width":231.60348510742188,"height":15.23968505859375,"text":"28 de setembro de 2020"}],[{"top":331.12982,"left":56.926014,"width":247.92564392089844,"height":15.48004150390625,"text":"Início das aulas"},{"top":331.12982,"left":304.85165,"width":231.60348510742188,"height":15.48004150390625,"text":"6 de outubro de 2020"}],[{"top":346.60986,"left":56.926014,"width":247.92564392089844,"height":15.120269775390625,"text":"Fim das aulas"},{"top":346.60986,"left":304.85165,"width":231.60348510742188,"height":15.120269775390625,"text":"23 de janeiro de 2021"}],[{"top":361.73013,"left":56.926014,"width":247.92564392089844,"height":29.760101318359375,"text":"Interrupção de atividades letivas (Natal)"},{"top":361.73013,"left":304.85165,"width":231.60348510742188,"height":29.760101318359375,"text":"21 de dezembro de 2020 a 3 de janeiro de\r2021"}],[{"top":391.49023,"left":56.926014,"width":247.92564392089844,"height":15.239990234375,"text":"Período de exames (época normal)"},{"top":391.49023,"left":304.85165,"width":231.60348510742188,"height":15.239990234375,"text":"25 de janeiro a 13 de fevereiro de 2021"}],[{"top":406.73022,"left":56.926014,"width":247.92564392089844,"height":17.879638671875,"text":"Interrupção de atividades letivas (Carnaval)"},{"top":406.73022,"left":304.85165,"width":231.60348510742188,"height":17.879638671875,"text":"15 e 16 de fevereiro de 2021"}],[{"top":424.60986,"left":56.926014,"width":247.92564392089844,"height":15.11968994140625,"text":"Período de exames (época de recurso)"},{"top":424.60986,"left":304.85165,"width":231.60348510742188,"height":15.11968994140625,"text":"17 de fevereiro a 2 de março de 2021"}],[{"top":439.72955,"left":56.926014,"width":247.92564392089844,"height":44.857757568359375,"text":"Data limite para lançamento de classificações no\rPortal Académico (frequência, exames de época\rnormal e de época de recurso)"},{"top":439.72955,"left":304.85165,"width":231.60348510742188,"height":44.857757568359375,"text":"9 de março de 2021"}]]},{"extraction_method":"lattice","top":531.5918,"left":56.926014,"width":481.6865234375,"height":210.5386962890625,"right":538.61255,"bottom":742.1305,"data":[[{"top":531.5918,"left":56.926014,"width":247.92564392089844,"height":15.07818603515625,"text":"Divulgação de horários"},{"top":531.5918,"left":304.85165,"width":233.76089477539062,"height":15.07818603515625,"text":"22 de fevereiro de 2021"}],[{"top":546.67,"left":56.926014,"width":247.92564392089844,"height":15.11981201171875,"text":"Início das aulas"},{"top":546.67,"left":304.85165,"width":233.76089477539062,"height":15.11981201171875,"text":"15 de março de 2021"}],[{"top":561.7898,"left":56.926014,"width":247.92564392089844,"height":15.23992919921875,"text":"Interrupção de atividades letivas (Páscoa)"},{"top":561.7898,"left":304.85165,"width":233.76089477539062,"height":15.23992919921875,"text":"29 de março a 5 de abril de 2021"}],[{"top":577.0297,"left":56.926014,"width":247.92564392089844,"height":15.12060546875,"text":"Fim das aulas"},{"top":577.0297,"left":304.85165,"width":233.76089477539062,"height":15.12060546875,"text":"26 de junho de 2021"}],[{"top":592.1503,"left":56.926014,"width":247.92564392089844,"height":15.11981201171875,"text":"Período de exames (época normal)"},{"top":592.1503,"left":304.85165,"width":233.76089477539062,"height":15.11981201171875,"text":"28 de junho a 17 de julho de 2021"}],[{"top":607.27014,"left":56.926014,"width":247.92564392089844,"height":15.1214599609375,"text":"Período de exames (época de recurso)"},{"top":607.27014,"left":304.85165,"width":233.76089477539062,"height":15.1214599609375,"text":"19 a 31 de julho de 2021"}],[{"top":622.3916,"left":56.926014,"width":247.92564392089844,"height":44.5482177734375,"text":"Data limite para lançamento de classificações no\rPortal Académico (frequência, exames de época\rnormal e de época de recurso)"},{"top":622.3916,"left":304.85165,"width":233.76089477539062,"height":44.5482177734375,"text":"2 de setembro de 2021"}],[{"top":666.9398,"left":56.926014,"width":247.92564392089844,"height":15.12060546875,"text":"Encerramento das atividades letivas 2020/2021"},{"top":666.9398,"left":304.85165,"width":233.76089477539062,"height":15.12060546875,"text":"31 de julho de 2021"}],[{"top":682.0604,"left":56.926014,"width":247.92564392089844,"height":15.12005615234375,"text":"Período de ausência de atividade letiva (férias)"},{"top":682.0604,"left":304.85165,"width":233.76089477539062,"height":15.12005615234375,"text":"1 a 31 de agosto de 2021"}],[{"top":697.1805,"left":56.926014,"width":247.92564392089844,"height":15.11834716796875,"text":"Exames de época especial"},{"top":697.1805,"left":304.85165,"width":233.76089477539062,"height":15.11834716796875,"text":"2 a 18 de setembro de 2021"}],[{"top":712.2988,"left":56.926014,"width":247.92564392089844,"height":29.8316650390625,"text":"Data limite para lançamento de classificações no\rPortal Académico (época especial)"},{"top":712.2988,"left":304.85165,"width":233.76089477539062,"height":29.8316650390625,"text":"30 de setembro de 2021"}]]},{"extraction_method":"lattice","top":106.02233,"left":56.923706,"width":481.68896484375,"height":74.67122650146484,"right":538.6127,"bottom":180.69356,"data":[[{"top":106.02233,"left":56.923706,"width":247.92626953125,"height":29.83771514892578,"text":"Data limite para entrega de trabalhos finais de\rlicenciatura*"},{"top":106.02233,"left":304.84998,"width":233.7626953125,"height":29.83771514892578,"text":"30 de setembro de 2021"}],[{"top":135.86005,"left":56.923706,"width":247.92626953125,"height":29.759963989257812,"text":"Data limite para entrega de trabalhos finais de\rmestrado"},{"top":135.86005,"left":304.84998,"width":233.7626953125,"height":29.759963989257812,"text":"30 de setembro de 2021"}],[{"top":165.62001,"left":56.923706,"width":247.92626953125,"height":15.07354736328125,"text":"Fim do ano letivo 2020/2021"},{"top":165.62001,"left":304.84998,"width":233.7626953125,"height":15.07354736328125,"text":"30 de setembro de 2021"}]]}]""" - private val textData = listOf() - private val validUTCDate = "20210516T214838Z" - - @Test - fun draftTest() { - val calendarData = RawCalendarData( - textData, - tableData, - validUTCDate - ) - } -} diff --git a/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarBusinessObjFormatCheckerTests.kt b/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarBusinessObjFormatCheckerTests.kt new file mode 100644 index 00000000..99ff5cc7 --- /dev/null +++ b/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarBusinessObjFormatCheckerTests.kt @@ -0,0 +1,197 @@ +package org.ionproject.integration.format.implementations + +import org.ionproject.integration.application.config.AppProperties +import org.ionproject.integration.application.dispatcher.IDispatcher +import org.ionproject.integration.application.job.ISELAcademicCalendarJob +import org.ionproject.integration.domain.common.Language +import org.ionproject.integration.domain.common.School +import org.ionproject.integration.infrastructure.http.IFileDownloader +import org.ionproject.integration.model.external.calendar.AcademicCalendar +import org.ionproject.integration.model.external.calendar.Lectures +import org.ionproject.integration.model.external.calendar.Evaluation +import org.ionproject.integration.model.external.calendar.Event +import org.ionproject.integration.model.external.calendar.Term +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory +import java.io.File +import java.time.LocalDate +import java.time.Month +import javax.sql.DataSource + +class AcademicCalendarBusinessObjFormatCheckerTests { + private val mockJobBuilderFactory = mock {} + + private val mockStepBuilderFactory = mock {} + + private val mockAppProperties = mock {} + + private val mockDownloader = mock {} + + private val mockDataSource = mock {} + + private val mockDispatcher = mock {} + + @Test + fun `when given a academic calendar if business object extraction is as expected then success`() { + val resourceFile = File("src/test/resources/calendarTest.pdf") + + val job = ISELAcademicCalendarJob( + mockJobBuilderFactory, + mockStepBuilderFactory, + mockAppProperties, + mockDownloader, + mockDispatcher, + mockDataSource + ) + val calendarData = job.extractCalendarPDF(resourceFile.toPath().toString()) + + val academicCalendar = AcademicCalendar.from(calendarData) + val academicCalendarBO = AcademicCalendar( + "2020-07-06T16:00:21Z", + academicCalendar.retrievalDateTime, + School("Instituto Superior de Engenharia de Lisboa", "ISEL"), + Language.PT, + listOf( + Term( + "2020-2021-1", + listOf( + Event( + "Interrupção de atividades letivas (Natal)", + LocalDate.of(2020, Month.DECEMBER, 21), + LocalDate.of(2021, Month.JANUARY, 3) + ), + Event( + "Interrupção de atividades letivas (Carnaval)", + LocalDate.of(2021, Month.FEBRUARY, 15), + LocalDate.of(2021, Month.FEBRUARY, 16) + ) + ), + listOf( + Evaluation( + "Período de exames (época normal)", + LocalDate.of(2021, Month.JANUARY, 25), + LocalDate.of(2021, Month.FEBRUARY, 13), + false + ), + Evaluation( + "Período de exames (época de recurso)", + LocalDate.of(2021, Month.FEBRUARY, 17), + LocalDate.of(2021, Month.MARCH, 2), + false + ) + ), + listOf( + Lectures( + "Aulas", + listOf(1, 2, 3, 4, 5, 6), + LocalDate.of(2020, Month.OCTOBER, 6), + LocalDate.of(2021, Month.JANUARY, 23) + ) + ), + listOf( + Event( + "Divulgação de horários", + LocalDate.of(2020, Month.SEPTEMBER, 9), + LocalDate.of(2020, Month.SEPTEMBER, 9) + ), + Event( + "Abertura das atividades letivas 2020/2021", + LocalDate.of(2020, Month.SEPTEMBER, 28), + LocalDate.of(2020, Month.SEPTEMBER, 28) + ), + Event( + "Data limite para lançamento de classificações no Portal Académico (frequência, exames de época normal e de época de recurso)", + LocalDate.of(2021, Month.MARCH, 9), + LocalDate.of(2021, Month.MARCH, 9) + ) + ) + ), + Term( + "2020-2021-2", + listOf( + Event( + "Interrupção de atividades letivas (Páscoa)", + LocalDate.of(2021, Month.MARCH, 29), + LocalDate.of(2021, Month.APRIL, 5) + ) + ), + listOf( + Evaluation( + "Período de exames (época normal)", + LocalDate.of(2021, Month.JUNE, 28), + LocalDate.of(2021, Month.JULY, 17), + false + ), + Evaluation( + "Período de exames (época de recurso)", + LocalDate.of(2021, Month.JULY, 19), + LocalDate.of(2021, Month.JULY, 31), + false + ), + Evaluation( + "Exames de época especial", + LocalDate.of(2021, Month.SEPTEMBER, 2), + LocalDate.of(2021, Month.SEPTEMBER, 18), + false + ) + ), + listOf( + Lectures( + "Aulas", + listOf(1, 2, 3, 4, 5, 6), + LocalDate.of(2021, Month.MARCH, 15), + LocalDate.of(2021, Month.JUNE, 26) + ) + ), + listOf( + Event( + "Divulgação de horários", + LocalDate.of(2021, Month.FEBRUARY, 22), + LocalDate.of(2021, Month.FEBRUARY, 22) + ), + Event( + "Encerramento das atividades letivas 2020/2021", + LocalDate.of(2021, Month.JULY, 31), + LocalDate.of(2021, Month.JULY, 31) + ), + Event( + "Período de ausência de atividade letiva (férias)", + LocalDate.of(2021, Month.AUGUST, 1), + LocalDate.of(2021, Month.AUGUST, 31) + ), + Event( + "Data limite para lançamento de classificações no Portal Académico (frequência, exames de época normal e de época de recurso)", + LocalDate.of(2021, Month.SEPTEMBER, 2), + LocalDate.of(2021, Month.SEPTEMBER, 2) + ), + Event( + "Data limite para lançamento de classificações no Portal Académico (época especial)", + LocalDate.of(2021, Month.SEPTEMBER, 30), + LocalDate.of(2021, Month.SEPTEMBER, 30) + ), + Event( + "Data limite para entrega de trabalhos finais de licenciatura*", + LocalDate.of(2021, Month.SEPTEMBER, 30), + LocalDate.of(2021, Month.SEPTEMBER, 30) + ), + Event( + "Data limite para entrega de trabalhos finais de mestrado", + LocalDate.of(2021, Month.SEPTEMBER, 30), + LocalDate.of(2021, Month.SEPTEMBER, 30) + ), + Event( + "Fim do ano letivo 2020/2021", + LocalDate.of(2021, Month.SEPTEMBER, 30), + LocalDate.of(2021, Month.SEPTEMBER, 30) + ) + ) + ) + ) + ) + + assertEquals(academicCalendarBO, academicCalendar) + } +} diff --git a/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarDtoFormatCheckerTest.kt b/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarDtoFormatCheckerTest.kt index 95999826..96ffc2a4 100644 --- a/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarDtoFormatCheckerTest.kt +++ b/src/test/kotlin/org/ionproject/integration/format/implementations/AcademicCalendarDtoFormatCheckerTest.kt @@ -11,7 +11,7 @@ import org.ionproject.integration.model.external.calendar.Term import org.ionproject.integration.domain.calendar.TermDto import org.ionproject.integration.domain.common.Language import org.ionproject.integration.domain.common.School -import org.ionproject.integration.domain.timetable.dto.SchoolDto +import org.ionproject.integration.domain.common.dto.SchoolDto import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.time.LocalDate diff --git a/src/test/kotlin/org/ionproject/integration/format/implementations/EvaluationsBusinessObjFormatCheckerTest.kt b/src/test/kotlin/org/ionproject/integration/format/implementations/EvaluationsBusinessObjFormatCheckerTest.kt new file mode 100644 index 00000000..5eac144b --- /dev/null +++ b/src/test/kotlin/org/ionproject/integration/format/implementations/EvaluationsBusinessObjFormatCheckerTest.kt @@ -0,0 +1,786 @@ +package org.ionproject.integration.format.implementations + +import org.ionproject.integration.application.config.AppProperties +import org.ionproject.integration.application.dispatcher.IDispatcher +import org.ionproject.integration.application.job.ISELEvaluationsJob +import org.ionproject.integration.domain.common.InstitutionModel +import org.ionproject.integration.domain.common.Programme +import org.ionproject.integration.domain.common.ProgrammeModel +import org.ionproject.integration.domain.common.ProgrammeResources +import org.ionproject.integration.domain.common.School +import org.ionproject.integration.domain.evaluations.Evaluations +import org.ionproject.integration.domain.evaluations.Exam +import org.ionproject.integration.domain.evaluations.ExamCategory +import org.ionproject.integration.infrastructure.http.IFileDownloader +import org.ionproject.integration.infrastructure.repository.IInstitutionRepository +import org.ionproject.integration.infrastructure.repository.IProgrammeRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory +import java.io.File +import java.net.URI +import java.time.ZoneId +import java.time.ZonedDateTime +import javax.sql.DataSource + +class EvaluationsBusinessObjFormatCheckerTest { + + private val mockJobBuilderFactory = mock {} + + private val mockStepBuilderFactory = mock {} + + private val mockAppProperties = mock {} + + private val mockDownloader = mock {} + + private val mockDispatcher = mock {} + + private val mockInstitutionRepository = mock {} + + private val mockProgrammeRepository = mock {} + + private val mockDataSource = mock {} + + @Test + fun `when given an evaluations pdf if business object extraction is as expected then success`() { + val resourceFile = File("src/test/resources/evaluationsTest.pdf") + + val job = ISELEvaluationsJob( + mockJobBuilderFactory, + mockStepBuilderFactory, + mockAppProperties, + mockDownloader, + mockDispatcher, + mockInstitutionRepository, + mockProgrammeRepository, + mockDataSource + ) + val evaluationsData = job.extractEvaluationsPDF(resourceFile.toPath().toString()) + + val institution = InstitutionModel( + "Instituto Superior de Engenharia de Lisboa", + "ISEL", + "pt.ipl.isel", + URI("") + ) + + val programme = ProgrammeModel( + institution, + "Licenciatura em Engenharia Informática e de Computadores", + "LEIC", + ProgrammeResources( + URI(""), + URI("") + ) + ) + + val evaluationsRetrieved = Evaluations.from(evaluationsData, programme) + + val evaluationsExpected = + Evaluations( + evaluationsRetrieved.creationDateTime, // 2020-2021 Evaluations PDF doesn't have a creation date in its properties, so it gets the retrieval date time. + evaluationsRetrieved.retrievalDateTime, + School( + "Instituto Superior de Engenharia de Lisboa", + "ISEL" + ), + Programme( + "Licenciatura em Engenharia Informática e de Computadores", + "LEIC" + ), + "2020-2021-2", + listOf( + Exam( + "AApl", + ZonedDateTime.of(2021, 7, 7, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 7, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AApl", + ZonedDateTime.of(2021, 7, 24, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 24, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AApl", + ZonedDateTime.of(2021, 9, 9, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 9, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "AC", + ZonedDateTime.of(2021, 7, 1, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 1, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AC", + ZonedDateTime.of(2021, 7, 19, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 19, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AC", + ZonedDateTime.of(2021, 9, 7, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "AED", + ZonedDateTime.of(2021, 6, 28, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 28, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AED", + ZonedDateTime.of(2021, 7, 20, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AED", + ZonedDateTime.of(2021, 9, 16, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 16, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "ALGA", + ZonedDateTime.of(2021, 7, 8, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 8, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "ALGA", + ZonedDateTime.of(2021, 7, 23, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 23, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "ALGA", + ZonedDateTime.of(2021, 9, 8, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "AVE", + ZonedDateTime.of(2021, 7, 5, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 5, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AVE", + ZonedDateTime.of(2021, 7, 22, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 22, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AVE", + ZonedDateTime.of(2021, 9, 15, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CDI", + ZonedDateTime.of(2021, 7, 16, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 16, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "CDI", + ZonedDateTime.of(2021, 7, 30, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 30, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "CDI", + ZonedDateTime.of(2021, 9, 17, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CN", + ZonedDateTime.of(2021, 7, 15, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 15, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "CN", + ZonedDateTime.of(2021, 7, 29, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 29, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "CN", + ZonedDateTime.of(2021, 9, 10, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 10, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "COM", + ZonedDateTime.of(2021, 7, 12, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 12, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "COM", + ZonedDateTime.of(2021, 7, 26, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 26, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "COM", + ZonedDateTime.of(2021, 9, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CQF", + ZonedDateTime.of(2021, 9, 8, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CSM", + ZonedDateTime.of(2021, 7, 12, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 12, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "CSM", + ZonedDateTime.of(2021, 7, 31, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 31, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "CSM", + ZonedDateTime.of(2021, 9, 8, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "DAW", + ZonedDateTime.of(2021, 6, 28, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 28, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "DAW", + ZonedDateTime.of(2021, 7, 21, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "DAW", + ZonedDateTime.of(2021, 9, 15, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "EGP", + ZonedDateTime.of(2021, 7, 1, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 1, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "EGP", + ZonedDateTime.of(2021, 7, 20, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "EGP", + ZonedDateTime.of(2021, 9, 9, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 9, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "Eltr", + ZonedDateTime.of(2021, 7, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "Eltr", + ZonedDateTime.of(2021, 7, 27, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 27, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "Eltr", + ZonedDateTime.of(2021, 9, 15, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "Emp", + ZonedDateTime.of(2021, 9, 16, 18, 30, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 16, 21, 30, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "GAP", + ZonedDateTime.of(2021, 6, 30, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 30, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "GAP", + ZonedDateTime.of(2021, 7, 19, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 19, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "GAP", + ZonedDateTime.of(2021, 9, 3, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 3, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "GQS", + ZonedDateTime.of(2021, 9, 7, 18, 30, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 21, 30, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "IASA", + ZonedDateTime.of(2021, 7, 2, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 2, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "IASA", + ZonedDateTime.of(2021, 7, 20, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "IASA", + ZonedDateTime.of(2021, 9, 2, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 2, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "IEB", + ZonedDateTime.of(2021, 9, 6, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "LC", + ZonedDateTime.of(2021, 9, 14, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 14, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "LSD", + ZonedDateTime.of(2021, 7, 6, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 6, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "LSD", + ZonedDateTime.of(2021, 7, 21, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "LSD", + ZonedDateTime.of(2021, 9, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PC", + ZonedDateTime.of(2021, 7, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PC", + ZonedDateTime.of(2021, 7, 27, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 27, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PC", + ZonedDateTime.of(2021, 9, 17, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PDM", + ZonedDateTime.of(2021, 9, 14, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 14, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PE", + ZonedDateTime.of(2021, 7, 5, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 5, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PE", + ZonedDateTime.of(2021, 7, 22, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 22, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PE", + ZonedDateTime.of(2021, 9, 9, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 9, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PG", + ZonedDateTime.of(2021, 6, 29, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 29, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PG", + ZonedDateTime.of(2021, 7, 23, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 23, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PG", + ZonedDateTime.of(2021, 9, 8, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PI", + ZonedDateTime.of(2021, 7, 2, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 2, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PI", + ZonedDateTime.of(2021, 7, 21, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PI", + ZonedDateTime.of(2021, 9, 10, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 10, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "POO", + ZonedDateTime.of(2021, 9, 14, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 14, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PSC", + ZonedDateTime.of(2021, 7, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PSC", + ZonedDateTime.of(2021, 7, 21, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PSC", + ZonedDateTime.of(2021, 9, 10, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 10, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "RCp", + ZonedDateTime.of(2021, 7, 14, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 14, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "RCp", + ZonedDateTime.of(2021, 7, 28, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 28, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "RCp", + ZonedDateTime.of(2021, 9, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "RI", + ZonedDateTime.of(2021, 9, 7, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SE1", + ZonedDateTime.of(2021, 9, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SE2", + ZonedDateTime.of(2021, 7, 9, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 9, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SE2", + ZonedDateTime.of(2021, 7, 26, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 26, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SE2", + ZonedDateTime.of(2021, 9, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SG", + ZonedDateTime.of(2021, 7, 1, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 1, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SG", + ZonedDateTime.of(2021, 7, 20, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SG", + ZonedDateTime.of(2021, 9, 8, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SegInf", + ZonedDateTime.of(2021, 9, 17, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SI1", + ZonedDateTime.of(2021, 6, 30, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 30, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SI1", + ZonedDateTime.of(2021, 7, 19, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 19, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SI1", + ZonedDateTime.of(2021, 9, 3, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 3, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SI2", + ZonedDateTime.of(2021, 7, 8, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 8, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SI2", + ZonedDateTime.of(2021, 7, 23, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 23, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SI2", + ZonedDateTime.of(2021, 9, 13, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SM", + ZonedDateTime.of(2021, 9, 15, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SO", + ZonedDateTime.of(2021, 7, 9, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 9, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SO", + ZonedDateTime.of(2021, 7, 26, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 26, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SO", + ZonedDateTime.of(2021, 9, 7, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "TAR", + ZonedDateTime.of(2021, 7, 16, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 16, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "TAR", + ZonedDateTime.of(2021, 7, 30, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 30, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "TAR", + ZonedDateTime.of(2021, 9, 7, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "TJ", + ZonedDateTime.of(2021, 7, 12, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 12, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "TJ", + ZonedDateTime.of(2021, 7, 28, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 28, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "TJ", + ZonedDateTime.of(2021, 9, 17, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "TMD", + ZonedDateTime.of(2021, 7, 10, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 10, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "TMD", + ZonedDateTime.of(2021, 7, 29, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 29, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "TMD", + ZonedDateTime.of(2021, 9, 13, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ) + ) + ) + + assertEquals(evaluationsExpected, evaluationsRetrieved) + } +} diff --git a/src/test/kotlin/org/ionproject/integration/format/implementations/EvaluationsDtoFormatCheckerTest.kt b/src/test/kotlin/org/ionproject/integration/format/implementations/EvaluationsDtoFormatCheckerTest.kt new file mode 100644 index 00000000..f81d60c1 --- /dev/null +++ b/src/test/kotlin/org/ionproject/integration/format/implementations/EvaluationsDtoFormatCheckerTest.kt @@ -0,0 +1,732 @@ +package org.ionproject.integration.format.implementations + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.ionproject.integration.domain.common.Programme +import org.ionproject.integration.domain.common.School +import org.ionproject.integration.domain.evaluations.Evaluations +import org.ionproject.integration.domain.evaluations.EvaluationsDto +import org.ionproject.integration.domain.evaluations.Exam +import org.ionproject.integration.domain.evaluations.ExamCategory +import org.ionproject.integration.infrastructure.DateUtils +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.ZoneId +import java.time.ZonedDateTime + +internal class EvaluationsDtoFormatCheckerTest { + + private val mapper = jacksonObjectMapper() + + @Test + fun `when Serialized Evaluations is equal to expected Dto then Success`() { + + val evaluations = + Evaluations( + DateUtils.formatToISO8601(ZonedDateTime.of(2021, 7, 8, 0, 12, 56, 0, ZoneId.systemDefault())), + DateUtils.formatToISO8601(ZonedDateTime.of(2021, 7, 8, 0, 12, 56, 0, ZoneId.systemDefault())), + School( + "Instituto Superior de Engenharia de Lisboa", + "ISEL" + ), + Programme( + "Licenciatura em Engenharia Informática e de Computadores", + "LEIC" + ), + "2020-2021-2", + listOf( + Exam( + "AApl", + ZonedDateTime.of(2021, 7, 7, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 7, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AApl", + ZonedDateTime.of(2021, 7, 24, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 24, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AApl", + ZonedDateTime.of(2021, 9, 9, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 9, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "AC", + ZonedDateTime.of(2021, 7, 1, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 1, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AC", + ZonedDateTime.of(2021, 7, 19, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 19, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AC", + ZonedDateTime.of(2021, 9, 7, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "AED", + ZonedDateTime.of(2021, 6, 28, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 28, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AED", + ZonedDateTime.of(2021, 7, 20, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AED", + ZonedDateTime.of(2021, 9, 16, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 16, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "ALGA", + ZonedDateTime.of(2021, 7, 8, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 8, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "ALGA", + ZonedDateTime.of(2021, 7, 23, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 23, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "ALGA", + ZonedDateTime.of(2021, 9, 8, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "AVE", + ZonedDateTime.of(2021, 7, 5, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 5, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "AVE", + ZonedDateTime.of(2021, 7, 22, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 22, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "AVE", + ZonedDateTime.of(2021, 9, 15, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CDI", + ZonedDateTime.of(2021, 7, 16, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 16, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "CDI", + ZonedDateTime.of(2021, 7, 30, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 30, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "CDI", + ZonedDateTime.of(2021, 9, 17, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CN", + ZonedDateTime.of(2021, 7, 15, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 15, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "CN", + ZonedDateTime.of(2021, 7, 29, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 29, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "CN", + ZonedDateTime.of(2021, 9, 10, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 10, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "COM", + ZonedDateTime.of(2021, 7, 12, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 12, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "COM", + ZonedDateTime.of(2021, 7, 26, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 26, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "COM", + ZonedDateTime.of(2021, 9, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CQF", + ZonedDateTime.of(2021, 9, 8, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "CSM", + ZonedDateTime.of(2021, 7, 12, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 12, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "CSM", + ZonedDateTime.of(2021, 7, 31, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 31, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "CSM", + ZonedDateTime.of(2021, 9, 8, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "DAW", + ZonedDateTime.of(2021, 6, 28, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 28, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "DAW", + ZonedDateTime.of(2021, 7, 21, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "DAW", + ZonedDateTime.of(2021, 9, 15, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "EGP", + ZonedDateTime.of(2021, 7, 1, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 1, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "EGP", + ZonedDateTime.of(2021, 7, 20, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "EGP", + ZonedDateTime.of(2021, 9, 9, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 9, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "Eltr", + ZonedDateTime.of(2021, 7, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "Eltr", + ZonedDateTime.of(2021, 7, 27, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 27, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "Eltr", + ZonedDateTime.of(2021, 9, 15, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "Emp", + ZonedDateTime.of(2021, 9, 16, 18, 30, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 16, 21, 30, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "GAP", + ZonedDateTime.of(2021, 6, 30, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 30, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "GAP", + ZonedDateTime.of(2021, 7, 19, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 19, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "GAP", + ZonedDateTime.of(2021, 9, 3, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 3, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "GQS", + ZonedDateTime.of(2021, 9, 7, 18, 30, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 21, 30, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "IASA", + ZonedDateTime.of(2021, 7, 2, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 2, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "IASA", + ZonedDateTime.of(2021, 7, 20, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "IASA", + ZonedDateTime.of(2021, 9, 2, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 2, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "IEB", + ZonedDateTime.of(2021, 9, 6, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "LC", + ZonedDateTime.of(2021, 9, 14, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 14, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "LSD", + ZonedDateTime.of(2021, 7, 6, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 6, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "LSD", + ZonedDateTime.of(2021, 7, 21, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "LSD", + ZonedDateTime.of(2021, 9, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PC", + ZonedDateTime.of(2021, 7, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PC", + ZonedDateTime.of(2021, 7, 27, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 27, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PC", + ZonedDateTime.of(2021, 9, 17, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PDM", + ZonedDateTime.of(2021, 9, 14, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 14, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PE", + ZonedDateTime.of(2021, 7, 5, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 5, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PE", + ZonedDateTime.of(2021, 7, 22, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 22, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PE", + ZonedDateTime.of(2021, 9, 9, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 9, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PG", + ZonedDateTime.of(2021, 6, 29, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 29, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PG", + ZonedDateTime.of(2021, 7, 23, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 23, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PG", + ZonedDateTime.of(2021, 9, 8, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PI", + ZonedDateTime.of(2021, 7, 2, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 2, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PI", + ZonedDateTime.of(2021, 7, 21, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PI", + ZonedDateTime.of(2021, 9, 10, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 10, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "POO", + ZonedDateTime.of(2021, 9, 14, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 14, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "PSC", + ZonedDateTime.of(2021, 7, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "PSC", + ZonedDateTime.of(2021, 7, 21, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 21, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "PSC", + ZonedDateTime.of(2021, 9, 10, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 10, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "RCp", + ZonedDateTime.of(2021, 7, 14, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 14, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "RCp", + ZonedDateTime.of(2021, 7, 28, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 28, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "RCp", + ZonedDateTime.of(2021, 9, 13, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "RI", + ZonedDateTime.of(2021, 9, 7, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SE1", + ZonedDateTime.of(2021, 9, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SE2", + ZonedDateTime.of(2021, 7, 9, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 9, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SE2", + ZonedDateTime.of(2021, 7, 26, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 26, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SE2", + ZonedDateTime.of(2021, 9, 6, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 6, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SG", + ZonedDateTime.of(2021, 7, 1, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 1, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SG", + ZonedDateTime.of(2021, 7, 20, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 20, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SG", + ZonedDateTime.of(2021, 9, 8, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 8, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SegInf", + ZonedDateTime.of(2021, 9, 17, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SI1", + ZonedDateTime.of(2021, 6, 30, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 6, 30, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SI1", + ZonedDateTime.of(2021, 7, 19, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 19, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SI1", + ZonedDateTime.of(2021, 9, 3, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 3, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SI2", + ZonedDateTime.of(2021, 7, 8, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 8, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SI2", + ZonedDateTime.of(2021, 7, 23, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 23, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SI2", + ZonedDateTime.of(2021, 9, 13, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SM", + ZonedDateTime.of(2021, 9, 15, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 15, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "SO", + ZonedDateTime.of(2021, 7, 9, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 9, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "SO", + ZonedDateTime.of(2021, 7, 26, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 26, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "SO", + ZonedDateTime.of(2021, 9, 7, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "TAR", + ZonedDateTime.of(2021, 7, 16, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 16, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "TAR", + ZonedDateTime.of(2021, 7, 30, 19, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 30, 22, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "TAR", + ZonedDateTime.of(2021, 9, 7, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 7, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "TJ", + ZonedDateTime.of(2021, 7, 12, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 12, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "TJ", + ZonedDateTime.of(2021, 7, 28, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 28, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "TJ", + ZonedDateTime.of(2021, 9, 17, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 17, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ), + Exam( + "TMD", + ZonedDateTime.of(2021, 7, 10, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 10, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_NORMAL, + "" + ), + Exam( + "TMD", + ZonedDateTime.of(2021, 7, 29, 10, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 7, 29, 13, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_ALTERN, + "" + ), + Exam( + "TMD", + ZonedDateTime.of(2021, 9, 13, 14, 0, 0, 0, ZoneId.systemDefault()), + ZonedDateTime.of(2021, 9, 13, 17, 0, 0, 0, ZoneId.systemDefault()), + ExamCategory.EXAM_SPECIAL, + "" + ) + ) + ) + val serialized = mapper.writeValueAsString(EvaluationsDto.from(evaluations)) + + val expectedJson = + """{"creationDateTime":"2021-07-08T00:12:56Z","retrievalDateTime":"2021-07-08T00:12:56Z","school":{"name":"Instituto Superior de Engenharia de Lisboa","acr":"ISEL"},"programme":{"name":"Licenciatura em Engenharia Informática e de Computadores","acr":"LEIC"},"calendarTerm":"2020-2021-2","exams":[{"course":"AApl","startDate":"2021-07-07T10:00:00Z","endDate":"2021-07-07T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"AApl","startDate":"2021-07-24T10:00:00Z","endDate":"2021-07-24T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"AApl","startDate":"2021-09-09T14:00:00Z","endDate":"2021-09-09T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"AC","startDate":"2021-07-01T14:00:00Z","endDate":"2021-07-01T17:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"AC","startDate":"2021-07-19T19:00:00Z","endDate":"2021-07-19T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"AC","startDate":"2021-09-07T10:00:00Z","endDate":"2021-09-07T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"AED","startDate":"2021-06-28T19:00:00Z","endDate":"2021-06-28T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"AED","startDate":"2021-07-20T14:00:00Z","endDate":"2021-07-20T17:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"AED","startDate":"2021-09-16T19:00:00Z","endDate":"2021-09-16T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"ALGA","startDate":"2021-07-08T19:00:00Z","endDate":"2021-07-08T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"ALGA","startDate":"2021-07-23T10:00:00Z","endDate":"2021-07-23T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"ALGA","startDate":"2021-09-08T19:00:00Z","endDate":"2021-09-08T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"AVE","startDate":"2021-07-05T19:00:00Z","endDate":"2021-07-05T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"AVE","startDate":"2021-07-22T10:00:00Z","endDate":"2021-07-22T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"AVE","startDate":"2021-09-15T14:00:00Z","endDate":"2021-09-15T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"CDI","startDate":"2021-07-16T19:00:00Z","endDate":"2021-07-16T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"CDI","startDate":"2021-07-30T19:00:00Z","endDate":"2021-07-30T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"CDI","startDate":"2021-09-17T19:00:00Z","endDate":"2021-09-17T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"CN","startDate":"2021-07-15T10:00:00Z","endDate":"2021-07-15T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"CN","startDate":"2021-07-29T19:00:00Z","endDate":"2021-07-29T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"CN","startDate":"2021-09-10T19:00:00Z","endDate":"2021-09-10T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"COM","startDate":"2021-07-12T19:00:00Z","endDate":"2021-07-12T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"COM","startDate":"2021-07-26T14:00:00Z","endDate":"2021-07-26T17:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"COM","startDate":"2021-09-13T19:00:00Z","endDate":"2021-09-13T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"CQF","startDate":"2021-09-08T10:00:00Z","endDate":"2021-09-08T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"CSM","startDate":"2021-07-12T19:00:00Z","endDate":"2021-07-12T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"CSM","startDate":"2021-07-31T10:00:00Z","endDate":"2021-07-31T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"CSM","startDate":"2021-09-08T10:00:00Z","endDate":"2021-09-08T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"DAW","startDate":"2021-06-28T19:00:00Z","endDate":"2021-06-28T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"DAW","startDate":"2021-07-21T10:00:00Z","endDate":"2021-07-21T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"DAW","startDate":"2021-09-15T19:00:00Z","endDate":"2021-09-15T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"EGP","startDate":"2021-07-01T19:00:00Z","endDate":"2021-07-01T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"EGP","startDate":"2021-07-20T14:00:00Z","endDate":"2021-07-20T17:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"EGP","startDate":"2021-09-09T19:00:00Z","endDate":"2021-09-09T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"Eltr","startDate":"2021-07-13T19:00:00Z","endDate":"2021-07-13T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"Eltr","startDate":"2021-07-27T10:00:00Z","endDate":"2021-07-27T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"Eltr","startDate":"2021-09-15T19:00:00Z","endDate":"2021-09-15T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"Emp","startDate":"2021-09-16T18:30:00Z","endDate":"2021-09-16T21:30:00Z","category":"EXAM_SPECIAL","location":""},{"course":"GAP","startDate":"2021-06-30T10:00:00Z","endDate":"2021-06-30T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"GAP","startDate":"2021-07-19T10:00:00Z","endDate":"2021-07-19T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"GAP","startDate":"2021-09-03T14:00:00Z","endDate":"2021-09-03T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"GQS","startDate":"2021-09-07T18:30:00Z","endDate":"2021-09-07T21:30:00Z","category":"EXAM_SPECIAL","location":""},{"course":"IASA","startDate":"2021-07-02T10:00:00Z","endDate":"2021-07-02T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"IASA","startDate":"2021-07-20T19:00:00Z","endDate":"2021-07-20T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"IASA","startDate":"2021-09-02T19:00:00Z","endDate":"2021-09-02T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"IEB","startDate":"2021-09-06T10:00:00Z","endDate":"2021-09-06T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"LC","startDate":"2021-09-14T10:00:00Z","endDate":"2021-09-14T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"LSD","startDate":"2021-07-06T10:00:00Z","endDate":"2021-07-06T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"LSD","startDate":"2021-07-21T19:00:00Z","endDate":"2021-07-21T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"LSD","startDate":"2021-09-06T19:00:00Z","endDate":"2021-09-06T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"PC","startDate":"2021-07-13T19:00:00Z","endDate":"2021-07-13T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"PC","startDate":"2021-07-27T19:00:00Z","endDate":"2021-07-27T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"PC","startDate":"2021-09-17T19:00:00Z","endDate":"2021-09-17T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"PDM","startDate":"2021-09-14T19:00:00Z","endDate":"2021-09-14T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"PE","startDate":"2021-07-05T10:00:00Z","endDate":"2021-07-05T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"PE","startDate":"2021-07-22T19:00:00Z","endDate":"2021-07-22T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"PE","startDate":"2021-09-09T19:00:00Z","endDate":"2021-09-09T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"PG","startDate":"2021-06-29T19:00:00Z","endDate":"2021-06-29T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"PG","startDate":"2021-07-23T14:00:00Z","endDate":"2021-07-23T17:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"PG","startDate":"2021-09-08T14:00:00Z","endDate":"2021-09-08T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"PI","startDate":"2021-07-02T19:00:00Z","endDate":"2021-07-02T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"PI","startDate":"2021-07-21T19:00:00Z","endDate":"2021-07-21T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"PI","startDate":"2021-09-10T19:00:00Z","endDate":"2021-09-10T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"POO","startDate":"2021-09-14T19:00:00Z","endDate":"2021-09-14T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"PSC","startDate":"2021-07-06T19:00:00Z","endDate":"2021-07-06T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"PSC","startDate":"2021-07-21T14:00:00Z","endDate":"2021-07-21T17:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"PSC","startDate":"2021-09-10T19:00:00Z","endDate":"2021-09-10T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"RCp","startDate":"2021-07-14T10:00:00Z","endDate":"2021-07-14T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"RCp","startDate":"2021-07-28T19:00:00Z","endDate":"2021-07-28T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"RCp","startDate":"2021-09-13T19:00:00Z","endDate":"2021-09-13T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"RI","startDate":"2021-09-07T14:00:00Z","endDate":"2021-09-07T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SE1","startDate":"2021-09-06T19:00:00Z","endDate":"2021-09-06T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SE2","startDate":"2021-07-09T19:00:00Z","endDate":"2021-07-09T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"SE2","startDate":"2021-07-26T10:00:00Z","endDate":"2021-07-26T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"SE2","startDate":"2021-09-06T19:00:00Z","endDate":"2021-09-06T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SG","startDate":"2021-07-01T10:00:00Z","endDate":"2021-07-01T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"SG","startDate":"2021-07-20T19:00:00Z","endDate":"2021-07-20T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"SG","startDate":"2021-09-08T14:00:00Z","endDate":"2021-09-08T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SegInf","startDate":"2021-09-17T14:00:00Z","endDate":"2021-09-17T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SI1","startDate":"2021-06-30T19:00:00Z","endDate":"2021-06-30T22:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"SI1","startDate":"2021-07-19T14:00:00Z","endDate":"2021-07-19T17:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"SI1","startDate":"2021-09-03T19:00:00Z","endDate":"2021-09-03T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SI2","startDate":"2021-07-08T14:00:00Z","endDate":"2021-07-08T17:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"SI2","startDate":"2021-07-23T19:00:00Z","endDate":"2021-07-23T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"SI2","startDate":"2021-09-13T10:00:00Z","endDate":"2021-09-13T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SM","startDate":"2021-09-15T10:00:00Z","endDate":"2021-09-15T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"SO","startDate":"2021-07-09T10:00:00Z","endDate":"2021-07-09T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"SO","startDate":"2021-07-26T19:00:00Z","endDate":"2021-07-26T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"SO","startDate":"2021-09-07T19:00:00Z","endDate":"2021-09-07T22:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"TAR","startDate":"2021-07-16T10:00:00Z","endDate":"2021-07-16T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"TAR","startDate":"2021-07-30T19:00:00Z","endDate":"2021-07-30T22:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"TAR","startDate":"2021-09-07T10:00:00Z","endDate":"2021-09-07T13:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"TJ","startDate":"2021-07-12T10:00:00Z","endDate":"2021-07-12T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"TJ","startDate":"2021-07-28T10:00:00Z","endDate":"2021-07-28T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"TJ","startDate":"2021-09-17T14:00:00Z","endDate":"2021-09-17T17:00:00Z","category":"EXAM_SPECIAL","location":""},{"course":"TMD","startDate":"2021-07-10T10:00:00Z","endDate":"2021-07-10T13:00:00Z","category":"EXAM_NORMAL","location":""},{"course":"TMD","startDate":"2021-07-29T10:00:00Z","endDate":"2021-07-29T13:00:00Z","category":"EXAM_ALTERN","location":""},{"course":"TMD","startDate":"2021-09-13T14:00:00Z","endDate":"2021-09-13T17:00:00Z","category":"EXAM_SPECIAL","location":""}]}""" + + assertEquals(expectedJson, serialized) + } +} diff --git a/src/test/kotlin/org/ionproject/integration/format/implementations/TimetableDtoFormatCheckerTest.kt b/src/test/kotlin/org/ionproject/integration/format/implementations/TimetableDtoFormatCheckerTest.kt index 0dbc7360..85c4ee0d 100644 --- a/src/test/kotlin/org/ionproject/integration/format/implementations/TimetableDtoFormatCheckerTest.kt +++ b/src/test/kotlin/org/ionproject/integration/format/implementations/TimetableDtoFormatCheckerTest.kt @@ -11,11 +11,11 @@ import org.ionproject.integration.domain.timetable.model.Faculty import org.ionproject.integration.domain.timetable.model.Instructor import org.ionproject.integration.domain.timetable.dto.InstructorDto import org.ionproject.integration.domain.timetable.model.Label -import org.ionproject.integration.domain.timetable.model.Programme -import org.ionproject.integration.domain.timetable.dto.ProgrammeDto +import org.ionproject.integration.domain.common.Programme +import org.ionproject.integration.domain.common.dto.ProgrammeDto import org.ionproject.integration.domain.timetable.model.RecurrentEvent import org.ionproject.integration.domain.common.School -import org.ionproject.integration.domain.timetable.dto.SchoolDto +import org.ionproject.integration.domain.common.dto.SchoolDto import org.ionproject.integration.domain.timetable.dto.SectionDto import org.ionproject.integration.domain.timetable.Timetable import org.ionproject.integration.domain.timetable.dto.TimetableDto diff --git a/src/test/kotlin/org/ionproject/integration/infrastructure/file/OutputFormatTests.kt b/src/test/kotlin/org/ionproject/integration/infrastructure/file/OutputFormatTests.kt new file mode 100644 index 00000000..5d414a75 --- /dev/null +++ b/src/test/kotlin/org/ionproject/integration/infrastructure/file/OutputFormatTests.kt @@ -0,0 +1,116 @@ +package org.ionproject.integration.infrastructure.file + +import org.ionproject.integration.infrastructure.exception.ArgumentException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class OutputFormatTests { + @Test + fun `when given JSON format then assert extension is correct`() { + val expected = ".json" + val actual = OutputFormat.JSON.extension + + assertEquals(expected, actual) + } + + @Test + fun `when given YAML format then assert extension is correct`() { + val expected = ".yml" + val actual = OutputFormat.YAML.extension + + assertEquals(expected, actual) + } + + @Test + fun `when given json in factory method then return JSON format`() { + val expected = OutputFormat.JSON + val actual = OutputFormat.of("json") + + assertEquals(expected, actual) + } + + @Test + fun `when given json in uppercase in factory method then return JSON format`() { + val expected = OutputFormat.JSON + val actual = OutputFormat.of("JSON") + + assertEquals(expected, actual) + } + + @Test + fun `when given json with leading or trailing space in factory method then return JSON format`() { + val expected = OutputFormat.JSON + val actual = OutputFormat.of(" JsON ") + + assertEquals(expected, actual) + } + + @Test + fun `when given yaml in factory method then return JSON format`() { + val expected = OutputFormat.YAML + val actual = OutputFormat.of("yaml") + + assertEquals(expected, actual) + } + + @Test + fun `when given yml in factory method then return JSON format`() { + val expected = OutputFormat.YAML + val actual = OutputFormat.of("yml") + + assertEquals(expected, actual) + } + + @Test + fun `when given YAML in uppercase in factory method then return JSON format`() { + val expected = OutputFormat.YAML + val actual = OutputFormat.of("YAML") + + assertEquals(expected, actual) + } + + @Test + fun `when given YML in uppercase in factory method then return JSON format`() { + val expected = OutputFormat.YAML + val actual = OutputFormat.of("YML") + + assertEquals(expected, actual) + } + + @Test + fun `when given YAML with leading or trailing space in factory method then return JSON format`() { + val expected = OutputFormat.YAML + val actual = OutputFormat.of(" YaMl ") + + assertEquals(expected, actual) + } + + @Test + fun `when given YML with leading or trailing space in factory method then return JSON format`() { + val expected = OutputFormat.YAML + val actual = OutputFormat.of(" YMl ") + + assertEquals(expected, actual) + } + + @Test + fun `when given empty format then error`() { + val expectedErrorMessage = INVALID_FORMAT_ERROR.format("") + val ex = assertThrows { + OutputFormat.of("") + } + + assertEquals(expectedErrorMessage, ex.message) + } + + @Test + fun `when given unsupported format then error`() { + val expectedErrorMessage = INVALID_FORMAT_ERROR.format("csv") + val ex = assertThrows { + OutputFormat.of("csv") + } + + assertEquals(expectedErrorMessage, ex.message) + } +} diff --git a/src/test/kotlin/org/ionproject/integration/step/tasklet/iseltimetable/implementations/WriteFileTaskletTests.kt b/src/test/kotlin/org/ionproject/integration/step/tasklet/iseltimetable/implementations/WriteFileTaskletTests.kt index 1de427ae..436836a4 100644 --- a/src/test/kotlin/org/ionproject/integration/step/tasklet/iseltimetable/implementations/WriteFileTaskletTests.kt +++ b/src/test/kotlin/org/ionproject/integration/step/tasklet/iseltimetable/implementations/WriteFileTaskletTests.kt @@ -4,10 +4,10 @@ import org.ionproject.integration.application.job.tasklet.WriteFileTasklet import org.ionproject.integration.application.dispatcher.DispatchResult import org.ionproject.integration.application.dispatcher.IDispatcher import org.ionproject.integration.application.job.ISELTimetableJob -import org.ionproject.integration.domain.timetable.model.Programme -import org.ionproject.integration.domain.timetable.dto.ProgrammeDto +import org.ionproject.integration.domain.common.Programme +import org.ionproject.integration.domain.common.dto.ProgrammeDto import org.ionproject.integration.domain.common.School -import org.ionproject.integration.domain.timetable.dto.SchoolDto +import org.ionproject.integration.domain.common.dto.SchoolDto import org.ionproject.integration.domain.timetable.Timetable import org.ionproject.integration.domain.timetable.dto.TimetableDto import org.ionproject.integration.domain.timetable.TimetableTeachers diff --git a/src/test/kotlin/org/ionproject/integration/ui/controller/JobControllerTests.kt b/src/test/kotlin/org/ionproject/integration/ui/controller/JobControllerTests.kt new file mode 100644 index 00000000..af6ff2a0 --- /dev/null +++ b/src/test/kotlin/org/ionproject/integration/ui/controller/JobControllerTests.kt @@ -0,0 +1,130 @@ +package org.ionproject.integration.ui.controller + +import org.assertj.core.api.Assertions.assertThat +import org.hamcrest.CoreMatchers.containsString +import org.ionproject.integration.application.JobEngine +import org.ionproject.integration.application.job.JobType +import org.ionproject.integration.domain.common.InstitutionModel +import org.ionproject.integration.infrastructure.exception.ArgumentException +import org.ionproject.integration.infrastructure.file.OutputFormat +import org.ionproject.integration.ui.dto.InputProcessor +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.mock.web.MockServletContext +import org.springframework.test.context.TestPropertySource +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.net.URI +import java.time.LocalDateTime + +@WebMvcTest +@TestPropertySource("classpath:application.properties") +class JobControllerTests { + @MockBean + private lateinit var jobEngine: JobEngine + + @MockBean + private lateinit var inputProcessor: InputProcessor + + @Autowired + private lateinit var mockMvc: MockMvc + + @Value("\${server.servlet.context-path}") + private lateinit var contextPath: String + + private val mockInstitution = InstitutionModel("test", "test", "test", URI("www.test.com")) + + @BeforeEach + fun setUp() { + assertThat(contextPath).isNotBlank + (mockMvc.dispatcherServlet.servletContext as MockServletContext).contextPath = contextPath + } + + @Test + fun `when receiving a request to list running jobs then return OK`() { + val date = LocalDateTime.of(2020, 6, 30, 15, 3) + + val jobParams = JobEngine.IntegrationJobParameters( + creationDate = date, + startDate = date, + format = OutputFormat.YAML, + institution = mockInstitution, + uri = URI("www.test.com") + ) + + val mockJob1 = JobEngine.IntegrationJob( + type = JobType.TIMETABLE, + status = JobEngine.JobStatus(1, JobEngine.JobExecutionResult.RUNNING), + parameters = jobParams + ) + + val mockJob2 = JobEngine.IntegrationJob( + type = JobType.ACADEMIC_CALENDAR, + status = JobEngine.JobStatus(3, JobEngine.JobExecutionResult.CREATED), + parameters = jobParams + ) + + val expectedResponse = + """[{"type":"timetable","id":1,"status":"RUNNING","createdOn":"2020-06-30T15:03:00Z","startedOn":"2020-06-30T15:03:00Z","links":{"self":"http://localhost/integration/jobs/1"}},{"type":"calendar","id":3,"status":"CREATED","createdOn":"2020-06-30T15:03:00Z","startedOn":"2020-06-30T15:03:00Z","links":{"self":"http://localhost/integration/jobs/3"}}]""" + + whenever(jobEngine.getRunningJobs()) doReturn listOf(mockJob1, mockJob2) + + mockMvc.perform(get("$contextPath$JOBS_URI").contextPath(contextPath)) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string(containsString(expectedResponse))) + } + + @Test + fun `when receiving a run job request then return CREATED`() { + + whenever(inputProcessor.getJobRequest(any())) doReturn JobEngine.CalendarJobRequest( + OutputFormat.YAML, + mockInstitution + ) + whenever(jobEngine.runJob(any())) doReturn JobEngine.JobStatus(1, JobEngine.JobExecutionResult.CREATED) + + val expectedResponse = """{"location":"http://localhost/integration/jobs/1","status":"CREATED"}""" + + mockMvc.perform( + post("$contextPath$JOBS_URI").contextPath(contextPath) + .contentType(MediaType.APPLICATION_JSON) + .content("{}") + ) + .andExpect(status().isCreated) + .andExpect(header().string("Location", """http://localhost$contextPath$JOBS_URI/1""")) + .andExpect(content().string(containsString(expectedResponse))) + } + + @Test + fun `when receiving a bad job request then return BAD_REQUEST`() { + whenever(inputProcessor.getJobRequest(any())) doThrow ArgumentException("You've been a bad, bad boy!") + + val expectedResponse = + """{"type":"https://github.com/i-on-project/integration/blob/master/docs/infrastructure/ArgumentException.md","title":"Bad Request","status":400,"detail":"You've been a bad, bad boy!","instance":"/integration/jobs"""" + + mockMvc.perform( + post("$contextPath$JOBS_URI").contextPath(contextPath) + .contentType(MediaType.APPLICATION_JSON) + .content("{}") + ) + .andDo(print()) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(content().string(containsString(expectedResponse))) + } +} diff --git a/src/test/kotlin/org/ionproject/integration/ui/dto/InputProcessorTests.kt b/src/test/kotlin/org/ionproject/integration/ui/dto/InputProcessorTests.kt new file mode 100644 index 00000000..72150a6b --- /dev/null +++ b/src/test/kotlin/org/ionproject/integration/ui/dto/InputProcessorTests.kt @@ -0,0 +1,251 @@ +package org.ionproject.integration.ui.dto + +import org.ionproject.integration.application.JobEngine +import org.ionproject.integration.application.job.JobType +import org.ionproject.integration.domain.common.InstitutionModel +import org.ionproject.integration.domain.common.ProgrammeModel +import org.ionproject.integration.domain.common.ProgrammeResources +import org.ionproject.integration.infrastructure.exception.ArgumentException +import org.ionproject.integration.infrastructure.file.INVALID_FORMAT_ERROR +import org.ionproject.integration.infrastructure.file.OutputFormat +import org.ionproject.integration.infrastructure.repository.IInstitutionRepository +import org.ionproject.integration.infrastructure.repository.IProgrammeRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.net.URI + +private const val TEST_SCHOOL_NAME = "Test School" +private const val TEST_SCHOOL_ACRONYM = "TS" +private const val TEST_PROGRAMME_ACRONYM = "SP" +private const val TEST_PROGRAMME_NAME = "Some Engineering Programme I Guess" +private const val TEST_SCHOOL_ID = "test.school" +private val TEST_URI = URI("test.school./someFile.pdf") + +class InputProcessorTests { + private val testInstitution = InstitutionModel( + TEST_SCHOOL_NAME, + TEST_SCHOOL_ACRONYM, + TEST_SCHOOL_ID, + TEST_URI + ) + + private val testProgramme = ProgrammeModel( + institutionModel = testInstitution, + name = TEST_PROGRAMME_NAME, + acronym = TEST_PROGRAMME_ACRONYM, + resources = ProgrammeResources(TEST_URI, TEST_URI) + ) + + private val mockInstitutionRepoOK = mock { + on { getInstitutionByIdentifier(any()) } doReturn testInstitution + } + private val mockProgrammeRepoOK = mock { + on { getProgrammeByAcronymAndInstitution(any(), any()) } doReturn testProgramme + } + + private val inputProcessor = InputProcessor(mockInstitutionRepoOK, mockProgrammeRepoOK) + + @Test + fun `when given a valid CALENDAR job then return job request OK`() { + val createCalendarJob = CreateJobDto( + institution = TEST_SCHOOL_ID, + format = OutputFormat.JSON.name, + type = JobType.ACADEMIC_CALENDAR.identifier + ) + + val expected = JobEngine.CalendarJobRequest( + format = OutputFormat.JSON, + institution = testInstitution, + ) + val actual = inputProcessor.getJobRequest(createCalendarJob) + + assertEquals(expected, actual) + } + + @Test + fun `when given a valid job with extra whitespace then trim and return job request OK`() { + val createCalendarJob = CreateJobDto( + institution = " $TEST_SCHOOL_ID ", + format = " ${OutputFormat.JSON.name} ", + type = " ${JobType.ACADEMIC_CALENDAR.identifier} " + ) + + val expected = JobEngine.CalendarJobRequest( + format = OutputFormat.JSON, + institution = testInstitution, + ) + val actual = inputProcessor.getJobRequest(createCalendarJob) + + assertEquals(expected, actual) + } + + @Test + fun `when given a valid TIMETABLE job then return job request OK`() { + val createTimetableJob = CreateJobDto( + institution = TEST_SCHOOL_ID, + programme = TEST_PROGRAMME_ACRONYM, + format = OutputFormat.YAML.name, + type = JobType.TIMETABLE.identifier + ) + + val expected = JobEngine.TimetableJobRequest( + format = OutputFormat.YAML, + institution = testInstitution, + programme = testProgramme + ) + val actual = inputProcessor.getJobRequest(createTimetableJob) + + assertEquals(expected, actual) + } + + @Test + fun `when given an empty job then throw exception`() { + val expected = INVALID_JOB_TYPE_ERROR.format("null") + + val ex = assertThrows { + inputProcessor.getJobRequest(CreateJobDto()) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given job with empty format then throw exception`() { + val expected = MISSING_PARAMETER_ERROR.format(FORMAT) + + val ex = assertThrows { + val jobDto = CreateJobDto( + institution = "ok", + format = " ", + type = JobType.ACADEMIC_CALENDAR.identifier + ) + inputProcessor.getJobRequest(jobDto) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given job with invalid format then throw exception`() { + val expected = INVALID_FORMAT_ERROR.format("jason") + + val ex = assertThrows { + val jobDto = CreateJobDto( + institution = "ok", + format = " jason", + type = JobType.ACADEMIC_CALENDAR.identifier + ) + inputProcessor.getJobRequest(jobDto) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given job with null format then throw exception`() { + val expected = MISSING_PARAMETER_ERROR.format(FORMAT) + + val ex = assertThrows { + val jobDto = CreateJobDto( + institution = "ok", + type = JobType.ACADEMIC_CALENDAR.identifier + ) + inputProcessor.getJobRequest(jobDto) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given job with empty institution then throw exception`() { + val expected = MISSING_PARAMETER_ERROR.format(INSTITUTION) + + val ex = assertThrows { + val jobDto = CreateJobDto( + institution = " ", + format = OutputFormat.YAML.name, + type = JobType.ACADEMIC_CALENDAR.identifier + ) + inputProcessor.getJobRequest(jobDto) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given job with null institution then throw exception`() { + val expected = MISSING_PARAMETER_ERROR.format(INSTITUTION) + + val ex = assertThrows { + val jobDto = CreateJobDto( + format = OutputFormat.YAML.name, + type = JobType.ACADEMIC_CALENDAR.identifier + ) + inputProcessor.getJobRequest(jobDto) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given job with empty programme then throw exception`() { + val expected = MISSING_PARAMETER_ERROR.format(PROGRAMME) + + val ex = assertThrows { + val jobDto = CreateJobDto( + institution = TEST_SCHOOL_ID, + programme = " ", + format = OutputFormat.YAML.name, + type = JobType.TIMETABLE.identifier + ) + inputProcessor.getJobRequest(jobDto) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given job with null programme then throw exception`() { + val expected = MISSING_PARAMETER_ERROR.format(PROGRAMME) + + val ex = assertThrows { + val jobDto = CreateJobDto( + institution = TEST_SCHOOL_ID, + format = OutputFormat.YAML.name, + type = JobType.TIMETABLE.identifier + ) + inputProcessor.getJobRequest(jobDto) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given an empty job type then throw exception`() { + val expected = INVALID_JOB_TYPE_ERROR.format("null") + + val ex = assertThrows { + inputProcessor.getJobRequest( + CreateJobDto(" ") + ) + } + + assertEquals(expected, ex.message) + } + + @Test + fun `when given an invalid job type then throw exception`() { + val jobType = " invalid_job " + val expected = INVALID_JOB_TYPE_ERROR.format(jobType.trim()) + + val ex = assertThrows { + inputProcessor.getJobRequest(CreateJobDto(type = jobType)) + } + + assertEquals(expected, ex.message) + } +} diff --git a/src/test/kotlin/org/ionproject/integration/utils/DateUtilsTests.kt b/src/test/kotlin/org/ionproject/integration/utils/DateUtilsTests.kt index 17af2f80..72246587 100644 --- a/src/test/kotlin/org/ionproject/integration/utils/DateUtilsTests.kt +++ b/src/test/kotlin/org/ionproject/integration/utils/DateUtilsTests.kt @@ -158,4 +158,34 @@ class DateUtilsTests { assertEquals("1982-05-11", DateUtils.formatToCalendarDate(intervalDate.from)) assertEquals("1982-05-17", DateUtils.formatToCalendarDate(intervalDate.to)) } + + @Test + fun `when Date Time Range and Duration are presented then Return a IntervalDateTime`() { + // Arrange + val dayMonthStr = "29 jun. (terça)" + val timeStr = "19h00" + val durationStr = "3h00" + + // Act + val intervalDate = DateUtils.getEvaluationDateTimeFrom("2021", dayMonthStr, timeStr, durationStr) + + // Assert + assertEquals("2021-06-29T19:00Z[UTC]", intervalDate.from.toString()) + assertEquals("2021-06-29T22:00Z[UTC]", intervalDate.to.toString()) + } + + @Test + fun `when Date Time Range with and Duration with Half-Hour are presented then Return a IntervalDateTime`() { + // Arrange + val dayMonthStr = "2 jun. (segunda)" + val timeStr = "19h30" + val durationStr = "2h30" + + // Act + val intervalDate = DateUtils.getEvaluationDateTimeFrom("2021", dayMonthStr, timeStr, durationStr) + + // Assert + assertEquals("2021-06-02T19:30Z[UTC]", intervalDate.from.toString()) + assertEquals("2021-06-02T22:00Z[UTC]", intervalDate.to.toString()) + } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index f266cd95..d7933fc5 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -8,6 +8,8 @@ spring.datasource.password= spring.batch.job.enabled = false +#spring web +server.servlet.context-path=/integration spring.mail.host=localhost spring.mail.username=alert-mailbox@domain.com diff --git a/src/test/resources/evaluationsTest.pdf b/src/test/resources/evaluationsTest.pdf new file mode 100644 index 00000000..782987fc Binary files /dev/null and b/src/test/resources/evaluationsTest.pdf differ