Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions collectors/kopis.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,16 @@ def _parse_concert(item: ET.Element) -> dict:


def search_concerts(title: str) -> list[dict]:
"""공연명으로 KOPIS 검색. 2020-01-01~1년 후 범위, 최대 20건 반환."""
"""공연명으로 KOPIS 검색 (전체 장르 대상). 2020-01-01~1년 후 범위, 최대 20건 반환.

어드민이 자동 매칭에서 누락된 공연을 수동으로 찾는 용도이므로
genrenm(장르) 제약 없이 KOPIS 전체 장르를 검색 대상으로 한다.
"""
today = datetime.date.today()
stdate = _SEARCH_STDATE
eddate = (today + datetime.timedelta(days=_DEFAULT_LOOKAHEAD_DAYS)).strftime("%Y%m%d")
params = {
**_DEFAULT_PARAMS,
"service": _API_KEY,
"shprfnm": title,
"stdate": stdate,
"eddate": eddate,
Expand Down
34 changes: 32 additions & 2 deletions db/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,42 @@ def save_releases(artist_id: int, releases: list[dict]) -> None:
logger.info("릴리즈 저장 완료: %d / %d건 처리", saved, len(releases))


def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> None:
def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> set:
"""수집된 공연 목록을 concert 테이블에 저장한다. kopis_id 중복 시 무시.

title·start_date·end_date가 모두 일치하는 공연이 이미 존재하면(예: 어드민이
kopis_id 없이 수동 등록한 공연과 동일 공연) 신규 저장을 건너뛴다.

use_prfstate=True이면 prfstate → _KOPIS_STATUS_MAP으로 status를 결정한다.
기본값(False)은 배치 수집용 PENDING을 사용한다.

반환값: title·기간 중복으로 저장을 건너뛴 concert들의 kopis_id 집합
"""
saved = 0
duplicate_kopis_ids = set()
for concert in concerts:
try:
with get_session() as session:
title_match = session.execute(
text("""
SELECT id FROM concert
WHERE title = :title AND start_date = :start_date
AND end_date = :end_date
"""),
{
"title": concert["prfnm"],
"start_date": concert["prfpdfrom"],
"end_date": concert["prfpdto"],
},
).fetchone()
if title_match is not None:
logger.info(
"동일 공연명·기간 이미 존재 — 저장 건너뜀: title=%s, kopis_id=%s",
concert["prfnm"], concert["kopis_id"],
)
duplicate_kopis_ids.add(concert["kopis_id"])
continue

status = (
_KOPIS_STATUS_MAP.get(concert.get("prfstate"), "PENDING")
if use_prfstate
Expand Down Expand Up @@ -237,7 +263,11 @@ def save_concerts(concerts: list[dict], use_prfstate: bool = False) -> None:
saved += 1
except SQLAlchemyError as e:
logger.error("공연 저장 실패 — 건너뜀: kopis_id=%s, 오류=%s", concert["kopis_id"], e)
logger.info("공연 저장 완료: %d / %d건 처리", saved, len(concerts))
logger.info(
"공연 저장 완료: %d / %d건 처리 (title·기간 중복 %d건 제외)",
saved, len(concerts), len(duplicate_kopis_ids),
)
return duplicate_kopis_ids


def get_completed_concerts() -> list[dict]:
Expand Down
23 changes: 11 additions & 12 deletions scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,30 +657,29 @@ def run_setlist_collect() -> None:
def collect_and_save_concert(kopis_id: str) -> dict:
"""단건 KOPIS 공연을 수집해 alias 매칭 후 DB에 저장한다.

KOPIS에 데이터가 없으면 {"status": "not_found"}, 내한 공연이 아니거나
alias 매칭이 없으면 {"status": "skipped", "reason": ...}, 성공 시
{"status": "ok", "concert_id", "title", "matched_artists"}를 반환한다.
어드민이 KOPIS에서 직접 골라 트리거하는 수집이므로 (자동 매칭에서 누락된 공연을
구제하는 용도) 내한 여부·alias 매칭 여부와 무관하게 저장한다.

KOPIS에 데이터가 없으면 {"status": "not_found"}, 동일 title·기간의 공연이
이미 존재하면(예: 어드민 수동 등록과 중복) {"status": "skipped",
"reason": "duplicate_title"}, 성공 시 {"status": "ok", "concert_id", "title",
"matched_artists"}를 반환한다.
저장 직후 재조회에 실패하면 (있어선 안 되는 내부 불일치) RuntimeError를 발생시킨다.
"""
concert = kopis.collect_by_id(kopis_id)
if concert is None:
logger.warning("KOPIS 공연 데이터 없음: kopis_id=%s", kopis_id)
return {"status": "not_found"}
if concert.get("visit") != "Y":
logger.info("내한 공연 아님 — 저장 건너뜀: kopis_id=%s", kopis_id)
return {"status": "skipped", "reason": "not_touring"}

aliases = get_all_aliases()
if not has_match(concert, aliases):
logger.info("alias 매칭 없음 — 저장 건너뜀: kopis_id=%s", kopis_id)
return {"status": "skipped", "reason": "no_alias_match"}

save_concerts([concert], use_prfstate=True)
duplicate_kopis_ids = save_concerts([concert], use_prfstate=True)
if kopis_id in duplicate_kopis_ids:
return {"status": "skipped", "reason": "duplicate_title"}

concert_row = get_concert_by_kopis_id(kopis_id)
if concert_row is None:
raise RuntimeError(f"공연 저장 후 조회 실패: kopis_id={kopis_id}")

aliases = get_all_aliases()
matched_artists = []
matches, _ = match_concert(concert_row, aliases)
if matches:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ def test_returns_404_when_not_found(self, client):
assert res.status_code == 404

def test_returns_200_with_success_false_when_skipped(self, client):
"""비즈니스 스킵(내한 아님 등)이면 200 + success:false를 반환해야 한다."""
"""비즈니스 스킵이면 200 + success:false를 반환해야 한다."""
with patch(
"api.collect_and_save_concert",
return_value={"status": "skipped", "reason": "not_touring"},
return_value={"status": "skipped", "reason": "some_reason"},
):
res = client.post("/collect/concert", json={"kopis_id": "PF001"}, headers=_AUTH)
assert res.status_code == 200
assert res.json() == {"success": False, "reason": "not_touring"}
assert res.json() == {"success": False, "reason": "some_reason"}

def test_called_with_kopis_id(self, client):
"""collect_and_save_concert가 kopis_id로 직접 호출되어야 한다."""
Expand Down
47 changes: 41 additions & 6 deletions tests/test_kopis.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ def _make_concert(self, **kwargs) -> dict:
def test_insert_sql_contains_on_conflict(self):
"""INSERT SQL에 ON CONFLICT가 포함되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = None
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -637,6 +638,7 @@ def test_insert_sql_contains_on_conflict(self):
def test_insert_sql_includes_poster_url(self):
"""INSERT SQL에 poster_url 컬럼이 포함되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = None
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -651,6 +653,7 @@ def test_insert_sql_includes_poster_url(self):
def test_poster_url_param_passed(self):
"""INSERT 파라미터에 poster_url 값이 전달되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = None
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -665,6 +668,7 @@ def test_poster_url_param_passed(self):
def test_insert_sql_includes_price(self):
"""INSERT SQL에 price 컬럼이 포함되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = None
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -679,6 +683,7 @@ def test_insert_sql_includes_price(self):
def test_price_param_passed(self):
"""INSERT 파라미터에 price 값이 전달되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = None
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -693,7 +698,7 @@ def test_price_param_passed(self):
def test_booking_links_inserted_for_new_concert(self):
"""신규 공연 저장 시 relates가 concert_booking_link 테이블에 별도 INSERT되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = (1,)
mock_session.execute.return_value.fetchone.side_effect = [None, (1,)]
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -713,7 +718,7 @@ def test_booking_links_inserted_for_new_concert(self):
def test_booking_links_inserted_for_existing_concert(self):
"""기존 공연(INSERT DO NOTHING)도 SELECT fallback으로 booking_link가 INSERT되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.side_effect = [None, (5,)]
mock_session.execute.return_value.fetchone.side_effect = [None, None, (5,)]
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -731,7 +736,7 @@ def test_booking_links_inserted_for_existing_concert(self):
def test_booking_link_insert_has_on_conflict(self):
"""concert_booking_link INSERT에 ON CONFLICT DO NOTHING이 포함되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = (1,)
mock_session.execute.return_value.fetchone.side_effect = [None, (1,)]
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
Expand All @@ -749,7 +754,7 @@ def test_booking_link_insert_has_on_conflict(self):
def test_concert_image_inserted_for_new_concert(self):
"""신규 공연 저장 시 still_urls가 concert_image에 position 순으로 INSERT되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = (1,)
mock_session.execute.return_value.fetchone.side_effect = [None, (1,)]
concert = self._make_concert(still_urls=["http://still1.jpg", "http://still2.jpg"])
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
Expand All @@ -768,7 +773,7 @@ def test_concert_image_inserted_for_new_concert(self):
def test_concert_image_not_inserted_for_existing_concert(self):
"""기존 공연(RETURNING None)이면 concert_image INSERT가 실행되지 않아야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.side_effect = [None, (5,)]
mock_session.execute.return_value.fetchone.side_effect = [None, None, (5,)]
concert = self._make_concert(still_urls=["http://still1.jpg"])
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
Expand All @@ -780,10 +785,40 @@ def test_concert_image_not_inserted_for_existing_concert(self):
]
assert len(image_inserts) == 0

def test_skips_insert_when_title_and_dates_match_existing(self):
"""title·start_date·end_date가 모두 일치하는 공연이 있으면 INSERT를 건너뛴다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = (99,)
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
duplicates = save_concerts([self._make_concert()])

insert_sqls = [
str(c.args[0]) for c in mock_session.execute.call_args_list
if "INSERT INTO concert" in str(c.args[0])
]
assert insert_sqls == []
assert duplicates == {"PF123456"}

def test_title_date_check_uses_concert_fields(self):
"""중복 검사 쿼리에 title·start_date·end_date 파라미터가 전달되어야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = None
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
save_concerts([self._make_concert()])

check_call = mock_session.execute.call_args_list[0]
assert check_call.args[1] == {
"title": "공연명", "start_date": "2024-01-01", "end_date": "2024-01-31",
}

def test_concert_image_not_inserted_when_still_urls_empty(self):
"""still_urls가 빈 리스트이면 concert_image INSERT가 실행되지 않아야 한다."""
mock_session = MagicMock()
mock_session.execute.return_value.fetchone.return_value = (1,)
mock_session.execute.return_value.fetchone.side_effect = [None, (1,)]
concert = self._make_concert(still_urls=[])
with patch("db.repository.get_session") as mock_get_session:
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
Expand Down
36 changes: 20 additions & 16 deletions tests/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,29 +654,25 @@ def test_returns_not_found_when_kopis_has_no_data(self):
with patch("scheduler.kopis.collect_by_id", return_value=None):
assert collect_and_save_concert("PF000") == {"status": "not_found"}

def test_returns_skipped_when_not_touring(self):
"""내한 공연이 아니면 status:skipped, reason:not_touring을 반환해야 한다."""
def test_saves_even_when_not_touring(self):
"""내한 공연이 아니어도 저장된다 (어드민이 직접 고른 공연이므로 visit 무관)."""
concert = {**self._CONCERT_RAW, "visit": "N"}
with patch("scheduler.kopis.collect_by_id", return_value=concert):
result = collect_and_save_concert("PF001")
assert result == {"status": "skipped", "reason": "not_touring"}

def test_returns_skipped_when_no_alias_match(self):
"""alias 매칭이 없으면 status:skipped, reason:no_alias_match를 반환해야 한다."""
with (
patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW),
patch("scheduler.kopis.collect_by_id", return_value=concert),
patch("scheduler.get_all_aliases", return_value=self._ALIASES),
patch("scheduler.has_match", return_value=False),
patch("scheduler.save_concerts") as mock_save,
patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW),
patch("scheduler.match_concert", return_value=([], [{"concert_id": 10}])),
):
result = collect_and_save_concert("PF001")
assert result == {"status": "skipped", "reason": "no_alias_match"}
mock_save.assert_called_once()
assert result["status"] == "ok"

