| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/ui/ash/glanceables/glanceables_classroom_client_impl.h" |
| |
| #include <algorithm> |
| #include <functional> |
| #include <memory> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "ash/glanceables/classroom/glanceables_classroom_types.h" |
| #include "base/barrier_closure.h" |
| #include "base/check.h" |
| #include "base/containers/flat_map.h" |
| #include "base/functional/bind.h" |
| #include "base/functional/callback_forward.h" |
| #include "base/functional/callback_helpers.h" |
| #include "base/memory/weak_ptr.h" |
| #include "base/time/time.h" |
| #include "base/types/expected.h" |
| #include "google_apis/classroom/classroom_api_course_work_response_types.h" |
| #include "google_apis/classroom/classroom_api_courses_response_types.h" |
| #include "google_apis/classroom/classroom_api_list_course_work_request.h" |
| #include "google_apis/classroom/classroom_api_list_courses_request.h" |
| #include "google_apis/classroom/classroom_api_list_student_submissions_request.h" |
| #include "google_apis/classroom/classroom_api_student_submissions_response_types.h" |
| #include "google_apis/common/api_error_codes.h" |
| #include "google_apis/common/request_sender.h" |
| #include "google_apis/gaia/gaia_constants.h" |
| #include "net/traffic_annotation/network_traffic_annotation.h" |
| #include "third_party/abseil-cpp/absl/types/optional.h" |
| |
| namespace ash { |
| namespace { |
| |
| using ::google_apis::ApiErrorCode; |
| using ::google_apis::RequestSender; |
| using ::google_apis::classroom::Course; |
| using ::google_apis::classroom::Courses; |
| using ::google_apis::classroom::CourseWork; |
| using ::google_apis::classroom::CourseWorkItem; |
| using ::google_apis::classroom::ListCoursesRequest; |
| using ::google_apis::classroom::ListCourseWorkRequest; |
| using ::google_apis::classroom::ListStudentSubmissionsRequest; |
| using ::google_apis::classroom::StudentSubmission; |
| using ::google_apis::classroom::StudentSubmissions; |
| |
| // Special filter value for `ListCoursesRequest` to request courses with access |
| // limited to the requesting user. |
| constexpr char kOwnCoursesFilterValue[] = "me"; |
| |
| // Special parameter value to request student submissions for all course work in |
| // the specified course. |
| constexpr char kAllStudentSubmissionsParameterValue[] = "-"; |
| |
| // TODO(b/282013130): Update the traffic annotation tag once all "[TBD]" items |
| // are ready. |
| constexpr net::NetworkTrafficAnnotationTag kTrafficAnnotationTag = |
| net::DefineNetworkTrafficAnnotation("glanceables_classroom_integration", R"( |
| semantics { |
| sender: "Glanceables keyed service" |
| description: "Provide ChromeOS users quick access to their " |
| "classroom items without opening the app or website" |
| trigger: "[TBD] Depends on UI surface and pre-fetching strategy" |
| internal { |
| contacts { |
| email: "chromeos-launcher@google.com" |
| } |
| } |
| user_data { |
| type: ACCESS_TOKEN |
| } |
| data: "The request is authenticated with an OAuth2 access token " |
| "identifying the Google account" |
| destination: GOOGLE_OWNED_SERVICE |
| last_reviewed: "2023-05-12" |
| } |
| policy { |
| cookies_allowed: NO |
| setting: "[TBD] This feature cannot be disabled in settings" |
| policy_exception_justification: "WIP, guarded by `GlanceablesV2` flag" |
| } |
| )"); |
| |
| absl::optional<base::Time> ConvertCourseWorkItemDue( |
| const absl::optional<CourseWorkItem::DueDateTime>& raw_due) { |
| if (!raw_due.has_value()) { |
| return absl::nullopt; |
| } |
| |
| const auto exploded_due = base::Time::Exploded{.year = raw_due->year, |
| .month = raw_due->month, |
| .day_of_month = raw_due->day}; |
| base::Time due; |
| if (!base::Time::FromUTCExploded(exploded_due, &due)) { |
| return absl::nullopt; |
| } |
| return due + raw_due->time_of_day; |
| } |
| |
| GlanceablesClassroomStudentSubmission::State CalculateStudentSubmissionState( |
| const std::unique_ptr<StudentSubmission>& raw_student_submission) { |
| const auto raw_state = raw_student_submission->state(); |
| if (raw_state == StudentSubmission::State::kNew || |
| raw_state == StudentSubmission::State::kCreated || |
| raw_state == StudentSubmission::State::kReclaimedByStudent) { |
| return GlanceablesClassroomStudentSubmission::State::kAssigned; |
| } |
| |
| if (raw_state == StudentSubmission::State::kTurnedIn) { |
| return GlanceablesClassroomStudentSubmission::State::kTurnedIn; |
| } |
| |
| if (raw_state == StudentSubmission::State::kReturned) { |
| return raw_student_submission->assigned_grade().has_value() |
| ? GlanceablesClassroomStudentSubmission::State::kGraded |
| : GlanceablesClassroomStudentSubmission::State::kAssigned; |
| } |
| |
| return GlanceablesClassroomStudentSubmission::State::kOther; |
| } |
| |
| // TODO(b/283369115): consider doing this only once after fetching all |
| // submissions. |
| base::flat_map<std::string, std::vector<GlanceablesClassroomStudentSubmission*>> |
| GroupStudentSubmissionsByCourseWorkId( |
| const std::vector<std::unique_ptr<GlanceablesClassroomStudentSubmission>>& |
| student_submissions) { |
| base::flat_map<std::string, |
| std::vector<GlanceablesClassroomStudentSubmission*>> |
| grouped_submissions; |
| for (const auto& submission : student_submissions) { |
| grouped_submissions[submission->course_work_id].push_back(submission.get()); |
| } |
| return grouped_submissions; |
| } |
| |
| } // namespace |
| |
| GlanceablesClassroomClientImpl::GlanceablesClassroomClientImpl( |
| const GlanceablesClassroomClientImpl::CreateRequestSenderCallback& |
| create_request_sender_callback) |
| : create_request_sender_callback_(create_request_sender_callback) {} |
| |
| GlanceablesClassroomClientImpl::~GlanceablesClassroomClientImpl() = default; |
| |
| void GlanceablesClassroomClientImpl::IsStudentRoleActive( |
| IsRoleEnabledCallback callback) { |
| CHECK(callback); |
| |
| InvokeOnceStudentDataFetched(base::BindOnce( |
| [](base::WeakPtr<GlanceablesClassroomClientImpl> self, |
| base::OnceCallback<void(bool active)> callback) { |
| if (!self) { |
| std::move(callback).Run(false); |
| return; |
| } |
| std::move(callback).Run(!self->student_courses_.empty()); |
| }, |
| weak_factory_.GetWeakPtr(), std::move(callback))); |
| } |
| |
| void GlanceablesClassroomClientImpl::GetCompletedStudentAssignments( |
| GetStudentAssignmentsCallback callback) { |
| CHECK(callback); |
| |
| auto due_predicate = base::BindRepeating( |
| [](const absl::optional<base::Time>& due) { return true; }); |
| auto submission_state_predicate = base::BindRepeating( |
| [](GlanceablesClassroomStudentSubmission::State state) { |
| return state == |
| GlanceablesClassroomStudentSubmission::State::kTurnedIn || |
| state == GlanceablesClassroomStudentSubmission::State::kGraded; |
| }); |
| InvokeOnceStudentDataFetched(base::BindOnce( |
| &GlanceablesClassroomClientImpl::GetFilteredStudentAssignments, |
| weak_factory_.GetWeakPtr(), std::move(due_predicate), |
| std::move(submission_state_predicate), std::move(callback))); |
| } |
| |
| void GlanceablesClassroomClientImpl:: |
| GetStudentAssignmentsWithApproachingDueDate( |
| GetStudentAssignmentsCallback callback) { |
| CHECK(callback); |
| |
| auto due_predicate = base::BindRepeating( |
| [](const base::Time& now, const absl::optional<base::Time>& due) { |
| return due.has_value() && now < due.value(); |
| }, |
| base::Time::Now()); |
| auto submission_state_predicate = base::BindRepeating( |
| [](GlanceablesClassroomStudentSubmission::State state) { |
| return state == GlanceablesClassroomStudentSubmission::State::kAssigned; |
| }); |
| InvokeOnceStudentDataFetched(base::BindOnce( |
| &GlanceablesClassroomClientImpl::GetFilteredStudentAssignments, |
| weak_factory_.GetWeakPtr(), std::move(due_predicate), |
| std::move(submission_state_predicate), std::move(callback))); |
| } |
| |
| void GlanceablesClassroomClientImpl::GetStudentAssignmentsWithMissedDueDate( |
| GetStudentAssignmentsCallback callback) { |
| CHECK(callback); |
| |
| auto due_predicate = base::BindRepeating( |
| [](const base::Time& now, const absl::optional<base::Time>& due) { |
| return due.has_value() && now > due.value(); |
| }, |
| base::Time::Now()); |
| auto submission_state_predicate = base::BindRepeating( |
| [](GlanceablesClassroomStudentSubmission::State state) { |
| return state == GlanceablesClassroomStudentSubmission::State::kAssigned; |
| }); |
| InvokeOnceStudentDataFetched(base::BindOnce( |
| &GlanceablesClassroomClientImpl::GetFilteredStudentAssignments, |
| weak_factory_.GetWeakPtr(), std::move(due_predicate), |
| std::move(submission_state_predicate), std::move(callback))); |
| } |
| |
| void GlanceablesClassroomClientImpl::GetStudentAssignmentsWithoutDueDate( |
| GetStudentAssignmentsCallback callback) { |
| CHECK(callback); |
| |
| auto due_predicate = base::BindRepeating( |
| [](const absl::optional<base::Time>& due) { return !due.has_value(); }); |
| auto submission_state_predicate = base::BindRepeating( |
| [](GlanceablesClassroomStudentSubmission::State state) { |
| return state == GlanceablesClassroomStudentSubmission::State::kAssigned; |
| }); |
| InvokeOnceStudentDataFetched(base::BindOnce( |
| &GlanceablesClassroomClientImpl::GetFilteredStudentAssignments, |
| weak_factory_.GetWeakPtr(), std::move(due_predicate), |
| std::move(submission_state_predicate), std::move(callback))); |
| } |
| |
| void GlanceablesClassroomClientImpl::FetchStudentCourses( |
| FetchCoursesCallback callback) { |
| CHECK(callback); |
| |
| student_courses_.clear(); |
| FetchCoursesPage( |
| /*student_id=*/kOwnCoursesFilterValue, /*teacher_id=*/"", |
| /*page_token=*/"", student_courses_, std::move(callback)); |
| } |
| |
| void GlanceablesClassroomClientImpl::FetchTeacherCourses( |
| FetchCoursesCallback callback) { |
| CHECK(callback); |
| |
| teacher_courses_.clear(); |
| FetchCoursesPage( |
| /*student_id=*/"", /*teacher_id=*/kOwnCoursesFilterValue, |
| /*page_token=*/"", teacher_courses_, std::move(callback)); |
| } |
| |
| void GlanceablesClassroomClientImpl::FetchCourseWork( |
| const std::string& course_id, |
| FetchCourseWorkCallback callback) { |
| CHECK(!course_id.empty()); |
| CHECK(callback); |
| |
| const auto [iter, inserted] = course_work_.emplace( |
| course_id, |
| std::vector<std::unique_ptr<GlanceablesClassroomCourseWorkItem>>()); |
| if (!inserted) { |
| iter->second.clear(); |
| } |
| |
| FetchCourseWorkPage(course_id, /*page_token=*/"", std::move(callback)); |
| } |
| |
| void GlanceablesClassroomClientImpl::FetchStudentSubmissions( |
| const std::string& course_id, |
| FetchStudentSubmissionsCallback callback) { |
| CHECK(!course_id.empty()); |
| CHECK(callback); |
| |
| const auto [iter, inserted] = student_submissions_.emplace( |
| course_id, |
| std::vector<std::unique_ptr<GlanceablesClassroomStudentSubmission>>()); |
| if (!inserted) { |
| iter->second.clear(); |
| } |
| |
| FetchStudentSubmissionsPage(course_id, /*page_token=*/"", |
| std::move(callback)); |
| } |
| |
| void GlanceablesClassroomClientImpl::InvokeOnceStudentDataFetched( |
| base::OnceClosure callback) { |
| CHECK(callback); |
| |
| if (student_data_fetch_status_ == FetchStatus::kFetched) { |
| std::move(callback).Run(); |
| return; |
| } |
| |
| callbacks_waiting_for_student_data_.push_back(std::move(callback)); |
| |
| if (student_data_fetch_status_ == FetchStatus::kNotFetched) { |
| student_data_fetch_status_ = FetchStatus::kFetching; |
| FetchStudentCourses(base::BindOnce( |
| &GlanceablesClassroomClientImpl::OnCoursesFetched, |
| weak_factory_.GetWeakPtr(), |
| base::BindOnce(&GlanceablesClassroomClientImpl::OnStudentDataFetched, |
| weak_factory_.GetWeakPtr()))); |
| } |
| } |
| |
| void GlanceablesClassroomClientImpl::FetchCoursesPage( |
| const std::string& student_id, |
| const std::string& teacher_id, |
| const std::string& page_token, |
| std::vector<std::unique_ptr<GlanceablesClassroomCourse>>& courses_container, |
| FetchCoursesCallback callback) { |
| CHECK(!student_id.empty() || !teacher_id.empty()); |
| CHECK(callback); |
| |
| auto* const request_sender = GetRequestSender(); |
| request_sender->StartRequestWithAuthRetry( |
| std::make_unique<ListCoursesRequest>( |
| request_sender, student_id, teacher_id, page_token, |
| base::BindOnce(&GlanceablesClassroomClientImpl::OnCoursesPageFetched, |
| weak_factory_.GetWeakPtr(), student_id, teacher_id, |
| std::ref(courses_container), std::move(callback)))); |
| } |
| |
| void GlanceablesClassroomClientImpl::OnCoursesPageFetched( |
| const std::string& student_id, |
| const std::string& teacher_id, |
| std::vector<std::unique_ptr<GlanceablesClassroomCourse>>& courses_container, |
| FetchCoursesCallback callback, |
| base::expected<std::unique_ptr<Courses>, ApiErrorCode> result) { |
| CHECK(!student_id.empty() || !teacher_id.empty()); |
| CHECK(callback); |
| |
| if (!result.has_value()) { |
| // TODO(b/282013130): handle failures of a single page fetch request more |
| // gracefully (retry and/or reflect errors on UI). |
| courses_container.clear(); |
| std::move(callback).Run(courses_container); |
| return; |
| } |
| |
| for (const auto& item : result.value()->items()) { |
| if (item->state() == Course::State::kActive) { |
| courses_container.push_back(std::make_unique<GlanceablesClassroomCourse>( |
| item->id(), item->name())); |
| } |
| } |
| |
| if (result.value()->next_page_token().empty()) { |
| std::move(callback).Run(courses_container); |
| } else { |
| FetchCoursesPage(student_id, teacher_id, result.value()->next_page_token(), |
| courses_container, std::move(callback)); |
| } |
| } |
| |
| void GlanceablesClassroomClientImpl::OnCoursesFetched( |
| base::OnceClosure on_course_work_and_student_submissions_fetched, |
| const std::vector<std::unique_ptr<GlanceablesClassroomCourse>>& courses) { |
| CHECK(on_course_work_and_student_submissions_fetched); |
| |
| // `FetchCourseWork()` + `FetchStudentSubmissions()` per course. |
| const auto expected_callback_calls = courses.size() * 2; |
| const auto barrier_closure = base::BarrierClosure( |
| expected_callback_calls, |
| std::move(on_course_work_and_student_submissions_fetched)); |
| |
| for (const auto& course : courses) { |
| // Helps to prevent the presubmit error. Otherwise it thinks explicit |
| // `std::unique_ptr` constructor is called with `barrier_closure` and asks |
| // to use `std::make_unique<T>()` or `base::WrapUnique` instead. Looks like |
| // a false-positive regular expression match. |
| using FetchCourseWorkIgnoredArg = |
| const std::vector<std::unique_ptr<GlanceablesClassroomCourseWorkItem>>&; |
| using FetchStudentSubmissionsIgnoredArg = const std::vector< |
| std::unique_ptr<GlanceablesClassroomStudentSubmission>>&; |
| |
| FetchCourseWork(course->id, base::IgnoreArgs<FetchCourseWorkIgnoredArg>( |
| barrier_closure)); |
| FetchStudentSubmissions( |
| course->id, |
| base::IgnoreArgs<FetchStudentSubmissionsIgnoredArg>(barrier_closure)); |
| } |
| } |
| |
| void GlanceablesClassroomClientImpl::FetchCourseWorkPage( |
| const std::string& course_id, |
| const std::string& page_token, |
| FetchCourseWorkCallback callback) { |
| CHECK(!course_id.empty()); |
| CHECK(callback); |
| |
| auto* const request_sender = GetRequestSender(); |
| request_sender->StartRequestWithAuthRetry( |
| std::make_unique<ListCourseWorkRequest>( |
| request_sender, course_id, page_token, |
| base::BindOnce( |
| &GlanceablesClassroomClientImpl::OnCourseWorkPageFetched, |
| weak_factory_.GetWeakPtr(), course_id, std::move(callback)))); |
| } |
| |
| void GlanceablesClassroomClientImpl::OnCourseWorkPageFetched( |
| const std::string& course_id, |
| FetchCourseWorkCallback callback, |
| base::expected<std::unique_ptr<CourseWork>, ApiErrorCode> result) { |
| CHECK(!course_id.empty()); |
| CHECK(callback); |
| |
| const auto iter = course_work_.find(course_id); |
| |
| if (!result.has_value()) { |
| // TODO(b/282013130): handle failures of a single page fetch request more |
| // gracefully (retry and/or reflect errors on UI). |
| iter->second.clear(); |
| std::move(callback).Run(iter->second); |
| return; |
| } |
| |
| for (const auto& item : result.value()->items()) { |
| if (item->state() == CourseWorkItem::State::kPublished) { |
| iter->second.push_back( |
| std::make_unique<GlanceablesClassroomCourseWorkItem>( |
| item->id(), item->title(), item->alternate_link(), |
| ConvertCourseWorkItemDue(item->due_date_time()))); |
| } |
| } |
| |
| if (result.value()->next_page_token().empty()) { |
| std::move(callback).Run(iter->second); |
| } else { |
| FetchCourseWorkPage(course_id, result.value()->next_page_token(), |
| std::move(callback)); |
| } |
| } |
| |
| void GlanceablesClassroomClientImpl::FetchStudentSubmissionsPage( |
| const std::string& course_id, |
| const std::string& page_token, |
| FetchStudentSubmissionsCallback callback) { |
| CHECK(!course_id.empty()); |
| CHECK(callback); |
| |
| auto* const request_sender = GetRequestSender(); |
| request_sender->StartRequestWithAuthRetry( |
| std::make_unique<ListStudentSubmissionsRequest>( |
| request_sender, course_id, kAllStudentSubmissionsParameterValue, |
| page_token, |
| base::BindOnce( |
| &GlanceablesClassroomClientImpl::OnStudentSubmissionsPageFetched, |
| weak_factory_.GetWeakPtr(), course_id, std::move(callback)))); |
| } |
| |
| void GlanceablesClassroomClientImpl::OnStudentSubmissionsPageFetched( |
| const std::string& course_id, |
| FetchStudentSubmissionsCallback callback, |
| base::expected<std::unique_ptr<StudentSubmissions>, ApiErrorCode> result) { |
| CHECK(!course_id.empty()); |
| CHECK(callback); |
| |
| const auto iter = student_submissions_.find(course_id); |
| |
| if (!result.has_value()) { |
| // TODO(b/282013130): handle failures of a single page fetch request more |
| // gracefully (retry and/or reflect errors on UI). |
| iter->second.clear(); |
| std::move(callback).Run(iter->second); |
| return; |
| } |
| |
| for (const auto& item : result.value()->items()) { |
| iter->second.push_back( |
| std::make_unique<GlanceablesClassroomStudentSubmission>( |
| item->id(), item->course_work_id(), |
| CalculateStudentSubmissionState(item))); |
| } |
| |
| if (result.value()->next_page_token().empty()) { |
| std::move(callback).Run(iter->second); |
| } else { |
| FetchStudentSubmissionsPage(course_id, result.value()->next_page_token(), |
| std::move(callback)); |
| } |
| } |
| |
| void GlanceablesClassroomClientImpl::OnStudentDataFetched() { |
| student_data_fetch_status_ = FetchStatus::kFetched; |
| for (auto& cb : callbacks_waiting_for_student_data_) { |
| std::move(cb).Run(); |
| } |
| } |
| |
| void GlanceablesClassroomClientImpl::GetFilteredStudentAssignments( |
| base::RepeatingCallback<bool(const absl::optional<base::Time>&)> |
| due_predicate, |
| base::RepeatingCallback<bool(GlanceablesClassroomStudentSubmission::State)> |
| submission_state_predicate, |
| GetStudentAssignmentsCallback callback) { |
| CHECK(due_predicate); |
| CHECK(submission_state_predicate); |
| CHECK(callback); |
| |
| std::vector<std::unique_ptr<GlanceablesClassroomStudentAssignment>> |
| filtered_assignments; |
| |
| for (const auto& course : student_courses_) { |
| const auto course_work_iter = course_work_.find(course->id); |
| const auto submissions_iter = student_submissions_.find(course->id); |
| if (course_work_iter == course_work_.end() || |
| submissions_iter == student_submissions_.end()) { |
| continue; |
| } |
| |
| const auto submissions = |
| GroupStudentSubmissionsByCourseWorkId(submissions_iter->second); |
| |
| for (const auto& course_work_item : course_work_iter->second) { |
| if (!due_predicate.Run(course_work_item->due)) { |
| continue; |
| } |
| |
| const auto submission_iter = submissions.find(course_work_item->id); |
| if (submission_iter == submissions.end()) { |
| continue; |
| } |
| |
| // There should be only one iteration, because course work item and |
| // student submission have 1:1 relationship for students. |
| for (const auto* const submission : submission_iter->second) { |
| if (!submission_state_predicate.Run(submission->state)) { |
| continue; |
| } |
| |
| filtered_assignments.push_back( |
| std::make_unique<GlanceablesClassroomStudentAssignment>( |
| course->name, course_work_item->title, course_work_item->link, |
| course_work_item->due)); |
| } |
| } |
| } |
| |
| std::move(callback).Run(std::move(filtered_assignments)); |
| } |
| |
| RequestSender* GlanceablesClassroomClientImpl::GetRequestSender() { |
| if (!request_sender_) { |
| CHECK(create_request_sender_callback_); |
| request_sender_ = |
| std::move(create_request_sender_callback_) |
| .Run( |
| {GaiaConstants::kClassroomReadOnlyCoursesOAuth2Scope, |
| GaiaConstants::kClassroomReadOnlyCourseWorkSelfOAuth2Scope, |
| GaiaConstants::kClassroomReadOnlyCourseWorkStudentsOAuth2Scope, |
| GaiaConstants:: |
| kClassroomReadOnlyStudentSubmissionsSelfOAuth2Scope, |
| GaiaConstants:: |
| kClassroomReadOnlyStudentSubmissionsStudentsOAuth2Scope}, |
| kTrafficAnnotationTag); |
| CHECK(request_sender_); |
| } |
| return request_sender_.get(); |
| } |
| |
| } // namespace ash |