From d44b85073a3c534ee8716162c23d45a42704d758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Szczodrzy=C5=84ski?= Date: Fri, 31 Jan 2025 20:48:44 +0100 Subject: [PATCH] [API/Usos] Implement basic grades support --- .../data/api/edziennik/usos/UsosFeatures.kt | 18 +- .../data/api/edziennik/usos/data/UsosData.kt | 10 ++ .../usos/data/api/UsosApiEctsPoints.kt | 56 ++++++ .../usos/data/api/UsosApiExamReports.kt | 162 ++++++++++++++++++ .../data/db/enums/LoginTypeFeatures.kt | 1 + .../edziennik/utils/managers/GradesManager.kt | 14 ++ 6 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiEctsPoints.kt create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiExamReports.kt diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/UsosFeatures.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/UsosFeatures.kt index f36580fb..2a009af8 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/UsosFeatures.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/UsosFeatures.kt @@ -10,10 +10,12 @@ import pl.szczodrzynski.edziennik.data.db.enums.FeatureType import pl.szczodrzynski.edziennik.data.db.enums.LoginMethod import pl.szczodrzynski.edziennik.data.db.enums.LoginType -const val ENDPOINT_USOS_API_USER = 7000 -const val ENDPOINT_USOS_API_TERMS = 7010 -const val ENDPOINT_USOS_API_COURSES = 7020 -const val ENDPOINT_USOS_API_TIMETABLE = 7030 +const val ENDPOINT_USOS_API_USER = 7000 +const val ENDPOINT_USOS_API_TERMS = 7010 +const val ENDPOINT_USOS_API_COURSES = 7020 +const val ENDPOINT_USOS_API_TIMETABLE = 7030 +const val ENDPOINT_USOS_API_ECTS_POINTS = 7040 +const val ENDPOINT_USOS_API_EXAM_REPORTS = 7050 val UsosFeatures = listOf( /* @@ -39,4 +41,12 @@ val UsosFeatures = listOf( Feature(LoginType.USOS, FeatureType.TIMETABLE, listOf( ENDPOINT_USOS_API_TIMETABLE to LoginMethod.USOS_API, )), + + /* + * Grades + */ + Feature(LoginType.USOS, FeatureType.GRADES, listOf( + ENDPOINT_USOS_API_ECTS_POINTS to LoginMethod.USOS_API, + ENDPOINT_USOS_API_EXAM_REPORTS to LoginMethod.USOS_API, + )), ) diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/UsosData.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/UsosData.kt index dbccb16b..8c85546c 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/UsosData.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/UsosData.kt @@ -8,6 +8,8 @@ import pl.szczodrzynski.edziennik.R import pl.szczodrzynski.edziennik.data.api.edziennik.template.data.web.TemplateWebSample import pl.szczodrzynski.edziennik.data.api.edziennik.usos.* import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiCourses +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiEctsPoints +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiExamReports import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiTerms import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiTimetable import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiUser @@ -58,6 +60,14 @@ class UsosData(val data: DataUsos, val onSuccess: () -> Unit) { data.startProgress(R.string.edziennik_progress_endpoint_timetable) UsosApiTimetable(data, lastSync, onSuccess) } + ENDPOINT_USOS_API_ECTS_POINTS -> { + data.startProgress(R.string.edziennik_progress_endpoint_grade_categories) + UsosApiEctsPoints(data, lastSync, onSuccess) + } + ENDPOINT_USOS_API_EXAM_REPORTS -> { + data.startProgress(R.string.edziennik_progress_endpoint_grades) + UsosApiExamReports(data, lastSync, onSuccess) + } else -> onSuccess(endpointId) } } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiEctsPoints.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiEctsPoints.kt new file mode 100644 index 00000000..b6d112f1 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiEctsPoints.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) Kuba SzczodrzyƄski 2025-1-31. + */ + +package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api + +import com.google.gson.JsonObject +import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_API_INCOMPLETE_RESPONSE +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.ENDPOINT_USOS_API_ECTS_POINTS +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi +import pl.szczodrzynski.edziennik.ext.DAY +import pl.szczodrzynski.edziennik.ext.filter + +class UsosApiEctsPoints( + override val data: DataUsos, + override val lastSync: Long?, + val onSuccess: (endpointId: Int) -> Unit, +) : UsosApi(data, lastSync) { + companion object { + const val TAG = "UsosApiEctsPoints" + } + + init { + apiRequest( + tag = TAG, + service = "courses/user_ects_points", + responseType = ResponseType.OBJECT, + ) { json, response -> + if (!processResponse(json)) { + data.error(TAG, ERROR_USOS_API_INCOMPLETE_RESPONSE, response) + return@apiRequest + } + + data.setSyncNext(ENDPOINT_USOS_API_ECTS_POINTS, 2 * DAY) + onSuccess(ENDPOINT_USOS_API_ECTS_POINTS) + } + } + + private fun processResponse(json: JsonObject): Boolean { + for ((_, coursePointsEl) in json.entrySet()) { + if (!coursePointsEl.isJsonObject) + continue + for ((courseId, pointsEl) in coursePointsEl.asJsonObject.entrySet()) { + if (!pointsEl.isJsonPrimitive) + continue + val gradeCategories = data.gradeCategories + .filter { it.text == courseId } + gradeCategories.forEach { + it.weight = pointsEl.asString.toFloatOrNull() ?: -1.0f + } + } + } + return true + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiExamReports.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiExamReports.kt new file mode 100644 index 00000000..adeeae10 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/usos/data/api/UsosApiExamReports.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) Kuba SzczodrzyƄski 2025-1-31. + */ + +package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api + +import com.google.gson.JsonObject +import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_API_INCOMPLETE_RESPONSE +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.ENDPOINT_USOS_API_EXAM_REPORTS +import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi +import pl.szczodrzynski.edziennik.data.api.models.DataRemoveModel +import pl.szczodrzynski.edziennik.data.db.entity.Grade +import pl.szczodrzynski.edziennik.data.db.entity.Grade.Companion.TYPE_NORMAL +import pl.szczodrzynski.edziennik.data.db.entity.Metadata +import pl.szczodrzynski.edziennik.data.db.entity.SYNC_ALWAYS +import pl.szczodrzynski.edziennik.data.db.enums.MetadataType +import pl.szczodrzynski.edziennik.ext.getBoolean +import pl.szczodrzynski.edziennik.ext.getInt +import pl.szczodrzynski.edziennik.ext.getJsonArray +import pl.szczodrzynski.edziennik.ext.getJsonObject +import pl.szczodrzynski.edziennik.ext.getLong +import pl.szczodrzynski.edziennik.ext.getString +import pl.szczodrzynski.edziennik.ext.join +import pl.szczodrzynski.edziennik.utils.models.Date + +class UsosApiExamReports( + override val data: DataUsos, + override val lastSync: Long?, + val onSuccess: (endpointId: Int) -> Unit, +) : UsosApi(data, lastSync) { + companion object { + const val TAG = "UsosApiExamReports" + } + + init { + apiRequest( + tag = TAG, + service = "examrep/user2", + fields = listOf( + "type_description", + "course_unit" to listOf("id", "course_name"), + "sessions" to listOf( + "description", + "issuer_grades" to listOf( + "exam_id", + "exam_session_number", + "value_symbol", + // "value_description", + "passes", + "counts_into_average", + "date_modified", + "modification_author", + "comment", + ), + ), + ), + responseType = ResponseType.OBJECT, + ) { json, response -> + if (!processResponse(json)) { + data.error(TAG, ERROR_USOS_API_INCOMPLETE_RESPONSE, response) + return@apiRequest + } + + data.toRemove.add(DataRemoveModel.Grades.all()) + data.setSyncNext(ENDPOINT_USOS_API_EXAM_REPORTS, SYNC_ALWAYS) + onSuccess(ENDPOINT_USOS_API_EXAM_REPORTS) + } + } + + private fun processResponse(json: JsonObject): Boolean { + for ((_, courseEditionEl) in json.entrySet()) { + if (!courseEditionEl.isJsonObject) + continue + for ((courseId, examReportsEl) in courseEditionEl.asJsonObject.entrySet()) { + if (!examReportsEl.isJsonArray) + continue + for (examReportEl in examReportsEl.asJsonArray) { + if (!examReportEl.isJsonObject) + continue + val examReport = examReportEl.asJsonObject + processExamReport(courseId, examReport) + } + } + } + return true + } + + private fun processExamReport(courseId: String, examReport: JsonObject) { + val typeDescription = examReport.getLangString("type_description") + val courseUnit = examReport.getJsonObject("course_unit") + ?: return + val courseUnitId = courseUnit.getString("id")?.toLongOrNull() + ?: return + val courseName = courseUnit.getLangString("course_name") + ?: return + val sessions = examReport.getJsonArray("sessions") + ?: return + + for (sessionEl in sessions) { + if (!sessionEl.isJsonObject) + continue + val session = sessionEl.asJsonObject + + val sessionDescription = session.getLangString("description") + val issuerGrade = session.getJsonObject("issuer_grades") ?: continue + + val examId = issuerGrade.getInt("exam_id") ?: continue + val sessionNumber = issuerGrade.getInt("exam_session_number") ?: continue + val valueSymbol = issuerGrade.getString("value_symbol") ?: continue + val passes = issuerGrade.getBoolean("passes") + val countsIntoAverage = issuerGrade.getString("counts_into_average") ?: "T" + val dateModified = issuerGrade.getString("date_modified") + val modificationAuthorId = issuerGrade.getJsonObject("modification_author") + ?.getLong("id") ?: -1L + val comment = issuerGrade.getString("comment") + + val gradeCategory = data.gradeCategories[courseUnitId] + val classType = gradeCategory?.columns?.get(0) + val value = valueSymbol.toFloatOrNull() ?: 0.0f + + val gradeObject = Grade( + profileId = profileId, + id = examId * 10L + sessionNumber, + name = valueSymbol, + type = TYPE_NORMAL, + value = value, + weight = if (countsIntoAverage == "T") gradeCategory?.weight ?: 0.0f else 0.0f, + color = (if (passes == true) 0xFF465FB3 else 0xFFB71C1C).toInt(), + category = typeDescription, + description = listOfNotNull(classType, sessionDescription).join(" - "), + comment = comment, + semester = 1, + teacherId = modificationAuthorId, + subjectId = data.getSubject( + id = null, + name = courseName, + shortName = courseId, + ).id, + addedDate = Date.fromIso(dateModified), + ) + + if (sessionNumber > 1) { + val origId = examId * 10L + sessionNumber - 1 + val grades = data.gradeList.filter { it.id == origId } + grades.firstOrNull()?.parentId = gradeObject.id + gradeObject.isImprovement = true + } + + data.gradeList.add(gradeObject) + data.metadataList.add( + Metadata( + profileId, + MetadataType.GRADE, + gradeObject.id, + profile?.empty ?: false, + profile?.empty ?: false, + ) + ) + } + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/enums/LoginTypeFeatures.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/enums/LoginTypeFeatures.kt index b0d16002..f5eee20f 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/enums/LoginTypeFeatures.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/enums/LoginTypeFeatures.kt @@ -93,6 +93,7 @@ internal val FEATURES_PODLASIE = setOf( internal val FEATURES_USOS = setOf( TIMETABLE, AGENDA, + GRADES, STUDENT_INFO, STUDENT_NUMBER, diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/GradesManager.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/GradesManager.kt index bfce3067..1fd188fa 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/GradesManager.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/GradesManager.kt @@ -15,6 +15,7 @@ import pl.szczodrzynski.edziennik.data.db.entity.Grade.Companion.TYPE_NORMAL import pl.szczodrzynski.edziennik.data.db.entity.Grade.Companion.TYPE_POINT_AVG import pl.szczodrzynski.edziennik.data.db.entity.Grade.Companion.TYPE_POINT_SUM import pl.szczodrzynski.edziennik.data.db.entity.Grade.Companion.TYPE_YEAR_FINAL +import pl.szczodrzynski.edziennik.data.db.enums.SchoolType import pl.szczodrzynski.edziennik.data.db.full.GradeFull import pl.szczodrzynski.edziennik.ext.asColoredSpannable import pl.szczodrzynski.edziennik.ext.get @@ -158,6 +159,19 @@ class GradesManager(val app: App) : CoroutineScope { } } type == TYPE_NORMAL && defColor -> grade.color and 0xffffff + type == TYPE_NORMAL && app.profile.loginStoreType.schoolType == SchoolType.UNIVERSITY -> { + when (grade.name.lowercase()) { + "zal" -> 0x4caf50 + "nb", "nk" -> 0xff7043 + "2.0", "nzal" -> 0xff3d00 + "3.0" -> 0xffff00 + "3.5" -> 0xc6ff00 + "4.0" -> 0x76ff03 + "4.5" -> 0x64dd17 + "5.0" -> 0x00c853 + else -> grade.color and 0xffffff + } + } type in TYPE_NORMAL..TYPE_YEAR_FINAL -> { when (grade.name.lowercase()) { "+", "++", "+++" -> 0x4caf50