Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE-97] 지원 현황 및 면접 기록 조회 API 정렬 로직 개선, 검색 쿼리 추가 #274

Merged
merged 20 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1eedccd
[feat]: 지원 현황 검색과 조회로직 통합 및 DB레벨 정렬 적용
Profile-exe Sep 18, 2024
d168a5f
[refactor]: 재사용되는 로직 메서드로 추출
Profile-exe Sep 18, 2024
3f06ec4
[fix]: 데이터가 없는 경우 sortHelper의 IndexOutOfBoundsException 방지를 위한 분기 추가
Profile-exe Sep 18, 2024
e27fc6f
[refactor]: 알파벳 역순으로 순서 수정
Profile-exe Sep 21, 2024
9a05ba9
[feat]: AnswerAdaptor 검색과 정렬이 적용된 면접 기록 지원자 조회 구현
Profile-exe Sep 22, 2024
7500caa
[feat]: AnswerService 면접 기록 지원자 조회 구현
Profile-exe Sep 22, 2024
e21cb07
[feat]: AnswerService 면접 기록 지원자 페이지 정보 구현
Profile-exe Sep 22, 2024
f28b145
[feat]: 면접 기록 조회 API 검색 적용 및 정렬 개선
Profile-exe Sep 22, 2024
9a73ce8
[feat]: 페이징 없는 지원자 조회 구현
Profile-exe Sep 23, 2024
005ddc3
[feat]: 어플리케이션 레벨 페이징 유틸 함수 구현
Profile-exe Sep 23, 2024
1bbc0c0
[fix]: 점수 기준 정렬 오류 해결
Profile-exe Sep 23, 2024
6a12907
[refactor]: 검색 쿼리 파라미터 required를 false로 변경
Profile-exe Sep 23, 2024
2600682
[fix]: pageInfo가 올바른 totalCount를 가지도록 수정
Profile-exe Sep 23, 2024
9591722
[fix]: interviewer가 없는 경우 NPE 방지를 위한 분기 처리 추가
Profile-exe Sep 23, 2024
b395075
[style]: spotless
Profile-exe Sep 23, 2024
b4dc8b6
[refactor]: RecordsViewResponseDto 내부 정적 팩토리 메서드 구현
Profile-exe Sep 23, 2024
4b0bf42
[refactor]: getScoreMap 메서드 calculateAverageScoresByApplicant 로 이름 변경
Profile-exe Sep 23, 2024
a613304
[refactor]: 면접 기록 조회 공통로직 메서드로 추출 및 if 분기 하나로 통합
Profile-exe Sep 23, 2024
dad5f5a
[refactor]: 기수별 면접기록 필터링 로직 메서드로 추출
Profile-exe Sep 23, 2024
09c1d32
[style]: spotless
Profile-exe Sep 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ public ResponseEntity<Map<String, Object>> getApplicantById(String applicantId)
public ResponseEntity<AnswersResponseDto> getApplicantsByYear(
@PathVariable(value = "year") Integer year,
@PathVariable(value = "page") Integer page,
@ParameterObject String order) {
AnswersResponseDto result = applicantQueryUseCase.execute(year, page, order);
@ParameterObject String order,
@RequestParam(required = false) String searchKeyword) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 searchKeyword가 null일 경우에는 전체 조회를 하기 위한 용도인 거죠?

그리고 혹시 @ParameterObject 대신 @RequestParam을 사용하신 이유가 따로 있을까요? ㅎㅎ

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ParameterObject를 사용하면 swagger에서 required 필드로 보여 필수로 입력해야 한다고 인지됩니다!
그래서 @RequestParamrequired 값을 false로 두어 필수 값이 아님을 명시했습니다.

AnswersResponseDto result = applicantQueryUseCase.execute(year, page, order, searchKeyword);
return new ResponseEntity<>(result, HttpStatus.OK);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,44 @@ public Map<String, Object> execute(String answerId) {
return qna;
}

@Transactional(readOnly = true)
public AnswersResponseDto execute(Integer year, Integer page, String sortType, String searchKeyword) {
PageInfo pageInfo = getPageInfo(year, page, searchKeyword);
List<MongoAnswer> sortedResult = answerAdaptor.findByYearAndSearchKeyword(year, page, sortType, searchKeyword);

List<Map<String, Object>> qnaMapList = getQnaMapListWithIdAndPassState(sortedResult);

if (qnaMapList.isEmpty()) {
return AnswersResponseDto.of(Collections.emptyList(), pageInfo);
}
return AnswersResponseDto.of(qnaMapList, pageInfo);
}