def test_returns_ok_with_matched_artists_on_success(self):
"""매칭 성공 시 status:ok와 matched_artists를 반환해야 한다."""
with (
patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW),
patch("scheduler.get_all_aliases", return_value=self._ALIASES),
patch("scheduler.has_match", return_value=True),
patch("scheduler.save_concerts"),
patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW),
patch(
Expand All @@ -695,16 +691,16 @@ def test_returns_ok_with_matched_artists_on_success(self):
}

def test_returns_ok_with_empty_matches_when_no_match_found(self):
"""저장은 됐지만 실제 매칭이 0건이면 matched_artists 빈 리스트로 성공 반환해야 한다."""
"""alias 매칭이 없어도 저장되고, matched_artists 빈 리스트로 성공 반환해야 한다."""
with (
patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW),
patch("scheduler.get_all_aliases", return_value=self._ALIASES),
patch("scheduler.has_match", return_value=True),
patch("scheduler.save_concerts"),
patch("scheduler.save_concerts") as mock_save,
patch("scheduler.get_concert_by_kopis_id", return_value=self._CONCERT_ROW),
patch("scheduler.match_concert", return_value=([], [{"concert_id": 10}])),
):
result = collect_and_save_concert("PF001")
mock_save.assert_called_once()
assert result["status"] == "ok"
assert result["matched_artists"] == []

Expand All @@ -713,13 +709,21 @@ def test_raises_when_db_lookup_fails_after_save(self):
with (
patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW),
patch("scheduler.get_all_aliases", return_value=self._ALIASES),
patch("scheduler.has_match", return_value=True),
patch("scheduler.save_concerts"),
patch("scheduler.get_concert_by_kopis_id", return_value=None),
pytest.raises(RuntimeError),
):
collect_and_save_concert("PF001")

def test_returns_skipped_when_duplicate_title(self):
"""save_concerts가 kopis_id를 중복으로 반환하면 status:skipped를 반환해야 한다."""
with (
patch("scheduler.kopis.collect_by_id", return_value=self._CONCERT_RAW),
patch("scheduler.save_concerts", return_value={"PF001"}),
):
result = collect_and_save_concert("PF001")
assert result == {"status": "skipped", "reason": "duplicate_title"}


class TestCollectAndSaveSetlist:
_CONCERT = {
Expand Down
Loading