/* TODO (andgein): move it to GroupController */ public async Task <bool> CanRevokeAccessAsync(int groupId, string userId, string revokedById) { var group = await groupsRepo.FindGroupByIdAsync(groupId).ConfigureAwait(false) ?? throw new ArgumentNullException($"Can't find group with id={groupId}"); var isCourseAdmin = await courseRolesRepo.HasUserAccessToCourse(revokedById, @group.CourseId, CourseRoleType.CourseAdmin).ConfigureAwait(false); if (group.OwnerId == revokedById || isCourseAdmin) { return(true); } return(db.GroupAccesses.Any(a => a.GroupId == groupId && a.UserId == userId && a.GrantedById == revokedById && a.IsEnabled)); }
public async Task <ActionResult <TempCourseUpdateResponse> > CreateCourse([FromRoute] string courseId) { var tmpCourseId = GetTmpCourseId(courseId, UserId); if (!DontCheckBaseCourseExistsOnCreate && !await courseManager.HasCourseAsync(courseId)) { return(new TempCourseUpdateResponse { ErrorType = ErrorType.NotFound, Message = $"Не существует курса {courseId}" }); } if (!await courseRolesRepo.HasUserAccessToCourse(UserId, courseId, CourseRoleType.CourseAdmin)) { return(new TempCourseUpdateResponse { ErrorType = ErrorType.Forbidden, Message = $"Необходимо быть администратором курса {courseId}" }); } var tmpCourse = await tempCoursesRepo.FindAsync(tmpCourseId); if (tmpCourse != null) { return(new TempCourseUpdateResponse { ErrorType = ErrorType.Conflict, Message = $"Ваша временная версия курса {courseId} уже существует с id {tmpCourseId}." }); } var versionId = Guid.NewGuid(); var courseTitle = "Заготовка временного курса"; if (!courseManager.TryCreateTempCourse(tmpCourseId, courseTitle, versionId)) { throw new Exception(); } var result = await tempCoursesRepo.AddTempCourseAsync(tmpCourseId, UserId); var loadingTime = result.LoadingTime; await courseRolesRepo.ToggleRole(tmpCourseId, UserId, CourseRoleType.CourseAdmin, UserId, "Создал временный курс"); return(new TempCourseUpdateResponse { Message = $"Временный курс с id {tmpCourseId} успешно создан.", LastUploadTime = loadingTime }); }
public async Task <ActionResult <ApiSlideInfo> > SlideInfo([FromRoute] Course course, [FromRoute] Guid slideId) { var isInstructor = await courseRolesRepo.HasUserAccessToCourse(User.GetUserId(), course.Id, CourseRoleType.Instructor).ConfigureAwait(false); var slide = course?.FindSlideById(slideId, isInstructor); if (slide == null) { var instructorNote = course?.FindInstructorNoteById(slideId); if (instructorNote != null && isInstructor) { slide = instructorNote.Slide; } } if (slide == null) { return(NotFound(new { status = "error", message = "Course or slide not found" })); } var userId = User.GetUserId(); var getSlideMaxScoreFunc = await BuildGetSlideMaxScoreFunc(solutionsRepo, userQuizzesRepo, visitsRepo, groupsRepo, course, userId); var getGitEditLinkFunc = await BuildGetGitEditLinkFunc(userId, course, courseRolesRepo, coursesRepo); var baseUrl = CourseUnitUtils.GetDirectoryRelativeWebPath(slide.Info.SlideFile); var slideRenderContext = new SlideRenderContext(course.Id, slide, UserId, baseUrl, !isInstructor, course.Settings.VideoAnnotationsGoogleDoc, Url); return(await slideRenderer.BuildSlideInfo(slideRenderContext, getSlideMaxScoreFunc, getGitEditLinkFunc)); }
public async Task <Comment> AddCommentAsync(string authorId, string courseId, Guid slideId, int parentCommentId, bool isForInstructorsOnly, string commentText) { var commentsPolicy = await commentPoliciesRepo.GetCommentsPolicyAsync(courseId).ConfigureAwait(false); var isInstructor = await courseRolesRepo.HasUserAccessToCourse(authorId, courseId, CourseRoleType.Instructor).ConfigureAwait(false); var isApproved = commentsPolicy.ModerationPolicy == CommentModerationPolicy.Postmoderation || isInstructor; /* Instructors' replies are automatically correct */ var isReply = parentCommentId != -1; var isCorrectAnswer = isReply && isInstructor && !isForInstructorsOnly; var comment = new Comment { AuthorId = authorId, CourseId = courseId, SlideId = slideId, ParentCommentId = parentCommentId, Text = commentText, IsApproved = isApproved, IsCorrectAnswer = isCorrectAnswer, IsForInstructorsOnly = isForInstructorsOnly, PublishTime = DateTime.Now }; db.Comments.Add(comment); await db.SaveChangesAsync().ConfigureAwait(false); return(await FindCommentByIdAsync(comment.Id).ConfigureAwait(false)); }
public async Task <ActionResult <RunSolutionResponse> > RunSolution( [FromRoute] Course course, [FromRoute] Guid slideId, [FromBody] RunSolutionParameters parameters, [FromQuery] Language language) { var courseId = course.Id; /* Check that no checking solution by this user in last time */ var delta = TimeSpan.FromSeconds(30); var halfMinuteAgo = DateTime.Now.Subtract(delta); if (await userSolutionsRepo.IsCheckingSubmissionByUser(courseId, slideId, User.Identity.GetUserId(), halfMinuteAgo, DateTime.MaxValue)) { return(Json(new RunSolutionResponse(SolutionRunStatus.Ignored) { Message = $"Ваше решение по этой задаче уже проверяется. Дождитесь окончания проверки. Вы можете отправить новое решение через {delta.Seconds} секунд." })); } var code = parameters.Solution; if (code.Length > TextsRepo.MaxTextSize) { return(Json(new RunSolutionResponse(SolutionRunStatus.Ignored) { Message = "Слишком длинный код" })); } var isInstructor = await courseRolesRepo.HasUserAccessToCourse(UserId, courseId, CourseRoleType.Instructor); var exerciseSlide = (await courseManager.FindCourseAsync(courseId))?.FindSlideById(slideId, isInstructor) as ExerciseSlide; if (exerciseSlide == null) { return(NotFound(new ErrorResponse("Slide not found"))); } var result = await CheckSolution( courseId, exerciseSlide, code, language, UserId, User.Identity.Name, waitUntilChecked : true, saveSubmissionOnCompileErrors : false ).ConfigureAwait(false); return(result); }
public async Task <List <Guid> > GetVisibleUnitIds(Course course, string userId) { var canSeeEverything = await courseRolesRepo.HasUserAccessToCourse(userId, course.Id, CourseRoleType.Tester); if (canSeeEverything) { return(course.GetUnitsNotSafe().Select(u => u.Id).ToList()); } return(await GetPublishedUnitIds(course)); }
protected async Task <bool> CanUserSeeNotApprovedCommentsAsync(string userId, string courseId) { if (string.IsNullOrEmpty(userId)) { return(false); } var hasCourseAccessForCommentEditing = await coursesRepo.HasCourseAccess(userId, courseId, CourseAccessType.EditPinAndRemoveComments).ConfigureAwait(false); var isCourseAdmin = await courseRolesRepo.HasUserAccessToCourse(userId, courseId, CourseRoleType.CourseAdmin).ConfigureAwait(false); return(isCourseAdmin || hasCourseAccessForCommentEditing); }
public async Task <IActionResult> ChangeOwner(int groupId, [FromBody] ChangeOwnerParameters parameters) { var group = await groupsRepo.FindGroupByIdAsync(groupId).ConfigureAwait(false); var isCourseAdmin = await courseRolesRepo.HasUserAccessToCourse(UserId, group.CourseId, CourseRoleType.CourseAdmin).ConfigureAwait(false); var canChangeOwner = group.OwnerId == UserId || isCourseAdmin; if (!canChangeOwner) { return(StatusCode((int)HttpStatusCode.Forbidden, new ErrorResponse("You can't change the owner of this group. Only current owner and course admin can change the owner."))); } /* New owner should exist and be a course instructor */ var user = await usersRepo.FindUserById(parameters.OwnerId).ConfigureAwait(false); if (user == null) { return(NotFound(new ErrorResponse($"Can't find user with id {parameters.OwnerId}"))); } var isInstructor = await courseRolesRepo.HasUserAccessToCourse(parameters.OwnerId, group.CourseId, CourseRoleType.Instructor).ConfigureAwait(false); if (!isInstructor) { return(NotFound(new ErrorResponse($"User {parameters.OwnerId} is not an instructor of course {group.CourseId}"))); } /* Grant full access to previous owner */ await groupAccessesRepo.GrantAccessAsync(groupId, group.OwnerId, GroupAccessType.FullAccess, UserId).ConfigureAwait(false); /* Change owner */ await groupsRepo.ChangeGroupOwnerAsync(groupId, parameters.OwnerId).ConfigureAwait(false); /* Revoke access from new owner */ await groupAccessesRepo.RevokeAccessAsync(groupId, parameters.OwnerId).ConfigureAwait(false); return(Ok(new SuccessResponseWithMessage($"New group's owner is {parameters.OwnerId}"))); }
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CourseAccessRequirement requirement) { /* Get MVC context. See https://docs.microsoft.com/en-US/aspnet/core/security/authorization/policies#accessing-mvc-request-context-in-handlers */ if (!(context.Resource is AuthorizationFilterContext mvcContext)) { log.Error("Can't get MVC context in CourseRoleAuthenticationHandler"); context.Fail(); return; } var courseId = GetCourseIdFromRequestAsync(mvcContext); if (string.IsNullOrEmpty(courseId)) { context.Fail(); return; } if (!context.User.Identity.IsAuthenticated) { context.Fail(); return; } var userId = context.User.GetUserId(); var user = await usersRepo.FindUserById(userId).ConfigureAwait(false); if (user == null) { context.Fail(); return; } if (usersRepo.IsSystemAdministrator(user)) { context.Succeed(requirement); return; } var isCourseAdmin = await courseRolesRepo.HasUserAccessToCourse(userId, courseId, CourseRoleType.CourseAdmin).ConfigureAwait(false); if (isCourseAdmin || await coursesRepo.HasCourseAccess(userId, courseId, requirement.CourseAccessType).ConfigureAwait(false)) { context.Succeed(requirement); } else { context.Fail(); } }
public async Task <ActionResult <ReviewCommentResponse> > AddExerciseCodeReviewComment([FromRoute] int reviewId, [FromBody] ReviewCreateCommentParameters parameters) { var review = await slideCheckingsRepo.FindExerciseCodeReviewById(reviewId); var submissionUserId = review.ExerciseCheckingId.HasValue ? review.ExerciseChecking.UserId : review.Submission.UserId; var submissionCourseId = review.ExerciseCheckingId.HasValue ? review.ExerciseChecking.CourseId : review.Submission.CourseId; var isInstructor = await courseRolesRepo.HasUserAccessToCourse(UserId, submissionCourseId, CourseRoleType.Instructor); if (submissionUserId != UserId && !isInstructor) { return(StatusCode((int)HttpStatusCode.Forbidden, new ErrorResponse("You can't comment this review"))); } var canReply = isInstructor || !review.Author.IsUlearnBot() || review.NotDeletedComments.Any(c => !c.Author.IsUlearnBot()); if (!canReply) { return(StatusCode((int)HttpStatusCode.Forbidden, new ErrorResponse("You can't reply this review"))); } var comment = await slideCheckingsRepo.AddExerciseCodeReviewComment(UserId, reviewId, parameters.Text); if (review.ExerciseCheckingId.HasValue && review.ExerciseChecking.IsChecked) { var course = await courseManager.FindCourseAsync(submissionCourseId); var slideId = review.ExerciseChecking.SlideId; var unit = course?.FindUnitBySlideId(slideId, isInstructor); if (unit != null && await unitsRepo.IsUnitVisibleForStudents(course, unit.Id)) { await NotifyAboutCodeReviewComment(comment); } } return(ReviewCommentResponse.Build(comment)); }
public async Task <ActionResult <FlashcardsStatistics> > FlashcardsStatistics([FromQuery(Name = "course_id")][BindRequired] string courseId) { var course = await courseManager.FindCourseAsync(courseId); if (course == null) { return(NotFound()); } var hasUserAccessToCourse = await courseRolesRepo.HasUserAccessToCourse(UserId, course.Id, CourseRoleType.Instructor); if (!hasUserAccessToCourse) { return(BadRequest($"You don't have access to course with id {course.Id}")); } var flashcardVisitsByCourse = await usersFlashcardsVisitsRepo.GetUserFlashcardsVisitsAsync(course.Id); var statistics = ToFlashcardsStatistics(flashcardVisitsByCourse, course); return(statistics); }
public async Task <ActionResult <CourseExercisesStatisticsResponse> > CourseStatistics([FromQuery(Name = "course_id")][BindRequired] string courseId, int count = 10000, DateTime?from = null, DateTime?to = null) { var course = await courseManager.FindCourseAsync(courseId); if (course == null) { return(NotFound()); } if (!from.HasValue) { from = DateTime.MinValue; } if (!to.HasValue) { to = DateTime.MaxValue; } count = Math.Min(count, 10000); var isInstructor = await courseRolesRepo.HasUserAccessToCourse(UserId, course.Id, CourseRoleType.Instructor).ConfigureAwait(false); var exerciseSlides = course.GetSlides(isInstructor).OfType <ExerciseSlide>().ToList(); /* TODO (andgein): I can't select all submissions because ApplicationUserId column doesn't exist in database (ApplicationUser_Id exists). * We should remove this column after EF Core 2.1 release (and remove tuples here) */ var submissions = await userSolutionsRepo.GetAllSubmissions(course.Id, includeManualAndAutomaticCheckings : false) .Where(s => s.Timestamp >= from && s.Timestamp <= to) .OrderByDescending(s => s.Timestamp) .Take(count) .Select(s => Tuple.Create(s.SlideId, s.AutomaticCheckingIsRightAnswer, s.Timestamp)) .ToListAsync().ConfigureAwait(false); var getSlideMaxScoreFunc = await BuildGetSlideMaxScoreFunc(solutionsRepo, userQuizzesRepo, visitsRepo, groupsRepo, course, User.GetUserId()); var getGitEditLinkFunc = await BuildGetGitEditLinkFunc(User.GetUserId(), course, courseRolesRepo, coursesRepo); const int daysLimit = 30; var result = new CourseExercisesStatisticsResponse { AnalyzedSubmissionsCount = submissions.Count, Exercises = exerciseSlides.Select( slide => { /* Statistics for this exercise slide: */ var exerciseSubmissions = submissions.Where(s => s.Item1 == slide.Id).ToList(); return(new OneExerciseStatistics { Exercise = slideRenderer.BuildShortSlideInfo(course.Id, slide, getSlideMaxScoreFunc, getGitEditLinkFunc, Url), SubmissionsCount = exerciseSubmissions.Count, AcceptedCount = exerciseSubmissions.Count(s => s.Item2), /* Select last 30 (`datesLimit`) dates */ LastDates = exerciseSubmissions.GroupBy(s => s.Item3.Date).OrderByDescending(g => g.Key).Take(daysLimit).ToDictionary( /* Date: */ g => g.Key, /* Statistics for this date: */ g => new OneExerciseStatisticsForDate { SubmissionsCount = g.Count(), AcceptedCount = g.Count(s => s.Item2) } ) }); }).ToList() }; return(result); }
public async Task <ActionResult <UsersSearchResponse> > Search([FromQuery] UsersSearchParameters parameters) { var words = parameters.Query?.Split(' ', '\t').ToList() ?? new List <string>(); if (words.Count > 10) { return(BadRequest(new ErrorResponse("Too many words in query"))); } var currentUser = await usersRepo.FindUserById(UserId).ConfigureAwait(false); var isSystemAdministrator = usersRepo.IsSystemAdministrator(currentUser); if (!string.IsNullOrEmpty(parameters.CourseId)) { if (!parameters.CourseRoleType.HasValue) { return(BadRequest(new ErrorResponse("You should specify course_role with course_id"))); } if (parameters.CourseRoleType == CourseRoleType.Student) { return(BadRequest(new ErrorResponse("You can not search students by this method: there are too many students"))); } /* Only instructors can search by course role */ var isInstructor = await courseRolesRepo.HasUserAccessToCourse(UserId, parameters.CourseId, CourseRoleType.Instructor).ConfigureAwait(false); if (!isInstructor) { return(StatusCode((int)HttpStatusCode.Unauthorized, new ErrorResponse("Only instructors can search by course role"))); } } else if (parameters.CourseRoleType.HasValue) { /* Only sys-admins can search all instructors or all course-admins */ if (!isSystemAdministrator) { return(StatusCode((int)HttpStatusCode.Unauthorized, new ErrorResponse("Only system administrator can search by course role without specified course_id"))); } } if (parameters.LmsRoleType.HasValue) { if (!isSystemAdministrator) { return(StatusCode((int)HttpStatusCode.Unauthorized, new ErrorResponse("Only system administrator can search by lms role"))); } } var request = new UserSearchRequest { CurrentUser = currentUser, Words = words, CourseId = parameters.CourseId, MinCourseRoleType = parameters.CourseRoleType, LmsRole = parameters.LmsRoleType, }; /* Start the search! * First of all we will try to find `strict` users: users with strict match for pattern. These users will be at first place in the response. */ var strictUsers = await userSearcher.SearchUsersAsync(request, strict : true, offset : 0, count : parameters.Offset + parameters.Count).ConfigureAwait(false); var users = strictUsers.ToList(); /* If strict users count is enough for answer, just take needed piece of list */ if (users.Count >= parameters.Offset + parameters.Count) { users = users.Skip(parameters.Offset).Take(parameters.Count).ToList(); } else { /* If there is part of strict users which we should return, then cut off it */ if (parameters.Offset < users.Count) { users = users.Skip(parameters.Offset).ToList(); } else { users.Clear(); } /* * (strict users) (non-strict users) * 0 1 2 3 4 5 6 * ^ ^ * offset offset+count */ var nonStrictUsers = await userSearcher.SearchUsersAsync(request, strict : false, offset : parameters.Offset - strictUsers.Count, count : parameters.Count - users.Count).ConfigureAwait(false); /* Add all non-strict users if there is no this user in strict users list */ foreach (var user in nonStrictUsers) { var alreadyExistUser = strictUsers.FirstOrDefault(u => u.User.Id == user.User.Id); if (alreadyExistUser != null) { alreadyExistUser.Fields.UnionWith(user.Fields); } else { users.Add(user); } } } var instructors = await courseRolesRepo.GetListOfUsersWithCourseRole(CourseRoleType.Instructor, null, true).ConfigureAwait(false); var currentUserIsInstructor = instructors.Contains(User.GetUserId()); return(new UsersSearchResponse { Users = users.Select(u => new FoundUserResponse { User = BuildShortUserInfo(u.User, discloseLogin: u.Fields.Contains(SearchField.Login) || currentUserIsInstructor && instructors.Contains(u.User.Id), discloseEmail: u.Fields.Contains(SearchField.Email)), Fields = u.Fields.ToList(), }).ToList(), Pagination = new PaginationResponse { Offset = parameters.Offset, Count = users.Count, } }); }
public async Task <ActionResult <UsersProgressResponse> > UserProgress([FromRoute] string courseId, [FromBody] UserProgressParameters parameters) { if (!await courseManager.HasCourseAsync(courseId)) { return(NotFound(new ErrorResponse($"Course {courseId} not found"))); } var course = await courseManager.FindCourseAsync(courseId); var userIds = parameters.UserIds; if (userIds == null || userIds.Count == 0) { userIds = new List <string> { UserId } } ; else { var userIdsWithProgressNotVisibleForUser = await GetUserIdsWithProgressNotVisibleForUser(course.Id, userIds); if (userIdsWithProgressNotVisibleForUser?.Any() ?? false) { var userIdsStr = string.Join(", ", userIdsWithProgressNotVisibleForUser); return(NotFound(new ErrorResponse($"Users {userIdsStr} not found"))); } } var isInstructor = await courseRolesRepo.HasUserAccessToCourse(UserId, courseId, CourseRoleType.Instructor).ConfigureAwait(false); var visibleSlides = course.GetSlides(isInstructor).Select(s => s.Id).ToHashSet(); var scores = await visitsRepo.GetScoresForSlides(course.Id, userIds); var visitsTimestamps = await visitsRepo.GetLastVisitsInCourse(course.Id, UserId); var additionalScores = await GetAdditionalScores(course.Id, userIds).ConfigureAwait(false); var attempts = await userQuizzesRepo.GetUsedAttemptsCountAsync(course.Id, userIds).ConfigureAwait(false); var waitingQuizSlides = await userQuizzesRepo.GetSlideIdsWaitingForManualCheckAsync(course.Id, userIds).ConfigureAwait(false); var waitingExerciseSlides = await slideCheckingsRepo.GetSlideIdsWaitingForManualExerciseCheckAsync(course.Id, userIds).ConfigureAwait(false); var prohibitFurtherManualCheckingSlides = await slideCheckingsRepo.GetProhibitFurtherManualCheckingSlides(course.Id, userIds).ConfigureAwait(false); var skippedSlides = await visitsRepo.GetSkippedSlides(course.Id, userIds); var usersProgress = new Dictionary <string, UserProgress>(); foreach (var userId in scores.Keys) { var visitedSlides = scores[userId] .Where(kvp => visibleSlides.Contains(kvp.Key)) .ToDictionary(kvp => kvp.Key, kvp => new UserProgressSlideResult { Visited = true, Timestamp = visitsTimestamps.TryGetValue(kvp.Key, out var visit) ? visit.Timestamp : null, Score = kvp.Value, IsSkipped = skippedSlides.GetValueOrDefault(userId)?.Contains(kvp.Key) ?? false, UsedAttempts = attempts.GetValueOrDefault(userId)?.GetValueOrDefault(kvp.Key) ?? 0, WaitingForManualChecking = (waitingExerciseSlides.GetValueOrDefault(userId)?.Contains(kvp.Key) ?? false) || (waitingQuizSlides.GetValueOrDefault(userId)?.Contains(kvp.Key) ?? false), ProhibitFurtherManualChecking = prohibitFurtherManualCheckingSlides.GetValueOrDefault(userId)?.Contains(kvp.Key) ?? false });
public async Task <ActionResult> ExportGroupMembersAsTsv([Required] int groupId, Guid?quizSlideId = null) { var group = await groupsRepo.FindGroupByIdAsync(groupId).ConfigureAwait(false); if (group == null) { return(StatusCode((int)HttpStatusCode.NotFound, "Group not found")); } var isSystemAdministrator = await IsSystemAdministratorAsync().ConfigureAwait(false); var isCourseAdmin = await courseRolesRepo.HasUserAccessToCourse(UserId, group.CourseId, CourseRoleType.CourseAdmin).ConfigureAwait(false); if (!(isSystemAdministrator || isCourseAdmin)) { return(StatusCode((int)HttpStatusCode.Forbidden, "You should be course or system admin")); } var users = await groupMembersRepo.GetGroupMembersAsUsersAsync(groupId).ConfigureAwait(false); var extendedUserInfo = await GetExtendedUserInfo(users).ConfigureAwait(false); List <string> questions = null; var courseId = group.CourseId; var course = await courseManager.GetCourseAsync(courseId); if (quizSlideId != null) { var slide = course.FindSlideById(quizSlideId.Value, false); if (slide == null) { return(StatusCode((int)HttpStatusCode.NotFound, $"Slide not found in course {courseId}")); } if (!(slide is QuizSlide quizSlide)) { return(StatusCode((int)HttpStatusCode.NotFound, $"Slide is not quiz slide in course {courseId}")); } List <List <string> > answers; (questions, answers) = await GetQuizAnswers(users, courseId, quizSlide).ConfigureAwait(false); extendedUserInfo = extendedUserInfo.Zip(answers, (u, a) => { u.Answers = a; return(u); }).ToList(); } var slides = course.GetSlides(false).Where(s => s.ShouldBeSolved).Select(s => s.Id).ToList(); var scores = GetScoresByScoringGroups(users.Select(u => u.Id).ToList(), slides, course); var scoringGroupsWithScores = scores.Select(kvp => kvp.Key.ScoringGroup).ToHashSet(); var scoringGroups = course.Settings.Scoring.Groups.Values.Where(sg => scoringGroupsWithScores.Contains(sg.Id)).ToList(); var headers = new List <string> { "Id", "Login", "Email", "FirstName", "LastName", "VisibleName", "Gender", "LastVisit", "IpAddress" }; if (questions != null) { headers = headers.Concat(questions).ToList(); } if (scoringGroups.Count > 0) { headers = headers.Concat(scoringGroups.Select(s => s.Abbreviation)).ToList(); } var rows = new List <List <string> > { headers }; foreach (var i in extendedUserInfo) { var row = new List <string> { i.Id, i.Login, i.Email, i.FirstName, i.LastName, i.VisibleName, i.Gender.ToString(), i.LastVisit.ToSortableDate(), i.IpAddress }; if (i.Answers != null) { row = row.Concat(i.Answers).ToList(); } row.AddRange(scoringGroups.Select(scoringGroup => (scores.ContainsKey((i.Id, scoringGroup.Id)) ? scores[(i.Id, scoringGroup.Id)] : 0).ToString()));