private List<Map<String, Object>> getQnaMapListWithIdAndPassState(List<MongoAnswer> sortedResult) {
return sortedResult.stream().map(answer -> {
Map<String, Object> qna = answer.getQna();
qna.put("id", answer.getId());
qna.put(PASS_STATE_KEY, answer.getApplicantStateOrDefault());
return qna;
}).toList();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getQnaMapListWithIdAndPassState 를 따로 나눈 이유가
id랑 pass_state_key만 따로 넣을 수 도 있어서 분리 한 것일까요? (정렬 없이)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞습니다. qna 맵을 추출하고 여기에 idpassState를 넣기 때문에 메서드로 분리했습니다.


@Override
public PageInfo getPageInfo(Integer year, Integer page, String searchKeyword) {
long totalCount = answerAdaptor.getTotalCountByYearAndSearchKeyword(year, searchKeyword);
return new PageInfo(totalCount, page);
}

@Transactional(readOnly = true)
public List<MongoAnswer> execute(Integer page, Integer year, String sortType, String searchKeyword, List<String> applicantIds) {
return answerAdaptor.findByYearAndSearchKeywordAndApplicantIds(page, year, sortType, searchKeyword, applicantIds);
}

@Override
public List<MongoAnswer> execute(Integer year, String sortType, String searchKeyword, List<String> applicantIds) {
return answerAdaptor.findByYearAndSearchKeywordAndApplicantIds(year, sortType, searchKeyword, applicantIds);
}

@Transactional(readOnly = true)
public AnswersResponseDto execute(Integer year, Integer page, String sortType) {
PageInfo pageInfo = getPageInfo(year, page);
Expand Down Expand Up @@ -182,13 +220,9 @@ public List<GetApplicantsStatusResponse> getApplicantsStatus(Integer year, Strin
}

private List<Map<String, Object>> sortAndAddIds(List<MongoAnswer> result, String sortType) {
sortHelper.sort(result, sortType);
return result.stream().map(
answer -> {
Map<String, Object> qna = answer.getQna();
qna.put("id", answer.getId());
qna.put(PASS_STATE_KEY, answer.getApplicantStateOrDefault());
return qna;
}).toList();
if (!result.isEmpty()) {
sortHelper.sort(result, sortType);
}
return getQnaMapListWithIdAndPassState(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.econovation.recruit.api.applicant.dto.AnswersResponseDto;
import com.econovation.recruit.api.applicant.dto.GetApplicantsStatusResponse;
import com.econovation.recruit.utils.vo.PageInfo;
import com.econovation.recruitcommon.annotation.UseCase;
import com.econovation.recruitdomain.domains.applicant.domain.MongoAnswer;
import java.util.List;
Expand All @@ -11,7 +12,13 @@
public interface ApplicantQueryUseCase {
Map<String, Object> execute(String applicantId);

AnswersResponseDto execute(Integer year, Integer page, String order);
AnswersResponseDto execute(Integer year, Integer page, String order, String searchKeyword);

List<MongoAnswer> execute(Integer page, Integer year, String sortType, String searchKeyword, List<String> applicantIds);

List<MongoAnswer> execute(Integer year, String sortType, String searchKeyword, List<String> applicantIds);

PageInfo getPageInfo(Integer year, Integer page, String searchKeyword);

List<Map<String, Object>> execute();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ public List<InterviewerResponseDto> findAll() {
@Override
public List<InterviewerResponseDto> findAll(String sortType) {
List<Interviewer> interviewers = interviewerLoadPort.findAll();
interviewerSortHelper.sort(interviewers, sortType);
if (!interviewers.isEmpty()) {
interviewerSortHelper.sort(interviewers, sortType);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interviewers가 null 이라면 즉, 빈 배열이라면 아래 return return interviewers.stream().map(InterviewerResponseDto::from).toList(); 에서 NPE가 발생 할 것 같은데
이 부분 예외 처리를 따로 하면 좋을 것 같아요

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

실제로 지금 상황에서 interviewer 가 아무도 없을 때 500에러가 떠서 운영 환경에서 사용자는 아무 액션을 받지못함 (FE가 처리를 못하니까)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다! 해당 부분은 9591722 커밋에 반영했습니다.

@Override
public List<InterviewerResponseDto> findAll(String sortType) {
    List<Interviewer> interviewers = interviewerLoadPort.findAll();
    if (interviewers != null && !interviewers.isEmpty()) {
        interviewerSortHelper.sort(interviewers, sortType);
        return interviewers.stream().map(InterviewerResponseDto::from).toList();
    }
    return Collections.emptyList();
}

return interviewers.stream().map(InterviewerResponseDto::from).toList();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/")
@Tag(name = "[6.0] Record API", description = "면접 기록 Record API")
@RequiredArgsConstructor
public class RecordController {

private final RecordUseCase recordUseCase;

@Operation(summary = "지원자의 면접기록을 생성합니다")
Expand All @@ -55,8 +57,11 @@ public ResponseEntity<RecordResponseDto> findByApplicantId(String applicantId) {
@ApiErrorExceptionsExample(RecordFindExceptionDocs.class)
@GetMapping("/page/{page}/records")
public ResponseEntity<RecordsViewResponseDto> findAll(
@PathVariable(name = "page") Integer page, @ParameterObject String sortType, @ParameterObject Integer year) {
return new ResponseEntity<>(recordUseCase.execute(page, year, sortType), HttpStatus.OK);
@PathVariable(name = "page") Integer page,
@ParameterObject String order,
@ParameterObject Integer year,
@RequestParam(required = false) String searchKeyword) {
return new ResponseEntity<>(recordUseCase.execute(page, year, order, searchKeyword), HttpStatus.OK);
}

@Operation(summary = "지원자의 면접기록을 전부 조회합니다")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.econovation.recruit.api.record.service;

import static com.econovation.recruit.utils.sort.SortHelper.paginateList;

import com.econovation.recruit.api.applicant.usecase.ApplicantQueryUseCase;
import com.econovation.recruit.api.record.dto.RecordsViewResponseDto;
import com.econovation.recruit.api.record.usecase.RecordUseCase;
Expand All @@ -16,8 +18,13 @@
import com.econovation.recruitdomain.out.RecordLoadPort;
import com.econovation.recruitdomain.out.RecordRecordPort;
import com.econovation.recruitdomain.out.ScoreLoadPort;

import java.util.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -26,6 +33,7 @@
@Service
@RequiredArgsConstructor
public class RecordService implements RecordUseCase {

private final RecordRecordPort recordRecordPort;
private final RecordLoadPort recordLoadPort;
private final ScoreLoadPort scoreLoadPort;
Expand Down Expand Up @@ -53,71 +61,153 @@ public List<Record> findAll() {
/**
* 1. Newest // 시간 순 오름차순 2. Name // 이름순 오름차순 3. Object // 지원 분야별 오름차순 4. Score // 점수 내림차순
*
* @return List<RecordResponseDto> // 지원자의 면접기록을 페이지별로 조회합니다 ( 이 화면에서는 Applicants,Scores,
* Records를 모두 조회합니다 )
* @return List<RecordResponseDto> // 지원자의 면접기록을 페이지별로 조회합니다 ( 이 화면에서는 Applicants,Scores, Records를 모두 조회합니다 )
*/
@Override
public RecordsViewResponseDto execute(Integer page, Integer year, String sortType) {
List<Record> result = recordLoadPort.findAll(page);
PageInfo pageInfo = getPageInfo(page);

List<String> applicantIds = result.stream().map(Record::getApplicantId).toList();
List<Score> scores = scoreLoadPort.findByApplicantIds(applicantIds);
List<MongoAnswer> applicants = applicantQueryUseCase.execute(applicantIds).stream().filter(applicant ->year == null || applicant.getYear().equals(year)).toList();
List<MongoAnswer> applicants = applicantQueryUseCase.execute(applicantIds).stream()
.filter(applicant -> year == null || applicant.getYear().equals(year)).toList();

if (result.isEmpty() || applicants.isEmpty()) {
return RecordsViewResponseDto.of(
pageInfo,
Collections.emptyList(),
Collections.emptyMap(),
Collections.emptyList());
return createEmptyResponse(pageInfo);
}

Map<String, Integer> yearByAnswerIdMap = applicants.stream().collect(Collectors.toMap(MongoAnswer::getId, MongoAnswer::getYear));
Map<String, Double> scoreMap =
scores.stream()
.filter(score ->year == null || yearByAnswerIdMap.get(score.getApplicantId()).equals(year))
.collect(
Collectors.groupingBy(
Score::getApplicantId,
Collectors.averagingDouble(Score::getScore)));
Map<String, Double> scoreMap = getScoreMap(year, applicantIds, yearByAnswerIdMap);

result = result.stream().filter(record -> year == null ||
Optional.ofNullable(record.getApplicantId())
.map(yearByAnswerIdMap::get)
.map(y -> y.equals(year))
.orElse(false)
)
.toList();
Optional.ofNullable(record.getApplicantId())
.map(yearByAnswerIdMap::get)
.map(y -> y.equals(year))
.orElse(false)
)
.toList();

applicants = new ArrayList<>(applicants); // Unmodifiable List일 경우 Sort 불가. stream().toList()의 결과는 Unmodifiable List

List<Record> records;
if (sortType.equals("score")) {
List<Record> records = sortRecordsByScoresDesc(result, scoreMap);
return RecordsViewResponseDto.of(pageInfo, records, scoreMap, applicants);
records = sortRecordsByScoresDesc(result, scoreMap);
} else {
List<Record> records = sortRecordsByApplicantsAndSortType(result, applicants, sortType);
return RecordsViewResponseDto.of(pageInfo, records, scoreMap, applicants);
records = sortRecordsByApplicantsAndSortType(result, applicants, sortType);
}

return RecordsViewResponseDto.of(pageInfo, records, scoreMap, applicants);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇 이 부분은 제가 이슈로 작성한 BE-103 내용이긴 한데, 명덕님께서 해결해주셨네요!

BE-103 이슈는 제가 당장 해결하지 못할 것 같아서 Assignee를 저로 하지는 않았지만 명덕님께서 해결하셨다면

해결하신 이슈에 Assignee 설정 부탁드리겠습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assignee 설정 완료했습니다!


@Override
public RecordsViewResponseDto execute(Integer page, Integer year, String sortType, String searchKeyword) {
List<Record> result = recordLoadPort.findAll();
List<String> applicantIds = result.stream().map(Record::getApplicantId).toList();

List<MongoAnswer> applicants;
if (sortType.equals("score")) {
applicants = applicantQueryUseCase.execute(year, sortType, searchKeyword, applicantIds);
} else {
applicants = applicantQueryUseCase.execute(page, year, sortType, searchKeyword, applicantIds);
}

if (result.isEmpty() || applicants.isEmpty()) {
return createEmptyResponse(new PageInfo(0, page));
}

Map<String, Integer> yearByAnswerIdMap = applicants.stream().collect(Collectors.toMap(MongoAnswer::getId, MongoAnswer::getYear));

applicantIds = applicants.stream().map(MongoAnswer::getId).toList(); // 검색 결과에 따라 applicantIds 재할당
Map<String, Double> scoreMap = getScoreMap(year, applicantIds, yearByAnswerIdMap);

result = result.stream().filter(record -> year == null ||
Optional.ofNullable(record.getApplicantId())
.map(yearByAnswerIdMap::get)
.map(y -> y.equals(year))
.orElse(false)
)
.toList();

List<Record> records;
if (sortType.equals("score")) {
records = sortRecordsByScoresDesc(result, scoreMap, page);
} else {
records = sortRecordsByApplicantsAndSortType(result, applicants);
}

PageInfo pageInfo = applicantQueryUseCase.getPageInfo(year, page, searchKeyword);
return RecordsViewResponseDto.of(pageInfo, records, scoreMap, applicants);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에 sortType이 score인걸 확인하고 있는데 왜 아래서 또 확인을하고 적용하는 거죠? 한꺼번에 하면 안되는 이유가 있나요?
왜 두번 체크하는지 모르겠습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a613304 해당 커밋에서 공통로직을 메서드로 추출 후 분기를 하나로 처리했습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dad5f5a 커밋에서 기수별 면접기록 필터링 로직도 메서드로 추출했습니다.


private RecordsViewResponseDto createEmptyResponse(PageInfo pageInfo) {
return RecordsViewResponseDto.of(
pageInfo,
Collections.emptyList(),
Collections.emptyMap(),
Collections.emptyList());
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 Dto 클래스 안에 static으로 Dto를 만드는 정적 팩토리 메서드를 사용하는데
service단에 private으로 Dto 만드는 메서드를 사용하시는 이유가 있을까요??

어떤 관점에서 service에 작성을 한건지 궁금합니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존에 메서드 내부에 작성된 로직인데, DTO 클래스에 넣는게 좋다고 생각합니다! 반영하겠습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

b4dc8b6 커밋에 반영했습니다!


private Map<String, Double> getScoreMap(Integer year, List<String> applicantIds, Map<String, Integer> yearByAnswerIdMap) {
List<Score> scores = scoreLoadPort.findByApplicantIds(applicantIds);
return scores.stream()
.filter(score -> year == null || yearByAnswerIdMap.get(score.getApplicantId()).equals(year))
.collect(
Collectors.groupingBy(
Score::getApplicantId,
Collectors.averagingDouble(Score::getScore)));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왜 메소드 명이 getScoreMap이죠? 이렇게 메소드 분리해서 책임을 명확히 분리할 거면 이 메소드를 사용하는 곳에서 이 메소드가 어떤
역할을 하는지 직관적이어야 합니다. (그래야 다른 사람이 코드를 읽을 때 파악하기 쉬움)

그런데 이 메소드 역할은 Score를 가져와서 평균을 내는 작업이네요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

함수 내부 로직으로 있던 코드인데, 변수명이 scoreMap이라 그대로 사용했습니다.
4b0bf42 커밋에서 calculateAverageScoresByApplicant로 메서드명 변경했습니다!


private List<Record> sortRecordsByScoresDesc(
List<Record> records, Map<String, Double> scoreMap) {
// score 내림차순 정렬
return records.stream()
List<Record> sortedRecords = records.stream()
.sorted(
Comparator.comparing(
record -> {
Double score = scoreMap.get(record.getApplicantId());
return score == null ? 0 : score;
}))
.toList();
return sortedRecords;
}

private List<Record> sortRecordsByScoresDesc(
List<Record> records, Map<String, Double> scoreMap, Integer page) {
// score 내림차순 정렬
List<Record> sortedRecords = records.stream()
.sorted(
Comparator.comparing(
record -> scoreMap.getOrDefault(record.getApplicantId(), 0.0),
Comparator.reverseOrder()
)
)
.toList();
// 페이징 함수 호출
return paginateList(sortedRecords, page);
}

private List<Record> sortRecordsByApplicantsAndSortType(
List<Record> records, List<MongoAnswer> applicants, String sortType) {
// Newest, Name, Object 정렬
sortHelper.sort(applicants, sortType);
if (!applicants.isEmpty()) {
sortHelper.sort(applicants, sortType);
}
Map<String, Integer> applicantIndexMap = new HashMap<>();
for (int i = 0; i < applicants.size(); i++) {
applicantIndexMap.put(applicants.get(i).getId(), i);
}

return records.stream()
.sorted(
Comparator.comparing(
record ->
applicantIndexMap.getOrDefault(
record.getApplicantId(), Integer.MAX_VALUE)))
.toList();
}

private List<Record> sortRecordsByApplicantsAndSortType(List<Record> records, List<MongoAnswer> applicants) {

Map<String, Integer> applicantIndexMap = new HashMap<>();
for (int i = 0; i < applicants.size(); i++) {
applicantIndexMap.put(applicants.get(i).getId(), i);
Expand Down Expand Up @@ -149,9 +239,7 @@ public void updateRecordUrl(String applicantId, String url) {
recordLoadPort
.findByApplicantId(applicantId)
.ifPresent(
record -> {
record.updateUrl(url);
});
record -> record.updateUrl(url));
}

@Override
Expand All @@ -160,9 +248,7 @@ public void updateRecordContents(String applicantId, String contents) {
recordLoadPort
.findByApplicantId(applicantId)
.ifPresent(
record -> {
record.updateRecord(contents);
});
record -> record.updateRecord(contents));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public interface RecordUseCase {

RecordsViewResponseDto execute(Integer page, Integer year, String sortType);

RecordsViewResponseDto execute(Integer page, Integer year, String sortType, String searchKeyword);

Record findByApplicantId(String applicantId);

void updateRecordUrl(String applicantId, String url);
Expand Down
Loading