From 3226a6428a34c113ad9d2c369e67cd50d671bbd1 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Fri, 3 Jul 2026 19:30:19 +0900 Subject: [PATCH 01/70] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/user/apps.py | 1 + ...xt_merged_to_userext_merged_to_and_more.py | 140 +++++++++ app/user/models/merge.py | 177 ++++++++++++ app/user/models/user.py | 2 + app/user/test/__init__.py | 0 app/user/test/merge_test.py | 269 ++++++++++++++++++ 6 files changed, 589 insertions(+) create mode 100644 app/user/migrations/0011_historicaluserext_merged_to_userext_merged_to_and_more.py create mode 100644 app/user/models/merge.py create mode 100644 app/user/test/__init__.py create mode 100644 app/user/test/merge_test.py diff --git a/app/user/apps.py b/app/user/apps.py index 235b1e1..68f1654 100644 --- a/app/user/apps.py +++ b/app/user/apps.py @@ -20,3 +20,4 @@ def ready(self): register(Organization) from user.models.mcp_token import McpToken # noqa: F401 + from user.models.merge import UserMergeHistory, UserMergeObject # noqa: F401 diff --git a/app/user/migrations/0011_historicaluserext_merged_to_userext_merged_to_and_more.py b/app/user/migrations/0011_historicaluserext_merged_to_userext_merged_to_and_more.py new file mode 100644 index 0000000..691ee68 --- /dev/null +++ b/app/user/migrations/0011_historicaluserext_merged_to_userext_merged_to_and_more.py @@ -0,0 +1,140 @@ +import uuid + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("contenttypes", "0002_remove_content_type_name"), ("user", "0010_mcptoken")] + operations = [ + migrations.AddField( + model_name="historicaluserext", + name="merged_to", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="userext", + name="merged_to", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="merged_from", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.CreateModel( + name="UserMergeHistory", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("reverted_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "source", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ( + "target", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="UserMergeObject", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("target_id", models.CharField(max_length=255)), + ("field_names", django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=63))), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="merged_objects", + to="user.usermergehistory", + ), + ), + ( + "target_type", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="contenttypes.contenttype"), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [models.Index(fields=["target_type", "target_id"], name="user_userme_target__3e69cc_idx")], + }, + ), + ] diff --git a/app/user/models/merge.py b/app/user/models/merge.py new file mode 100644 index 0000000..748a7f5 --- /dev/null +++ b/app/user/models/merge.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from collections.abc import Generator, Iterable +from functools import lru_cache +from itertools import chain + +from allauth.account.models import EmailAddress +from core.models import BaseAbstractModel +from core.util.dateutil import now_aware +from django.apps import apps +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField +from django.db.models.base import Model +from django.db.models.constraints import UniqueConstraint +from django.db.models.deletion import PROTECT +from django.db.models.fields import CharField, DateTimeField +from django.db.models.fields.related import ForeignKey +from django.db.models.indexes import Index +from django.db.models.query_utils import Q +from django.db.transaction import atomic +from simple_history.manager import HistoryManager +from simple_history.models import registered_models +from simple_history.utils import bulk_update_with_history, get_history_model_for_model + +from .user import UserExt + + +@lru_cache(maxsize=1) +def _movable_relations() -> Iterable[tuple[type[Model], Iterable[ForeignKey]]]: + authorship = frozenset( + f.name for f in BaseAbstractModel._meta.get_fields() if getattr(f, "related_model", None) is UserExt + ) | {"revoked_by"} + snapshots = {get_history_model_for_model(m) for m in registered_models.values()} + by_model: dict[type[Model], list[ForeignKey]] = {} + for model in apps.get_models(): + if model._meta.proxy or model is UserMergeHistory or model in snapshots: + continue + if model._meta.app_label == "admin": # LogEntry + continue + for field in model._meta.get_fields(): + if not (field.is_relation and not field.auto_created and field.related_model is UserExt): + continue + if field.many_to_many or field.name in authorship: + continue + by_model.setdefault(model, []).append(field) + return tuple((model, tuple(fields)) for model, fields in by_model.items()) + + +class UserMergeHistory(BaseAbstractModel): + source = ForeignKey("user.UserExt", on_delete=PROTECT, related_name="+") + target = ForeignKey("user.UserExt", on_delete=PROTECT, related_name="+") + reverted_at = DateTimeField(null=True, blank=True) + + def __str__(self) -> str: + return f"UserMerge {self.source_id}→{self.target_id}" + + @property + def is_self_merge(self) -> bool: + return self.created_by_id is not None and self.created_by_id == self.target_id + + @staticmethod + def assert_self_mergeable(source: UserExt, target: UserExt) -> None: + if not EmailAddress.objects.filter(user=target).exists(): + msg = "남길 계정에 인증된 이메일이 필요합니다. 이메일을 추가하고 인증한 뒤 다시 시도해 주세요." + raise ValueError(msg) + if EmailAddress.objects.filter(user=target, verified=False).exists(): + msg = "남길 계정에 인증되지 않은 이메일이 있습니다. 인증을 완료하거나 해당 이메일을 삭제한 뒤 다시 시도해 주세요." + raise ValueError(msg) + if EmailAddress.objects.filter(user=source, verified=False).exists(): + msg = "합칠 계정에 인증되지 않은 이메일이 있습니다. 해당 계정으로 로그인해 인증을 완료하거나 삭제한 뒤 다시 시도해 주세요." + raise ValueError(msg) + + def _create_merge_objects( + self, model: type[Model], fields: Iterable[ForeignKey] + ) -> Generator[UserMergeObject, None, None]: + aggregated: dict[object, tuple[Model, list[str]]] = {} + + for field in fields: + groups: list[tuple[tuple[str, ...], Q | None]] = [] + if field.unique: + groups.append(((), None)) + for ut in model._meta.unique_together: + if field.name in ut: + groups.append((tuple(model._meta.get_field(f).attname for f in ut if f != field.name), None)) + for constraint in model._meta.constraints: + if isinstance(constraint, UniqueConstraint) and field.name in constraint.fields: + others = tuple(model._meta.get_field(f).attname for f in constraint.fields if f != field.name) + groups.append((others, constraint.condition)) + + for row in model._base_manager.filter(**{field.attname: self.source_id}): + movable = True + for other_attnames, condition in groups: + if condition is not None and not model._base_manager.filter(pk=row.pk).filter(condition).exists(): + continue + qs = model._base_manager.filter( + **{field.attname: self.target_id}, + **{a: getattr(row, a) for a in other_attnames}, + ) + if (qs.filter(condition) if condition is not None else qs).exists(): + movable = False + break + if movable: + aggregated.setdefault(row.pk, (row, []))[1].append(field.name) + + for row, names in aggregated.values(): + yield UserMergeObject(history=self, target_object=row, field_names=names) + + @atomic + def merge(self) -> None: + if self.source.pk == self.target.pk: + raise ValueError("같은 계정끼리는 병합할 수 없습니다.") + if self.target.merged_to_id is not None: + raise ValueError("남길 계정이 이미 다른 계정에 병합되어 있습니다.") + if self.source.merged_to_id is not None: + raise ValueError("합칠 계정이 이미 다른 계정에 병합되어 있습니다.") + + merge_objects = chain.from_iterable(self._create_merge_objects(m, fs) for m, fs in _movable_relations()) + for merge_object in UserMergeObject.objects.bulk_create(merge_objects): + merge_object.apply() + + self.source.is_active = False + self.source.merged_to = self.target + self.source.save(update_fields=["is_active", "merged_to"]) + + @atomic + def unmerge(self) -> None: + if self.reverted_at is not None: + raise ValueError("이미 되돌린 병합입니다.") + + for merge_object in self.merged_objects.select_related("target_type"): + merge_object.history = self + merge_object.undo() + + self.source.is_active = True + self.source.merged_to = None + self.source.save(update_fields=["is_active", "merged_to"]) + + self.reverted_at = now_aware() + self.save(update_fields=["reverted_at"]) + + +class UserMergeObject(BaseAbstractModel): + history = ForeignKey(UserMergeHistory, on_delete=PROTECT, related_name="merged_objects") + target_type = ForeignKey(ContentType, on_delete=PROTECT) + target_id = CharField(max_length=255) + target_object = GenericForeignKey("target_type", "target_id") + field_names = ArrayField(CharField(max_length=63)) + + class Meta: + indexes = [Index(fields=["target_type", "target_id"])] + + def __str__(self) -> str: + return f"{self.target_type.model}#{self.target_id} {self.field_names}" + + def apply(self) -> None: + self._repoint(self.history.target_id, f"account_merge:{self.history_id}") + + def undo(self) -> None: + self._repoint(self.history.source_id, f"account_unmerge:{self.history_id}") + + def _repoint(self, user_id: object, change_reason: str) -> None: + model = self.target_type.model_class() + if isinstance(getattr(model, "history", None), HistoryManager): + obj = model._base_manager.get(pk=self.target_id) + for name in self.field_names: + setattr(obj, model._meta.get_field(name).attname, user_id) + bulk_update_with_history( + [obj], + model, + self.field_names, + default_user=self.history.created_by, + default_change_reason=change_reason, + ) + else: + attnames = {model._meta.get_field(name).attname: user_id for name in self.field_names} + model._base_manager.filter(pk=self.target_id).update(**attnames) diff --git a/app/user/models/user.py b/app/user/models/user.py index fbf0c57..bf648d2 100644 --- a/app/user/models/user.py +++ b/app/user/models/user.py @@ -24,6 +24,8 @@ class UserExt(ScanCodeMixin, AbstractUser): nickname = models.CharField(max_length=128, null=True, blank=True) unique_id = models.UUIDField(unique=True, editable=False, null=False, blank=False, default=uuid4) + merged_to = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="merged_from") + class Meta(AbstractUser.Meta): ordering = ["-date_joined"] indexes = [models.Index(fields=["unique_id"], name="userext_unique_id_idx")] diff --git a/app/user/test/__init__.py b/app/user/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/test/merge_test.py b/app/user/test/merge_test.py new file mode 100644 index 0000000..541fb7c --- /dev/null +++ b/app/user/test/merge_test.py @@ -0,0 +1,269 @@ +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount +from core.util.thread_local import thread_local +from django.contrib.contenttypes.models import ContentType +from shop.order.models import Order +from user.models import UserExt +from user.models.mcp_token import McpToken +from user.models.merge import UserMergeHistory, UserMergeObject +from user.models.organization import Organization, OrganizationUserRelation + + +@pytest.fixture +def source_user(db) -> UserExt: + return UserExt.objects.create_user(username="source", email="source@example.com") + + +@pytest.fixture +def target_user(db) -> UserExt: + return UserExt.objects.create_user(username="target", email="target@example.com") + + +@pytest.fixture +def actor_user(db) -> UserExt: + return UserExt.objects.create_superuser(username="admin", email="admin@example.com") + + +@contextmanager +def _acting_as(user): + """created_by 는 get_current_user()(thread-local)에서 잡히므로, 실행자를 테스트에서 명시하려면 여기서 주입.""" + if user is not None: + thread_local.current_request = SimpleNamespace(user=user) + try: + yield + finally: + if hasattr(thread_local, "current_request"): + del thread_local.current_request + + +def _merge(source, target, actor=None) -> UserMergeHistory: + with _acting_as(actor): + history = UserMergeHistory.objects.create(source=source, target=target) # created_by=actor + history.merge() + return history + + +def test_merge_repoints_owned_business_fk(source_user, target_user): + order = Order.objects.create(user=source_user, name="src-order") + + _merge(source_user, target_user) + + order.refresh_from_db() + assert order.user_id == target_user.id + + +def test_merge_deactivates_source_and_sets_merged_to(source_user, target_user): + merge = _merge(source_user, target_user) + + source_user.refresh_from_db() + assert source_user.is_active is False + assert source_user.merged_to_id == target_user.id + assert merge.source_id == source_user.id and merge.target_id == target_user.id + + +def test_merge_records_moved_objects(source_user, target_user): + order = Order.objects.create(user=source_user, name="src-order") + + merge = _merge(source_user, target_user) + + order_ct = ContentType.objects.get_for_model(Order) + recorded = UserMergeObject.objects.get(history=merge, target_type=order_ct, target_id=str(order.id)) + assert recorded.field_names == ["user"] + + +def test_tracked_model_gets_history_with_change_reason(source_user, target_user, actor_user): + order = Order.objects.create(user=source_user, name="src-order") + + merge = _merge(source_user, target_user, actor=actor_user) + + update_record = order.history.filter(history_type="~").order_by("-history_date", "-history_id").first() + assert update_record is not None + assert update_record.user_id == target_user.id + assert update_record.history_change_reason == f"account_merge:{merge.pk}" + assert update_record.history_user_id == actor_user.id + + +def test_untracked_model_is_repointed(source_user, target_user): + org = Organization.objects.create(name="Acme") + relation = OrganizationUserRelation.objects.create(organization=org, user=source_user) + + _merge(source_user, target_user) + + relation.refresh_from_db() + assert relation.user_id == target_user.id + + +def test_allauth_accounts_are_moved(source_user, target_user): + SocialAccount.objects.create(user=source_user, provider="google", uid="src-google", extra_data={}) + EmailAddress.objects.create(user=source_user, email="source@example.com", verified=True, primary=True) + + _merge(source_user, target_user) + + assert SocialAccount.objects.filter(user=target_user, uid="src-google").exists() + assert EmailAddress.objects.filter(user=target_user, email="source@example.com").exists() + + +def test_mcp_token_is_moved(source_user, target_user): + token = McpToken.objects.create(user=source_user) + + _merge(source_user, target_user) + + token.refresh_from_db() + assert token.user_id == target_user.id + + +def test_audit_fields_are_not_moved(source_user, target_user): + """created_by(=authorship) 는 이관하지 않는다 — 병합 후에도 source 를 가리켜야 함.""" + org = Organization.objects.create(name="Acme") + Organization.objects.filter(pk=org.pk).update(created_by=source_user) + + _merge(source_user, target_user) + + org.refresh_from_db() + assert org.created_by_id == source_user.id + + +def test_primary_email_conflict_stays_on_source(source_user, target_user): + """target 이 이미 primary 를 가지면 source 의 primary 이메일은 (user,primary) 조건부 제약 때문에 옮기지 않고 source 에 남긴다.""" + EmailAddress.objects.create(user=source_user, email="source@example.com", verified=True, primary=True) + EmailAddress.objects.create(user=target_user, email="target@example.com", verified=True, primary=True) + + _merge(source_user, target_user) + + assert EmailAddress.objects.filter(user=target_user, primary=True).count() == 1 + assert EmailAddress.objects.filter(user=source_user, email="source@example.com").exists() + + +def test_chain_is_flattened_to_depth_one(target_user): + """B→A 병합 후 A→C 병합 시, B 는 A 가 아니라 최종 C 를 직접 가리켜야 한다.""" + b = UserExt.objects.create_user(username="b", email="b@example.com") + a = UserExt.objects.create_user(username="a", email="a@example.com") + c = target_user + + _merge(b, a) + _merge(a, c) + + b.refresh_from_db() + a.refresh_from_db() + assert a.merged_to_id == c.id + assert b.merged_to_id == c.id # 평탄화 — depth 1 + + +def test_prior_merge_record_not_rewritten(source_user, target_user): + """UserMergeHistory.source/target 은 sweep 대상이 아니어야 — 후속 병합이 과거 기록을 재작성하면 안 됨.""" + c = UserExt.objects.create_user(username="c", email="c@example.com") + first = _merge(source_user, target_user) + _merge(target_user, c) # target 을 다시 source 로 병합 + + first.refresh_from_db() + assert first.target_id == target_user.id and first.source_id == source_user.id + + +def test_unmerge_restores_objects_and_source(source_user, target_user): + order = Order.objects.create(user=source_user, name="src-order") + merge = _merge(source_user, target_user) + + merge.unmerge() + + order.refresh_from_db() + source_user.refresh_from_db() + merge.refresh_from_db() + assert order.user_id == source_user.id + assert source_user.is_active is True + assert source_user.merged_to_id is None + assert merge.reverted_at is not None + + +# ---- method 파생 (is_self_merge) -------------------------------------------- + + +def test_is_self_merge_true_when_actor_is_target(source_user, target_user): + merge = _merge(source_user, target_user, actor=target_user) + + assert merge.is_self_merge is True + + +def test_is_self_merge_false_for_operator(source_user, target_user, actor_user): + merge = _merge(source_user, target_user, actor=actor_user) + + assert merge.is_self_merge is False + + +# ---- guards ------------------------------------------------------------------ + + +def test_merge_same_user_rejected(source_user): + with pytest.raises(ValueError): + _merge(source_user, source_user) + + +def test_merge_into_already_merged_target_rejected(source_user, target_user): + other = UserExt.objects.create_user(username="c", email="c@example.com") + _merge(target_user, other) # target 이 이미 병합됨 + + with pytest.raises(ValueError): + _merge(source_user, target_user) + + +def test_merge_already_merged_source_rejected(source_user, target_user): + other = UserExt.objects.create_user(username="c", email="c@example.com") + _merge(source_user, other) # source 가 이미 병합됨 + + with pytest.raises(ValueError): + _merge(source_user, target_user) + + +def test_double_unmerge_rejected(source_user, target_user): + merge = _merge(source_user, target_user) + merge.unmerge() + + with pytest.raises(ValueError): + merge.unmerge() + + +# ---- assert_self_mergeable (본인 병합 사전검증) -------------------------------- + + +def _verified_email(user, email): + return EmailAddress.objects.create(user=user, email=email, verified=True, primary=True) + + +def test_self_mergeable_passes_when_both_verified(source_user, target_user): + _verified_email(source_user, "source@example.com") + _verified_email(target_user, "target@example.com") + + UserMergeHistory.assert_self_mergeable(source_user, target_user) # no raise + + +def test_self_mergeable_allows_email_less_source(source_user, target_user): + _verified_email(target_user, "target@example.com") # source 는 이메일 없음 → 허용 + + UserMergeHistory.assert_self_mergeable(source_user, target_user) # no raise + + +def test_self_mergeable_rejects_email_less_target(source_user, target_user): + _verified_email(source_user, "source@example.com") + + with pytest.raises(ValueError): + UserMergeHistory.assert_self_mergeable(source_user, target_user) + + +def test_self_mergeable_rejects_unverified_target_email(source_user, target_user): + _verified_email(source_user, "source@example.com") + _verified_email(target_user, "target@example.com") + EmailAddress.objects.create(user=target_user, email="extra@example.com", verified=False) + + with pytest.raises(ValueError): + UserMergeHistory.assert_self_mergeable(source_user, target_user) + + +def test_self_mergeable_rejects_unverified_source_email(source_user, target_user): + _verified_email(target_user, "target@example.com") + EmailAddress.objects.create(user=source_user, email="source@example.com", verified=False) + + with pytest.raises(ValueError): + UserMergeHistory.assert_self_mergeable(source_user, target_user) From fe55dd00015794ee97f2f8c338654d9f4198208a Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 4 Jul 2026 15:57:50 +0900 Subject: [PATCH 02/70] =?UTF-8?q?feat:=20accounts.pycon.kr=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/account_urls.py | 7 + app/core/authn/allauth_adapter.py | 16 + app/core/const/account.py | 43 +++ app/core/middleware/host_urlconf.py | 18 ++ app/core/middleware/response_exception.py | 24 ++ app/core/settings.py | 9 +- app/core/templatetags/i18n_extras.py | 9 + app/core/urls.py | 2 + app/user/models/merge.py | 27 +- app/user/templates/user/_provider_forms.html | 23 ++ app/user/templates/user/account_base.html | 33 ++ app/user/templates/user/account_home.html | 10 + app/user/templates/user/account_login.html | 9 + .../user/account_password_login.html | 17 ++ app/user/templates/user/merge_confirm.html | 22 ++ app/user/templates/user/merge_result.html | 16 + app/user/templates/user/merge_start.html | 12 + app/user/test/merge_view_test.py | 283 ++++++++++++++++++ app/user/urls.py | 10 + app/user/views.py | 150 ++++++++++ 20 files changed, 728 insertions(+), 12 deletions(-) create mode 100644 app/core/account_urls.py create mode 100644 app/core/middleware/host_urlconf.py create mode 100644 app/core/middleware/response_exception.py create mode 100644 app/core/templatetags/i18n_extras.py create mode 100644 app/user/templates/user/_provider_forms.html create mode 100644 app/user/templates/user/account_base.html create mode 100644 app/user/templates/user/account_home.html create mode 100644 app/user/templates/user/account_login.html create mode 100644 app/user/templates/user/account_password_login.html create mode 100644 app/user/templates/user/merge_confirm.html create mode 100644 app/user/templates/user/merge_result.html create mode 100644 app/user/templates/user/merge_start.html create mode 100644 app/user/test/merge_view_test.py create mode 100644 app/user/urls.py create mode 100644 app/user/views.py diff --git a/app/core/account_urls.py b/app/core/account_urls.py new file mode 100644 index 0000000..07046cd --- /dev/null +++ b/app/core/account_urls.py @@ -0,0 +1,7 @@ +from django.urls import include, path + +urlpatterns = [ + path("accounts/", include("allauth.urls")), + path("authn/social/", include("allauth.headless.urls")), + path("", include("user.urls")), +] diff --git a/app/core/authn/allauth_adapter.py b/app/core/authn/allauth_adapter.py index 3fee12d..291b505 100644 --- a/app/core/authn/allauth_adapter.py +++ b/app/core/authn/allauth_adapter.py @@ -4,13 +4,18 @@ from urllib.parse import urlparse from allauth.account.adapter import DefaultAccountAdapter +from allauth.core.exceptions import ImmediateHttpResponse from allauth.headless.adapter import DefaultHeadlessAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.models import SocialLogin from allauth.socialaccount.providers.base import Provider +from allauth.socialaccount.providers.base.constants import AuthProcess +from core.const.account import MERGE_SOURCE_SESSION_KEY from core.logger.util.django_helper import get_request_log_data from django.conf import settings from django.http.request import HttpRequest +from django.shortcuts import redirect +from django.urls import reverse # allauth.socialaccount.providers.base.AuthError 상수의 가능한 값 (UNKNOWN / CANCELLED / DENIED) SocialAuthError = Literal["unknown", "cancelled", "denied"] @@ -27,6 +32,17 @@ class SocialAccountLoggingAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> bool: return True + def pre_social_login(self, request: HttpRequest, sociallogin: SocialLogin) -> None: + # 로그인 상태에서 계정 병합으로 다른 기존 계정의 소셜 로그인을 인증 시 병합 확인 페이지로. + if ( + sociallogin.state.get("process") == AuthProcess.CONNECT + and request.user.is_authenticated + and sociallogin.is_existing + and sociallogin.user.pk != request.user.pk + ): + request.session[MERGE_SOURCE_SESSION_KEY] = sociallogin.user.pk + raise ImmediateHttpResponse(redirect(reverse("account-merge-confirm"))) + def on_authentication_error( self, request: HttpRequest, diff --git a/app/core/const/account.py b/app/core/const/account.py index 28e72f0..927ec05 100644 --- a/app/core/const/account.py +++ b/app/core/const/account.py @@ -1,6 +1,49 @@ import secrets import string +MERGE_SOURCE_SESSION_KEY = "account_merge_source_user_id" +MERGE_MESSAGES = { + "wrong_account_or_password": { + "ko": "이메일(또는 아이디)이나 비밀번호가 올바르지 않습니다.", + "en": "Your email/username or password is incorrect.", + }, + "target_no_verified_email": { + "ko": "남길 계정에 인증된 이메일이 필요합니다. 이메일을 추가하고 인증한 뒤 다시 시도해 주세요.", + "en": "The account you keep needs a verified email. Add and verify an email, then try again.", + }, + "target_unverified_email": { + "ko": "남길 계정에 인증되지 않은 이메일이 있습니다. 인증을 완료하거나 해당 이메일을 삭제한 뒤 다시 시도해 주세요.", + "en": "The account you keep has an unverified email. Verify it or delete that email, then try again.", + }, + "source_unverified_email": { + "ko": "합칠 계정에 인증되지 않은 이메일이 있습니다. 해당 계정으로 로그인해 인증을 완료하거나 삭제한 뒤 다시 시도해 주세요.", + "en": ( + "The account to merge has an unverified email. " + "Sign in to that account to verify or delete it, then try again." + ), + }, + "same_account": { + "ko": "같은 계정끼리는 병합할 수 없습니다.", + "en": "You can't merge an account with itself.", + }, + "target_already_merged": { + "ko": "남길 계정이 이미 다른 계정에 병합되어 있습니다.", + "en": "The account you keep has already been merged into another account.", + }, + "source_already_merged": { + "ko": "합칠 계정이 이미 다른 계정에 병합되어 있습니다.", + "en": "The account to merge has already been merged into another account.", + }, + "already_reverted": { + "ko": "이미 되돌린 병합입니다.", + "en": "This merge has already been reverted.", + }, + "no_source": { + "ko": "병합할 계정 정보를 찾을 수 없습니다. 다시 시도해 주세요.", + "en": "We couldn't find the account to merge. Please try again.", + }, +} + def generate_random_password(length: int = 16) -> str: alphabet = string.ascii_letters + string.digits + string.punctuation diff --git a/app/core/middleware/host_urlconf.py b/app/core/middleware/host_urlconf.py new file mode 100644 index 0000000..389d8ab --- /dev/null +++ b/app/core/middleware/host_urlconf.py @@ -0,0 +1,18 @@ +from collections.abc import Callable + +from django.conf import settings +from django.http import HttpRequest, HttpResponse + + +class HostUrlconfMiddleware: + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + self.rules = getattr(settings, "HOST_URLCONFS", []) + + def __call__(self, request: HttpRequest) -> HttpResponse: + host = request.get_host().partition(":")[0] + for pattern, urlconf in self.rules: + if pattern.match(host): + request.urlconf = urlconf + break + return self.get_response(request) diff --git a/app/core/middleware/response_exception.py b/app/core/middleware/response_exception.py new file mode 100644 index 0000000..f8f4ad0 --- /dev/null +++ b/app/core/middleware/response_exception.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from collections.abc import Callable + +from django.http import HttpRequest, HttpResponse + + +class ResponseException(Exception): + def __init__(self, response: HttpResponse) -> None: + super().__init__(response) + self.response = response + + +class ResponseExceptionMiddleware: + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + return self.get_response(request) + + def process_exception(self, request: HttpRequest, exception: Exception) -> HttpResponse | None: + if isinstance(exception, ResponseException): + return exception.response + return None diff --git a/app/core/settings.py b/app/core/settings.py index 94dbf2d..b6ef2e1 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -1,5 +1,6 @@ import os import pathlib +import re import types import typing @@ -154,8 +155,8 @@ ] MIDDLEWARE = [ - # Django default middlewares "django.middleware.security.SecurityMiddleware", + "core.middleware.host_urlconf.HostUrlconfMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -178,9 +179,13 @@ "core.middleware.thread_middleware.ThreadLocalMiddleware", # Request Response Logger "core.middleware.request_response_logger.RequestResponseLogger", + "core.middleware.response_exception.ResponseExceptionMiddleware", ] ROOT_URLCONF = "core.urls" +HOST_URLCONFS = [ + (re.compile(r"^accounts"), "core.account_urls"), +] TEMPLATES = [ { @@ -195,7 +200,7 @@ "django.contrib.messages.context_processors.messages", "django.template.context_processors.request", ], - "builtins": ["core.templatetags.data_uri"], + "builtins": ["core.templatetags.data_uri", "core.templatetags.i18n_extras"], }, }, ] diff --git a/app/core/templatetags/i18n_extras.py b/app/core/templatetags/i18n_extras.py new file mode 100644 index 0000000..2f36a3a --- /dev/null +++ b/app/core/templatetags/i18n_extras.py @@ -0,0 +1,9 @@ +from django import template +from django.utils.translation import get_language + +register = template.Library() + + +@register.simple_tag +def is_english() -> bool: + return (get_language() or "").lower().startswith("en") diff --git a/app/core/urls.py b/app/core/urls.py index 844274e..2cb76d7 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -52,6 +52,8 @@ # Django-Allauth path("accounts/", include("allauth.urls")), path("authn/social/", include("allauth.headless.urls")), + # accounts.pycon.kr + path("account/", include("user.urls")), # V1 API re_path("^v1/", include((v1_apis, "v1"), namespace="v1")), # API Docs diff --git a/app/user/models/merge.py b/app/user/models/merge.py index 748a7f5..10846f7 100644 --- a/app/user/models/merge.py +++ b/app/user/models/merge.py @@ -5,6 +5,7 @@ from itertools import chain from allauth.account.models import EmailAddress +from core.const.account import MERGE_MESSAGES from core.models import BaseAbstractModel from core.util.dateutil import now_aware from django.apps import apps @@ -26,6 +27,15 @@ from .user import UserExt +class MergeError(ValueError): + def __init__(self, code: str) -> None: + self.code = code + super().__init__(MERGE_MESSAGES[code]["ko"]) + + def localized(self, *, en: bool) -> str: + return MERGE_MESSAGES[self.code]["en" if en else "ko"] + + @lru_cache(maxsize=1) def _movable_relations() -> Iterable[tuple[type[Model], Iterable[ForeignKey]]]: authorship = frozenset( @@ -62,14 +72,11 @@ def is_self_merge(self) -> bool: @staticmethod def assert_self_mergeable(source: UserExt, target: UserExt) -> None: if not EmailAddress.objects.filter(user=target).exists(): - msg = "남길 계정에 인증된 이메일이 필요합니다. 이메일을 추가하고 인증한 뒤 다시 시도해 주세요." - raise ValueError(msg) + raise MergeError("target_no_verified_email") if EmailAddress.objects.filter(user=target, verified=False).exists(): - msg = "남길 계정에 인증되지 않은 이메일이 있습니다. 인증을 완료하거나 해당 이메일을 삭제한 뒤 다시 시도해 주세요." - raise ValueError(msg) + raise MergeError("target_unverified_email") if EmailAddress.objects.filter(user=source, verified=False).exists(): - msg = "합칠 계정에 인증되지 않은 이메일이 있습니다. 해당 계정으로 로그인해 인증을 완료하거나 삭제한 뒤 다시 시도해 주세요." - raise ValueError(msg) + raise MergeError("source_unverified_email") def _create_merge_objects( self, model: type[Model], fields: Iterable[ForeignKey] @@ -109,11 +116,11 @@ def _create_merge_objects( @atomic def merge(self) -> None: if self.source.pk == self.target.pk: - raise ValueError("같은 계정끼리는 병합할 수 없습니다.") + raise MergeError("same_account") if self.target.merged_to_id is not None: - raise ValueError("남길 계정이 이미 다른 계정에 병합되어 있습니다.") + raise MergeError("target_already_merged") if self.source.merged_to_id is not None: - raise ValueError("합칠 계정이 이미 다른 계정에 병합되어 있습니다.") + raise MergeError("source_already_merged") merge_objects = chain.from_iterable(self._create_merge_objects(m, fs) for m, fs in _movable_relations()) for merge_object in UserMergeObject.objects.bulk_create(merge_objects): @@ -126,7 +133,7 @@ def merge(self) -> None: @atomic def unmerge(self) -> None: if self.reverted_at is not None: - raise ValueError("이미 되돌린 병합입니다.") + raise MergeError("already_reverted") for merge_object in self.merged_objects.select_related("target_type"): merge_object.history = self diff --git a/app/user/templates/user/_provider_forms.html b/app/user/templates/user/_provider_forms.html new file mode 100644 index 0000000..484e4b7 --- /dev/null +++ b/app/user/templates/user/_provider_forms.html @@ -0,0 +1,23 @@ +
+ {% csrf_token %} + + + {% for provider in providers %} + + {% endfor %} +
diff --git a/app/user/templates/user/account_base.html b/app/user/templates/user/account_base.html new file mode 100644 index 0000000..7abb653 --- /dev/null +++ b/app/user/templates/user/account_base.html @@ -0,0 +1,33 @@ +{% is_english as en %} + + + + + {% block title %}{% if en %}Account · PyCon Korea{% else %}계정 · PyCon Korea{% endif %}{% endblock %} + + + + +
+
+
+ + diff --git a/app/user/templates/user/account_home.html b/app/user/templates/user/account_home.html new file mode 100644 index 0000000..29fcdc7 --- /dev/null +++ b/app/user/templates/user/account_home.html @@ -0,0 +1,10 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Account · PyCon Korea{% else %}계정 · PyCon Korea{% endif %}{% endblock %} +{% block body %} +

{% if en %}Account{% else %}계정 관리{% endif %}

+

{{ user.email }}

+

{% if en %}Merge another account{% else %}다른 계정 병합{% endif %}

+

+ {% if en %}Email management and password change are coming soon.{% else %}이메일 관리 · 비밀번호 변경은 준비 중입니다.{% endif %} +

+{% endblock %} diff --git a/app/user/templates/user/account_login.html b/app/user/templates/user/account_login.html new file mode 100644 index 0000000..1df8811 --- /dev/null +++ b/app/user/templates/user/account_login.html @@ -0,0 +1,9 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Sign in · PyCon Korea{% else %}로그인 · PyCon Korea{% endif %}{% endblock %} +{% block body %} +

{% if en %}Sign in to PyCon Korea{% else %}PyCon Korea 계정 로그인{% endif %}

+

{% if en %}Sign in with a social account.{% else %}소셜 계정으로 로그인하세요.{% endif %}

+ {% include "user/_provider_forms.html" %} +

{% if en %}or{% else %}또는{% endif %}

+

{% if en %}Sign in with email{% else %}이메일로 로그인{% endif %}

+{% endblock %} diff --git a/app/user/templates/user/account_password_login.html b/app/user/templates/user/account_password_login.html new file mode 100644 index 0000000..7a9b6c3 --- /dev/null +++ b/app/user/templates/user/account_password_login.html @@ -0,0 +1,17 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Sign in with email · PyCon Korea{% else %}이메일 로그인 · PyCon Korea{% endif %}{% endblock %} +{% block body %} +

{% if en %}Sign in with email{% else %}이메일로 로그인{% endif %}

+ {% if error %}

{{ error }}

{% endif %} +
+ {% csrf_token %} + + + +
+

{% if en %}Back to social sign-in{% else %}소셜 로그인으로 돌아가기{% endif %}

+{% endblock %} diff --git a/app/user/templates/user/merge_confirm.html b/app/user/templates/user/merge_confirm.html new file mode 100644 index 0000000..b0af661 --- /dev/null +++ b/app/user/templates/user/merge_confirm.html @@ -0,0 +1,22 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Confirm merge · PyCon Korea{% else %}계정 병합 확인 · PyCon Korea{% endif %}{% endblock %} +{% block body %} +

{% if en %}Confirm account merge{% else %}계정 병합 확인{% endif %}

+

{% if en %}Account to merge{% else %}합칠 계정{% endif %}
{{ source.email|default:source.username }}

+

{% if en %}Account to keep{% else %}남길 계정{% endif %}
{{ target.email|default:target.username }}

+ {% if error %} +
{{ error }}
+

{% if en %}Go back{% else %}돌아가기{% endif %}

+ {% else %} + {% if en %} +

The orders, talks, and other data of the account to merge will move to the account to keep, and the merged account will be deactivated. This is hard to undo.

+ {% else %} +

합칠 계정의 주문·발표 등 데이터가 남길 계정으로 이동하며, 합칠 계정은 비활성화됩니다. 이 작업은 되돌리기 어렵습니다.

+ {% endif %} +
+ {% csrf_token %} + +
+

{% if en %}Cancel{% else %}취소{% endif %}

+ {% endif %} +{% endblock %} diff --git a/app/user/templates/user/merge_result.html b/app/user/templates/user/merge_result.html new file mode 100644 index 0000000..87b11fa --- /dev/null +++ b/app/user/templates/user/merge_result.html @@ -0,0 +1,16 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Account merge · PyCon Korea{% else %}계정 병합 · PyCon Korea{% endif %}{% endblock %} +{% block body %} + {% if error %} +

{% if en %}Can't merge{% else %}병합할 수 없습니다{% endif %}

+
{{ error }}
+ {% else %} +

{% if en %}Merge complete{% else %}병합 완료{% endif %}

+ {% if en %} +

Your accounts have been merged into {{ target.email|default:target.username }}.

+ {% else %} +

계정이 {{ target.email|default:target.username }}(으)로 병합되었습니다.

+ {% endif %} + {% endif %} +

{% if en %}Back to account{% else %}계정으로 돌아가기{% endif %}

+{% endblock %} diff --git a/app/user/templates/user/merge_start.html b/app/user/templates/user/merge_start.html new file mode 100644 index 0000000..033143c --- /dev/null +++ b/app/user/templates/user/merge_start.html @@ -0,0 +1,12 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Merge account · PyCon Korea{% else %}계정 병합 · PyCon Korea{% endif %}{% endblock %} +{% block body %} +

{% if en %}Merge another account{% else %}다른 계정 병합{% endif %}

+ {% if en %} +

Sign in with the social account you want to merge, and its data will move to your current account ({{ user.email }}).

+ {% else %} +

합칠 계정의 소셜 로그인으로 인증하면, 그 계정의 데이터가 현재 로그인한 계정({{ user.email }})으로 이동합니다.

+ {% endif %} + {% include "user/_provider_forms.html" %} +

{% if en %}Back to account{% else %}계정으로 돌아가기{% endif %}

+{% endblock %} diff --git a/app/user/test/merge_view_test.py b/app/user/test/merge_view_test.py new file mode 100644 index 0000000..8dee5ea --- /dev/null +++ b/app/user/test/merge_view_test.py @@ -0,0 +1,283 @@ +import pytest +from allauth.account.models import EmailAddress +from allauth.core.exceptions import ImmediateHttpResponse +from allauth.socialaccount.models import SocialAccount, SocialLogin +from core.authn.allauth_adapter import SocialAccountLoggingAdapter +from django.urls import reverse +from shop.order.models import Order +from user.models import UserExt +from user.models.merge import UserMergeHistory +from user.views import HEADLESS_PROVIDER_REDIRECT_URL, MERGE_SOURCE_SESSION_KEY + + +@pytest.fixture +def source_user(db) -> UserExt: + user = UserExt.objects.create_user(username="source", email="source@example.com") + EmailAddress.objects.create(user=user, email="source@example.com", verified=True, primary=True) + return user + + +@pytest.fixture +def target_user(db) -> UserExt: + user = UserExt.objects.create_user(username="target", email="target@example.com") + EmailAddress.objects.create(user=user, email="target@example.com", verified=True, primary=True) + return user + + +def _stash_source(client, source): + session = client.session + session[MERGE_SOURCE_SESSION_KEY] = source.pk + session.save() + + +# ---- MergeConfirmView --------------------------------------------------------- + + +def test_confirm_get_shows_both_accounts(client, source_user, target_user): + client.force_login(target_user) + _stash_source(client, source_user) + + response = client.get(reverse("account-merge-confirm")) + + assert response.status_code == 200 + assert b"source@example.com" in response.content + assert b"target@example.com" in response.content + + +def test_confirm_post_executes_merge(client, source_user, target_user): + order = Order.objects.create(user=source_user, name="src-order") + client.force_login(target_user) + _stash_source(client, source_user) + + response = client.post(reverse("account-merge-confirm")) + + assert response.status_code == 200 + order.refresh_from_db() + source_user.refresh_from_db() + assert order.user_id == target_user.id + assert source_user.is_active is False + assert source_user.merged_to_id == target_user.id + assert MERGE_SOURCE_SESSION_KEY not in client.session + + +def test_confirm_post_sets_self_merge(client, source_user, target_user): + client.force_login(target_user) + _stash_source(client, source_user) + + client.post(reverse("account-merge-confirm")) + + history = UserMergeHistory.objects.get(source=source_user, target=target_user) + assert history.created_by_id == target_user.id + assert history.is_self_merge is True + + +def test_confirm_blocks_when_source_has_unverified_email(client, source_user, target_user): + EmailAddress.objects.create(user=source_user, email="extra@example.com", verified=False) + client.force_login(target_user) + _stash_source(client, source_user) + + response = client.post(reverse("account-merge-confirm")) + + source_user.refresh_from_db() + assert source_user.is_active is True # 병합 안 됨 + assert b"\xec\x9d\xb8\xec\xa6\x9d" in response.content # "인증" 안내 노출 + + +def test_confirm_no_source_in_session_returns_400(client, target_user): + client.force_login(target_user) + + response = client.get(reverse("account-merge-confirm")) + + assert response.status_code == 400 + + +def test_confirm_requires_authentication(client, source_user): + _stash_source(client, source_user) + + response = client.get(reverse("account-merge-confirm")) + + assert response.status_code == 302 + assert reverse("account-login") in response["Location"] + + +def test_start_lists_provider_forms(client, target_user): + client.force_login(target_user) + + response = client.get(reverse("account-merge-start")) + + assert response.status_code == 200 + assert HEADLESS_PROVIDER_REDIRECT_URL.encode() in response.content + assert b'name="process" value="connect"' in response.content + + +# ---- AccountLoginView --------------------------------------------------------- + + +def test_login_page_renders_provider_forms(client, db): + response = client.get(reverse("account-login")) + + assert response.status_code == 200 + assert HEADLESS_PROVIDER_REDIRECT_URL.encode() in response.content + assert b'name="process" value="login"' in response.content + + +def test_login_redirects_authenticated_user_to_home(client, target_user): + client.force_login(target_user) + + response = client.get(reverse("account-login")) + + assert response.status_code == 302 + assert response["Location"] == reverse("account-home") + + +def test_home_requires_authentication(client, db): + response = client.get(reverse("account-home")) + + assert response.status_code == 302 + assert reverse("account-login") in response["Location"] + + +def test_home_links_to_merge(client, target_user): + client.force_login(target_user) + + response = client.get(reverse("account-home")) + + assert response.status_code == 200 + assert reverse("account-merge-start").encode() in response.content + + +# ---- PasswordLoginView -------------------------------------------------------- + + +@pytest.fixture +def password_user(db) -> UserExt: + user = UserExt.objects.create_user(username="pw", email="pw@example.com", password="s3cret-pw-123") # nosec B106 + EmailAddress.objects.create(user=user, email="pw@example.com", verified=True, primary=True) + return user + + +def test_password_login_page_renders_form(client, db): + response = client.get(reverse("account-password-login")) + + assert response.status_code == 200 + assert b'name="login"' in response.content + assert b'name="password"' in response.content + + +def test_login_page_links_to_password_login(client, db): + response = client.get(reverse("account-login")) + + assert reverse("account-password-login").encode() in response.content + + +def test_password_login_success_authenticates_and_redirects(client, password_user): + response = client.post(reverse("account-password-login"), {"login": "pw@example.com", "password": "s3cret-pw-123"}) + + assert response.status_code == 302 + assert client.session.get("_auth_user_id") == str(password_user.pk) + + +def test_password_login_wrong_password_shows_error(client, password_user): + response = client.post(reverse("account-password-login"), {"login": "pw@example.com", "password": "wrong"}) + + assert response.status_code == 400 + assert "_auth_user_id" not in client.session + + +def test_password_login_redirects_authenticated_user(client, target_user): + client.force_login(target_user) + + response = client.get(reverse("account-password-login")) + + assert response.status_code == 302 + assert response["Location"] == reverse("account-home") + + +# ---- 호스트 스코프 라우팅 (accounts.*) ---------------------------------------- + + +def test_accounts_host_serves_login_at_root(client, db): + response = client.get("/login/", HTTP_HOST="accounts.pycon.kr") + + assert response.status_code == 200 + assert HEADLESS_PROVIDER_REDIRECT_URL.encode() in response.content + + +def test_default_host_has_no_root_login(client, db): + # accounts.* 가 아니면 /login/ 이 아니라 /account/login/ 이어야 한다. + assert client.get("/login/").status_code == 404 + assert client.get(reverse("account-login")).status_code == 200 + + +# ---- pre_social_login adapter hook ------------------------------------------- + + +def test_pre_social_login_triggers_merge_confirm(rf, source_user, target_user): + account = SocialAccount.objects.create(user=source_user, provider="google", uid="src-google", extra_data={}) + sociallogin = SocialLogin(user=source_user, account=account) + sociallogin.state = {"process": "connect"} + request = rf.get("/") + request.user = target_user + request.session = {} + + with pytest.raises(ImmediateHttpResponse): + SocialAccountLoggingAdapter().pre_social_login(request, sociallogin) + + assert request.session[MERGE_SOURCE_SESSION_KEY] == source_user.pk + + +def test_pre_social_login_ignores_own_account(rf, source_user): + account = SocialAccount.objects.create(user=source_user, provider="google", uid="own", extra_data={}) + sociallogin = SocialLogin(user=source_user, account=account) + sociallogin.state = {"process": "connect"} + request = rf.get("/") + request.user = source_user # 같은 계정 재연결 — 병합 아님 + request.session = {} + + SocialAccountLoggingAdapter().pre_social_login(request, sociallogin) # no raise + + assert MERGE_SOURCE_SESSION_KEY not in request.session + + +def test_pre_social_login_ignores_plain_login(rf, source_user, target_user): + account = SocialAccount.objects.create(user=source_user, provider="google", uid="src-google", extra_data={}) + sociallogin = SocialLogin(user=source_user, account=account) + sociallogin.state = {"process": "login"} # connect 가 아니면 무시 + request = rf.get("/") + request.user = target_user + request.session = {} + + SocialAccountLoggingAdapter().pre_social_login(request, sociallogin) # no raise + + assert MERGE_SOURCE_SESSION_KEY not in request.session + + +# ---- 다국어 (en) -------------------------------------------------------------- + + +def test_login_page_english_via_accept_language(client, db): + response = client.get(reverse("account-login"), HTTP_ACCEPT_LANGUAGE="en") + + assert b"Sign in with Google" in response.content + + +def test_confirm_english_via_accept_language(client, source_user, target_user): + client.force_login(target_user) + _stash_source(client, source_user) + + response = client.get(reverse("account-merge-confirm"), HTTP_ACCEPT_LANGUAGE="en") + + assert b"Confirm account merge" in response.content + assert b"Account to keep" in response.content + + +def test_confirm_english_error_message(client, source_user, target_user): + EmailAddress.objects.create(user=source_user, email="extra@example.com", verified=False) + client.force_login(target_user) + _stash_source(client, source_user) + + response = client.post(reverse("account-merge-confirm"), HTTP_ACCEPT_LANGUAGE="en") + + source_user.refresh_from_db() + assert source_user.is_active is True # 병합 안 됨 + assert b"unverified email" in response.content # 영어 에러 메시지 diff --git a/app/user/urls.py b/app/user/urls.py new file mode 100644 index 0000000..dd394b8 --- /dev/null +++ b/app/user/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from user.views import AccountHomeView, AccountLoginView, MergeConfirmView, MergeStartView, PasswordLoginView + +urlpatterns = [ + path("", AccountHomeView.as_view(), name="account-home"), + path("login/", AccountLoginView.as_view(), name="account-login"), + path("login/password/", PasswordLoginView.as_view(), name="account-password-login"), + path("merge/", MergeStartView.as_view(), name="account-merge-start"), + path("merge/confirm/", MergeConfirmView.as_view(), name="account-merge-confirm"), +] diff --git a/app/user/views.py b/app/user/views.py new file mode 100644 index 0000000..7ec7988 --- /dev/null +++ b/app/user/views.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from http import HTTPStatus + +from allauth.account.forms import LoginForm +from core.const.account import MERGE_MESSAGES, MERGE_SOURCE_SESSION_KEY +from core.middleware.response_exception import ResponseException +from core.templatetags.i18n_extras import is_english +from django.contrib.auth import get_user_model +from django.db.transaction import atomic +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.urls import reverse, reverse_lazy +from django.utils.http import url_has_allowed_host_and_scheme, urlencode +from django.views import View +from user.models.merge import MergeError, UserMergeHistory + +HEADLESS_PROVIDER_REDIRECT_URL = reverse_lazy("headless:browser:socialaccount:redirect_to_provider") +PROVIDERS = [ + {"id": "google", "name_ko": "Google", "name_en": "Google"}, + {"id": "naver", "name_ko": "네이버", "name_en": "Naver"}, + {"id": "kakao", "name_ko": "카카오", "name_en": "Kakao"}, +] + +User = get_user_model() + + +def check_login(request: HttpRequest) -> None: + if not request.user.is_authenticated: + login_url = f"{reverse('account-login')}?{urlencode({'next': request.get_full_path()})}" + raise ResponseException(redirect(login_url)) + + +def redirect_if_authenticated(request: HttpRequest, target: str) -> None: + if request.user.is_authenticated: + raise ResponseException(redirect(target)) + + +def _login_target(request: HttpRequest) -> str: + if (n := request.GET.get("next")) and url_has_allowed_host_and_scheme(n, allowed_hosts=None): + return n + return reverse("account-home") + + +class AccountHomeView(View): + def get(self, request: HttpRequest) -> HttpResponse: + check_login(request) + return render(request, "user/account_home.html", {"user": request.user}) + + +class AccountLoginView(View): + def get(self, request: HttpRequest) -> HttpResponse: + target = _login_target(request) + redirect_if_authenticated(request, target) + return render( + request=request, + template_name="user/account_login.html", + context={ + "providers": PROVIDERS, + "redirect_url": HEADLESS_PROVIDER_REDIRECT_URL, + "callback_url": target, + "process": "login", + }, + ) + + +class PasswordLoginView(View): + def get(self, request: HttpRequest) -> HttpResponse: + redirect_if_authenticated(request, _login_target(request)) + return render(request, "user/account_password_login.html") + + def post(self, request: HttpRequest) -> HttpResponse: + target = _login_target(request) + redirect_if_authenticated(request, target) + + form = LoginForm(data=request.POST, request=request) + if form.is_valid(): + return form.login(request, redirect_url=target) + + return render( + request=request, + template_name="user/account_password_login.html", + context={"error": MERGE_MESSAGES["wrong_account_or_password"]["en" if is_english() else "ko"]}, + status=HTTPStatus.BAD_REQUEST, + ) + + +class MergeStartView(View): + def get(self, request: HttpRequest) -> HttpResponse: + check_login(request) + return render( + request=request, + template_name="user/merge_start.html", + context={ + "providers": PROVIDERS, + "redirect_url": HEADLESS_PROVIDER_REDIRECT_URL, + "callback_url": reverse("account-merge-start"), + "process": "connect", + "user": request.user, + }, + ) + + +class MergeConfirmView(View): + def get(self, request: HttpRequest) -> HttpResponse: + check_login(request) + return render( + request=request, + template_name="user/merge_confirm.html", + context=self._confirm_context(self._source(request), request.user), + ) + + def post(self, request: HttpRequest) -> HttpResponse: + check_login(request) + source = self._source(request) + + try: + with atomic(): + UserMergeHistory.assert_self_mergeable(source, request.user) + UserMergeHistory.objects.create(source=source, target=request.user).merge() + except MergeError as e: + return render( + request=request, + template_name="user/merge_confirm.html", + context=self._confirm_context(source, request.user, error=e.localized(en=is_english())), + ) + + request.session.pop(MERGE_SOURCE_SESSION_KEY, None) + return render(request, "user/merge_result.html", {"target": request.user}) + + def _source(self, request: HttpRequest) -> User: + source_id = request.session.get(MERGE_SOURCE_SESSION_KEY) + if source_id and source_id != request.user.pk and (source := User.objects.filter(pk=source_id).first()): + return source + raise ResponseException( + render( + request=request, + template_name="user/merge_result.html", + context={"error": MERGE_MESSAGES["no_source"]["en" if is_english() else "ko"]}, + status=HTTPStatus.BAD_REQUEST, + ) + ) + + def _confirm_context(self, source: User, target: User, *, error: str | None = None) -> dict: + if error is None: + try: + UserMergeHistory.assert_self_mergeable(source, target) + except MergeError as e: + error = e.localized(en=is_english()) + return {"source": source, "target": target, "error": error} From 16a81799cd72e3820a7d08a95877ad4d4dc59f66 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 4 Jul 2026 19:43:21 +0900 Subject: [PATCH 03/70] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9C=A0=EC=A0=80=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/filtersets/merge.py | 14 ++ app/admin_api/serializers/merge.py | 56 +++++ app/admin_api/test/user_merge_test.py | 220 ++++++++++++++++++ app/admin_api/urls.py | 2 + app/admin_api/views/merge.py | 84 +++++++ app/core/openapi/ui_hints.py | 10 +- .../serializer/pk_related_serializer_field.py | 15 ++ app/user/models/merge.py | 15 +- 8 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 app/admin_api/filtersets/merge.py create mode 100644 app/admin_api/serializers/merge.py create mode 100644 app/admin_api/test/user_merge_test.py create mode 100644 app/admin_api/views/merge.py create mode 100644 app/core/serializer/pk_related_serializer_field.py diff --git a/app/admin_api/filtersets/merge.py b/app/admin_api/filtersets/merge.py new file mode 100644 index 0000000..b5f8306 --- /dev/null +++ b/app/admin_api/filtersets/merge.py @@ -0,0 +1,14 @@ +from django.db.models import QuerySet +from django_filters import rest_framework as filters +from user.models.merge import UserMergeHistory + + +class UserMergeAdminFilterSet(filters.FilterSet): + reverted = filters.BooleanFilter(method="filter_reverted") + + class Meta: + model = UserMergeHistory + fields = ["source", "target", "reverted"] + + def filter_reverted(self, queryset: QuerySet, name: str, value: bool) -> QuerySet: + return queryset.filter(reverted_at__isnull=not value) diff --git a/app/admin_api/serializers/merge.py b/app/admin_api/serializers/merge.py new file mode 100644 index 0000000..31da5e6 --- /dev/null +++ b/app/admin_api/serializers/merge.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from core.const.serializer import COMMON_ADMIN_FIELDS +from core.serializer.base_abstract_serializer import BaseAbstractSerializer +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from core.serializer.pk_related_serializer_field import PrimaryKeyRelatedSerializerField +from django.db.transaction import atomic +from rest_framework import serializers +from user.models import UserExt +from user.models.merge import MergeError, UserMergeHistory, UserMergeObject + + +class UserMergeHistoryListAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class UserExtSerializer(serializers.ModelSerializer): + str_repr = serializers.CharField(source="__str__", read_only=True) + + class Meta: + model = UserExt + fields = ("id", "username", "email", "nickname", "is_active", "str_repr") + + source = PrimaryKeyRelatedSerializerField(queryset=UserExt.objects.all(), serializer=UserExtSerializer) + target = PrimaryKeyRelatedSerializerField(queryset=UserExt.objects.all(), serializer=UserExtSerializer) + is_self_merge = serializers.BooleanField(read_only=True) + + class Meta: + model = UserMergeHistory + fields = COMMON_ADMIN_FIELDS + ("source", "target", "is_self_merge", "reverted_at") + + def validate(self, attrs: dict) -> dict: + if attrs["source"] == attrs["target"]: + raise serializers.ValidationError({"target": MergeError("same_account").localized(en=False)}) + return attrs + + def create(self, validated_data: dict) -> UserMergeHistory: + try: + with atomic(): + history = super().create(validated_data) + history.merge() + except MergeError as e: + raise serializers.ValidationError({"detail": e.localized(en=False)}) from e + return history + + +class UserMergeHistoryAdminSerializer(UserMergeHistoryListAdminSerializer): + class UserMergeObjectAdminSerializer(serializers.ModelSerializer): + target_type_app = serializers.CharField(read_only=True) + target_type_resource = serializers.CharField(read_only=True) + + class Meta: + model = UserMergeObject + fields = ("id", "target_type_app", "target_type_resource", "target_id", "field_names") + + merged_objects = UserMergeObjectAdminSerializer(many=True, read_only=True) + + class Meta(UserMergeHistoryListAdminSerializer.Meta): + fields = UserMergeHistoryListAdminSerializer.Meta.fields + ("merged_objects",) diff --git a/app/admin_api/test/user_merge_test.py b/app/admin_api/test/user_merge_test.py new file mode 100644 index 0000000..173fe20 --- /dev/null +++ b/app/admin_api/test/user_merge_test.py @@ -0,0 +1,220 @@ +import http + +import pytest +from allauth.account.models import EmailAddress +from django.urls import reverse +from rest_framework.test import APIClient +from shop.order.models import Order +from user.models import UserExt +from user.models.merge import UserMergeHistory + +LIST = "v1:admin-user-merge-list" +DETAIL = "v1:admin-user-merge-detail" +PREVIEW = "v1:admin-user-merge-preview" +REVERT = "v1:admin-user-merge-revert" + + +@pytest.fixture +def source_user(db) -> UserExt: + return UserExt.objects.create_user(username="source", email="source@example.com") + + +@pytest.fixture +def target_user(db) -> UserExt: + return UserExt.objects.create_user(username="target", email="target@example.com") + + +# ---- Auth ------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_unauthenticated_list_rejected(): + response = APIClient().get(reverse(LIST)) + assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED) + + +@pytest.mark.django_db +def test_non_superuser_preview_rejected(customer_user, source_user, target_user): + client = APIClient() + client.force_authenticate(user=customer_user) + response = client.post(reverse(PREVIEW), {"source": source_user.id, "target": target_user.id}, format="json") + assert response.status_code == http.HTTPStatus.FORBIDDEN + + +# ---- Preview (savepoint 실행 후 롤백) ---------------------------------------- + + +@pytest.mark.django_db +def test_preview_lists_actual_merge_objects(api_client, source_user, target_user): + order = Order.objects.create(user=source_user, name="src-order") + + response = api_client.post(reverse(PREVIEW), {"source": source_user.id, "target": target_user.id}, format="json") + assert response.status_code == http.HTTPStatus.OK, response.json() + body = response.json() + assert body["source"]["id"] == source_user.id + assert body["target"]["id"] == target_user.id + + assert body["is_self_merge"] is False + + moved = next(m for m in body["merged_objects"] if m["target_id"] == str(order.id)) + assert moved["target_type_app"] == "shop" + assert moved["target_type_resource"] == "order" + assert moved["field_names"] == ["user"] + assert body["source"]["is_active"] is False # post-merge 상태 노출(source 비활성) + + +@pytest.mark.django_db +def test_preview_rolls_back_and_persists_nothing(api_client, source_user, target_user): + Order.objects.create(user=source_user, name="src-order") + + api_client.post(reverse(PREVIEW), {"source": source_user.id, "target": target_user.id}, format="json") + + assert Order.objects.get(name="src-order").user_id == source_user.id + assert not UserMergeHistory.objects.exists() + source_user.refresh_from_db() + assert source_user.is_active is True + assert source_user.merged_to_id is None + + +@pytest.mark.django_db +def test_preview_same_account_rejected(api_client, source_user): + response = api_client.post(reverse(PREVIEW), {"source": source_user.id, "target": source_user.id}, format="json") + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +# ---- Create (execute merge) ------------------------------------------------- + + +@pytest.mark.django_db +def test_create_merges_and_repoints(api_client, superuser, source_user, target_user): + order = Order.objects.create(user=source_user, name="src-order") + + response = api_client.post(reverse(LIST), {"source": source_user.id, "target": target_user.id}, format="json") + assert response.status_code == http.HTTPStatus.CREATED, response.json() + body = response.json() + assert body["source"]["id"] == source_user.id + assert body["target"]["id"] == target_user.id + assert body["is_self_merge"] is False + assert superuser.email in body["created_by"] # StringRelatedField → str(UserExt) + assert body["reverted_at"] is None + + # Order 는 라우트 컨벤션상 shop/order. + [moved] = body["merged_objects"] + assert moved["target_type_app"] == "shop" + assert moved["target_type_resource"] == "order" + assert moved["target_id"] == str(order.id) + assert moved["field_names"] == ["user"] + + order.refresh_from_db() + source_user.refresh_from_db() + assert order.user_id == target_user.id + assert source_user.is_active is False + assert source_user.merged_to_id == target_user.id + + +@pytest.mark.django_db +def test_create_same_account_rejected(api_client, source_user): + response = api_client.post(reverse(LIST), {"source": source_user.id, "target": source_user.id}, format="json") + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert UserMergeHistory.objects.count() == 0 + + +@pytest.mark.django_db +def test_create_into_already_merged_target_rejected(api_client, source_user, target_user): + other = UserExt.objects.create_user(username="other", email="other@example.com") + UserMergeHistory.objects.create(source=target_user, target=other).merge() # target 이 이미 병합됨 + + response = api_client.post(reverse(LIST), {"source": source_user.id, "target": target_user.id}, format="json") + assert response.status_code == http.HTTPStatus.BAD_REQUEST + # 실패한 병합 기록이 남으면 안 됨(atomic rollback). + assert not UserMergeHistory.objects.filter(source=source_user).exists() + + +@pytest.mark.django_db +def test_create_ignores_self_mergeable_constraints(api_client, source_user, target_user): + # 운영자 병합은 assert_self_mergeable 미적용 — 인증 이메일이 없어도 성공. + assert not EmailAddress.objects.filter(user=target_user).exists() + response = api_client.post(reverse(LIST), {"source": source_user.id, "target": target_user.id}, format="json") + assert response.status_code == http.HTTPStatus.CREATED, response.json() + + +# ---- List / Retrieve -------------------------------------------------------- + + +@pytest.mark.django_db +def test_list_and_retrieve(api_client, source_user, target_user): + merge = UserMergeHistory.objects.create(source=source_user, target=target_user) + merge.merge() + + response = api_client.get(reverse(LIST)) + assert response.status_code == http.HTTPStatus.OK + rows = response.json()["results"] + assert any(row["id"] == str(merge.id) for row in rows) + + response = api_client.get(reverse(DETAIL, kwargs={"pk": merge.id})) + assert response.status_code == http.HTTPStatus.OK + assert response.json()["source"]["id"] == source_user.id + + +@pytest.mark.django_db +def test_list_excludes_merged_objects_retrieve_includes(api_client, source_user, target_user): + Order.objects.create(user=source_user, name="src-order") + merge = UserMergeHistory.objects.create(source=source_user, target=target_user) + merge.merge() + + [row] = api_client.get(reverse(LIST)).json()["results"] + assert "merged_objects" not in row # list 는 대량 가능성 때문에 제외 + + detail = api_client.get(reverse(DETAIL, kwargs={"pk": merge.id})).json() + assert len(detail["merged_objects"]) == 1 + + +@pytest.mark.django_db +def test_list_filter_reverted(api_client, source_user, target_user): + merge = UserMergeHistory.objects.create(source=source_user, target=target_user) + merge.merge() + + assert api_client.get(reverse(LIST), {"reverted": "true"}).json()["results"] == [] + assert len(api_client.get(reverse(LIST), {"reverted": "false"}).json()["results"]) == 1 + + +# ---- Revert ----------------------------------------------------------------- + + +@pytest.mark.django_db +def test_revert_restores(api_client, source_user, target_user): + order = Order.objects.create(user=source_user, name="src-order") + merge = UserMergeHistory.objects.create(source=source_user, target=target_user) + merge.merge() + + response = api_client.post(reverse(REVERT, kwargs={"pk": merge.id})) + assert response.status_code == http.HTTPStatus.OK, response.json() + body = response.json() + assert body["reverted_at"] is not None # 재조회 없이 in-place 갱신된 history 직렬화 + + order.refresh_from_db() + source_user.refresh_from_db() + assert order.user_id == source_user.id + assert source_user.is_active is True + assert source_user.merged_to_id is None + + +@pytest.mark.django_db +def test_double_revert_rejected(api_client, source_user, target_user): + merge = UserMergeHistory.objects.create(source=source_user, target=target_user) + merge.merge() + merge.unmerge() + + response = api_client.post(reverse(REVERT, kwargs={"pk": merge.id})) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +# ---- JSON Schema (form) ----------------------------------------------------- + + +@pytest.mark.django_db +def test_json_schema_exposes_source_and_target(api_client): + response = api_client.get(reverse(LIST) + "json-schema/") + assert response.status_code == http.HTTPStatus.OK + props = response.json()["schema"]["properties"] + assert "source" in props and "target" in props diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index 90b9e41..4908e37 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -14,6 +14,7 @@ from admin_api.views.external_api.google_oauth2 import GoogleOAuth2AdminViewSet from admin_api.views.file import PublicFileAdminViewSet from admin_api.views.mcp_token import McpTokenAdminViewSet +from admin_api.views.merge import UserMergeAdminViewSet from admin_api.views.modification_audit import ModificationAuditAdminViewSet from admin_api.views.notification import ( EmailNotificationHistoryAdminViewSet, @@ -49,6 +50,7 @@ admin_user_router.register("userext", UserAdminViewSet, basename="admin-user") admin_user_router.register("organization", OrganizationAdminViewSet, basename="admin-organization") admin_user_router.register("mcptoken", McpTokenAdminViewSet, basename="admin-mcp-token") +admin_user_router.register("usermergehistory", UserMergeAdminViewSet, basename="admin-user-merge") admin_cms_router = routers.SimpleRouter() admin_cms_router.register("domaingroup", DomainGroupAdminViewSet, basename="admin-domain-group") diff --git a/app/admin_api/views/merge.py b/app/admin_api/views/merge.py new file mode 100644 index 0000000..ede74d9 --- /dev/null +++ b/app/admin_api/views/merge.py @@ -0,0 +1,84 @@ +from admin_api.filtersets.merge import UserMergeAdminFilterSet +from admin_api.serializers.merge import UserMergeHistoryAdminSerializer, UserMergeHistoryListAdminSerializer +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.pagination import AdminPagination +from core.viewset.json_schema_viewset import JsonSchemaMixin +from core.viewset.selectables_viewset import SelectablesMixin +from django.db.models import Prefetch +from django.db.models.query import QuerySet +from django.db.transaction import atomic, set_rollback +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import decorators, mixins, request, response, status, viewsets +from rest_framework.exceptions import ValidationError +from user.models.merge import MergeError, UserMergeHistory, UserMergeObject + + +@extend_schema_view( + list=extend_schema(tags=[OpenAPITag.ADMIN_USER]), + retrieve=extend_schema(tags=[OpenAPITag.ADMIN_USER]), + create=extend_schema(tags=[OpenAPITag.ADMIN_USER]), +) +class UserMergeAdminViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + JsonSchemaMixin, + SelectablesMixin, + viewsets.GenericViewSet, +): + pagination_class = AdminPagination + http_method_names = ["get", "post"] + permission_classes = [IsSuperUser] + serializer_class = UserMergeHistoryAdminSerializer + filterset_class = UserMergeAdminFilterSet + queryset = UserMergeHistory.objects.select_related_with_user("source", "target").order_by("-created_at", "-id") + + def get_queryset(self) -> QuerySet: + qs = super().get_queryset() + return ( + qs + if self.action == "list" + else qs.prefetch_related( + Prefetch( + lookup="merged_objects", + queryset=UserMergeObject.objects.select_related("target_type"), + ), + ) + ) + + def get_serializer_class(self) -> type: + if self.action == "list": + return UserMergeHistoryListAdminSerializer + return UserMergeHistoryAdminSerializer + + @extend_schema( + tags=[OpenAPITag.ADMIN_USER], + request=UserMergeHistoryAdminSerializer, + responses={status.HTTP_200_OK: UserMergeHistoryAdminSerializer}, + ) + @decorators.action(detail=False, methods=["POST"], url_path="preview") + def preview(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + with atomic(): + serializer.save() + data = serializer.data + set_rollback(True) + return response.Response(data=data) + + @extend_schema( + tags=[OpenAPITag.ADMIN_USER], + request=None, + responses={status.HTTP_200_OK: UserMergeHistoryAdminSerializer}, + ) + @decorators.action(detail=True, methods=["POST"], url_path="revert") + def revert(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + history: UserMergeHistory = self.get_object() + try: + history.unmerge() + except MergeError as e: + raise ValidationError({"detail": e.localized(en=False)}) from e + + return response.Response(data=self.get_serializer(history).data) diff --git a/app/core/openapi/ui_hints.py b/app/core/openapi/ui_hints.py index ec7368d..7f8ffb6 100644 --- a/app/core/openapi/ui_hints.py +++ b/app/core/openapi/ui_hints.py @@ -5,14 +5,16 @@ from modeltranslation.fields import TranslationField +def admin_route_for_model(model: type) -> dict: + return {"app": model._meta.app_config.name.split(".")[0], "resource": model._meta.model_name} + + def ui_hints_for_model_field(model_field: object) -> dict: hints: dict = {} if isinstance(model_field, (ForeignKey, ManyToManyField)): - hints["ui:options"] = { - "choiceApp": model_field.related_model._meta.app_config.name.split(".")[0], - "choiceResource": model_field.related_model._meta.model_name, - } + route = admin_route_for_model(model_field.related_model) + hints["ui:options"] = {"choiceApp": route["app"], "choiceResource": route["resource"]} if isinstance(model_field, ManyToManyField): return hints | {"ui:field": "m2m_select"} diff --git a/app/core/serializer/pk_related_serializer_field.py b/app/core/serializer/pk_related_serializer_field.py new file mode 100644 index 0000000..21779e4 --- /dev/null +++ b/app/core/serializer/pk_related_serializer_field.py @@ -0,0 +1,15 @@ +from typing import Any + +from rest_framework import serializers + + +class PrimaryKeyRelatedSerializerField(serializers.PrimaryKeyRelatedField): + def __init__(self, *args: Any, serializer: type[serializers.BaseSerializer], **kwargs: Any) -> None: + self.serializer = serializer + super().__init__(*args, **kwargs) + + def use_pk_only_optimization(self) -> bool: + return False + + def to_representation(self, value: Any) -> Any: + return self.serializer(value, context=self.context).data diff --git a/app/user/models/merge.py b/app/user/models/merge.py index 10846f7..a6d7244 100644 --- a/app/user/models/merge.py +++ b/app/user/models/merge.py @@ -1,12 +1,13 @@ from __future__ import annotations from collections.abc import Generator, Iterable -from functools import lru_cache +from functools import cached_property, lru_cache from itertools import chain from allauth.account.models import EmailAddress from core.const.account import MERGE_MESSAGES from core.models import BaseAbstractModel +from core.openapi.ui_hints import admin_route_for_model from core.util.dateutil import now_aware from django.apps import apps from django.contrib.contenttypes.fields import GenericForeignKey @@ -160,6 +161,18 @@ class Meta: def __str__(self) -> str: return f"{self.target_type.model}#{self.target_id} {self.field_names}" + @cached_property + def target_type_model_info(self) -> dict[str, str]: + return admin_route_for_model(self.target_type.model_class()) + + @property + def target_type_app(self) -> str: + return self.target_type_model_info["app"] + + @property + def target_type_resource(self) -> str: + return self.target_type_model_info["resource"] + def apply(self) -> None: self._repoint(self.history.target_id, f"account_merge:{self.history_id}") From 86daf02747775c2594554bf6802a41fd286ba8de Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 4 Jul 2026 21:43:43 +0900 Subject: [PATCH 04/70] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/authn/allauth_adapter.py | 3 + app/core/const/account.py | 46 ++++++ app/core/settings.py | 3 +- .../email/email_confirmation_message.html | 25 +++ .../email/email_confirmation_subject.txt | 1 + app/user/account_views/__init__.py | 0 app/user/account_views/account.py | 58 +++++++ app/user/account_views/email.py | 96 +++++++++++ app/user/account_views/merge.py | 73 +++++++++ app/user/account_views/utils.py | 33 ++++ app/user/management/__init__.py | 0 app/user/management/commands/__init__.py | 0 .../commands/backfill_email_addresses.py | 36 +++++ app/user/templates/user/account_base.html | 21 ++- app/user/templates/user/account_home.html | 3 +- .../templates/user/email_confirm_result.html | 14 ++ app/user/templates/user/email_manage.html | 56 +++++++ app/user/test/email_view_test.py | 146 +++++++++++++++++ app/user/test/merge_view_test.py | 3 +- app/user/urls.py | 27 +++- app/user/views.py | 150 ------------------ 21 files changed, 634 insertions(+), 160 deletions(-) create mode 100644 app/core/templates/account/email/email_confirmation_message.html create mode 100644 app/core/templates/account/email/email_confirmation_subject.txt create mode 100644 app/user/account_views/__init__.py create mode 100644 app/user/account_views/account.py create mode 100644 app/user/account_views/email.py create mode 100644 app/user/account_views/merge.py create mode 100644 app/user/account_views/utils.py create mode 100644 app/user/management/__init__.py create mode 100644 app/user/management/commands/__init__.py create mode 100644 app/user/management/commands/backfill_email_addresses.py create mode 100644 app/user/templates/user/email_confirm_result.html create mode 100644 app/user/templates/user/email_manage.html create mode 100644 app/user/test/email_view_test.py delete mode 100644 app/user/views.py diff --git a/app/core/authn/allauth_adapter.py b/app/core/authn/allauth_adapter.py index 291b505..afad4c0 100644 --- a/app/core/authn/allauth_adapter.py +++ b/app/core/authn/allauth_adapter.py @@ -27,6 +27,9 @@ class NoNewUsersAccountAdapter(DefaultAccountAdapter): def is_open_for_signup(self, request: HttpRequest) -> bool: return False + def get_email_confirmation_url(self, request: HttpRequest, emailconfirmation: Any) -> str: + return request.build_absolute_uri(reverse("account-email-confirm", kwargs={"key": emailconfirmation.key})) + class SocialAccountLoggingAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> bool: diff --git a/app/core/const/account.py b/app/core/const/account.py index 927ec05..dd7b294 100644 --- a/app/core/const/account.py +++ b/app/core/const/account.py @@ -43,6 +43,52 @@ "en": "We couldn't find the account to merge. Please try again.", }, } +EMAIL_MESSAGES = { + "verification_sent": { + "ko": "확인 이메일을 보냈습니다. 메일함을 확인해 주세요.", + "en": "A verification email has been sent. Please check your inbox.", + }, + "resent": { + "ko": "확인 이메일을 다시 보냈습니다.", + "en": "The verification email has been resent.", + }, + "verified": { + "ko": "이메일이 인증되었습니다.", + "en": "Your email has been verified.", + }, + "already_verified": { + "ko": "이미 인증된 이메일입니다.", + "en": "This email is already verified.", + }, + "invalid_link": { + "ko": "확인 링크가 유효하지 않거나 만료되었습니다.", + "en": "The verification link is invalid or has expired.", + }, + "deleted": { + "ko": "이메일을 삭제했습니다.", + "en": "The email has been removed.", + }, + "cannot_delete": { + "ko": "이 이메일은 삭제할 수 없습니다.", + "en": "This email can't be removed.", + }, + "primary_set": { + "ko": "대표 이메일을 변경했습니다.", + "en": "Your primary email has been updated.", + }, + "cannot_set_primary": { + "ko": "인증된 이메일만 대표로 지정할 수 있습니다.", + "en": "Only a verified email can be set as your primary email.", + }, + "add_failed": { + "ko": "이 이메일은 추가할 수 없습니다. 형식이 올바르지 않거나 이미 사용 중일 수 있습니다.", + "en": "This email can't be added. It may be invalid or already in use.", + }, + "not_found": { + "ko": "이메일을 찾을 수 없습니다.", + "en": "Email not found.", + }, +} def generate_random_password(length: int = 16) -> str: diff --git a/app/core/settings.py b/app/core/settings.py index b6ef2e1..2e2ab77 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -262,7 +262,8 @@ ] ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" -ACCOUNT_LOGIN_METHODS = {"username", "email"} +ACCOUNT_LOGIN_METHODS = {"email"} +ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] ACCOUNT_EMAIL_VERIFICATION = "none" ACCOUNT_ADAPTER = "core.authn.allauth_adapter.NoNewUsersAccountAdapter" diff --git a/app/core/templates/account/email/email_confirmation_message.html b/app/core/templates/account/email/email_confirmation_message.html new file mode 100644 index 0000000..654afd1 --- /dev/null +++ b/app/core/templates/account/email/email_confirmation_message.html @@ -0,0 +1,25 @@ +{% is_english as en %} + + + + +
+ + +
+

{% if en %}Verify your email{% else %}이메일 인증{% endif %}

+

+ {% if en %}Please confirm this email address by clicking the button below.{% else %}아래 버튼을 눌러 이메일 주소를 인증해 주세요.{% endif %} +

+

+ {% if en %}Verify email{% else %}이메일 인증하기{% endif %} +

+

{% if en %}Or open this link:{% else %}또는 이 링크를 여세요:{% endif %}

+

{{ activate_url }}

+

+ {% if en %}If you didn't request this, you can safely ignore this email. — PyCon Korea{% else %}요청하지 않으셨다면 이 메일을 무시하셔도 됩니다. — 파이콘 한국{% endif %} +

+
+
+ + diff --git a/app/core/templates/account/email/email_confirmation_subject.txt b/app/core/templates/account/email/email_confirmation_subject.txt new file mode 100644 index 0000000..4871fc5 --- /dev/null +++ b/app/core/templates/account/email/email_confirmation_subject.txt @@ -0,0 +1 @@ +{% is_english as en %}{% if en %}Verify your email · PyCon Korea{% else %}이메일 인증 · 파이콘 한국{% endif %} diff --git a/app/user/account_views/__init__.py b/app/user/account_views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/account_views/account.py b/app/user/account_views/account.py new file mode 100644 index 0000000..ddd338d --- /dev/null +++ b/app/user/account_views/account.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from http import HTTPStatus + +from allauth.account.forms import LoginForm +from core.const.account import MERGE_MESSAGES +from core.templatetags.i18n_extras import is_english +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.views.decorators.http import require_GET, require_http_methods +from user.account_views.utils import ( + HEADLESS_PROVIDER_REDIRECT_URL, + PROVIDERS, + check_login, + login_target, + redirect_if_authenticated, +) + + +@require_GET +def account_home(request: HttpRequest) -> HttpResponse: + check_login(request) + return render(request, "user/account_home.html", {"user": request.user}) + + +@require_GET +def account_login(request: HttpRequest) -> HttpResponse: + target = login_target(request) + redirect_if_authenticated(request, target) + return render( + request, + "user/account_login.html", + { + "providers": PROVIDERS, + "redirect_url": HEADLESS_PROVIDER_REDIRECT_URL, + "callback_url": target, + "process": "login", + }, + ) + + +@require_http_methods(["GET", "POST"]) +def password_login(request: HttpRequest) -> HttpResponse: + target = login_target(request) + redirect_if_authenticated(request, target) + + if request.method == "GET": + return render(request, "user/account_password_login.html") + + form = LoginForm(data=request.POST, request=request) + if form.is_valid(): + return form.login(request, redirect_url=target) + return render( + request, + "user/account_password_login.html", + {"error": MERGE_MESSAGES["wrong_account_or_password"]["en" if is_english() else "ko"]}, + status=HTTPStatus.BAD_REQUEST, + ) diff --git a/app/user/account_views/email.py b/app/user/account_views/email.py new file mode 100644 index 0000000..56a18a9 --- /dev/null +++ b/app/user/account_views/email.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from http import HTTPStatus + +from allauth.account.forms import AddEmailForm +from allauth.account.internal.flows.manage_email import can_delete_email, can_mark_as_primary +from allauth.account.models import EmailAddress, EmailConfirmationHMAC +from core.const.account import EMAIL_MESSAGES +from core.middleware.response_exception import ResponseException +from core.templatetags.i18n_extras import is_english +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.views.decorators.http import require_GET, require_POST +from user.account_views.utils import check_login + + +def _email_msg(code: str) -> str: + return EMAIL_MESSAGES[code]["en" if is_english() else "ko"] + + +def _render_emails( + request: HttpRequest, *, error: str | None = None, notice: str | None = None, status: HTTPStatus = HTTPStatus.OK +) -> HttpResponse: + return render( + request, + "user/email_manage.html", + { + "user": request.user, + "emails": EmailAddress.objects.filter(user=request.user).order_by("-primary", "-verified", "email"), + "error": error, + "notice": notice, + }, + status=status, + ) + + +def _target_email(request: HttpRequest) -> EmailAddress: + if email := EmailAddress.objects.filter(user=request.user, pk=request.POST.get("email_id")).first(): + return email + raise ResponseException(_render_emails(request, error=_email_msg("not_found"), status=HTTPStatus.BAD_REQUEST)) + + +@require_GET +def manage_emails(request: HttpRequest) -> HttpResponse: + check_login(request) + return _render_emails(request) + + +@require_POST +def add_email(request: HttpRequest) -> HttpResponse: + check_login(request) + form = AddEmailForm(user=request.user, data=request.POST) + if not form.is_valid(): + return _render_emails(request, error=_email_msg("add_failed"), status=HTTPStatus.BAD_REQUEST) + form.save(request) + return _render_emails(request, notice=_email_msg("verification_sent")) + + +@require_POST +def delete_email(request: HttpRequest) -> HttpResponse: + check_login(request) + email = _target_email(request) + if not can_delete_email(email): + return _render_emails(request, error=_email_msg("cannot_delete"), status=HTTPStatus.BAD_REQUEST) + email.remove() + return _render_emails(request, notice=_email_msg("deleted")) + + +@require_POST +def resend_email(request: HttpRequest) -> HttpResponse: + check_login(request) + email = _target_email(request) + if email.verified: + return _render_emails(request, error=_email_msg("already_verified"), status=HTTPStatus.BAD_REQUEST) + email.send_confirmation(request) + return _render_emails(request, notice=_email_msg("resent")) + + +@require_POST +def set_primary_email(request: HttpRequest) -> HttpResponse: + check_login(request) + email = _target_email(request) + if not can_mark_as_primary(email): + return _render_emails(request, error=_email_msg("cannot_set_primary"), status=HTTPStatus.BAD_REQUEST) + email.set_as_primary() + return _render_emails(request, notice=_email_msg("primary_set")) + + +@require_GET +def confirm_email(request: HttpRequest, key: str) -> HttpResponse: + confirmation = EmailConfirmationHMAC.from_key(key) + if confirmation is None: + context = {"error": _email_msg("invalid_link")} + return render(request, "user/email_confirm_result.html", context, status=HTTPStatus.BAD_REQUEST) + confirmation.confirm(request) + return render(request, "user/email_confirm_result.html", {"email": confirmation.email_address.email}) diff --git a/app/user/account_views/merge.py b/app/user/account_views/merge.py new file mode 100644 index 0000000..977a2e6 --- /dev/null +++ b/app/user/account_views/merge.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from http import HTTPStatus + +from core.const.account import MERGE_MESSAGES, MERGE_SOURCE_SESSION_KEY +from core.middleware.response_exception import ResponseException +from core.templatetags.i18n_extras import is_english +from django.db.transaction import atomic +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse +from django.views.decorators.http import require_GET, require_http_methods +from user.account_views.utils import HEADLESS_PROVIDER_REDIRECT_URL, PROVIDERS, User, check_login +from user.models.merge import MergeError, UserMergeHistory + + +@require_GET +def merge_start(request: HttpRequest) -> HttpResponse: + check_login(request) + return render( + request, + "user/merge_start.html", + { + "providers": PROVIDERS, + "redirect_url": HEADLESS_PROVIDER_REDIRECT_URL, + "callback_url": reverse("account-merge-start"), + "process": "connect", + "user": request.user, + }, + ) + + +@require_http_methods(["GET", "POST"]) +def merge_confirm(request: HttpRequest) -> HttpResponse: + check_login(request) + source = _merge_source(request) + + if request.method == "GET": + return render(request, "user/merge_confirm.html", _confirm_context(source, request.user)) + + try: + with atomic(): + UserMergeHistory.assert_self_mergeable(source, request.user) + UserMergeHistory.objects.create(source=source, target=request.user).merge() + except MergeError as e: + context = _confirm_context(source, request.user, error=e.localized(en=is_english())) + return render(request, "user/merge_confirm.html", context) + + request.session.pop(MERGE_SOURCE_SESSION_KEY, None) + return render(request, "user/merge_result.html", {"target": request.user}) + + +def _merge_source(request: HttpRequest) -> User: + source_id = request.session.get(MERGE_SOURCE_SESSION_KEY) + if source_id and source_id != request.user.pk and (source := User.objects.filter(pk=source_id).first()): + return source + raise ResponseException( + render( + request, + "user/merge_result.html", + {"error": MERGE_MESSAGES["no_source"]["en" if is_english() else "ko"]}, + status=HTTPStatus.BAD_REQUEST, + ) + ) + + +def _confirm_context(source: User, target: User, *, error: str | None = None) -> dict: + if error is None: + try: + UserMergeHistory.assert_self_mergeable(source, target) + except MergeError as e: + error = e.localized(en=is_english()) + return {"source": source, "target": target, "error": error} diff --git a/app/user/account_views/utils.py b/app/user/account_views/utils.py new file mode 100644 index 0000000..142a3b0 --- /dev/null +++ b/app/user/account_views/utils.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from core.middleware.response_exception import ResponseException +from django.contrib.auth import get_user_model +from django.http import HttpRequest +from django.shortcuts import redirect +from django.urls import reverse, reverse_lazy +from django.utils.http import url_has_allowed_host_and_scheme, urlencode + +HEADLESS_PROVIDER_REDIRECT_URL = reverse_lazy("headless:browser:socialaccount:redirect_to_provider") +PROVIDERS = [ + {"id": "google", "name_ko": "Google", "name_en": "Google"}, + {"id": "naver", "name_ko": "네이버", "name_en": "Naver"}, + {"id": "kakao", "name_ko": "카카오", "name_en": "Kakao"}, +] +User = get_user_model() + + +def check_login(request: HttpRequest) -> None: + if not request.user.is_authenticated: + login_url = f"{reverse('account-login')}?{urlencode({'next': request.get_full_path()})}" + raise ResponseException(redirect(login_url)) + + +def redirect_if_authenticated(request: HttpRequest, target: str) -> None: + if request.user.is_authenticated: + raise ResponseException(redirect(target)) + + +def login_target(request: HttpRequest) -> str: + if (n := request.GET.get("next")) and url_has_allowed_host_and_scheme(n, allowed_hosts=None): + return n + return reverse("account-home") diff --git a/app/user/management/__init__.py b/app/user/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/management/commands/__init__.py b/app/user/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/management/commands/backfill_email_addresses.py b/app/user/management/commands/backfill_email_addresses.py new file mode 100644 index 0000000..06a3ed9 --- /dev/null +++ b/app/user/management/commands/backfill_email_addresses.py @@ -0,0 +1,36 @@ +from allauth.account.models import EmailAddress +from allauth.socialaccount.models import SocialAccount +from django.core.management.base import BaseCommand, CommandParser +from user.models import UserExt + + +class Command(BaseCommand): + help = "비소셜·활성·이메일 있음이면서 EmailAddress 가 없는 계정에 EmailAddress(verified·primary)를 백필한다." + + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument("--apply", action="store_true", help="실제 생성 (기본은 dry-run)") + + def handle(self, *args: object, **options: object) -> None: + candidates = ( + UserExt.objects.filter(is_active=True) + .exclude(email="") + .exclude(email__isnull=True) + .exclude(pk__in=SocialAccount.objects.values("user_id")) + .exclude(pk__in=EmailAddress.objects.values("user_id")) + ) + rows = [EmailAddress(user=user, email=user.email.lower(), verified=True, primary=True) for user in candidates] + + by_email: dict[str, list] = {} + for row in rows: + by_email.setdefault(row.email, []).append(row.user_id) + if dupes := {email: uids for email, uids in by_email.items() if len(uids) > 1}: + self.stdout.write( + self.style.WARNING(f"여러 계정이 같은 email 사용 {len(dupes)}건 (verified 충돌로 일부만 생성됨)") + ) + + if not options["apply"]: + self.stdout.write(f"[dry-run] 생성 예정 EmailAddress {len(rows)}건 — 실제 생성하려면 --apply") + return + + created = EmailAddress.objects.bulk_create(rows, ignore_conflicts=True) + self.stdout.write(self.style.SUCCESS(f"EmailAddress {len(created)}건 생성 완료")) diff --git a/app/user/templates/user/account_base.html b/app/user/templates/user/account_base.html index 7abb653..09e57be 100644 --- a/app/user/templates/user/account_base.html +++ b/app/user/templates/user/account_base.html @@ -7,7 +7,7 @@ diff --git a/app/user/templates/user/account_home.html b/app/user/templates/user/account_home.html index 29fcdc7..c812f3c 100644 --- a/app/user/templates/user/account_home.html +++ b/app/user/templates/user/account_home.html @@ -3,8 +3,9 @@ {% block body %}

{% if en %}Account{% else %}계정 관리{% endif %}

{{ user.email }}

+

{% if en %}Manage email addresses{% else %}이메일 관리{% endif %}

{% if en %}Merge another account{% else %}다른 계정 병합{% endif %}

- {% if en %}Email management and password change are coming soon.{% else %}이메일 관리 · 비밀번호 변경은 준비 중입니다.{% endif %} + {% if en %}Password change is coming soon.{% else %}비밀번호 변경은 준비 중입니다.{% endif %}

{% endblock %} diff --git a/app/user/templates/user/email_confirm_result.html b/app/user/templates/user/email_confirm_result.html new file mode 100644 index 0000000..a904981 --- /dev/null +++ b/app/user/templates/user/email_confirm_result.html @@ -0,0 +1,14 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Email verification · PyCon Korea{% else %}이메일 인증 · PyCon Korea{% endif %}{% endblock %} +{% block body %} +

{% if en %}Email verification{% else %}이메일 인증{% endif %}

+ {% if error %} +
{{ error }}
+ {% else %} +
+ {% if en %}Your email has been verified.{% else %}이메일이 인증되었습니다.{% endif %} + {% if email %}({{ email }}){% endif %} +
+ {% endif %} +

{% if en %}Manage emails{% else %}이메일 관리{% endif %}

+{% endblock %} diff --git a/app/user/templates/user/email_manage.html b/app/user/templates/user/email_manage.html new file mode 100644 index 0000000..803f5f4 --- /dev/null +++ b/app/user/templates/user/email_manage.html @@ -0,0 +1,56 @@ +{% extends "user/account_base.html" %} +{% block title %}{% if en %}Email · PyCon Korea{% else %}이메일 관리 · PyCon Korea{% endif %}{% endblock %} +{% block body %} +

{% if en %}Email addresses{% else %}이메일 관리{% endif %}

+ {% if notice %}
{{ notice }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + + + + {% for email in emails %} + + + + + {% endfor %} + +
+ {{ email.email }} +
+ {% if email.primary %}{% if en %}primary{% else %}대표{% endif %}{% endif %} + {% if email.verified %} + {% if en %}verified{% else %}인증됨{% endif %} + {% else %} + {% if en %}unverified{% else %}미인증{% endif %} + {% endif %} +
+
+
+ {% if not email.verified %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if email.verified and not email.primary %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% csrf_token %} + +
+
+
+ +
+

{% if en %}Add email{% else %}이메일 추가{% endif %}

+
+ {% csrf_token %} + + +
+

{% if en %}Back{% else %}돌아가기{% endif %}

+{% endblock %} diff --git a/app/user/test/email_view_test.py b/app/user/test/email_view_test.py new file mode 100644 index 0000000..5166567 --- /dev/null +++ b/app/user/test/email_view_test.py @@ -0,0 +1,146 @@ +import http + +import pytest +from allauth.account.models import EmailAddress, EmailConfirmationHMAC +from django.urls import reverse +from user.models import UserExt + + +@pytest.fixture +def user(db) -> UserExt: + u = UserExt.objects.create_user(username="u", email="u@example.com") + EmailAddress.objects.create(user=u, email="u@example.com", verified=True, primary=True) + return u + + +def _unverified(user: UserExt, email: str = "new@example.com") -> EmailAddress: + return EmailAddress.objects.create(user=user, email=email, verified=False) + + +# ---- Auth ------------------------------------------------------------------- + + +def test_manage_requires_login(client, db): + response = client.get(reverse("account-email")) + assert response.status_code == http.HTTPStatus.FOUND # → login + + +def test_manage_lists_emails(client, user): + client.force_login(user) + response = client.get(reverse("account-email")) + assert response.status_code == http.HTTPStatus.OK + assert b"u@example.com" in response.content + + +# ---- Add + verify ----------------------------------------------------------- + + +def test_add_creates_unverified_and_sends_confirmation(client, user, mailoutbox): + client.force_login(user) + response = client.post(reverse("account-email-add"), {"email": "new@example.com"}) + assert response.status_code == http.HTTPStatus.OK + assert EmailAddress.objects.filter(user=user, email="new@example.com", verified=False).exists() + assert len(mailoutbox) == 1 + assert "new@example.com" in mailoutbox[0].to + assert "email/confirm/" in mailoutbox[0].body # activate_url 포함 + + +def test_add_duplicate_rejected(client, user): + client.force_login(user) + response = client.post(reverse("account-email-add"), {"email": "u@example.com"}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert EmailAddress.objects.filter(user=user).count() == 1 + + +def test_confirm_verifies_email(client, user): + email = _unverified(user) + key = EmailConfirmationHMAC(email).key + + response = client.get(reverse("account-email-confirm", kwargs={"key": key})) + assert response.status_code == http.HTTPStatus.OK + email.refresh_from_db() + assert email.verified is True + + +def test_confirm_invalid_key_rejected(client, db): + response = client.get(reverse("account-email-confirm", kwargs={"key": "bogus-key"})) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +# ---- Resend ----------------------------------------------------------------- + + +def test_resend_unverified(client, user, mailoutbox): + email = _unverified(user) + client.force_login(user) + response = client.post(reverse("account-email-resend"), {"email_id": email.pk}) + assert response.status_code == http.HTTPStatus.OK + assert len(mailoutbox) == 1 + + +def test_resend_verified_rejected(client, user, mailoutbox): + client.force_login(user) + primary = EmailAddress.objects.get(user=user) + response = client.post(reverse("account-email-resend"), {"email_id": primary.pk}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert len(mailoutbox) == 0 + + +# ---- Delete ----------------------------------------------------------------- + + +def test_delete_email(client, user): + email = _unverified(user) + client.force_login(user) + response = client.post(reverse("account-email-delete"), {"email_id": email.pk}) + assert response.status_code == http.HTTPStatus.OK + assert not EmailAddress.objects.filter(pk=email.pk).exists() + + +def test_delete_last_email_rejected(client, user): + # email-only 로그인이라 마지막 이메일 삭제는 차단(dangling 방지). + client.force_login(user) + only = EmailAddress.objects.get(user=user) + response = client.post(reverse("account-email-delete"), {"email_id": only.pk}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert EmailAddress.objects.filter(pk=only.pk).exists() + + +def test_delete_primary_with_others_rejected(client, user): + # 다른 이메일이 있을 때 대표 이메일 삭제는 거부(먼저 대표를 옮겨야 함). + EmailAddress.objects.create(user=user, email="new@example.com", verified=True) + client.force_login(user) + primary = EmailAddress.objects.get(user=user, primary=True) + response = client.post(reverse("account-email-delete"), {"email_id": primary.pk}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert EmailAddress.objects.filter(pk=primary.pk).exists() + + +# ---- Primary ---------------------------------------------------------------- + + +def test_set_primary_verified(client, user): + other = EmailAddress.objects.create(user=user, email="new@example.com", verified=True) + client.force_login(user) + response = client.post(reverse("account-email-primary"), {"email_id": other.pk}) + assert response.status_code == http.HTTPStatus.OK + other.refresh_from_db() + assert other.primary is True + + +def test_set_primary_unverified_rejected(client, user): + other = _unverified(user) + client.force_login(user) + response = client.post(reverse("account-email-primary"), {"email_id": other.pk}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + other.refresh_from_db() + assert other.primary is False + + +def test_action_on_other_users_email_rejected(client, user): + other_user = UserExt.objects.create_user(username="o", email="o@example.com") + other_email = EmailAddress.objects.create(user=other_user, email="o@example.com", verified=True) + client.force_login(user) + response = client.post(reverse("account-email-delete"), {"email_id": other_email.pk}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST # request.user 로 스코프 → 못 찾음 + assert EmailAddress.objects.filter(pk=other_email.pk).exists() diff --git a/app/user/test/merge_view_test.py b/app/user/test/merge_view_test.py index 8dee5ea..0bf3dc4 100644 --- a/app/user/test/merge_view_test.py +++ b/app/user/test/merge_view_test.py @@ -3,11 +3,12 @@ from allauth.core.exceptions import ImmediateHttpResponse from allauth.socialaccount.models import SocialAccount, SocialLogin from core.authn.allauth_adapter import SocialAccountLoggingAdapter +from core.const.account import MERGE_SOURCE_SESSION_KEY from django.urls import reverse from shop.order.models import Order +from user.account_views.utils import HEADLESS_PROVIDER_REDIRECT_URL from user.models import UserExt from user.models.merge import UserMergeHistory -from user.views import HEADLESS_PROVIDER_REDIRECT_URL, MERGE_SOURCE_SESSION_KEY @pytest.fixture diff --git a/app/user/urls.py b/app/user/urls.py index dd394b8..0d3da21 100644 --- a/app/user/urls.py +++ b/app/user/urls.py @@ -1,10 +1,25 @@ from django.urls import path -from user.views import AccountHomeView, AccountLoginView, MergeConfirmView, MergeStartView, PasswordLoginView +from user.account_views.account import account_home, account_login, password_login +from user.account_views.email import ( + add_email, + confirm_email, + delete_email, + manage_emails, + resend_email, + set_primary_email, +) +from user.account_views.merge import merge_confirm, merge_start urlpatterns = [ - path("", AccountHomeView.as_view(), name="account-home"), - path("login/", AccountLoginView.as_view(), name="account-login"), - path("login/password/", PasswordLoginView.as_view(), name="account-password-login"), - path("merge/", MergeStartView.as_view(), name="account-merge-start"), - path("merge/confirm/", MergeConfirmView.as_view(), name="account-merge-confirm"), + path("", account_home, name="account-home"), + path("login/", account_login, name="account-login"), + path("login/password/", password_login, name="account-password-login"), + path("merge/", merge_start, name="account-merge-start"), + path("merge/confirm/", merge_confirm, name="account-merge-confirm"), + path("email/", manage_emails, name="account-email"), + path("email/add/", add_email, name="account-email-add"), + path("email/delete/", delete_email, name="account-email-delete"), + path("email/resend/", resend_email, name="account-email-resend"), + path("email/primary/", set_primary_email, name="account-email-primary"), + path("email/confirm//", confirm_email, name="account-email-confirm"), ] diff --git a/app/user/views.py b/app/user/views.py deleted file mode 100644 index 7ec7988..0000000 --- a/app/user/views.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -from http import HTTPStatus - -from allauth.account.forms import LoginForm -from core.const.account import MERGE_MESSAGES, MERGE_SOURCE_SESSION_KEY -from core.middleware.response_exception import ResponseException -from core.templatetags.i18n_extras import is_english -from django.contrib.auth import get_user_model -from django.db.transaction import atomic -from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, render -from django.urls import reverse, reverse_lazy -from django.utils.http import url_has_allowed_host_and_scheme, urlencode -from django.views import View -from user.models.merge import MergeError, UserMergeHistory - -HEADLESS_PROVIDER_REDIRECT_URL = reverse_lazy("headless:browser:socialaccount:redirect_to_provider") -PROVIDERS = [ - {"id": "google", "name_ko": "Google", "name_en": "Google"}, - {"id": "naver", "name_ko": "네이버", "name_en": "Naver"}, - {"id": "kakao", "name_ko": "카카오", "name_en": "Kakao"}, -] - -User = get_user_model() - - -def check_login(request: HttpRequest) -> None: - if not request.user.is_authenticated: - login_url = f"{reverse('account-login')}?{urlencode({'next': request.get_full_path()})}" - raise ResponseException(redirect(login_url)) - - -def redirect_if_authenticated(request: HttpRequest, target: str) -> None: - if request.user.is_authenticated: - raise ResponseException(redirect(target)) - - -def _login_target(request: HttpRequest) -> str: - if (n := request.GET.get("next")) and url_has_allowed_host_and_scheme(n, allowed_hosts=None): - return n - return reverse("account-home") - - -class AccountHomeView(View): - def get(self, request: HttpRequest) -> HttpResponse: - check_login(request) - return render(request, "user/account_home.html", {"user": request.user}) - - -class AccountLoginView(View): - def get(self, request: HttpRequest) -> HttpResponse: - target = _login_target(request) - redirect_if_authenticated(request, target) - return render( - request=request, - template_name="user/account_login.html", - context={ - "providers": PROVIDERS, - "redirect_url": HEADLESS_PROVIDER_REDIRECT_URL, - "callback_url": target, - "process": "login", - }, - ) - - -class PasswordLoginView(View): - def get(self, request: HttpRequest) -> HttpResponse: - redirect_if_authenticated(request, _login_target(request)) - return render(request, "user/account_password_login.html") - - def post(self, request: HttpRequest) -> HttpResponse: - target = _login_target(request) - redirect_if_authenticated(request, target) - - form = LoginForm(data=request.POST, request=request) - if form.is_valid(): - return form.login(request, redirect_url=target) - - return render( - request=request, - template_name="user/account_password_login.html", - context={"error": MERGE_MESSAGES["wrong_account_or_password"]["en" if is_english() else "ko"]}, - status=HTTPStatus.BAD_REQUEST, - ) - - -class MergeStartView(View): - def get(self, request: HttpRequest) -> HttpResponse: - check_login(request) - return render( - request=request, - template_name="user/merge_start.html", - context={ - "providers": PROVIDERS, - "redirect_url": HEADLESS_PROVIDER_REDIRECT_URL, - "callback_url": reverse("account-merge-start"), - "process": "connect", - "user": request.user, - }, - ) - - -class MergeConfirmView(View): - def get(self, request: HttpRequest) -> HttpResponse: - check_login(request) - return render( - request=request, - template_name="user/merge_confirm.html", - context=self._confirm_context(self._source(request), request.user), - ) - - def post(self, request: HttpRequest) -> HttpResponse: - check_login(request) - source = self._source(request) - - try: - with atomic(): - UserMergeHistory.assert_self_mergeable(source, request.user) - UserMergeHistory.objects.create(source=source, target=request.user).merge() - except MergeError as e: - return render( - request=request, - template_name="user/merge_confirm.html", - context=self._confirm_context(source, request.user, error=e.localized(en=is_english())), - ) - - request.session.pop(MERGE_SOURCE_SESSION_KEY, None) - return render(request, "user/merge_result.html", {"target": request.user}) - - def _source(self, request: HttpRequest) -> User: - source_id = request.session.get(MERGE_SOURCE_SESSION_KEY) - if source_id and source_id != request.user.pk and (source := User.objects.filter(pk=source_id).first()): - return source - raise ResponseException( - render( - request=request, - template_name="user/merge_result.html", - context={"error": MERGE_MESSAGES["no_source"]["en" if is_english() else "ko"]}, - status=HTTPStatus.BAD_REQUEST, - ) - ) - - def _confirm_context(self, source: User, target: User, *, error: str | None = None) -> dict: - if error is None: - try: - UserMergeHistory.assert_self_mergeable(source, target) - except MergeError as e: - error = e.localized(en=is_english()) - return {"source": source, "target": target, "error": error} From 78ae1fbde9a0bbc10855027b9c0ff59e2b2e2185 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 5 Jul 2026 15:14:54 +0900 Subject: [PATCH 05/70] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/authn/allauth_adapter.py | 3 + app/core/const/account.py | 10 + app/core/settings.py | 1 + .../email/password_reset_key_message.html | 25 +++ .../email/password_reset_key_subject.txt | 1 + app/user/account_views/password.py | 122 ++++++++++++ app/user/templates/user/account_base.html | 2 + app/user/templates/user/account_home.html | 4 +- .../templates/user/account_password_form.html | 33 ++++ .../user/account_password_login.html | 1 + .../user/account_password_reset.html | 24 +++ app/user/test/password_view_test.py | 187 ++++++++++++++++++ app/user/urls.py | 4 + 13 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 app/core/templates/account/email/password_reset_key_message.html create mode 100644 app/core/templates/account/email/password_reset_key_subject.txt create mode 100644 app/user/account_views/password.py create mode 100644 app/user/templates/user/account_password_form.html create mode 100644 app/user/templates/user/account_password_reset.html create mode 100644 app/user/test/password_view_test.py diff --git a/app/core/authn/allauth_adapter.py b/app/core/authn/allauth_adapter.py index afad4c0..432ead5 100644 --- a/app/core/authn/allauth_adapter.py +++ b/app/core/authn/allauth_adapter.py @@ -30,6 +30,9 @@ def is_open_for_signup(self, request: HttpRequest) -> bool: def get_email_confirmation_url(self, request: HttpRequest, emailconfirmation: Any) -> str: return request.build_absolute_uri(reverse("account-email-confirm", kwargs={"key": emailconfirmation.key})) + def get_reset_password_from_key_url(self, key: str) -> str: + return self.request.build_absolute_uri(reverse("account-password-reset-from-key", kwargs={"key": key})) + class SocialAccountLoggingAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> bool: diff --git a/app/core/const/account.py b/app/core/const/account.py index dd7b294..d0f55b2 100644 --- a/app/core/const/account.py +++ b/app/core/const/account.py @@ -89,6 +89,16 @@ "en": "Email not found.", }, } +PASSWORD_MESSAGES = { + "changed": { + "ko": "비밀번호를 변경했습니다.", + "en": "Your password has been changed.", + }, + "reset_done": { + "ko": "비밀번호를 재설정했습니다. 이제 새 비밀번호로 로그인할 수 있습니다.", + "en": "Your password has been reset. You can now sign in with your new password.", + }, +} def generate_random_password(length: int = 16) -> str: diff --git a/app/core/settings.py b/app/core/settings.py index 2e2ab77..f0375f4 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -265,6 +265,7 @@ ACCOUNT_LOGIN_METHODS = {"email"} ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] ACCOUNT_EMAIL_VERIFICATION = "none" +ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False ACCOUNT_ADAPTER = "core.authn.allauth_adapter.NoNewUsersAccountAdapter" SOCIALACCOUNT_ONLY = False diff --git a/app/core/templates/account/email/password_reset_key_message.html b/app/core/templates/account/email/password_reset_key_message.html new file mode 100644 index 0000000..fb17615 --- /dev/null +++ b/app/core/templates/account/email/password_reset_key_message.html @@ -0,0 +1,25 @@ +{% is_english as en %} + + + + +
+ + +
+

{% if en %}Reset your password{% else %}비밀번호 재설정{% endif %}

+

+ {% if en %}We received a request to reset your password. Click the button below to set a new one.{% else %}비밀번호 재설정 요청을 받았습니다. 아래 버튼을 눌러 새 비밀번호를 설정해 주세요.{% endif %} +

+

+ {% if en %}Reset password{% else %}비밀번호 재설정하기{% endif %} +

+

{% if en %}Or open this link:{% else %}또는 이 링크를 여세요:{% endif %}

+

{{ password_reset_url }}

+

+ {% if en %}If you didn't request this, you can safely ignore this email. — PyCon Korea{% else %}요청하지 않으셨다면 이 메일을 무시하셔도 됩니다. — 파이콘 한국{% endif %} +

+
+
+ + diff --git a/app/core/templates/account/email/password_reset_key_subject.txt b/app/core/templates/account/email/password_reset_key_subject.txt new file mode 100644 index 0000000..bc0fabd --- /dev/null +++ b/app/core/templates/account/email/password_reset_key_subject.txt @@ -0,0 +1 @@ +{% is_english as en %}{% if en %}Reset your password · PyCon Korea{% else %}비밀번호 재설정 · 파이콘 한국{% endif %} diff --git a/app/user/account_views/password.py b/app/user/account_views/password.py new file mode 100644 index 0000000..24d3dbb --- /dev/null +++ b/app/user/account_views/password.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from http import HTTPStatus + +from allauth.account.forms import ( + ChangePasswordForm, + ResetPasswordForm, + ResetPasswordKeyForm, + SetPasswordForm, + UserTokenForm, +) +from core.const.account import PASSWORD_MESSAGES +from core.templatetags.i18n_extras import is_english +from django.contrib.auth import update_session_auth_hash +from django.forms import Form +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.views.decorators.http import require_http_methods +from user.account_views.utils import check_login + +PASSWORD_FORM_TEMPLATE = "user/account_password_form.html" # nosec B105 (템플릿 경로, 비밀번호 아님) + + +def _form_errors(form: Form) -> list[str]: + return [str(error) for errors in form.errors.values() for error in errors] + + +@require_http_methods(["GET", "POST"]) +def password_change(request: HttpRequest) -> HttpResponse: + check_login(request) + has_password = request.user.has_usable_password() + context = {"show_current": has_password, "back_home": True} + + if request.method == "GET": + return render( + request=request, + template_name=PASSWORD_FORM_TEMPLATE, + context=context, + ) + + form = (ChangePasswordForm if has_password else SetPasswordForm)(user=request.user, data=request.POST) + if not form.is_valid(): + context["errors"] = _form_errors(form) + return render( + request=request, + template_name=PASSWORD_FORM_TEMPLATE, + context=context, + status=HTTPStatus.BAD_REQUEST, + ) + form.save() + update_session_auth_hash(request, request.user) + + return render( + request=request, + template_name=PASSWORD_FORM_TEMPLATE, + context={ + "show_current": True, + "back_home": True, + "notice": PASSWORD_MESSAGES["changed"]["en" if is_english() else "ko"], + }, + ) + + +@require_http_methods(["GET", "POST"]) +def password_reset(request: HttpRequest) -> HttpResponse: + if request.method == "GET": + return render(request, "user/account_password_reset.html") + + form = ResetPasswordForm(data=request.POST) + if not form.is_valid(): + return render( + request=request, + template_name="user/account_password_reset.html", + context={"errors": _form_errors(form)}, + status=HTTPStatus.BAD_REQUEST, + ) + form.save(request) + + return render( + request=request, + template_name="user/account_password_reset.html", + context={"sent": True}, + ) + + +@require_http_methods(["GET", "POST"]) +def password_reset_from_key(request: HttpRequest, key: str) -> HttpResponse: + uidb36, _, temp_key = key.partition("-") + token_form = UserTokenForm(data={"uidb36": uidb36, "key": temp_key}) + if not token_form.is_valid(): + return render( + request=request, + template_name=PASSWORD_FORM_TEMPLATE, + context={"invalid": True}, + status=HTTPStatus.BAD_REQUEST, + ) + + if request.method == "GET": + return render( + request=request, + template_name=PASSWORD_FORM_TEMPLATE, + context={"autofocus_new": True}, + ) + + form = ResetPasswordKeyForm(user=token_form.reset_user, temp_key=temp_key, data=request.POST) + if not form.is_valid(): + return render( + request=request, + template_name=PASSWORD_FORM_TEMPLATE, + context={"autofocus_new": True, "errors": _form_errors(form)}, + status=HTTPStatus.BAD_REQUEST, + ) + form.save() + + return render( + request=request, + template_name=PASSWORD_FORM_TEMPLATE, + context={ + "done": True, + "notice": PASSWORD_MESSAGES["reset_done"]["en" if is_english() else "ko"], + }, + ) diff --git a/app/user/templates/user/account_base.html b/app/user/templates/user/account_base.html index 09e57be..5fd0d36 100644 --- a/app/user/templates/user/account_base.html +++ b/app/user/templates/user/account_base.html @@ -3,6 +3,8 @@ + + {% block title %}{% if en %}Account · PyCon Korea{% else %}계정 · PyCon Korea{% endif %}{% endblock %} diff --git a/app/user/templates/user/account_home.html b/app/user/templates/user/account_home.html index bfa690d..bb923c0 100644 --- a/app/user/templates/user/account_home.html +++ b/app/user/templates/user/account_home.html @@ -3,7 +3,13 @@ {% block body %}

{% if en %}Account{% else %}계정 관리{% endif %}

{{ user.email }}

-

{% if en %}Manage email addresses{% else %}이메일 관리{% endif %}

-

{% if en %}Merge another account{% else %}다른 계정 병합{% endif %}

-

{% if en %}Change password{% else %}비밀번호 변경{% endif %}

+ {% endblock %} diff --git a/app/user/test/account_view_test.py b/app/user/test/account_view_test.py new file mode 100644 index 0000000..1903f51 --- /dev/null +++ b/app/user/test/account_view_test.py @@ -0,0 +1,37 @@ +import pytest +from django.contrib.auth import SESSION_KEY +from django.urls import reverse +from user.models import UserExt + + +@pytest.fixture +def user(db) -> UserExt: + return UserExt.objects.create_user(username="u", email="u@example.com") + + +def test_account_home_shows_logout_button(client, user): + client.force_login(user) + + response = client.get(reverse("account-home")) + + assert response.status_code == 200 + assert reverse("account-logout").encode() in response.content + + +def test_logout_clears_session_and_redirects(client, user): + client.force_login(user) + assert SESSION_KEY in client.session + + response = client.post(reverse("account-logout")) + + assert response.status_code == 302 + assert response.url == reverse("account-login") + assert SESSION_KEY not in client.session + + +def test_logout_rejects_get(client, user): + client.force_login(user) + + response = client.get(reverse("account-logout")) + + assert response.status_code == 405 diff --git a/app/user/urls.py b/app/user/urls.py index d5c70f2..ff8f5fc 100644 --- a/app/user/urls.py +++ b/app/user/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from user.account_views.account import account_home, account_login, password_login +from user.account_views.account import account_home, account_login, account_logout, password_login from user.account_views.email import ( add_email, confirm_email, @@ -15,6 +15,7 @@ path("", account_home, name="account-home"), path("login/", account_login, name="account-login"), path("login/password/", password_login, name="account-password-login"), + path("logout/", account_logout, name="account-logout"), path("password/", password_change, name="account-password-change"), path("password/reset/", password_reset, name="account-password-reset"), path("password/reset/key//", password_reset_from_key, name="account-password-reset-from-key"), From 5259511ba3f638e1a2172b800ffefb01c2a003e5 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 6 Jul 2026 08:06:32 +0900 Subject: [PATCH 16/70] =?UTF-8?q?fix:=20=EB=B0=9C=ED=91=9C=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EC=82=AC=EB=9E=8C=EB=8F=84=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=BC=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/participant_portal_api/views/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/participant_portal_api/views/user.py b/app/participant_portal_api/views/user.py index 7e37612..5eb332c 100644 --- a/app/participant_portal_api/views/user.py +++ b/app/participant_portal_api/views/user.py @@ -8,7 +8,7 @@ UserPortalSerializer, UserPortalSignInSerializer, ) -from rest_framework import decorators, request, response, status, viewsets +from rest_framework import decorators, permissions, request, response, status, viewsets from user.models import UserExt @@ -21,7 +21,7 @@ class UserPortalViewSet(viewsets.GenericViewSet): tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], responses={status.HTTP_200_OK: UserPortalSerializer}, ) - @decorators.action(detail=False, methods=["get"], url_path="me") + @decorators.action(detail=False, methods=["get"], url_path="me", permission_classes=[permissions.IsAuthenticated]) def retrieve_profile(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: if not request.user.is_authenticated: return response.Response(status=status.HTTP_401_UNAUTHORIZED) From ed73647c7064b5f45a07b7127c3636de923a0f5a Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 6 Jul 2026 08:27:04 +0900 Subject: [PATCH 17/70] =?UTF-8?q?fix:=20=EC=B0=B8=EA=B0=80=EC=9E=90=20?= =?UTF-8?q?=ED=8F=AC=ED=83=88=EC=9D=98=20=EB=B0=9C=ED=91=9C=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EA=B6=8C=ED=95=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../permissions/__init__.py | 20 ----- .../serializers/user.py | 88 ------------------- app/participant_portal_api/views/file.py | 2 - .../views/modification_audit.py | 2 - .../views/presentation.py | 7 +- app/participant_portal_api/views/user.py | 45 +--------- 6 files changed, 5 insertions(+), 159 deletions(-) delete mode 100644 app/participant_portal_api/permissions/__init__.py diff --git a/app/participant_portal_api/permissions/__init__.py b/app/participant_portal_api/permissions/__init__.py deleted file mode 100644 index 92a6e74..0000000 --- a/app/participant_portal_api/permissions/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from event.presentation.models import PresentationSpeaker -from rest_framework import permissions, request, views -from user.models import UserExt - - -class IsSessionSpeaker(permissions.BasePermission): - message = "You do not have permission to perform this action." - - def has_permission(self, request: request.Request, view: views.APIView) -> bool: - if not (isinstance(request.user, UserExt) and request.user.is_active and request.user.is_authenticated): - return False - - return ( - PresentationSpeaker.objects.filter_active() - .filter( - user=request.user, - presentation__deleted_at__isnull=True, - ) - .exists() - ) diff --git a/app/participant_portal_api/serializers/user.py b/app/participant_portal_api/serializers/user.py index 43ee8c1..94e679e 100644 --- a/app/participant_portal_api/serializers/user.py +++ b/app/participant_portal_api/serializers/user.py @@ -1,8 +1,6 @@ -import functools import typing import unicodedata -from core.serializer.read_only_serializer import ReadOnlyModelSerializer from core.util.thread_local import get_current_user from file.models import PublicFile from participant_portal_api.serializers.modification_audit import ModificationAuditCreationPortalSerializer @@ -52,89 +50,3 @@ def validate(self, attrs: dict[str, typing.Any]) -> dict[str, typing.Any]: raise serializers.ValidationError("You can only update your own profile.") return super().validate(attrs) - - -class UserPortalSignInSerializer(ReadOnlyModelSerializer): - identity = serializers.CharField(max_length=150, required=True) - password = serializers.CharField(write_only=True, required=True) - - class Meta: - fields = ("identity", "password") - - @functools.cached_property - def user(self) -> UserExt | None: - if not (email := normalize_str(self.initial_data.get("identity", ""))): - return None - - return UserExt.objects.filter(is_active=True, email=email).first() - - def validate_identity(self, email: str) -> str: - if not (email := normalize_str(email)): - raise serializers.ValidationError("Email cannot be empty.") - - if not self.user: - raise serializers.ValidationError("User not found or inactive or wrong password.") - - return email - - def validate_password(self, password: str) -> str: - if not (password := normalize_str(password)): - raise serializers.ValidationError("Password cannot be empty.") - - return password - - def validate(self, attrs: dict[str, str]) -> dict[str, str]: - if not (self.user and self.user.check_password(attrs["password"])): - raise serializers.ValidationError("User not found or inactive or wrong password.") - - return attrs - - -class UserPortalPasswordChangeSerializer(ReadOnlyModelSerializer): - old_password = serializers.CharField(write_only=True, required=True) - new_password = serializers.CharField(write_only=True, required=True) - new_password_confirm = serializers.CharField(write_only=True, required=True) - - class Meta: - model = UserExt - fields = ("old_password", "new_password", "new_password_confirm") - - def validate_old_password(self, old_password: str) -> str: - if not (old_password := normalize_str(old_password)): - raise serializers.ValidationError("Old password cannot be empty.") - return old_password - - def validate_new_password(self, new_password: str) -> str: - if not (new_password := normalize_str(new_password)): - raise serializers.ValidationError("New password cannot be empty.") - return new_password - - def validate_new_password_confirm(self, new_password_confirm: str) -> str: - if not (new_password_confirm := normalize_str(new_password_confirm)): - raise serializers.ValidationError("New password confirmation cannot be empty.") - return new_password_confirm - - def validate(self, attrs: dict[str, str]) -> dict[str, str]: - user: UserExt = self.instance - old_password, new_password, new_password_confirm = ( - attrs["old_password"], - attrs["new_password"], - attrs["new_password_confirm"], - ) - - if not user.check_password(old_password): - raise serializers.ValidationError("Old password is incorrect.") - - if new_password == old_password: - raise serializers.ValidationError("New password cannot be the same as the old password.") - - if new_password != new_password_confirm: - raise serializers.ValidationError("New password and confirmation do not match.") - - return attrs - - def save(self, **kwargs: typing.Any) -> UserExt: - user: UserExt = self.instance - user.set_password(self.validated_data["new_password"]) - user.save(update_fields=["password"]) - return user diff --git a/app/participant_portal_api/views/file.py b/app/participant_portal_api/views/file.py index ce5b12d..7000085 100644 --- a/app/participant_portal_api/views/file.py +++ b/app/participant_portal_api/views/file.py @@ -2,7 +2,6 @@ from django.db import models from drf_spectacular import utils from file.models import PublicFile -from participant_portal_api.permissions import IsSessionSpeaker from participant_portal_api.serializers.file import PublicFilePortalSerializer, PublicFilePortalUploadSerializer from rest_framework import decorators, mixins, parsers, request, response, serializers, status, viewsets @@ -11,7 +10,6 @@ class PublicFilePortalViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = PublicFilePortalSerializer queryset = PublicFile.objects.filter_active().select_related_with_user() - permission_classes = [IsSessionSpeaker] def get_queryset(self) -> models.QuerySet[PublicFile]: """본인이 업로드한 파일만 조회 가능하도록 필터링""" diff --git a/app/participant_portal_api/views/modification_audit.py b/app/participant_portal_api/views/modification_audit.py index 4f9509a..080e734 100644 --- a/app/participant_portal_api/views/modification_audit.py +++ b/app/participant_portal_api/views/modification_audit.py @@ -8,7 +8,6 @@ ) from event.presentation.models import Presentation from participant_portal_api.models import ModificationAudit, ModificationAuditComment -from participant_portal_api.permissions import IsSessionSpeaker from participant_portal_api.serializers.modification_audit import ( ModificationAuditCancelPortalSerializer, ModificationAuditResponsePortalSerializer, @@ -32,7 +31,6 @@ ) class ModificationAuditPortalViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = ModificationAuditResponsePortalSerializer - permission_classes = [IsSessionSpeaker] queryset = ( ModificationAudit.objects.filter_active() .prefetch_related( diff --git a/app/participant_portal_api/views/presentation.py b/app/participant_portal_api/views/presentation.py index 68b0d9b..1601646 100644 --- a/app/participant_portal_api/views/presentation.py +++ b/app/participant_portal_api/views/presentation.py @@ -2,7 +2,6 @@ from drf_spectacular import utils from event.presentation.models import Presentation, PresentationSpeaker from participant_portal_api.models import ModificationAudit -from participant_portal_api.permissions import IsSessionSpeaker from participant_portal_api.serializers.presentation import PresentationPortalSerializer from rest_framework import mixins, response, viewsets @@ -20,7 +19,6 @@ class PresentationPortalViewSet( ): serializer_class = PresentationPortalSerializer queryset = Presentation.objects.filter_active().get_all_nested_data().order_by("-created_at") - permission_classes = [IsSessionSpeaker] http_method_names = ["get", "patch"] def get_queryset(self): @@ -33,10 +31,7 @@ def get_queryset(self): .get_queryset() .filter( id__in=PresentationSpeaker.objects.filter_active() - .filter( - user=self.request.user, - presentation__deleted_at__isnull=True, - ) + .filter(user=self.request.user, presentation__deleted_at__isnull=True) .values_list("presentation_id", flat=True), ) ) diff --git a/app/participant_portal_api/views/user.py b/app/participant_portal_api/views/user.py index 5eb332c..21f99c0 100644 --- a/app/participant_portal_api/views/user.py +++ b/app/participant_portal_api/views/user.py @@ -1,13 +1,8 @@ from core.const.tag import OpenAPITag -from django.contrib.auth import login, logout +from django.contrib.auth import logout from drf_spectacular.utils import extend_schema from participant_portal_api.models import ModificationAudit -from participant_portal_api.permissions import IsSessionSpeaker -from participant_portal_api.serializers.user import ( - UserPortalPasswordChangeSerializer, - UserPortalSerializer, - UserPortalSignInSerializer, -) +from participant_portal_api.serializers.user import UserPortalSerializer from rest_framework import decorators, permissions, request, response, status, viewsets from user.models import UserExt @@ -15,12 +10,8 @@ class UserPortalViewSet(viewsets.GenericViewSet): serializer_class = UserPortalSerializer queryset = UserExt.objects.filter(is_active=True) - permission_classes = [IsSessionSpeaker] - @extend_schema( - tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], - responses={status.HTTP_200_OK: UserPortalSerializer}, - ) + @extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], responses={status.HTTP_200_OK: UserPortalSerializer}) @decorators.action(detail=False, methods=["get"], url_path="me", permission_classes=[permissions.IsAuthenticated]) def retrieve_profile(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: if not request.user.is_authenticated: @@ -36,10 +27,7 @@ def retrieve_profile(self, request: request.Request, *args: tuple, **kwargs: dic return response.Response(data=data) - @extend_schema( - tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], - responses={status.HTTP_200_OK: UserPortalSerializer}, - ) + @extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], responses={status.HTTP_200_OK: UserPortalSerializer}) @retrieve_profile.mapping.patch def patch_profile(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: if not request.user.is_authenticated: @@ -51,33 +39,8 @@ def patch_profile(self, request: request.Request, *args: tuple, **kwargs: dict) return response.Response(data=UserPortalSerializer(instance).data) - @extend_schema( - tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], - request=UserPortalSignInSerializer, - responses={status.HTTP_200_OK: UserPortalSerializer}, - ) - @decorators.action(detail=False, methods=["post"], url_path="signin", permission_classes=[]) - def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: - serializer = UserPortalSignInSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - login(request=request, user=serializer.user, backend="django.contrib.auth.backends.ModelBackend") - return response.Response(data=UserPortalSerializer(serializer.user).data) - @extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], responses={status.HTTP_204_NO_CONTENT: None}) @decorators.action(detail=False, methods=["delete"], url_path="signout") def signout(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: logout(request=request) return response.Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], - request=UserPortalPasswordChangeSerializer, - responses={status.HTTP_200_OK: UserPortalSerializer}, - ) - @decorators.action(detail=False, methods=["put"], url_path="password") - def change_password(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: - serializer = UserPortalPasswordChangeSerializer(data=request.data, instance=request.user) - serializer.is_valid(raise_exception=True) - serializer.save() - return response.Response(data=UserPortalSerializer(request.user).data) From 2f879f12f991ad236403f71da9a50723f783c0eb Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 6 Jul 2026 11:34:37 +0900 Subject: [PATCH 18/70] =?UTF-8?q?chore:=20dooray!=20MCP=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcp_app/dooray.py | 29 ++++++++++++++++++++++++----- mcp_app/server.py | 4 +++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/mcp_app/dooray.py b/mcp_app/dooray.py index 1c498d0..5ebfb0c 100644 --- a/mcp_app/dooray.py +++ b/mcp_app/dooray.py @@ -1,6 +1,6 @@ from __future__ import annotations -from asyncio import gather +from asyncio import Semaphore, gather from collections.abc import Iterable from fastmcp import FastMCP @@ -49,6 +49,24 @@ def _members(ids: Iterable[str] | None) -> list[dict] | None: return [{"type": "member", "member": {"organizationMemberId": m}} for m in ids] if ids else None +async def _with_member_names(rows: list[dict]) -> list[dict]: + sem = Semaphore(8) + + async def _one(member_id: str) -> tuple[str, dict]: + async with sem: + try: + detail = await _proxy(method="GET", dooray_path=f"/common/v1/members/{member_id}") + except ToolError: + return member_id, {} + return member_id, detail.get("result") or {} + + resolved = dict(await gather(*(_one(r["organizationMemberId"]) for r in rows))) + return [ + {**r, "name": (d := resolved.get(r["organizationMemberId"]) or {}).get("name"), "userCode": d.get("userCode")} + for r in rows + ] + + def register(mcp: FastMCP) -> None: tag = {DOORAY_TAG} @@ -71,7 +89,7 @@ async def dooray_projects(member_me: bool = True, page: int = 0, size: int = 20) @mcp.tool( title="Dooray 프로젝트 선택지", description=( - "프로젝트의 workflow(업무상태)·member·milestone·tag 를 한 번에 조회. " + "프로젝트의 workflow(업무상태)·member(이름 포함)·milestone·tag 를 한 번에 조회. " "쓰기 전 유효 id(workflowId/organizationMemberId/milestoneId/tagId) 확보용. " "본인 organizationMemberId 는 `auth_status` 의 dooray.member_id 로 확인." ), @@ -86,7 +104,7 @@ async def dooray_project_choices(project_id: str) -> dict: ) return { "workflows": workflows.get("result"), - "members": members.get("result"), + "members": await _with_member_names(members.get("result") or []), "milestones": milestones.get("result"), "tags": tags.get("result"), } @@ -96,8 +114,9 @@ async def dooray_project_choices(project_id: str) -> dict: description=( "프로젝트 업무(Post) 목록. 필터는 콤마구분 문자열 또는 리스트. " "post_workflow_classes=backlog|registered|working|closed, " + "to_member_ids/from_member_ids=담당자/작성자(organizationMemberId), subjects=제목 필터, " "date 필터(due_at/created_at/updated_at)=today|thisweek|prev-Nd|next-Nd|ISO8601범위, " - "order=±postDueAt|postUpdatedAt|createdAt." + "order=±postDueAt|postUpdatedAt|createdAt. 본문 body 는 미포함(상세는 `dooray_post`)." ), tags=tag, ) @@ -137,7 +156,7 @@ async def dooray_posts( }, ) - @mcp.tool(title="Dooray 업무 상세", description="특정 업무(Post) 상세 조회.", tags=tag) + @mcp.tool(title="Dooray 업무 상세", description="특정 업무(Post) 상세 조회(목록과 달리 본문 body 포함).", tags=tag) async def dooray_post(project_id: str, post_id: str) -> dict: return await _proxy( method="GET", diff --git a/mcp_app/server.py b/mcp_app/server.py index 50562a1..7c871f1 100644 --- a/mcp_app/server.py +++ b/mcp_app/server.py @@ -26,7 +26,9 @@ "관계 필드는 choices 도구로 유효 id 를 채우세요. " "CMS 섹션 body 는 MDX — 컴포넌트·props 는 `mdx_components`, 미리보기는 `mdx_preview` " "(둘 다 domain 인자: `도메인 그룹 목록`의 실제 호스트, 예 2026.pycon.kr). " - "MDX 작성 시 JSX 는 최대한 지양하고 순수 마크다운을 우선해, 최소한의 코드 길이로 구현하세요." + "MDX 작성 시 JSX 는 최대한 지양하고 순수 마크다운을 우선해, 최소한의 코드 길이로 구현하세요. " + "Dooray 개인 토큰이 등록돼 있으면(dooray.connected) 업무(Post) 읽기·생성·수정·댓글 도구도 쓸 수 있습니다 " + "(project_id 는 `dooray_projects`, 담당자·태그 등 id 는 `dooray_project_choices`)." ) _GUIDANCE_PUBLIC = ( "공개 도구(후원사·발표·이벤트 조회)를 사용할 수 있습니다. 더 많은 기능이 필요하면 유효한 토큰으로 다시 연결하세요." From 01f8f766a706623277069260045cfd6955c8b3d3 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 6 Jul 2026 14:00:06 +0900 Subject: [PATCH 19/70] =?UTF-8?q?feat:=20dooray!=EC=9D=98=20=EC=97=85?= =?UTF-8?q?=EB=AC=B4=20=EC=83=81=EC=9C=84=20=EC=84=A4=EC=A0=95/=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=97=85=EB=AC=B4=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=8F=84=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcp_app/dooray.py | 111 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/mcp_app/dooray.py b/mcp_app/dooray.py index 5ebfb0c..65f1c13 100644 --- a/mcp_app/dooray.py +++ b/mcp_app/dooray.py @@ -27,12 +27,21 @@ def _csv(value: str | Iterable[str] | None) -> str | None: return ",".join(str(v) for v in value) -async def _proxy(method: str, dooray_path: str, *, params: dict | None = None, data: dict | None = None) -> dict: +async def _proxy( + method: str, + dooray_path: str, + *, + params: dict | None = None, + data: dict | None = None, + keep_data_nulls: Iterable[str] = (), +) -> dict: + keep = set(keep_data_nulls) + body = {k: v for k, v in (data or {}).items() if v is not None or k in keep} resp = await _client.request( method, f"/v1/admin-api/proxy/dooray/{dooray_path.lstrip('/')}", params={k: v for k, v in (params or {}).items() if v is not None}, - json={k: v for k, v in (data or {}).items() if v is not None} or None, + json=body or None, headers={"Authorization": f"Bearer {(await get_context().get_state(AUTH_KEY)).jwt}"}, ) if message := _PROXY_ERRORS.get(resp.status_code): @@ -49,6 +58,15 @@ def _members(ids: Iterable[str] | None) -> list[dict] | None: return [{"type": "member", "member": {"organizationMemberId": m}} for m in ids] if ids else None +def _users(to_member_ids: Iterable[str] | None, cc_member_ids: Iterable[str] | None) -> dict | None: + users = {} + if to := _members(to_member_ids): + users["to"] = to + if cc := _members(cc_member_ids): + users["cc"] = cc + return users or None + + async def _with_member_names(rows: list[dict]) -> list[dict]: sem = Semaphore(8) @@ -175,7 +193,8 @@ async def dooray_post_comments(project_id: str, post_id: str) -> dict: description=( "⚠️ Dooray 에 즉시 반영. 새 업무(Post)를 생성한다. 본문은 마크다운. " "담당자/참조자 id 는 `dooray_project_choices`(프로젝트 멤버)·`auth_status`의 dooray.member_id(본인)에서 확보. " - "priority=highest|high|normal|low|lowest|none." + "priority=highest|high|normal|low|lowest|none. " + "workflow_id 지정 시 생성 직후 해당 상태로 이동(미지정이면 프로젝트 기본 상태=보통 보류/등록)." ), tags=tag, ) @@ -190,20 +209,15 @@ async def dooray_create_post( tag_ids: list[str] | None = None, priority: str | None = None, parent_post_id: str | None = None, + workflow_id: str | None = None, ) -> dict: - users = {} - if to_member_ids: - users["to"] = _members(to_member_ids) - if cc_member_ids: - users["cc"] = _members(cc_member_ids) - - return await _proxy( + created = await _proxy( method="POST", dooray_path=f"/project/v1/projects/{project_id}/posts", data={ "subject": subject, "body": {"mimeType": "text/x-markdown", "content": body_markdown}, - "users": users or None, + "users": _users(to_member_ids, cc_member_ids), "dueDate": due_date, "milestoneId": milestone_id, "tagIds": tag_ids, @@ -211,6 +225,13 @@ async def dooray_create_post( "parentPostId": parent_post_id, }, ) + if workflow_id and (post_id := (created.get("result") or {}).get("id")): + await _proxy( + method="POST", + dooray_path=f"/project/v1/projects/{project_id}/posts/{post_id}/set-workflow", + data={"workflowId": workflow_id}, + ) + return created @mcp.tool( title="Dooray 업무 수정", @@ -229,12 +250,6 @@ async def dooray_update_post( tag_ids: list[str] | None = None, priority: str | None = None, ) -> dict: - users = {} - if to_member_ids: - users["to"] = _members(to_member_ids) - if cc_member_ids: - users["cc"] = _members(cc_member_ids) - return await _proxy( method="PUT", dooray_path=f"/project/v1/projects/{project_id}/posts/{post_id}", @@ -243,7 +258,7 @@ async def dooray_update_post( "body": ( {"mimeType": "text/x-markdown", "content": body_markdown} if body_markdown is not None else None ), - "users": users or None, + "users": _users(to_member_ids, cc_member_ids), "dueDate": due_date, "milestoneId": milestone_id, "tagIds": tag_ids, @@ -265,7 +280,11 @@ async def dooray_set_post_workflow(project_id: str, post_id: str, workflow_id: s @mcp.tool( title="Dooray 업무 완료 처리", - description="⚠️ Dooray 에 즉시 반영. 업무를 완료(closed) 상태로 변경.", + description=( + "⚠️ Dooray 에 즉시 반영. 업무를 완료(closed) 상태로 변경. " + "‼️ 업무 삭제(휴지통)는 이 도구(및 Dooray API)로 불가 — 완료 처리가 최대치다. " + "정말 지우려면 Dooray 웹 UI에서 수동으로만 가능." + ), tags=tag, ) async def dooray_set_post_done(project_id: str, post_id: str) -> dict: @@ -274,6 +293,60 @@ async def dooray_set_post_done(project_id: str, post_id: str) -> dict: dooray_path=f"/project/v1/projects/{project_id}/posts/{post_id}/set-done", ) + @mcp.tool( + title="Dooray 업무 상위(에픽) 설정/해제", + description=( + "⚠️ Dooray 에 즉시 반영. 기존 업무의 상위 업무(에픽 등)를 설정하거나 해제한다. " + "parent_post_id 지정=그 업무의 하위로 묶기, 미지정(None)=상위 해제(최상위로). " + "제약: 이미 하위 업무를 가진 업무는 다른 업무의 하위로 설정할 수 없다(2단계 계층 불가). " + "상위-하위 관계는 같은 프로젝트 안에서만 가능." + ), + tags=tag, + ) + async def dooray_set_parent_post(project_id: str, post_id: str, parent_post_id: str | None = None) -> dict: + return await _proxy( + method="POST", + dooray_path=f"/project/v1/projects/{project_id}/posts/{post_id}/set-parent-post", + data={"parentPostId": parent_post_id}, + keep_data_nulls=("parentPostId",), + ) + + @mcp.tool( + title="Dooray 업무 이동", + description=( + "⚠️ Dooray 에 즉시 반영·비가역. 업무를 다른 프로젝트로 이동한다. " + "주의: 이동하면 그 업무의 워크플로(단계)·태그 정보는 사라진다. " + "include_sub_posts=True 면 하위 업무도 함께 이동. target_project_id 는 `dooray_projects` 로 확보." + ), + tags=tag, + ) + async def dooray_move_post( + project_id: str, post_id: str, target_project_id: str, include_sub_posts: bool = True + ) -> dict: + return await _proxy( + method="POST", + dooray_path=f"/project/v1/projects/{project_id}/posts/{post_id}/move", + data={"targetProjectId": target_project_id, "includeSubPosts": include_sub_posts}, + ) + + @mcp.tool( + title="Dooray 태그 정의 생성", + description=( + "⚠️ Dooray 에 즉시 반영. 프로젝트에 새 태그(정의)를 생성한다. " + "name 이 `그룹명:태그명` 형식이면 그룹 태그, 그냥 `태그명`이면 개별 태그. color 는 6자리 hex(예: ffffff). " + "생성된 태그 id 는 이후 업무의 tag_ids 로 사용. " + "‼️ 주의: 한 번 만든 태그 정의는 이 도구(및 Dooray API)로는 수정·삭제할 수 없다 — " + "오타/불필요 태그 정리는 오직 Dooray 웹 UI에서 수동으로만 가능하니, 생성 전 name·color 를 신중히 확정할 것." + ), + tags=tag, + ) + async def dooray_add_tag(project_id: str, name: str, color: str = "ffffff") -> dict: + return await _proxy( + method="POST", + dooray_path=f"/project/v1/projects/{project_id}/tags", + data={"name": name, "color": color}, + ) + @mcp.tool( title="Dooray 업무 댓글 작성", description="⚠️ Dooray 에 즉시 반영(담당자에게 알림). 업무에 댓글(마크다운)을 작성.", From aa30a72e3b6880354641567f3ccbbe3b9bc8aa6d Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Tue, 7 Jul 2026 21:17:14 +0900 Subject: [PATCH 20/70] =?UTF-8?q?chore:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 48 +-- uv.lock | 857 ++++++++++++++++++++++++------------------------- 2 files changed, 450 insertions(+), 455 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b600a5c..9f62f02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,46 +7,46 @@ requires-python = "==3.12.*" dependencies = [ "argon2-cffi>=25.1.0", "celery[redis]>=5.6.3", - "cryptography>=48.0.0", - "django>=6.0.5", - "django-allauth[headless,openid,socialaccount]>=65.16.1", + "cryptography>=49.0.0", + "django>=6.0.6", + "django-allauth[headless,openid,socialaccount]>=65.18.0", "django-constance>=4.3.5", "django-cors-headers>=4.9.0", - "django-environ>=0.13.0", + "django-environ>=0.14.0", "django-extensions>=4.1", "django-filter>=25.2", "django-modeltranslation>=0.20.3", "django-picklefield>=3.4.0", - "django-simple-history>=3.11.0", + "django-simple-history>=3.12.0", "djangorestframework>=3.17.1", - "drf-spectacular>=0.29.0", + "drf-spectacular>=0.30.0", "drf-standardized-errors[openapi]>=0.16.0", - "faker>=40.18.0", + "faker>=40.28.1", "gunicorn>=26.0.0", - "google-api-python-client>=2.196.0", + "google-api-python-client>=2.198.0", "google-auth-httplib2>=0.4.0", "google-auth-oauthlib>=1.4.0", "httpx>=0.28.1", "jinja2>=3.1.6", - "model-bakery>=1.23.4", + "model-bakery>=1.24.0", "packaging>=26.2", "pandas[excel, performance]>=3.0.3", "psycopg[binary]>=3.3.4", "py-openapi-schema-to-json-schema>=0.0.3", - "sentry-sdk[django]>=2.60.0", - "setuptools>=82.0.1", + "sentry-sdk[django]>=2.64.0", + "setuptools>=83.0.0", "shortuuid>=1.0.13", "qrcode[pil]>=8.0", "weasyprint>=63.0", - "opentelemetry-sdk>=1.42.1", - "opentelemetry-exporter-otlp-proto-http>=1.42.1", - "opentelemetry-instrumentation-django>=0.63b1", - "opentelemetry-instrumentation-psycopg>=0.63b1", - "opentelemetry-instrumentation-celery>=0.63b1", - "opentelemetry-instrumentation-redis>=0.63b1", - "opentelemetry-instrumentation-httpx>=0.63b1", - "opentelemetry-instrumentation-requests>=0.63b1", - "pyjwt>=2.12.1", + "opentelemetry-sdk>=1.43.0", + "opentelemetry-exporter-otlp-proto-http>=1.43.0", + "opentelemetry-instrumentation-django>=0.64b0", + "opentelemetry-instrumentation-psycopg>=0.64b0", + "opentelemetry-instrumentation-celery>=0.64b0", + "opentelemetry-instrumentation-redis>=0.64b0", + "opentelemetry-instrumentation-httpx>=0.64b0", + "opentelemetry-instrumentation-requests>=0.64b0", + "pyjwt>=2.13.0", ] [dependency-groups] @@ -55,15 +55,15 @@ mcp = [ "playwright>=1.49.0", ] dev = [ - "django-stubs[compatible-mypy]>=6.0.4", + "django-stubs[compatible-mypy]>=6.0.6", "djangorestframework-stubs[compatible-mypy]>=3.17.0", "freezegun>=1.5.0", - "ipython>=9.13.0", + "ipython>=9.15.0", "pre-commit>=4.6.0", - "pytest>=9.0.3", + "pytest>=9.1.1", "pytest-cov>=5.0.0", "pytest-django>=4.12.0", - "pytest-socket>=0.7.0", + "pytest-socket>=0.8.0", ] deployment = [ "packaging>=26.2", diff --git a/uv.lock b/uv.lock index 85a8598..7323f25 100644 --- a/uv.lock +++ b/uv.lock @@ -42,15 +42,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] [[package]] @@ -97,26 +97,27 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.5.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, - { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, - { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, - { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, - { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, - { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, - { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, - { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, - { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, - { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] @@ -223,44 +224,44 @@ mcp = [ requires-dist = [ { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "celery", extras = ["redis"], specifier = ">=5.6.3" }, - { name = "cryptography", specifier = ">=48.0.0" }, - { name = "django", specifier = ">=6.0.5" }, - { name = "django-allauth", extras = ["headless", "openid", "socialaccount"], specifier = ">=65.16.1" }, + { name = "cryptography", specifier = ">=49.0.0" }, + { name = "django", specifier = ">=6.0.6" }, + { name = "django-allauth", extras = ["headless", "openid", "socialaccount"], specifier = ">=65.18.0" }, { name = "django-constance", specifier = ">=4.3.5" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, - { name = "django-environ", specifier = ">=0.13.0" }, + { name = "django-environ", specifier = ">=0.14.0" }, { name = "django-extensions", specifier = ">=4.1" }, { name = "django-filter", specifier = ">=25.2" }, { name = "django-modeltranslation", specifier = ">=0.20.3" }, { name = "django-picklefield", specifier = ">=3.4.0" }, - { name = "django-simple-history", specifier = ">=3.11.0" }, + { name = "django-simple-history", specifier = ">=3.12.0" }, { name = "djangorestframework", specifier = ">=3.17.1" }, - { name = "drf-spectacular", specifier = ">=0.29.0" }, + { name = "drf-spectacular", specifier = ">=0.30.0" }, { name = "drf-standardized-errors", extras = ["openapi"], specifier = ">=0.16.0" }, - { name = "faker", specifier = ">=40.18.0" }, - { name = "google-api-python-client", specifier = ">=2.196.0" }, + { name = "faker", specifier = ">=40.28.1" }, + { name = "google-api-python-client", specifier = ">=2.198.0" }, { name = "google-auth-httplib2", specifier = ">=0.4.0" }, { name = "google-auth-oauthlib", specifier = ">=1.4.0" }, { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.6" }, - { name = "model-bakery", specifier = ">=1.23.4" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.42.1" }, - { name = "opentelemetry-instrumentation-celery", specifier = ">=0.63b1" }, - { name = "opentelemetry-instrumentation-django", specifier = ">=0.63b1" }, - { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.63b1" }, - { name = "opentelemetry-instrumentation-psycopg", specifier = ">=0.63b1" }, - { name = "opentelemetry-instrumentation-redis", specifier = ">=0.63b1" }, - { name = "opentelemetry-instrumentation-requests", specifier = ">=0.63b1" }, - { name = "opentelemetry-sdk", specifier = ">=1.42.1" }, + { name = "model-bakery", specifier = ">=1.24.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.43.0" }, + { name = "opentelemetry-instrumentation-celery", specifier = ">=0.64b0" }, + { name = "opentelemetry-instrumentation-django", specifier = ">=0.64b0" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.64b0" }, + { name = "opentelemetry-instrumentation-psycopg", specifier = ">=0.64b0" }, + { name = "opentelemetry-instrumentation-redis", specifier = ">=0.64b0" }, + { name = "opentelemetry-instrumentation-requests", specifier = ">=0.64b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.43.0" }, { name = "packaging", specifier = ">=26.2" }, { name = "pandas", extras = ["excel", "performance"], specifier = ">=3.0.3" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "py-openapi-schema-to-json-schema", specifier = ">=0.0.3" }, - { name = "pyjwt", specifier = ">=2.12.1" }, + { name = "pyjwt", specifier = ">=2.13.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.0" }, - { name = "sentry-sdk", extras = ["django"], specifier = ">=2.60.0" }, - { name = "setuptools", specifier = ">=82.0.1" }, + { name = "sentry-sdk", extras = ["django"], specifier = ">=2.64.0" }, + { name = "setuptools", specifier = ">=83.0.0" }, { name = "shortuuid", specifier = ">=1.0.13" }, { name = "weasyprint", specifier = ">=63.0" }, ] @@ -268,15 +269,15 @@ requires-dist = [ [package.metadata.requires-dev] deployment = [{ name = "packaging", specifier = ">=26.2" }] dev = [ - { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=6.0.4" }, + { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=6.0.6" }, { name = "djangorestframework-stubs", extras = ["compatible-mypy"], specifier = ">=3.17.0" }, { name = "freezegun", specifier = ">=1.5.0" }, - { name = "ipython", specifier = ">=9.13.0" }, + { name = "ipython", specifier = ">=9.15.0" }, { name = "pre-commit", specifier = ">=4.6.0" }, - { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-django", specifier = ">=4.12.0" }, - { name = "pytest-socket", specifier = ">=0.7.0" }, + { name = "pytest-socket", specifier = ">=0.8.0" }, ] mcp = [ { name = "fastmcp", specifier = ">=3.4.2" }, @@ -402,34 +403,34 @@ redis = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, ] [[package]] @@ -443,39 +444,36 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +version = "3.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/56/10a88e00039537d74bd420f0457c52ab8f58a1af56126e3b9f1b1c8c4724/charset_normalizer-3.4.8.tar.gz", hash = "sha256:d9bf144d6faf12c70d58e47f7512992ae2882b820031d6cef68152deb645bf2d", size = 151790, upload-time = "2026-07-06T15:27:58.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/c2/39de60ef5687662f467bed3d1e6944c67a4f0d057141d0404002b8f405ae/charset_normalizer-3.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:faac37c4904598daa00cb4c9b32f3b4cc814fb5f145d7a531ceb4a70f2114132", size = 319040, upload-time = "2026-07-06T15:26:15.854Z" }, + { url = "https://files.pythonhosted.org/packages/a7/57/a9474c3aeaa337c8a330c0dc5df266527d56da3b189c029529f6b08af2a4/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f191c19a32dc6cec0fb8079789d786254653a9ce906fcab04ccd2eed07bba233", size = 215541, upload-time = "2026-07-06T15:26:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/13/a9/be1ff7e81f6e086dced2a7a7a28b789be351d9796084ccaf6136a4ffafb3/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05811b76943d477bb90822dedb5c4565cef70148847a59d574e2b35043aeb563", size = 236913, upload-time = "2026-07-06T15:26:18.482Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/d8c5eae93da26d463f9ebe46a4937ca44434dc2937a565b92437befb3d94/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3868a3e4ec1e40b419e060d063f93eac6f046fa21426c4816421223ae7dc8ab8", size = 232815, upload-time = "2026-07-06T15:26:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/98e301ca944bcca5e6bc312406b579c8a6d81546c1b494afb3a9478495d6/charset_normalizer-3.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25f93d194eb6264c64416cabff46a91f6d99b97e7525a1b4f35c77a99e75cc68", size = 223995, upload-time = "2026-07-06T15:26:20.931Z" }, + { url = "https://files.pythonhosted.org/packages/fa/df/f5222366b76dcb31453a9bd922610c893540d0e729fd390439b0d3e972ee/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ee6a62492f18d432cca031fabd158f400a8c25bf7b9458f50953393a2a23d97a", size = 208522, upload-time = "2026-07-06T15:26:22.214Z" }, + { url = "https://files.pythonhosted.org/packages/74/52/293220d59d8ddfb8aa56836b33bd6df58e70795d8a102a858c2984480f00/charset_normalizer-3.4.8-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c16cb4fc35e4b064f5ee78d849f15a550ada1729c3372916672e38f1f01d1d4", size = 219660, upload-time = "2026-07-06T15:26:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2c/81a298e66f3d01e61bfc6f7064bbb553b067a9f1d979e5962bf00733069b/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2fbd0edb0426ab28e70fac9d1d4ef549eb5a64a2521f0428c441d75e4387e6c2", size = 218230, upload-time = "2026-07-06T15:26:24.629Z" }, + { url = "https://files.pythonhosted.org/packages/0d/45/f1dd2328cbc3340705f82072c09bd4c68d6e079191cde05810c1eac77eee/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccb9052771216170015f810b88065fd9e13b1e0b391f92abb9b47e0919a42aad", size = 210006, upload-time = "2026-07-06T15:26:25.837Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/28697000620e117eb413424caaf60b6f98ddb1b09b2c11f7c0038d9936a7/charset_normalizer-3.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3809ba5d3cd02aca0894597f2669a825bdfe2229061515c128b0f4e5533b4ab5", size = 225771, upload-time = "2026-07-06T15:26:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/12/a7/d5844e315f5f35e7938c415f07a1df144eed1cf993f1b43cc16c980c5b46/charset_normalizer-3.4.8-cp312-cp312-win32.whl", hash = "sha256:de63c31666a049f653ada24e800192e3c019e96bc7d70fb449a000bccf26a36f", size = 150922, upload-time = "2026-07-06T15:26:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/eb8b72f184b1e4986dd9daec15d7f6d9285a6728d2b07b7f04656829f473/charset_normalizer-3.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:14a4bbe066f3fb05c6ba70e9cf9d34614b57a2fd70ea8c27cc30f34155e16a58", size = 162294, upload-time = "2026-07-06T15:26:29.745Z" }, + { url = "https://files.pythonhosted.org/packages/09/5a/ab810134aa41034a08ffe94c058102016e6ad9bce62f3cdba547b4723385/charset_normalizer-3.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:2b5b0c0dca0a02c3f816f89abf18af3d20416dedbc3d3aa5f3981045f88ae7b0", size = 152409, upload-time = "2026-07-06T15:26:31.034Z" }, + { url = "https://files.pythonhosted.org/packages/23/52/d5bee5b6ea81882d549b566d2545b044bbcbc33fe5fbe001008a7e745a21/charset_normalizer-3.4.8-py3-none-any.whl", hash = "sha256:b7c1fb310df524e01fbe84d43b7f95aa4f808f8eaa0dafc185f64ba395e37d54", size = 64279, upload-time = "2026-07-06T15:27:57.043Z" }, ] [[package]] name = "click" -version = "8.4.0" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -526,65 +524,63 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.0" +version = "7.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [[package]] name = "cryptography" -version = "48.0.0" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] [[package]] @@ -602,7 +598,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.18.0" +version = "4.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -610,9 +606,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/19/5c438b428b3dca208eb920804dc16aeb3ca1e85d6163d17e8fb0785ead19/cyclopts-4.18.0.tar.gz", hash = "sha256:fb7b730f21932e0784f7e54462df0447aaa1fbf034d65b605bd8a25dce58b188", size = 182821, upload-time = "2026-06-11T19:55:05.326Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/89/f4c775c651d91f9cd8149f70baec94c902a34e5f17a7a67881881bcfb244/cyclopts-4.20.0.tar.gz", hash = "sha256:1d819de2b12dc6b1c9f17ce0f4937d82922c0b83ac846eb4b3289c9c9f321c9f", size = 190236, upload-time = "2026-06-29T15:04:42.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/9f/b67f14c6b686ca90d317c0358f1a52ae171f43f83c808683fae3ba0b1f90/cyclopts-4.18.0-py3-none-any.whl", hash = "sha256:18ba2912e48e890a97ecc8a05c9beddf30a407b43f4e14cccfd40efddc41f029", size = 221216, upload-time = "2026-06-11T19:55:03.773Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/7243bc65d33c5ff5150569c42c8c1154aadae20b25638afe35f7f568489c/cyclopts-4.20.0-py3-none-any.whl", hash = "sha256:0b4337e9c11303d86b33d3f37c629dc01638f84591681e0e5611286bdd507646", size = 229383, upload-time = "2026-06-29T15:04:40.621Z" }, ] [[package]] @@ -635,38 +631,38 @@ wheels = [ [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] name = "django" -version = "6.0.5" +version = "6.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" }, + { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, ] [[package]] name = "django-allauth" -version = "65.16.1" +version = "65.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/df/357187dfff18c7783e4911827a6c69437e290d7259a32a99c23fcd85997f/django_allauth-65.16.1.tar.gz", hash = "sha256:4425ac3088541c4c54983e16e08f6e3eb9f438dc1b1009534fa51c8bb739ed31", size = 2232835, upload-time = "2026-04-17T18:53:59.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/10/2d33c5bc3c0f9d7db1276a1babcaadd147e73eb416ccb1bd9523c6ac925f/django_allauth-65.18.0.tar.gz", hash = "sha256:afb82e2c545b9a5539370ad120468faaed84715e095a7b6e76972dacdc6376fe", size = 2246988, upload-time = "2026-05-29T13:01:26.071Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/58/d95b6c3088d83697bfd93782ee57bc6a6462e41eb19121a947b8a015396a/django_allauth-65.16.1-py3-none-any.whl", hash = "sha256:e49df24056bf37c44e56aaad1e51f78994b7d175bc3476d65e8f8f58390a8ce8", size = 2051868, upload-time = "2026-04-17T18:54:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/1d/75/5a4c2bf050e38ab16549395a7cd751ddcf3a18305bff4852cfd6c0d94998/django_allauth-65.18.0-py3-none-any.whl", hash = "sha256:e508640c83b94eaf1a9b4e9ac5332dc310dcdc83fca40d58a3f670e15bad1e84", size = 2061080, upload-time = "2026-05-29T13:01:16.532Z" }, ] [package.optional-dependencies] @@ -706,11 +702,11 @@ wheels = [ [[package]] name = "django-environ" -version = "0.13.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/3c/60983e6ec9b24a8d8588eecebfd21123cba980bce0a905807a27692f0860/django_environ-0.13.0.tar.gz", hash = "sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f", size = 63529, upload-time = "2026-02-18T01:08:08.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/e1/4c4ddcf6e90f023e89edb1d92f9a7fffce61ad4b691df497f2f5c0be6e26/django_environ-0.14.0.tar.gz", hash = "sha256:b6c48d93b9d2ff8a3ea14099e90c35aa4f101c1b4d5f262dfee0d27b06742ed7", size = 60417, upload-time = "2026-06-18T22:49:55.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/00/3767393ece946084e1c6830a33ffb8e39d68642e27ad5ac7d4c8bd5de866/django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e", size = 20682, upload-time = "2026-02-18T01:08:07.359Z" }, + { url = "https://files.pythonhosted.org/packages/1a/fe/67b423fc30f16d10259e901320fbc121746bf168aaaad9043e6ebb0110c1/django_environ-0.14.0-py3-none-any.whl", hash = "sha256:8dbe8a57f0a540ab8abd6f54f230de5e99e3a2c9d797cb9caecb037bca3d47d8", size = 20934, upload-time = "2026-06-18T22:49:54.355Z" }, ] [[package]] @@ -764,19 +760,19 @@ wheels = [ [[package]] name = "django-simple-history" -version = "3.11.0" +version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/11/410049f1454b99a78f719d3403fc89437c2a38ee092e939d5ab8d4846738/django_simple_history-3.11.0.tar.gz", hash = "sha256:2c587479cf2c3071e9aa555d0d11b73676994db4910770958f57659ade2deffe", size = 234862, upload-time = "2025-12-11T13:50:55.022Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/75/658d2e0ba3f51c0774cc7c71d2879e3260880d441406bba4bab64d70a4de/django_simple_history-3.12.0.tar.gz", hash = "sha256:4cc363c6c806834f32c9d19c9db2d97165f379f41e581c39fb7dbac9e4c20c11", size = 235106, upload-time = "2026-06-22T13:16:23.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/c2/e9854a3438cfc80891ab4d3826b7c61a0fe5ba3a4da89104a8f5c9afb5df/django_simple_history-3.11.0-py3-none-any.whl", hash = "sha256:f3c298db49e418ffce7fb709a5e83108452ea2179ec5c4b9232484c25427192a", size = 81868, upload-time = "2025-12-11T13:50:53.71Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8e/af1d95934a9bec0f9127a9ca9599ea33aee7f57afb4ea56018e13ae70018/django_simple_history-3.12.0-py3-none-any.whl", hash = "sha256:371878332fb3c00556c69d61e563daf042c34aef6cee6839123b391e2d0efa40", size = 81883, upload-time = "2026-06-22T13:16:22.391Z" }, ] [[package]] name = "django-stubs" -version = "6.0.4" +version = "6.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -784,9 +780,9 @@ dependencies = [ { name = "types-pyyaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/82/ccf2a2dc9cdb4bd9cbe91f11e887589bf2da7609506db00ccbc73bd8a6da/django_stubs-6.0.4.tar.gz", hash = "sha256:7aee77e8de9c14c0d9cf84988befe826d93cbc15a87e0ade2943f14d553451cf", size = 280019, upload-time = "2026-05-09T21:24:30.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/de/1b8ccb0909970fb4a8b48426f67132164110304875abdb4e4912c55480f4/django_stubs-6.0.6.tar.gz", hash = "sha256:dfc01e052e33c7f8f0c30c3ff8eda0903ee29ac710d1e46d9effd773744a69b0", size = 281381, upload-time = "2026-06-23T08:22:54.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/e7/5128914ada94dd6277626ef5a4a5680a4def7d2f9366214d26c1cd86723b/django_stubs-6.0.4-py3-none-any.whl", hash = "sha256:e991c68f77239663577a5f4fc75e99c84f867f378cafc97cbf4acc5aff378279", size = 543791, upload-time = "2026-05-09T21:24:28.218Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b0/b73855fb9cf381fa1d754ea33c25f14f5b11c297f48dc3a4e0359dc3adc2/django_stubs-6.0.6-py3-none-any.whl", hash = "sha256:c488fea05a9eac40ddbdc69887f63a5c0922cb13df285291ee99c9bbc89bc4f1", size = 546491, upload-time = "2026-06-23T08:22:52.466Z" }, ] [package.optional-dependencies] @@ -796,15 +792,15 @@ compatible-mypy = [ [[package]] name = "django-stubs-ext" -version = "6.0.4" +version = "6.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/b0/94335dc59138483c2bd2edf81cb39240fdef5e72c8cf0a6c177db207617b/django_stubs_ext-6.0.4.tar.gz", hash = "sha256:ff21f7b4362928b56e18cda0595f296e33c665f3019f4e3e4231977385e76cac", size = 6684, upload-time = "2026-05-09T21:24:02.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/8b/dc3c37cf994836ee09bc07f6a0c0ea840b975449940bd7ff77cc97a732f3/django_stubs_ext-6.0.6.tar.gz", hash = "sha256:e6f09884e48d7c5b250a373dfa22aa3e83bb91b8babbd8d05187f6b92247f232", size = 6674, upload-time = "2026-06-23T08:21:38.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/42/7db8470c0e276e7c7763c468441381dfa8727a360c68473f33ef828422bb/django_stubs_ext-6.0.4-py3-none-any.whl", hash = "sha256:0434a912bb08a370afcac9e90305c53e6f4eed3c1d1d46962559da5f8dbb8f27", size = 10373, upload-time = "2026-05-09T21:24:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e4/7d60a1bdb092807318af34a809dc6674b95cc78ec4d3aaa6027174e7201c/django_stubs_ext-6.0.6-py3-none-any.whl", hash = "sha256:5470c970f61a3ccf5aae7633feef1a097f944f417b955da200c9ac26ecd9134b", size = 10361, upload-time = "2026-06-23T08:21:37.228Z" }, ] [[package]] @@ -859,7 +855,7 @@ wheels = [ [[package]] name = "drf-spectacular" -version = "0.29.0" +version = "0.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -869,9 +865,9 @@ dependencies = [ { name = "pyyaml" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/43/41d25039a6a53545420ebc98eb9f877ec9fe30c7bd03fefabcaf9b953af7/drf_spectacular-0.30.0.tar.gz", hash = "sha256:53e79e7ba00e240441b63c32273754a5368e4c2ab44a19f2595277cc1cd559c9", size = 252311, upload-time = "2026-07-06T11:29:46.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150, upload-time = "2026-07-06T11:29:45.12Z" }, ] [[package]] @@ -938,31 +934,31 @@ wheels = [ [[package]] name = "faker" -version = "40.18.0" +version = "40.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/06/70886e82d8f1d2b73454f3a7c1b7405300128df22e70d85a828951366932/faker-40.18.0.tar.gz", hash = "sha256:2207575c0e8f90e6ccd6dbef764de875c614d16d3db4eee9712d9a00087f2e70", size = 1968243, upload-time = "2026-05-14T16:43:04.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/2d/3ead106cef42eab305e15f9c089dcc68a40387d5ce04af18585af4ebbb44/faker-40.28.1.tar.gz", hash = "sha256:2a63fb51abab8790636d4030a094cf942404cbccc9c288d1cad70e2e3e9ecd58", size = 2022717, upload-time = "2026-07-01T22:23:43.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/5c0b2d3a4b7a715f1835dd3f963bfbe841a02ae5cad1df8ee0325dfad235/faker-40.18.0-py3-none-any.whl", hash = "sha256:61a6b94b74605ddb090a065deb197a1c585ae7a874c094cf6693671d271e6083", size = 2006355, upload-time = "2026-05-14T16:43:02.489Z" }, + { url = "https://files.pythonhosted.org/packages/92/a6/6111b9f13c1564b0e2f5dbeeb611fd00dc731e6add2336f62b798598c73d/faker-40.28.1-py3-none-any.whl", hash = "sha256:e8d3f5c469100a553d246dce7937c291308068a5ec6c9c3a228d7878b50720be", size = 2061052, upload-time = "2026-07-01T22:23:41.946Z" }, ] [[package]] name = "fastmcp" -version = "3.4.2" +version = "3.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/18/46beaec18c9f86a599ae3f9cdf6677dd6b50240cfd844d18233710b47f13/fastmcp-3.4.2.tar.gz", hash = "sha256:b468722946fc467c3796a6572f7a14d93d48c014cf8fea12910245220cbbe4e1", size = 28756849, upload-time = "2026-06-06T01:30:35.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/92/cf0e921675af66534eb6dfffffa1871b5062f01650bcea63b62005a12f50/fastmcp-3.4.3.tar.gz", hash = "sha256:31e212ae9ff223876c6d7af2082e73f90bc692460e7a054568dfa88719f075ff", size = 28789252, upload-time = "2026-07-05T23:27:55.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/4d/8b1ba42251160e11ca34686344572121432c23a082d56ef6bbdec5888fc1/fastmcp-3.4.2-py3-none-any.whl", hash = "sha256:c87a62b029f0c5400ada85f683629345d2466c39169f0cb853e487b2f7308c08", size = 8018, upload-time = "2026-06-06T01:30:38.118Z" }, + { url = "https://files.pythonhosted.org/packages/e4/79/64000c2792743877baae152499a951c06a9c14b793309ac4ac06135f1ae7/fastmcp-3.4.3-py3-none-any.whl", hash = "sha256:bdf15277800586a033a6e1e95d20301cd508aaa872df3d508c956a2195de6c72", size = 8019, upload-time = "2026-07-05T23:27:53.241Z" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.2" +version = "3.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "platformdirs" }, @@ -972,9 +968,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/fa/500f6362016c34fba01c6412bbfd53435dc75900abd1266d88e111e2e9c2/fastmcp_slim-3.4.3.tar.gz", hash = "sha256:e3e12ac1e87c5195fa59a1c3cd2d31b487a463c269089fb4020b7571cbb57b29", size = 588086, upload-time = "2026-07-05T23:27:33.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4f/66765dfe4fe65b45e134e54ca46118d93189fb10cd8ecb635cf504b68552/fastmcp_slim-3.4.3-py3-none-any.whl", hash = "sha256:21ad50465494edb141eed76b177dc788e00e705a5df09b53a31efebeb4432a95", size = 761455, upload-time = "2026-07-05T23:27:32.547Z" }, ] [package.optional-dependencies] @@ -1013,11 +1009,11 @@ server = [ [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/c8/35bdf04fb30755e2ed758f877edf3eb4a243c2463d3a258cc28b18b7a6e2/filelock-3.29.6.tar.gz", hash = "sha256:895c532ef3f4ef04972b9446a8c4e2931a5c399ff3c4be4c9369f2639b80f793", size = 70301, upload-time = "2026-07-06T23:08:08.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/7467c2946ccd9617f7da38187071bdc45bb9a95df51f4d63d6622432ce4e/filelock-3.29.6-py3-none-any.whl", hash = "sha256:14d5f5597d2e0c4dbd774cfb6d8132da1db44da83732aab679d54f7dcf97ab65", size = 45478, upload-time = "2026-07-06T23:08:07.197Z" }, ] [[package]] @@ -1058,7 +1054,7 @@ wheels = [ [[package]] name = "google-api-core" -version = "2.30.3" +version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, @@ -1067,14 +1063,14 @@ dependencies = [ { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, ] [[package]] name = "google-api-python-client" -version = "2.196.0" +version = "2.198.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -1083,22 +1079,22 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/53/0cd38e3a29d72ce45e27feba2ce1cd8049d69af9c48cb14fb164f1be9133/google_api_python_client-2.198.0.tar.gz", hash = "sha256:dfe3e16fb241af6e9c460a33f65085b3450e05cea09364f6b5d8997fb7e43e2a", size = 15060142, upload-time = "2026-06-25T14:32:42.953Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/d5/92/0fc9e7a09eb240c31b879bd8d2e43f81ed1f86c4798b79ead4a083921ab3/google_api_python_client-2.198.0-py3-none-any.whl", hash = "sha256:fabac935474e817da5e662ff61bf7139439d6f92b32d332a7318a2d45931e03e", size = 15644203, upload-time = "2026-06-25T14:32:39.963Z" }, ] [[package]] name = "google-auth" -version = "2.53.0" +version = "2.55.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" }, ] [[package]] @@ -1141,20 +1137,20 @@ wheels = [ [[package]] name = "greenlet" -version = "3.5.2" +version = "3.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/7a/6bc2a7835731387ed303b9390ce68a116ab053df05450a59181239200454/greenlet-3.5.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", size = 288351, upload-time = "2026-06-17T17:36:17.019Z" }, - { url = "https://files.pythonhosted.org/packages/57/1b/bd98062fcef6d0e9d0873ab6f2d029772e6ea342972ae43275bd6177900f/greenlet-3.5.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", size = 604273, upload-time = "2026-06-17T18:07:20.296Z" }, - { url = "https://files.pythonhosted.org/packages/25/e6/fe392c522bf45d976abe7db2793f6ef4e87b053ebb869deeaae46aeb54da/greenlet-3.5.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", size = 616536, upload-time = "2026-06-17T18:29:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/42/df/cdb1f75f07214f13110e7e3879531f11c26083bd480a56a9474c430ec44c/greenlet-3.5.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd", size = 621843, upload-time = "2026-06-17T18:39:27.507Z" }, - { url = "https://files.pythonhosted.org/packages/68/4a/399ff81fa93a19d6a9df394cef0355f082dbc19ad41aba9593cd0ad444e2/greenlet-3.5.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", size = 613749, upload-time = "2026-06-17T17:39:28.148Z" }, - { url = "https://files.pythonhosted.org/packages/2e/25/36a3628a7edcfeefddd3101dc88039c79721c5f8d688db7ebed1cbaaa789/greenlet-3.5.2-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728", size = 424889, upload-time = "2026-06-17T18:41:19.469Z" }, - { url = "https://files.pythonhosted.org/packages/a5/75/f519593f12ad43d08e28c03a95cfe2eeae011707dbc9dab0c4a263ce90f9/greenlet-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", size = 1573725, upload-time = "2026-06-17T18:22:12.023Z" }, - { url = "https://files.pythonhosted.org/packages/f1/bc/bc1ea4b0754c6c51bbf9d94677b0b1f7fbda8cbb404e44a896854fc0a940/greenlet-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", size = 1638132, upload-time = "2026-06-17T17:40:06.971Z" }, - { url = "https://files.pythonhosted.org/packages/36/c0/f0f5a34247df60de285f75f22e57f14027f4b3c43820981854b5b643ca6d/greenlet-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", size = 239393, upload-time = "2026-06-17T17:33:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/09/17/a8544e165445f30aea67a8d9cf2786d2bb0eb1b0e0d224b4d9bd80e2d587/greenlet-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", size = 237723, upload-time = "2026-06-17T17:36:47.776Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, ] [[package]] @@ -1202,14 +1198,14 @@ wheels = [ [[package]] name = "httplib2" -version = "0.31.2" +version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyparsing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/f5/ccf58de92d61e3ad921119668f54ed36ca1d0cf5dcc5c1657dfb164fd78b/httplib2-0.32.0.tar.gz", hash = "sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025", size = 254283, upload-time = "2026-06-26T10:13:56.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" }, ] [[package]] @@ -1247,11 +1243,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1274,7 +1270,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.13.0" +version = "9.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1284,14 +1280,14 @@ dependencies = [ { name = "matplotlib-inline" }, { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit" }, - { name = "psutil" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, { name = "pygments" }, { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, ] [[package]] @@ -1374,14 +1370,14 @@ wheels = [ [[package]] name = "joserfc" -version = "1.7.1" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/90/25cb27518750218e4f850be63d8bbb2343efaad1c01c3571aaa4b3c33bd7/joserfc-1.7.1.tar.gz", hash = "sha256:77d0b76514879c68c6f433bc5b7357a4ab72008ff1e33d8379fd11d72bd8ca81", size = 233181, upload-time = "2026-06-08T07:21:33.412Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/26/abe1ad855eb334b5ebc9c6495d4798e12bee70e5e8e815d54570710b8312/joserfc-1.7.2.tar.gz", hash = "sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947", size = 233183, upload-time = "2026-06-29T09:03:10.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/00/fa62404c3e347f946faa13aa21085205f9cc06ad17671e37f81a51662ae8/joserfc-1.7.1-py3-none-any.whl", hash = "sha256:b3e3d655612e2e1ef67b2600f2f420e12e537b020208fab1761fad647319c164", size = 70423, upload-time = "2026-06-08T07:21:32.001Z" }, + { url = "https://files.pythonhosted.org/packages/13/80/d1b30336582cced4dce0dae776508a6011723e32f907bc7a702c0b25890a/joserfc-1.7.2-py3-none-any.whl", hash = "sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5", size = 70426, upload-time = "2026-06-29T09:03:09.393Z" }, ] [[package]] @@ -1474,35 +1470,35 @@ redis = [ [[package]] name = "librt" -version = "0.11.0" +version = "0.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, ] [[package]] name = "llvmlite" -version = "0.47.0" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/a0/acc8ffcd5bdc63df0097e22c719bfcd61b604358343089313a8aebbb24ab/llvmlite-0.48.0.tar.gz", hash = "sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2", size = 184016, upload-time = "2026-07-02T20:20:05.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, - { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, - { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/92/a2/28696a9e61e245d1a79816d29d106692a90a2b6e7d78c98b326db70827af/llvmlite-0.48.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:d66c3beb4209087ddd4cf4ed2a0856b6887e6a913bdcf1aacfec9851cf2cba4e", size = 40480651, upload-time = "2026-07-01T18:41:35.694Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/72409351db66d0a317ec5087e076f31fb7b773a640db8a90ce6b5cac9edd/llvmlite-0.48.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:416fa4c2c66c2c6dc6d0a402648c19206e548efa0aa1eff01ad5cdad0af8217d", size = 59890118, upload-time = "2026-07-01T18:41:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/3a/27/5ae2f3722606360480707adb47f001ad89df8251d06b14ee80336e660b66/llvmlite-0.48.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5e5a5131045b72345c71062ea1a91910dde913792b6c9b28ebb2c1c0a712e98", size = 58343459, upload-time = "2026-07-01T18:41:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/16/78/d824ffff7521cd140dc2006e44ce2bc82e64b48d1b32e90e956308c85a74/llvmlite-0.48.0-cp312-cp312-win_amd64.whl", hash = "sha256:d45c7541a80934ec6d8ab0defe67439494ecd2193cbf852a44ba827808976ac1", size = 41865022, upload-time = "2026-07-01T18:41:48.663Z" }, ] [[package]] @@ -1550,7 +1546,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1568,9 +1564,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] @@ -1584,14 +1580,14 @@ wheels = [ [[package]] name = "model-bakery" -version = "1.23.4" +version = "1.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/51/d3cfd4ab5c1cb9f889c604dc96cc0f1d7a52972ee28fe7a44a9dd705521a/model_bakery-1.23.4.tar.gz", hash = "sha256:0cd8e958e229734bd41feffdcb513a0f079c79c9ddb2e0aad11ed2c23f6d43d5", size = 23812, upload-time = "2026-03-27T09:28:39.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/49/3e5b51d7af52bf39e348767c1feb16c97d18654a8e3f3306eb4b899e98cf/model_bakery-1.24.0.tar.gz", hash = "sha256:6469223c9fb0eccb021d5c33667b914e9208dd2282d7dc1e55394c2310a7687d", size = 28372, upload-time = "2026-06-23T21:55:23.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/88/55c8e0a873e7a0f8e0d6c5ca236512cc37555632be178e7ba72bfbe0a619/model_bakery-1.23.4-py3-none-any.whl", hash = "sha256:65bfa0405d3ea60aca8c21e7e184c6617d6c8d32d0493cc75232a4476f471514", size = 25984, upload-time = "2026-03-27T09:28:38.439Z" }, + { url = "https://files.pythonhosted.org/packages/60/8e/9108507501846c9ee2a1b277d35d1f92ae251852bbd1527f6dbf2665d94a/model_bakery-1.24.0-py3-none-any.whl", hash = "sha256:d1f739cfc43e8d403d04734a18c73c8e677f655dd08719e35342c2f5ada1d7e9", size = 30819, upload-time = "2026-06-23T21:55:21.911Z" }, ] [[package]] @@ -1605,7 +1601,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -1614,16 +1610,16 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/dc/7e6d49f04fca40b9dd5c752a51a432ffe67fb45200702bc9eee0cb4bbb26/mypy-2.0.0.tar.gz", hash = "sha256:1a9e3900ac5c40f1fe813506c7739da6e6f0eab2729067ebd94bfb0bbba53532", size = 3869036, upload-time = "2026-05-06T19:26:43.22Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/4b/f6cd12ef1eb63be1c342da3e8ca811d2280276177f6de4ef20cb2366d79b/mypy-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:660790551c988e69d8bf7a35c8b4149edeb22f4a339165702be843532e9dcdb5", size = 14756610, upload-time = "2026-05-06T19:26:19.221Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/67d09ca28bee21feaca264b2a680cf2d300bcc2071136ad064928324c843/mypy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a15bf92cd8781f8e72f69ffa7e30d1f434402d065ee1ecd5223ef2ef100f914", size = 13554270, upload-time = "2026-05-06T19:26:08.977Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/44718b5c6b1b5a27440ff2effe6a1be0fa2a190c0f4e2e21a83728416f95/mypy-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ff370b43d7def05bbcd2f5267f0bcda72dd6a552ef2ea9375b02d6fe06da270", size = 13924663, upload-time = "2026-05-06T19:21:24.932Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2b/bbb9cc5773f946846a7c340097e59bcf84095437dda0d56bb4f6cf1f6541/mypy-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37bd246590a018e5a11703b7b09c39d47ede3df5ba3fa863c5b8590b465beb01", size = 14946862, upload-time = "2026-05-06T19:24:23.023Z" }, - { url = "https://files.pythonhosted.org/packages/43/25/e9318566f443a5130b4ff0ad3367ee6c4c4c49ff083fe5214a7318c18282/mypy-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cce87e92214fac8bf8feb8a680d0c1b6fb748d50e9b57fbb13e4b1d83a3ed19b", size = 15175090, upload-time = "2026-05-06T19:26:28.794Z" }, - { url = "https://files.pythonhosted.org/packages/67/65/2ec28c834f21e164c33bc296a7db538ad50c74f83e517c7a0be95ff6de86/mypy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e19e9cb69b66a4141009d24898259914fa2b71d026de0b46edf9fafdbf4fd46e", size = 11052899, upload-time = "2026-05-06T19:25:39.084Z" }, - { url = "https://files.pythonhosted.org/packages/9e/72/d1ec625cfc9bd101c07a6834ef1f94e820296f8fdbad2eb03f50e0983f8c/mypy-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:b021614cb08d44785b025982163ec3c39c94bff766ead071fa9e82b4ef6f62cd", size = 9972935, upload-time = "2026-05-06T19:23:24.204Z" }, - { url = "https://files.pythonhosted.org/packages/5c/14/fd0694aa594d6e9f9fd16ce821be2eff295197a273262ef56ddcc1388d68/mypy-2.0.0-py3-none-any.whl", hash = "sha256:8a92b2be3146b4fa1f062af7eb05574cbf3e6eb8e1f14704af1075423144e4e5", size = 2673434, upload-time = "2026-05-06T19:26:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -1646,18 +1642,18 @@ wheels = [ [[package]] name = "numba" -version = "0.65.1" +version = "0.66.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "llvmlite" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/a0/570e3dc53e5602b49108f62a13e529f1eec8bfc7ef37d49c825924dcf546/numba-0.66.0.tar.gz", hash = "sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363", size = 2806181, upload-time = "2026-07-01T23:12:46.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, - { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, - { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, - { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/62/a3/70deb7f88461c1cd5d16aa990c2380604102661a427667b8950dcdccc27f/numba-0.66.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:53ca5900b7cab15109796030113a6b28576bae5ad7bb507ad6dd1360ddd81ba4", size = 2727264, upload-time = "2026-07-01T23:12:18.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/25c319845e9a4e08f16611ddbda56a192eb7b6ed13e1a2bff2da272ffb97/numba-0.66.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0999e3ee1b18c48e1fb51d11af35ef59852c7f4f50569c9550c25faef0616ad1", size = 3866252, upload-time = "2026-07-01T23:12:20.429Z" }, + { url = "https://files.pythonhosted.org/packages/71/ef/a82d6fd6bf1b0fe461651e924d3647eeec9ac17f8eee4896264bf7480930/numba-0.66.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efe0d2d5099790df945e0cb6e1b3104bd965d7bbfac50d62f1d5d1d6ade0825d", size = 3566974, upload-time = "2026-07-01T23:12:22.116Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/9e6171e378822ab191c7abcfd3d8cfc8644516f6c7834c22e210e4acc070/numba-0.66.0-cp312-cp312-win_amd64.whl", hash = "sha256:b075a4e7ebc43dc6294f223e2821659656209fd5e0ce53245877c23d66d6e1a9", size = 2797403, upload-time = "2026-07-01T23:12:23.724Z" }, ] [[package]] @@ -1742,31 +1738,31 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, + { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b2/41ebc74ae1d5859901f1b69305de58724bf043381103d6ef413521cbc35a/opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264", size = 17048, upload-time = "2026-06-24T15:19:41.264Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -1777,14 +1773,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/b685ed7af2e17c29ffc8af56f1fa8bc2033258fc30fb0d2b722f49d13ba0/opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610", size = 21795, upload-time = "2026-06-24T15:19:43.164Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1792,28 +1788,28 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/97/02fe6e1c8b1ffac42d0b429c18080edb24e0e0d18c86612edf72b5752382/opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d", size = 41935, upload-time = "2026-06-24T15:19:12.951Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0c/cb9fe342de5299c7af24582eb7d788661cc53a1c4b904da92309caaa9417/opentelemetry_instrumentation-0.64b0-py3-none-any.whl", hash = "sha256:133ab7ffca796557aec059bf6be3190a34b6dea987f25be3d9409e230cbdad8b", size = 35880, upload-time = "2026-06-24T15:18:17.277Z" }, ] [[package]] name = "opentelemetry-instrumentation-celery" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/23/3930fda593ff62c33649aa9cb90d8c48f0654f717715503bfbd4b3a4cb66/opentelemetry_instrumentation_celery-0.63b1.tar.gz", hash = "sha256:90d45267e7ad43fdc8643ec5f17f8f310adc301e2e075774efba18222ee7bad3", size = 15520, upload-time = "2026-05-21T16:36:25.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/96/76c25b05c1e42793a0d9b78fbb383199fcf447b3b06ef7af6a518f3c8252/opentelemetry_instrumentation_celery-0.64b0.tar.gz", hash = "sha256:730e4ef8842861855dadc54c02b0fc3cf453c7d2b189a7125382a30e5d342e24", size = 15519, upload-time = "2026-06-24T15:19:22.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/8c/da3bdf3422feea847a4f19aebe8288bfa0b17b21b26cf0de444c6d2e9751/opentelemetry_instrumentation_celery-0.63b1-py3-none-any.whl", hash = "sha256:9e3888b3ed067196540ee1546155f69b757f0614dd1490930d86a5e021b28bd6", size = 13170, upload-time = "2026-05-21T16:35:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7f/c29233f0af2eaf41a015c3e143551cc70c47a97a1bc6e830b6712066c783/opentelemetry_instrumentation_celery-0.64b0-py3-none-any.whl", hash = "sha256:ef21be15d603ac84e15a0394c551a1d25721d31c24f0dcf3a9832dd7b7e06807", size = 13169, upload-time = "2026-06-24T15:18:30.747Z" }, ] [[package]] name = "opentelemetry-instrumentation-dbapi" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1821,14 +1817,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/bf/2bb8048a3ba5686bb70e5d3d7dd2aa2b3d838ccd324e88557780f86b7635/opentelemetry_instrumentation_dbapi-0.63b1.tar.gz", hash = "sha256:406978ed56bcfc5fd246fd918e6b36d0f5de26fa396c78cf63326a7b530597c8", size = 19323, upload-time = "2026-05-21T16:36:27.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/9f/3c6ce6f4394faa1540f48b7d442f455582ca76449952506ce767a54f09bf/opentelemetry_instrumentation_dbapi-0.64b0.tar.gz", hash = "sha256:8e3a1528fc9753a04190a7e7bf0180c5abd059f3aa68ec3857edb363c9847c44", size = 20138, upload-time = "2026-06-24T15:19:24.097Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/71/c7476e07d8bef5dfe5c301d1650f3ce15c4e3b7f4ffd4bbf6a0e3ebf3836/opentelemetry_instrumentation_dbapi-0.63b1-py3-none-any.whl", hash = "sha256:1bc842ddcacb7ec3622ef306ffdb3c584bf97df79d381e03604ea6eb23c63781", size = 14401, upload-time = "2026-05-21T16:35:23.941Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1b/77972514bcc046741339b76e2cc104bef536c1433b45a2b39adbdadc9889/opentelemetry_instrumentation_dbapi-0.64b0-py3-none-any.whl", hash = "sha256:1caa96d438bf37f71dd778c9f1e03ba1f50b8ab54aeef68b2a630c93c05cddc6", size = 14812, upload-time = "2026-06-24T15:18:33.541Z" }, ] [[package]] name = "opentelemetry-instrumentation-django" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1837,14 +1833,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/37/90fd5f0dc4a2042dfdb609a9dbe42fde26b3690f9c33ece529b024251015/opentelemetry_instrumentation_django-0.63b1.tar.gz", hash = "sha256:f2071d2f92e4779c5a14dd452b0dfe426343599e6efa9d888304fb639a9f3101", size = 25565, upload-time = "2026-05-21T16:36:27.727Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/2e/8f6084eab186533b0574683a8c39e51803e6c34c21fc33e194f58352d704/opentelemetry_instrumentation_django-0.64b0.tar.gz", hash = "sha256:3d2673b4f77156b15b1b119c8849b50e3644cfc325f7a24c03602596de2dbba4", size = 25387, upload-time = "2026-06-24T15:19:24.795Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/14/3efdfb34393f6780ab3dc916dba9221e7be5f07eaa768c4575e14cea98fa/opentelemetry_instrumentation_django-0.63b1-py3-none-any.whl", hash = "sha256:909ea4afbb18f16eb811d7ac330ed0f6fda7765191d49bccb8cca38c4fbc005f", size = 19125, upload-time = "2026-05-21T16:35:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/de/ef/7e7857fae434e0eb0395d8a5b399c21efd23fe0f3c166ecdcb8596902541/opentelemetry_instrumentation_django-0.64b0-py3-none-any.whl", hash = "sha256:c1f628e53c22aa8f9cc9cbe6e61e781940da6eda5f910d26720ef8ac73e25afe", size = 18938, upload-time = "2026-06-24T15:18:34.449Z" }, ] [[package]] name = "opentelemetry-instrumentation-httpx" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1853,28 +1849,28 @@ dependencies = [ { name = "opentelemetry-util-http" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/27/c2b4335bca030e893acbe5ff2b4f434868773bf94508be7e6bf5af981b24/opentelemetry_instrumentation_httpx-0.63b1.tar.gz", hash = "sha256:f41ec82f25c3abcdada621052db3e5fd648e3b43d55eec4b9c0c5d3ecb7b4ff4", size = 23557, upload-time = "2026-05-21T16:36:34.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/2a/2893a8781b93894f1e8014904c0342da7e4302de6597c2d5c0cb6c1a552e/opentelemetry_instrumentation_httpx-0.64b0.tar.gz", hash = "sha256:c2cfcd03d3665762860ebd0c28038c6e47fbb48d7942dec31dd75fc634d25c92", size = 23555, upload-time = "2026-06-24T15:19:29.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/b8/f536780996195c3b9f2354998554671e05a7a262df8c043f63fe9e5a6f0b/opentelemetry_instrumentation_httpx-0.63b1-py3-none-any.whl", hash = "sha256:14df6e99d81be9a8cd238f6639b6fa52404c4d3ce219058fcb5dc8c0f2211f86", size = 16336, upload-time = "2026-05-21T16:35:32.221Z" }, + { url = "https://files.pythonhosted.org/packages/06/29/a20309bd3f5a8051b61ca475e78623410c3b077e3deccae19aa4f8b5b9a2/opentelemetry_instrumentation_httpx-0.64b0-py3-none-any.whl", hash = "sha256:04829e5723941b5ceb0c88b44d63983e226b5c75b2b2e34a57739fdd0e060608", size = 16336, upload-time = "2026-06-24T15:18:41.412Z" }, ] [[package]] name = "opentelemetry-instrumentation-psycopg" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-instrumentation-dbapi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/5d/4c3a1be7e68f07c8e4491411e17e646139e32212ebe8a493a6c4d6dbd1bf/opentelemetry_instrumentation_psycopg-0.63b1.tar.gz", hash = "sha256:86819f4cccea21a47920807826495797b7261cc1893c8e855415e76fb7b8e2e8", size = 11737, upload-time = "2026-05-21T16:36:39.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d0/d0ef8b06ceb46f55e625b7f6c91d9a9d4c1ab17d535e60884428dd93afaa/opentelemetry_instrumentation_psycopg-0.64b0.tar.gz", hash = "sha256:e9f41fad425183392d97068dbe2ce6e938514520312cfd7bf409242bd800c013", size = 12115, upload-time = "2026-06-24T15:19:33.394Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/40/be335cad9028a04ca6025d6f311a76ebd200156cdee985138992895d84e1/opentelemetry_instrumentation_psycopg-0.63b1-py3-none-any.whl", hash = "sha256:4d74ec2dda8e54ead9bd71f134af984792f77e1d8809591ae53edadddd4c8fc0", size = 10857, upload-time = "2026-05-21T16:35:40.784Z" }, + { url = "https://files.pythonhosted.org/packages/42/0d/35029ab3c8aa5da2eae18f6e3ddb0636425927cdbcf4770ffe5a8aaf2201/opentelemetry_instrumentation_psycopg-0.64b0-py3-none-any.whl", hash = "sha256:a9835b8749db71a9c1e075ae81c084b1f370130341ceff6a231095f89f48a0c6", size = 10823, upload-time = "2026-06-24T15:18:47.659Z" }, ] [[package]] name = "opentelemetry-instrumentation-redis" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1882,14 +1878,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/58/2a91453c70943d6af4b5f9f1c232d69e6093800f95349ff5f1f8a89cf6ba/opentelemetry_instrumentation_redis-0.63b1.tar.gz", hash = "sha256:28d235159df43cc2bc8779af5c602afad1e08603fff75ac8ca34dd1bf30a9cb9", size = 16711, upload-time = "2026-05-21T16:36:44.662Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/89/9b556b53faf2b5a23f2bb844e087e6577704129bd5964decc3fb9d7a1309/opentelemetry_instrumentation_redis-0.64b0.tar.gz", hash = "sha256:c37e194c1e8ff59944d5c2f18014ee18f244a103958b1f2482099525ae9099f9", size = 17045, upload-time = "2026-06-24T15:19:38.281Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/17/33c21901325f6bf96939f355db174627c148c83211d0412622d4066f560d/opentelemetry_instrumentation_redis-0.63b1-py3-none-any.whl", hash = "sha256:f0e51c4006f68e340abbf28a7995feff004de78649697cbdf3bac0072cacd082", size = 14539, upload-time = "2026-05-21T16:35:49.995Z" }, + { url = "https://files.pythonhosted.org/packages/85/f2/96cd02f91303ea0d387aa46c615365877875ca47f9796e9fb1503ec1e0e6/opentelemetry_instrumentation_redis-0.64b0-py3-none-any.whl", hash = "sha256:5458a541986f665b9d7e641421fa5d0c286f1813b452faab6f84503823719d52", size = 14642, upload-time = "2026-06-24T15:18:54.141Z" }, ] [[package]] name = "opentelemetry-instrumentation-requests" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1897,14 +1893,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/39/7b14ef15c7c74b0da7d32b449732795a5cf7495897b72fc0b48280b96f50/opentelemetry_instrumentation_requests-0.63b1.tar.gz", hash = "sha256:513fcaa3d93debbdb359c00ce1a137a34a89ee908c51ac43beb7e8c18ac2b3cd", size = 18098, upload-time = "2026-05-21T16:36:46.02Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/97/d5fb7b3f329c24ff2f6478f1a0c0c516ba942d3df2acbb197d276af31816/opentelemetry_instrumentation_requests-0.64b0.tar.gz", hash = "sha256:8213a20b6578c41aa05cc5b48419aea75e1584924a4904dfcf36524597e7cc96", size = 18106, upload-time = "2026-06-24T15:19:39.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/18/a5e35fe8c9ad8041b71dd712658589de5d692aaa17d7cbce7f87a5cb0d0f/opentelemetry_instrumentation_requests-0.63b1-py3-none-any.whl", hash = "sha256:935c980a11e33bfd7ed969c741e4bd7c84077045651469f10e163534368d87f7", size = 13378, upload-time = "2026-05-21T16:35:52.166Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b2/f0f32ec327bdd76d245d28975dfb446d62eea2d267c06b6cb6c1a9b7e7d4/opentelemetry_instrumentation_requests-0.64b0-py3-none-any.whl", hash = "sha256:dcad4324ad97d785a5a3b9500acf3af3965b4c1d1b95776fa5e275b173d9541c", size = 13385, upload-time = "2026-06-24T15:18:56.163Z" }, ] [[package]] name = "opentelemetry-instrumentation-wsgi" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -1912,57 +1908,57 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/55/832f287fb153adc25c05bc2594d00ac4d1dbeca8b19388b2666d5154c912/opentelemetry_instrumentation_wsgi-0.63b1.tar.gz", hash = "sha256:03d61c4678ce82402e7f37b6a3dbd84cb97b85b3cb416a78c2e74c7c6d9451fa", size = 19667, upload-time = "2026-05-21T16:36:53.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/4f/0b5232a0d635eee5da7c85af503d2f00fdcac61cc6d4d4a69aadf1fa7a0e/opentelemetry_instrumentation_wsgi-0.64b0.tar.gz", hash = "sha256:1cce6ea28d1800c154e6ccdf52f05bae2f05c5e28ee44f0dddb17ada26208986", size = 19667, upload-time = "2026-06-24T15:19:46.407Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/b6/0854591a78960f376c7f943a6927c2863e1f8b0c93003aafe03aa49c089b/opentelemetry_instrumentation_wsgi-0.63b1-py3-none-any.whl", hash = "sha256:86779715262227d3436bdfb16aabd1c524b0f236725a69e8754ceda76c6d79dc", size = 13786, upload-time = "2026-05-21T16:36:05.221Z" }, + { url = "https://files.pythonhosted.org/packages/23/5d/4da612cfe1ad96730dba19c9ab97f531be0558a918998e11478734cb36b2/opentelemetry_instrumentation_wsgi-0.64b0-py3-none-any.whl", hash = "sha256:5a3b0174f39072c0abb749be4ae7e469c7585e87cdfaf63665657b1a7058c105", size = 13787, upload-time = "2026-06-24T15:19:05.417Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" }, ] [[package]] name = "opentelemetry-util-http" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/d8/7bf5e4cec0578ac3c28c18eb7b88f34279139cbc8c568d6aa02b9c5ae53e/opentelemetry_util_http-0.63b1.tar.gz", hash = "sha256:ba1268f00922ee522dba2ae38458060f99486e7385a8056985901ca9685adfff", size = 11102, upload-time = "2026-05-21T16:36:56.675Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/1b/1029a805fd7242f7dfce91633b244c3b14a94d703232878f71e01ce862b1/opentelemetry_util_http-0.64b0.tar.gz", hash = "sha256:8a86a220dbfc56d736f47f1e5c4e7932a21fcf69052312e1bcf166444dc79322", size = 11102, upload-time = "2026-06-24T15:19:48.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/f1/34e047e8f6a3c67e5220acf1af7b9f62868c25d77791bca74457bd2180a6/opentelemetry_util_http-0.63b1-py3-none-any.whl", hash = "sha256:6284194028c59cd439f8acfe388145069a6127f11dc077e1344a2094adacc3f8", size = 8205, upload-time = "2026-05-21T16:36:09.736Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/5f8ec5b30546f2dc22cd5fc5759bce2ab5be6e89a2e710a405ac9ef64ed3/opentelemetry_util_http-0.64b0-py3-none-any.whl", hash = "sha256:c1e5350d25507c1afcd6076cf9ac062485a0a4f79cd9971366996fd3056bacdb", size = 8204, upload-time = "2026-06-24T15:19:09.02Z" }, ] [[package]] @@ -2051,49 +2047,47 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, ] [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] name = "playwright" -version = "1.60.0" +version = "1.61.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet" }, { name = "pyee" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, - { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, - { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, - { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, - { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, - { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, - { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" }, + { url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" }, ] [[package]] @@ -2147,17 +2141,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.6" +version = "7.35.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, ] [[package]] @@ -2390,11 +2384,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -2431,7 +2425,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -2440,9 +2434,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -2473,36 +2467,36 @@ wheels = [ [[package]] name = "pytest-socket" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389, upload-time = "2024-01-28T20:17:23.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/3c/f9b58e57830e58980dbe8867d0e348f45701d3f3ea065672d448f4366da5/pytest_socket-0.8.0.tar.gz", hash = "sha256:af9bb5f487da72be63573a6194cfac033b6c7a1c1561e150521105970f9e99f2", size = 13912, upload-time = "2026-05-21T16:50:22.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e8/4a8568580bae3dcd678599ed8e86a82d505a44df71c1ced4246c1aa14b4b/pytest_socket-0.8.0-py3-none-any.whl", hash = "sha256:81821ba59f07d7600fe2b551d8714f40b068bd46e8b6704c48664e9d60cdacb8", size = 8414, upload-time = "2026-05-21T16:50:21.022Z" }, ] [[package]] name = "python-calamine" -version = "0.6.2" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/18/e1e53ade001b30a3c6642d876e5defe8431da8c31fb7798909e6c8ab8c34/python_calamine-0.6.2.tar.gz", hash = "sha256:2c90e5224c5e92db9fcd8f22b6085ce63b935cfe7a893ac9a1c3c56793bafd9d", size = 138000, upload-time = "2026-02-18T13:38:17.389Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/0b/81a04d6b50c6761906e5bbb3c23341540dfd072d1826de53f02a2b4e4fe0/python_calamine-0.7.0.tar.gz", hash = "sha256:761fc39ddc22736b261c46888d83649f1ab4207a4e2090f0066f0d09e1a0d691", size = 136922, upload-time = "2026-06-17T07:57:26.3Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/ec/e111c1a3a4c138ebc41e416e33730ee6d7c54e714af21c2a4e59b41715a5/python_calamine-0.6.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:857e4cddadba9b55c76dc583c58c5dc101a6cd5320190c10f8b2ab98d66c9040", size = 879539, upload-time = "2026-02-18T13:36:21.674Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/fe4c2138ff21542e2f1130a4d83c330d7f9486b62775196e998b88a03de6/python_calamine-0.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd89d6a53e4b22328cd685fc054c31d359cb3ae67bd24bc57e1c1db62a4cfc97", size = 858642, upload-time = "2026-02-18T13:36:23.847Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/bfeaf45ac5e2f6553723dd2fbe127d1d17c6f26496db5781de42a933776a/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d6c9af39db39e0c70710ae79cd1b5d980f9c0aea55fc16d194460c1561a0c6a", size = 925242, upload-time = "2026-02-18T13:36:25.236Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6e/81106aa80609075015d400584030605b05f5e12931717160dcc58fdc4980/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a2382dbc410dd48c99d89ee460662cc70892fe1b2901ab982604b923e8eb8f6", size = 905295, upload-time = "2026-02-18T13:36:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/4d/ba/6311b24f9889246be63b664630c5601039ef771f7ed04c8f51aace39b7a9/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ebb93255709874ede5b5e62828cb5758e60097e5390b6c9a3eb7751b617b12e", size = 1063473, upload-time = "2026-02-18T13:36:29.226Z" }, - { url = "https://files.pythonhosted.org/packages/23/e4/027a1b046d30768872307ebe808dc4cdc5357295cdcda98b30b3ea924904/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:837bca19bd945cb83aded433f4cf76e80d70a5400404d876400ca7e88e5ea311", size = 965355, upload-time = "2026-02-18T13:36:31.376Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4d/da8716a1b3a66938aaabe36873f6fa210fa063bab1b20c2ec236013de6b3/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:723990a47668cb819f307ccc634741370d3cd3804a0ee8cda392a522ae6d5016", size = 935091, upload-time = "2026-02-18T13:36:32.777Z" }, - { url = "https://files.pythonhosted.org/packages/36/40/9521e8da5496cbc4b18027626a40018301f546b3e9802ca2f3a6cb5b4739/python_calamine-0.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b067630d693e1d7de41e3d44a99c7dd3feebb52db8dda8636ac3f70d8b6a4ad6", size = 974070, upload-time = "2026-02-18T13:36:34.055Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b0/7a63963512c5ba7e9539b7452e2b1561625e63e4e29c044e487e2e93dcbe/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6ab09c9da53a2b33633e9f940aed11c08e083810a0fd6885826cdc52ba4f86a5", size = 1100321, upload-time = "2026-02-18T13:36:35.475Z" }, - { url = "https://files.pythonhosted.org/packages/22/81/e2bc38a5cf9629f656adcdabe8e134028f60c236e4bb96375dda90db3fdd/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:ae08e1308a0d0c6b8b4cc0a039ed8a85fc9ee2f8a3ca9ea57b1af9f97ed68fe4", size = 1181039, upload-time = "2026-02-18T13:36:37.195Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ea/513117015fd5903ca6dde9c8fb8502af60af6965642f4e3311623943e673/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c441a20c7aff0e904ca01b5cdc1e5be2c6d4a41a24a0ea4d5ea6d211343bb95f", size = 1144843, upload-time = "2026-02-18T13:36:38.393Z" }, - { url = "https://files.pythonhosted.org/packages/a2/14/8846478dacf31535f5f15448ade3bc688b51f3183f1b52844451aa27b0e6/python_calamine-0.6.2-cp312-cp312-win32.whl", hash = "sha256:39cae8e66f8bce499f5f965f4575ddf61e30184cc97f02e1c7031a57abe0903b", size = 692411, upload-time = "2026-02-18T13:36:39.741Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e2/2d2dcf4ec7e5ec08e33bf966ab010a7be178a4b623bd5f7601d47f2c734c/python_calamine-0.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:1617efa24532f2420934a8cf77e6d33ff1740cae1d39355cab4f4cf141fdab49", size = 748960, upload-time = "2026-02-18T13:36:40.922Z" }, - { url = "https://files.pythonhosted.org/packages/f1/eb/2f50f3395c0435e6186cab56c36d04c06581ba827264bca1f1acae523aa3/python_calamine-0.6.2-cp312-cp312-win_arm64.whl", hash = "sha256:c2b378db494740e540e8157a7e5fe61dadae69ad2d988a7c80f9583f434acf07", size = 718992, upload-time = "2026-02-18T13:36:42.671Z" }, + { url = "https://files.pythonhosted.org/packages/12/ef/9d5e11427d74f18ab276949500b9d1b684709383cefd74e5267b2766d006/python_calamine-0.7.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:215a69348eb661bfca4a921fd2435f5114f317bcca4a376c708eb7a5432b8c80", size = 873356, upload-time = "2026-06-17T07:55:38.397Z" }, + { url = "https://files.pythonhosted.org/packages/58/c4/8ec10f6cacd42eeadb046c9d1269f3dd9b8c539f2bbd57ccc53e1159c181/python_calamine-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:691beff5eb29350365e08dce786a0b234aab1ebcaa9f4e71ad80bfe20c555b4e", size = 861440, upload-time = "2026-06-17T07:55:40.202Z" }, + { url = "https://files.pythonhosted.org/packages/82/f8/d270e4463a60c038930e8db583e919f0a6bc795f67c40a8ea96de0b27212/python_calamine-0.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5428babe897ff1bd3663a1667a1730972f73db7781b6c3e1dd757f036c6d2569", size = 929399, upload-time = "2026-06-17T07:55:42.365Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/fc63eee397751d7d0174366d870f441b6a1f9089d2eb278046f6656132cc/python_calamine-0.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:30e77c1c6c96b9f19022de76d9b241fd1b0cf4a409ce8cdc59e84e66d3065d02", size = 913259, upload-time = "2026-06-17T07:55:44Z" }, + { url = "https://files.pythonhosted.org/packages/29/a1/a2a294ab7cdd1b29fc04f47c2fb6d599357914002f6692f08a8b081a9499/python_calamine-0.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7c5791803c97826995fec968625dd9456918cdda969a77296c0f1ea2f87aa58", size = 1084348, upload-time = "2026-06-17T07:55:45.5Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e6/e5cc567b18947d554e52fb5f2ec540b1ec271d5a303b0185c32cac587eb0/python_calamine-0.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95bee4df16c35f4442db7c7d795a124a611ea9665beb7ea709beff26dff8eab", size = 975506, upload-time = "2026-06-17T07:55:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c4/35b116c916b510bdfc2cf2b7a6ad511509a6de2e1f9630b86f0de8f816eb/python_calamine-0.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:581c3e7135bd30f689294efc174064bc5e6f2b580ac76541845678ffd8d7faf2", size = 952408, upload-time = "2026-06-17T07:55:48.806Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3b/378f720182a6d9dd9930db095afbc058fe2d8af59636205272fc49da9f24/python_calamine-0.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:98e141e7290f43e03583593ab0ad36723ec2460e8d3feaff430be3ba711526b3", size = 997443, upload-time = "2026-06-17T07:55:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c1/1011e3b6661e06c69e27a5cd6bc9078dd45fdb1ab38dbc65960d8e0a53ef/python_calamine-0.7.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:13e3ed1634c71b4b14fbb0778a5ac6ea703f9298578c03a85d7cc0c4287b8f79", size = 1105758, upload-time = "2026-06-17T07:55:52.113Z" }, + { url = "https://files.pythonhosted.org/packages/83/62/e991f60d768f54bd6170a0cacae44b10436bfc3791ff76db0403741038ed/python_calamine-0.7.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f184402af4e53a67471dcd928d843de4f29c1e9987c0c730d8ac3da7b11c28a5", size = 1186588, upload-time = "2026-06-17T07:55:53.662Z" }, + { url = "https://files.pythonhosted.org/packages/82/a5/27e50519589e2421abfa7b65bcaec354a74c9226aff8562d628a70685a64/python_calamine-0.7.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:60e921dc08cb92956ce63d81a2efcedce68f7a9e1f5842a05c9293a6f36785a0", size = 1163848, upload-time = "2026-06-17T07:55:55.38Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/ac8bb30f6b3b4c1541b81e12cc469757b249d01d4ce43b0b0cf50e59a7f5/python_calamine-0.7.0-cp312-cp312-win32.whl", hash = "sha256:a489da3116f90cc4b5c590aa5fe1f5f305f86899ddaddb37f62cd3dfaa20dc32", size = 732098, upload-time = "2026-06-17T07:55:56.999Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/27c191f8d0b332a435af7b88caf653a7962da2df6cd64acb7e00ea66f1af/python_calamine-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:49f11033d30155b8913c41c324a2199e3eb7a258e9df6713886fd59a7b91fe91", size = 769111, upload-time = "2026-06-17T07:55:58.841Z" }, + { url = "https://files.pythonhosted.org/packages/38/9a/706b90e5ad8a91da6ff168294027e4efe05c39a4c73d37ee961cbb8661a1/python_calamine-0.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:a12bd449f261941744346aa4517ea3eb39801d412a13be0424694a4134425899", size = 751353, upload-time = "2026-06-17T07:56:00.539Z" }, ] [[package]] @@ -2519,15 +2513,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.1" +version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, ] [[package]] @@ -2689,38 +2683,38 @@ wheels = [ [[package]] name = "rich-rst" -version = "2.0.1" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/56/3191bae66b08ccc637ea8120426068bcb361cc323c96404c310886937067/rich_rst-2.0.1.tar.gz", hash = "sha256:cbe236ed0901d1ec8427cc6a50bf0a34353ba28ad014dc24def68bfe7f3b9e68", size = 300570, upload-time = "2026-05-16T00:47:57.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3d/55c17d3ebdf3cd81356002afe5bef9bb8af631db2819785b6eac845b925b/rich_rst-2.0.1-py3-none-any.whl", hash = "sha256:7ee15f345ce25fa02b582c272a6cdbaf0c21243e38061cea273cff659bf3ef61", size = 272922, upload-time = "2026-05-16T00:47:55.508Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, ] [[package]] name = "rpds-py" -version = "0.30.0" +version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, ] [[package]] @@ -2738,15 +2732,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.60.0" +version = "2.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" }, + { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, ] [package.optional-dependencies] @@ -2756,11 +2750,11 @@ django = [ [[package]] name = "setuptools" -version = "82.0.1" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] @@ -2792,15 +2786,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.4.4" +version = "3.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249, upload-time = "2026-06-20T17:36:58.322Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518, upload-time = "2026-06-20T17:36:56.729Z" }, ] [[package]] @@ -2856,11 +2850,11 @@ wheels = [ [[package]] name = "traitlets" -version = "5.15.0" +version = "5.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, ] [[package]] @@ -2874,11 +2868,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -2904,14 +2898,14 @@ wheels = [ [[package]] name = "tzlocal" -version = "5.3.1" +version = "5.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, ] [[package]] @@ -2943,15 +2937,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.49.0" +version = "0.50.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/f6/cc9aadc0e481344a42095d222bfa764122fb8cfba708d1922917bd8bfb01/uvicorn-0.50.2.tar.gz", hash = "sha256:b92bf03509b82bcb9d49e7335b4fd364518ad021c2dc18b4e6a2fec8c955a0bb", size = 93716, upload-time = "2026-07-06T10:38:31.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/7c228ee10c7ab8fd3a21d06579a6f7c6075c6ce72594a20fb5d2f206ff24/uvicorn-0.50.2-py3-none-any.whl", hash = "sha256:4ae72a385630bcc17a0adb8290f26c993865e0b43a2114c2aab96420172c056a", size = 72846, upload-time = "2026-07-06T10:38:30.543Z" }, ] [[package]] @@ -2965,7 +2959,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.3" +version = "21.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -2973,9 +2967,9 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/65/ec1d92091671e6407d3e7c1f5801413bb7b2b57630a50cae7750456ba0ed/virtualenv-21.6.0.tar.gz", hash = "sha256:e18a4d750f2b64dea73e72ffde3922f3c52365fabdc8157ebd3da20d031c4734", size = 5526111, upload-time = "2026-07-06T22:49:56.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e7/2fbd0cc1653c53eed8f10670538bb547de2b3e37aacad283faa82a71094b/virtualenv-21.6.0-py3-none-any.whl", hash = "sha256:bce9d097950fef9d81129b333babfb7767072850c2f1acce0ec536708401bfd1", size = 5506216, upload-time = "2026-07-06T22:49:54.941Z" }, ] [[package]] @@ -3005,11 +2999,11 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.7.0" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] [[package]] @@ -3060,22 +3054,22 @@ wheels = [ [[package]] name = "wrapt" -version = "2.2.1" +version = "2.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, - { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, - { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, - { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, - { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, - { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, - { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, ] [[package]] @@ -3098,15 +3092,16 @@ wheels = [ [[package]] name = "zopfli" -version = "0.4.2" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/4d/b5a8c14cfc7beb5dd798f316418126b5107a54d5f9ba322ad874089b91ce/zopfli-0.4.2.tar.gz", hash = "sha256:a75e646fed3a2a42a82e69a81644009189c8ab4271691f020d52da8630d0580e", size = 179062, upload-time = "2026-05-26T11:00:52.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/dd/f2b25f7dd657db0fdd0436ea4caa0f5709132d65728bfb2d48ee11bc8bf7/zopfli-0.4.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d1972b58083b85af460d8936f623d849b487c151b26eae925b25ec921ae6cd22", size = 291485, upload-time = "2026-05-26T11:00:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/b50b1cc4d192953552751b3ed809a68c005392e14aefd78f63067fb7493f/zopfli-0.4.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:932083f7ca190fbd73cdf8ad7d32ec7231ecd25e1b3fade547cfb11b823ff91f", size = 829353, upload-time = "2026-05-26T11:00:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/bb/74/ff2a9351738fd6ec86249c4daaff3e4afb0deb3a8bff3640f3c6f3212a2b/zopfli-0.4.2-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18af6d2f778552d9bfdaa8e6caaf38c2bd57d0efba34a47860fcc4f6ba899696", size = 818434, upload-time = "2026-05-26T11:00:30.279Z" }, - { url = "https://files.pythonhosted.org/packages/3c/dd/fe32b457a95f4f75e96bba99d86935c3c5be988b1389c85cf4bbe2194844/zopfli-0.4.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:65a847b1f234df456584f0267fb0f270a95772e675a98ae9c21f3b4839077b2f", size = 1777947, upload-time = "2026-05-26T11:00:33.697Z" }, - { url = "https://files.pythonhosted.org/packages/73/ec/0959952a13124b5d58829f808daabe2d2e0358d25b33f7460366ddab21ce/zopfli-0.4.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:70c20595aa30aae246ac71a9fa471e2aa768318150ab814aeea2f651c59c6a32", size = 1863865, upload-time = "2026-05-26T11:00:35.297Z" }, - { url = "https://files.pythonhosted.org/packages/38/0e/11dab5379262c9b19ec9a2d8c0bc4314f62a47047ac6f54912a2e0fb82df/zopfli-0.4.2-cp310-abi3-win32.whl", hash = "sha256:d3bc0b483575ab8849d9c15b349f78ae0141eb4dfe2ba260c8fdc0b3ba5c6c42", size = 82229, upload-time = "2026-05-26T11:00:38.07Z" }, - { url = "https://files.pythonhosted.org/packages/04/f6/f75c781c1464045a3c465625e813605636f09e5fb2e15494eeee5263f8ef/zopfli-0.4.2-cp310-abi3-win_amd64.whl", hash = "sha256:bc25a3b3568910afb4bbd951d99835de29cade95417bca215339eac172eb381d", size = 102103, upload-time = "2026-05-26T11:00:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, ] From e681eb7ba0b7037182388f3f48fc7c60ecd9c45c Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Thu, 9 Jul 2026 00:45:36 +0900 Subject: [PATCH 21/70] fix: support zero-won checkout --- app/admin_api/serializers/shop/orders.py | 2 +- .../test/shop/order_notifications_api_test.py | 30 +++ app/admin_api/test/shop/orders_api_test.py | 65 ++++++ app/admin_api/test/shop/products_api_test.py | 21 ++ app/admin_api/views/shop/orders.py | 11 +- app/core/const/shop_error_messages.py | 5 +- app/shop/conftest.py | 20 +- app/shop/order/serializers/dto.py | 18 ++ app/shop/order/test/cart_api_test.py | 194 ++++++++++++++++ app/shop/order/test/opr_model_test.py | 9 + app/shop/order/test/order_model_test.py | 10 + app/shop/order/test/orders_api_test.py | 122 ++++++++++- app/shop/order/test/orders_schema_test.py | 16 ++ .../order/test/soft_delete_filtering_test.py | 3 +- app/shop/order/views/carts.py | 27 ++- app/shop/order/views/orders.py | 21 +- app/shop/patron.py | 1 + app/shop/payment_history/serializers.py | 17 +- app/shop/payment_history/services.py | 161 ++++++++++++++ .../test/concurrent_webhook_test.py | 4 +- .../test/free_checkout_service_test.py | 207 ++++++++++++++++++ app/shop/payment_history/test/tasks_test.py | 15 ++ .../test/webhook_serializer_test.py | 76 ++++++- .../payment_history/test/webhook_view_test.py | 4 +- app/shop/serializers/cart_validation/cart.py | 12 +- .../serializers/cart_validation/product.py | 44 +++- app/shop/test/cart_orderable_test.py | 30 ++- app/shop/test/patron_api_test.py | 13 ++ app/shop/test/product_orderable_test.py | 30 ++- app/shop/test/refund_serializer_test.py | 37 ++++ 30 files changed, 1181 insertions(+), 44 deletions(-) create mode 100644 app/shop/order/test/orders_schema_test.py create mode 100644 app/shop/payment_history/services.py create mode 100644 app/shop/payment_history/test/free_checkout_service_test.py diff --git a/app/admin_api/serializers/shop/orders.py b/app/admin_api/serializers/shop/orders.py index 32d022e..7b9635c 100644 --- a/app/admin_api/serializers/shop/orders.py +++ b/app/admin_api/serializers/shop/orders.py @@ -85,7 +85,7 @@ class Meta: current_paid_price = serializers.IntegerField(read_only=True) current_status = serializers.CharField(read_only=True) first_paid_at = serializers.DateTimeField(read_only=True) - latest_imp_id = serializers.CharField(read_only=True) + latest_imp_id = serializers.ReadOnlyField() class Meta: model = Order diff --git a/app/admin_api/test/shop/order_notifications_api_test.py b/app/admin_api/test/shop/order_notifications_api_test.py index 70c6869..ef26a7c 100644 --- a/app/admin_api/test/shop/order_notifications_api_test.py +++ b/app/admin_api/test/shop/order_notifications_api_test.py @@ -50,6 +50,36 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client, } +@pytest.mark.django_db +def test_notification_preview_includes_free_completed_order(api_client, order_email_template, order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + scancode_url = urljoin(settings.BACKEND_DOMAIN, order.scancode_path) + + response = OrderNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(order_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + assert response.json() == { + "template_variables": ["customer_email", "customer_name", "first_paid_price", "order_name"], + "recipients": [ + { + "recipient": order.customer_info.email, + "context": { + "scancode_url": scancode_url, + "order_name": order.name, + "first_paid_at": order.first_paid_at.isoformat(), + "first_paid_price": 0, + "customer_name": order.customer_info.name, + "customer_phone": order.customer_info.phone, + "customer_email": order.customer_info.email, + }, + "missing_variables": [], + } + ], + } + + @pytest.mark.django_db def test_notification_preview_rejects_unknown_template_id(api_client, order_factory): order_factory(status="completed") diff --git a/app/admin_api/test/shop/orders_api_test.py b/app/admin_api/test/shop/orders_api_test.py index 9d140ac..abce19d 100644 --- a/app/admin_api/test/shop/orders_api_test.py +++ b/app/admin_api/test/shop/orders_api_test.py @@ -3,9 +3,11 @@ import pandas import pytest +import yaml from admin_api.serializers.shop.orders import OrderAdminSerializer from admin_api.test.helpers import OrdersAdminApi from admin_api.views.shop.orders import OrderAdminViewSet +from core.const.shop_error_messages import NotRefundableErrorMessages from freezegun import freeze_time from model_bakery import baker from rest_framework.fields import DateTimeField @@ -17,6 +19,7 @@ HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, ) +from rest_framework.test import APIClient from shop.order.models import CustomerInfo, Order, OrderProductRelation from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus @@ -42,6 +45,22 @@ def test_admin_list_returns_only_orders_with_payment_history_and_products(api_cl } +@pytest.mark.django_db +def test_admin_list_includes_free_completed_order(api_client, order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + + response = OrdersAdminApi(http_client=api_client).list() + + assert response.status_code == HTTP_200_OK + row = response.json()["results"][0] + assert row["id"] == str(order.id) + assert row["current_status"] == PaymentHistoryStatus.completed + assert row["current_paid_price"] == 0 + assert row["latest_imp_id"] is None + assert row["payment_histories"][0]["price"] == 0 + assert row["payment_histories"][0]["imp_id"] is None + + @pytest.mark.django_db def test_admin_list_orders_by_first_paid_at_desc(api_client, order_factory): # 먼저 생성된 주문(= created_at 이 더 과거)이 더 최근에 결제되도록 구성. @@ -136,6 +155,19 @@ def test_admin_refund_action_refunds_order(api_client, mock_portone_req_cancel_p assert completed_order.payment_histories.filter(status=PaymentHistoryStatus.refunded).exists() +@pytest.mark.django_db +def test_admin_refund_action_rejects_free_completed_order_without_portone_cancel( + api_client, mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + + response = OrdersAdminApi(http_client=api_client).refund(order.id) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST in str(response.json()) + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_admin_refund_product_action_does_partial_refund( api_client, ticket_product, mock_portone_req_cancel_payment, order_factory @@ -165,6 +197,39 @@ def test_admin_refund_product_action_returns_404_for_unknown_rel(api_client, ord assert response.status_code == HTTP_404_NOT_FOUND +@pytest.mark.django_db +def test_admin_refund_product_action_rejects_free_completed_opr_without_portone_cancel( + api_client, mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + opr = order.products.get() + + response = OrdersAdminApi(http_client=api_client).refund_product(order.id, opr.id) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE in str(response.json()) + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_admin_refund_actions_document_validation_error_responses(): + response = APIClient().get("/api/schema/v1/") + assert response.status_code == HTTP_200_OK + + schema = yaml.safe_load(response.content) + total_refund_path = next(path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/refund/")) + product_refund_path = next( + path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/products/{rel_id}/refund/") + ) + + for path in (total_refund_path, product_refund_path): + responses = schema["paths"][path]["post"]["responses"] + assert "204" in responses + assert responses["400"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ValidationErrorResponse", + } + + @pytest.mark.django_db def test_admin_refund_allows_expired_window(api_client, mock_portone_req_cancel_payment, order_factory): completed_order = order_factory(status="completed") diff --git a/app/admin_api/test/shop/products_api_test.py b/app/admin_api/test/shop/products_api_test.py index ac5e95a..96632ac 100644 --- a/app/admin_api/test/shop/products_api_test.py +++ b/app/admin_api/test/shop/products_api_test.py @@ -149,6 +149,27 @@ def test_admin_product_create_returns_201(api_client, ticket_product): assert Product.objects.filter(name_ko="신규 상품").exists() +@pytest.mark.django_db +def test_admin_product_create_allows_zero_price(api_client, ticket_product): + response = ProductsAdminApi(http_client=api_client).create( + { + "name_ko": "무료 튜토리얼", + "name_en": "Free Tutorial", + "price": 0, + "stock": 10, + "visible_starts_at": FAR_PAST.isoformat(), + "visible_ends_at": FAR_FUTURE.isoformat(), + "orderable_starts_at": FAR_PAST.isoformat(), + "orderable_ends_at": FAR_FUTURE.isoformat(), + "refundable_ends_at": FAR_FUTURE.isoformat(), + "category": str(ticket_product.category.id), + } + ) + assert response.status_code == HTTP_201_CREATED + assert response.json()["price"] == 0 + assert Product.objects.filter(name_ko="무료 튜토리얼", price=0).exists() + + @pytest.mark.django_db def test_admin_product_partial_update_can_set_refundable_ends_at_null(api_client, ticket_product): # null = 환불 불가 상품. 운영자가 어드민에서 직접 지정하는 경로. diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py index 95f9f1a..bd9011c 100644 --- a/app/admin_api/views/shop/orders.py +++ b/app/admin_api/views/shop/orders.py @@ -16,6 +16,7 @@ from django.db import models, transaction from django.http.response import StreamingHttpResponse from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view +from drf_standardized_errors.openapi_serializers import ValidationErrorResponseSerializer from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets from rest_framework.decorators import action from shop.order import exports, imports @@ -92,7 +93,10 @@ class OrderAdminViewSet( @extend_schema( summary="주문 전체 환불", tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND], - responses={status.HTTP_204_NO_CONTENT: None}, + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + }, ) @action(detail=True, methods=["post"], url_path="refund") @transaction.atomic @@ -109,7 +113,10 @@ def refund(self, request: request.Request, pk: typing.Any = None) -> response.Re @extend_schema( summary="주문 부분 환불", tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND], - responses={status.HTTP_204_NO_CONTENT: None}, + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + }, ) @action(detail=True, methods=["post"], url_path=r"products/(?P[^/.]+)/refund") @transaction.atomic diff --git a/app/core/const/shop_error_messages.py b/app/core/const/shop_error_messages.py index f80d187..033bf6e 100644 --- a/app/core/const/shop_error_messages.py +++ b/app/core/const/shop_error_messages.py @@ -22,7 +22,7 @@ class ProductNotOrderableErrorMessages: SOLDOUT = "{} 상품은 매진되었습니다." ALREADY_ORDERED_TOO_MUCH = "{} 상품의 인당 최대 구매 수량 초과로 구매하실 수 없습니다." TOO_MUCH_CART_PRODUCT = "{} 상품의 재고 수량을 초과하여 구매하실 수 없습니다. 장바구니에 담은 수량을 확인해주세요." - PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 1원 이상으로 구매해주세요." + PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 0원 이상으로 구매해주세요." PRICE_TOO_HIGH = "결제 금액이 너무 높습니다, 후원 금액 등을 줄여 100만원 미만으로 구매해주세요." DONATION_NOT_ALLOWED = "{} 상품은 후원이 불가능한 상품입니다." DONATION_PRICE_OUT_OF_RANGE = "{} 상품의 후원 금액이 범위를 벗어났습니다. {}원 이상 {}원 이하로 입력해주세요." @@ -57,7 +57,7 @@ class CartNotOrderableErrorMessages: ALREADY_ORDERED = "이미 결제한 장바구니입니다." CONTAINS_PAID_PRODUCT = "결제한 상품이 포함되어 있습니다. PyCon 한국 준비 위원회에 문의해주세요." EMPTY = "장바구니가 비어있습니다, 먼저 상품을 담아주세요." - CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 1원 이상으로 구매해주세요." + CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 0원 이상으로 구매해주세요." CART_PRICE_TOO_HIGH = "장바구니의 금액이 너무 높습니다. 일부 상품을 제거하여 100만원 미만으로 구매해주세요." TICKET_INFO_REQUIRED = "참가자 정보가 입력되지 않은 티켓이 있습니다. 모든 티켓의 참가자 정보를 입력해주세요." @@ -99,6 +99,7 @@ class PortOneWebhookFailureCode(models.TextChoices): UNEXPECTED_RETRIEVED_ORDER_STATUS = "UNEXPECTED_RETRIEVED_ORDER_STATUS", "예상한 결제 상태가 아닙니다." UNEXPECTED_RETRIEVED_ORDER_ID = "UNEXPECTED_RETRIEVED_ORDER_ID", "결제 ID가 일치하지 않습니다." UNEXPECTED_PAID_PRICE = "UNEXPECTED_PAID_PRICE", "결제 금액이 일치하지 않습니다." + ORDER_NOT_ORDERABLE = "ORDER_NOT_ORDERABLE", "주문 가능한 상태가 아니거나 재고가 부족합니다." UNSUPPORTED_CURRENCY = "UNSUPPORTED_CURRENCY", "지원하지 않는 통화입니다." ILLEGAL_STATUS_TRANSITION = "ILLEGAL_STATUS_TRANSITION", "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다." CANCELLED_NOT_SUPPORTED = ( diff --git a/app/shop/conftest.py b/app/shop/conftest.py index 436bef8..7cf548b 100644 --- a/app/shop/conftest.py +++ b/app/shop/conftest.py @@ -243,7 +243,14 @@ def order_factory(request, customer_user): ``donation>0`` 이면 종류와 무관하게 ``donation_product`` 사용. """ - def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = True) -> Order: + def make( + *, + status: OrderStatus = "cart", + donation: int = 0, + is_ticket: bool = True, + product_price: int | None = None, + imp_id: str | None = _COMPLETED_ORDER_IMP_ID, + ) -> Order: if status == "empty": return Order.objects.create(user=customer_user, name="cart") @@ -256,7 +263,10 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T name_en=used_product.name_en, ) OrderProductRelation.objects.create( - order=order, product=used_product, price=used_product.price, donation_price=donation + order=order, + product=used_product, + price=used_product.price if product_price is None else product_price, + donation_price=donation, ) CustomerInfo.objects.create(order=order, name="홍길동", phone="01012345678", email="customer@example.com") @@ -270,7 +280,7 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T order.products.update(status=OrderProductRelation.OrderProductStatus.paid) PaymentHistory.objects.create( order=order, - imp_id=_COMPLETED_ORDER_IMP_ID, + imp_id=imp_id, status=PaymentHistoryStatus.completed, price=order.first_paid_price, ) @@ -287,14 +297,14 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T if status == "refunded": order.products.update(status=OrderProductRelation.OrderProductStatus.refunded) second_ph = PaymentHistory.objects.create( - order=order, imp_id=_COMPLETED_ORDER_IMP_ID, status=PaymentHistoryStatus.refunded, price=0 + order=order, imp_id=imp_id, status=PaymentHistoryStatus.refunded, price=0 ) PaymentHistory.objects.filter(id=second_ph.id).update(created_at=later_at) return order if status == "partial_refunded": second_ph = PaymentHistory.objects.create( order=order, - imp_id=_COMPLETED_ORDER_IMP_ID, + imp_id=imp_id, status=PaymentHistoryStatus.partial_refunded, price=order.first_paid_price // 2, ) diff --git a/app/shop/order/serializers/dto.py b/app/shop/order/serializers/dto.py index 56b69a0..87255b5 100644 --- a/app/shop/order/serializers/dto.py +++ b/app/shop/order/serializers/dto.py @@ -156,3 +156,21 @@ class Meta: "merchant_uid", ) model = SingleProductCart + + +class CreateSingleProductOrderResponseDto(serializers.Serializer): + id = serializers.UUIDField() + name = serializers.CharField() + payment_histories = PaymentHistoryDto(many=True) + products = OrderProductRelationDto(many=True) + first_paid_price = serializers.IntegerField() + current_paid_price = serializers.IntegerField() + current_status = serializers.ChoiceField(choices=PaymentHistoryStatus.choices) + created_at = serializers.DateTimeField() + customer_info = CustomerInfoDto(allow_null=True) + merchant_uid = serializers.CharField(allow_null=True) + + # 0원 즉시 완료 응답(OrderDto)에만 존재하는 필드. 유료 SingleProductCart 응답에는 없음. + scancode_url = serializers.URLField(required=False, allow_null=True) + first_paid_at = serializers.DateTimeField(required=False, allow_null=True) + not_fully_refundable_reason = serializers.CharField(required=False, allow_null=True) diff --git a/app/shop/order/test/cart_api_test.py b/app/shop/order/test/cart_api_test.py index 8d7e8a3..90c6384 100644 --- a/app/shop/order/test/cart_api_test.py +++ b/app/shop/order/test/cart_api_test.py @@ -1,5 +1,13 @@ +import threading +import time +from types import SimpleNamespace + import pytest +from core.const.shop_error_messages import ProductNotOrderableErrorMessages from core.util.testutil import to_json +from django import db +from django.db import transaction +from rest_framework.exceptions import ValidationError from rest_framework.status import ( HTTP_200_OK, HTTP_201_CREATED, @@ -7,9 +15,13 @@ HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND, ) +from rest_framework.test import APIClient from shop.conftest import VALID_TICKET_INFO from shop.order.models import Order, OrderProductRelation from shop.order.serializers.dto import OrderDto +from shop.order.views.carts import CartProductViewSet +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from shop.serializers.cart_validation import OrderableCheckSerializerMode, ProductOrderableCheckSerializer from shop.test.helpers import CartApi, CartProductsApi @@ -92,6 +104,147 @@ def test_cart_add_product_creates_new_cart_when_none_exists(customer_client, cus assert list(opr.history.values_list("history_type", flat=True)) == ["+"] +@pytest.mark.django_db +def test_cart_product_queryset_create_action_uses_unlocked_queryset(customer_user): + view = CartProductViewSet() + view.request = SimpleNamespace(user=customer_user) + view.action = "create" + + assert list(view.get_queryset()) == [] + + +@pytest.mark.django_db(transaction=True) +def test_cart_add_product_after_concurrent_free_checkout_creates_new_cart(customer_user, ticket_product): + completed_cart = Order.objects.create(user=customer_user, name="cart") + created_order_ids = [] + errors = [] + + def add_product(): + db.close_old_connections() + try: + user = customer_user.__class__.objects.get(id=customer_user.id) + serializer = ProductOrderableCheckSerializer( + data={ + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + }, + context={ + "request": SimpleNamespace(user=user), + "mode": OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART, + }, + ) + serializer.is_valid(raise_exception=True) + created_order_ids.append(serializer.save().order_id) + except Exception as exc: # pragma: no cover - surfaced by assertion below + errors.append(exc) + finally: + db.close_old_connections() + + with transaction.atomic(): + Order.objects.select_for_update().get(id=completed_cart.id) + thread = threading.Thread(target=add_product) + thread.start() + time.sleep(0.2) + PaymentHistory.objects.create( + order=completed_cart, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + + thread.join(timeout=3) + + assert not thread.is_alive() + assert errors == [] + assert created_order_ids != [completed_cart.id] + assert not completed_cart.products.filter_active().exists() + assert Order.objects.filter(user=customer_user).exclude(id=completed_cart.id).count() == 1 + + +@pytest.mark.django_db(transaction=True) +def test_concurrent_cart_add_without_existing_cart_reuses_one_cart(monkeypatch, customer_user, ticket_product): + original_get_cart = ProductOrderableCheckSerializer._get_unpaid_cart_for_update + no_cart_barrier = threading.Barrier(2) + created_order_ids = [] + errors = [] + + def delayed_get_cart(serializer): + cart = original_get_cart(serializer) + if cart is None: + try: + no_cart_barrier.wait(timeout=0.5) + except threading.BrokenBarrierError: + pass + return cart + + monkeypatch.setattr(ProductOrderableCheckSerializer, "_get_unpaid_cart_for_update", delayed_get_cart) + + def add_product(): + db.close_old_connections() + try: + user = customer_user.__class__.objects.get(id=customer_user.id) + serializer = ProductOrderableCheckSerializer( + data={ + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + }, + context={ + "request": SimpleNamespace(user=user), + "mode": OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART, + }, + ) + serializer.is_valid(raise_exception=True) + created_order_ids.append(serializer.save().order_id) + except Exception as exc: # pragma: no cover - surfaced by assertion below + errors.append(exc) + finally: + db.close_old_connections() + + threads = [threading.Thread(target=add_product) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert all(not thread.is_alive() for thread in threads) + assert errors == [] + assert len(created_order_ids) == 2 + assert len(set(created_order_ids)) == 1 + cart = Order.objects.get(user=customer_user) + assert cart.products.filter_active().count() == 2 + + +@pytest.mark.django_db +def test_cart_add_revalidates_after_user_lock_before_saving(customer_user, ticket_product): + ticket_product.max_quantity_per_user = 1 + ticket_product.save(update_fields=["max_quantity_per_user"]) + payload = { + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + } + context = { + "request": SimpleNamespace(user=customer_user), + "mode": OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART, + } + first_serializer = ProductOrderableCheckSerializer(data=payload, context=context) + second_serializer = ProductOrderableCheckSerializer(data=payload, context=context) + assert first_serializer.is_valid(raise_exception=True) + assert second_serializer.is_valid(raise_exception=True) + + first_serializer.save() + + with pytest.raises(ValidationError) as exc_info: + second_serializer.save() + + assert ProductNotOrderableErrorMessages.ALREADY_ORDERED_TOO_MUCH.format(ticket_product.name) in str( + exc_info.value.detail + ) + assert OrderProductRelation.objects.filter(product=ticket_product, order__user=customer_user).count() == 1 + + @pytest.mark.django_db def test_cart_add_product_rejects_invalid_product_id(customer_client): response = CartProductsApi(http_client=customer_client).create( @@ -126,6 +279,47 @@ def test_cart_remove_product_invalidates_prepared_payment(customer_client, order assert pending_order.prepared_cart_hash is None +@pytest.mark.django_db(transaction=True) +def test_cart_remove_product_after_concurrent_free_checkout_returns_404(customer_user, order_factory): + pending_order = order_factory() + opr = pending_order.products.first() + responses = [] + + def remove_product(): + db.close_old_connections() + try: + user = customer_user.__class__.objects.get(id=customer_user.id) + client = APIClient() + client.force_authenticate(user=user) + responses.append(CartProductsApi(http_client=client).delete(opr.id)) + finally: + db.close_old_connections() + + with transaction.atomic(): + Order.objects.select_for_update().get(id=pending_order.id) + thread = threading.Thread(target=remove_product) + thread.start() + time.sleep(0.2) + locked_opr = OrderProductRelation.objects.select_for_update().get(id=opr.id) + locked_opr.status = OrderProductRelation.OrderProductStatus.paid + locked_opr.save(update_fields=["status"]) + PaymentHistory.objects.create( + order=pending_order, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + + thread.join(timeout=3) + + assert not thread.is_alive() + assert len(responses) == 1 + assert responses[0].status_code == HTTP_404_NOT_FOUND + opr.refresh_from_db() + assert opr.deleted_at is None + assert opr.status == OrderProductRelation.OrderProductStatus.paid + + @pytest.mark.django_db def test_cart_remove_product_rejects_other_users_opr(other_client, order_factory): pending_order = order_factory() diff --git a/app/shop/order/test/opr_model_test.py b/app/shop/order/test/opr_model_test.py index 7344fbb..44dcc5e 100644 --- a/app/shop/order/test/opr_model_test.py +++ b/app/shop/order/test/opr_model_test.py @@ -63,6 +63,15 @@ def test_opr_not_refundable_reason_when_price_is_zero(customer_user, ticket_prod assert opr.not_refundable_reason == NotRefundableErrorMessages.PRODUCT_PRICE_IS_ZERO +@pytest.mark.django_db +def test_free_completed_opr_without_imp_id_not_refundable(order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + opr = order.products.get() + + assert opr.status == OrderProductRelation.OrderProductStatus.paid + assert opr.not_refundable_reason == NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE + + @pytest.mark.django_db def test_opr_not_refundable_reason_returns_none_for_refundable_opr(order_factory): completed_order = order_factory(status="completed") diff --git a/app/shop/order/test/order_model_test.py b/app/shop/order/test/order_model_test.py index 7d533a6..5e7eb4b 100644 --- a/app/shop/order/test/order_model_test.py +++ b/app/shop/order/test/order_model_test.py @@ -132,6 +132,16 @@ def test_not_fully_refundable_reason_when_price_zero(customer_user, ticket_produ assert refreshed.not_fully_refundable_reason == NotRefundableErrorMessages.ORDER_REFUNDABLE_PRICE_NOT_FOUND +@pytest.mark.django_db +def test_free_completed_order_without_imp_id_not_refundable_as_no_imp_id(order_factory): + order = order_factory(status="completed", product_price=0, imp_id=None) + + assert order.current_status == PaymentHistoryStatus.completed + assert order.current_paid_price == 0 + assert order.latest_imp_id is None + assert order.not_fully_refundable_reason == NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST + + @pytest.mark.django_db def test_not_fully_refundable_reason_when_expected_price_mismatch(ticket_product, order_factory): completed_order = order_factory(status="completed") diff --git a/app/shop/order/test/orders_api_test.py b/app/shop/order/test/orders_api_test.py index 16b38b4..dae98e1 100644 --- a/app/shop/order/test/orders_api_test.py +++ b/app/shop/order/test/orders_api_test.py @@ -17,8 +17,8 @@ from shop.conftest import VALID_TICKET_INFO from shop.order.models import CustomerInfo, Order, OrderProductRelation, SingleProductCart, TicketInfo from shop.order.serializers.dto import OrderDto, SingleProductCartDto -from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus -from shop.test.helpers import OrdersApi +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, PaymentWebhookEvent +from shop.test.helpers import CartProductsApi, OrdersApi @pytest.mark.django_db @@ -105,6 +105,52 @@ def test_create_single_product_order_creates_cart_and_calls_portone( assert list(cart.order_product_relation.history.values_list("history_type", flat=True)) == ["+"] +@pytest.mark.django_db +def test_create_single_product_order_free_completes_without_portone_or_webhook( + customer_client, customer_user, ticket_product, mock_portone_register +): + ticket_product.price = 0 + ticket_product.save(update_fields=["price"]) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + response = OrdersApi(http_client=customer_client).create_single( + { + "product": str(ticket_product.id), + "options": [], + "customer_info": { + "name": "홍길동", + "phone": "010-1234-5678", + "email": "customer@example.com", + "organization": "", + }, + "ticket_info": VALID_TICKET_INFO, + } + ) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert response.status_code == HTTP_201_CREATED + completed = Order.objects.for_dto_response().get(user=customer_user) + assert response.json() == to_json(OrderDto(instance=completed).data) + assert completed.current_status == PaymentHistoryStatus.completed + assert completed.current_paid_price == 0 + assert completed.latest_imp_id is None + assert not SingleProductCart.objects.filter(id=completed.id).exists() + assert completed.products.get().status == OrderProductRelation.OrderProductStatus.paid + assert PaymentHistory.objects.filter( + order=completed, + status=PaymentHistoryStatus.completed, + price=0, + imp_id=None, + ).exists() + mock_portone_register.assert_not_called() + assert not PaymentWebhookEvent.objects.exists() + mock_delay.assert_called_once_with(str(completed.id)) + + @pytest.mark.django_db def test_create_single_product_order_rejects_invalid_product(customer_client, mock_portone_register): response = OrdersApi(http_client=customer_client).create_single( @@ -157,6 +203,53 @@ def test_create_order_persists_customer_info_and_schedules_portone_call( mocked_on_commit.assert_called_once() +@pytest.mark.django_db +def test_create_order_free_cart_completes_without_portone_or_webhook( + customer_client, customer_user, ticket_product, mock_portone_register +): + ticket_product.price = 0 + ticket_product.save(update_fields=["price"]) + add_response = CartProductsApi(http_client=customer_client).create( + { + "product": str(ticket_product.id), + "options": [], + "ticket_info": VALID_TICKET_INFO, + } + ) + assert add_response.status_code == HTTP_201_CREATED + cart = Order.objects.get(user=customer_user) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + response = OrdersApi(http_client=customer_client).create( + {"name": "홍길동", "phone": "010-1234-5678", "email": "customer@example.com", "organization": ""} + ) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert response.status_code == HTTP_201_CREATED + completed = Order.objects.for_dto_response().get(id=cart.id) + assert response.json() == to_json(OrderDto(instance=completed).data) + assert completed.current_status == PaymentHistoryStatus.completed + assert completed.current_paid_price == 0 + assert completed.latest_imp_id is None + assert list(completed.products.values_list("status", flat=True)) == [OrderProductRelation.OrderProductStatus.paid] + assert ( + PaymentHistory.objects.filter( + order=completed, + status=PaymentHistoryStatus.completed, + price=0, + imp_id=None, + ).count() + == 1 + ) + mock_portone_register.assert_not_called() + assert not PaymentWebhookEvent.objects.exists() + mock_delay.assert_called_once_with(str(completed.id)) + + @pytest.mark.django_db def test_destroy_order_refunds_when_owned_by_request_user( customer_client, mock_portone_req_cancel_payment, order_factory @@ -181,6 +274,23 @@ def test_destroy_order_rejects_other_users_order(other_client, mock_portone_req_ mock_portone_req_cancel_payment.assert_not_called() +@pytest.mark.django_db +def test_destroy_order_rejects_free_completed_order_without_portone_cancel( + customer_client, mock_portone_req_cancel_payment, order_factory +): + completed_order = order_factory(status="completed", product_price=0, imp_id=None) + + response = OrdersApi(http_client=customer_client).delete(completed_order.id) + + assert response.status_code == HTTP_400_BAD_REQUEST + completed_order.refresh_from_db() + assert list(completed_order.products.values_list("status", flat=True)) == [ + OrderProductRelation.OrderProductStatus.paid + ] + assert completed_order.payment_histories.count() == 1 + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_retrieve_receipt_returns_kcp_redirect_html_for_owner(customer_client, mock_portone_kcp_receipt, order_factory): completed_order = order_factory(status="completed") @@ -199,11 +309,13 @@ def test_retrieve_receipt_returns_404_when_order_has_no_imp_id(customer_client, @pytest.mark.django_db -def test_retrieve_receipt_returns_404_when_payment_history_has_no_imp_id(customer_client, order_factory): - completed_order = order_factory(status="completed") - completed_order.payment_histories.update(imp_id=None) +def test_retrieve_receipt_returns_404_when_payment_history_has_no_imp_id( + customer_client, mock_portone_kcp_receipt, order_factory +): + completed_order = order_factory(status="completed", product_price=0, imp_id=None) response = OrdersApi(http_client=customer_client).retrieve_receipt(completed_order.id) assert response.status_code == HTTP_404_NOT_FOUND + mock_portone_kcp_receipt.assert_not_called() @pytest.mark.django_db diff --git a/app/shop/order/test/orders_schema_test.py b/app/shop/order/test/orders_schema_test.py new file mode 100644 index 0000000..d796318 --- /dev/null +++ b/app/shop/order/test/orders_schema_test.py @@ -0,0 +1,16 @@ +import pytest +import yaml +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_create_single_product_order_response_schema_is_not_ambiguous_oneof(): + response = APIClient().get("/api/schema/v1/") + assert response.status_code == 200 + + schema = yaml.safe_load(response.content) + path = next(path for path in schema["paths"] if path.endswith("/shop/orders/single/")) + response_schema = schema["paths"][path]["post"]["responses"]["201"]["content"]["application/json"]["schema"] + + assert response_schema == {"$ref": "#/components/schemas/CreateSingleProductOrderResponseDto"} + assert "oneOf" not in schema["components"]["schemas"]["CreateSingleProductOrderResponseDto"] diff --git a/app/shop/order/test/soft_delete_filtering_test.py b/app/shop/order/test/soft_delete_filtering_test.py index 0130be5..29a86f3 100644 --- a/app/shop/order/test/soft_delete_filtering_test.py +++ b/app/shop/order/test/soft_delete_filtering_test.py @@ -10,7 +10,7 @@ from django.urls import reverse from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_404_NOT_FOUND from rest_framework.test import APIClient -from shop.conftest import WEBHOOK_WHITELISTED_IP +from shop.conftest import VALID_TICKET_INFO, WEBHOOK_WHITELISTED_IP from shop.order.exports import OrderProductExportSerializer from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, TicketInfo from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus @@ -160,6 +160,7 @@ def test_webhook_paid_transition_skips_soft_deleted_opr(mock_portone_find_paymen """webhook PAID 처리 시 soft-deleted OPR 는 paid 로 전환되지 않고 deleted 상태가 유지된다.""" order = order_factory(status="cart") active_opr = order.products.get() + TicketInfo.objects.create(order_product_relation=active_opr, **VALID_TICKET_INFO) stale_opr = OrderProductRelation.objects.create(order=order, product=ticket_product, price=ticket_product.price) stale_opr.delete() order.prepare_payment() diff --git a/app/shop/order/views/carts.py b/app/shop/order/views/carts.py index 43df149..bfa1d19 100644 --- a/app/shop/order/views/carts.py +++ b/app/shop/order/views/carts.py @@ -1,11 +1,12 @@ import typing from core.const.tag import OpenAPITag +from django.db import transaction from django.db.models import Exists, OuterRef, QuerySet from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes from drf_spectacular.utils import extend_schema, extend_schema_view from drf_standardized_errors.openapi_serializers import ErrorResponse403Serializer, ValidationErrorResponseSerializer -from rest_framework import mixins, request, response, status, viewsets +from rest_framework import exceptions, mixins, request, response, status, viewsets from shop.order.models import Order, OrderProductRelation from shop.order.serializers.dto import OrderDto from shop.payment_history.models import PaymentHistory @@ -77,7 +78,13 @@ class CartProductViewSet(mixins.CreateModelMixin, mixins.DestroyModelMixin, view lookup_url_kwarg = "order_product_rel_id" serializer_class = ProductOrderableCheckSerializer - def get_queryset(self) -> QuerySet[Order]: + def get_queryset(self) -> QuerySet[OrderProductRelation]: + queryset = self._base_queryset() + if getattr(self, "action", None) == "destroy": + return queryset.select_for_update(of=("self",)) + return queryset + + def _base_queryset(self) -> QuerySet[OrderProductRelation]: if not isinstance(self.request.user, UserExt): return OrderProductRelation.objects.none() @@ -88,3 +95,19 @@ def get_queryset(self) -> QuerySet[Order]: single_product_cart__isnull=True, status=OrderProductRelation.OrderProductStatus.pending, ) + + def _lock_parent_order_for_destroy(self, order_product_rel_id: typing.Any) -> None: + order_id = self._base_queryset().filter(id=order_product_rel_id).values_list("order_id", flat=True).first() + if not order_id: + raise exceptions.NotFound() + + order = Order.objects.select_for_update().filter_active().filter(id=order_id).first() + if not order or PaymentHistory.objects.filter_active().filter(order_id=order_id).exists(): + raise exceptions.NotFound() + + @transaction.atomic + def destroy( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + self._lock_parent_order_for_destroy(kwargs.get(self.lookup_url_kwarg)) + return super().destroy(request, *args, **kwargs) diff --git a/app/shop/order/views/orders.py b/app/shop/order/views/orders.py index b0e154c..abb9530 100644 --- a/app/shop/order/views/orders.py +++ b/app/shop/order/views/orders.py @@ -16,12 +16,18 @@ from rest_framework import mixins, renderers, request, response, serializers, status, viewsets from rest_framework.decorators import action from shop.order.models import CustomerInfo, Order, OrderProductRelation, OrderQuerySet -from shop.order.serializers.dto import CertificateIssueResponseDto, OrderDto, SingleProductCartDto +from shop.order.serializers.dto import ( + CertificateIssueResponseDto, + CreateSingleProductOrderResponseDto, + OrderDto, + SingleProductCartDto, +) from shop.order.serializers.validator import ( OptionProductOptionCustomResponseModifyRequestSerializer, OrderProductUpdateSerializer, ) from shop.payment_history.models import PaymentHistory +from shop.payment_history.services import complete_free_checkout from shop.serializers.cart_validation import ( CartOrderableCheckSerializer, CustomerInfoCheckSerializer, @@ -93,7 +99,7 @@ def get_queryset(self) -> models.QuerySet[Order]: tags=[OpenAPITag.SHOP_ORDER], request=SingleProductCartOrderableCheckSerializer, responses={ - status.HTTP_201_CREATED: SingleProductCartDto, + status.HTTP_201_CREATED: CreateSingleProductOrderResponseDto, status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, }, @@ -112,6 +118,11 @@ def create_single_product_order( cart = order_product_rel.single_product_cart cart.prepare_payment() + if cart.prepared_price == 0: + completed_order = complete_free_checkout(cart) + dto_order = Order.objects.for_dto_response().get(id=completed_order.id) + return response.Response(data=OrderDto(instance=dto_order).data, status=status.HTTP_201_CREATED) + portone_client.register_or_update_prepared_payment(merchant_id=cart.merchant_uid, price=cart.prepared_price) return response.Response(data=SingleProductCartDto(instance=cart).data, status=status.HTTP_201_CREATED) @@ -145,6 +156,7 @@ def create( data=[ { "product": product_rel.product_id, + "donation_price": product_rel.donation_price, "options": [ { "product_option_group": product_option_rel.product_option_group_id, @@ -171,6 +183,11 @@ def create( cart.save() cart.prepare_payment() + if cart.prepared_price == 0: + completed_order = complete_free_checkout(cart) + dto_cart = Order.objects.for_dto_response().get(id=completed_order.id) + return response.Response(data=OrderDto(instance=dto_cart).data, status=status.HTTP_201_CREATED) + # idempotent + forward-only — DB commit 후 비동기 호출. 실패 시 클라이언트 retry 가 자연 보상. transaction.on_commit( lambda: portone_client.register_or_update_prepared_payment( diff --git a/app/shop/patron.py b/app/shop/patron.py index f78f6ea..248bfbb 100644 --- a/app/shop/patron.py +++ b/app/shop/patron.py @@ -70,6 +70,7 @@ class PatronViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): OrderProductRelation.objects.filter_active().filter( order_id=OuterRef("id"), product__donation_allowed=True, + donation_price__gt=0, status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, ) ), diff --git a/app/shop/payment_history/serializers.py b/app/shop/payment_history/serializers.py index 67b68bc..e697596 100644 --- a/app/shop/payment_history/serializers.py +++ b/app/shop/payment_history/serializers.py @@ -14,7 +14,9 @@ PaymentWebhookEvent, is_legal_payment_status_transition, ) +from shop.payment_history.services import lock_and_revalidate_checkout_order from shop.payment_history.tasks import send_payment_completed_notifications +from shop.serializers.cart_validation import OrderableCheckSerializerMode class PortOneV1PaymentStatus(models.TextChoices): @@ -124,6 +126,9 @@ def validate(self, data: dict) -> dict: if retrieved_order_data["currency"] != "KRW": self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.UNSUPPORTED_CURRENCY) + if retrieved_order_data["amount"] <= 0: + self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE) + if not order.matches_payment_preparation(data["merchant_uid"], retrieved_order_data["amount"]): self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE) @@ -132,6 +137,11 @@ def validate(self, data: dict) -> dict: @transaction.atomic def create(self, validated_data: dict) -> PaymentHistory: # CANCELLED webhook (관리자 콘솔 취소) 자동 처리는 미구현 — validate_status 에서 거부됨. + validation_mode = ( + OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + if isinstance(self.cart_or_order, SingleProductCart) + else OrderableCheckSerializerMode.CHECKOUT_CART + ) order = self._lock_or_promote_order(validated_data["merchant_uid"]) # cart→Order 승격 시 cached cart_or_order 가 stale (cart 는 hard_delete 됨) — 새 Order 로 교체해 # 이후 _queue_event 가 derive 하는 order/single_product_cart 가 deleted row 를 가리키지 않게 한다. @@ -144,7 +154,12 @@ def create(self, validated_data: dict) -> PaymentHistory: if not is_legal_payment_status_transition(order.current_status, next_status): self._reject(PortOneWebhookFailureCode.ILLEGAL_STATUS_TRANSITION) - for product_rel in order.products.filter_active(): + try: + product_rels = lock_and_revalidate_checkout_order(order, validation_mode) + except serializers.ValidationError: + self._reject_and_cancel_paid_payment(PortOneWebhookFailureCode.ORDER_NOT_ORDERABLE) + + for product_rel in product_rels: product_rel.status = OrderProductRelation.OrderProductStatus.paid product_rel.save() diff --git a/app/shop/payment_history/services.py b/app/shop/payment_history/services.py new file mode 100644 index 0000000..0173642 --- /dev/null +++ b/app/shop/payment_history/services.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from django.db import transaction +from rest_framework import serializers +from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition +from shop.payment_history.tasks import send_payment_completed_notifications +from shop.product.models import Option, OptionGroup, Product, Tag +from shop.serializers.cart_validation import ( + CartOrderableCheckSerializer, + OrderableCheckSerializerMode, + ProductOrderableCheckSerializer, +) + +FREE_CHECKOUT_PRICE_ERROR = "무료 주문은 결제 준비 금액이 0원이어야 합니다." +FREE_CHECKOUT_TARGET_ERROR = "무료 주문 대상을 찾을 수 없습니다." +FREE_CHECKOUT_TRANSITION_ERROR = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다." + + +def complete_free_checkout(cart_or_order: Order | SingleProductCart) -> Order: + validation_mode = ( + OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + if isinstance(cart_or_order, SingleProductCart) + else OrderableCheckSerializerMode.CHECKOUT_CART + ) + + with transaction.atomic(): + order = _lock_or_promote_order(cart_or_order) + + if order.prepared_price != 0: + raise serializers.ValidationError(FREE_CHECKOUT_PRICE_ERROR) + if not is_legal_payment_status_transition(order.current_status, PaymentHistoryStatus.completed): + raise serializers.ValidationError(FREE_CHECKOUT_TRANSITION_ERROR) + + product_rels = lock_and_revalidate_checkout_order(order, validation_mode) + + for product_rel in product_rels: + product_rel.status = OrderProductRelation.OrderProductStatus.paid + product_rel.save(update_fields=["status"]) + + PaymentHistory.objects.create( + order=order, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + transaction.on_commit(lambda: send_payment_completed_notifications.delay(str(order.id))) + _clear_order_payment_cache(order) + return order + + +def _lock_or_promote_order(cart_or_order: Order | SingleProductCart) -> Order: + if isinstance(cart_or_order, SingleProductCart): + if cart := SingleProductCart.objects.select_for_update().filter_active().filter(id=cart_or_order.id).first(): + return cart.to_order() + if order := Order.objects.select_for_update().filter_active().filter(id=cart_or_order.id).first(): + return order + raise serializers.ValidationError(FREE_CHECKOUT_TARGET_ERROR) + + return Order.objects.select_for_update().filter_active().get(id=cart_or_order.id) + + +def lock_and_revalidate_checkout_order( + order: Order, validation_mode: OrderableCheckSerializerMode +) -> list[OrderProductRelation]: + product_rels = _lock_order_allocation_rows(order) + context = {"request": SimpleNamespace(user=order.user), "mode": validation_mode} + + ProductOrderableCheckSerializer( + data=[_build_product_validation_payload(product_rel, validation_mode) for product_rel in product_rels], + context=context, + many=True, + ).is_valid(raise_exception=True) + + if validation_mode == OrderableCheckSerializerMode.CHECKOUT_CART: + CartOrderableCheckSerializer(data={"cart": order.id}, context=context).is_valid(raise_exception=True) + + return product_rels + + +def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]: + product_rels = list( + OrderProductRelation.objects.select_for_update() + .filter_active() + .filter(order=order) + .select_related("product", "product__category") + .order_by("id") + ) + product_rel_ids = [product_rel.id for product_rel in product_rels] + product_ids = [product_rel.product_id for product_rel in product_rels] + + if not product_ids: + return product_rels + + list(Product.objects.select_for_update().filter_active().filter(id__in=product_ids).order_by("id")) + list(Tag.objects.select_for_update().filter_active().filter(products__product_id__in=product_ids).order_by("id")) + + option_groups = list( + OptionGroup.objects.select_for_update().filter_active().filter(product_id__in=product_ids).order_by("id") + ) + option_group_ids = [option_group.id for option_group in option_groups] + + if option_group_ids: + list(Option.objects.select_for_update().filter_active().filter(group_id__in=option_group_ids).order_by("id")) + + list( + OrderProductOptionRelation.objects.select_for_update() + .filter_active() + .filter(order_product_relation_id__in=product_rel_ids) + .order_by("id") + ) + list(TicketInfo.objects.select_for_update().filter_active().filter(order_product_relation_id__in=product_rel_ids)) + + return product_rels + + +def _build_product_validation_payload( + product_rel: OrderProductRelation, validation_mode: OrderableCheckSerializerMode +) -> dict: + payload = { + "product": product_rel.product_id, + "donation_price": product_rel.donation_price, + "options": [ + { + "product_option_group": option_rel.product_option_group_id, + "product_option": option_rel.product_option_id, + "custom_response": option_rel.custom_response, + } + for option_rel in product_rel.options.filter_active().order_by("id") + ], + } + + if validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT and product_rel.product.category.is_ticket: + ticket_info = getattr(product_rel, "ticket_info", None) + if ticket_info: + payload["ticket_info"] = { + "name": ticket_info.name, + "phone": ticket_info.phone, + "email": ticket_info.email, + "organization": ticket_info.organization or "", + "contribution_message": ticket_info.contribution_message, + } + + return payload + + +def _clear_order_payment_cache(order: Order) -> None: + for attr in ( + "active_products", + "active_payment_histories", + "first_paid_price", + "first_payment_history", + "first_paid_at", + "current_payment_history", + "current_paid_price", + "current_status", + "latest_imp_id", + ): + order.__dict__.pop(attr, None) diff --git a/app/shop/payment_history/test/concurrent_webhook_test.py b/app/shop/payment_history/test/concurrent_webhook_test.py index e56c88d..830cc44 100644 --- a/app/shop/payment_history/test/concurrent_webhook_test.py +++ b/app/shop/payment_history/test/concurrent_webhook_test.py @@ -11,7 +11,8 @@ import pytest from django.db import connections -from shop.order.models import Order, SingleProductCart +from shop.conftest import VALID_TICKET_INFO +from shop.order.models import Order, SingleProductCart, TicketInfo from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus from shop.payment_history.serializers import PortOneV1WebhookRequestSerializer from shop.test.helpers import make_portone_payment_info, make_webhook_payload @@ -23,6 +24,7 @@ def test_concurrent_webhooks_for_same_cart_create_exactly_one_payment_history( ): cart_id = single_product_cart.id expected_price = single_product_cart.first_paid_price + TicketInfo.objects.create(order_product_relation=single_product_cart.order_product_relation, **VALID_TICKET_INFO) single_product_cart.prepare_payment() merchant_uid = single_product_cart.merchant_uid mock_portone_find_payment_info.return_value = make_portone_payment_info(order=single_product_cart) diff --git a/app/shop/payment_history/test/free_checkout_service_test.py b/app/shop/payment_history/test/free_checkout_service_test.py new file mode 100644 index 0000000..1ea8c88 --- /dev/null +++ b/app/shop/payment_history/test/free_checkout_service_test.py @@ -0,0 +1,207 @@ +import uuid +from unittest.mock import patch + +import pytest +from core.const.shop_error_messages import CartNotOrderableErrorMessages, ProductNotOrderableErrorMessages +from core.util.testutil import errors_payload +from rest_framework.exceptions import ValidationError +from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from shop.payment_history.services import complete_free_checkout + + +@pytest.mark.django_db +def test_complete_free_checkout_marks_order_paid_and_records_zero_payment(customer_user, non_ticket_product): + order = Order.objects.create(user=customer_user, name="free") + OrderProductRelation.objects.create(order=order, product=non_ticket_product, price=0) + order.prepare_payment() + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + completed = complete_free_checkout(order) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert completed.id == order.id + assert list(completed.products.values_list("status", flat=True)) == [OrderProductRelation.OrderProductStatus.paid] + assert completed.current_status == PaymentHistoryStatus.completed + assert completed.current_paid_price == 0 + payment_history = PaymentHistory.objects.get(order=completed) + assert payment_history.status == PaymentHistoryStatus.completed + assert payment_history.price == 0 + assert payment_history.imp_id is None + mock_delay.assert_called_once_with(str(order.id)) + + +@pytest.mark.django_db +def test_complete_free_checkout_promotes_single_product_cart(single_product_cart, option): + product = single_product_cart.order_product_relation.product + product.price = 0 + product.donation_allowed = True + product.save(update_fields=["price", "donation_allowed"]) + single_product_cart.order_product_relation.price = 0 + single_product_cart.order_product_relation.donation_price = 0 + single_product_cart.order_product_relation.save(update_fields=["price", "donation_price"]) + OrderProductOptionRelation.objects.create( + order_product_relation=single_product_cart.order_product_relation, + product_option_group=option.group, + product_option=option, + custom_response=None, + ) + TicketInfo.objects.create( + order_product_relation=single_product_cart.order_product_relation, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + contribution_message="응원합니다", + ) + single_product_cart.prepare_payment() + cart_id = single_product_cart.id + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + patch("shop.payment_history.services.send_payment_completed_notifications.delay") as mock_delay, + ): + completed = complete_free_checkout(single_product_cart) + mock_on_commit.assert_called_once() + mock_on_commit.call_args.args[0]() + + assert completed.id == cart_id + assert Order.objects.filter(id=cart_id).exists() + assert not SingleProductCart.objects.filter(id=cart_id).exists() + completed_opr = completed.products.select_related("ticket_info").prefetch_related("options").get() + assert completed_opr.status == OrderProductRelation.OrderProductStatus.paid + assert completed_opr.price == 0 + assert completed_opr.donation_price == 0 + ticket_info = completed_opr.ticket_info + assert ticket_info.name == "김참가" + assert ticket_info.phone == "010-9999-8888" + assert ticket_info.email == "attendee@example.com" + assert ticket_info.organization == "PSK" + assert ticket_info.contribution_message == "응원합니다" + option_rel = completed_opr.options.get() + assert option_rel.product_option_group_id == option.group_id + assert option_rel.product_option_id == option.id + assert option_rel.custom_response is None + assert PaymentHistory.objects.filter( + order=completed, + status=PaymentHistoryStatus.completed, + price=0, + imp_id=None, + ).exists() + mock_delay.assert_called_once_with(str(cart_id)) + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_empty_zero_price_order(customer_user): + order = Order.objects.create(user=customer_user, name="empty") + order.prepare_payment() + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + pytest.raises(ValidationError) as exc_info, + ): + complete_free_checkout(order) + + assert errors_payload(exc_info.value.detail) == { + "non_field_errors": [{"detail": CartNotOrderableErrorMessages.EMPTY, "code": "invalid"}] + } + assert PaymentHistory.objects.filter(order=order).count() == 0 + mock_on_commit.assert_not_called() + + +@pytest.mark.django_db +def test_complete_free_checkout_rechecks_stock_after_single_cart_preparation( + single_product_cart, other_user +): + product = single_product_cart.order_product_relation.product + product.price = 0 + product.stock = 1 + product.save(update_fields=["price", "stock"]) + single_product_cart.order_product_relation.price = 0 + single_product_cart.order_product_relation.save(update_fields=["price"]) + TicketInfo.objects.create( + order_product_relation=single_product_cart.order_product_relation, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + single_product_cart.prepare_payment() + completed_order = Order.objects.create(user=other_user, name="already paid") + OrderProductRelation.objects.create( + order=completed_order, + product=product, + price=0, + status=OrderProductRelation.OrderProductStatus.paid, + ) + PaymentHistory.objects.create( + order=completed_order, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=0, + ) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + pytest.raises(ValidationError) as exc_info, + ): + complete_free_checkout(single_product_cart) + + assert ProductNotOrderableErrorMessages.SOLDOUT.format(product.name) in str(exc_info.value.detail) + assert PaymentHistory.objects.filter(order_id=single_product_cart.id).count() == 0 + assert SingleProductCart.objects.filter(id=single_product_cart.id).exists() + mock_on_commit.assert_not_called() + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_duplicate_single_cart_completion(single_product_cart): + single_product_cart.order_product_relation.price = 0 + single_product_cart.order_product_relation.save(update_fields=["price"]) + TicketInfo.objects.create( + order_product_relation=single_product_cart.order_product_relation, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + single_product_cart.prepare_payment() + cart_id = single_product_cart.id + complete_free_checkout(single_product_cart) + + with ( + patch("shop.payment_history.services.transaction.on_commit") as mock_on_commit, + pytest.raises(ValidationError) as exc_info, + ): + complete_free_checkout(single_product_cart) + + assert errors_payload(exc_info.value.detail) == [ + {"detail": "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다.", "code": "invalid"} + ] + assert PaymentHistory.objects.filter(order_id=cart_id).count() == 1 + mock_on_commit.assert_not_called() + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_positive_amount(order_factory): + order = order_factory(status="prepared", is_ticket=False) + + with pytest.raises(ValidationError) as exc_info: + complete_free_checkout(order) + + assert errors_payload(exc_info.value.detail) == [ + {"detail": "무료 주문은 결제 준비 금액이 0원이어야 합니다.", "code": "invalid"} + ] + + +@pytest.mark.django_db +def test_complete_free_checkout_rejects_missing_single_cart_target(): + ghost_cart = SingleProductCart(id=uuid.uuid4()) + + with pytest.raises(ValidationError) as exc_info: + complete_free_checkout(ghost_cart) + + assert errors_payload(exc_info.value.detail) == [{"detail": "무료 주문 대상을 찾을 수 없습니다.", "code": "invalid"}] diff --git a/app/shop/payment_history/test/tasks_test.py b/app/shop/payment_history/test/tasks_test.py index b6fdb99..8280040 100644 --- a/app/shop/payment_history/test/tasks_test.py +++ b/app/shop/payment_history/test/tasks_test.py @@ -116,6 +116,21 @@ def test_creates_email_history_when_template_exists(order_with_customer, email_t assert sent_to.status == NotificationStatus.CREATED +@pytest.mark.django_db +def test_send_payment_completed_notifications_uses_zero_price_context( + order_factory, email_template, override_email_setting +): + order = order_factory(status="completed", product_price=0, imp_id=None) + + send_payment_completed_notifications(str(order.id)) + + history = EmailNotificationHistory.objects.filter_active().get() + sent_to = history.sent_to_list.get() + assert sent_to.context["first_paid_price"] == 0 + assert sent_to.context["first_paid_at"] == order.first_paid_at.isoformat() + assert sent_to.context["customer_email"] == order.customer_info.email + + # --------------------------------------------------------------------------- # Happy path — 알림톡 # --------------------------------------------------------------------------- diff --git a/app/shop/payment_history/test/webhook_serializer_test.py b/app/shop/payment_history/test/webhook_serializer_test.py index 1dc94e1..b9b0ada 100644 --- a/app/shop/payment_history/test/webhook_serializer_test.py +++ b/app/shop/payment_history/test/webhook_serializer_test.py @@ -6,7 +6,8 @@ from core.external_apis.portone.client import PortOneException from core.util.testutil import errors_payload from rest_framework import serializers as drf_serializers -from shop.order.models import Order, OrderProductRelation, SingleProductCart +from shop.conftest import VALID_TICKET_INFO +from shop.order.models import Order, OrderProductRelation, SingleProductCart, TicketInfo from shop.payment_history.models import PaymentHistoryStatus, PaymentWebhookEvent from shop.payment_history.serializers import PortOneV1WebhookRequestSerializer, PortOneV1WebhookRequestStatus from shop.test.helpers import make_portone_payment_info, make_webhook_payload @@ -167,6 +168,42 @@ def test_validate_rejects_when_paid_amount_does_not_match_order_price( ) +@pytest.mark.django_db +def test_validate_rejects_zero_amount_webhook_without_cancel( + mock_portone_find_payment_info, mock_portone_req_cancel_payment, order_factory +): + pending_order = order_factory(status="prepared") + mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order, amount=0) + serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [ + {"detail": PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE.label, "code": "UNEXPECTED_PAID_PRICE"} + ], + } + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_validate_rejects_zero_prepared_order_webhook_without_free_checkout_bypass( + mock_portone_find_payment_info, mock_portone_req_cancel_payment, order_factory +): + pending_order = order_factory(status="prepared", product_price=0) + assert pending_order.prepared_price == 0 + mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order, amount=0) + serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [ + {"detail": PortOneWebhookFailureCode.UNEXPECTED_PAID_PRICE.label, "code": "UNEXPECTED_PAID_PRICE"} + ], + } + assert pending_order.payment_histories.count() == 0 + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_validate_rejects_fractional_paid_amount( mock_portone_find_payment_info, mock_portone_req_cancel_payment, order_factory @@ -277,7 +314,7 @@ def test_validate_passes_happy_path_for_single_product_cart(single_product_cart, @pytest.mark.django_db def test_create_completes_pending_order(mock_portone_find_payment_info, order_factory): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order, imp_uid="imp_done") serializer = PortOneV1WebhookRequestSerializer( data=make_webhook_payload(merchant_uid=pending_order.merchant_uid, imp_uid="imp_done") @@ -293,8 +330,41 @@ def test_create_completes_pending_order(mock_portone_find_payment_info, order_fa assert statuses == [OrderProductRelation.OrderProductStatus.paid] +@pytest.mark.django_db +def test_create_revalidates_allocation_after_validate_before_marking_paid( + mock_portone_find_payment_info, + mock_portone_req_cancel_payment, + order_factory, + non_ticket_product, +): + non_ticket_product.stock = 1 + non_ticket_product.save(update_fields=["stock"]) + pending_order = order_factory(status="prepared", is_ticket=False) + mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) + serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) + assert serializer.is_valid() + + order_factory(status="completed", is_ticket=False) + + with pytest.raises(drf_serializers.ValidationError): + serializer.save() + + pending_order.refresh_from_db() + assert pending_order.payment_histories.count() == 0 + assert list(pending_order.products.values_list("status", flat=True)) == [ + OrderProductRelation.OrderProductStatus.pending, + ] + mock_portone_req_cancel_payment.assert_called_once_with( + imp_id="imp_test", + refund_request_price=pending_order.first_paid_price, + current_leftover_price=pending_order.first_paid_price, + reason=PortOneWebhookFailureCode.ORDER_NOT_ORDERABLE.label, + ) + + @pytest.mark.django_db def test_create_promotes_single_product_cart_to_order(single_product_cart, mock_portone_find_payment_info): + TicketInfo.objects.create(order_product_relation=single_product_cart.order_product_relation, **VALID_TICKET_INFO) single_product_cart.prepare_payment() mock_portone_find_payment_info.return_value = make_portone_payment_info(order=single_product_cart) serializer = PortOneV1WebhookRequestSerializer( @@ -330,7 +400,7 @@ def test_create_rejects_webhook_when_state_machine_blocks( @pytest.mark.django_db def test_create_registers_notification_task_on_commit(mock_portone_find_payment_info, mocked_on_commit, order_factory): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) serializer = PortOneV1WebhookRequestSerializer(data=make_webhook_payload(merchant_uid=pending_order.merchant_uid)) assert serializer.is_valid() diff --git a/app/shop/payment_history/test/webhook_view_test.py b/app/shop/payment_history/test/webhook_view_test.py index c06ea0c..336c633 100644 --- a/app/shop/payment_history/test/webhook_view_test.py +++ b/app/shop/payment_history/test/webhook_view_test.py @@ -59,7 +59,7 @@ def _post_webhook( def test_ip_allowlist_respects_debug_bypass( debug, expected_status, expected_body, mock_portone_find_payment_info, order_factory ): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) with override_settings(DEBUG=debug): response = _post_webhook(merchant_uid=pending_order.merchant_uid, ip=_NON_WHITELISTED_IP) @@ -96,7 +96,7 @@ def test_ip_allowlist_respects_debug_bypass( ) @pytest.mark.django_db def test_accepts_request_from_whitelisted_ip(post_kwargs, mock_portone_find_payment_info, order_factory): - pending_order = order_factory(status="prepared") + pending_order = order_factory(status="prepared", is_ticket=False) mock_portone_find_payment_info.return_value = make_portone_payment_info(order=pending_order) response = _post_webhook(merchant_uid=pending_order.merchant_uid, **post_kwargs) assert response.status_code == HTTP_200_OK diff --git a/app/shop/serializers/cart_validation/cart.py b/app/shop/serializers/cart_validation/cart.py index 00d2bb7..fb9e606 100644 --- a/app/shop/serializers/cart_validation/cart.py +++ b/app/shop/serializers/cart_validation/cart.py @@ -34,24 +34,28 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: def validate(self, data: dict) -> dict: cart: Order = data["cart"] + active_products = cart.products.filter_active() # 이미 결제한 장바구니인 경우 주문 불가능 # `current_status` 는 active PH 기준이라 != pending 이면 곧 active PH 가 존재 — 별도 exists() 불필요. if cart.current_status != PaymentHistoryStatus.pending: raise serializers.ValidationError(CartNotOrderableErrorMessages.ALREADY_ORDERED) + if not active_products.exists(): + raise serializers.ValidationError(CartNotOrderableErrorMessages.EMPTY) + # 장바구니 내에 이미 결제한 상품이 있는 경우 주문 불가능 - if cart.products.filter_active().exclude(status=OrderProductRelation.OrderProductStatus.pending).exists(): + if active_products.exclude(status=OrderProductRelation.OrderProductStatus.pending).exists(): raise serializers.ValidationError(CartNotOrderableErrorMessages.CONTAINS_PAID_PRODUCT) - # 장바구니 내의 상품들 주문 금액 합계가 0원 이하거나 100만원 이상인 경우 주문 불가능 - if cart.first_paid_price <= 0: + # 장바구니 내의 상품들 주문 금액 합계가 음수이거나 100만원 이상인 경우 주문 불가능 + if cart.first_paid_price < 0: raise serializers.ValidationError(CartNotOrderableErrorMessages.CART_PRICE_TOO_LOW) if cart.first_paid_price >= 1_000_000: raise serializers.ValidationError(CartNotOrderableErrorMessages.CART_PRICE_TOO_HIGH) # 티켓 상품은 결제 전 참가자 정보(TicketInfo)가 반드시 있어야 함 - if cart.products.filter_active().filter(product__category__is_ticket=True, ticket_info__isnull=True).exists(): + if active_products.filter(product__category__is_ticket=True, ticket_info__isnull=True).exists(): raise serializers.ValidationError(CartNotOrderableErrorMessages.TICKET_INFO_REQUIRED) return data diff --git a/app/shop/serializers/cart_validation/product.py b/app/shop/serializers/cart_validation/product.py index 58a871d..c3c97e7 100644 --- a/app/shop/serializers/cart_validation/product.py +++ b/app/shop/serializers/cart_validation/product.py @@ -52,7 +52,7 @@ class ProductOrderableCheckSerializer(serializers.ModelSerializer): - 재고 충분 (mode 별 cart 누적 / +1 / 무시 분기) - 인당 최대 구매 수량 안 (mode 별) - 후원 금액 정책: donation_allowed + [min, max] 범위 - - 총 금액 (product + donation + option additional) 이 [1원, 100만원) + - 총 금액 (product + donation + option additional) 이 [0원, 100만원) ==================== 상품군 (Tag) ==================== - TagOrderableCheckSerializer 위임 (재고 / 인당 한도) @@ -259,8 +259,8 @@ def validate( + donation_price + sum(o["product_option"].additional_price for o in options if o["product_option"]) ) - # 후원 금액 포함 단일 상품 금액이 0원 이하인 경우 주문 불가능 — PortOne 결제는 0원 금액에서 실패하므로 사전 차단. - if total_price <= 0: + # 후원 금액 포함 단일 상품 금액이 음수인 경우 주문 불가능. + if total_price < 0: raise serializers.ValidationError(ProductNotOrderableErrorMessages.PRICE_TOO_LOW) # 후원 금액 포함 단일 상품 금액이 100만원 이상인 경우 주문 불가능 if total_price >= 1_000_000: @@ -322,7 +322,9 @@ def create(self, validated_data: ProductOrderableCheckAfterValidationDataType) - } if self.validation_mode == OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: - if not (cart := Order.objects.filter(user=self.user).filter_has_no_payment_histories().first()): + self._lock_user_for_cart_update() + self._revalidate_add_to_cart_after_user_lock(validated_data) + if not (cart := self._get_unpaid_cart_for_update()): cart = Order.objects.create(user=self.user) order_product_rel_create_kwargs["order"] = cart elif self.validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: @@ -351,3 +353,37 @@ def create(self, validated_data: ProductOrderableCheckAfterValidationDataType) - TicketInfo.objects.create(**ticket_info_data, order_product_relation=order_product_rel) return order_product_rel + + def _lock_user_for_cart_update(self) -> None: + UserExt.objects.select_for_update().only("id").get(id=self.user.id) + + def _revalidate_add_to_cart_after_user_lock( + self, validated_data: ProductOrderableCheckAfterValidationDataType + ) -> None: + payload: dict[str, object] = { + "product": validated_data["product"].id, + "donation_price": validated_data.get("donation_price", 0), + "options": [ + { + "product_option_group": option_data["product_option_group"].id, + "product_option": option_data["product_option"].id if option_data["product_option"] else None, + "custom_response": option_data["custom_response"], + } + for option_data in validated_data["options"] + ], + } + if ticket_info := validated_data.get("ticket_info"): + payload["ticket_info"] = ticket_info + + ProductOrderableCheckSerializer(data=payload, context=self.context).is_valid(raise_exception=True) + + def _get_unpaid_cart_for_update(self) -> Order | None: + cart = Order.objects.select_for_update().filter(user=self.user).filter_has_no_payment_histories().first() + if not cart: + return None + + # A concurrent free checkout can create PaymentHistory while this SELECT waits on the cart row. + # Recheck in a fresh statement after the lock so stale writers do not mutate a completed order. + if Order.objects.filter(id=cart.id).filter_has_no_payment_histories().exists(): + return cart + return None diff --git a/app/shop/test/cart_orderable_test.py b/app/shop/test/cart_orderable_test.py index 34703a1..1cddf6e 100644 --- a/app/shop/test/cart_orderable_test.py +++ b/app/shop/test/cart_orderable_test.py @@ -42,18 +42,42 @@ def test_cart_rejects_when_contains_paid_product(customer_user, order_factory): @pytest.mark.django_db -def test_cart_rejects_when_price_is_zero_or_negative(customer_user): - # OPR 없이 빈 cart — first_paid_price=0 → CART_PRICE_TOO_LOW. +def test_cart_rejects_when_empty(customer_user): empty_cart = Order.objects.create(user=customer_user, name="empty") serializer = CartOrderableCheckSerializer( data={"cart": str(empty_cart.id)}, context=make_serializer_context(customer_user) ) assert serializer.is_valid() is False assert errors_payload(serializer.errors) == { - "non_field_errors": [{"detail": CartNotOrderableErrorMessages.CART_PRICE_TOO_LOW, "code": "invalid"}], + "non_field_errors": [{"detail": CartNotOrderableErrorMessages.EMPTY, "code": "invalid"}], } +@pytest.mark.django_db +def test_cart_allows_zero_price_active_product(customer_user, non_ticket_product): + cart = Order.objects.create(user=customer_user, name="free") + OrderProductRelation.objects.create(order=cart, product=non_ticket_product, price=0) + + serializer = CartOrderableCheckSerializer( + data={"cart": str(cart.id)}, context=make_serializer_context(customer_user) + ) + + assert serializer.is_valid() is True + + +@pytest.mark.django_db +def test_cart_rejects_when_price_is_negative(customer_user, non_ticket_product): + cart = Order.objects.create(user=customer_user, name="negative") + OrderProductRelation.objects.create(order=cart, product=non_ticket_product, price=0) + cart.__dict__["first_paid_price"] = -1 + serializer = CartOrderableCheckSerializer(context=make_serializer_context(customer_user)) + + with pytest.raises(ValidationError) as exc_info: + serializer.validate({"cart": cart}) + + assert exc_info.value.detail[0] == CartNotOrderableErrorMessages.CART_PRICE_TOO_LOW + + @pytest.mark.django_db def test_cart_rejects_when_price_too_high(customer_user, non_ticket_product): # OPR 가격 1_000_000 이상 → CART_PRICE_TOO_HIGH. diff --git a/app/shop/test/patron_api_test.py b/app/shop/test/patron_api_test.py index 915241d..e8978eb 100644 --- a/app/shop/test/patron_api_test.py +++ b/app/shop/test/patron_api_test.py @@ -15,6 +15,19 @@ def test_patron_list_includes_only_orders_with_donation_product(anon_client, cus assert response.json() == [{"name": "홍길동", "contribution_message": ""}] +@pytest.mark.django_db +def test_patron_list_excludes_zero_won_donation_capable_order(anon_client, order_factory): + order = order_factory(status="completed", donation=0, is_ticket=False, product_price=0) + product = order.products.get().product + product.donation_allowed = True + product.save(update_fields=["donation_allowed"]) + + response = PatronApi(http_client=anon_client).list() + + assert response.status_code == HTTP_200_OK + assert response.json() == [] + + @pytest.mark.django_db def test_patron_list_excludes_refunded_orders(anon_client, order_factory): order_factory(status="refunded", donation=5000) diff --git a/app/shop/test/product_orderable_test.py b/app/shop/test/product_orderable_test.py index 95c0a71..b512c0f 100644 --- a/app/shop/test/product_orderable_test.py +++ b/app/shop/test/product_orderable_test.py @@ -198,12 +198,6 @@ def test_product_rejects_when_option_does_not_belong_to_product( lambda _product: ProductNotOrderableErrorMessages.PRICE_TOO_HIGH, id="total_price_too_high", ), - pytest.param( - {"price": 0}, - 0, - lambda _product: ProductNotOrderableErrorMessages.PRICE_TOO_LOW, - id="total_price_is_zero", - ), ], ) @pytest.mark.django_db @@ -232,6 +226,30 @@ def test_product_passes_happy_path(customer_user, non_ticket_product): assert serializer.is_valid() +@pytest.mark.django_db +def test_product_allows_zero_price_without_donation(customer_user, non_ticket_product): + non_ticket_product.price = 0 + non_ticket_product.save(update_fields=["price"]) + + serializer = ProductOrderableCheckSerializer( + data={"product": str(non_ticket_product.id), "options": []}, + context=make_serializer_context(customer_user), + ) + + assert serializer.is_valid() is True + + +@pytest.mark.django_db +def test_product_rejects_when_total_price_is_negative(customer_user, non_ticket_product): + non_ticket_product.price = -1 + serializer = ProductOrderableCheckSerializer(context=make_serializer_context(customer_user)) + + with pytest.raises(ValidationError) as exc_info: + serializer.validate({"product": non_ticket_product, "options": [], "donation_price": 0}) + + assert exc_info.value.detail[0] == ProductNotOrderableErrorMessages.PRICE_TOO_LOW + + @pytest.mark.django_db def test_product_create_in_add_mode_appends_to_existing_unpaid_cart(customer_user, non_ticket_product): # 기존 unpaid Order(cart) 있음 → 동일 cart 에 OPR append. diff --git a/app/shop/test/refund_serializer_test.py b/app/shop/test/refund_serializer_test.py index ac7b886..16895ed 100644 --- a/app/shop/test/refund_serializer_test.py +++ b/app/shop/test/refund_serializer_test.py @@ -56,6 +56,24 @@ def test_total_refund_rejects_when_order_has_no_imp_id(mock_portone_req_cancel_p mock_portone_req_cancel_payment.assert_not_called() +@pytest.mark.django_db +def test_total_refund_rejects_free_completed_order_without_portone_cancel( + mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + serializer = OrderTotalRefundSerializer( + instance=order, + data={"id": str(order.id)}, + context={"check_totp": False}, + ) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [{"detail": NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST, "code": "invalid"}], + } + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_total_refund_rejects_when_order_already_refunded(mock_portone_req_cancel_payment, order_factory): refunded_order = order_factory(status="refunded") @@ -332,6 +350,25 @@ def test_partial_refund_rejects_when_refund_window_expired(mock_portone_req_canc mock_portone_req_cancel_payment.assert_not_called() +@pytest.mark.django_db +def test_partial_refund_rejects_free_completed_order_without_portone_cancel( + mock_portone_req_cancel_payment, order_factory +): + order = order_factory(status="completed", product_price=0, imp_id=None) + opr = order.products.get() + serializer = OrderProductRefundSerializer( + instance=opr, + data={"id": str(opr.id)}, + context={"check_totp": False}, + ) + + assert serializer.is_valid() is False + assert errors_payload(serializer.errors) == { + "non_field_errors": [{"detail": NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE, "code": "invalid"}], + } + mock_portone_req_cancel_payment.assert_not_called() + + @pytest.mark.django_db def test_total_refund_expected_price_is_zero_when_no_paid_oprs(order_factory): refunded_order = order_factory(status="refunded") From 78aad015e6ffb89d93e2d0f70f6bc234b0f1a9aa Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Thu, 9 Jul 2026 00:49:28 +0900 Subject: [PATCH 22/70] chore: format free checkout changes --- app/shop/payment_history/services.py | 5 ++++- .../payment_history/test/free_checkout_service_test.py | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/shop/payment_history/services.py b/app/shop/payment_history/services.py index 0173642..f47f0b9 100644 --- a/app/shop/payment_history/services.py +++ b/app/shop/payment_history/services.py @@ -132,7 +132,10 @@ def _build_product_validation_payload( ], } - if validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT and product_rel.product.category.is_ticket: + if ( + validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + and product_rel.product.category.is_ticket + ): ticket_info = getattr(product_rel, "ticket_info", None) if ticket_info: payload["ticket_info"] = { diff --git a/app/shop/payment_history/test/free_checkout_service_test.py b/app/shop/payment_history/test/free_checkout_service_test.py index 1ea8c88..862b366 100644 --- a/app/shop/payment_history/test/free_checkout_service_test.py +++ b/app/shop/payment_history/test/free_checkout_service_test.py @@ -114,9 +114,7 @@ def test_complete_free_checkout_rejects_empty_zero_price_order(customer_user): @pytest.mark.django_db -def test_complete_free_checkout_rechecks_stock_after_single_cart_preparation( - single_product_cart, other_user -): +def test_complete_free_checkout_rechecks_stock_after_single_cart_preparation(single_product_cart, other_user): product = single_product_cart.order_product_relation.product product.price = 0 product.stock = 1 @@ -204,4 +202,6 @@ def test_complete_free_checkout_rejects_missing_single_cart_target(): with pytest.raises(ValidationError) as exc_info: complete_free_checkout(ghost_cart) - assert errors_payload(exc_info.value.detail) == [{"detail": "무료 주문 대상을 찾을 수 없습니다.", "code": "invalid"}] + assert errors_payload(exc_info.value.detail) == [ + {"detail": "무료 주문 대상을 찾을 수 없습니다.", "code": "invalid"} + ] From 8ca663c5ffbae74f032a17370241cd97e1ad8224 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Thu, 9 Jul 2026 01:04:14 +0900 Subject: [PATCH 23/70] fix: reduce checkout lock queries --- app/shop/payment_history/services.py | 37 ++++++-- .../test/free_checkout_service_test.py | 87 ++++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/app/shop/payment_history/services.py b/app/shop/payment_history/services.py index f47f0b9..357ff79 100644 --- a/app/shop/payment_history/services.py +++ b/app/shop/payment_history/services.py @@ -7,7 +7,7 @@ from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition from shop.payment_history.tasks import send_payment_completed_notifications -from shop.product.models import Option, OptionGroup, Product, Tag +from shop.product.models import Option, OptionGroup, Product, ProductTagRelation, Tag from shop.serializers.cart_validation import ( CartOrderableCheckSerializer, OrderableCheckSerializerMode, @@ -17,6 +17,9 @@ FREE_CHECKOUT_PRICE_ERROR = "무료 주문은 결제 준비 금액이 0원이어야 합니다." FREE_CHECKOUT_TARGET_ERROR = "무료 주문 대상을 찾을 수 없습니다." FREE_CHECKOUT_TRANSITION_ERROR = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다." +_LOCKED_OPTIONS_ATTR = "_locked_checkout_options" +_LOCKED_TICKET_INFO_ATTR = "_locked_checkout_ticket_info" +_MISSING = object() def complete_free_checkout(cart_or_order: Order | SingleProductCart) -> Order: @@ -95,7 +98,10 @@ def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]: return product_rels list(Product.objects.select_for_update().filter_active().filter(id__in=product_ids).order_by("id")) - list(Tag.objects.select_for_update().filter_active().filter(products__product_id__in=product_ids).order_by("id")) + active_tag_ids = ( + ProductTagRelation.objects.filter_active().filter(product_id__in=product_ids).order_by().values("tag_id") + ) + list(Tag.objects.select_for_update().filter_active().filter(id__in=active_tag_ids).order_by("id")) option_groups = list( OptionGroup.objects.select_for_update().filter_active().filter(product_id__in=product_ids).order_by("id") @@ -105,13 +111,26 @@ def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]: if option_group_ids: list(Option.objects.select_for_update().filter_active().filter(group_id__in=option_group_ids).order_by("id")) - list( + option_rels = list( OrderProductOptionRelation.objects.select_for_update() .filter_active() .filter(order_product_relation_id__in=product_rel_ids) .order_by("id") ) - list(TicketInfo.objects.select_for_update().filter_active().filter(order_product_relation_id__in=product_rel_ids)) + ticket_infos = list( + TicketInfo.objects.select_for_update().filter_active().filter(order_product_relation_id__in=product_rel_ids) + ) + + option_rels_by_product_rel_id: dict[int, list[OrderProductOptionRelation]] = {} + for option_rel in option_rels: + option_rels_by_product_rel_id.setdefault(option_rel.order_product_relation_id, []).append(option_rel) + + ticket_infos_by_product_rel_id = { + ticket_info.order_product_relation_id: ticket_info for ticket_info in ticket_infos + } + for product_rel in product_rels: + setattr(product_rel, _LOCKED_OPTIONS_ATTR, option_rels_by_product_rel_id.get(product_rel.id, [])) + setattr(product_rel, _LOCKED_TICKET_INFO_ATTR, ticket_infos_by_product_rel_id.get(product_rel.id)) return product_rels @@ -119,6 +138,10 @@ def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]: def _build_product_validation_payload( product_rel: OrderProductRelation, validation_mode: OrderableCheckSerializerMode ) -> dict: + option_rels = getattr(product_rel, _LOCKED_OPTIONS_ATTR, _MISSING) + if option_rels is _MISSING: + option_rels = product_rel.options.filter_active().order_by("id") + payload = { "product": product_rel.product_id, "donation_price": product_rel.donation_price, @@ -128,7 +151,7 @@ def _build_product_validation_payload( "product_option": option_rel.product_option_id, "custom_response": option_rel.custom_response, } - for option_rel in product_rel.options.filter_active().order_by("id") + for option_rel in option_rels ], } @@ -136,7 +159,9 @@ def _build_product_validation_payload( validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT and product_rel.product.category.is_ticket ): - ticket_info = getattr(product_rel, "ticket_info", None) + ticket_info = getattr(product_rel, _LOCKED_TICKET_INFO_ATTR, _MISSING) + if ticket_info is _MISSING: + ticket_info = getattr(product_rel, "ticket_info", None) if ticket_info: payload["ticket_info"] = { "name": ticket_info.name, diff --git a/app/shop/payment_history/test/free_checkout_service_test.py b/app/shop/payment_history/test/free_checkout_service_test.py index 862b366..874d7d0 100644 --- a/app/shop/payment_history/test/free_checkout_service_test.py +++ b/app/shop/payment_history/test/free_checkout_service_test.py @@ -4,10 +4,16 @@ import pytest from core.const.shop_error_messages import CartNotOrderableErrorMessages, ProductNotOrderableErrorMessages from core.util.testutil import errors_payload +from django.db import transaction from rest_framework.exceptions import ValidationError from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus -from shop.payment_history.services import complete_free_checkout +from shop.payment_history.services import ( + _build_product_validation_payload, + _lock_order_allocation_rows, + complete_free_checkout, +) +from shop.serializers.cart_validation import OrderableCheckSerializerMode @pytest.mark.django_db @@ -95,6 +101,85 @@ def test_complete_free_checkout_promotes_single_product_cart(single_product_cart mock_delay.assert_called_once_with(str(cart_id)) +@pytest.mark.django_db +def test_lock_order_allocation_rows_attaches_payload_relations_without_extra_queries( + customer_user, ticket_product, option, django_assert_num_queries +): + order = Order.objects.create(user=customer_user, name="free") + product_rel = OrderProductRelation.objects.create(order=order, product=ticket_product, price=0) + option_rel = OrderProductOptionRelation.objects.create( + order_product_relation=product_rel, + product_option_group=option.group, + product_option=option, + custom_response=None, + ) + TicketInfo.objects.create( + order_product_relation=product_rel, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + contribution_message="응원합니다", + ) + + with transaction.atomic(): + locked_product_rel = _lock_order_allocation_rows(order)[0] + + with django_assert_num_queries(0): + payload = _build_product_validation_payload( + locked_product_rel, OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + ) + + assert payload == { + "product": ticket_product.id, + "donation_price": 0, + "options": [ + { + "product_option_group": option.group_id, + "product_option": option.id, + "custom_response": option_rel.custom_response, + } + ], + "ticket_info": { + "name": "김참가", + "phone": "010-9999-8888", + "email": "attendee@example.com", + "organization": "PSK", + "contribution_message": "응원합니다", + }, + } + + +@pytest.mark.django_db +def test_build_product_validation_payload_falls_back_to_related_rows(customer_user, ticket_product, option): + order = Order.objects.create(user=customer_user, name="free") + product_rel = OrderProductRelation.objects.create(order=order, product=ticket_product, price=0) + OrderProductOptionRelation.objects.create( + order_product_relation=product_rel, + product_option_group=option.group, + product_option=option, + custom_response="L", + ) + TicketInfo.objects.create( + order_product_relation=product_rel, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + + payload = _build_product_validation_payload(product_rel, OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT) + + assert payload["options"] == [ + { + "product_option_group": option.group_id, + "product_option": option.id, + "custom_response": "L", + } + ] + assert payload["ticket_info"]["email"] == "attendee@example.com" + + @pytest.mark.django_db def test_complete_free_checkout_rejects_empty_zero_price_order(customer_user): order = Order.objects.create(user=customer_user, name="empty") From a9ab1cf23fe519d94d94c239c06c7bb904a8ba7b Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Fri, 10 Jul 2026 20:47:49 +0900 Subject: [PATCH 24/70] =?UTF-8?q?feat:=20=EC=95=84=EB=B0=B4=ED=8A=B8=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filtersets/event/presentation.py | 2 + .../test/event/presentation/filterset_test.py | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 app/admin_api/test/event/presentation/filterset_test.py diff --git a/app/admin_api/filtersets/event/presentation.py b/app/admin_api/filtersets/event/presentation.py index 0506fe0..da8cb57 100644 --- a/app/admin_api/filtersets/event/presentation.py +++ b/app/admin_api/filtersets/event/presentation.py @@ -17,6 +17,7 @@ class PresentationCategoryAdminFilterSet(filters.FilterSet): class PresentationAdminFilterSet(filters.FilterSet): type = filters.UUIDFilter(field_name="type_id") + event = filters.UUIDFilter(field_name="type__event_id") class PresentationSpeakerAdminFilterSet(filters.FilterSet): @@ -26,3 +27,4 @@ class PresentationSpeakerAdminFilterSet(filters.FilterSet): class RoomScheduleAdminFilterSet(filters.FilterSet): room = filters.UUIDFilter(field_name="room_id") presentation = filters.UUIDFilter(field_name="presentation_id") + event = filters.UUIDFilter(field_name="room__event_id") diff --git a/app/admin_api/test/event/presentation/filterset_test.py b/app/admin_api/test/event/presentation/filterset_test.py new file mode 100644 index 0000000..4e4b11f --- /dev/null +++ b/app/admin_api/test/event/presentation/filterset_test.py @@ -0,0 +1,62 @@ +import pytest +from admin_api.filtersets.event.presentation import ( + PresentationAdminFilterSet, + RoomScheduleAdminFilterSet, +) +from event.models import Event +from event.presentation.models import Presentation, PresentationType, Room, RoomSchedule +from user.models.organization import Organization + + +@pytest.mark.django_db +def test_presentation_admin_filterset_filters_by_event() -> None: + # Given: 서로 다른 이벤트에 속한 발표 2개 + organization = Organization.objects.create(name="Test Organization") + event_a = Event.objects.create(name="Event A", organization=organization) + event_b = Event.objects.create(name="Event B", organization=organization) + + type_a = PresentationType.objects.create(name="Type A", event=event_a) + type_b = PresentationType.objects.create(name="Type B", event=event_b) + presentation_a = Presentation.objects.create(type=type_a, title="A") + Presentation.objects.create(type=type_b, title="B") + + # When: event_a 로 필터링 + filterset = PresentationAdminFilterSet({"event": str(event_a.pk)}, queryset=Presentation.objects.all()) + + # Then: event_a 의 발표만 반환 + assert list(filterset.qs) == [presentation_a] + + +@pytest.mark.django_db +def test_room_schedule_admin_filterset_filters_by_event() -> None: + # Given: 서로 다른 이벤트의 방/발표에 속한 스케줄 2개 + organization = Organization.objects.create(name="Test Organization") + event_a = Event.objects.create(name="Event A", organization=organization) + event_b = Event.objects.create(name="Event B", organization=organization) + + room_a = Room.objects.create(event=event_a, name="Room A") + room_b = Room.objects.create(event=event_b, name="Room B") + + type_a = PresentationType.objects.create(name="Type A", event=event_a) + type_b = PresentationType.objects.create(name="Type B", event=event_b) + presentation_a = Presentation.objects.create(type=type_a, title="A") + presentation_b = Presentation.objects.create(type=type_b, title="B") + + schedule_a = RoomSchedule.objects.create( + room=room_a, + presentation=presentation_a, + start_at="2026-08-16T10:00:00+09:00", + end_at="2026-08-16T11:00:00+09:00", + ) + RoomSchedule.objects.create( + room=room_b, + presentation=presentation_b, + start_at="2026-08-16T10:00:00+09:00", + end_at="2026-08-16T11:00:00+09:00", + ) + + # When: event_a 로 필터링 + filterset = RoomScheduleAdminFilterSet({"event": str(event_a.pk)}, queryset=RoomSchedule.objects.all()) + + # Then: event_a 의 스케줄만 반환 + assert list(filterset.qs) == [schedule_a] From f0526a1d8dcba33a8459e5bb8cb05e940a1e0b50 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Fri, 10 Jul 2026 21:29:08 +0900 Subject: [PATCH 25/70] =?UTF-8?q?feat:=20room=EC=97=90=20order=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/event/presentation.py | 2 +- app/admin_api/views/event/presentation.py | 4 +++- app/event/presentation/migrations/0016_room_order.py | 6 ++++++ app/event/presentation/models.py | 4 +++- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 app/event/presentation/migrations/0016_room_order.py diff --git a/app/admin_api/serializers/event/presentation.py b/app/admin_api/serializers/event/presentation.py index ac92848..8e9a58d 100644 --- a/app/admin_api/serializers/event/presentation.py +++ b/app/admin_api/serializers/event/presentation.py @@ -102,4 +102,4 @@ def validate(self, attrs: dict) -> dict: class RoomAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): class Meta: model = Room - fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en") + fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en", "order") diff --git a/app/admin_api/views/event/presentation.py b/app/admin_api/views/event/presentation.py index 6eea808..892303a 100644 --- a/app/admin_api/views/event/presentation.py +++ b/app/admin_api/views/event/presentation.py @@ -97,7 +97,9 @@ class RoomAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.ModelViewSet) serializer_class = RoomAdminSerializer permission_classes = [IsSuperUser] filterset_class = RoomAdminFilterSet - queryset = Room.objects.filter_active().select_related_with_user("event").order_by("-event__event_end_at", "pk") + queryset = ( + Room.objects.filter_active().select_related_with_user("event").order_by("-event__event_end_at", "order", "pk") + ) @extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION]) for m in ADMIN_METHODS}) diff --git a/app/event/presentation/migrations/0016_room_order.py b/app/event/presentation/migrations/0016_room_order.py new file mode 100644 index 0000000..a37310e --- /dev/null +++ b/app/event/presentation/migrations/0016_room_order.py @@ -0,0 +1,6 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("presentation", "0015_remove_presentationcategory_uq__prst_cat__type__name_name_ko_and_more")] + operations = [migrations.AddField(model_name="room", name="order", field=models.IntegerField(default=0))] diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index e576ea1..b3f4feb 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -174,16 +174,18 @@ class Room(BaseAbstractModel): choices_select_related = ("event",) choices_meta_schema: ClassVar[dict] = { "event": {"label": "이벤트", "type": "string", "filter": "select"}, + "order": {"label": "순서", "type": "number"}, } event = models.ForeignKey(Event, on_delete=models.PROTECT) name = models.CharField(max_length=256) + order = models.IntegerField(default=0) def __str__(self) -> str: return f"[{self.event.name}] {self.name}" def _choice_meta_fields(self) -> dict: - return {"event": self.event.name} + return {"event": self.event.name, "order": self.order} class RoomScheduleQuerySet(BaseAbstractModelQuerySet): From 943ba43480fd7bf9cd381a230efde788e06e53a6 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Sat, 11 Jul 2026 14:34:43 +0900 Subject: [PATCH 26/70] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=ED=91=9C=20=EB=8B=B4=EA=B8=B0=20API=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/const/tag.py | 1 + .../0017_add_presentation_bookmark.py | 65 ++ app/event/presentation/models.py | 19 + app/event/presentation/serializers.py | 12 + .../presentation/test/bookmark_api_test.py | 596 ++++++++++++++++++ app/event/presentation/urls.py | 25 +- app/event/presentation/views.py | 105 ++- 7 files changed, 802 insertions(+), 21 deletions(-) create mode 100644 app/event/presentation/migrations/0017_add_presentation_bookmark.py create mode 100644 app/event/presentation/test/bookmark_api_test.py diff --git a/app/core/const/tag.py b/app/core/const/tag.py index 5bb8452..912cb6a 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -3,6 +3,7 @@ class OpenAPITag: EVENT = "Event" EVENT_PRESENTATION = "Event > Presentation" EVENT_SPONSOR = "Event > Sponsor" + EVENT_PRESENTATION_BOOKMARK = "Event > Presentation Bookmark" SHOP_USER = "Shop > 고객" SHOP_PRODUCT = "Shop > 상품" diff --git a/app/event/presentation/migrations/0017_add_presentation_bookmark.py b/app/event/presentation/migrations/0017_add_presentation_bookmark.py new file mode 100644 index 0000000..993e7c8 --- /dev/null +++ b/app/event/presentation/migrations/0017_add_presentation_bookmark.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0.6 on 2026-07-11 05:12 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("event", "0006_event_logo_historicalevent_logo"), + ("presentation", "0016_room_order"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PresentationBookmark", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="presentation_bookmarks", + to="event.event", + ), + ), + ( + "presentation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bookmarks", + to="presentation.presentation", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="presentation_bookmarks", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("user", "presentation"), + name="uq__prst_bkmk__user__presentation", + ) + ], + }, + ), + ] diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index b3f4feb..994adc8 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -210,3 +210,22 @@ class RoomSchedule(BaseAbstractModel): def __str__(self) -> str: return f"[{self.room}] {self.start_at} - {self.end_at} ({self.presentation})" + + +class PresentationBookmark(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="presentation_bookmarks") + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="presentation_bookmarks") + presentation = models.ForeignKey(Presentation, on_delete=models.CASCADE, related_name="bookmarks") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "presentation"], + name="uq__prst_bkmk__user__presentation", + ) + ] + + def __str__(self) -> str: + return f"[Bookmark] {self.user} - {self.presentation}" diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index 0e9a3b5..471b51f 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -10,6 +10,18 @@ from rest_framework import serializers +class PresentationBookmarkRequestSerializer(serializers.Serializer): + presentation_id = serializers.UUIDField() + + +class PresentationBookmarkListResponseSerializer(serializers.Serializer): + presentation_ids = serializers.ListField(child=serializers.UUIDField()) + + +class PresentationBookmarkResponseSerializer(serializers.Serializer): + presentation_id = serializers.UUIDField() + + class PresentationTypeSerializer(serializers.ModelSerializer): event = EventSerializer(read_only=True) diff --git a/app/event/presentation/test/bookmark_api_test.py b/app/event/presentation/test/bookmark_api_test.py new file mode 100644 index 0000000..21a132a --- /dev/null +++ b/app/event/presentation/test/bookmark_api_test.py @@ -0,0 +1,596 @@ +import http +import uuid +from datetime import datetime + +import pytest +from django.urls import reverse +from event.models import Event +from event.presentation.models import Presentation, PresentationBookmark, PresentationType +from model_bakery import baker +from rest_framework.test import APIClient +from user.models.organization import Organization +from user.models.user import UserExt + +# ────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────── + + +@pytest.fixture +def user(db) -> UserExt: + return baker.make(UserExt) + + +@pytest.fixture +def other_user(db) -> UserExt: + return baker.make(UserExt) + + +@pytest.fixture +def authed_client(user: UserExt) -> APIClient: + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def anon_client() -> APIClient: + return APIClient() + + +@pytest.fixture +def organization(db) -> Organization: + return baker.make(Organization) + + +@pytest.fixture +def event(organization: Organization) -> Event: + return Event.objects.create( + organization=organization, + name="파이콘 한국 2026", + event_start_at=datetime(2026, 8, 15), + ) + + +@pytest.fixture +def other_event(organization: Organization) -> Event: + return Event.objects.create( + organization=organization, + name="파이콘 한국 2025", + event_start_at=datetime(2025, 8, 15), + ) + + +@pytest.fixture +def presentation_type(event: Event) -> PresentationType: + return PresentationType.objects.create(event=event, name="Talk") + + +@pytest.fixture +def presentation(presentation_type: PresentationType) -> Presentation: + return Presentation.objects.create(type=presentation_type, title="Django 심화") + + +@pytest.fixture +def presentation_2(presentation_type: PresentationType) -> Presentation: + return Presentation.objects.create(type=presentation_type, title="FastAPI 입문") + + +@pytest.fixture +def other_event_presentation(other_event: Event) -> Presentation: + pt = PresentationType.objects.create(event=other_event, name="Talk") + return Presentation.objects.create(type=pt, title="작년 발표") + + +# ────────────────────────────────────────────── +# Helper +# ────────────────────────────────────────────── + + +def list_url(event_name: str | None = None) -> str: + url = reverse("v1:presentation-bookmark-list") + if event_name: + url += f"?event={event_name}" + return url + + +def detail_url(presentation_id: uuid.UUID | str) -> str: + return reverse("v1:presentation-bookmark-detail", kwargs={"pk": str(presentation_id)}) + + +# ══════════════════════════════════════════════ +# GET /v1/event/presentation/bookmarks/ +# ══════════════════════════════════════════════ + + +class TestBookmarkList: + """GET 북마크 목록 조회 API 테스트""" + + @pytest.mark.django_db + def test_returns_empty_list_when_no_bookmarks(self, authed_client: APIClient, event: Event): + """ + 북마크가 하나도 없을 때 빈 배열을 반환하는지 검증합니다. + 프론트에서 빈 상태 UI를 렌더링하기 위해 빈 배열이 정상 응답이어야 합니다. + """ + response = authed_client.get(list_url(event.name)) + + assert response.status_code == http.HTTPStatus.OK + assert response.json() == {"presentation_ids": []} + + @pytest.mark.django_db + def test_returns_bookmarked_presentation_ids( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + presentation: Presentation, + presentation_2: Presentation, + ): + """ + 유저가 북마크한 세션의 presentation_id 목록이 정확히 반환되는지 검증합니다. + 프론트는 이 ID 목록을 전체 세션 목록과 매칭해서 내 시간표를 구성합니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + + response = authed_client.get(list_url(event.name)) + + assert response.status_code == http.HTTPStatus.OK + returned_ids = set(response.json()["presentation_ids"]) + assert returned_ids == {str(presentation.id), str(presentation_2.id)} + + @pytest.mark.django_db + def test_filters_by_event_name( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + other_event: Event, + presentation: Presentation, + other_event_presentation: Presentation, + ): + """ + event 쿼리 파라미터로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다. + 다른 행사의 북마크가 섞여 나오면 안 됩니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, event=other_event, presentation=other_event_presentation) + + response = authed_client.get(list_url(event.name)) + + assert response.status_code == http.HTTPStatus.OK + returned_ids = response.json()["presentation_ids"] + assert len(returned_ids) == 1 + assert returned_ids[0] == str(presentation.id) + + @pytest.mark.django_db + def test_defaults_to_latest_event_when_no_event_param( + self, authed_client: APIClient, user: UserExt, event: Event, other_event: Event, presentation: Presentation + ): + """ + event 쿼리 파라미터를 생략하면, 기존 presentation API와 동일하게 + 가장 최신 행사(event_start_at 기준)의 북마크를 반환하는지 검증합니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + + response = authed_client.get(list_url()) + + assert response.status_code == http.HTTPStatus.OK + # event(2026)이 other_event(2025)보다 최신이므로 event의 북마크가 반환되어야 함 + assert str(presentation.id) in response.json()["presentation_ids"] + + @pytest.mark.django_db + def test_does_not_return_other_users_bookmarks( + self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation + ): + """ + 다른 유저가 북마크한 세션이 현재 유저의 목록에 포함되지 않는지 검증합니다. + 북마크는 유저별로 완전히 격리되어야 합니다. + """ + PresentationBookmark.objects.create(user=other_user, event=event, presentation=presentation) + + response = authed_client.get(list_url(event.name)) + + assert response.status_code == http.HTTPStatus.OK + assert response.json()["presentation_ids"] == [] + + @pytest.mark.django_db + def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event): + """ + 로그인하지 않은 유저가 요청하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + 프론트는 403을 받으면 로그인 모달을 띄웁니다. + """ + response = anon_client.get(list_url(event.name)) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + + @pytest.mark.django_db + def test_nonexistent_event_returns_404(self, authed_client: APIClient): + """ + 존재하지 않는 행사 이름으로 요청하면 404를 반환하는지 검증합니다. + """ + response = authed_client.get(list_url("존재하지 않는 행사")) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + +# ══════════════════════════════════════════════ +# POST /v1/event/presentation/bookmarks/ +# ══════════════════════════════════════════════ + + +class TestBookmarkCreate: + """POST 북마크 추가 API 테스트""" + + @pytest.mark.django_db + def test_creates_bookmark_and_returns_201( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 새로운 세션을 북마크하면 201 Created와 함께 + 해당 presentation_id가 응답으로 반환되는지 검증합니다. + DB에 북마크 레코드가 실제로 생성되었는지도 확인합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.CREATED + assert response.json()["presentation_id"] == str(presentation.id) + assert PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() + + @pytest.mark.django_db + def test_sets_event_from_presentation( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 북마크 생성 시 presentation의 type.event로부터 event를 자동 설정하는지 검증합니다. + 프론트는 POST 시 event를 명시적으로 보내지 않으므로, 서버가 presentation에서 파생해야 합니다. + """ + authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + + bookmark = PresentationBookmark.objects.get(user=user, presentation=presentation) + assert bookmark.event_id == event.id + + @pytest.mark.django_db + def test_duplicate_bookmark_returns_200_idempotent( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 이미 북마크한 세션을 다시 추가하면 에러 대신 200 OK를 반환하는지 검증합니다. + (멱등성) 프론트의 되돌리기/연타로 중복 요청이 발생할 수 있으므로, + 두 번째 요청도 정상 응답이어야 합니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + + response = authed_client.post( + list_url(), + data={"presentation_id": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.OK + assert response.json()["presentation_id"] == str(presentation.id) + # 중복 레코드가 생성되지 않았는지 확인 + assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1 + + @pytest.mark.django_db + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient): + """ + 존재하지 않는 presentation_id로 북마크를 추가하면 404를 반환하는지 검증합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": str(uuid.uuid4())}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db + def test_soft_deleted_presentation_returns_404(self, authed_client: APIClient, presentation: Presentation): + """ + 소프트 삭제된(deleted_at이 설정된) presentation을 북마크하려고 하면 + 404를 반환하는지 검증합니다. + filter_active()가 삭제된 레코드를 제외해야 합니다. + """ + presentation.delete() + + response = authed_client.post( + list_url(), + data={"presentation_id": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db + def test_invalid_presentation_id_format_returns_400(self, authed_client: APIClient): + """ + presentation_id에 UUID가 아닌 값을 보내면 400 Bad Request를 반환하는지 검증합니다. + serializer의 UUIDField 유효성 검사가 동작해야 합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": "not-a-uuid"}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + @pytest.mark.django_db + def test_missing_presentation_id_returns_400(self, authed_client: APIClient): + """ + 요청 바디에 presentation_id 필드가 없으면 400 Bad Request를 반환하는지 검증합니다. + """ + response = authed_client.post(list_url(), data={}, format="json") + + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + @pytest.mark.django_db + def test_unauthenticated_returns_403(self, anon_client: APIClient, presentation: Presentation): + """ + 로그인하지 않은 유저가 북마크 추가를 시도하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + """ + response = anon_client.post( + list_url(), + data={"presentation_id": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + + @pytest.mark.django_db + def test_allows_overlapping_time_sessions( + self, authed_client: APIClient, user: UserExt, presentation: Presentation, presentation_2: Presentation + ): + """ + 시간이 겹치는 세션들도 모두 북마크할 수 있는지 검증합니다. + UX 확정 사항: 겹침 경고는 프론트가 처리하고, 서버는 시간대 충돌을 검사하지 않습니다. + """ + resp1 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(), data={"presentation_id": str(presentation_2.id)}, format="json") + + assert resp1.status_code == http.HTTPStatus.CREATED + assert resp2.status_code == http.HTTPStatus.CREATED + assert PresentationBookmark.objects.filter(user=user).count() == 2 + + +# ══════════════════════════════════════════════ +# DELETE /v1/event/presentation/bookmarks/{presentation_id}/ +# ══════════════════════════════════════════════ + + +class TestBookmarkDestroy: + """DELETE 북마크 삭제 API 테스트""" + + @pytest.mark.django_db + def test_deletes_bookmark_and_returns_204( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 북마크된 세션을 삭제하면 204 No Content를 반환하고, + DB에서 실제로 레코드가 삭제(hard delete)되는지 검증합니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + + response = authed_client.delete(detail_url(presentation.id)) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() + + @pytest.mark.django_db + def test_not_bookmarked_returns_204_idempotent( + self, authed_client: APIClient, event: Event, presentation: Presentation + ): + """ + 유저가 북마크하지 않은 세션에 대해 삭제를 요청해도 + 에러 대신 204를 반환하는지 검증합니다. (멱등성) + 프론트의 되돌리기 UX에서 DELETE 직후 같은 세션에 POST가 오고, + 다시 DELETE가 올 수 있으므로 멱등이어야 합니다. + """ + response = authed_client.delete(detail_url(presentation.id)) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + + @pytest.mark.django_db + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient): + """ + 존재하지 않는 presentation_id로 삭제를 요청하면 404를 반환하는지 검증합니다. + presentation 자체가 DB에 없는 경우만 404이고, + presentation은 있지만 북마크가 없는 경우는 204입니다. + """ + response = authed_client.delete(detail_url(uuid.uuid4())) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db + def test_does_not_delete_other_users_bookmark( + self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation + ): + """ + 삭제 요청이 다른 유저의 북마크에 영향을 주지 않는지 검증합니다. + user.presentation 필터로 현재 유저의 북마크만 삭제해야 합니다. + """ + PresentationBookmark.objects.create(user=other_user, event=event, presentation=presentation) + + response = authed_client.delete(detail_url(presentation.id)) + + assert response.status_code == http.HTTPStatus.NO_CONTENT + # 다른 유저의 북마크는 그대로 남아 있어야 함 + assert PresentationBookmark.objects.filter(user=other_user, presentation=presentation).exists() + + @pytest.mark.django_db + def test_unauthenticated_returns_403(self, anon_client: APIClient, presentation: Presentation): + """ + 로그인하지 않은 유저가 북마크 삭제를 시도하면 403 Forbidden을 반환하는지 검증합니다. + DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, + IsAuthenticated 권한 체크에서 403을 반환합니다. + """ + response = anon_client.delete(detail_url(presentation.id)) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + + @pytest.mark.django_db + def test_only_deletes_specified_bookmark( + self, + authed_client: APIClient, + user: UserExt, + event: Event, + presentation: Presentation, + presentation_2: Presentation, + ): + """ + 특정 세션 하나를 삭제할 때, 유저의 다른 북마크는 유지되는지 검증합니다. + 삭제 범위가 정확히 요청된 presentation에만 한정되어야 합니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + + authed_client.delete(detail_url(presentation.id)) + + assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() + assert PresentationBookmark.objects.filter(user=user, presentation=presentation_2).exists() + + +# ══════════════════════════════════════════════ +# 통합 시나리오: 담기 → 빼기 → 되돌리기 (POST → DELETE → POST) +# ══════════════════════════════════════════════ + + +class TestBookmarkUndoFlow: + """프론트의 '담기 → 빼기 → 되돌리기' 시나리오 통합 테스트""" + + @pytest.mark.django_db + def test_add_remove_re_add_flow( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 프론트의 실제 사용 시나리오를 재현합니다: + 1. 세션 담기 (POST) → 201 + 2. 세션 빼기 (DELETE) → 204 + 3. 되돌리기 (POST 재전송) → 201 + 각 단계에서 GET으로 목록을 조회해 상태가 올바른지 확인합니다. + """ + # 1단계: 담기 + resp = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + assert resp.status_code == http.HTTPStatus.CREATED + + # GET으로 확인: 1개 + resp = authed_client.get(list_url(event.name)) + assert len(resp.json()["presentation_ids"]) == 1 + + # 2단계: 빼기 + resp = authed_client.delete(detail_url(presentation.id)) + assert resp.status_code == http.HTTPStatus.NO_CONTENT + + # GET으로 확인: 0개 + resp = authed_client.get(list_url(event.name)) + assert len(resp.json()["presentation_ids"]) == 0 + + # 3단계: 되돌리기 (다시 POST) + resp = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + assert resp.status_code == http.HTTPStatus.CREATED + + # GET으로 확인: 다시 1개 + resp = authed_client.get(list_url(event.name)) + assert len(resp.json()["presentation_ids"]) == 1 + + @pytest.mark.django_db + def test_rapid_double_delete_is_safe( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): + """ + 같은 세션에 대해 DELETE가 빠르게 2번 연속 호출되어도 + 두 번째 요청이 에러 없이 204를 반환하는지 검증합니다. + 네트워크 재시도나 프론트 연타로 발생할 수 있는 시나리오입니다. + """ + PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + + resp1 = authed_client.delete(detail_url(presentation.id)) + resp2 = authed_client.delete(detail_url(presentation.id)) + + assert resp1.status_code == http.HTTPStatus.NO_CONTENT + assert resp2.status_code == http.HTTPStatus.NO_CONTENT + + @pytest.mark.django_db + def test_rapid_double_post_is_safe(self, authed_client: APIClient, user: UserExt, presentation: Presentation): + """ + 같은 세션에 대해 POST가 빠르게 2번 연속 호출되어도 + 두 번째 요청이 에러 없이 200을 반환하고 중복 레코드가 생기지 않는지 검증합니다. + """ + resp1 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + + assert resp1.status_code == http.HTTPStatus.CREATED + assert resp2.status_code == http.HTTPStatus.OK + assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1 + + +# ══════════════════════════════════════════════ +# 에러 응답 포맷 검증 +# ══════════════════════════════════════════════ + + +class TestErrorResponseFormat: + """에러 응답이 drf-standardized-errors의 공통 envelope 포맷을 따르는지 검증""" + + @pytest.mark.django_db + def test_403_follows_error_envelope(self, anon_client: APIClient, event: Event): + """ + 403 응답이 프론트의 ErrorResponseSchema와 호환되는 + { "type": "...", "errors": [{ "code": "...", "detail": "...", "attr": ... }] } + 포맷인지 검증합니다. + 프론트는 errors[0].code로 에러 종류를 분기합니다. + DRF SessionAuthentication + IsAuthenticated 조합에서 + 미인증 요청은 403 + code: "not_authenticated"로 응답합니다. + """ + response = anon_client.get(list_url(event.name)) + + assert response.status_code == http.HTTPStatus.FORBIDDEN + body = response.json() + assert "type" in body + assert "errors" in body + assert len(body["errors"]) > 0 + assert "code" in body["errors"][0] + assert "detail" in body["errors"][0] + assert body["errors"][0]["code"] == "not_authenticated" + + @pytest.mark.django_db + def test_404_follows_error_envelope(self, authed_client: APIClient): + """ + 404 응답이 공통 에러 envelope 포맷을 따르는지 검증합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": str(uuid.uuid4())}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + body = response.json() + assert body["type"] == "client_error" + assert "errors" in body + assert body["errors"][0]["code"] == "not_found" + + @pytest.mark.django_db + def test_400_validation_error_follows_envelope(self, authed_client: APIClient): + """ + 400 유효성 검사 에러가 공통 에러 envelope 포맷을 따르는지 검증합니다. + """ + response = authed_client.post( + list_url(), + data={"presentation_id": "invalid"}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.BAD_REQUEST + body = response.json() + assert body["type"] == "validation_error" + assert "errors" in body diff --git a/app/event/presentation/urls.py b/app/event/presentation/urls.py index d47dec0..df73b04 100644 --- a/app/event/presentation/urls.py +++ b/app/event/presentation/urls.py @@ -1,20 +1,3 @@ -""" -URL configuration for core project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - from django.urls import include, path from event.presentation import views from rest_framework import routers @@ -23,4 +6,10 @@ cms_router.register("category", views.PresentationCategoryViewSet, basename="presentation-category") cms_router.register("", views.PresentationViewSet, basename="presentation") -urlpatterns = [path("", include(cms_router.urls))] +bookmark_router = routers.SimpleRouter() +bookmark_router.register("bookmarks", views.PresentationBookmarkViewSet, basename="presentation-bookmark") + +urlpatterns = [ + path("", include(bookmark_router.urls)), + path("", include(cms_router.urls)), +] diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py index 130e94d..a4f4c8d 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,10 +1,26 @@ from core.const.tag import OpenAPITag +from django.db.models import Q from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema +from drf_standardized_errors.openapi_serializers import ( + ErrorResponse401Serializer, + ErrorResponse404Serializer, +) +from event.models import Event from event.presentation.filters import PresentationFilterSet -from event.presentation.models import Presentation, PresentationCategory -from event.presentation.serializers import PresentationCategorySerializer, PresentationSerializer -from rest_framework import mixins, viewsets +from event.presentation.models import Presentation, PresentationBookmark, PresentationCategory +from event.presentation.serializers import ( + PresentationBookmarkListResponseSerializer, + PresentationBookmarkRequestSerializer, + PresentationBookmarkResponseSerializer, + PresentationCategorySerializer, + PresentationSerializer, +) +from rest_framework import mixins, status, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) @@ -19,3 +35,86 @@ class PresentationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view queryset = Presentation.objects.get_all_nested_data() serializer_class = PresentationSerializer filterset_class = PresentationFilterSet + + +@extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION_BOOKMARK]) +class PresentationBookmarkViewSet(viewsets.GenericViewSet): + permission_classes = [IsAuthenticated] + serializer_class = PresentationBookmarkRequestSerializer + + def _resolve_event(self, request: Request) -> Event: + event_name = request.query_params.get("event") + if not event_name: + event = Event.objects.filter_active().first() + if not event: + raise NotFound("해당 행사 정보가 없습니다.") + return event + + event = Event.objects.filter_active().filter(Q(name_ko=event_name) | Q(name_en=event_name)).first() + if not event: + raise NotFound("해당 행사 정보가 없습니다.") + return event + + @extend_schema( + summary="북마크 목록 조회", + parameters=[], + responses={ + status.HTTP_200_OK: PresentationBookmarkListResponseSerializer, + status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, + status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, + }, + ) + def list(self, request: Request) -> Response: + event = self._resolve_event(request) + presentation_ids = list( + PresentationBookmark.objects.filter(user=request.user, event=event).values_list( + "presentation_id", flat=True + ) + ) + return Response({"presentation_ids": presentation_ids}) + + @extend_schema( + summary="북마크 추가", + request=PresentationBookmarkRequestSerializer, + responses={ + status.HTTP_201_CREATED: PresentationBookmarkResponseSerializer, + status.HTTP_200_OK: PresentationBookmarkResponseSerializer, + status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, + status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, + }, + ) + def create(self, request: Request) -> Response: + serializer = PresentationBookmarkRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + presentation_id = serializer.validated_data["presentation_id"] + + presentation = Presentation.objects.filter_active().filter(id=presentation_id).first() + if not presentation: + raise NotFound("해당 세션 정보가 없습니다.") + + event = presentation.type.event + _, created = PresentationBookmark.objects.get_or_create( + user=request.user, + presentation=presentation, + defaults={"event": event}, + ) + return Response( + {"presentation_id": str(presentation.id)}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @extend_schema( + summary="북마크 삭제", + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, + status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, + }, + ) + def destroy(self, request: Request, pk: str = None) -> Response: + presentation = Presentation.objects.filter_active().filter(id=pk).first() + if not presentation: + raise NotFound("해당 세션 정보가 없습니다.") + + PresentationBookmark.objects.filter(user=request.user, presentation=presentation).delete() + return Response(status=status.HTTP_204_NO_CONTENT) From f96289b4144b15756793cbf3dcf560bdeb424b5e Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 11 Jul 2026 18:10:14 +0900 Subject: [PATCH 27/70] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=EC=A0=9C=ED=95=9C=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C?= =?UTF-8?q?=EC=9D=B4=20=EC=98=81=ED=96=A5=EB=B0=9B=EB=8A=94=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/shop/product/serializers/dto.py | 13 +++++++++++-- .../product/test/leftover_stock_per_user_test.py | 14 ++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/shop/product/serializers/dto.py b/app/shop/product/serializers/dto.py index 68af51c..a430634 100644 --- a/app/shop/product/serializers/dto.py +++ b/app/shop/product/serializers/dto.py @@ -7,11 +7,20 @@ from rest_framework import serializers from shop.product.models import Option, OptionGroup, Product +# product 인당 한도(product_max_quantity_per_user)는 "이 유저가 이 상품(티켓)을 몇 개 살 수 있나" 축이라, +# 옵션/그룹 인스턴스 개수(max_quantity_per_product 축, 1 OPR 아래 다수 OPOR)를 제한하지 않는다. +# breakdown 에는 참고용으로 남기되 leftover_stock_per_user(= binding min) 계산에서는 제외한다. +_LEFTOVER_PER_USER_EXCLUDED_KEYS = frozenset({"product_max_quantity_per_user"}) + def _min_ignoring_none(values: Iterable[int | None]) -> int | None: return min(finite) if (finite := [v for v in values if v is not None]) else None +def _leftover_stock_per_user(info: dict[str, int | None]) -> int | None: + return _min_ignoring_none(v for k, v in info.items() if k not in _LEFTOVER_PER_USER_EXCLUDED_KEYS) + + @dataclass(frozen=True) class StockContext: """Product 페이지에 등장하는 모든 product / option / group 의 점유 수량을 단일 stage 에서 prefetch 한 결과""" @@ -115,7 +124,7 @@ def get_leftover_stock_info(self, option: Option) -> dict[str, int | None]: return self.context.get("stock_context", _EMPTY_STOCK_CONTEXT).option_leftover_info(option) def get_leftover_stock_per_user(self, option: Option) -> int | None: - return _min_ignoring_none(self.get_leftover_stock_info(option).values()) + return _leftover_stock_per_user(self.get_leftover_stock_info(option)) class OptionGroupDto(serializers.ModelSerializer): @@ -148,7 +157,7 @@ def get_leftover_stock_info(self, group: OptionGroup) -> dict[str, int | None]: return self.context.get("stock_context", _EMPTY_STOCK_CONTEXT).option_group_leftover_info(group) def get_leftover_stock_per_user(self, group: OptionGroup) -> int | None: - return _min_ignoring_none(self.get_leftover_stock_info(group).values()) + return _leftover_stock_per_user(self.get_leftover_stock_info(group)) class ProductDto(serializers.ModelSerializer): diff --git a/app/shop/product/test/leftover_stock_per_user_test.py b/app/shop/product/test/leftover_stock_per_user_test.py index 7df3c2b..f3f216d 100644 --- a/app/shop/product/test/leftover_stock_per_user_test.py +++ b/app/shop/product/test/leftover_stock_per_user_test.py @@ -88,10 +88,11 @@ def test_breakdown_subtracts_cart_and_purchased_for_logged_in_user( @pytest.mark.django_db -def test_product_level_limit_can_be_binding_constraint( +def test_product_level_limit_does_not_cap_option_leftover( customer_client, customer_user, ticket_product, group_with_options ): - # Product.max_quantity_per_user 가 group / option 한도 보다 작아서 binding 이 되는 경우 — 명시적 케이스. + # Product.max_quantity_per_user 는 "티켓 몇 장" 축이라 옵션/그룹 인스턴스 개수(max_quantity_per_product 축)를 + # 제한하지 않는다. breakdown 엔 잔여값을 남기되 leftover_stock_per_user 엔 반영되지 않아야 한다. ticket_product.max_quantity_per_user = 2 ticket_product.save() group, a, _b = group_with_options @@ -101,11 +102,16 @@ def test_product_level_limit_can_be_binding_constraint( a.save() data = _serialize_via_api(customer_client, ticket_product) - option_a = next(o for o in data["option_groups"][0]["options"] if o["id"] == str(a.id)) + grp = data["option_groups"][0] + # 그룹 레벨: product 한도(2)가 breakdown 엔 남지만 leftover 는 group 한도(10)가 결정. + assert grp["leftover_stock_info"]["product_max_quantity_per_user"] == 2 + assert grp["leftover_stock_per_user"] == 10 + + option_a = next(o for o in grp["options"] if o["id"] == str(a.id)) assert option_a["leftover_stock_info"]["product_max_quantity_per_user"] == 2 assert option_a["leftover_stock_info"]["option_group_max_quantity_per_user"] == 10 assert option_a["leftover_stock_info"]["option_max_quantity_per_user"] == 10 - assert option_a["leftover_stock_per_user"] == 2 # ticket_product 한도가 binding + assert option_a["leftover_stock_per_user"] == 10 # product 한도(2)는 binding 아님 @pytest.mark.django_db From 0a53920780c061707859023776be3707245d5b79 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Sat, 11 Jul 2026 22:06:51 +0900 Subject: [PATCH 28/70] =?UTF-8?q?fix:=20PR=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=BD=94=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/urls.py | 2 + app/event/presentation/filters.py | 27 +++ ...okmark.py => 0017_presentationbookmark.py} | 11 +- app/event/presentation/models.py | 1 - app/event/presentation/serializers.py | 18 +- .../presentation/test/bookmark_api_test.py | 209 ++++++++++-------- app/event/presentation/urls.py | 7 +- app/event/presentation/views.py | 87 +++----- 8 files changed, 193 insertions(+), 169 deletions(-) rename app/event/presentation/migrations/{0017_add_presentation_bookmark.py => 0017_presentationbookmark.py} (80%) diff --git a/app/core/urls.py b/app/core/urls.py index 2cb76d7..e3dcc3f 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -23,6 +23,7 @@ from django.urls import include, path, re_path, resolvers from django.views.decorators.cache import cache_page from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from event.presentation.urls import bookmark_urlpatterns # type: ignore[assignment] v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [ @@ -30,6 +31,7 @@ path("admin-api/", include("admin_api.urls")), path("participant-portal/", include("participant_portal_api.urls")), path("event/presentation/", include("event.presentation.urls")), + path("events//presentation-bookmarks/", include(bookmark_urlpatterns)), path("event/sponsor/", include("event.sponsor.urls")), path("event/", include("event.urls")), path("external-api/", include("external_api.urls")), diff --git a/app/event/presentation/filters.py b/app/event/presentation/filters.py index ff4c3ce..9fb43f9 100644 --- a/app/event/presentation/filters.py +++ b/app/event/presentation/filters.py @@ -1,8 +1,16 @@ +from enum import StrEnum + from core.models import BaseAbstractModelQuerySet from django.db.models import Q from django_filters import rest_framework as filters from django_filters.constants import EMPTY_VALUES from event.filters import EventFilterMixin +from event.presentation.models import PresentationBookmark +from rest_framework import exceptions + + +class PresentationBookmarkErrorCode(StrEnum): + EVENT_NOT_FOUND = "event_not_found" class PresentationFilterSet(EventFilterMixin): @@ -14,3 +22,22 @@ def filter_by_type_names(self, queryset: BaseAbstractModelQuerySet, name: str, v return queryset return queryset.filter(Q(type__name_ko__in=values) | Q(type__name_en__in=values)) + + +class PresentationBookmarkFilterSet(filters.FilterSet): + event = filters.UUIDFilter(method="filter_by_event") + + class Meta: + model = PresentationBookmark + fields = ["event"] + + def filter_by_event( + self, queryset: BaseAbstractModelQuerySet, name: str, values: list[str] + ) -> BaseAbstractModelQuerySet: + filtered_queryset = queryset.filter(presentation__type__event__id=values) + if not filtered_queryset.exists(): + raise exceptions.NotFound( + detail="해당 행사 정보가 없습니다.", + code=PresentationBookmarkErrorCode.EVENT_NOT_FOUND, + ) + return filtered_queryset diff --git a/app/event/presentation/migrations/0017_add_presentation_bookmark.py b/app/event/presentation/migrations/0017_presentationbookmark.py similarity index 80% rename from app/event/presentation/migrations/0017_add_presentation_bookmark.py rename to app/event/presentation/migrations/0017_presentationbookmark.py index 993e7c8..30576d4 100644 --- a/app/event/presentation/migrations/0017_add_presentation_bookmark.py +++ b/app/event/presentation/migrations/0017_presentationbookmark.py @@ -1,4 +1,4 @@ -# Generated by Django 6.0.6 on 2026-07-11 05:12 +# Generated by Django 6.0.6 on 2026-07-11 08:37 import uuid @@ -9,7 +9,6 @@ class Migration(migrations.Migration): dependencies = [ - ("event", "0006_event_logo_historicalevent_logo"), ("presentation", "0016_room_order"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -28,14 +27,6 @@ class Migration(migrations.Migration): ), ), ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "event", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="presentation_bookmarks", - to="event.event", - ), - ), ( "presentation", models.ForeignKey( diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index 994adc8..b8f52bf 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -215,7 +215,6 @@ def __str__(self) -> str: class PresentationBookmark(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="presentation_bookmarks") - event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="presentation_bookmarks") presentation = models.ForeignKey(Presentation, on_delete=models.CASCADE, related_name="bookmarks") created_at = models.DateTimeField(auto_now_add=True) diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index 471b51f..97f1c0c 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -1,17 +1,31 @@ +from typing import Any + from event.presentation.models import ( CallForPresentationSchedule, Presentation, + PresentationBookmark, PresentationCategory, PresentationSpeaker, PresentationType, RoomSchedule, ) from event.serializers import EventSerializer -from rest_framework import serializers +from rest_framework import exceptions, serializers + + +class NotFoundPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + def fail(self, key, **kwargs): + if key == "does_not_exist": + raise exceptions.NotFound("해당 세션 정보가 없습니다.") + super().fail(key, **kwargs) class PresentationBookmarkRequestSerializer(serializers.Serializer): - presentation_id = serializers.UUIDField() + presentation = NotFoundPrimaryKeyRelatedField(queryset=Presentation.objects.filter(deleted_at__isnull=True)) + + def create(self, validated_data: Any) -> tuple[PresentationBookmark, bool]: + validated_data["user"] = self.context["request"].user + return PresentationBookmark.objects.get_or_create(**validated_data) class PresentationBookmarkListResponseSerializer(serializers.Serializer): diff --git a/app/event/presentation/test/bookmark_api_test.py b/app/event/presentation/test/bookmark_api_test.py index 21a132a..fda8a48 100644 --- a/app/event/presentation/test/bookmark_api_test.py +++ b/app/event/presentation/test/bookmark_api_test.py @@ -87,19 +87,19 @@ def other_event_presentation(other_event: Event) -> Presentation: # ────────────────────────────────────────────── -def list_url(event_name: str | None = None) -> str: - url = reverse("v1:presentation-bookmark-list") - if event_name: - url += f"?event={event_name}" - return url +def list_url(event_id: uuid.UUID | str) -> str: + return reverse("v1:presentation-bookmark-list", kwargs={"event_id": str(event_id)}) -def detail_url(presentation_id: uuid.UUID | str) -> str: - return reverse("v1:presentation-bookmark-detail", kwargs={"pk": str(presentation_id)}) +def detail_url(event_id: uuid.UUID | str, presentation_id: uuid.UUID | str) -> str: + return reverse( + "v1:presentation-bookmark-detail", + kwargs={"event_id": str(event_id), "presentation_id": str(presentation_id)}, + ) # ══════════════════════════════════════════════ -# GET /v1/event/presentation/bookmarks/ +# GET /v1/events/{event_id}/presentation-bookmarks/ # ══════════════════════════════════════════════ @@ -112,10 +112,10 @@ def test_returns_empty_list_when_no_bookmarks(self, authed_client: APIClient, ev 북마크가 하나도 없을 때 빈 배열을 반환하는지 검증합니다. 프론트에서 빈 상태 UI를 렌더링하기 위해 빈 배열이 정상 응답이어야 합니다. """ - response = authed_client.get(list_url(event.name)) + response = authed_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.OK - assert response.json() == {"presentation_ids": []} + assert response.json()["presentation_ids"] == [] @pytest.mark.django_db def test_returns_bookmarked_presentation_ids( @@ -130,17 +130,17 @@ def test_returns_bookmarked_presentation_ids( 유저가 북마크한 세션의 presentation_id 목록이 정확히 반환되는지 검증합니다. 프론트는 이 ID 목록을 전체 세션 목록과 매칭해서 내 시간표를 구성합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation_2) - response = authed_client.get(list_url(event.name)) + response = authed_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.OK returned_ids = set(response.json()["presentation_ids"]) assert returned_ids == {str(presentation.id), str(presentation_2.id)} @pytest.mark.django_db - def test_filters_by_event_name( + def test_filters_by_event_id( self, authed_client: APIClient, user: UserExt, @@ -150,35 +150,19 @@ def test_filters_by_event_name( other_event_presentation: Presentation, ): """ - event 쿼리 파라미터로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다. + event_id path로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다. 다른 행사의 북마크가 섞여 나오면 안 됩니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - PresentationBookmark.objects.create(user=user, event=other_event, presentation=other_event_presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=other_event_presentation) - response = authed_client.get(list_url(event.name)) + response = authed_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.OK returned_ids = response.json()["presentation_ids"] assert len(returned_ids) == 1 assert returned_ids[0] == str(presentation.id) - @pytest.mark.django_db - def test_defaults_to_latest_event_when_no_event_param( - self, authed_client: APIClient, user: UserExt, event: Event, other_event: Event, presentation: Presentation - ): - """ - event 쿼리 파라미터를 생략하면, 기존 presentation API와 동일하게 - 가장 최신 행사(event_start_at 기준)의 북마크를 반환하는지 검증합니다. - """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - - response = authed_client.get(list_url()) - - assert response.status_code == http.HTTPStatus.OK - # event(2026)이 other_event(2025)보다 최신이므로 event의 북마크가 반환되어야 함 - assert str(presentation.id) in response.json()["presentation_ids"] - @pytest.mark.django_db def test_does_not_return_other_users_bookmarks( self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation @@ -187,9 +171,9 @@ def test_does_not_return_other_users_bookmarks( 다른 유저가 북마크한 세션이 현재 유저의 목록에 포함되지 않는지 검증합니다. 북마크는 유저별로 완전히 격리되어야 합니다. """ - PresentationBookmark.objects.create(user=other_user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=other_user, presentation=presentation) - response = authed_client.get(list_url(event.name)) + response = authed_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.OK assert response.json()["presentation_ids"] == [] @@ -202,22 +186,22 @@ def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event) IsAuthenticated 권한 체크에서 403을 반환합니다. 프론트는 403을 받으면 로그인 모달을 띄웁니다. """ - response = anon_client.get(list_url(event.name)) + response = anon_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.FORBIDDEN @pytest.mark.django_db def test_nonexistent_event_returns_404(self, authed_client: APIClient): """ - 존재하지 않는 행사 이름으로 요청하면 404를 반환하는지 검증합니다. + 존재하지 않는 event_id로 요청하면 404를 반환하는지 검증합니다. """ - response = authed_client.get(list_url("존재하지 않는 행사")) + response = authed_client.get(list_url(uuid.uuid4())) assert response.status_code == http.HTTPStatus.NOT_FOUND # ══════════════════════════════════════════════ -# POST /v1/event/presentation/bookmarks/ +# POST /v1/events/{event_id}/presentation-bookmarks/ # ══════════════════════════════════════════════ @@ -234,8 +218,8 @@ def test_creates_bookmark_and_returns_201( DB에 북마크 레코드가 실제로 생성되었는지도 확인합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": str(presentation.id)}, + list_url(event.id), + data={"presentation": str(presentation.id)}, format="json", ) @@ -251,10 +235,10 @@ def test_sets_event_from_presentation( 북마크 생성 시 presentation의 type.event로부터 event를 자동 설정하는지 검증합니다. 프론트는 POST 시 event를 명시적으로 보내지 않으므로, 서버가 presentation에서 파생해야 합니다. """ - authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") bookmark = PresentationBookmark.objects.get(user=user, presentation=presentation) - assert bookmark.event_id == event.id + assert bookmark.presentation.type.event.id == event.id @pytest.mark.django_db def test_duplicate_bookmark_returns_200_idempotent( @@ -265,11 +249,11 @@ def test_duplicate_bookmark_returns_200_idempotent( (멱등성) 프론트의 되돌리기/연타로 중복 요청이 발생할 수 있으므로, 두 번째 요청도 정상 응답이어야 합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) response = authed_client.post( - list_url(), - data={"presentation_id": str(presentation.id)}, + list_url(event.id), + data={"presentation": str(presentation.id)}, format="json", ) @@ -279,20 +263,22 @@ def test_duplicate_bookmark_returns_200_idempotent( assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1 @pytest.mark.django_db - def test_nonexistent_presentation_returns_404(self, authed_client: APIClient): + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, event: Event): """ 존재하지 않는 presentation_id로 북마크를 추가하면 404를 반환하는지 검증합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": str(uuid.uuid4())}, + list_url(event.id), + data={"presentation": str(uuid.uuid4())}, format="json", ) assert response.status_code == http.HTTPStatus.NOT_FOUND @pytest.mark.django_db - def test_soft_deleted_presentation_returns_404(self, authed_client: APIClient, presentation: Presentation): + def test_soft_deleted_presentation_returns_404( + self, authed_client: APIClient, event: Event, presentation: Presentation + ): """ 소프트 삭제된(deleted_at이 설정된) presentation을 북마크하려고 하면 404를 반환하는지 검증합니다. @@ -301,46 +287,46 @@ def test_soft_deleted_presentation_returns_404(self, authed_client: APIClient, p presentation.delete() response = authed_client.post( - list_url(), - data={"presentation_id": str(presentation.id)}, + list_url(event.id), + data={"presentation": str(presentation.id)}, format="json", ) assert response.status_code == http.HTTPStatus.NOT_FOUND @pytest.mark.django_db - def test_invalid_presentation_id_format_returns_400(self, authed_client: APIClient): + def test_invalid_presentation_id_format_returns_400(self, authed_client: APIClient, event: Event): """ presentation_id에 UUID가 아닌 값을 보내면 400 Bad Request를 반환하는지 검증합니다. serializer의 UUIDField 유효성 검사가 동작해야 합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": "not-a-uuid"}, + list_url(event.id), + data={"presentation": "not-a-uuid"}, format="json", ) assert response.status_code == http.HTTPStatus.BAD_REQUEST @pytest.mark.django_db - def test_missing_presentation_id_returns_400(self, authed_client: APIClient): + def test_missing_presentation_id_returns_400(self, authed_client: APIClient, event: Event): """ - 요청 바디에 presentation_id 필드가 없으면 400 Bad Request를 반환하는지 검증합니다. + 요청 바디에 presentation 필드가 없으면 400 Bad Request를 반환하는지 검증합니다. """ - response = authed_client.post(list_url(), data={}, format="json") + response = authed_client.post(list_url(event.id), data={}, format="json") assert response.status_code == http.HTTPStatus.BAD_REQUEST @pytest.mark.django_db - def test_unauthenticated_returns_403(self, anon_client: APIClient, presentation: Presentation): + def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event, presentation: Presentation): """ 로그인하지 않은 유저가 북마크 추가를 시도하면 403 Forbidden을 반환하는지 검증합니다. DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, IsAuthenticated 권한 체크에서 403을 반환합니다. """ response = anon_client.post( - list_url(), - data={"presentation_id": str(presentation.id)}, + list_url(event.id), + data={"presentation": str(presentation.id)}, format="json", ) @@ -348,22 +334,40 @@ def test_unauthenticated_returns_403(self, anon_client: APIClient, presentation: @pytest.mark.django_db def test_allows_overlapping_time_sessions( - self, authed_client: APIClient, user: UserExt, presentation: Presentation, presentation_2: Presentation + self, + authed_client: APIClient, + user: UserExt, + event: Event, + presentation: Presentation, + presentation_2: Presentation, ): """ 시간이 겹치는 세션들도 모두 북마크할 수 있는지 검증합니다. UX 확정 사항: 겹침 경고는 프론트가 처리하고, 서버는 시간대 충돌을 검사하지 않습니다. """ - resp1 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") - resp2 = authed_client.post(list_url(), data={"presentation_id": str(presentation_2.id)}, format="json") + resp1 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(event.id), data={"presentation": str(presentation_2.id)}, format="json") assert resp1.status_code == http.HTTPStatus.CREATED assert resp2.status_code == http.HTTPStatus.CREATED assert PresentationBookmark.objects.filter(user=user).count() == 2 + @pytest.mark.django_db + def test_nonexistent_event_returns_404(self, authed_client: APIClient, presentation: Presentation): + """ + 존재하지 않는 event_id로 북마크를 추가하면 404를 반환하는지 검증합니다. + """ + response = authed_client.post( + list_url(uuid.uuid4()), + data={"presentation": str(presentation.id)}, + format="json", + ) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + # ══════════════════════════════════════════════ -# DELETE /v1/event/presentation/bookmarks/{presentation_id}/ +# DELETE /v1/events/{event_id}/presentation-bookmarks/{presentation_id}/ # ══════════════════════════════════════════════ @@ -378,9 +382,9 @@ def test_deletes_bookmark_and_returns_204( 북마크된 세션을 삭제하면 204 No Content를 반환하고, DB에서 실제로 레코드가 삭제(hard delete)되는지 검증합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) - response = authed_client.delete(detail_url(presentation.id)) + response = authed_client.delete(detail_url(event.id, presentation.id)) assert response.status_code == http.HTTPStatus.NO_CONTENT assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() @@ -395,18 +399,18 @@ def test_not_bookmarked_returns_204_idempotent( 프론트의 되돌리기 UX에서 DELETE 직후 같은 세션에 POST가 오고, 다시 DELETE가 올 수 있으므로 멱등이어야 합니다. """ - response = authed_client.delete(detail_url(presentation.id)) + response = authed_client.delete(detail_url(event.id, presentation.id)) assert response.status_code == http.HTTPStatus.NO_CONTENT @pytest.mark.django_db - def test_nonexistent_presentation_returns_404(self, authed_client: APIClient): + def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, event: Event): """ 존재하지 않는 presentation_id로 삭제를 요청하면 404를 반환하는지 검증합니다. presentation 자체가 DB에 없는 경우만 404이고, presentation은 있지만 북마크가 없는 경우는 204입니다. """ - response = authed_client.delete(detail_url(uuid.uuid4())) + response = authed_client.delete(detail_url(event.id, uuid.uuid4())) assert response.status_code == http.HTTPStatus.NOT_FOUND @@ -418,22 +422,22 @@ def test_does_not_delete_other_users_bookmark( 삭제 요청이 다른 유저의 북마크에 영향을 주지 않는지 검증합니다. user.presentation 필터로 현재 유저의 북마크만 삭제해야 합니다. """ - PresentationBookmark.objects.create(user=other_user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=other_user, presentation=presentation) - response = authed_client.delete(detail_url(presentation.id)) + response = authed_client.delete(detail_url(event.id, presentation.id)) assert response.status_code == http.HTTPStatus.NO_CONTENT # 다른 유저의 북마크는 그대로 남아 있어야 함 assert PresentationBookmark.objects.filter(user=other_user, presentation=presentation).exists() @pytest.mark.django_db - def test_unauthenticated_returns_403(self, anon_client: APIClient, presentation: Presentation): + def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event, presentation: Presentation): """ 로그인하지 않은 유저가 북마크 삭제를 시도하면 403 Forbidden을 반환하는지 검증합니다. DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고, IsAuthenticated 권한 체크에서 403을 반환합니다. """ - response = anon_client.delete(detail_url(presentation.id)) + response = anon_client.delete(detail_url(event.id, presentation.id)) assert response.status_code == http.HTTPStatus.FORBIDDEN @@ -450,14 +454,23 @@ def test_only_deletes_specified_bookmark( 특정 세션 하나를 삭제할 때, 유저의 다른 북마크는 유지되는지 검증합니다. 삭제 범위가 정확히 요청된 presentation에만 한정되어야 합니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation_2) + PresentationBookmark.objects.create(user=user, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation_2) - authed_client.delete(detail_url(presentation.id)) + authed_client.delete(detail_url(event.id, presentation.id)) assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists() assert PresentationBookmark.objects.filter(user=user, presentation=presentation_2).exists() + @pytest.mark.django_db + def test_nonexistent_event_returns_404(self, authed_client: APIClient, presentation: Presentation): + """ + 존재하지 않는 event_id로 삭제를 요청하면 404를 반환하는지 검증합니다. + """ + response = authed_client.delete(detail_url(uuid.uuid4(), presentation.id)) + + assert response.status_code == http.HTTPStatus.NOT_FOUND + # ══════════════════════════════════════════════ # 통합 시나리오: 담기 → 빼기 → 되돌리기 (POST → DELETE → POST) @@ -479,27 +492,27 @@ def test_add_remove_re_add_flow( 각 단계에서 GET으로 목록을 조회해 상태가 올바른지 확인합니다. """ # 1단계: 담기 - resp = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + resp = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") assert resp.status_code == http.HTTPStatus.CREATED # GET으로 확인: 1개 - resp = authed_client.get(list_url(event.name)) + resp = authed_client.get(list_url(event.id)) assert len(resp.json()["presentation_ids"]) == 1 # 2단계: 빼기 - resp = authed_client.delete(detail_url(presentation.id)) + resp = authed_client.delete(detail_url(event.id, presentation.id)) assert resp.status_code == http.HTTPStatus.NO_CONTENT # GET으로 확인: 0개 - resp = authed_client.get(list_url(event.name)) + resp = authed_client.get(list_url(event.id)) assert len(resp.json()["presentation_ids"]) == 0 # 3단계: 되돌리기 (다시 POST) - resp = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + resp = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") assert resp.status_code == http.HTTPStatus.CREATED # GET으로 확인: 다시 1개 - resp = authed_client.get(list_url(event.name)) + resp = authed_client.get(list_url(event.id)) assert len(resp.json()["presentation_ids"]) == 1 @pytest.mark.django_db @@ -511,22 +524,24 @@ def test_rapid_double_delete_is_safe( 두 번째 요청이 에러 없이 204를 반환하는지 검증합니다. 네트워크 재시도나 프론트 연타로 발생할 수 있는 시나리오입니다. """ - PresentationBookmark.objects.create(user=user, event=event, presentation=presentation) + PresentationBookmark.objects.create(user=user, presentation=presentation) - resp1 = authed_client.delete(detail_url(presentation.id)) - resp2 = authed_client.delete(detail_url(presentation.id)) + resp1 = authed_client.delete(detail_url(event.id, presentation.id)) + resp2 = authed_client.delete(detail_url(event.id, presentation.id)) assert resp1.status_code == http.HTTPStatus.NO_CONTENT assert resp2.status_code == http.HTTPStatus.NO_CONTENT @pytest.mark.django_db - def test_rapid_double_post_is_safe(self, authed_client: APIClient, user: UserExt, presentation: Presentation): + def test_rapid_double_post_is_safe( + self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation + ): """ 같은 세션에 대해 POST가 빠르게 2번 연속 호출되어도 두 번째 요청이 에러 없이 200을 반환하고 중복 레코드가 생기지 않는지 검증합니다. """ - resp1 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") - resp2 = authed_client.post(list_url(), data={"presentation_id": str(presentation.id)}, format="json") + resp1 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") assert resp1.status_code == http.HTTPStatus.CREATED assert resp2.status_code == http.HTTPStatus.OK @@ -551,7 +566,7 @@ def test_403_follows_error_envelope(self, anon_client: APIClient, event: Event): DRF SessionAuthentication + IsAuthenticated 조합에서 미인증 요청은 403 + code: "not_authenticated"로 응답합니다. """ - response = anon_client.get(list_url(event.name)) + response = anon_client.get(list_url(event.id)) assert response.status_code == http.HTTPStatus.FORBIDDEN body = response.json() @@ -563,13 +578,13 @@ def test_403_follows_error_envelope(self, anon_client: APIClient, event: Event): assert body["errors"][0]["code"] == "not_authenticated" @pytest.mark.django_db - def test_404_follows_error_envelope(self, authed_client: APIClient): + def test_404_follows_error_envelope(self, authed_client: APIClient, event: Event): """ 404 응답이 공통 에러 envelope 포맷을 따르는지 검증합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": str(uuid.uuid4())}, + list_url(event.id), + data={"presentation": str(uuid.uuid4())}, format="json", ) @@ -580,13 +595,13 @@ def test_404_follows_error_envelope(self, authed_client: APIClient): assert body["errors"][0]["code"] == "not_found" @pytest.mark.django_db - def test_400_validation_error_follows_envelope(self, authed_client: APIClient): + def test_400_validation_error_follows_envelope(self, authed_client: APIClient, event: Event): """ 400 유효성 검사 에러가 공통 에러 envelope 포맷을 따르는지 검증합니다. """ response = authed_client.post( - list_url(), - data={"presentation_id": "invalid"}, + list_url(event.id), + data={"presentation": "invalid"}, format="json", ) diff --git a/app/event/presentation/urls.py b/app/event/presentation/urls.py index df73b04..7118cb6 100644 --- a/app/event/presentation/urls.py +++ b/app/event/presentation/urls.py @@ -7,9 +7,12 @@ cms_router.register("", views.PresentationViewSet, basename="presentation") bookmark_router = routers.SimpleRouter() -bookmark_router.register("bookmarks", views.PresentationBookmarkViewSet, basename="presentation-bookmark") +bookmark_router.register("", views.PresentationBookmarkViewSet, basename="presentation-bookmark") urlpatterns = [ - path("", include(bookmark_router.urls)), path("", include(cms_router.urls)), ] + +bookmark_urlpatterns = [ + path("", include(bookmark_router.urls)), +] diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py index a4f4c8d..babea15 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,5 +1,6 @@ from core.const.tag import OpenAPITag -from django.db.models import Q +from django.db.models.query import QuerySet +from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from drf_standardized_errors.openapi_serializers import ( @@ -16,11 +17,7 @@ PresentationCategorySerializer, PresentationSerializer, ) -from rest_framework import mixins, status, viewsets -from rest_framework.exceptions import NotFound -from rest_framework.permissions import IsAuthenticated -from rest_framework.request import Request -from rest_framework.response import Response +from rest_framework import exceptions, mixins, permissions, request, response, status, viewsets @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) @@ -39,21 +36,26 @@ class PresentationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, view @extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION_BOOKMARK]) class PresentationBookmarkViewSet(viewsets.GenericViewSet): - permission_classes = [IsAuthenticated] + queryset = PresentationBookmark.objects.all() + permission_classes = [permissions.IsAuthenticated] serializer_class = PresentationBookmarkRequestSerializer + lookup_field = "presentation_id" - def _resolve_event(self, request: Request) -> Event: - event_name = request.query_params.get("event") - if not event_name: - event = Event.objects.filter_active().first() - if not event: - raise NotFound("해당 행사 정보가 없습니다.") - return event + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + get_object_or_404(Event, id=self.kwargs["event_id"]) - event = Event.objects.filter_active().filter(Q(name_ko=event_name) | Q(name_en=event_name)).first() - if not event: - raise NotFound("해당 행사 정보가 없습니다.") - return event + def get_queryset(self) -> QuerySet: + return ( + super().get_queryset().filter(user=self.request.user, presentation__type__event_id=self.kwargs["event_id"]) + ) + + def destroy(self, request: request.Request, **kwargs) -> response.Response: + presentation_id = self.kwargs["presentation_id"] + if not Presentation.objects.filter(id=presentation_id, deleted_at__isnull=True).exists(): + raise exceptions.NotFound("해당 세션 정보가 없습니다.") + self.get_queryset().filter(presentation_id=presentation_id).delete() + return response.Response(status=status.HTTP_204_NO_CONTENT) @extend_schema( summary="북마크 목록 조회", @@ -64,14 +66,10 @@ def _resolve_event(self, request: Request) -> Event: status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, }, ) - def list(self, request: Request) -> Response: - event = self._resolve_event(request) - presentation_ids = list( - PresentationBookmark.objects.filter(user=request.user, event=event).values_list( - "presentation_id", flat=True - ) - ) - return Response({"presentation_ids": presentation_ids}) + def list(self, request: request.Request, **kwargs) -> response.Response: + queryset = self.get_queryset() + presentation_ids = list(queryset.values_list("presentation_id", flat=True)) + return response.Response({"presentation_ids": presentation_ids}) @extend_schema( summary="북마크 추가", @@ -83,38 +81,13 @@ def list(self, request: Request) -> Response: status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, }, ) - def create(self, request: Request) -> Response: - serializer = PresentationBookmarkRequestSerializer(data=request.data) + def create(self, request: request.Request, **kwargs) -> response.Response: + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - presentation_id = serializer.validated_data["presentation_id"] - - presentation = Presentation.objects.filter_active().filter(id=presentation_id).first() - if not presentation: - raise NotFound("해당 세션 정보가 없습니다.") + instance, created = serializer.create(serializer.validated_data) + serializer.save() - event = presentation.type.event - _, created = PresentationBookmark.objects.get_or_create( - user=request.user, - presentation=presentation, - defaults={"event": event}, - ) - return Response( - {"presentation_id": str(presentation.id)}, + return response.Response( + {"presentation_id": str(instance.presentation_id)}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) - - @extend_schema( - summary="북마크 삭제", - responses={ - status.HTTP_204_NO_CONTENT: None, - status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer, - status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, - }, - ) - def destroy(self, request: Request, pk: str = None) -> Response: - presentation = Presentation.objects.filter_active().filter(id=pk).first() - if not presentation: - raise NotFound("해당 세션 정보가 없습니다.") - - PresentationBookmark.objects.filter(user=request.user, presentation=presentation).delete() - return Response(status=status.HTTP_204_NO_CONTENT) From 7117560889a781f348d2ca284e59c2b5155f33cc Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 00:21:31 +0900 Subject: [PATCH 29/70] =?UTF-8?q?feat:=20timetable=EC=9A=A9=20admin=5Fapi?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/event/timetable.py | 110 ++++++++ app/admin_api/test/event/timetable_test.py | 260 +++++++++++++++++++ app/admin_api/urls.py | 6 + app/admin_api/views/event/timetable.py | 48 ++++ app/core/serializer/operation_serializer.py | 93 +++++++ 5 files changed, 517 insertions(+) create mode 100644 app/admin_api/serializers/event/timetable.py create mode 100644 app/admin_api/test/event/timetable_test.py create mode 100644 app/admin_api/views/event/timetable.py create mode 100644 app/core/serializer/operation_serializer.py diff --git a/app/admin_api/serializers/event/timetable.py b/app/admin_api/serializers/event/timetable.py new file mode 100644 index 0000000..12ec014 --- /dev/null +++ b/app/admin_api/serializers/event/timetable.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import hashlib +from collections import defaultdict + +from core.serializer.operation_serializer import Operation, OperationSerializer +from core.util.dateutil import any_to_datetime +from django.db import transaction +from django.db.models import Count, Max +from event.models import Event +from event.presentation.models import Presentation, Room, RoomSchedule +from rest_framework import serializers + + +class RoomOperationAdminSerializer(OperationSerializer, serializers.ModelSerializer): + # 같은 요청의 신규 스케줄이 이 방을 참조하기 위한 요청 스코프 토큰(저장 안 함, 부모 save 에서 실제 pk 로 remap). + ref = serializers.CharField(write_only=True, required=False) + + class Meta(OperationSerializer.Meta): + model = Room + fields = OperationSerializer.Meta.fields + ("ref", "name_ko", "name_en", "order") + + def get_operation_queryset(self): # update/delete 는 이 event 하위 방으로 한정 + return Room.objects.filter_active().filter(event=self.context["event"]) + + +class RoomScheduleOperationAdminSerializer(OperationSerializer, serializers.ModelSerializer): + # room_id: 읽기는 방 pk 를 그대로 노출, 쓰기는 기존 방 id 또는 같은 요청 방 create 의 ref → 부모 save 에서 해소. + room_id = serializers.CharField() + presentation = serializers.PrimaryKeyRelatedField(queryset=Presentation.objects.filter_active()) + + class Meta(OperationSerializer.Meta): + model = RoomSchedule + fields = OperationSerializer.Meta.fields + ("room_id", "start_at", "end_at", "presentation") + + def get_operation_queryset(self): # update/delete 는 이 event 하위 스케줄로 한정 + return RoomSchedule.objects.filter_active().filter(room__event=self.context["event"]) + + def validate(self, attrs: dict) -> dict: + start = any_to_datetime(attrs.get("start_at", getattr(self.instance, "start_at", None))) + end = any_to_datetime(attrs.get("end_at", getattr(self.instance, "end_at", None))) + if start and end and start >= end: + raise serializers.ValidationError({"start_at": "시작 시간은 종료 시간보다 전이어야 합니다."}) + return attrs + + +def timetable_version(event: Event) -> str: + rooms = Room.objects.filter_active().filter(event_id=event.id).aggregate(c=Count("id"), m=Max("updated_at")) + scheds = ( + RoomSchedule.objects.filter_active() + .filter(room__event_id=event.id) + .aggregate(c=Count("id"), m=Max("updated_at")) + ) + raw = f"{rooms['c']}|{rooms['m']}|{scheds['c']}|{scheds['m']}" + return hashlib.sha256(raw.encode()).hexdigest()[:16] + + +class TimetableAdminSerializer(serializers.Serializer): + rooms = RoomOperationAdminSerializer(many=True, required=False) + schedules = RoomScheduleOperationAdminSerializer(many=True, required=False) + + def to_representation(self, event: Event) -> dict: + return super().to_representation( + { + "rooms": Room.objects.filter_active().filter(event_id=event.id).order_by("order", "pk"), + "schedules": ( + RoomSchedule.objects.filter_active().filter(room__event_id=event.id).order_by("start_at", "pk") + ), + } + ) + + @transaction.atomic + def save(self, **kwargs) -> Event: + event = self.context["event"] + room_ops = self.validated_data.get("rooms", []) + room_results = self.fields["rooms"].apply(room_ops, event=event) + + ref_to_room = { + op["ref"]: room + for op, room in zip(room_ops, room_results) + if op["op"] == Operation.CREATE and op.get("ref") is not None + } + rooms_by_id = {str(room.id): room for room in Room.objects.filter_active().filter(event_id=event.id)} + + schedule_ops = self.validated_data.get("schedules", []) + for attrs in schedule_ops: + if not (room_key := attrs.get("room_id")): + continue + if not (room := ref_to_room.get(room_key) or rooms_by_id.get(room_key)): + raise serializers.ValidationError({"schedules": "이벤트에 속한 활성 발표장이 아닙니다."}) + attrs["room_id"] = room.id + self.fields["schedules"].apply(schedule_ops) + + rows = ( + RoomSchedule.objects.filter_active() + .filter(room__event_id=event.id) + .order_by("room_id", "start_at") + .values("room_id", "room__deleted_at", "start_at", "end_at") + ) + by_room: dict = defaultdict(list) + for row in rows: + if row["room__deleted_at"] is not None: + raise serializers.ValidationError({"schedules": "삭제된 발표장을 참조하는 세션이 남아 있습니다."}) + by_room[row["room_id"]].append(row) + for items in by_room.values(): + for prev, cur in zip(items, items[1:]): + if cur["start_at"] < prev["end_at"]: + raise serializers.ValidationError({"schedules": "같은 발표장에 시간이 겹치는 세션이 있습니다."}) + + return event diff --git a/app/admin_api/test/event/timetable_test.py b/app/admin_api/test/event/timetable_test.py new file mode 100644 index 0000000..106ddbf --- /dev/null +++ b/app/admin_api/test/event/timetable_test.py @@ -0,0 +1,260 @@ +import http +import uuid + +import pytest +from admin_api.serializers.event.timetable import timetable_version +from django.urls import reverse +from event.models import Event +from event.presentation.models import Presentation, PresentationType, Room, RoomSchedule +from rest_framework.test import APIClient +from user.models.organization import Organization + +TIMETABLE = "v1:admin-event-presentation-timetable-detail" + + +@pytest.fixture +def event(db) -> Event: + organization = Organization.objects.create(name="Org") + return Event.objects.create(name="PyCon", organization=organization) + + +@pytest.fixture +def presentation(event) -> Presentation: + ptype = PresentationType.objects.create(name="Talk", event=event) + return Presentation.objects.create(type=ptype, title="A talk") + + +@pytest.fixture +def room(event) -> Room: + return Room.objects.create(event=event, name="Room A") + + +def _url(event: Event) -> str: + return reverse(TIMETABLE, args=[event.id]) + + +def _version(event: Event) -> str: + return timetable_version(event) + + +def _get(api_client: APIClient, event: Event): + return api_client.get(_url(event)) + + +def _put(api_client: APIClient, event: Event, payload: dict, if_match: str | None = None): + extra = {"HTTP_IF_MATCH": if_match} if if_match is not None else {} + return api_client.put(_url(event), payload, format="json", **extra) + + +# ---- Auth ------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_unauthenticated_rejected(event): + response = APIClient().get(_url(event)) + assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED) + + +@pytest.mark.django_db +def test_non_superuser_rejected(event, customer_user): + client = APIClient() + client.force_authenticate(user=customer_user) + assert client.get(_url(event)).status_code == http.HTTPStatus.FORBIDDEN + + +# ---- Read ------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_get_returns_rooms_schedules_and_etag(api_client, event, room, presentation): + RoomSchedule.objects.create( + room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00" + ) + response = _get(api_client, event) + assert response.status_code == http.HTTPStatus.OK + assert len(response.data["rooms"]) == 1 + assert len(response.data["schedules"]) == 1 + assert "version" not in response.data # 동시성 토큰은 ETag 헤더로만 + assert response["ETag"].strip('"') == _version(event) + + +@pytest.mark.django_db +def test_unknown_event_returns_404(api_client): + assert api_client.get(reverse(TIMETABLE, args=[uuid.uuid4()])).status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_head_returns_etag_without_body(api_client, event, room): + response = api_client.head(_url(event)) + assert response.status_code == http.HTTPStatus.OK + assert response["ETag"].strip('"') == _version(event) + assert not response.content + + +# ---- Save (op-based partial reflection) ------------------------------------- + + +@pytest.mark.django_db +def test_put_creates_room_and_schedule(api_client, event, presentation): + payload = { + "rooms": [{"op": "create", "ref": "new-room", "name_ko": "새방", "name_en": "New", "order": 0}], + "schedules": [ + { + "op": "create", + "room_id": "new-room", # 같은 요청 방 create 의 ref 로 참조 → 서버가 실제 pk 로 remap + "presentation": str(presentation.id), + "start_at": "2026-08-16T10:00:00+09:00", + "end_at": "2026-08-16T11:00:00+09:00", + } + ], + } + response = _put(api_client, event, payload, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.OK + [created_room] = response.data["rooms"] + [schedule] = response.data["schedules"] + assert str(schedule["room_id"]) == str(created_room["id"]) # ref → 서버 생성 id 로 해소됨 + + +@pytest.mark.django_db +def test_put_schedule_references_existing_room_by_id(api_client, event, room, presentation): + payload = { + "schedules": [ + { + "op": "create", + "room_id": str(room.id), # 기존 방은 실제 id 로 참조 + "presentation": str(presentation.id), + "start_at": "2026-08-16T10:00:00+09:00", + "end_at": "2026-08-16T11:00:00+09:00", + } + ], + } + response = _put(api_client, event, payload, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.OK + [schedule] = response.data["schedules"] + assert str(schedule["room_id"]) == str(room.id) + + +@pytest.mark.django_db +def test_put_unlisted_items_are_untouched(api_client, event): + keep = Room.objects.create(event=event, name="Keep") + edit = Room.objects.create(event=event, name="Edit") + + payload = {"rooms": [{"op": "update", "id": str(edit.id), "name_ko": "수정됨"}]} + response = _put(api_client, event, payload, if_match=_version(event)) + + assert response.status_code == http.HTTPStatus.OK + edit.refresh_from_db() + assert edit.name_ko == "수정됨" + assert Room.objects.filter_active().filter(id=keep.id).exists() # 목록에 없던 방은 그대로 + assert Room.objects.filter_active().filter(event_id=event.id).count() == 2 + + +@pytest.mark.django_db +def test_put_deletes_room_and_its_schedule(api_client, event, room, presentation): + schedule = RoomSchedule.objects.create( + room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00" + ) + payload = { + "rooms": [{"op": "delete", "id": str(room.id)}], + "schedules": [{"op": "delete", "id": str(schedule.id)}], + } + response = _put(api_client, event, payload, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.OK + assert not Room.objects.filter_active().filter(id=room.id).exists() + assert not RoomSchedule.objects.filter_active().filter(id=schedule.id).exists() + + +@pytest.mark.django_db +def test_put_deleting_room_with_remaining_schedule_is_rejected(api_client, event, room, presentation): + RoomSchedule.objects.create( + room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00" + ) + # 방만 삭제하고 세션은 남겨두면 고아 세션이 되므로 거부되어야 한다. + payload = {"rooms": [{"op": "delete", "id": str(room.id)}]} + response = _put(api_client, event, payload, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert Room.objects.filter_active().filter(id=room.id).exists() # 롤백됨 + + +@pytest.mark.django_db +def test_put_stale_if_match_returns_412(api_client, event, room): + stale = _version(event) + Room.objects.create(event=event, name="Concurrent") # 그 사이 버전 변경 + + response = _put(api_client, event, {"rooms": [{"op": "delete", "id": str(room.id)}]}, if_match=stale) + assert response.status_code == http.HTTPStatus.PRECONDITION_FAILED + assert len(response.data["rooms"]) == 2 # 현재 서버 상태를 함께 반환 + assert Room.objects.filter_active().filter(id=room.id).exists() # 저장 미적용 + + +@pytest.mark.django_db +def test_put_conflict_returns_400_and_rolls_back(api_client, event, room, presentation): + overlapping = { + "schedules": [ + { + "op": "create", + "room_id": str(room.id), + "presentation": str(presentation.id), + "start_at": "2026-08-16T10:00:00+09:00", + "end_at": "2026-08-16T11:00:00+09:00", + }, + { + "op": "create", + "room_id": str(room.id), + "presentation": str(presentation.id), + "start_at": "2026-08-16T10:30:00+09:00", + "end_at": "2026-08-16T11:30:00+09:00", + }, + ], + } + response = _put(api_client, event, overlapping, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert RoomSchedule.objects.filter_active().filter(room__event_id=event.id).count() == 0 # 롤백됨 + + +@pytest.mark.django_db +def test_put_update_missing_instance_returns_400(api_client, event): + payload = {"rooms": [{"op": "update", "id": str(uuid.uuid4()), "name_ko": "없음"}]} + response = _put(api_client, event, payload, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +@pytest.mark.django_db +def test_put_cannot_touch_other_event_room(api_client, event): + # 다른 event 의 방 id 로 update 를 시도해도 이 event 스코프 밖이라 400, 대상은 그대로여야 한다. + other_event = Event.objects.create(name="Other", organization=event.organization) + foreign = Room.objects.create(event=other_event, name="Foreign") + + payload = {"rooms": [{"op": "update", "id": str(foreign.id), "name_ko": "침범"}]} + response = _put(api_client, event, payload, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + foreign.refresh_from_db() + assert foreign.name_ko == "Foreign" # 변경 안 됨 + + +@pytest.mark.django_db +def test_put_schedule_with_unknown_room_ref_returns_400(api_client, event, presentation): + payload = { + "schedules": [ + { + "op": "create", + "room_id": "no-such-ref", # 존재하는 방 id 도, 이번 요청의 방 ref 도 아님 + "presentation": str(presentation.id), + "start_at": "2026-08-16T10:00:00+09:00", + "end_at": "2026-08-16T11:00:00+09:00", + } + ], + } + response = _put(api_client, event, payload, if_match=_version(event)) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + +# ---- Version token ---------------------------------------------------------- + + +@pytest.mark.django_db +def test_version_changes_on_delete(event): + room = Room.objects.create(event=event, name="R") + before = _version(event) + room.delete() # soft delete (updated_at 은 안 오르지만 count 가 바뀜) + assert _version(event) != before diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index 4b8ca65..aa15af4 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -11,6 +11,7 @@ RoomScheduleAdminViewSet, ) from admin_api.views.event.sponsor import SponsorAdminViewSet, SponsorTagAdminViewSet, SponsorTierAdminViewSet +from admin_api.views.event.timetable import EventPresentationTimetableAdminViewSet from admin_api.views.external_api.google_oauth2 import GoogleOAuth2AdminViewSet from admin_api.views.file import PublicFileAdminViewSet from admin_api.views.mcp_token import McpTokenAdminViewSet @@ -72,6 +73,11 @@ admin_event_router.register("presentationspeaker", PresentationSpeakerAdminViewSet) admin_event_router.register("room", RoomAdminViewSet) admin_event_router.register("roomschedule", RoomScheduleAdminViewSet) +admin_event_router.register( + "presentation/timetable", + EventPresentationTimetableAdminViewSet, + basename="admin-event-presentation-timetable", +) admin_participant_portal_router = routers.SimpleRouter() admin_participant_portal_router.register( diff --git a/app/admin_api/views/event/timetable.py b/app/admin_api/views/event/timetable.py new file mode 100644 index 0000000..f5cefe4 --- /dev/null +++ b/app/admin_api/views/event/timetable.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from admin_api.serializers.event.timetable import TimetableAdminSerializer, timetable_version +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from drf_spectacular.utils import extend_schema +from event.models import Event +from rest_framework import status, viewsets +from rest_framework.request import Request +from rest_framework.response import Response + + +class EventPresentationTimetableAdminViewSet(viewsets.GenericViewSet): + permission_classes = [IsSuperUser] + serializer_class = TimetableAdminSerializer + queryset = Event.objects.filter_active() + + @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION]) + def retrieve(self, request: Request, *args, **kwargs) -> Response: + event = self.get_object() + return Response( + data=self.get_serializer(event).data if request.method == "GET" else None, + status=status.HTTP_200_OK, + headers={"ETag": f'"{timetable_version(event)}"'}, + ) + + @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION]) + def update(self, request: Request, *args, **kwargs) -> Response: + event = self.get_object() + current = timetable_version(event) + + if_match = request.headers.get("If-Match") + if if_match is not None and if_match.strip('"') != current: + return Response( + data=self.get_serializer(event).data, + status=status.HTTP_412_PRECONDITION_FAILED, + headers={"ETag": f'"{current}"'}, + ) + + patch = TimetableAdminSerializer(data=request.data, context={"event": event}) + patch.is_valid(raise_exception=True) + patch.save() + + return Response( + data=self.get_serializer(event).data, + status=status.HTTP_200_OK, + headers={"ETag": f'"{timetable_version(event)}"'}, + ) diff --git a/app/core/serializer/operation_serializer.py b/app/core/serializer/operation_serializer.py new file mode 100644 index 0000000..6ccfd1c --- /dev/null +++ b/app/core/serializer/operation_serializer.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from django.db.models import TextChoices +from rest_framework import serializers + + +class Operation(TextChoices): + CREATE = "create", "추가" + UPDATE = "update", "수정" + DELETE = "delete", "삭제" + + +class OperationListSerializer(serializers.ListSerializer): + """`op` 필드로 항목별 추가/수정/삭제를 지시하는 부분반영 리스트. + + 목록에 없는 항목은 건드리지 않으며, DRF 가 항목별 검증을 인덱스로 집계한다. + child 는 `Meta.model` 을 가져야 한다(soft-delete `filter_active()` 전제). + pk 는 서버가 소유한다 — create 는 서버가 pk 를 생성하고, update/delete 는 `id` 로 대상을 지정한다. + 실제 저장은 부모 직렬화기가 순서를 통제하며 `apply()` 를 호출한다. + """ + + def run_child_validation(self, data: dict) -> dict: + if not isinstance(data, dict): + raise serializers.ValidationError("객체 형식이어야 합니다.") + + op = data.get("op") + if op not in Operation.values: + raise serializers.ValidationError({"op": f"{list(Operation.values)} 중 하나여야 합니다."}) + + pk = data.get("id") + if op == Operation.CREATE: + validated = dict(self.child.__class__(context=self.context).run_validation(data)) + else: + if pk is None: + raise serializers.ValidationError({"id": "update/delete 에는 id 가 필요합니다."}) + if not (instance := self.child.get_operation_queryset().filter(pk=pk).first()): + raise serializers.ValidationError({"id": "존재하지 않는 항목입니다."}) + if op == Operation.DELETE: + return {"op": Operation.DELETE.value, "id": instance.pk} + validated = dict( + self.child.__class__(instance=instance, partial=True, context=self.context).run_validation(data) + ) + + validated["op"] = op + validated.setdefault("id", pk) + return validated + + def apply(self, operations: list[dict], **create_defaults) -> list: + model = self.child.Meta.model + # name 과 attname(FK 의 `_id` 등) 을 모두 허용 — 도메인이 room_id 같은 attname 으로 값을 넘길 수 있다. + model_fields = {n for f in model._meta.get_fields() for n in (f.name, getattr(f, "attname", f.name))} + results = [] + for attrs in operations: + op = attrs["op"] + pk = attrs.get("id") + fields = {key: value for key, value in attrs.items() if key != "id" and key in model_fields} + if op == Operation.CREATE: + results.append(model.objects.create(**create_defaults, **fields)) + elif op == Operation.UPDATE: + instance = self.child.get_operation_queryset().get(pk=pk) + for field, value in fields.items(): + setattr(instance, field, value) + instance.save() + results.append(instance) + else: + instance = self.child.get_operation_queryset().get(pk=pk) + instance.delete() + results.append(instance) + return results + + +class OperationSerializer(serializers.Serializer): + """op 기반 부분반영 항목의 공통 필드(id·op)와 list_serializer_class 를 제공하는 믹스인. + + 사용: `class Foo(OperationSerializer, serializers.ModelSerializer)` 로 ModelSerializer 와 함께 상속하고, + `class Meta(OperationSerializer.Meta): model = ...; fields = OperationSerializer.Meta.fields + (...)`. + + - id: 이미 존재하는 항목의 pk. update/delete 대상 지정용(editable=False pk 는 기본 read-only 라 명시 선언). + - op: 검증·분기는 OperationListSerializer 가 담당하고, 이 필드는 문서화(spectacular)·validated_data 포함용. + + create 항목을 같은 요청 안에서 참조해야 하는 링크 토큰(ref 등)은 도메인 하위 직렬화기에 직접 선언하고 + 부모 직렬화기가 resolve 한다 — apply 는 모델 필드만 반영하므로 비모델 필드는 저장되지 않는다. + """ + + id = serializers.UUIDField(required=False) + op = serializers.ChoiceField(choices=Operation.choices, write_only=True) + + def get_operation_queryset(self): + return self.Meta.model.objects.filter_active() + + class Meta: + fields = ("id", "op") + list_serializer_class = OperationListSerializer From eb6362695b0cbcddd8219d657a12695a4a95d235 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 15:36:49 +0900 Subject: [PATCH 30/70] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=9D=98=20=EC=8B=9C=EA=B0=84=ED=91=9C=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=EC=97=90=EC=84=9C=20CORS=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EA=B0=80=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/settings.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/core/settings.py b/app/core/settings.py index eb1b854..623b7bc 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -93,7 +93,14 @@ for port in ("", ":3000", ":5173", ":5174") ] CORS_ALLOW_CREDENTIALS = True -CORS_ALLOW_HEADERS = [*corsheaders.defaults.default_headers, "accept-encoding", "origin", "x-csrftoken"] +CORS_ALLOW_HEADERS = [ + *corsheaders.defaults.default_headers, + "accept-encoding", + "origin", + "x-csrftoken", + "if-match", +] +CORS_EXPOSE_HEADERS = ["ETag"] SECURE_CROSS_ORIGIN_OPENER_POLICY = None if DEBUG else "same-origin" # Application definition From 7e1c63a57c8f517fa4fa80882e3e843593b31bbd Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 17:33:25 +0900 Subject: [PATCH 31/70] =?UTF-8?q?fix:=20=EA=B0=9C=EC=9D=B8=20=ED=9B=84?= =?UTF-8?q?=EC=9B=90=20=ED=8B=B0=EC=BC=93=20=EC=A0=95=EC=B1=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/shop/patron.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/shop/patron.py b/app/shop/patron.py index 248bfbb..f78f6ea 100644 --- a/app/shop/patron.py +++ b/app/shop/patron.py @@ -70,7 +70,6 @@ class PatronViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): OrderProductRelation.objects.filter_active().filter( order_id=OuterRef("id"), product__donation_allowed=True, - donation_price__gt=0, status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, ) ), From 53c43f7cee03eb21394d35050cebc597b086ca9e Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 17:33:44 +0900 Subject: [PATCH 32/70] =?UTF-8?q?refactor:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=ED=95=9C=20=EA=B3=B3=EC=97=90=20=EB=AA=A8=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/const/shop_error_messages.py | 6 ++++++ app/shop/payment_history/services.py | 10 ++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/core/const/shop_error_messages.py b/app/core/const/shop_error_messages.py index 033bf6e..90f908e 100644 --- a/app/core/const/shop_error_messages.py +++ b/app/core/const/shop_error_messages.py @@ -62,6 +62,12 @@ class CartNotOrderableErrorMessages: TICKET_INFO_REQUIRED = "참가자 정보가 입력되지 않은 티켓이 있습니다. 모든 티켓의 참가자 정보를 입력해주세요." +class FreeCheckoutErrorMessages: + PRICE_NOT_ZERO = "무료 주문은 결제 준비 금액이 0원이어야 합니다." + TARGET_NOT_FOUND = "무료 주문 대상을 찾을 수 없습니다." + ILLEGAL_STATUS_TRANSITION = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다." + + class NotRefundableErrorMessages: ONE_OF_PRODUCT_IS_USED = "주문 중 이미 사용한 상품이 존재합니다. 개별 환불을 진행해주세요." ONE_OF_PRODUCT_IS_USED_TRY_AFTER_CHANGING_STATUS = ( diff --git a/app/shop/payment_history/services.py b/app/shop/payment_history/services.py index 357ff79..da1f5d4 100644 --- a/app/shop/payment_history/services.py +++ b/app/shop/payment_history/services.py @@ -2,6 +2,7 @@ from types import SimpleNamespace +from core.const.shop_error_messages import FreeCheckoutErrorMessages from django.db import transaction from rest_framework import serializers from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo @@ -14,9 +15,6 @@ ProductOrderableCheckSerializer, ) -FREE_CHECKOUT_PRICE_ERROR = "무료 주문은 결제 준비 금액이 0원이어야 합니다." -FREE_CHECKOUT_TARGET_ERROR = "무료 주문 대상을 찾을 수 없습니다." -FREE_CHECKOUT_TRANSITION_ERROR = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다." _LOCKED_OPTIONS_ATTR = "_locked_checkout_options" _LOCKED_TICKET_INFO_ATTR = "_locked_checkout_ticket_info" _MISSING = object() @@ -33,9 +31,9 @@ def complete_free_checkout(cart_or_order: Order | SingleProductCart) -> Order: order = _lock_or_promote_order(cart_or_order) if order.prepared_price != 0: - raise serializers.ValidationError(FREE_CHECKOUT_PRICE_ERROR) + raise serializers.ValidationError(FreeCheckoutErrorMessages.PRICE_NOT_ZERO) if not is_legal_payment_status_transition(order.current_status, PaymentHistoryStatus.completed): - raise serializers.ValidationError(FREE_CHECKOUT_TRANSITION_ERROR) + raise serializers.ValidationError(FreeCheckoutErrorMessages.ILLEGAL_STATUS_TRANSITION) product_rels = lock_and_revalidate_checkout_order(order, validation_mode) @@ -60,7 +58,7 @@ def _lock_or_promote_order(cart_or_order: Order | SingleProductCart) -> Order: return cart.to_order() if order := Order.objects.select_for_update().filter_active().filter(id=cart_or_order.id).first(): return order - raise serializers.ValidationError(FREE_CHECKOUT_TARGET_ERROR) + raise serializers.ValidationError(FreeCheckoutErrorMessages.TARGET_NOT_FOUND) return Order.objects.select_for_update().filter_active().get(id=cart_or_order.id) From 73d0b06ee63003324446cddd82b4aae045bdd2e3 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 17:34:04 +0900 Subject: [PATCH 33/70] =?UTF-8?q?chore:=20getattr=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20attribute=EB=A5=BC=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/shop/order/views/carts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/shop/order/views/carts.py b/app/shop/order/views/carts.py index bfa1d19..88105c3 100644 --- a/app/shop/order/views/carts.py +++ b/app/shop/order/views/carts.py @@ -80,7 +80,7 @@ class CartProductViewSet(mixins.CreateModelMixin, mixins.DestroyModelMixin, view def get_queryset(self) -> QuerySet[OrderProductRelation]: queryset = self._base_queryset() - if getattr(self, "action", None) == "destroy": + if self.action == "destroy": return queryset.select_for_update(of=("self",)) return queryset From b2923a2ee515bbd9107bd7db229d0b522aa39070 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 17:34:16 +0900 Subject: [PATCH 34/70] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=95=EC=B1=85=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/shop/test/patron_api_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/shop/test/patron_api_test.py b/app/shop/test/patron_api_test.py index e8978eb..23e7524 100644 --- a/app/shop/test/patron_api_test.py +++ b/app/shop/test/patron_api_test.py @@ -16,7 +16,8 @@ def test_patron_list_includes_only_orders_with_donation_product(anon_client, cus @pytest.mark.django_db -def test_patron_list_excludes_zero_won_donation_capable_order(anon_client, order_factory): +def test_patron_list_includes_zero_won_donation_capable_order(anon_client, order_factory): + # 후원 금액이 0원이어도 후원 가능 상품을 구매하기만 하면 후원자 목록에 노출되어야 한다. order = order_factory(status="completed", donation=0, is_ticket=False, product_price=0) product = order.products.get().product product.donation_allowed = True @@ -25,7 +26,7 @@ def test_patron_list_excludes_zero_won_donation_capable_order(anon_client, order response = PatronApi(http_client=anon_client).list() assert response.status_code == HTTP_200_OK - assert response.json() == [] + assert response.json() == [{"name": "홍길동", "contribution_message": ""}] @pytest.mark.django_db From 7b4f40f1e86424b93e6140b3159c63378fd73512 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 17:41:04 +0900 Subject: [PATCH 35/70] =?UTF-8?q?fix:=20deadlock=20=EB=B0=9C=EC=83=9D=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=EC=84=B1=EC=9D=84=20=EB=82=AE=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/shop/payment_history/services.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/shop/payment_history/services.py b/app/shop/payment_history/services.py index da1f5d4..745bbe9 100644 --- a/app/shop/payment_history/services.py +++ b/app/shop/payment_history/services.py @@ -83,7 +83,9 @@ def lock_and_revalidate_checkout_order( def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]: product_rels = list( - OrderProductRelation.objects.select_for_update() + # of=("self",) — 조인한 Product/Category 까지 잠그지 않도록 제한. Product 는 아래에서 id 순으로 잠근다. + # (기본 FOR UPDATE 는 조인 테이블을 OPR.id 순으로 잠가, 상품을 공유하는 서로 다른 주문 간 락 순서가 엇갈려 데드락이 날 수 있음.) + OrderProductRelation.objects.select_for_update(of=("self",)) .filter_active() .filter(order=order) .select_related("product", "product__category") @@ -116,7 +118,10 @@ def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]: .order_by("id") ) ticket_infos = list( - TicketInfo.objects.select_for_update().filter_active().filter(order_product_relation_id__in=product_rel_ids) + TicketInfo.objects.select_for_update() + .filter_active() + .filter(order_product_relation_id__in=product_rel_ids) + .order_by("id") ) option_rels_by_product_rel_id: dict[int, list[OrderProductOptionRelation]] = {} From 0f96f622ae4ce3486d6d18a4de967d61715fd221 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 21:00:17 +0900 Subject: [PATCH 36/70] =?UTF-8?q?fix:=20pyconkr=20mcp=EA=B0=80=20421=20Mis?= =?UTF-8?q?directed=20Request=EB=A5=BC=20=EC=9D=BC=EC=9C=BC=ED=82=A4?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcp_app/__main__.py | 1 + pyproject.toml | 2 +- uv.lock | 14 +++++++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/mcp_app/__main__.py b/mcp_app/__main__.py index 4957f5d..3002fb2 100644 --- a/mcp_app/__main__.py +++ b/mcp_app/__main__.py @@ -7,4 +7,5 @@ host=config.HOST, port=config.PORT, path=config.PATH, + host_origin_protection=False, ) diff --git a/pyproject.toml b/pyproject.toml index 9f62f02..67946d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ [dependency-groups] mcp = [ - "fastmcp>=3.4.2", + "fastmcp>=3.4.4", "playwright>=1.49.0", ] dev = [ diff --git a/uv.lock b/uv.lock index 7323f25..580c29f 100644 --- a/uv.lock +++ b/uv.lock @@ -280,7 +280,7 @@ dev = [ { name = "pytest-socket", specifier = ">=0.8.0" }, ] mcp = [ - { name = "fastmcp", specifier = ">=3.4.2" }, + { name = "fastmcp", specifier = ">=3.4.4" }, { name = "playwright", specifier = ">=1.49.0" }, ] @@ -946,19 +946,19 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.4.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/92/cf0e921675af66534eb6dfffffa1871b5062f01650bcea63b62005a12f50/fastmcp-3.4.3.tar.gz", hash = "sha256:31e212ae9ff223876c6d7af2082e73f90bc692460e7a054568dfa88719f075ff", size = 28789252, upload-time = "2026-07-05T23:27:55.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/79/64000c2792743877baae152499a951c06a9c14b793309ac4ac06135f1ae7/fastmcp-3.4.3-py3-none-any.whl", hash = "sha256:bdf15277800586a033a6e1e95d20301cd508aaa872df3d508c956a2195de6c72", size = 8019, upload-time = "2026-07-05T23:27:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "platformdirs" }, @@ -968,9 +968,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/fa/500f6362016c34fba01c6412bbfd53435dc75900abd1266d88e111e2e9c2/fastmcp_slim-3.4.3.tar.gz", hash = "sha256:e3e12ac1e87c5195fa59a1c3cd2d31b487a463c269089fb4020b7571cbb57b29", size = 588086, upload-time = "2026-07-05T23:27:33.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/4f/66765dfe4fe65b45e134e54ca46118d93189fb10cd8ecb635cf504b68552/fastmcp_slim-3.4.3-py3-none-any.whl", hash = "sha256:21ad50465494edb141eed76b177dc788e00e705a5df09b53a31efebeb4432a95", size = 761455, upload-time = "2026-07-05T23:27:32.547Z" }, + { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, ] [package.optional-dependencies] From d61522f72b0446b73afdaeea37f41939bd68f82b Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 12 Jul 2026 21:58:44 +0900 Subject: [PATCH 37/70] =?UTF-8?q?feat:=20room=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20order=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/event/presentation/serializers.py | 3 ++- app/event/presentation/test/api_test.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index 97f1c0c..be7d5d7 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -61,10 +61,11 @@ class Meta: class RoomScheduleSerializer(serializers.ModelSerializer): room_name = serializers.CharField(source="room.name", read_only=True) + room_order = serializers.IntegerField(source="room.order", read_only=True) class Meta: model = RoomSchedule - fields = ("id", "room_name", "start_at", "end_at") + fields = ("id", "room_name", "room_order", "start_at", "end_at") class CallForPresentationScheduleSerializer(serializers.ModelSerializer): diff --git a/app/event/presentation/test/api_test.py b/app/event/presentation/test/api_test.py index d88793e..e19d0c5 100644 --- a/app/event/presentation/test/api_test.py +++ b/app/event/presentation/test/api_test.py @@ -16,7 +16,13 @@ def test_presentation_api(api_client: APIClient, create_presentation_set: Presen url = reverse("v1:presentation-list") response = api_client.get(url) assert response.status_code == http.HTTPStatus.OK - assert len(response.json()) > 0 + response_data = response.json() + assert len(response_data) > 0 + + # room_schedules 는 방 정렬(시간표 열 순서)을 위해 Room.order 를 포함한다. + room_schedule = response_data[0]["room_schedules"][0] + assert room_schedule["room_name"] == create_presentation_set.room.name + assert room_schedule["room_order"] == create_presentation_set.room.order @pytest.mark.django_db From 000eeb8256be35a834b5212f3dc47343a8975412 Mon Sep 17 00:00:00 2001 From: earthyoung Date: Fri, 17 Jul 2026 16:59:06 +0900 Subject: [PATCH 38/70] =?UTF-8?q?fix:=20POST=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=8B=9C=20=ED=95=84=EB=93=9C=EB=A5=BC=20presentation=20->=20p?= =?UTF-8?q?resentation=5Fid=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/event/presentation/serializers.py | 5 ++- .../presentation/test/bookmark_api_test.py | 32 +++++++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index 97f1c0c..c3aa291 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -21,7 +21,10 @@ def fail(self, key, **kwargs): class PresentationBookmarkRequestSerializer(serializers.Serializer): - presentation = NotFoundPrimaryKeyRelatedField(queryset=Presentation.objects.filter(deleted_at__isnull=True)) + presentation_id = NotFoundPrimaryKeyRelatedField( + queryset=Presentation.objects.filter(deleted_at__isnull=True), + source="presentation", + ) def create(self, validated_data: Any) -> tuple[PresentationBookmark, bool]: validated_data["user"] = self.context["request"].user diff --git a/app/event/presentation/test/bookmark_api_test.py b/app/event/presentation/test/bookmark_api_test.py index fda8a48..6ef3f11 100644 --- a/app/event/presentation/test/bookmark_api_test.py +++ b/app/event/presentation/test/bookmark_api_test.py @@ -219,7 +219,7 @@ def test_creates_bookmark_and_returns_201( """ response = authed_client.post( list_url(event.id), - data={"presentation": str(presentation.id)}, + data={"presentation_id": str(presentation.id)}, format="json", ) @@ -235,7 +235,7 @@ def test_sets_event_from_presentation( 북마크 생성 시 presentation의 type.event로부터 event를 자동 설정하는지 검증합니다. 프론트는 POST 시 event를 명시적으로 보내지 않으므로, 서버가 presentation에서 파생해야 합니다. """ - authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json") bookmark = PresentationBookmark.objects.get(user=user, presentation=presentation) assert bookmark.presentation.type.event.id == event.id @@ -253,7 +253,7 @@ def test_duplicate_bookmark_returns_200_idempotent( response = authed_client.post( list_url(event.id), - data={"presentation": str(presentation.id)}, + data={"presentation_id": str(presentation.id)}, format="json", ) @@ -269,7 +269,7 @@ def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, ev """ response = authed_client.post( list_url(event.id), - data={"presentation": str(uuid.uuid4())}, + data={"presentation_id": str(uuid.uuid4())}, format="json", ) @@ -288,7 +288,7 @@ def test_soft_deleted_presentation_returns_404( response = authed_client.post( list_url(event.id), - data={"presentation": str(presentation.id)}, + data={"presentation_id": str(presentation.id)}, format="json", ) @@ -302,7 +302,7 @@ def test_invalid_presentation_id_format_returns_400(self, authed_client: APIClie """ response = authed_client.post( list_url(event.id), - data={"presentation": "not-a-uuid"}, + data={"presentation_id": "not-a-uuid"}, format="json", ) @@ -326,7 +326,7 @@ def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event, """ response = anon_client.post( list_url(event.id), - data={"presentation": str(presentation.id)}, + data={"presentation_id": str(presentation.id)}, format="json", ) @@ -345,8 +345,8 @@ def test_allows_overlapping_time_sessions( 시간이 겹치는 세션들도 모두 북마크할 수 있는지 검증합니다. UX 확정 사항: 겹침 경고는 프론트가 처리하고, 서버는 시간대 충돌을 검사하지 않습니다. """ - resp1 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") - resp2 = authed_client.post(list_url(event.id), data={"presentation": str(presentation_2.id)}, format="json") + resp1 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation_2.id)}, format="json") assert resp1.status_code == http.HTTPStatus.CREATED assert resp2.status_code == http.HTTPStatus.CREATED @@ -359,7 +359,7 @@ def test_nonexistent_event_returns_404(self, authed_client: APIClient, presentat """ response = authed_client.post( list_url(uuid.uuid4()), - data={"presentation": str(presentation.id)}, + data={"presentation_id": str(presentation.id)}, format="json", ) @@ -492,7 +492,7 @@ def test_add_remove_re_add_flow( 각 단계에서 GET으로 목록을 조회해 상태가 올바른지 확인합니다. """ # 1단계: 담기 - resp = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + resp = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json") assert resp.status_code == http.HTTPStatus.CREATED # GET으로 확인: 1개 @@ -508,7 +508,7 @@ def test_add_remove_re_add_flow( assert len(resp.json()["presentation_ids"]) == 0 # 3단계: 되돌리기 (다시 POST) - resp = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + resp = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json") assert resp.status_code == http.HTTPStatus.CREATED # GET으로 확인: 다시 1개 @@ -540,8 +540,8 @@ def test_rapid_double_post_is_safe( 같은 세션에 대해 POST가 빠르게 2번 연속 호출되어도 두 번째 요청이 에러 없이 200을 반환하고 중복 레코드가 생기지 않는지 검증합니다. """ - resp1 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") - resp2 = authed_client.post(list_url(event.id), data={"presentation": str(presentation.id)}, format="json") + resp1 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json") + resp2 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json") assert resp1.status_code == http.HTTPStatus.CREATED assert resp2.status_code == http.HTTPStatus.OK @@ -584,7 +584,7 @@ def test_404_follows_error_envelope(self, authed_client: APIClient, event: Event """ response = authed_client.post( list_url(event.id), - data={"presentation": str(uuid.uuid4())}, + data={"presentation_id": str(uuid.uuid4())}, format="json", ) @@ -601,7 +601,7 @@ def test_400_validation_error_follows_envelope(self, authed_client: APIClient, e """ response = authed_client.post( list_url(event.id), - data={"presentation": "invalid"}, + data={"presentation_id": "invalid"}, format="json", ) From 5988049d49b5b42466dcfbd8ebc78734c7c7c29a Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:25:19 +0900 Subject: [PATCH 39/70] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=8A=B9=EC=9D=B8=20=EC=8B=9C=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=20=EC=96=B8=EC=96=B4=EB=A1=9C=20=EC=9E=98=EB=AA=BB?= =?UTF-8?q?=EB=90=9C=20=EB=B2=88=EC=97=AD=20=EC=BB=AC=EB=9F=BC=EC=9D=B4=20?= =?UTF-8?q?=EB=8D=AE=EC=96=B4=EC=8D=A8=EC=A7=80=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modeltranslation의 원본 필드(nickname, title, summary, description, biography)는 현재 활성 언어로 라우팅되는 descriptor다. 수정 요청 시 저장되는 diff에는 원본 필드와 언어별 컬럼이 함께 담기는데, apply_diff_to_model이 원본 필드까지 setattr 하면서 '승인 시점의 언어'로 값이 적용되고 있었다. 어드민 클라이언트는 language를 넘기지 않아 Accept-Language가 항상 ko이고 (useAdminAPI.ts의 useBackendAdminClient), 참가자 포탈은 사용자가 선택한 언어를 보낸다. 그래서 영문 UI로 수정을 요청한 참가자의 심사를 승인하면 영문 값이 한글 컬럼을 덮어썼다. - apply_diff_to_model이 modeltranslation 원본 필드를 건너뛰고 언어별 실제 컬럼 (nickname_ko/nickname_en 등)만 적용하도록 수정 - 승인 처리를 transaction.atomic으로 묶어, 여러 모델에 걸친 수정이 중간에 실패했을 때 일부만 반영된 채 심사 상태가 requested로 남는 문제 방지 diff 스냅샷 자체는 그대로 두어 심사 미리보기(fake_modified_instance) 동작에는 영향이 없다. Signed-off-by: JaehyuckSa --- .../serializers/modification_audit.py | 10 +- app/core/test/django_orm_test.py | 46 ++++++ app/core/util/django_orm.py | 19 +++ .../test/modification_audit_approval_test.py | 137 ++++++++++++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 app/core/test/django_orm_test.py create mode 100644 app/participant_portal_api/test/modification_audit_approval_test.py diff --git a/app/admin_api/serializers/modification_audit.py b/app/admin_api/serializers/modification_audit.py index 3fb9605..0a174de 100644 --- a/app/admin_api/serializers/modification_audit.py +++ b/app/admin_api/serializers/modification_audit.py @@ -2,6 +2,7 @@ import unicodedata from core.serializer.json_schema_serializer import JsonSchemaSerializer +from django.db import transaction from event.presentation.models import Presentation, PresentationSpeaker from participant_portal_api.models import ModificationAudit, ModificationAuditComment from rest_framework import serializers @@ -63,9 +64,12 @@ def validate(self, attrs: dict) -> dict: def save(self, **kwargs: dict) -> ModificationAudit: instance: ModificationAudit = self.instance - instance.status = ModificationAudit.Status.APPROVED - instance.apply_modification() - instance.save() + # 수정 요청은 여러 모델(예: 발표 + 발표자)에 걸칠 수 있다. 중간에 실패하면 일부만 반영된 채 + # 심사 상태는 requested로 남으므로, 적용과 상태 변경을 한 트랜잭션으로 묶는다. + with transaction.atomic(): + instance.status = ModificationAudit.Status.APPROVED + instance.apply_modification() + instance.save() return instance diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py new file mode 100644 index 0000000..6d59bf9 --- /dev/null +++ b/app/core/test/django_orm_test.py @@ -0,0 +1,46 @@ +"""core.util.django_orm 의 diff 적용 헬퍼 테스트.""" + +import pytest +from core.util.django_orm import apply_diff_to_model, model_to_identifier, translated_original_field_names +from django.utils.translation import override +from event.presentation.models import Presentation +from model_bakery import baker +from user.models import UserExt + + +def test_translated_original_field_names_returns_registered_fields(): + """modeltranslation 에 등록된 원본 필드명을 돌려준다.""" + assert translated_original_field_names(UserExt) == {"nickname"} + assert translated_original_field_names(Presentation) == {"title", "summary", "description"} + + +def test_translated_original_field_names_is_empty_for_unregistered_model(db): + """번역 등록이 없는 모델은 빈 집합을 돌려준다.""" + from file.models import PublicFile + + assert translated_original_field_names(PublicFile) == set() + + +@pytest.mark.parametrize("active_language", ["ko", "en"], ids=["applied_in_ko", "applied_in_en"]) +def test_apply_diff_to_model_ignores_translated_original_field(db, active_language): + """원본 필드(title)는 활성 언어에 따라 라우팅되므로 무시하고, 언어별 컬럼만 적용해야 한다.""" + presentation = baker.make(Presentation, title_ko="한글제목", title_en="EnglishTitle") + diff = {model_to_identifier(presentation): {"title": "NewEnglishTitle", "title_en": "NewEnglishTitle"}} + + with override(active_language): + apply_diff_to_model(diff) + + presentation.refresh_from_db() + assert presentation.title_en == "NewEnglishTitle" + assert presentation.title_ko == "한글제목" + + +def test_apply_diff_to_model_applies_plain_field(db): + """번역 대상이 아닌 필드는 그대로 적용한다.""" + presentation = baker.make(Presentation, slideshow_url="https://example.com/old") + diff = {model_to_identifier(presentation): {"slideshow_url": "https://example.com/new"}} + + apply_diff_to_model(diff) + + presentation.refresh_from_db() + assert presentation.slideshow_url == "https://example.com/new" diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 93a2d5e..02e17c8 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -12,6 +12,8 @@ from django.db.models.fields.files import FieldFile from django.forms import model_to_dict from django.utils.functional import Promise +from modeltranslation.translator import NotRegistered +from modeltranslation.translator import translator as modeltranslation_translator def arbitrary_value_to_basic_type(value: typing.Any) -> str | int | float | bool | None: @@ -251,6 +253,15 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s return resolved_models[key] +def translated_original_field_names(model_class: type[models.Model]) -> set[str]: + """django-modeltranslation이 번역 대상으로 등록한 '원본' 필드명(예: nickname, title) 집합.""" + try: + # TranslationOptions.fields는 버전에 따라 tuple 또는 dict이므로 set()으로 통일해 원본 필드명만 얻는다. + return set(modeltranslation_translator.get_options_for_model(model_class).fields) + except NotRegistered: + return set() + + def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[models.Model]: result_instances: list[models.Model] = [] @@ -258,8 +269,16 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m if not (model_instance := identifier_to_model(model_identifier)): raise ValueError(f"Model class not found for identifier: {model_identifier}") + # modeltranslation의 원본 필드(nickname 등)는 현재 활성 언어로 라우팅되는 descriptor다. + # diff를 만든 시점(요청자의 언어)과 적용하는 시점(승인자의 언어)이 다르면 엉뚱한 언어 컬럼을 + # 덮어쓰므로, 적용은 언어별 실제 컬럼(nickname_ko/nickname_en)으로만 수행한다. + skip_fields = translated_original_field_names(type(model_instance)) + # Apply the data to the model instance for field_name, value in model_data.items(): + if field_name in skip_fields: + continue + if is_identifier(value): # If the value is a model identifier, resolve it to a model instance if not (related_model_instance := identifier_to_model(value)): diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py new file mode 100644 index 0000000..b510805 --- /dev/null +++ b/app/participant_portal_api/test/modification_audit_approval_test.py @@ -0,0 +1,137 @@ +"""수정 심사 승인(approve) 시 변경 내역이 올바른 언어 컬럼에 반영되는지 검증한다. + +modeltranslation 의 원본 필드(nickname 등)는 '현재 활성 언어'로 라우팅되는 descriptor 다. +수정 요청을 만든 시점(요청자의 언어)과 승인 시점(승인자의 언어)이 다르면 +반대쪽 언어 컬럼을 덮어쓰던 버그가 있었다. +""" + +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest +from core.util.thread_local import thread_local +from django.core.files.base import ContentFile +from django.utils.translation import override +from file.models import PublicFile +from participant_portal_api.models import ModificationAudit +from participant_portal_api.serializers.user import UserPortalSerializer +from rest_framework import status +from rest_framework.test import APIClient +from user.models import UserExt + +APPROVE_URL = "/v1/admin-api/participant_portal_api/modificationaudit/{audit_id}/approve/" + + +@contextmanager +def _acting_as(user: UserExt): + """created_by 등은 thread-local 에서 실행자를 읽으므로, 요청자를 명시적으로 주입한다.""" + thread_local.current_request = SimpleNamespace(user=user) + try: + yield + finally: + if hasattr(thread_local, "current_request"): + del thread_local.current_request + + +@pytest.fixture +def requester(db) -> UserExt: + return UserExt.objects.create_user( + username="requester", email="requester@example.com", nickname_ko="한글이름", nickname_en="EnglishName" + ) + + +@pytest.fixture +def superuser(db) -> UserExt: + return UserExt.objects.create_superuser(username="admin", email="admin@example.com") + + +@pytest.fixture +def admin_client(superuser) -> APIClient: + client = APIClient() + client.force_authenticate(user=superuser) + return client + + +def _request_profile_modification(requester: UserExt, payload: dict, language: str) -> ModificationAudit: + """참가자 포탈에서 주어진 언어로 프로필 수정을 요청하고, 생성된 심사 건을 돌려준다.""" + with override(language), _acting_as(requester): + serializer = UserPortalSerializer(instance=requester, data=payload, partial=True) + assert serializer.is_valid(), serializer.errors + serializer.save() + + return ModificationAudit.objects.get(status=ModificationAudit.Status.REQUESTED) + + +def test_approve_en_nickname_request_under_ko_locale_keeps_ko_nickname(requester, admin_client): + """영문 UI 에서 영문 닉네임만 고친 요청을, 어드민이 한국어 로케일로 승인해도 한글 닉네임은 그대로여야 한다.""" + audit = _request_profile_modification(requester, {"nickname_en": "NewEnglish"}, language="en") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="ko" + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.nickname_en == "NewEnglish" + assert requester.nickname_ko == "한글이름" + + +def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester, admin_client): + """한국어 UI 에서 한글 닉네임만 고친 요청을, 어드민이 영문 로케일로 승인해도 영문 닉네임은 그대로여야 한다.""" + audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="en" + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.nickname_ko == "새한글이름" + assert requester.nickname_en == "EnglishName" + + +@pytest.mark.parametrize("approval_language", ["ko", "en"], ids=["approved_in_ko", "approved_in_en"]) +def test_approve_applies_requested_nickname_regardless_of_approval_locale(requester, admin_client, approval_language): + """요청한 언어의 닉네임 변경 자체는 승인자의 로케일과 무관하게 반영되어야 한다.""" + audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), + data={}, + format="json", + HTTP_ACCEPT_LANGUAGE=approval_language, + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.nickname_ko == "새한글이름" + + +def test_approve_applies_non_translated_field(requester, admin_client): + """번역 대상이 아닌 필드(프로필 이미지)는 기존대로 정상 반영되어야 한다.""" + with _acting_as(requester): + image = PublicFile.objects.create( + file=ContentFile(b"image-bytes", name="profile.png"), mimetype="image/png", hash="hash", size=11 + ) + + audit = _request_profile_modification(requester, {"image": image.pk}, language="ko") + + response = admin_client.patch( + APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="ko" + ) + + assert response.status_code == status.HTTP_200_OK, response.data + requester.refresh_from_db() + assert requester.image_id == image.pk + + +def test_approve_marks_audit_as_approved(requester, admin_client): + """승인이 끝나면 심사 상태가 approved 로 바뀌어야 한다.""" + audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") + + response = admin_client.patch(APPROVE_URL.format(audit_id=audit.id), data={}, format="json") + + assert response.status_code == status.HTTP_200_OK, response.data + assert response.data["status"] == ModificationAudit.Status.APPROVED + audit.refresh_from_db() + assert audit.status == ModificationAudit.Status.APPROVED From 62ad34e7394205a960788e9afe7827b255e4a731 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:39:28 +0900 Subject: [PATCH 40/70] =?UTF-8?q?change:=20=EC=88=98=EC=A0=95=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=8A=B9=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8/?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자명한 주석 제거, 핵심 근거 주석만 한 줄로 축약 - participant_portal_api/test에 누락된 __init__.py 추가 - 테스트 내 지역 import를 상위로 이동, 불필요한 db fixture 제거 Signed-off-by: JaehyuckSa --- app/admin_api/serializers/modification_audit.py | 2 -- app/core/test/django_orm_test.py | 5 ++--- app/core/util/django_orm.py | 5 +---- app/participant_portal_api/test/__init__.py | 0 .../test/modification_audit_approval_test.py | 7 +------ 5 files changed, 4 insertions(+), 15 deletions(-) create mode 100644 app/participant_portal_api/test/__init__.py diff --git a/app/admin_api/serializers/modification_audit.py b/app/admin_api/serializers/modification_audit.py index 0a174de..978a96e 100644 --- a/app/admin_api/serializers/modification_audit.py +++ b/app/admin_api/serializers/modification_audit.py @@ -64,8 +64,6 @@ def validate(self, attrs: dict) -> dict: def save(self, **kwargs: dict) -> ModificationAudit: instance: ModificationAudit = self.instance - # 수정 요청은 여러 모델(예: 발표 + 발표자)에 걸칠 수 있다. 중간에 실패하면 일부만 반영된 채 - # 심사 상태는 requested로 남으므로, 적용과 상태 변경을 한 트랜잭션으로 묶는다. with transaction.atomic(): instance.status = ModificationAudit.Status.APPROVED instance.apply_modification() diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py index 6d59bf9..c593f0b 100644 --- a/app/core/test/django_orm_test.py +++ b/app/core/test/django_orm_test.py @@ -4,6 +4,7 @@ from core.util.django_orm import apply_diff_to_model, model_to_identifier, translated_original_field_names from django.utils.translation import override from event.presentation.models import Presentation +from file.models import PublicFile from model_bakery import baker from user.models import UserExt @@ -14,10 +15,8 @@ def test_translated_original_field_names_returns_registered_fields(): assert translated_original_field_names(Presentation) == {"title", "summary", "description"} -def test_translated_original_field_names_is_empty_for_unregistered_model(db): +def test_translated_original_field_names_is_empty_for_unregistered_model(): """번역 등록이 없는 모델은 빈 집합을 돌려준다.""" - from file.models import PublicFile - assert translated_original_field_names(PublicFile) == set() diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 02e17c8..99f93f9 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -256,7 +256,6 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s def translated_original_field_names(model_class: type[models.Model]) -> set[str]: """django-modeltranslation이 번역 대상으로 등록한 '원본' 필드명(예: nickname, title) 집합.""" try: - # TranslationOptions.fields는 버전에 따라 tuple 또는 dict이므로 set()으로 통일해 원본 필드명만 얻는다. return set(modeltranslation_translator.get_options_for_model(model_class).fields) except NotRegistered: return set() @@ -269,9 +268,7 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m if not (model_instance := identifier_to_model(model_identifier)): raise ValueError(f"Model class not found for identifier: {model_identifier}") - # modeltranslation의 원본 필드(nickname 등)는 현재 활성 언어로 라우팅되는 descriptor다. - # diff를 만든 시점(요청자의 언어)과 적용하는 시점(승인자의 언어)이 다르면 엉뚱한 언어 컬럼을 - # 덮어쓰므로, 적용은 언어별 실제 컬럼(nickname_ko/nickname_en)으로만 수행한다. + # 원본 필드는 활성 언어로 라우팅되는 descriptor라 적용 시점의 언어에 좌우되므로, 언어별 컬럼만 적용한다. skip_fields = translated_original_field_names(type(model_instance)) # Apply the data to the model instance diff --git a/app/participant_portal_api/test/__init__.py b/app/participant_portal_api/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py index b510805..83cf8a3 100644 --- a/app/participant_portal_api/test/modification_audit_approval_test.py +++ b/app/participant_portal_api/test/modification_audit_approval_test.py @@ -1,9 +1,4 @@ -"""수정 심사 승인(approve) 시 변경 내역이 올바른 언어 컬럼에 반영되는지 검증한다. - -modeltranslation 의 원본 필드(nickname 등)는 '현재 활성 언어'로 라우팅되는 descriptor 다. -수정 요청을 만든 시점(요청자의 언어)과 승인 시점(승인자의 언어)이 다르면 -반대쪽 언어 컬럼을 덮어쓰던 버그가 있었다. -""" +"""수정 심사 승인 시 변경 내역이 승인자의 언어와 무관하게 올바른 언어 컬럼에 반영되는지 검증한다.""" from contextlib import contextmanager from types import SimpleNamespace From ce6d2535f5139cd145e1cf5ec260edfabadc4050 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:45:28 +0900 Subject: [PATCH 41/70] =?UTF-8?q?change:=20=EC=88=98=EC=A0=95=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=8A=B9=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A4=91=EB=B3=B5/=EB=AC=B4=EC=9D=98?= =?UTF-8?q?=EB=AF=B8=ED=95=9C=20=EC=BC=80=EC=9D=B4=EC=8A=A4=EC=99=80=20doc?= =?UTF-8?q?string=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수정 전에도 통과하던(= 이 버그를 잡지 못하는) 테스트 6건 제거하고, 회귀 감지에 필요한 4건만 남긴다. - 헬퍼 반환값만 단언하던 테스트 2건 제거 - 승인 후 상태 전이/타 언어 무관 반영 등 기존 동작 테스트 4건 제거 - 테스트 및 헬퍼 docstring 전부 제거 Signed-off-by: JaehyuckSa --- app/core/test/django_orm_test.py | 19 +------ app/core/util/django_orm.py | 1 - .../test/modification_audit_approval_test.py | 55 ------------------- 3 files changed, 1 insertion(+), 74 deletions(-) diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py index c593f0b..db00583 100644 --- a/app/core/test/django_orm_test.py +++ b/app/core/test/django_orm_test.py @@ -1,28 +1,12 @@ -"""core.util.django_orm 의 diff 적용 헬퍼 테스트.""" - import pytest -from core.util.django_orm import apply_diff_to_model, model_to_identifier, translated_original_field_names +from core.util.django_orm import apply_diff_to_model, model_to_identifier from django.utils.translation import override from event.presentation.models import Presentation -from file.models import PublicFile from model_bakery import baker -from user.models import UserExt - - -def test_translated_original_field_names_returns_registered_fields(): - """modeltranslation 에 등록된 원본 필드명을 돌려준다.""" - assert translated_original_field_names(UserExt) == {"nickname"} - assert translated_original_field_names(Presentation) == {"title", "summary", "description"} - - -def test_translated_original_field_names_is_empty_for_unregistered_model(): - """번역 등록이 없는 모델은 빈 집합을 돌려준다.""" - assert translated_original_field_names(PublicFile) == set() @pytest.mark.parametrize("active_language", ["ko", "en"], ids=["applied_in_ko", "applied_in_en"]) def test_apply_diff_to_model_ignores_translated_original_field(db, active_language): - """원본 필드(title)는 활성 언어에 따라 라우팅되므로 무시하고, 언어별 컬럼만 적용해야 한다.""" presentation = baker.make(Presentation, title_ko="한글제목", title_en="EnglishTitle") diff = {model_to_identifier(presentation): {"title": "NewEnglishTitle", "title_en": "NewEnglishTitle"}} @@ -35,7 +19,6 @@ def test_apply_diff_to_model_ignores_translated_original_field(db, active_langua def test_apply_diff_to_model_applies_plain_field(db): - """번역 대상이 아닌 필드는 그대로 적용한다.""" presentation = baker.make(Presentation, slideshow_url="https://example.com/old") diff = {model_to_identifier(presentation): {"slideshow_url": "https://example.com/new"}} diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 99f93f9..0198371 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -254,7 +254,6 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s def translated_original_field_names(model_class: type[models.Model]) -> set[str]: - """django-modeltranslation이 번역 대상으로 등록한 '원본' 필드명(예: nickname, title) 집합.""" try: return set(modeltranslation_translator.get_options_for_model(model_class).fields) except NotRegistered: diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py index 83cf8a3..4fc41c2 100644 --- a/app/participant_portal_api/test/modification_audit_approval_test.py +++ b/app/participant_portal_api/test/modification_audit_approval_test.py @@ -1,13 +1,9 @@ -"""수정 심사 승인 시 변경 내역이 승인자의 언어와 무관하게 올바른 언어 컬럼에 반영되는지 검증한다.""" - from contextlib import contextmanager from types import SimpleNamespace import pytest from core.util.thread_local import thread_local -from django.core.files.base import ContentFile from django.utils.translation import override -from file.models import PublicFile from participant_portal_api.models import ModificationAudit from participant_portal_api.serializers.user import UserPortalSerializer from rest_framework import status @@ -19,7 +15,6 @@ @contextmanager def _acting_as(user: UserExt): - """created_by 등은 thread-local 에서 실행자를 읽으므로, 요청자를 명시적으로 주입한다.""" thread_local.current_request = SimpleNamespace(user=user) try: yield @@ -48,7 +43,6 @@ def admin_client(superuser) -> APIClient: def _request_profile_modification(requester: UserExt, payload: dict, language: str) -> ModificationAudit: - """참가자 포탈에서 주어진 언어로 프로필 수정을 요청하고, 생성된 심사 건을 돌려준다.""" with override(language), _acting_as(requester): serializer = UserPortalSerializer(instance=requester, data=payload, partial=True) assert serializer.is_valid(), serializer.errors @@ -58,7 +52,6 @@ def _request_profile_modification(requester: UserExt, payload: dict, language: s def test_approve_en_nickname_request_under_ko_locale_keeps_ko_nickname(requester, admin_client): - """영문 UI 에서 영문 닉네임만 고친 요청을, 어드민이 한국어 로케일로 승인해도 한글 닉네임은 그대로여야 한다.""" audit = _request_profile_modification(requester, {"nickname_en": "NewEnglish"}, language="en") response = admin_client.patch( @@ -72,7 +65,6 @@ def test_approve_en_nickname_request_under_ko_locale_keeps_ko_nickname(requester def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester, admin_client): - """한국어 UI 에서 한글 닉네임만 고친 요청을, 어드민이 영문 로케일로 승인해도 영문 닉네임은 그대로여야 한다.""" audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") response = admin_client.patch( @@ -83,50 +75,3 @@ def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester requester.refresh_from_db() assert requester.nickname_ko == "새한글이름" assert requester.nickname_en == "EnglishName" - - -@pytest.mark.parametrize("approval_language", ["ko", "en"], ids=["approved_in_ko", "approved_in_en"]) -def test_approve_applies_requested_nickname_regardless_of_approval_locale(requester, admin_client, approval_language): - """요청한 언어의 닉네임 변경 자체는 승인자의 로케일과 무관하게 반영되어야 한다.""" - audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") - - response = admin_client.patch( - APPROVE_URL.format(audit_id=audit.id), - data={}, - format="json", - HTTP_ACCEPT_LANGUAGE=approval_language, - ) - - assert response.status_code == status.HTTP_200_OK, response.data - requester.refresh_from_db() - assert requester.nickname_ko == "새한글이름" - - -def test_approve_applies_non_translated_field(requester, admin_client): - """번역 대상이 아닌 필드(프로필 이미지)는 기존대로 정상 반영되어야 한다.""" - with _acting_as(requester): - image = PublicFile.objects.create( - file=ContentFile(b"image-bytes", name="profile.png"), mimetype="image/png", hash="hash", size=11 - ) - - audit = _request_profile_modification(requester, {"image": image.pk}, language="ko") - - response = admin_client.patch( - APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="ko" - ) - - assert response.status_code == status.HTTP_200_OK, response.data - requester.refresh_from_db() - assert requester.image_id == image.pk - - -def test_approve_marks_audit_as_approved(requester, admin_client): - """승인이 끝나면 심사 상태가 approved 로 바뀌어야 한다.""" - audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko") - - response = admin_client.patch(APPROVE_URL.format(audit_id=audit.id), data={}, format="json") - - assert response.status_code == status.HTTP_200_OK, response.data - assert response.data["status"] == ModificationAudit.Status.APPROVED - audit.refresh_from_db() - assert audit.status == ModificationAudit.Status.APPROVED From b159768fada205076b7d0430abcd9205317c37c2 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Sun, 26 Jul 2026 16:48:47 +0900 Subject: [PATCH 42/70] =?UTF-8?q?change:=20translated=5Foriginal=5Ffield?= =?UTF-8?q?=5Fnames=EB=A5=BC=20apply=5Fdiff=5Fto=5Fmodel=EC=97=90=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: JaehyuckSa --- app/core/util/django_orm.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 0198371..2af8691 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -253,13 +253,6 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s return resolved_models[key] -def translated_original_field_names(model_class: type[models.Model]) -> set[str]: - try: - return set(modeltranslation_translator.get_options_for_model(model_class).fields) - except NotRegistered: - return set() - - def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[models.Model]: result_instances: list[models.Model] = [] @@ -268,7 +261,10 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m raise ValueError(f"Model class not found for identifier: {model_identifier}") # 원본 필드는 활성 언어로 라우팅되는 descriptor라 적용 시점의 언어에 좌우되므로, 언어별 컬럼만 적용한다. - skip_fields = translated_original_field_names(type(model_instance)) + try: + skip_fields = set(modeltranslation_translator.get_options_for_model(type(model_instance)).fields) + except NotRegistered: + skip_fields = set() # Apply the data to the model instance for field_name, value in model_data.items(): From 64271f52ee9b489471d1158e8aed70dfd27b7c74 Mon Sep 17 00:00:00 2001 From: JaehyuckSa Date: Mon, 27 Jul 2026 13:36:18 +0900 Subject: [PATCH 43/70] =?UTF-8?q?feat:=20=EB=B0=9C=ED=91=9C=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=AF=B8=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 사진과 발표자 사진이 별도 필드라, 참가자 포탈에서 프로필 사진만 등록한 발표자는 발표 상세 페이지에 사진이 노출되지 않았음 - PresentationSpeaker.display_image 프로퍼티를 추가하고 공개 API 응답이 이를 사용하도록 변경. 발표자 이미지가 있으면 그대로 우선한다 - 프로필 이미지 조회로 인한 N+1을 막기 위해 speakers prefetch의 select_related에 user__image 추가 (쿼리 수 변화 없음) Signed-off-by: JaehyuckSa --- app/event/presentation/models.py | 7 +++- app/event/presentation/serializers.py | 2 +- app/event/presentation/test/api_test.py | 54 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index b8f52bf..e409d79 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -26,7 +26,7 @@ def get_all_nested_data(self): ), models.Prefetch( lookup="speakers", - queryset=PresentationSpeaker.objects.filter_active().select_related("user", "image"), + queryset=PresentationSpeaker.objects.filter_active().select_related("user__image", "image"), to_attr="_prefetched_active_speakers", ), models.Prefetch( @@ -159,6 +159,11 @@ class PresentationSpeaker(BaseAbstractModel): image = models.ForeignKey(PublicFile, on_delete=models.PROTECT, null=True, blank=True) biography = MarkdownField(blank=True, default="") + @property + def display_image(self) -> PublicFile | None: + """발표자 이미지를 따로 등록하지 않았다면 프로필 이미지로 대체한다.""" + return self.image or self.user.image + class CallForPresentationSchedule(BaseAbstractModel): presentation_type = models.ForeignKey(PresentationType, on_delete=models.PROTECT) diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index ecd0a42..de67b10 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -55,7 +55,7 @@ class Meta: class PresentationSpeakerSerializer(serializers.ModelSerializer): nickname = serializers.CharField(source="user.nickname", read_only=True) - image = serializers.FileField(source="image.file", read_only=True, allow_null=True) + image = serializers.FileField(source="display_image.file", read_only=True, allow_null=True) class Meta: model = PresentationSpeaker diff --git a/app/event/presentation/test/api_test.py b/app/event/presentation/test/api_test.py index e19d0c5..a31c0a3 100644 --- a/app/event/presentation/test/api_test.py +++ b/app/event/presentation/test/api_test.py @@ -7,6 +7,7 @@ from event.models import Event from event.presentation.models import Presentation, PresentationType from event.presentation.test.conftest import PresentationTestEntity +from file.models import PublicFile from rest_framework.test import APIClient from user.models.organization import Organization @@ -86,3 +87,56 @@ def test_presentation_defaults_to_latest_event(api_client: APIClient): response_data = response.json() assert len(response_data) == 1 assert response_data[0]["id"] == str(new_prst.id) + + +def _make_public_file(name: str) -> PublicFile: + return PublicFile.objects.create(file=f"public/{name}.png", mimetype="image/png", hash=name, size=0) + + +def _get_speaker_image(api_client: APIClient) -> str | None: + response = api_client.get(reverse("v1:presentation-list")) + assert response.status_code == http.HTTPStatus.OK + return response.json()[0]["speakers"][0]["image"] + + +@pytest.mark.django_db +def test_speaker_image_falls_back_to_profile_image( + api_client: APIClient, create_presentation_set: PresentationTestEntity +): + # Given: 발표자 이미지는 비어 있고 프로필 이미지만 등록된 발표자 + user = create_presentation_set.user + user.image = _make_public_file("profile") + user.save() + + # Then: 프로필 이미지가 대신 노출된다. + image_url = _get_speaker_image(api_client) + assert image_url is not None and image_url.endswith(user.image.file.url) + + +@pytest.mark.django_db +def test_speaker_image_takes_precedence_over_profile_image( + api_client: APIClient, create_presentation_set: PresentationTestEntity +): + # Given: 발표자 이미지와 프로필 이미지가 모두 등록된 발표자 + user = create_presentation_set.user + user.image = _make_public_file("profile") + user.save() + + speaker = create_presentation_set.presentation_speaker + speaker.image = _make_public_file("speaker") + speaker.save() + + # Then: 발표자 이미지가 우선한다. + image_url = _get_speaker_image(api_client) + assert image_url is not None and image_url.endswith(speaker.image.file.url) + + +@pytest.mark.django_db +def test_speaker_image_is_null_without_any_image( + api_client: APIClient, create_presentation_set: PresentationTestEntity +): + # Given: 발표자 이미지도 프로필 이미지도 없는 발표자 + assert create_presentation_set.presentation_speaker.image is None + assert create_presentation_set.user.image is None + + assert _get_speaker_image(api_client) is None From 55fc6f003899f2a042ea9899b1ab932e85ca0053 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 1 Aug 2026 13:07:09 +0900 Subject: [PATCH 44/70] =?UTF-8?q?fix:=20=EC=A3=BC=EB=AC=B8=20import=20?= =?UTF-8?q?=EC=8B=9C=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/test/shop/orders_api_test.py | 77 +++++++++++++++++++++- app/admin_api/views/shop/orders.py | 28 ++++---- app/core/test/fileutil_test.py | 38 +++++++++++ app/core/util/fileutil.py | 27 ++++++++ 4 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 app/core/test/fileutil_test.py create mode 100644 app/core/util/fileutil.py diff --git a/app/admin_api/test/shop/orders_api_test.py b/app/admin_api/test/shop/orders_api_test.py index abce19d..3975333 100644 --- a/app/admin_api/test/shop/orders_api_test.py +++ b/app/admin_api/test/shop/orders_api_test.py @@ -1,3 +1,4 @@ +from codecs import BOM_UTF8 from datetime import datetime, timezone from io import BytesIO @@ -246,6 +247,11 @@ def test_admin_import_template_returns_csv(api_client, ticket_product): response = OrdersAdminApi(http_client=api_client).import_template(product_id=str(ticket_product.id)) assert response.status_code == HTTP_200_OK assert "text/csv" in response.headers["Content-Type"] + # JSONRenderer 를 거치지 않은 그대로의 CSV 본문 + Excel 용 BOM 인지 확인. + assert response.content.startswith(BOM_UTF8) + assert response.content.decode("utf-8-sig").splitlines() == [ + "name,phone,email,organization,product_id,donation_price" + ] @pytest.mark.django_db @@ -260,12 +266,16 @@ def test_admin_import_template_returns_404_for_unknown_product(api_client): assert response.status_code == HTTP_404_NOT_FOUND -def _csv_file(rows: str) -> BytesIO: - csv_file = BytesIO(rows.encode("utf-8")) +def _csv_bytes(raw: bytes) -> BytesIO: + csv_file = BytesIO(raw) csv_file.name = "import.csv" return csv_file +def _csv_file(rows: str, encoding: str = "utf-8") -> BytesIO: + return _csv_bytes(rows.encode(encoding)) + + @pytest.mark.django_db def test_admin_import_csv_persists_paid_order_from_uploaded_row(api_client, customer_user, ticket_product): response = OrdersAdminApi(http_client=api_client).import_csv( @@ -280,6 +290,52 @@ def test_admin_import_csv_persists_paid_order_from_uploaded_row(api_client, cust assert opr.order.user == customer_user +@pytest.mark.django_db +def test_admin_import_csv_accepts_template_output_verbatim(api_client, customer_user, ticket_product): + """템플릿 → 작성 → 업로드 왕복. BOM 이 첫 컬럼명(name)에 섞여 들어가지 않아야 한다.""" + template = OrdersAdminApi(http_client=api_client).import_template(product_id=str(ticket_product.id)) + filled_csv = ( + template.content.decode("utf-8") + f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0\n" + ) + + response = OrdersAdminApi(http_client=api_client).import_csv(csv_file=_csv_file(filled_csv)) + + assert response.status_code == HTTP_201_CREATED + assert OrderProductRelation.objects.filter(product=ticket_product).exists() + + +@pytest.mark.django_db +def test_admin_import_csv_accepts_cp949_encoded_file(api_client, customer_user, ticket_product): + """한국어 Windows Excel 이 저장하는 CP949 파일도 읽어야 한다.""" + response = OrdersAdminApi(http_client=api_client).import_csv( + csv_file=_csv_file( + "name,phone,email,organization,product_id,donation_price\n" + f"홍길동,010-1234-5678,{customer_user.email},파이콘,{ticket_product.id},0\n", + encoding="cp949", + ) + ) + assert response.status_code == HTTP_201_CREATED + assert CustomerInfo.objects.get(order__products__product=ticket_product).name == "홍길동" + + +@pytest.mark.django_db +def test_admin_import_csv_rejects_undecodable_file(api_client): + """Excel '유니코드 텍스트'(UTF-16) 저장처럼 지원하지 않는 인코딩은 500 이 아니라 400.""" + response = OrdersAdminApi(http_client=api_client).import_csv( + csv_file=_csv_bytes("name,phone\n홍길동,010-1234-5678\n".encode("utf-16")) + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert [error["attr"] for error in response.json()["errors"]] == ["csv_file"] + + +@pytest.mark.django_db +def test_admin_import_csv_rejects_malformed_csv(api_client): + """열 개수가 어긋난 CSV 도 500 이 아니라 400.""" + response = OrdersAdminApi(http_client=api_client).import_csv(csv_file=_csv_file("a,b,c\n1,2,3\n4,5,6,7,8\n")) + assert response.status_code == HTTP_400_BAD_REQUEST + assert [error["attr"] for error in response.json()["errors"]] == ["csv_file"] + + @pytest.mark.django_db def test_admin_import_csv_rejects_missing_file(api_client): response = OrdersAdminApi(http_client=api_client).import_csv() @@ -297,6 +353,23 @@ def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_cl ) assert response.status_code == HTTP_400_BAD_REQUEST assert not OrderProductRelation.objects.exists() + assert response.json()["type"] == "validation_error" + + +@pytest.mark.django_db +def test_admin_import_csv_error_attr_identifies_the_failing_row(api_client, customer_user, ticket_product): + # 1행은 유효, 2행은 미가입 이메일 → attr 의 행 인덱스로 실패한 행을 식별할 수 있어야 한다. + response = OrdersAdminApi(http_client=api_client).import_csv( + csv_file=_csv_file( + "name,phone,email,organization,product_id,donation_price\n" + f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0\n" + f"김철수,010-2222-3333,nobody@example.com,,{ticket_product.id},0\n" + ) + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert [error["attr"] for error in response.json()["errors"]] == ["1.non_field_errors"] + # 유효한 1행도 저장되지 않는다 (전부 성공해야 저장). + assert not OrderProductRelation.objects.exists() @pytest.mark.parametrize("include_refunded", [False, True]) diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py index bd9011c..425f8ee 100644 --- a/app/admin_api/views/shop/orders.py +++ b/app/admin_api/views/shop/orders.py @@ -1,7 +1,7 @@ import datetime import io -import json import typing +from codecs import BOM_UTF8 from logging import getLogger import pandas @@ -10,11 +10,12 @@ from core.authz import IsSuperUser from core.const.tag import OpenAPITag from core.pagination import AdminPagination +from core.util.fileutil import read_uploaded_csv from core.viewset.json_schema_viewset import JsonSchemaMixin from core.viewset.selectables_viewset import SelectablesMixin from django.core.files import File from django.db import models, transaction -from django.http.response import StreamingHttpResponse +from django.http.response import HttpResponse, StreamingHttpResponse from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view from drf_standardized_errors.openapi_serializers import ValidationErrorResponseSerializer from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets @@ -146,7 +147,7 @@ def refund_product( responses={status.HTTP_200_OK: OpenApiTypes.STR}, ) @action(detail=False, methods=["get"], url_path="import-template") - def import_template(self, request: request.Request) -> response.Response: + def import_template(self, request: request.Request) -> HttpResponse: if not (product_id := request.query_params.get("product_id")): raise exceptions.ValidationError({"product_id": "이 값이 필요합니다."}) try: @@ -154,9 +155,10 @@ def import_template(self, request: request.Request) -> response.Response: except Product.DoesNotExist as e: raise exceptions.NotFound("Product not found") from e - return response.Response( - data=imports.OrderProductImportSerializer.get_template_csv(product=product), - content_type="text/csv", + csv_content = imports.OrderProductImportSerializer.get_template_csv(product=product) + return HttpResponse( + content=BOM_UTF8.decode("utf-8") + csv_content, + content_type="text/csv; charset=utf-8", headers={"Content-Disposition": "attachment; filename=order_import_template.csv"}, ) @@ -169,7 +171,10 @@ def import_template(self, request: request.Request) -> response.Response: "properties": {"csv_file": {"type": "string", "format": "binary"}}, } }, - responses={status.HTTP_201_CREATED: None}, + responses={ + status.HTTP_201_CREATED: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + }, ) @action( detail=False, @@ -182,18 +187,13 @@ def import_csv(self, request: request.Request) -> response.Response: if not (csv_file := request.FILES.get("csv_file")): raise exceptions.ValidationError({"csv_file": "이 값이 필요합니다."}) - csv_io = io.StringIO(csv_file.read().decode("utf-8")) - csv_df = pandas.read_csv(csv_io) + csv_df = read_uploaded_csv(csv_file.read()) csv_serializers = [ imports.OrderProductImportSerializer(data=datum) for datum in csv_df.to_dict(orient="index").values() ] # 모든 serializer 의 .is_valid() 를 호출하기 위해 list comprehension 사용 (all() 의 short-circuit 회피). if not all([s.is_valid() for s in csv_serializers]): - errors = [s.errors for s in csv_serializers] - return response.Response( - data=json.loads(json.dumps(errors, ensure_ascii=False)), - status=status.HTTP_400_BAD_REQUEST, - ) + raise exceptions.ValidationError([s.errors for s in csv_serializers]) for s in csv_serializers: s.save() return response.Response(status=status.HTTP_201_CREATED) diff --git a/app/core/test/fileutil_test.py b/app/core/test/fileutil_test.py new file mode 100644 index 0000000..8e754b4 --- /dev/null +++ b/app/core/test/fileutil_test.py @@ -0,0 +1,38 @@ +import pytest +from core.util.fileutil import read_uploaded_csv +from rest_framework import exceptions + +_CSV = "name,phone\n홍길동,010-1234-5678\n" + + +@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig", "cp949"]) +def test_read_uploaded_csv_reads_supported_encodings(encoding): + df = read_uploaded_csv(_CSV.encode(encoding)) + # utf-8-sig 로 먼저 디코딩하므로 BOM 이 첫 컬럼명에 남지 않는다. + assert list(df.columns) == ["name", "phone"] + assert df.iloc[0]["name"] == "홍길동" + + +def test_read_uploaded_csv_rejects_unsupported_encoding(): + # Excel 의 "유니코드 텍스트"(UTF-16) 저장 — UTF-8/CP949 둘 다 실패. + with pytest.raises(exceptions.ValidationError) as e: + read_uploaded_csv(_CSV.encode("utf-16")) + assert "csv_file" in e.value.detail + + +def test_read_uploaded_csv_rejects_malformed_csv(): + with pytest.raises(exceptions.ValidationError) as e: + read_uploaded_csv(b"a,b,c\n1,2,3\n4,5,6,7,8\n") + assert "csv_file" in e.value.detail + + +def test_read_uploaded_csv_rejects_empty_file(): + with pytest.raises(exceptions.ValidationError) as e: + read_uploaded_csv(b"") + assert "csv_file" in e.value.detail + + +def test_read_uploaded_csv_reports_errors_under_given_field(): + with pytest.raises(exceptions.ValidationError) as e: + read_uploaded_csv(b"", field="attachment") + assert "attachment" in e.value.detail diff --git a/app/core/util/fileutil.py b/app/core/util/fileutil.py new file mode 100644 index 0000000..76fc7eb --- /dev/null +++ b/app/core/util/fileutil.py @@ -0,0 +1,27 @@ +import io + +import pandas +from rest_framework import exceptions + +# 한국어 Windows Excel 의 "CSV (쉼표로 분리)" 저장은 CP949 이므로 UTF-8 만으로는 읽을 수 없다. +# 순서 중요 — UTF-8 을 먼저 시도해야 CP949 가 UTF-8 본문을 깨진 글자로 잘못 읽는 것을 막는다. +CSV_ENCODINGS = ("utf-8-sig", "cp949") + + +def read_uploaded_csv(raw: bytes, *, field: str = "csv_file") -> pandas.DataFrame: + """업로드된 CSV 를 DataFrame 으로 파싱. 인코딩·파싱 실패를 500 대신 400 으로 돌려준다.""" + for encoding in CSV_ENCODINGS: + try: + decoded = raw.decode(encoding) + break + except UnicodeDecodeError: + continue + else: + raise exceptions.ValidationError( + {field: "CSV 파일의 문자 인코딩을 인식할 수 없습니다. UTF-8 또는 CP949 로 저장해주세요."} + ) + + try: + return pandas.read_csv(io.StringIO(decoded)) + except (pandas.errors.ParserError, pandas.errors.EmptyDataError) as e: + raise exceptions.ValidationError({field: f"CSV 파일을 읽을 수 없습니다: {e}"}) from e From b0ad7923ab774d75c4e80458bfc577ccd33be604 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 1 Aug 2026 14:02:16 +0900 Subject: [PATCH 45/70] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20CSV=20import?= =?UTF-8?q?=20=EC=8B=9C=20EmailAddress=20=EA=B8=B0=EC=A4=80=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=AF=B8=EA=B0=80?= =?UTF-8?q?=EC=9E=85=EC=9E=90=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/test/shop/orders_api_test.py | 75 ++++++++++++++++-- app/admin_api/views/shop/orders.py | 2 +- app/core/util/strutil.py | 4 + app/shop/order/imports.py | 15 ++-- app/shop/order/test/imports_test.py | 90 ++++++++++++++++++---- app/user/models/user.py | 39 ++++++++++ app/user/test/manager_test.py | 90 ++++++++++++++++++++++ 7 files changed, 284 insertions(+), 31 deletions(-) create mode 100644 app/user/test/manager_test.py diff --git a/app/admin_api/test/shop/orders_api_test.py b/app/admin_api/test/shop/orders_api_test.py index 3975333..ce76079 100644 --- a/app/admin_api/test/shop/orders_api_test.py +++ b/app/admin_api/test/shop/orders_api_test.py @@ -23,6 +23,7 @@ from rest_framework.test import APIClient from shop.order.models import CustomerInfo, Order, OrderProductRelation from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from user.models import UserExt @pytest.mark.parametrize("client_fixture", ["anon_client", "customer_client"]) @@ -288,6 +289,34 @@ def test_admin_import_csv_persists_paid_order_from_uploaded_row(api_client, cust opr = OrderProductRelation.objects.get(product=ticket_product) assert opr.status == OrderProductRelation.OrderProductStatus.paid assert opr.order.user == customer_user + # 빈 셀은 pandas NaN → 문자열 "nan" 이 아니라 빈 문자열로 저장돼야 한다. + assert opr.order.customer_info.organization == "" + + +@pytest.mark.django_db +def test_admin_import_csv_rejects_blank_required_cell(api_client, customer_user, ticket_product): + response = OrdersAdminApi(http_client=api_client).import_csv( + csv_file=_csv_file( + "name,phone,email,organization,product_id,donation_price\n" + f",010-1234-5678,{customer_user.email},,{ticket_product.id},0\n" + ) + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert [error["attr"] for error in response.json()["errors"]] == ["0.name"] + assert not OrderProductRelation.objects.exists() + + +@pytest.mark.django_db +def test_admin_import_csv_skips_option_group_with_blank_cell(api_client, customer_user, ticket_product, option_group): + option_group.options.create(name="M", additional_price=0) + response = OrdersAdminApi(http_client=api_client).import_csv( + csv_file=_csv_file( + "name,phone,email,organization,product_id,donation_price,사이즈\n" + f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0,\n" + ) + ) + assert response.status_code == HTTP_201_CREATED + assert OrderProductRelation.objects.get(product=ticket_product).options.count() == 0 @pytest.mark.django_db @@ -343,12 +372,12 @@ def test_admin_import_csv_rejects_missing_file(api_client): @pytest.mark.django_db -def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_client, ticket_product): - # email 매칭되는 user 부재 → 모든 row validate 실패 → atomic rollback. +def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_client, customer_user, ticket_product): + # 전화번호 형식 불일치 → 모든 row validate 실패 → atomic rollback. response = OrdersAdminApi(http_client=api_client).import_csv( csv_file=_csv_file( "name,phone,email,organization,product_id,donation_price\n" - f"홍길동,010-1234-5678,nobody@example.com,,{ticket_product.id},0\n" + f"홍길동,전화번호아님,{customer_user.email},,{ticket_product.id},0\n" ) ) assert response.status_code == HTTP_400_BAD_REQUEST @@ -357,13 +386,16 @@ def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_cl @pytest.mark.django_db -def test_admin_import_csv_error_attr_identifies_the_failing_row(api_client, customer_user, ticket_product): - # 1행은 유효, 2행은 미가입 이메일 → attr 의 행 인덱스로 실패한 행을 식별할 수 있어야 한다. +def test_admin_import_csv_error_attr_identifies_the_failing_row( + api_client, customer_user, ticket_product, option_group +): + # 1행은 유효, 2행은 정의되지 않은 옵션값 → attr 의 행 인덱스로 실패한 행을 식별할 수 있어야 한다. + option_group.options.create(name="M", additional_price=0) response = OrdersAdminApi(http_client=api_client).import_csv( csv_file=_csv_file( - "name,phone,email,organization,product_id,donation_price\n" - f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0\n" - f"김철수,010-2222-3333,nobody@example.com,,{ticket_product.id},0\n" + "name,phone,email,organization,product_id,donation_price,사이즈\n" + f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0,M\n" + f"김철수,010-2222-3333,other@example.com,,{ticket_product.id},0,XXL\n" ) ) assert response.status_code == HTTP_400_BAD_REQUEST @@ -372,6 +404,33 @@ def test_admin_import_csv_error_attr_identifies_the_failing_row(api_client, cust assert not OrderProductRelation.objects.exists() +@pytest.mark.django_db +def test_admin_import_csv_creates_missing_user_from_row_email(api_client, ticket_product): + response = OrdersAdminApi(http_client=api_client).import_csv( + csv_file=_csv_file( + "name,phone,email,organization,product_id,donation_price\n" + f"홍길동,010-1234-5678,nobody@example.com,,{ticket_product.id},0\n" + ) + ) + assert response.status_code == HTTP_201_CREATED + assert OrderProductRelation.objects.get(product=ticket_product).order.user.email == "nobody@example.com" + + +@pytest.mark.django_db +def test_admin_import_csv_rolls_back_created_users_when_a_later_row_fails(api_client, ticket_product, option_group): + # 1행이 유저를 만들고 2행이 실패 → 라우트 atomic 으로 유저 생성까지 되돌아가야 한다. + option_group.options.create(name="M", additional_price=0) + response = OrdersAdminApi(http_client=api_client).import_csv( + csv_file=_csv_file( + "name,phone,email,organization,product_id,donation_price,사이즈\n" + f"홍길동,010-1234-5678,first@example.com,,{ticket_product.id},0,M\n" + f"김철수,010-2222-3333,second@example.com,,{ticket_product.id},0,XXL\n" + ) + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert not UserExt.objects.filter(email__in=["first@example.com", "second@example.com"]).exists() + + @pytest.mark.parametrize("include_refunded", [False, True]) @freeze_time(datetime(2026, 5, 23, 15, 30, 45, tzinfo=timezone.utc)) @pytest.mark.django_db diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py index 425f8ee..4b327d1 100644 --- a/app/admin_api/views/shop/orders.py +++ b/app/admin_api/views/shop/orders.py @@ -187,7 +187,7 @@ def import_csv(self, request: request.Request) -> response.Response: if not (csv_file := request.FILES.get("csv_file")): raise exceptions.ValidationError({"csv_file": "이 값이 필요합니다."}) - csv_df = read_uploaded_csv(csv_file.read()) + csv_df = read_uploaded_csv(csv_file.read()).fillna("") csv_serializers = [ imports.OrderProductImportSerializer(data=datum) for datum in csv_df.to_dict(orient="index").values() ] diff --git a/app/core/util/strutil.py b/app/core/util/strutil.py index 5b986ce..6f5acfb 100644 --- a/app/core/util/strutil.py +++ b/app/core/util/strutil.py @@ -19,6 +19,10 @@ def b64_to_uuid(in_str: str) -> UUID: return UUID(bytes=urlsafe_b64decode(in_str + "=" * (-len(in_str) % 4))) +def normalize_email(email: str | None) -> str: + return str(email or "").strip().lower() + + def format_korean_date(value: datetime) -> str: local = value.astimezone(KST) return f"{local.year}년 {local.month}월 {local.day}일({KOREAN_WEEKDAYS[local.weekday()]})" diff --git a/app/shop/order/imports.py b/app/shop/order/imports.py index 94b8238..e048447 100644 --- a/app/shop/order/imports.py +++ b/app/shop/order/imports.py @@ -51,7 +51,7 @@ class OrderProductImportSerializer(serializers.ModelSerializer): product_id = serializers.PrimaryKeyRelatedField(queryset=Product.objects.filter_active(), source="id") donation_price = serializers.IntegerField(required=True) - options = serializers.DictField(child=serializers.CharField(), required=True) + options = serializers.DictField(child=serializers.CharField(allow_blank=True), required=True) class Meta: model = OrderProductRelation @@ -65,8 +65,14 @@ def get_template_csv(cls, product: Product) -> str: return pandas.DataFrame(columns=serializer_fields + option_fields).to_csv(index=False) @functools.cached_property - def user(self) -> UserExt | None: - return UserExt.objects.filter(email=self.initial_data.get("email", "")).first() + def user(self) -> UserExt: + name: str = self.initial_data["name"] + user, _ = UserExt.objects.get_or_create_by_email( + email=self.initial_data["email"], + nickname_ko=name, + nickname_en=name, + ) + return user @functools.cached_property def product(self) -> Product | None: @@ -113,9 +119,6 @@ def to_internal_value(self, data: dict[str, str]) -> dict[str, typing.Any]: return super().to_internal_value(data | {"options": options}) def validate(self, data: dict) -> dict: - if not self.user: - raise serializers.ValidationError("User does not exists") - check_data = { "product": self.product.id, "donation_price": data["donation_price"], diff --git a/app/shop/order/test/imports_test.py b/app/shop/order/test/imports_test.py index cff09bc..78cff70 100644 --- a/app/shop/order/test/imports_test.py +++ b/app/shop/order/test/imports_test.py @@ -1,9 +1,11 @@ import pytest +from allauth.account.models import EmailAddress from core.util.testutil import errors_payload from shop.order.imports import OrderProductImportSerializer from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus from shop.product.models import OptionGroup +from user.models import UserExt @pytest.mark.django_db @@ -63,38 +65,94 @@ def test_import_includes_option_additional_price_in_opr_price(customer_user, tic assert opr.price == ticket_product.price + 1000 -@pytest.mark.parametrize( - ("override_email", "size_value", "expected_error"), - [ - # CSV email 이 매칭되는 UserExt 없음 → validate 단계 1. - ("nobody@example.com", "M", "User does not exists"), - # 옵션 값이 그룹에 정의된 옵션명과 불일치 → validate 단계 2. - (None, "XXL", "Invalid option: '사이즈' - XXL"), - ], -) @pytest.mark.django_db -def test_import_rejects_invalid_row_and_persists_nothing( - customer_user, ticket_product, option_group, override_email, size_value, expected_error -): +def test_import_rejects_invalid_row_and_persists_nothing(customer_user, ticket_product, option_group): option_group.options.create(name="M", additional_price=0) serializer = OrderProductImportSerializer( data={ "name": "홍길동", "phone": "010-1234-5678", - # None 일 때 fixture user 매칭 — 옵션 검증 단계까지 도달. - "email": override_email or customer_user.email, + "email": customer_user.email, "organization": "", "product_id": str(ticket_product.id), "donation_price": 0, - "사이즈": size_value, + # 옵션 값이 그룹에 정의된 옵션명과 불일치. + "사이즈": "XXL", } ) assert serializer.is_valid() is False + expected_error = "Invalid option: '사이즈' - XXL" assert errors_payload(serializer.errors) == {"non_field_errors": [{"detail": expected_error, "code": "invalid"}]} - # 어느 단계의 validation 실패든 atomic — Order / OPR / CustomerInfo 일체 미생성. + # validation 실패 시 Order / OPR / CustomerInfo 일체 미생성. 유저 생성도 옵션 검증 이후라 발생하지 않음. assert not Order.objects.exists() assert not OrderProductRelation.objects.exists() assert not CustomerInfo.objects.exists() + assert not UserExt.objects.filter(email="홍길동").exists() + + +@pytest.mark.django_db +def test_import_creates_user_when_email_matches_no_account(ticket_product): + serializer = OrderProductImportSerializer( + data={ + "name": "홍길동", + "phone": "010-1234-5678", + "email": "nobody@example.com", + "organization": "", + "product_id": str(ticket_product.id), + "donation_price": 0, + } + ) + assert serializer.is_valid(), serializer.errors + opr = serializer.save() + + created = UserExt.objects.get(email="nobody@example.com") + assert opr.order.user == created + assert created.nickname_ko == created.nickname_en == "홍길동" + # 비밀번호 미설정 — 본인이 비밀번호 재설정 / 소셜 로그인으로 계정을 이어받는다. + assert not created.has_usable_password() + assert EmailAddress.objects.filter(user=created, email="nobody@example.com", verified=True, primary=True).exists() + + +@pytest.mark.django_db +def test_import_matches_user_by_secondary_email_address(customer_user, ticket_product): + EmailAddress.objects.create(user=customer_user, email="alt@example.com", verified=True) + serializer = OrderProductImportSerializer( + data={ + "name": "홍길동", + "phone": "010-1234-5678", + "email": "alt@example.com", + "organization": "", + "product_id": str(ticket_product.id), + "donation_price": 0, + } + ) + assert serializer.is_valid(), serializer.errors + opr = serializer.save() + + assert opr.order.user == customer_user + assert UserExt.objects.count() == 1 + + +@pytest.mark.django_db +def test_import_resolves_merged_account_to_merge_target(customer_user, other_user, ticket_product): + # 병합된 소스 계정에 남은 이메일로 들어와도 주문은 병합 대상 계정에 붙어야 한다. + EmailAddress.objects.create(user=other_user, email="merged@example.com", verified=True) + other_user.merged_to = customer_user + other_user.is_active = False + other_user.save(update_fields=["merged_to", "is_active"]) + + serializer = OrderProductImportSerializer( + data={ + "name": "홍길동", + "phone": "010-1234-5678", + "email": "merged@example.com", + "organization": "", + "product_id": str(ticket_product.id), + "donation_price": 0, + } + ) + assert serializer.is_valid(), serializer.errors + assert serializer.save().order.user == customer_user @pytest.mark.django_db diff --git a/app/user/models/user.py b/app/user/models/user.py index 67feaea..8690528 100644 --- a/app/user/models/user.py +++ b/app/user/models/user.py @@ -1,5 +1,6 @@ from __future__ import annotations +import secrets import typing from functools import cached_property from uuid import uuid4 @@ -7,6 +8,7 @@ from core.const.system import SYSTEM_EMAIL, SYSTEM_USERNAME from core.fields import EncryptedTextField from core.scancode_mixin import ScanCodeMixin +from core.util.strutil import normalize_email from django.contrib.auth.models import AbstractUser, UserManager from django.db import models @@ -24,6 +26,43 @@ def _create_user(self, username, email, password, **extra_fields): ) return user + def filter_by_email(self, email: str) -> models.QuerySet[UserExt]: + if not (email := normalize_email(email)): + return self.none() + return self.filter(models.Q(emailaddress__email__iexact=email) | models.Q(email__iexact=email)).distinct() + + def get_or_create_by_email( + self, email: str, password: str | None = None, **extra_fields: typing.Any + ) -> tuple[UserExt, bool]: + email = normalize_email(email) + candidates = self.filter_by_email(email).order_by("-is_active", "-date_joined") + user = ( + candidates.filter(emailaddress__email__iexact=email, emailaddress__verified=True).first() + or candidates.first() + ) + if user is None: + return self.create_by_email(email, password, **extra_fields), True + + seen: set[int] = set() # 병합 대상 계정으로 해석. seen 은 비정상 병합 체인의 무한 루프 방어. + while user.merged_to_id and user.pk not in seen: + seen.add(user.pk) + user = user.merged_to + return user, False + + def create_by_email(self, email: str, password: str | None = None, **extra_fields: typing.Any) -> UserExt: + email = normalize_email(email) + if "username" not in extra_fields: + max_length: int = self.model._meta.get_field("username").max_length + local, _, domain = email.partition("@") + username = local[:max_length] + if self.filter(username=username).exists(): + username = (base := f"{domain}-{local}")[:max_length] + while self.filter(username=username).exists(): + suffix = f"-{secrets.token_hex(3)}" + username = base[: max_length - len(suffix)] + suffix + extra_fields["username"] = username + return self.create_user(email=email, password=password, **extra_fields) + class UserExt(ScanCodeMixin, AbstractUser): scancode_prefix = "user" diff --git a/app/user/test/manager_test.py b/app/user/test/manager_test.py new file mode 100644 index 0000000..254b393 --- /dev/null +++ b/app/user/test/manager_test.py @@ -0,0 +1,90 @@ +import pytest +from allauth.account.models import EmailAddress +from user.models import UserExt + + +@pytest.fixture +def user(db) -> UserExt: + return UserExt.objects.create_user(username="buyer", email="buyer@example.com") + + +@pytest.mark.django_db +def test_filter_by_email_matches_primary_and_secondary_addresses(user): + EmailAddress.objects.create(user=user, email="alt@example.com", verified=True) + assert list(UserExt.objects.filter_by_email("buyer@example.com")) == [user] + assert list(UserExt.objects.filter_by_email(" ALT@example.com ")) == [user] + + +@pytest.mark.django_db +def test_filter_by_email_falls_back_to_user_email_column(user): + # EmailAddress 백필 이전에 만들어진 계정 — allauth 레코드 없이 User.email 만 있는 경우. + EmailAddress.objects.filter(user=user).delete() + assert list(UserExt.objects.filter_by_email("buyer@example.com")) == [user] + + +@pytest.mark.django_db +def test_filter_by_email_returns_empty_for_unknown_or_blank(user): + assert not UserExt.objects.filter_by_email("nobody@example.com").exists() + assert not UserExt.objects.filter_by_email("").exists() + + +@pytest.mark.django_db +def test_get_or_create_by_email_returns_existing_account(user): + EmailAddress.objects.create(user=user, email="alt@example.com", verified=True) + assert UserExt.objects.get_or_create_by_email("alt@example.com") == (user, False) + + +@pytest.mark.django_db +def test_get_or_create_by_email_resolves_merged_account_to_target(user): + merged = UserExt.objects.create_user(username="merged", email="merged@example.com") + merged.merged_to = user + merged.is_active = False + merged.save(update_fields=["merged_to", "is_active"]) + assert UserExt.objects.get_or_create_by_email("merged@example.com") == (user, False) + + +@pytest.mark.django_db +def test_get_or_create_by_email_prefers_verified_address_owner(user): + unverified_owner = UserExt.objects.create_user(username="unverified", email="other@example.com") + EmailAddress.objects.create(user=unverified_owner, email="shared@example.com", verified=False) + EmailAddress.objects.create(user=user, email="shared@example.com", verified=True) + assert UserExt.objects.get_or_create_by_email("shared@example.com") == (user, False) + + +@pytest.mark.django_db +def test_get_or_create_by_email_creates_verified_account_without_password(db): + created, is_created = UserExt.objects.get_or_create_by_email( + " Nobody@Example.com ", nickname_ko="홍길동", nickname_en="Gildong" + ) + assert is_created + assert created.email == "nobody@example.com" + assert created.username == "nobody" + assert (created.nickname_ko, created.nickname_en) == ("홍길동", "Gildong") + assert not created.has_usable_password() + assert EmailAddress.objects.filter(user=created, email="nobody@example.com", verified=True, primary=True).exists() + + +@pytest.mark.django_db +def test_create_by_email_prefixes_domain_on_username_collision(db): + UserExt.objects.create_user(username="nobody", email="other@example.com") + created = UserExt.objects.create_by_email("nobody@example.com") + assert created.username == "example.com-nobody" + assert created.email == "nobody@example.com" + + +@pytest.mark.django_db +def test_create_by_email_appends_random_suffix_when_domain_prefix_also_taken(db): + UserExt.objects.create_user(username="nobody", email="other@example.com") + UserExt.objects.create_user(username="example.com-nobody", email="another@example.com") + created = UserExt.objects.create_by_email("nobody@example.com") + assert created.username.startswith("example.com-nobody-") + assert created.email == "nobody@example.com" + + +@pytest.mark.django_db +def test_create_by_email_passes_create_user_arguments_through(db): + created = UserExt.objects.create_by_email( + "staff@example.com", "s3cret", username="staff", is_superuser=True, nickname_ko="스태프" + ) + assert (created.username, created.is_superuser, created.nickname_ko) == ("staff", True, "스태프") + assert created.check_password("s3cret") From 2fea545cfb06d3ff4bac463db1ffb766300e225e Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 1 Aug 2026 14:26:02 +0900 Subject: [PATCH 46/70] =?UTF-8?q?fix:=202025=EB=85=84=20=ED=96=89=EC=82=AC?= =?UTF-8?q?=EA=B0=80=20retrieve=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/viewset/list_only_filter_viewset.py | 17 +++++++++++++++++ app/event/presentation/test/api_test.py | 20 ++++++++++++++++++++ app/event/presentation/views.py | 8 +++++++- app/event/sponsor/views.py | 3 ++- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 app/core/viewset/list_only_filter_viewset.py diff --git a/app/core/viewset/list_only_filter_viewset.py b/app/core/viewset/list_only_filter_viewset.py new file mode 100644 index 0000000..29ae7a2 --- /dev/null +++ b/app/core/viewset/list_only_filter_viewset.py @@ -0,0 +1,17 @@ +from django.db.models.query import QuerySet +from rest_framework import viewsets + + +class ListOnlyFilterMixin(viewsets.GenericViewSet): + """목록용 필터를 list action 에만 적용한다. + + DRF 의 get_object() 는 filter_queryset() 을 거치므로, 목록 기본 스코프 + (EventFilterMixin 의 "최신 이벤트" 등)가 단건 조회까지 좁혀 PK 가 유효한데도 + 404 가 난다. 단건 조회는 PK 로 지목하는 영구 링크이므로 스코프 밖이어도 접근 가능해야 한다. + """ + + def filter_queryset(self, queryset: QuerySet) -> QuerySet: + if self.action != "list": + return queryset + + return super().filter_queryset(queryset) diff --git a/app/event/presentation/test/api_test.py b/app/event/presentation/test/api_test.py index a31c0a3..9814d49 100644 --- a/app/event/presentation/test/api_test.py +++ b/app/event/presentation/test/api_test.py @@ -89,6 +89,26 @@ def test_presentation_defaults_to_latest_event(api_client: APIClient): assert response_data[0]["id"] == str(new_prst.id) +@pytest.mark.django_db +def test_presentation_retrieve_ignores_latest_event_default(api_client: APIClient): + # Given: 지난 행사의 발표가 있고, 그보다 최신인 행사가 존재함. + organization = Organization.objects.create(name="Test Organization") + old_event = Event.objects.create( + organization=organization, name="PyCon Korea 2025", event_start_at=datetime(2025, 8, 1) + ) + Event.objects.create(organization=organization, name="PyCon Korea 2026", event_start_at=datetime(2026, 8, 1)) + + old_type = PresentationType.objects.create(event=old_event, name="Talk") + old_prst = Presentation.objects.create(type=old_type, title="Old Presentation") + + # When: event 파라미터 없이 지난 행사의 발표를 단건 조회 + response = api_client.get(reverse("v1:presentation-detail", kwargs={"pk": old_prst.id})) + + # Then: 목록 기본 스코프(최신 행사)와 무관하게 조회된다. + assert response.status_code == http.HTTPStatus.OK + assert response.json()["id"] == str(old_prst.id) + + def _make_public_file(name: str) -> PublicFile: return PublicFile.objects.create(file=f"public/{name}.png", mimetype="image/png", hash=name, size=0) diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py index babea15..619cd56 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,4 +1,5 @@ from core.const.tag import OpenAPITag +from core.viewset.list_only_filter_viewset import ListOnlyFilterMixin from django.db.models.query import QuerySet from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator @@ -28,7 +29,12 @@ class PresentationCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) @method_decorator(name="retrieve", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) -class PresentationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): +class PresentationViewSet( + ListOnlyFilterMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): queryset = Presentation.objects.get_all_nested_data() serializer_class = PresentationSerializer filterset_class = PresentationFilterSet diff --git a/app/event/sponsor/views.py b/app/event/sponsor/views.py index 7bd3b1e..c78911c 100644 --- a/app/event/sponsor/views.py +++ b/app/event/sponsor/views.py @@ -1,4 +1,5 @@ from core.const.tag import OpenAPITag +from core.viewset.list_only_filter_viewset import ListOnlyFilterMixin from django.db import models from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema @@ -9,7 +10,7 @@ @method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_SPONSOR])) -class SponsorTierViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class SponsorTierViewSet(ListOnlyFilterMixin, mixins.ListModelMixin, viewsets.GenericViewSet): queryset = SponsorTier.objects.filter_active().prefetch_related( models.Prefetch( lookup="sponsors", From a8b933aae10307c9941c8a8dcdd43a24087bd15b Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 1 Aug 2026 15:12:38 +0900 Subject: [PATCH 47/70] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/const/account.py | 10 +++++ app/user/account_views/email.py | 32 ++++++++++++-- app/user/templates/user/account_base.html | 1 + .../templates/user/email_confirm_result.html | 3 ++ app/user/templates/user/email_manage.html | 8 +++- app/user/test/email_view_test.py | 44 +++++++++++++++++++ 6 files changed, 94 insertions(+), 4 deletions(-) diff --git a/app/core/const/account.py b/app/core/const/account.py index 32c269c..b1e3a79 100644 --- a/app/core/const/account.py +++ b/app/core/const/account.py @@ -96,6 +96,16 @@ "ko": "이 이메일은 추가할 수 없습니다. 형식이 올바르지 않거나 이미 사용 중일 수 있습니다.", "en": "This email can't be added. It may be invalid or already in use.", }, + "taken_by_other": { + "ko": "이미 다른 계정에서 인증된 이메일입니다. 두 계정이 모두 본인 것이라면 계정 병합을 이용해 주세요.", + "en": ( + "This email is already verified on another account. If both accounts are yours, use account merge instead." + ), + }, + "confirm_failed": { + "ko": "이메일 인증에 실패했습니다. 잠시 후 다시 시도해 주세요.", + "en": "We couldn't verify this email. Please try again later.", + }, "not_found": { "ko": "이메일을 찾을 수 없습니다.", "en": "Email not found.", diff --git a/app/user/account_views/email.py b/app/user/account_views/email.py index 56a18a9..3df2eec 100644 --- a/app/user/account_views/email.py +++ b/app/user/account_views/email.py @@ -18,15 +18,30 @@ def _email_msg(code: str) -> str: return EMAIL_MESSAGES[code]["en" if is_english() else "ko"] +def _verified_elsewhere(user, email: str) -> bool: + """다른 계정이 같은 주소를 이미 인증했는지. ACCOUNT_UNIQUE_EMAIL 때문에 이 경우 인증이 영영 불가능하다.""" + return EmailAddress.objects.filter(email__iexact=email, verified=True).exclude(user=user).exists() + + def _render_emails( request: HttpRequest, *, error: str | None = None, notice: str | None = None, status: HTTPStatus = HTTPStatus.OK ) -> HttpResponse: + emails = list(EmailAddress.objects.filter(user=request.user).order_by("-primary", "-verified", "email")) + conflicts = set( + EmailAddress.objects.filter(email__in=[e.email for e in emails], verified=True) + .exclude(user=request.user) + .values_list("email", flat=True) + ) + for email in emails: + email.taken_by_other = not email.verified and email.email in conflicts + return render( request, "user/email_manage.html", { "user": request.user, - "emails": EmailAddress.objects.filter(user=request.user).order_by("-primary", "-verified", "email"), + "emails": emails, + "taken_by_other_msg": _email_msg("taken_by_other"), "error": error, "notice": notice, }, @@ -52,6 +67,9 @@ def add_email(request: HttpRequest) -> HttpResponse: form = AddEmailForm(user=request.user, data=request.POST) if not form.is_valid(): return _render_emails(request, error=_email_msg("add_failed"), status=HTTPStatus.BAD_REQUEST) + # allauth 는 ACCOUNT_PREVENT_ENUMERATION 때문에 여기를 통과시키지만, 인증은 뒤에서 반드시 실패한다. + if _verified_elsewhere(request.user, form.cleaned_data["email"]): + return _render_emails(request, error=_email_msg("taken_by_other"), status=HTTPStatus.BAD_REQUEST) form.save(request) return _render_emails(request, notice=_email_msg("verification_sent")) @@ -72,6 +90,8 @@ def resend_email(request: HttpRequest) -> HttpResponse: email = _target_email(request) if email.verified: return _render_emails(request, error=_email_msg("already_verified"), status=HTTPStatus.BAD_REQUEST) + if _verified_elsewhere(request.user, email.email): + return _render_emails(request, error=_email_msg("taken_by_other"), status=HTTPStatus.BAD_REQUEST) email.send_confirmation(request) return _render_emails(request, notice=_email_msg("resent")) @@ -92,5 +112,11 @@ def confirm_email(request: HttpRequest, key: str) -> HttpResponse: if confirmation is None: context = {"error": _email_msg("invalid_link")} return render(request, "user/email_confirm_result.html", context, status=HTTPStatus.BAD_REQUEST) - confirmation.confirm(request) - return render(request, "user/email_confirm_result.html", {"email": confirmation.email_address.email}) + + email_address = confirmation.email_address + if confirmation.confirm(request) is None: + # allauth 는 인증 거부를 조용히 None 으로 돌려준다. 성공으로 표시하면 안 된다. + taken = _verified_elsewhere(email_address.user, email_address.email) + context = {"error": _email_msg("taken_by_other" if taken else "confirm_failed"), "show_merge_link": taken} + return render(request, "user/email_confirm_result.html", context, status=HTTPStatus.BAD_REQUEST) + return render(request, "user/email_confirm_result.html", {"email": email_address.email}) diff --git a/app/user/templates/user/account_base.html b/app/user/templates/user/account_base.html index 532778d..0aadb93 100644 --- a/app/user/templates/user/account_base.html +++ b/app/user/templates/user/account_base.html @@ -33,6 +33,7 @@ table.emails td { vertical-align: middle; padding: .5rem .6rem; word-break: break-word; } table.emails .badges { margin-top: .15rem; } table.emails .badges sup { margin-right: .3rem; } + table.emails .reason { margin-top: .3rem; font-size: .8rem; color: #b23b3b; } table.emails td.actions { width: 9rem; } table.emails .btns { display: flex; gap: .3rem; justify-content: flex-end; } table.emails .btns form { display: contents; } diff --git a/app/user/templates/user/email_confirm_result.html b/app/user/templates/user/email_confirm_result.html index a904981..7103fd8 100644 --- a/app/user/templates/user/email_confirm_result.html +++ b/app/user/templates/user/email_confirm_result.html @@ -4,6 +4,9 @@

{% if en %}Email verification{% else %}이메일 인증{% endif %}

{% if error %}
{{ error }}
+ {% if show_merge_link %} +

{% if en %}Merge accounts{% else %}계정 병합{% endif %}

+ {% endif %} {% else %}
{% if en %}Your email has been verified.{% else %}이메일이 인증되었습니다.{% endif %} diff --git a/app/user/templates/user/email_manage.html b/app/user/templates/user/email_manage.html index 803f5f4..4e633d8 100644 --- a/app/user/templates/user/email_manage.html +++ b/app/user/templates/user/email_manage.html @@ -19,10 +19,16 @@

{% if en %}Email addresses{% else %}이메일 관리{% endif %}

{% if en %}unverified{% else %}미인증{% endif %} {% endif %}
+ {% if email.taken_by_other %} + + {% endif %}
- {% if not email.verified %} + {% if not email.verified and not email.taken_by_other %}
{% csrf_token %} diff --git a/app/user/test/email_view_test.py b/app/user/test/email_view_test.py index 4b0fc2f..b09d839 100644 --- a/app/user/test/email_view_test.py +++ b/app/user/test/email_view_test.py @@ -12,6 +12,11 @@ def user(db) -> UserExt: return u +@pytest.fixture +def other_user(db) -> UserExt: + return UserExt.objects.create_user(username="other", email="taken@example.com") + + def _unverified(user: UserExt, email: str = "new@example.com") -> EmailAddress: return EmailAddress.objects.create(user=user, email=email, verified=False) @@ -66,6 +71,45 @@ def test_confirm_invalid_key_rejected(client, db): assert response.status_code == http.HTTPStatus.BAD_REQUEST +# ---- 다른 계정이 이미 인증한 주소 (ACCOUNT_UNIQUE_EMAIL 충돌) -------------------- + + +def test_add_taken_by_other_account_rejected(client, user, other_user, mailoutbox): + # allauth 는 열거 방지 때문에 통과시키지만, 인증이 불가능하므로 애초에 막는다. + client.force_login(user) + response = client.post(reverse("account-email-add"), {"email": "taken@example.com"}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert not EmailAddress.objects.filter(user=user, email="taken@example.com").exists() + assert len(mailoutbox) == 0 + + +def test_confirm_taken_by_other_account_fails_loudly(client, user, other_user): + email = _unverified(user, "taken@example.com") + key = EmailConfirmationHMAC(email).key + + response = client.get(reverse("account-email-confirm", kwargs={"key": key})) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + email.refresh_from_db() + assert email.verified is False + assert reverse("account-merge-start").encode() in response.content + + +def test_resend_taken_by_other_account_rejected(client, user, other_user, mailoutbox): + email = _unverified(user, "taken@example.com") + client.force_login(user) + response = client.post(reverse("account-email-resend"), {"email_id": email.pk}) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert len(mailoutbox) == 0 + + +def test_manage_shows_reason_for_conflicting_email(client, user, other_user): + _unverified(user, "taken@example.com") + client.force_login(user) + response = client.get(reverse("account-email")) + assert response.status_code == http.HTTPStatus.OK + assert reverse("account-merge-start").encode() in response.content + + # ---- Resend ----------------------------------------------------------------- From 91d217ad32c1ca12f9d52c0a89919f3791723242 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 1 Aug 2026 20:24:36 +0900 Subject: [PATCH 48/70] =?UTF-8?q?feat:=20=ED=9B=84=EC=9B=90=EC=82=AC=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=EC=97=90=20=EC=83=89=EC=83=81=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/event/sponsor.py | 5 ++- app/admin_api/test/event/sponsor_test.py | 38 +++++++++++++++++++ app/core/const/regex.py | 4 ++ app/core/models.py | 16 ++++++++ app/core/openapi/ui_hints.py | 4 +- ...oricalsponsortag_color_sponsortag_color.py | 30 +++++++++++++++ app/event/sponsor/models.py | 8 +++- app/event/sponsor/serializers.py | 10 ++++- app/event/sponsor/test/api_test.py | 28 +++++++++++++- app/event/sponsor/views.py | 6 ++- 10 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 app/admin_api/test/event/sponsor_test.py create mode 100644 app/event/sponsor/migrations/0019_historicalsponsortag_color_sponsortag_color.py diff --git a/app/admin_api/serializers/event/sponsor.py b/app/admin_api/serializers/event/sponsor.py index 8fa7795..c63087f 100644 --- a/app/admin_api/serializers/event/sponsor.py +++ b/app/admin_api/serializers/event/sponsor.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from core.const.serializer import COMMON_ADMIN_FIELDS from core.serializer.base_abstract_serializer import BaseAbstractSerializer from core.serializer.json_schema_serializer import JsonSchemaSerializer @@ -14,7 +16,8 @@ class Meta: class SponsorTagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): class Meta: model = SponsorTag - fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en") + fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en", "color") + extra_kwargs: ClassVar = {"color": {"allow_blank": False}} class SponsorAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): diff --git a/app/admin_api/test/event/sponsor_test.py b/app/admin_api/test/event/sponsor_test.py new file mode 100644 index 0000000..ad1f9b5 --- /dev/null +++ b/app/admin_api/test/event/sponsor_test.py @@ -0,0 +1,38 @@ +import pytest +from admin_api.serializers.event.sponsor import SponsorTagAdminSerializer +from admin_api.views.event.sponsor import SponsorTagAdminViewSet +from model_bakery import baker + + +@pytest.fixture +def sponsor_tag(db): + return baker.make("sponsor.SponsorTag", color=None) + + +@pytest.mark.parametrize("color", ["#3498db", "#ABCDEF", None]) +def test_admin_serializer_accepts_color(sponsor_tag, color): + serializer = SponsorTagAdminSerializer(instance=sponsor_tag, data={"color": color}, partial=True) + assert serializer.is_valid(), serializer.errors + + +@pytest.mark.parametrize("color", ["", "3498db", "#3498d", "red"]) +def test_admin_serializer_rejects_invalid_color(sponsor_tag, color): + serializer = SponsorTagAdminSerializer(instance=sponsor_tag, data={"color": color}, partial=True) + assert not serializer.is_valid() + assert "color" in serializer.errors + + +def test_admin_serializer_stores_null_color(sponsor_tag): + serializer = SponsorTagAdminSerializer(instance=sponsor_tag, data={"color": None}, partial=True) + assert serializer.is_valid(), serializer.errors + tag = serializer.save() + tag.refresh_from_db() + assert tag.color is None + + +def test_json_schema_exposes_color_picker_widget(): + schema = SponsorTagAdminViewSet().get_json_schema() + + assert schema["schema"]["properties"]["color"]["type"] == ["string", "null"] + assert "color" not in schema["schema"]["required"] + assert schema["ui_schema"]["color"] == {"ui:widget": "color"} diff --git a/app/core/const/regex.py b/app/core/const/regex.py index d710969..5d7277f 100644 --- a/app/core/const/regex.py +++ b/app/core/const/regex.py @@ -18,3 +18,7 @@ # 전화번호 PHONE_PATTERN = r"^([\d]{3}-[\d]{3,4}-[\d]{4}|\+[\d]{9,14})$" PHONE_REGEX = re.compile(PHONE_PATTERN) + +# 색상 +COLOR_PATTERN = r"^#[0-9a-fA-F]{6}$" +COLOR_REGEX = re.compile(COLOR_PATTERN) diff --git a/app/core/models.py b/app/core/models.py index cec5fba..8dc2619 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -2,8 +2,10 @@ import typing import uuid +from core.const.regex import COLOR_REGEX from core.util.thread_local import get_current_user from django.contrib.auth import get_user_model +from django.core.validators import RegexValidator from django.db import models from django.db.models.functions import Now from django.utils import timezone @@ -121,3 +123,17 @@ def delete(self, using: str | None = None) -> None: class MarkdownField(models.TextField): is_markdown = True + + +class ColorField(models.CharField): + empty_strings_allowed = False + default_validators: typing.ClassVar = [ + RegexValidator(regex=COLOR_REGEX, message="색상은 #RRGGBB 형식이어야 합니다.") + ] + + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("max_length", 7) + super().__init__(*args, **kwargs) + + def get_prep_value(self, value: str | None) -> str | None: + return super().get_prep_value(value) or None diff --git a/app/core/openapi/ui_hints.py b/app/core/openapi/ui_hints.py index 7f8ffb6..39e9ec7 100644 --- a/app/core/openapi/ui_hints.py +++ b/app/core/openapi/ui_hints.py @@ -1,4 +1,4 @@ -from core.models import MarkdownField +from core.models import ColorField, MarkdownField from django.db.models.fields import TextField from django.db.models.fields.files import FileField from django.db.models.fields.related import ForeignKey, ManyToManyField @@ -22,6 +22,8 @@ def ui_hints_for_model_field(model_field: object) -> dict: return {"ui:field": "file"} if isinstance(model_field, TranslationField): model_field = model_field.translated_field + if isinstance(model_field, ColorField): + return {"ui:widget": "color"} if isinstance(model_field, MarkdownField): # MarkdownField 는 TextField 하위 → 먼저 검사 return {"ui:widget": "textarea", "ui:field": "markdown"} if isinstance(model_field, TextField): diff --git a/app/event/sponsor/migrations/0019_historicalsponsortag_color_sponsortag_color.py b/app/event/sponsor/migrations/0019_historicalsponsortag_color_sponsortag_color.py new file mode 100644 index 0000000..21899d0 --- /dev/null +++ b/app/event/sponsor/migrations/0019_historicalsponsortag_color_sponsortag_color.py @@ -0,0 +1,30 @@ +import core.models +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [("sponsor", "0018_remove_sponsor_uq__spsr__name_name_ko_and_more")] + operations = [ + migrations.AddField( + model_name="historicalsponsortag", + name="color", + field=core.models.ColorField( + blank=True, + default=None, + help_text="태그 표시 색상 (예: #3498db). 지정하지 않으면 프론트엔드 기본색을 사용합니다.", + max_length=7, + null=True, + ), + ), + migrations.AddField( + model_name="sponsortag", + name="color", + field=core.models.ColorField( + blank=True, + default=None, + help_text="태그 표시 색상 (예: #3498db). 지정하지 않으면 프론트엔드 기본색을 사용합니다.", + max_length=7, + null=True, + ), + ), + ] diff --git a/app/event/sponsor/models.py b/app/event/sponsor/models.py index 87b5146..319d466 100644 --- a/app/event/sponsor/models.py +++ b/app/event/sponsor/models.py @@ -3,7 +3,7 @@ import functools import typing -from core.models import BaseAbstractModel, MarkdownField +from core.models import BaseAbstractModel, ColorField, MarkdownField from django.db import models from event.models import Event @@ -95,6 +95,12 @@ class SponsorTag(BaseAbstractModel): event = models.ForeignKey(Event, on_delete=models.PROTECT) name = models.CharField(max_length=256) + color = ColorField( + null=True, + blank=True, + default=None, + help_text="태그 표시 색상 (예: #3498db). 지정하지 않으면 프론트엔드 기본색을 사용합니다.", + ) class Meta: ordering = ["name"] diff --git a/app/event/sponsor/serializers.py b/app/event/sponsor/serializers.py index 03aa076..0ad1f72 100644 --- a/app/event/sponsor/serializers.py +++ b/app/event/sponsor/serializers.py @@ -1,10 +1,16 @@ -from event.sponsor.models import Sponsor, SponsorTier +from event.sponsor.models import Sponsor, SponsorTag, SponsorTier from rest_framework import serializers +class SponsorTagSerializer(serializers.ModelSerializer): + class Meta: + model = SponsorTag + fields = ("id", "name", "color") + + class SponsorSerializer(serializers.ModelSerializer): logo = serializers.FileField(source="logo.file", read_only=True) - tags = serializers.StringRelatedField(many=True, read_only=True) + tags = SponsorTagSerializer(many=True, read_only=True) class Meta: model = Sponsor diff --git a/app/event/sponsor/test/api_test.py b/app/event/sponsor/test/api_test.py index 1baf037..5fe6380 100644 --- a/app/event/sponsor/test/api_test.py +++ b/app/event/sponsor/test/api_test.py @@ -4,7 +4,7 @@ import pytest from django.urls import reverse from event.models import Event -from event.sponsor.models import Sponsor, SponsorTier, SponsorTierSponsorRelation +from event.sponsor.models import Sponsor, SponsorTag, SponsorTagRelation, SponsorTier, SponsorTierSponsorRelation from file.models import PublicFile from rest_framework.test import APIClient from user.models.organization import Organization @@ -80,6 +80,32 @@ def test_sponsor_filter_by_event_name(api_client: APIClient, two_events): assert response_data[0]["id"] == str(old_tier.id) +@pytest.mark.django_db +def test_sponsor_tags_include_color(api_client: APIClient, two_events): + _, new_event = two_events + tier = SponsorTier.objects.create(event=new_event, name="Gold", order=0) + sponsor = _make_sponsor(new_event, "New Sponsor", tier) + + # Given: 색상이 있는 태그와 없는 태그, 그리고 삭제된 태그가 붙어있음 + colored_tag = SponsorTag.objects.create(event=new_event, name="Keynote", color="#3498db") + plain_tag = SponsorTag.objects.create(event=new_event, name="Community") + deleted_tag = SponsorTag.objects.create(event=new_event, name="Deleted") + for tag in (colored_tag, plain_tag, deleted_tag): + SponsorTagRelation.objects.create(sponsor=sponsor, tag=tag) + SponsorTag.objects.filter(pk=deleted_tag.pk).delete() + + # When: 후원사 목록을 조회 + response = api_client.get(reverse("v1:sponsor-list")) + + # Then: 살아있는 태그만 id/name/color 형태로 반환 + assert response.status_code == http.HTTPStatus.OK + tags = response.json()[0]["sponsors"][0]["tags"] + assert sorted(tags, key=lambda t: t["name"]) == [ + {"id": str(plain_tag.id), "name": "Community", "color": None}, + {"id": str(colored_tag.id), "name": "Keynote", "color": "#3498db"}, + ] + + @pytest.mark.django_db def test_sponsor_no_events_returns_empty(api_client: APIClient): # When: 이벤트가 없을 때 요청 diff --git a/app/event/sponsor/views.py b/app/event/sponsor/views.py index c78911c..8fd23fc 100644 --- a/app/event/sponsor/views.py +++ b/app/event/sponsor/views.py @@ -4,7 +4,7 @@ from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from event.sponsor.filters import SponsorTierFilterSet -from event.sponsor.models import Sponsor, SponsorTier +from event.sponsor.models import Sponsor, SponsorTag, SponsorTier from event.sponsor.serializers import SponsorTierSerializer from rest_framework import mixins, viewsets @@ -14,7 +14,9 @@ class SponsorTierViewSet(ListOnlyFilterMixin, mixins.ListModelMixin, viewsets.Ge queryset = SponsorTier.objects.filter_active().prefetch_related( models.Prefetch( lookup="sponsors", - queryset=Sponsor.objects.filter_active().select_related("logo"), + queryset=Sponsor.objects.filter_active() + .select_related("logo") + .prefetch_related(models.Prefetch(lookup="tags", queryset=SponsorTag.objects.filter_active())), to_attr="_prefetched_active_sponsors", ) ) From c6dc7eed91e6b14ef60e2947ba72761647504546 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 2 Aug 2026 16:48:06 +0900 Subject: [PATCH 49/70] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EA=B7=B8=EB=A3=B9=EC=9D=84=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=ED=95=9C=20=EA=B2=BD=EC=9A=B0=20export=20=EC=8B=9C=20=EC=A0=9C?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EB=85=B8=EC=B6=9C=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/shop/products.py | 26 ++++- app/admin_api/test/shop/products_api_test.py | 74 +++++++++++++- app/admin_api/views/shop/orders.py | 5 +- app/shop/order/exports.py | 70 ++++++++++++- app/shop/order/test/exports_test.py | 101 ++++++++++++++++++- app/shop/product/models.py | 76 +++++++------- 6 files changed, 305 insertions(+), 47 deletions(-) diff --git a/app/admin_api/serializers/shop/products.py b/app/admin_api/serializers/shop/products.py index 12d5fae..0c144ed 100644 --- a/app/admin_api/serializers/shop/products.py +++ b/app/admin_api/serializers/shop/products.py @@ -99,16 +99,25 @@ class Meta: class TagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): leftover_stock = serializers.IntegerField(read_only=True, allow_null=True) + sold_count = serializers.IntegerField(read_only=True) class Meta: model = Tag - fields = COMMON_ADMIN_FIELDS + ("name_ko", "name_en", "stock", "max_quantity_per_user", "leftover_stock") + fields = COMMON_ADMIN_FIELDS + ( + "name_ko", + "name_en", + "stock", + "max_quantity_per_user", + "leftover_stock", + "sold_count", + ) class OptionGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedFieldModelSerializer): class OptionAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedModelSerializer): id = serializers.UUIDField(required=False, help_text="기존 Option 수정 시 PK 전달, 새로 추가 시 생략") leftover_stock = serializers.IntegerField(read_only=True, allow_null=True) + sold_count = serializers.IntegerField(read_only=True) class Meta: model = Option @@ -121,11 +130,24 @@ class Meta: "additional_price", "stock", "leftover_stock", + "sold_count", ) # group 은 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능. extra_kwargs = {"group": {"required": False}} list_serializer_class = InstanceListSerializer + def validate_stock(self, stock: int) -> int: + # stock=0 은 "무제한" sentinel, 판매 이력이 없는 옵션의 음수는 품절 처리 관용구라 둘 다 허용. + # 판매분보다 작은 값만 거절 — leftover_stock 이 음수가 되는 유일한 입력 경로다. + if self.instance is None or stock == 0: + return stock + sold_count: int = self.instance.sold_count + if sold_count and stock < sold_count: + raise serializers.ValidationError( + f"이미 {sold_count}개가 판매된 옵션입니다. 재고는 {sold_count} 이상이거나 0(무제한)이어야 합니다." + ) + return stock + options = OptionAdminSerializer(many=True, required=False) class Meta: @@ -239,6 +261,7 @@ class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, seria tag_set = serializers.PrimaryKeyRelatedField(many=True, queryset=Tag.objects.filter_active(), required=False) tag_set_detail = TagAdminSerializer(many=True, read_only=True, source="tag_set") leftover_stock = serializers.IntegerField(read_only=True, allow_null=True) + sold_count = serializers.IntegerField(read_only=True) current_status = serializers.ChoiceField(choices=Product.CurrentStatus.choices, read_only=True) image = serializers.PrimaryKeyRelatedField( queryset=PublicFile.objects.filter_active(), @@ -271,6 +294,7 @@ class Meta: "tag_set", "tag_set_detail", "leftover_stock", + "sold_count", "current_status", ) diff --git a/app/admin_api/test/shop/products_api_test.py b/app/admin_api/test/shop/products_api_test.py index 96632ac..993f38f 100644 --- a/app/admin_api/test/shop/products_api_test.py +++ b/app/admin_api/test/shop/products_api_test.py @@ -11,7 +11,8 @@ HTTP_403_FORBIDDEN, ) from shop.conftest import FAR_FUTURE, FAR_PAST -from shop.product.models import Category, CategoryGroup, OptionGroup, Product, Tag +from shop.order.models import OrderProductOptionRelation +from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag PRODUCT_SELECTABLES_URL = reverse("v1:admin-shop-product-list") + "selectables/" @@ -489,3 +490,74 @@ def test_admin_product_selectables_include_meta(api_client, ticket_product): ) # meta_schema 는 모델의 choices_meta_schema 를 반영한다. assert {"category", "price", "stock", "status"} <= set(body["meta_schema"]) + + +@pytest.fixture +def sold_option(order_factory, option_group) -> Option: + """paid OPR 2건이 붙은 옵션 — sold_count=2, stock=10.""" + sized = option_group.options.create(name="M", stock=10) + for _ in range(2): + OrderProductOptionRelation.objects.create( + order_product_relation=order_factory(status="completed").products.get(), + product_option_group=option_group, + product_option=sized, + ) + return sized + + +def _option_payload(option: Option, *, stock: int) -> dict: + # nested options 는 전량 동기화라 유지할 옵션을 모두 실어야 한다 (누락 시 soft delete). + return {"options": [{"id": str(option.id), "name_ko": option.name_ko, "name_en": option.name_en, "stock": stock}]} + + +@pytest.mark.django_db +def test_admin_option_update_rejects_stock_below_sold_count(api_client, option_group, sold_option): + # 이미 2개 팔린 옵션에 stock=1 → leftover_stock 이 -1 이 되는 유일한 입력 경로라 거절. + response = OptionGroupsAdminApi(http_client=api_client).update( + option_group.id, _option_payload(sold_option, stock=1) + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert "이미 2개가 판매된 옵션입니다" in str(response.json()) + sold_option.refresh_from_db() + assert sold_option.stock == 10 + + +@pytest.mark.django_db +def test_admin_option_update_allows_stock_equal_to_sold_count(api_client, option_group, sold_option): + # 판매 수량과 같은 값 = leftover 0 (판매 마감) — 운영자가 실제로 쓰는 마감 방식이라 허용. + response = OptionGroupsAdminApi(http_client=api_client).update( + option_group.id, _option_payload(sold_option, stock=2) + ) + assert response.status_code == HTTP_200_OK + sold_option.refresh_from_db() + assert sold_option.stock == 2 + assert sold_option.leftover_stock == 0 + + +@pytest.mark.django_db +def test_admin_option_update_allows_zero_stock_as_unlimited(api_client, option_group, sold_option): + response = OptionGroupsAdminApi(http_client=api_client).update( + option_group.id, _option_payload(sold_option, stock=0) + ) + assert response.status_code == HTTP_200_OK + sold_option.refresh_from_db() + assert sold_option.leftover_stock is None + + +@pytest.mark.django_db +def test_admin_option_update_allows_negative_stock_when_nothing_sold(api_client, option_group, option): + # 판매 이력 없는 옵션을 품절 노출시키는 관용구(-1) — stock=0 이 무제한이라 이 방법뿐이므로 막지 않는다. + response = OptionGroupsAdminApi(http_client=api_client).update(option_group.id, _option_payload(option, stock=-1)) + assert response.status_code == HTTP_200_OK + option.refresh_from_db() + assert option.stock == -1 + + +@pytest.mark.django_db +def test_admin_option_group_retrieve_exposes_sold_count(api_client, option_group, sold_option): + # stock=0 이면 leftover_stock 이 null 이라 어드민에서 판매 수량을 볼 수 없었다 — sold_count 로 항상 노출. + response = OptionGroupsAdminApi(http_client=api_client).retrieve(option_group.id) + assert response.status_code == HTTP_200_OK + payload = {o["id"]: o for o in response.json()["options"]}[str(sold_option.id)] + assert payload["sold_count"] == 2 + assert payload["leftover_stock"] == 8 diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py index 4b327d1..2f35ecc 100644 --- a/app/admin_api/views/shop/orders.py +++ b/app/admin_api/views/shop/orders.py @@ -254,9 +254,12 @@ def export(self, request: request.Request) -> StreamingHttpResponse: "주문": exports.OrderExportSerializer(instance=order_qs, many=True).export(), "주문상품": exports.OrderProductExportSerializer(instance=order_product_qs, many=True).export(), } - with pandas.ExcelWriter(fileio) as writer: + # engine 명시 — pandas 의 "auto" 는 설치된 패키지에 따라 openpyxl 로 바뀔 수 있고, + # autofit_columns 는 xlsxwriter 의 set_column API 에 의존한다. + with pandas.ExcelWriter(fileio, engine="xlsxwriter") as writer: for sheet_name, df in df_dict.items(): df.to_excel(writer, sheet_name=sheet_name, startrow=0, startcol=0) + exports.autofit_columns(writer.sheets[sheet_name], df) return StreamingHttpResponse( streaming_content=File(fileio), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/app/shop/order/exports.py b/app/shop/order/exports.py index dd656f2..3ee78e5 100644 --- a/app/shop/order/exports.py +++ b/app/shop/order/exports.py @@ -1,16 +1,62 @@ import collections.abc +import re import typing +import unicodedata import pandas from rest_framework import serializers from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation from shop.product.models import Option, OptionGroup +MIN_COLUMN_WIDTH = 8 +MAX_COLUMN_WIDTH = 50 +COLUMN_WIDTH_PADDING = 2 + +# 한 주문 상품이 같은 옵션 그룹을 여러 번 고른 경우의 2번째 이후 컬럼명 — `검은색 티셔츠 (2)`. +NUMBERED_OPTION_COLUMN = re.compile(r"^(?P.+) \((?P\d+)\)$") + + +def _display_width(value: object) -> int: + # 엑셀 열 너비는 글자 수 기준이라 한글/전각은 두 칸으로 세야 실제 표시 폭에 맞는다. + return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in str(value)) + + +def autofit_columns(worksheet: typing.Any, df: pandas.DataFrame, *, index: bool = True) -> None: + """헤더/값의 표시 폭에 맞춰 각 열 너비를 지정한다. `engine="xlsxwriter"` 전용.""" + offset = 1 if index else 0 + if index: + worksheet.set_column(0, 0, MIN_COLUMN_WIDTH) + + for position, column in enumerate(df.columns): + content_width = max( + [_display_width(column), *(_display_width(value) for value in df[column].dropna())], + ) + width = min(max(content_width + COLUMN_WIDTH_PADDING, MIN_COLUMN_WIDTH), MAX_COLUMN_WIDTH) + worksheet.set_column(position + offset, position + offset, width) + + +def _option_column_sort_key(column: str) -> tuple[str, int]: + match = NUMBERED_OPTION_COLUMN.match(column) + return (match["name"], int(match["nth"])) if match else (column, 1) + + +def _ordered_columns(df: pandas.DataFrame, fixed_labels: collections.abc.Sequence[str]) -> list[str]: + """고정 컬럼은 field_def 순서, 옵션 그룹 컬럼은 이름순으로 배치한다. + + 옵션 컬럼은 행마다 동적으로 붙어 기본 순서가 "먼저 등장한 순" — 데이터가 바뀌면 열 순서도 바뀌고 + `그룹명 (2)` 가 다른 그룹 뒤로 밀려 수기 집계에서 누락되기 쉽다. (그룹명, n) 정렬로 항상 붙여 둔다. + """ + present = [str(column) for column in df.columns] + fixed = [label for label in fixed_labels if label in present] + dynamic = sorted(set(present) - set(fixed), key=_option_column_sort_key) + return fixed + dynamic + class ListExportSerializer(serializers.ListSerializer): def export(self) -> pandas.DataFrame: field_def = self.child.Meta.field_def # type: ignore[attr-defined,union-attr] - return pandas.DataFrame(data=self.data).rename(columns=dict(field_def)) + df = pandas.DataFrame(data=self.data).rename(columns=dict(field_def)) + return df[_ordered_columns(df, [label for _, label in field_def])] class OrderExportSerializer(serializers.ModelSerializer): @@ -66,14 +112,28 @@ class Meta: def to_representation(self, instance: OrderProductRelation) -> dict[str, typing.Any]: result: dict[str, typing.Any] = super().to_representation(instance) - options: collections.abc.Iterable[OrderProductOptionRelation] = instance.options.filter_active() + options: collections.abc.Iterable[OrderProductOptionRelation] = ( + instance.options.filter_active() + .select_related("product_option_group", "product_option") + .order_by("product_option__priority", "created_at", "id") + ) + seen_per_group: collections.Counter[str] = collections.Counter() for option in options: option_group: OptionGroup = option.product_option_group - selected_option: Option = option.product_option + selected_option: Option | None = option.product_option name: str = option_group.name - value: str | None = option.custom_response if option_group.is_custom_response else selected_option.name - result[name] = value + value: str | None = ( + option.custom_response + if option_group.is_custom_response + else (selected_option.name if selected_option else None) + ) + + seen_per_group[name] += 1 + # 같은 그룹 옵션을 여러 개 고른 주문은 `그룹명`, `그룹명 (2)` … 로 컬럼을 나눈다. + # 한 키에 덮어쓰면 두 번째부터가 사라져 사이즈별 수량 집계가 어긋난다. + nth = seen_per_group[name] + result[name if nth == 1 else f"{name} ({nth})"] = value return result diff --git a/app/shop/order/test/exports_test.py b/app/shop/order/test/exports_test.py index b57bef2..9288aa2 100644 --- a/app/shop/order/test/exports_test.py +++ b/app/shop/order/test/exports_test.py @@ -1,6 +1,18 @@ +import io + +import pandas import pytest +from openpyxl import load_workbook +from openpyxl.utils import get_column_letter from rest_framework.fields import DateTimeField -from shop.order.exports import OrderExportSerializer, OrderProductExportSerializer +from shop.order.exports import ( + COLUMN_WIDTH_PADDING, + MAX_COLUMN_WIDTH, + MIN_COLUMN_WIDTH, + OrderExportSerializer, + OrderProductExportSerializer, + autofit_columns, +) from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation from shop.product.models import OptionGroup @@ -72,6 +84,71 @@ def test_order_product_export_flattens_options_as_dynamic_columns(ticket_product ] +@pytest.mark.django_db +def test_order_product_export_splits_repeated_options_of_one_group_into_numbered_columns(ticket_product, order_factory): + # 한 주문 상품이 같은 그룹 옵션을 여러 개 고른 경우 — 한 컬럼에 덮어쓰면 두 번째부터 사라져 + # 사이즈별 수량 집계가 어긋난다 (재고 음수 사고의 원인). + completed_order = order_factory(status="completed") + size_group = OptionGroup.objects.create(product=ticket_product, name="검은색 티셔츠") + small = size_group.options.create(name="S", priority=0) + large = size_group.options.create(name="L", priority=10) + opr = completed_order.products.first() + for selected in (large, small, large): + OrderProductOptionRelation.objects.create( + order_product_relation=opr, product_option_group=size_group, product_option=selected + ) + + df = OrderProductExportSerializer(instance=OrderProductRelation.objects.filter(id=opr.id), many=True).export() + row = df.to_dict(orient="records")[0] + # priority 순 정렬이라 S 가 먼저, 그 다음 L 두 벌. + assert row["검은색 티셔츠"] == "S" + assert row["검은색 티셔츠 (2)"] == "L" + assert row["검은색 티셔츠 (3)"] == "L" + + +@pytest.mark.django_db +def test_order_product_export_orders_option_columns_by_group_name(ticket_product, order_factory): + # 옵션 컬럼은 행마다 동적으로 붙어 기본값이 "먼저 등장한 순" — 데이터 순서에 따라 `흰색 (2)` 가 + # `검은색 (3)` 앞으로 끼어들어 같은 그룹이 흩어진다. 고정 컬럼 뒤에 (그룹명, n) 순으로 배치한다. + white = OptionGroup.objects.create(product=ticket_product, name="흰색 티셔츠") + black = OptionGroup.objects.create(product=ticket_product, name="검은색 티셔츠") + for group, counts in ((white, 2), (black, 3)): + opr = order_factory(status="completed").products.get() + selected = group.options.create(name="M") + for _ in range(counts): + OrderProductOptionRelation.objects.create( + order_product_relation=opr, product_option_group=group, product_option=selected + ) + + df = OrderProductExportSerializer(instance=OrderProductRelation.objects.filter_active(), many=True).export() + fixed = [label for _, label in OrderProductExportSerializer.Meta.field_def] + assert list(df.columns) == [ + *fixed, + "검은색 티셔츠", + "검은색 티셔츠 (2)", + "검은색 티셔츠 (3)", + "흰색 티셔츠", + "흰색 티셔츠 (2)", + ] + + +@pytest.mark.django_db +def test_order_product_export_renders_unselected_optional_option_as_none(ticket_product, order_factory): + # placeholder_mode=OPTIONAL 그룹은 product_option 없이 저장될 수 있다 — export 가 터지면 안 된다. + completed_order = order_factory(status="completed") + opr = completed_order.products.first() + OrderProductOptionRelation.objects.create( + order_product_relation=opr, + product_option_group=OptionGroup.objects.create( + product=ticket_product, name="사이즈", placeholder_mode=OptionGroup.PlaceholderMode.OPTIONAL + ), + product_option=None, + ) + + df = OrderProductExportSerializer(instance=OrderProductRelation.objects.filter(id=opr.id), many=True).export() + assert df.to_dict(orient="records")[0]["사이즈"] is None + + @pytest.mark.django_db def test_order_product_export_calling_export_on_child_raises_to_force_list_serializer(): # 단건 OrderProductExportSerializer.export() 는 NotImplemented — `many=True` 강제하는 guard. @@ -83,3 +160,25 @@ def test_order_product_export_calling_export_on_child_raises_to_force_list_seria def test_order_export_calling_export_on_child_raises(): with pytest.raises(NotImplementedError): OrderExportSerializer().export() + + +def test_autofit_columns_sizes_each_column_to_its_widest_cell(): + long_header = "긴한글헤더입니다" + df = pandas.DataFrame({"짧음": ["a"], long_header: ["x"], "값이긴열": ["가" * 40]}) + fileio = io.BytesIO() + with pandas.ExcelWriter(fileio, engine="xlsxwriter") as writer: + df.to_excel(writer, sheet_name="s") + autofit_columns(writer.sheets["s"], df) + + # xlsxwriter 는 같은 너비의 인접 열을 하나의 range 로 묶어 쓰므로 min~max 로 펼쳐서 읽는다. + # 되읽은 너비에는 폰트 보정분(~0.71)이 붙어 abs=1 로 비교. + widths = { + get_column_letter(index): dim.width + for dim in load_workbook(fileio)["s"].column_dimensions.values() + for index in range(dim.min, dim.max + 1) + } + assert widths["A"] == pytest.approx(MIN_COLUMN_WIDTH, abs=1) # index 열 + assert widths["B"] == pytest.approx(MIN_COLUMN_WIDTH, abs=1) # 헤더 "짧음" 폭 4 → 하한 + # 헤더가 값보다 넓으면 헤더 기준. 한글은 2칸으로 센다. + assert widths["C"] == pytest.approx(len(long_header) * 2 + COLUMN_WIDTH_PADDING, abs=1) + assert widths["D"] == pytest.approx(MAX_COLUMN_WIDTH, abs=1) # 값 폭 80 → 상한 diff --git a/app/shop/product/models.py b/app/shop/product/models.py index b72c92c..9b4c45c 100644 --- a/app/shop/product/models.py +++ b/app/shop/product/models.py @@ -87,21 +87,21 @@ def __str__(self) -> str: # pragma: no cover @functools.cached_property def leftover_stock(self) -> int | None: """해당 태그에 속한 상품들의 재고를 반환합니다.""" + return (self.stock - self.sold_count) if self.stock else None + + @functools.cached_property + def sold_count(self) -> int: + """해당 태그에 속한 상품들의 판매 수량을 반환합니다.""" from shop.order.models import OrderProductRelation return ( - ( - self.stock - - OrderProductRelation.objects.filter_active() - .filter( - product__tags__tag=self, - single_product_cart__isnull=True, - status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, - ) - .count() + OrderProductRelation.objects.filter_active() + .filter( + product__tags__tag=self, + single_product_cart__isnull=True, + status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, ) - if self.stock - else None + .count() ) def get_user_taken_stock_count(self, *, user: "UserExt", include_cart: bool, include_purchased: bool) -> int: @@ -230,25 +230,25 @@ def current_status(self) -> "Product.CurrentStatus": return self.CurrentStatus.ACTIVE @functools.cached_property - def leftover_stock(self) -> int | None: - """해당 상품의 재고를 반환합니다.""" + def sold_count(self) -> int: + """해당 상품의 판매 수량을 반환합니다.""" from shop.order.models import OrderProductRelation return ( - ( - self.stock - - OrderProductRelation.objects.filter_active() - .filter( - product=self, - single_product_cart__isnull=True, - status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, - ) - .count() + OrderProductRelation.objects.filter_active() + .filter( + product=self, + single_product_cart__isnull=True, + status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, ) - if self.stock - else None + .count() ) + @functools.cached_property + def leftover_stock(self) -> int | None: + """해당 상품의 재고를 반환합니다.""" + return (self.stock - self.sold_count) if self.stock else None + def get_user_taken_stock_count(self, *, user: "UserExt", include_cart: bool, include_purchased: bool) -> int: """해당 유저가 담거나 구매한 상품의 수량을 반환합니다.""" from shop.order.models import OrderProductRelation @@ -429,26 +429,26 @@ def __str__(self) -> str: # pragma: no cover return f"{self.name} ({self.additional_price}원)" @functools.cached_property - def leftover_stock(self) -> int | None: - """해당 옵션의 재고를 반환합니다.""" + def sold_count(self) -> int: + """해당 옵션의 판매 수량을 반환합니다.""" from shop.order.models import OrderProductOptionRelation, OrderProductRelation return ( - ( - self.stock - - OrderProductOptionRelation.objects.filter_active() - .filter( - product_option=self, - order_product_relation__single_product_cart__isnull=True, - order_product_relation__deleted_at__isnull=True, - order_product_relation__status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, - ) - .count() + OrderProductOptionRelation.objects.filter_active() + .filter( + product_option=self, + order_product_relation__single_product_cart__isnull=True, + order_product_relation__deleted_at__isnull=True, + order_product_relation__status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, ) - if self.stock - else None + .count() ) + @functools.cached_property + def leftover_stock(self) -> int | None: + """해당 옵션의 재고를 반환합니다.""" + return (self.stock - self.sold_count) if self.stock else None + def get_user_taken_stock_count(self, *, user: "UserExt", include_cart: bool, include_purchased: bool) -> int: """해당 유저가 담거나 구매한 옵션의 수량을 반환합니다.""" from shop.order.models import OrderProductOptionRelation, OrderProductRelation From ca4e21cb34e2a1b1ed7834b673792905a5c7cd09 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 2 Aug 2026 17:10:55 +0900 Subject: [PATCH 50/70] =?UTF-8?q?fix:=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EB=82=B4=EC=97=90=EC=84=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EA=B0=80=20=EB=92=A4=EC=A7=91=ED=9E=88?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=ED=95=AD=EC=83=81=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=ED=95=98=EA=B2=8C=20=EB=85=B8=EC=B6=9C=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/test/django_orm_test.py | 29 +++++++++++++++++++++++++++-- app/core/util/django_orm.py | 5 +++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py index db00583..e3b2f2b 100644 --- a/app/core/test/django_orm_test.py +++ b/app/core/test/django_orm_test.py @@ -1,7 +1,12 @@ import pytest -from core.util.django_orm import apply_diff_to_model, model_to_identifier +from core.util.django_orm import apply_diff_to_model, model_to_identifier, model_to_jsonable_dict from django.utils.translation import override -from event.presentation.models import Presentation +from event.presentation.models import ( + Presentation, + PresentationCategory, + PresentationCategoryRelation, + PresentationSpeaker, +) from model_bakery import baker @@ -26,3 +31,23 @@ def test_apply_diff_to_model_applies_plain_field(db): presentation.refresh_from_db() assert presentation.slideshow_url == "https://example.com/new" + + +def test_model_to_jsonable_dict_sorts_reverse_relation_identifiers(db): + presentation = baker.make(Presentation) + speakers = baker.make(PresentationSpeaker, presentation=presentation, _quantity=3) + + snapshot = model_to_jsonable_dict(presentation)["model_data"][model_to_identifier(presentation)] + + assert snapshot["speakers"] == sorted(model_to_identifier(speaker) for speaker in speakers) + + +def test_model_to_jsonable_dict_sorts_many_to_many_identifiers(db): + presentation = baker.make(Presentation) + categories = baker.make(PresentationCategory, type=presentation.type, _quantity=3) + for category in categories: + baker.make(PresentationCategoryRelation, presentation=presentation, category=category) + + snapshot = model_to_jsonable_dict(presentation)["model_data"][model_to_identifier(presentation)] + + assert snapshot["categories"] == sorted(model_to_identifier(category) for category in categories) diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 2af8691..cccd3dd 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -126,7 +126,8 @@ def _model_to_jsonable_dict( # noqa: C901 jsonable_value.append(key) if key not in converted_models: converted_models[key] = _model_to_jsonable_dict(v, converted_models, None, True) - jsonable_model_dict[field] = jsonable_value + # 관계 조회는 정렬이 없으면 DB 물리 순서를 따르므로, 순서 변동이 변경으로 오인되지 않게 정렬한다. + jsonable_model_dict[field] = sorted(jsonable_value) else: jsonable_model_dict[field] = [arbitrary_value_to_basic_type(v) for v in value] else: @@ -147,7 +148,7 @@ def _model_to_jsonable_dict( # noqa: C901 model_identifier_list.append(key) if key not in converted_models: converted_models[key] = _model_to_jsonable_dict(model, converted_models, None, True) - jsonable_model_dict[leftover_field] = model_identifier_list + jsonable_model_dict[leftover_field] = sorted(model_identifier_list) return jsonable_model_dict From 6e0ecec8ccc3bfc45cc6ae4ef23639d4389523e0 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 2 Aug 2026 17:13:45 +0900 Subject: [PATCH 51/70] =?UTF-8?q?fix:=20RelatedField=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EC=9D=B4=20=EB=8B=A4=EB=A6=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20diff=EA=B0=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/test/django_orm_test.py | 33 +++++++++++++++++++++++++++++++- app/core/util/django_orm.py | 8 ++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py index e3b2f2b..9f1159b 100644 --- a/app/core/test/django_orm_test.py +++ b/app/core/test/django_orm_test.py @@ -1,5 +1,10 @@ import pytest -from core.util.django_orm import apply_diff_to_model, model_to_identifier, model_to_jsonable_dict +from core.util.django_orm import ( + apply_diff_to_model, + get_diff_data_from_jsonized_models, + model_to_identifier, + model_to_jsonable_dict, +) from django.utils.translation import override from event.presentation.models import ( Presentation, @@ -51,3 +56,29 @@ def test_model_to_jsonable_dict_sorts_many_to_many_identifiers(db): snapshot = model_to_jsonable_dict(presentation)["model_data"][model_to_identifier(presentation)] assert snapshot["categories"] == sorted(model_to_identifier(category) for category in categories) + + +PRESENTATION_KEY = "mdl:presentation:presentation:7dee621b-c3bb-4404-9900-fd796b03a5a0" +SPEAKER_A = "mdl:presentation:presentationspeaker:aee66cdf-d110-43e5-904a-41b8a7910923" +SPEAKER_B = "mdl:presentation:presentationspeaker:1367c0d5-312e-4606-b143-7d5ebbdafee1" + + +def test_get_diff_data_ignores_reordered_relation_identifiers(): + asis = {PRESENTATION_KEY: {"speakers": [SPEAKER_B, SPEAKER_A]}} + tobe = {PRESENTATION_KEY: {"speakers": [SPEAKER_A, SPEAKER_B]}} + + assert get_diff_data_from_jsonized_models(asis, tobe) == {} + + +def test_get_diff_data_detects_changed_relation_members(): + asis = {PRESENTATION_KEY: {"speakers": [SPEAKER_B]}} + tobe = {PRESENTATION_KEY: {"speakers": [SPEAKER_A, SPEAKER_B]}} + + assert get_diff_data_from_jsonized_models(asis, tobe) == {PRESENTATION_KEY: {"speakers": [SPEAKER_A, SPEAKER_B]}} + + +def test_get_diff_data_keeps_order_significant_for_plain_lists(): + asis = {PRESENTATION_KEY: {"tags": ["b", "a"]}} + tobe = {PRESENTATION_KEY: {"tags": ["a", "b"]}} + + assert get_diff_data_from_jsonized_models(asis, tobe) == {PRESENTATION_KEY: {"tags": ["a", "b"]}} diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index cccd3dd..081dd09 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -45,6 +45,11 @@ def is_identifier(identifier: str) -> bool: return isinstance(identifier, str) and identifier.startswith("mdl:") and len(identifier.split(":")) == 4 +def is_identifier_list(value: typing.Any) -> bool: + """Check if the given value is a list of model identifiers (a serialized m2m/reverse relation).""" + return isinstance(value, list) and all(is_identifier(item) for item in value) + + def identifier_to_model(identifier: str) -> models.Model | None: if not is_identifier(identifier): raise ValueError(f"Invalid model identifier: {identifier}") @@ -185,6 +190,9 @@ def get_diff_data_from_jsonized_models( value_b = model_tobe[field_name] if value_a == value_b: continue + # 관계 필드는 구성원만 의미가 있으므로, 순서만 다른 경우는 변경으로 보지 않는다. + if is_identifier_list(value_a) and is_identifier_list(value_b) and set(value_a) == set(value_b): + continue if value_a is not None and value_b is not None and type(value_a) != type(value_b): # noqa: E721 raise TypeError( f"Type mismatch for field '{field_name}' in model '{model_identifier}': " From 83bdc10b39a696649480e4b193fe10f55e018606 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 2 Aug 2026 17:18:03 +0900 Subject: [PATCH 52/70] =?UTF-8?q?fix:=20=EC=88=98=EC=A0=95=20=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20=EC=8A=B9=EC=9D=B8=20=EC=8B=9C=20=EA=B4=80=EA=B3=84?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=A0=81=EC=9A=A9=EC=9D=B4=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/test/django_orm_test.py | 32 +++++++++++++++++ app/core/util/django_orm.py | 34 +++++++++++-------- .../test/modification_audit_approval_test.py | 28 +++++++++++++++ 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py index 9f1159b..937ddd0 100644 --- a/app/core/test/django_orm_test.py +++ b/app/core/test/django_orm_test.py @@ -38,6 +38,38 @@ def test_apply_diff_to_model_applies_plain_field(db): assert presentation.slideshow_url == "https://example.com/new" +def test_apply_diff_to_model_ignores_reordered_reverse_relation(db): + presentation = baker.make(Presentation) + speakers = baker.make(PresentationSpeaker, presentation=presentation, _quantity=2) + diff = {model_to_identifier(presentation): {"speakers": [model_to_identifier(s) for s in reversed(speakers)]}} + + apply_diff_to_model(diff) + + assert set(presentation.speakers.values_list("id", flat=True)) == {s.pk for s in speakers} + + +def test_apply_diff_to_model_rejects_member_change_on_non_nullable_reverse_relation(db): + presentation = baker.make(Presentation) + speakers = baker.make(PresentationSpeaker, presentation=presentation, _quantity=2) + diff = {model_to_identifier(presentation): {"speakers": [model_to_identifier(speakers[0])]}} + + with pytest.raises(ValueError, match="speakers"): + apply_diff_to_model(diff) + + assert presentation.speakers.count() == 2 + + +def test_apply_diff_to_model_applies_many_to_many_members(db): + presentation = baker.make(Presentation) + old_category, new_category = baker.make(PresentationCategory, type=presentation.type, _quantity=2) + baker.make(PresentationCategoryRelation, presentation=presentation, category=old_category) + diff = {model_to_identifier(presentation): {"categories": [model_to_identifier(new_category)]}} + + apply_diff_to_model(diff) + + assert set(presentation.categories.values_list("id", flat=True)) == {new_category.pk} + + def test_model_to_jsonable_dict_sorts_reverse_relation_identifiers(db): presentation = baker.make(Presentation) speakers = baker.make(PresentationSpeaker, presentation=presentation, _quantity=3) diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py index 081dd09..dd7a098 100644 --- a/app/core/util/django_orm.py +++ b/app/core/util/django_orm.py @@ -262,6 +262,21 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s return resolved_models[key] +def _apply_related_diff(manager: models.manager.BaseManager, field_name: str, identifiers: list[str]) -> None: + related_model_instances = [identifier_to_model(item) for item in identifiers] + if None in related_model_instances: + raise ValueError(f"One or more related models not found for identifiers: {identifiers}") + + if {item.pk for item in manager.all()} == {item.pk for item in related_model_instances}: + return + + # 역참조 FK의 remove()는 Django가 field.null=True일 때만 제공하므로, 그 외에는 구성원 변경을 적용할 수 없다. + if not hasattr(manager, "remove"): + raise ValueError(f"Cannot change members of the non-nullable reverse relation '{field_name}'") + + manager.set(related_model_instances) + + def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[models.Model]: result_instances: list[models.Model] = [] @@ -285,20 +300,11 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m if not (related_model_instance := identifier_to_model(value)): raise ValueError(f"Related model not found for identifier: {value}") setattr(model_instance, field_name, related_model_instance) - elif isinstance(value, collections.abc.Iterable) and value and all(is_identifier(item) for item in value): - # If the value is a list of model identifiers, resolve them to model instances - related_model_instances = [identifier_to_model(item) for item in value] - if None in related_model_instances: - raise ValueError(f"One or more related models not found for identifiers: {value}") - - old_related_models = {item.pk: item for item in getattr(model_instance, field_name, [])} - new_related_models = {item.pk: item for item in related_model_instances} - - field = getattr(model_instance, field_name) - for del_pk in old_related_models.keys() - new_related_models.keys(): - field.remove(old_related_models[del_pk]) - for add_pk in new_related_models.keys() - old_related_models.keys(): - field.add(new_related_models[add_pk]) + elif is_identifier_list(value) and isinstance( + related_manager := getattr(model_instance, field_name, None), models.manager.BaseManager + ): + # If the value is a list of model identifiers, it is an m2m/reverse relation + _apply_related_diff(related_manager, field_name, value) else: setattr(model_instance, field_name, value) diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py index 4fc41c2..0b509bb 100644 --- a/app/participant_portal_api/test/modification_audit_approval_test.py +++ b/app/participant_portal_api/test/modification_audit_approval_test.py @@ -2,8 +2,12 @@ from types import SimpleNamespace import pytest +from core.util.django_orm import model_to_identifier from core.util.thread_local import thread_local +from django.contrib.contenttypes.models import ContentType from django.utils.translation import override +from event.presentation.models import Presentation, PresentationSpeaker +from model_bakery import baker from participant_portal_api.models import ModificationAudit from participant_portal_api.serializers.user import UserPortalSerializer from rest_framework import status @@ -75,3 +79,27 @@ def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester requester.refresh_from_db() assert requester.nickname_ko == "새한글이름" assert requester.nickname_en == "EnglishName" + + +def test_approve_presentation_audit_with_reordered_speakers(db, admin_client): + """발표자 순서만 뒤집힌 채 저장된 과거 수정 요청도 승인할 수 있어야 한다.""" + presentation = baker.make(Presentation) + speakers = baker.make(PresentationSpeaker, presentation=presentation, biography_ko="이전 소개", _quantity=2) + audit = ModificationAudit.objects.create( + instance_type=ContentType.objects.get_for_model(Presentation), + instance_id=str(presentation.pk), + original_data={model_to_identifier(presentation): {}}, + modification_data={ + model_to_identifier(presentation): { + "speakers": [model_to_identifier(speaker) for speaker in reversed(speakers)] + }, + model_to_identifier(speakers[0]): {"biography_ko": "새 소개"}, + }, + ) + + response = admin_client.patch(APPROVE_URL.format(audit_id=audit.id), data={}, format="json") + + assert response.status_code == status.HTTP_200_OK, response.data + speakers[0].refresh_from_db() + assert speakers[0].biography_ko == "새 소개" + assert set(presentation.speakers.values_list("id", flat=True)) == {speaker.pk for speaker in speakers} From 87620138cbb7cad2383fd8ad8e777491a0dd1f1f Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 9 Aug 2026 19:28:24 +0900 Subject: [PATCH 53/70] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EB=B3=84=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filtersets/shop/order_products.py | 63 +++ app/admin_api/serializers/notification.py | 4 +- app/admin_api/serializers/shop/orders.py | 85 +++- app/admin_api/test/helpers.py | 23 +- .../test/shop/order_notifications_api_test.py | 38 +- .../order_product_notifications_api_test.py | 382 ++++++++++++++++++ app/admin_api/urls.py | 10 +- .../views/shop/order_notifications.py | 148 +++++-- app/admin_api/views/shop/orders.py | 22 +- app/internal_api/views.py | 13 +- .../migrations/0005_sent_to_dedupe_key.py | 65 +++ app/notification/models/base.py | 24 +- app/shop/order/models.py | 82 +++- app/shop/payment_history/test/tasks_test.py | 7 +- 14 files changed, 839 insertions(+), 127 deletions(-) create mode 100644 app/admin_api/filtersets/shop/order_products.py create mode 100644 app/admin_api/test/shop/order_product_notifications_api_test.py create mode 100644 app/notification/migrations/0005_sent_to_dedupe_key.py diff --git a/app/admin_api/filtersets/shop/order_products.py b/app/admin_api/filtersets/shop/order_products.py new file mode 100644 index 0000000..c9b196a --- /dev/null +++ b/app/admin_api/filtersets/shop/order_products.py @@ -0,0 +1,63 @@ +from core.filter.multi_field import MultiFieldOrCharInFilter +from django_filters import rest_framework as filters +from shop.order.models import OrderProductRelation + + +class OrderProductRelationAdminFilterSet(filters.FilterSet): + id = filters.BaseInFilter(field_name="id") + order_id = filters.BaseInFilter(field_name="order_id") + user_id = filters.BaseInFilter(field_name="order__user_id") + user_unique_id = filters.BaseInFilter(field_name="order__user__unique_id") + name = MultiFieldOrCharInFilter( + field_names=[ + "order__user__nickname_ko", + "order__user__nickname_en", + "order__user__username", + "order__customer_info__name", + "ticket_info__name", + ], + lookup_expr="icontains", + ) + email = MultiFieldOrCharInFilter( + field_names=["order__user__email", "order__customer_info__email", "ticket_info__email"], + lookup_expr="icontains", + ) + imp_id = MultiFieldOrCharInFilter(field_names=["order_latest_imp_id"], lookup_expr="icontains") + + status = filters.BaseCSVFilter(field_name="status", lookup_expr="in") + order_status = filters.BaseCSVFilter(field_name="order_current_status", lookup_expr="in") + + first_paid_at_after = filters.DateTimeFilter(field_name="order_first_paid_at", lookup_expr="gte") + first_paid_at_before = filters.DateTimeFilter(field_name="order_first_paid_at", lookup_expr="lte") + + product_id = filters.BaseInFilter(field_name="product_id") + category_id = filters.BaseInFilter(field_name="product__category_id") + category_group_id = filters.BaseInFilter(field_name="product__category__group_id") + event_id = filters.BaseInFilter(field_name="product__category__event_id") + is_ticket = filters.BooleanFilter(field_name="product__category__is_ticket") + + price_min = filters.NumberFilter(field_name="price", lookup_expr="gte") + price_max = filters.NumberFilter(field_name="price", lookup_expr="lte") + + class Meta: + model = OrderProductRelation + fields = [ + "id", + "order_id", + "user_id", + "user_unique_id", + "name", + "email", + "imp_id", + "status", + "order_status", + "first_paid_at_after", + "first_paid_at_before", + "product_id", + "category_id", + "category_group_id", + "event_id", + "is_ticket", + "price_min", + "price_max", + ] diff --git a/app/admin_api/serializers/notification.py b/app/admin_api/serializers/notification.py index 3d7062f..97a955d 100644 --- a/app/admin_api/serializers/notification.py +++ b/app/admin_api/serializers/notification.py @@ -28,8 +28,8 @@ class _NotiHistorySentToAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): class Meta: - fields = COMMON_ADMIN_FIELDS + ("recipient", "context", "status", "failure_reason") - read_only_fields = (*COMMON_ADMIN_FIELDS, "status", "failure_reason") + fields = COMMON_ADMIN_FIELDS + ("recipient", "dedupe_key", "context", "status", "failure_reason") + read_only_fields = (*COMMON_ADMIN_FIELDS, "dedupe_key", "status", "failure_reason") class EmailNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase): diff --git a/app/admin_api/serializers/shop/orders.py b/app/admin_api/serializers/shop/orders.py index 7b9635c..8b57fb5 100644 --- a/app/admin_api/serializers/shop/orders.py +++ b/app/admin_api/serializers/shop/orders.py @@ -124,8 +124,19 @@ class OrderExportRequestSerializer(JsonSchemaSerializer, serializers.Serializer) include_refunded = serializers.BooleanField(default=False) +def _option_response_context(order_product_rel: OrderProductRelation) -> dict[str, Any]: + return { + o_rel.product_option_group.name: ( + o_rel.custom_response + if o_rel.product_option_group.is_custom_response + else (o_rel.product_option.name if o_rel.product_option else "") + ) + for o_rel in order_product_rel.options.all() + } + + class _OrderRecipientItemSerializer(serializers.Serializer): - """Order → Recipient ({recipient, context}) 변환. + """Order → Recipient ({recipient, context, dedupe_key}) 변환. customer_info / 첫 상품 / recipient 부재 시 None 반환. None-skip 의미를 가지므로 반드시 `SkipNoneListSerializer` (Meta.list_serializer_class) 와 함께 `many=True` 로 사용 — 단독 사용 시 호출자가 None 처리 책임. @@ -133,6 +144,7 @@ class _OrderRecipientItemSerializer(serializers.Serializer): recipient = serializers.CharField() context = serializers.JSONField() + dedupe_key = serializers.CharField(allow_blank=True) class Meta: list_serializer_class = SkipNoneListSerializer @@ -144,25 +156,52 @@ def to_representation(self, order: Order) -> Recipient | None: return None if not (recipient := getattr(customer_info, CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL[channel], "")): return None - if not (order_product_rel := next(iter(order.products.filter_active()), None)): + if not (order_product_rel := next(iter(order.products.all()), None)): return None - ctx: dict[str, Any] = { - o_rel.product_option_group.name: ( - o_rel.custom_response - if o_rel.product_option_group.is_custom_response - else (o_rel.product_option.name if o_rel.product_option else "") - ) - for o_rel in order_product_rel.options.filter_active() - } | order.build_notification_context() + ctx = _option_response_context(order_product_rel) | order.build_notification_context() + return {"recipient": recipient, "context": ctx | self.context["context_override"], "dedupe_key": ""} + - return {"recipient": recipient, "context": ctx | self.context["context_override"]} +class _OrderProductRecipientItemSerializer(serializers.Serializer): + """OrderProductRelation → Recipient 변환. None-skip 규약은 `_OrderRecipientItemSerializer` 와 동일. + + 한 주문에서 여러 건이 나오므로 같은 수신자 중복을 허용하기 위해 `dedupe_key` 에 OPR id 를 싣는다. + """ + + recipient = serializers.CharField() + context = serializers.JSONField() + dedupe_key = serializers.CharField(allow_blank=True) + + class Meta: + list_serializer_class = SkipNoneListSerializer + + def to_representation(self, opr: OrderProductRelation) -> Recipient | None: + channel: NotificationChannel = self.context["channel"] + + if (participant := opr.participant_info) is None: + return None + if not (recipient := getattr(participant, CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL[channel], "")): + return None + + ctx: dict[str, Any] = {} + if (order := opr.order) is not None and getattr(order, "customer_info", None): + ctx = order.build_notification_context() + + ctx |= opr.build_notification_context(participant=participant) | _option_response_context(opr) + + return { + "recipient": recipient, + "context": ctx | self.context["context_override"], + "dedupe_key": str(opr.id), + } class OrderSendNotificationPreviewResponseSerializer(JsonSchemaSerializer, serializers.Serializer): class RecipientItemSerializer(JsonSchemaSerializer, serializers.Serializer): recipient = serializers.CharField() context = serializers.JSONField() + dedupe_key = serializers.CharField(allow_blank=True) missing_variables = serializers.ListField(child=serializers.CharField()) template_variables = serializers.ListField(child=serializers.CharField()) @@ -170,6 +209,9 @@ class RecipientItemSerializer(JsonSchemaSerializer, serializers.Serializer): class OrderSendNotificationSerializer(JsonSchemaSerializer, serializers.Serializer): + recipient_item_serializer_class: type[serializers.Serializer] = _OrderRecipientItemSerializer + empty_recipients_message = "발송 대상이 없습니다 (filterset 결과 0건 또는 customer_info/첫 상품 부재)." + channel = serializers.ChoiceField(choices=NotificationChannel.choices) template_id = serializers.UUIDField() context_override = serializers.JSONField(required=False, default=dict) @@ -185,7 +227,11 @@ def validate(self, attrs: dict) -> dict: return {**attrs, "template": t} def _build_recipient_items(self) -> list[Recipient]: - return _OrderRecipientItemSerializer(instance=self.instance, many=True, context=self.validated_data).data + return self.recipient_item_serializer_class( + instance=self.instance, + many=True, + context=self.validated_data, + ).data def build_preview_response(self) -> OrderSendNotificationPreviewResponseSerializer: template_vars = self.validated_data["template"].template_variables @@ -199,11 +245,15 @@ def build_preview_response(self) -> OrderSendNotificationPreviewResponseSerializ }, ) + def build_rendered_html(self) -> str: + """filterset 에 걸린 첫 대상의 context 로 렌더한 HTML — 특정 건을 보려면 `?id=` 로 좁힌다.""" + if not (items := self._build_recipient_items()): + raise serializers.ValidationError(self.empty_recipients_message) + return self.validated_data["template"].build_preview_sent_to(items[0]["context"]).render_as_html() + def build_send_response(self) -> serializers.Serializer: if not (items := self._build_recipient_items()): - raise serializers.ValidationError( - "발송 대상이 없습니다 (filterset 결과 0건 또는 customer_info/첫 상품 부재)." - ) + raise serializers.ValidationError(self.empty_recipients_message) channel: NotificationChannel = self.validated_data["channel"] template = self.validated_data["template"] if invalid := [ @@ -218,3 +268,8 @@ def build_send_response(self) -> serializers.Serializer: history.send() history.refresh_from_db() return HISTORY_ADMIN_SERIALIZER_BY_CHANNEL[channel](instance=history) + + +class OrderProductSendNotificationSerializer(OrderSendNotificationSerializer): + recipient_item_serializer_class = _OrderProductRecipientItemSerializer + empty_recipients_message = "발송 대상이 없습니다 (filterset 결과 0건 또는 참가자/주문자 정보 부재)." diff --git a/app/admin_api/test/helpers.py b/app/admin_api/test/helpers.py index 4a47a26..8d63110 100644 --- a/app/admin_api/test/helpers.py +++ b/app/admin_api/test/helpers.py @@ -30,14 +30,27 @@ def export(self, params=None): return self.http_client.post(url, format="json") -class OrderNotificationsAdminApi(ModelApiFixture): +class _NotificationSendApiFixture(ModelApiFixture): + def _url(self, action, params): + url = reverse(f"{self.name}-{action}") + return f"{url}?{urlencode(params, doseq=True)}" if params else url + + def preview(self, data=None, params=None): + return self.http_client.post(self._url("preview", params), data, format="json") + + def send(self, data=None, params=None): + return self.http_client.post(self._url("send", params), data, format="json") + + def render(self, data=None, params=None): + return self.http_client.post(self._url("render-preview", params), data, format="json") + + +class OrderNotificationsAdminApi(_NotificationSendApiFixture): name: ClassVar[str] = "v1:admin-shop-order-notification" - def preview(self, data=None): - return self.http_client.post(reverse(f"{self.name}-preview"), data, format="json") - def send(self, data=None): - return self.http_client.post(reverse(f"{self.name}-send"), data, format="json") +class OrderProductNotificationsAdminApi(_NotificationSendApiFixture): + name: ClassVar[str] = "v1:admin-shop-order-product-notification" class CategoryGroupsAdminApi(ModelApiFixture): diff --git a/app/admin_api/test/shop/order_notifications_api_test.py b/app/admin_api/test/shop/order_notifications_api_test.py index ef26a7c..a1dd710 100644 --- a/app/admin_api/test/shop/order_notifications_api_test.py +++ b/app/admin_api/test/shop/order_notifications_api_test.py @@ -1,8 +1,5 @@ -from urllib.parse import urljoin - import pytest from admin_api.test.helpers import OrderNotificationsAdminApi -from django.conf import settings from notification.models.email import EmailNotificationHistory, EmailNotificationTemplate from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from shop.order.models import CustomerInfo @@ -27,7 +24,6 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client, {"channel": "email", "template_id": str(order_email_template.id)} ) assert response.status_code == HTTP_200_OK - scancode_url = urljoin(settings.BACKEND_DOMAIN, completed_order.scancode_path) # 서버에서 isoformat 문자열로 변환된 채로 응답 / DB 저장 (JSONField datetime 미지원 회피). first_paid_at_str = completed_order.first_paid_at.isoformat() assert response.json() == { @@ -36,14 +32,18 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client, { "recipient": "customer@example.com", "context": { - "scancode_url": scancode_url, "order_name": "파이콘 한국 2026 티켓", "first_paid_at": first_paid_at_str, "first_paid_price": 10000, "customer_name": "홍길동", "customer_phone": "01012345678", "customer_email": "customer@example.com", + "customer_organization": "", + "성함": "홍길동", + "성명": "홍길동", + "소속": "", }, + "dedupe_key": "", "missing_variables": [], } ], @@ -53,8 +53,6 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client, @pytest.mark.django_db def test_notification_preview_includes_free_completed_order(api_client, order_email_template, order_factory): order = order_factory(status="completed", product_price=0, imp_id=None) - scancode_url = urljoin(settings.BACKEND_DOMAIN, order.scancode_path) - response = OrderNotificationsAdminApi(http_client=api_client).preview( {"channel": "email", "template_id": str(order_email_template.id)} ) @@ -66,14 +64,18 @@ def test_notification_preview_includes_free_completed_order(api_client, order_em { "recipient": order.customer_info.email, "context": { - "scancode_url": scancode_url, "order_name": order.name, "first_paid_at": order.first_paid_at.isoformat(), "first_paid_price": 0, "customer_name": order.customer_info.name, "customer_phone": order.customer_info.phone, "customer_email": order.customer_info.email, + "customer_organization": "", + "성함": order.customer_info.name, + "성명": order.customer_info.name, + "소속": "", }, + "dedupe_key": "", "missing_variables": [], } ], @@ -94,16 +96,24 @@ def test_notification_preview_rejects_unknown_template_id(api_client, order_fact @pytest.mark.django_db -def test_notification_preview_excludes_refunded_orders(api_client, order_email_template, order_factory): - order_factory(status="refunded") +def test_notification_preview_includes_refunded_orders(api_client, order_email_template, order_factory): + order = order_factory(status="refunded") response = OrderNotificationsAdminApi(http_client=api_client).preview( {"channel": "email", "template_id": str(order_email_template.id)} ) assert response.status_code == HTTP_200_OK - assert response.json() == { - "template_variables": ["customer_email", "customer_name", "first_paid_price", "order_name"], - "recipients": [], - } + [recipient] = response.json()["recipients"] + assert recipient["recipient"] == order.customer_info.email + + +@pytest.mark.django_db +def test_notification_preview_excludes_unpaid_orders(api_client, order_email_template, order_factory): + order_factory(status="cart") + response = OrderNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(order_email_template.id)} + ) + assert response.status_code == HTTP_200_OK + assert response.json()["recipients"] == [] @pytest.mark.django_db diff --git a/app/admin_api/test/shop/order_product_notifications_api_test.py b/app/admin_api/test/shop/order_product_notifications_api_test.py new file mode 100644 index 0000000..a436ae0 --- /dev/null +++ b/app/admin_api/test/shop/order_product_notifications_api_test.py @@ -0,0 +1,382 @@ +from datetime import UTC, datetime +from urllib.parse import urljoin + +import pytest +from admin_api.test.helpers import OrderProductNotificationsAdminApi +from django.conf import settings +from notification.models.email import EmailNotificationHistory, EmailNotificationTemplate +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN +from shop.order.models import CustomerInfo, OrderProductRelation, TicketInfo + + +@pytest.fixture +def opr_email_template(superuser) -> EmailNotificationTemplate: + return EmailNotificationTemplate.objects.create( + code="ticket-qr", + title="티켓 QR 안내", + sent_from="from@example.com", + data=( + '{"title":"{{ product_name }} QR",' + '"from_":"f",' + '"send_to":"{{ participant_email }}",' + '"body":"{{ participant_name }}님 {{ scancode_url }}"}' + ), + created_by=superuser, + updated_by=superuser, + ) + + +@pytest.fixture +def two_ticket_order(order_factory, ticket_product): + order = order_factory(status="completed") + OrderProductRelation.objects.create( + order=order, + product=ticket_product, + price=ticket_product.price, + status=OrderProductRelation.OrderProductStatus.paid, + ) + return order + + +@pytest.mark.django_db +def test_opr_notification_preview_rejects_non_superuser(customer_client): + response = OrderProductNotificationsAdminApi(http_client=customer_client).preview({}) + assert response.status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_opr_notification_send_rejects_non_superuser(customer_client): + response = OrderProductNotificationsAdminApi(http_client=customer_client).send({}) + assert response.status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_opr_notification_preview_uses_product_scancode_url(api_client, opr_email_template, ticket_opr): + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + order = ticket_opr.order + assert recipient["dedupe_key"] == str(ticket_opr.id) + assert recipient["context"]["scancode_url"] == urljoin(settings.BACKEND_DOMAIN, ticket_opr.scancode_path) + assert recipient["context"]["order_name"] == order.name + + +@pytest.mark.django_db +def test_opr_notification_preview_fans_out_per_product(api_client, opr_email_template, two_ticket_order): + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + recipients = response.json()["recipients"] + assert len(recipients) == 2 + assert {r["recipient"] for r in recipients} == {"customer@example.com"} + assert len({r["context"]["scancode_url"] for r in recipients}) == 2 + assert {r["dedupe_key"] for r in recipients} == {str(p.id) for p in two_ticket_order.products.all()} + + +@pytest.mark.django_db +def test_opr_notification_send_creates_one_sent_to_per_product(api_client, opr_email_template, two_ticket_order): + response = OrderProductNotificationsAdminApi(http_client=api_client).send( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_201_CREATED + history = EmailNotificationHistory.objects.get(id=response.json()["id"]) + sent_to_list = history.sent_to_list.all() + assert len(sent_to_list) == 2 + assert {s.recipient for s in sent_to_list} == {"customer@example.com"} + assert {s.dedupe_key for s in sent_to_list} == {str(p.id) for p in two_ticket_order.products.all()} + + +@pytest.mark.django_db +def test_opr_notification_preview_prefers_ticket_info_over_customer_info(api_client, opr_email_template, ticket_opr): + TicketInfo.objects.create( + order_product_relation=ticket_opr, + name="참가자", + phone="01099998888", + email="participant@example.com", + organization="파이콘", + contribution_message="기여 메시지", + ) + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + assert recipient["recipient"] == "participant@example.com" + assert recipient["context"]["participant_name"] == "참가자" + assert recipient["context"]["participant_organization"] == "파이콘" + assert recipient["context"]["contribution_message"] == "기여 메시지" + assert recipient["context"]["customer_email"] == "customer@example.com" + + +@pytest.mark.django_db +def test_opr_notification_preview_falls_back_to_customer_info(api_client, opr_email_template, ticket_opr): + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + assert recipient["recipient"] == "customer@example.com" + assert recipient["context"]["participant_name"] == "홍길동" + + +@pytest.mark.django_db +def test_opr_notification_preview_includes_refunded_product(api_client, opr_email_template, two_ticket_order): + refunded = two_ticket_order.products.first() + refunded.status = OrderProductRelation.OrderProductStatus.refunded + refunded.save() + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + recipients = response.json()["recipients"] + assert len(recipients) == 2 + assert str(refunded.id) in {r["dedupe_key"] for r in recipients} + + +@pytest.mark.django_db +def test_opr_notification_preview_includes_fully_refunded_order(api_client, opr_email_template, order_factory): + order = order_factory(status="refunded") + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + assert recipient["dedupe_key"] == str(order.products.get().id) + + +@pytest.mark.django_db +def test_opr_notification_preview_excludes_unpaid_product(api_client, opr_email_template, order_factory): + order_factory(status="cart") + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + assert response.json()["recipients"] == [] + + +@pytest.mark.django_db +def test_opr_notification_preview_product_id_filter_scopes_to_that_product( + api_client, opr_email_template, order_factory, ticket_product, non_ticket_product +): + order = order_factory(status="completed") + OrderProductRelation.objects.create( + order=order, + product=non_ticket_product, + price=non_ticket_product.price, + status=OrderProductRelation.OrderProductStatus.paid, + ) + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)}, + params={"product_id": str(ticket_product.id)}, + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + assert recipient["context"]["product_name"] == ticket_product.name + + +@pytest.mark.django_db +def test_opr_notification_preview_status_filter_scopes_to_opr_status(api_client, opr_email_template, two_ticket_order): + used = two_ticket_order.products.first() + used.status = OrderProductRelation.OrderProductStatus.used + used.save() + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)}, + params={"status": "used"}, + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + assert recipient["dedupe_key"] == str(used.id) + + +@pytest.mark.django_db +def test_opr_notification_preview_is_ticket_filter(api_client, opr_email_template, order_factory, non_ticket_product): + order = order_factory(status="completed") + goods = OrderProductRelation.objects.create( + order=order, + product=non_ticket_product, + price=non_ticket_product.price, + status=OrderProductRelation.OrderProductStatus.paid, + ) + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)}, + params={"is_ticket": "true"}, + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + assert recipient["dedupe_key"] != str(goods.id) + + +@pytest.mark.django_db +def test_opr_notification_send_rejects_when_no_eligible_recipients(api_client, opr_email_template, ticket_opr): + CustomerInfo.objects.filter(order=ticket_opr.order).hard_delete() + + response = OrderProductNotificationsAdminApi(http_client=api_client).send( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "발송 대상이 없습니다" in str(response.json()) + + +@pytest.mark.django_db +def test_opr_notification_context_override_wins(api_client, opr_email_template, ticket_opr): + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + { + "channel": "email", + "template_id": str(opr_email_template.id), + "context_override": {"participant_name": "덮어쓴 이름"}, + } + ) + + assert response.status_code == HTTP_200_OK + [recipient] = response.json()["recipients"] + assert recipient["context"]["participant_name"] == "덮어쓴 이름" + + +@pytest.mark.django_db +def test_opr_notification_preview_query_count_does_not_grow_with_targets( + api_client, opr_email_template, order_factory, ticket_product, django_assert_max_num_queries +): + for _ in range(5): + order = order_factory(status="completed") + OrderProductRelation.objects.create( + order=order, + product=ticket_product, + price=ticket_product.price, + status=OrderProductRelation.OrderProductStatus.paid, + ) + + with django_assert_max_num_queries(12): + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + assert len(response.json()["recipients"]) == 10 + + +@pytest.mark.django_db +def test_opr_notification_preview_provides_korean_aliases(api_client, opr_email_template, ticket_opr): + TicketInfo.objects.create( + order_product_relation=ticket_opr, + name="참가자", + phone="01099998888", + email="participant@example.com", + organization="파이콘", + ) + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + ctx = response.json()["recipients"][0]["context"] + assert ctx["성함"] == ctx["성명"] == "참가자" + assert ctx["소속"] == "파이콘" + assert "연도" not in ctx + + +@pytest.mark.django_db +def test_opr_notification_preview_derives_year_from_event(api_client, opr_email_template, used_ticket_opr): + event = used_ticket_opr.product.category.event + event.event_start_at = datetime(2026, 8, 15, tzinfo=UTC) + event.save(update_fields=["event_start_at"]) + + response = OrderProductNotificationsAdminApi(http_client=api_client).preview( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + assert response.json()["recipients"][0]["context"]["연도"] == 2026 + + +@pytest.mark.django_db +def test_opr_notification_render_returns_html_for_first_target(api_client, opr_email_template, ticket_opr): + TicketInfo.objects.create( + order_product_relation=ticket_opr, + name="참가자", + phone="01099998888", + email="participant@example.com", + ) + + response = OrderProductNotificationsAdminApi(http_client=api_client).render( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + assert response["Content-Type"].startswith("text/html") + body = response.content.decode() + assert "참가자님" in body + assert urljoin(settings.BACKEND_DOMAIN, ticket_opr.scancode_path) in body + + +@pytest.mark.django_db +def test_opr_notification_render_scopes_to_filtered_target(api_client, opr_email_template, two_ticket_order): + second = two_ticket_order.products.last() + + response = OrderProductNotificationsAdminApi(http_client=api_client).render( + {"channel": "email", "template_id": str(opr_email_template.id)}, + params={"id": str(second.id)}, + ) + + assert response.status_code == HTTP_200_OK + assert urljoin(settings.BACKEND_DOMAIN, second.scancode_path) in response.content.decode() + + +@pytest.mark.django_db +def test_opr_notification_render_rejects_when_no_target(api_client, opr_email_template, ticket_opr): + CustomerInfo.objects.filter(order=ticket_opr.order).hard_delete() + + response = OrderProductNotificationsAdminApi(http_client=api_client).render( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_opr_notification_render_rejects_non_superuser(customer_client): + response = OrderProductNotificationsAdminApi(http_client=customer_client).render({}) + assert response.status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_opr_notification_render_escapes_participant_supplied_html(api_client, opr_email_template, ticket_opr): + # 참가자 정보는 구매자가 자유 입력하므로, 렌더 결과가 admin 브라우저에서 실행되면 stored XSS 가 된다. + payload = '' + TicketInfo.objects.create( + order_product_relation=ticket_opr, + name=payload, + phone="01099998888", + email="participant@example.com", + ) + + response = OrderProductNotificationsAdminApi(http_client=api_client).render( + {"channel": "email", "template_id": str(opr_email_template.id)} + ) + + assert response.status_code == HTTP_200_OK + body = response.content.decode() + assert payload not in body + assert "<img src=x onerror="alert(1)">" in body diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index aa15af4..7137080 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -26,7 +26,10 @@ NHNCloudSMSNotificationTemplateAdminViewSet, ) from admin_api.views.proxy.dooray import DoorayProxyView -from admin_api.views.shop.order_notifications import OrderNotificationAdminViewSet +from admin_api.views.shop.order_notifications import ( + OrderNotificationAdminViewSet, + OrderProductNotificationAdminViewSet, +) from admin_api.views.shop.orders import OrderAdminViewSet from admin_api.views.shop.products import ( CategoryAdminViewSet, @@ -120,6 +123,11 @@ admin_shop_router.register( "order-notifications", OrderNotificationAdminViewSet, basename="admin-shop-order-notification" ) +admin_shop_router.register( + "order-product-notifications", + OrderProductNotificationAdminViewSet, + basename="admin-shop-order-product-notification", +) admin_shop_router.register("product", ProductAdminViewSet, basename="admin-shop-product") admin_shop_router.register("category", CategoryAdminViewSet, basename="admin-shop-category") admin_shop_router.register("tag", TagAdminViewSet, basename="admin-shop-tag") diff --git a/app/admin_api/views/shop/order_notifications.py b/app/admin_api/views/shop/order_notifications.py index 78e24ff..780d74c 100644 --- a/app/admin_api/views/shop/order_notifications.py +++ b/app/admin_api/views/shop/order_notifications.py @@ -1,3 +1,4 @@ +from admin_api.filtersets.shop.order_products import OrderProductRelationAdminFilterSet from admin_api.filtersets.shop.orders import OrderAdminFilterSet from admin_api.serializers.notification import ( EmailNotificationHistoryAdminSerializer, @@ -5,78 +6,143 @@ NHNCloudSMSNotificationHistoryAdminSerializer, ) from admin_api.serializers.shop.orders import ( + OrderProductSendNotificationSerializer, OrderSendNotificationPreviewResponseSerializer, OrderSendNotificationSerializer, ) from core.authz import IsSuperUser from core.const.tag import OpenAPITag +from core.openapi.schemas import build_html_responses from core.viewset.json_schema_viewset import JsonSchemaMixin from core.viewset.selectables_viewset import SelectablesMixin from django.db import models from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_view from rest_framework import request, response, status, viewsets from rest_framework.decorators import action -from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation -from shop.payment_history.models import REFUNDABLE_STATUSES, PaymentHistory +from rest_framework.renderers import StaticHTMLRenderer +from shop.order.models import Order, OrderProductRelation +from shop.payment_history.models import PURCHASED_STATUSES, PaymentHistory -ACTION_METHODS = ["preview", "send"] +ACTION_METHODS = ["preview", "render_preview", "send"] -@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER]) for m in ACTION_METHODS}) -class OrderNotificationAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.GenericViewSet): +def _schema(summaries: dict[str, str]) -> dict: + return {m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER], summary=summaries[m]) for m in ACTION_METHODS} + + +_SEND_HISTORY_RESPONSE = PolymorphicProxySerializer( + component_name="OrderSendNotificationHistory", + serializers=[ + EmailNotificationHistoryAdminSerializer, + NHNCloudSMSNotificationHistoryAdminSerializer, + NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer, + ], + resource_type_field_name=None, +) + + +class _NotificationSendMixin(JsonSchemaMixin, SelectablesMixin, viewsets.GenericViewSet): http_method_names = ["post"] permission_classes = [IsSuperUser] + + @extend_schema(responses={status.HTTP_200_OK: OrderSendNotificationPreviewResponseSerializer}) + @action(detail=False, methods=["post"], url_path="preview") + def preview(self, request: request.Request) -> response.Response: + req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data) + req.is_valid(raise_exception=True) + return response.Response(data=req.build_preview_response().data, status=status.HTTP_200_OK) + + @extend_schema(responses=build_html_responses(names=["Order Notification Render Preview"])) + @action(detail=False, methods=["post"], url_path="render", renderer_classes=[StaticHTMLRenderer]) + def render_preview(self, request: request.Request) -> response.Response: + req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data) + req.is_valid(raise_exception=True) + return response.Response(data=req.build_rendered_html()) + + @extend_schema(responses={status.HTTP_201_CREATED: _SEND_HISTORY_RESPONSE}) + @action(detail=False, methods=["post"], url_path="send") + def send(self, request: request.Request) -> response.Response: + req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data) + req.is_valid(raise_exception=True) + return response.Response(data=req.build_send_response().data, status=status.HTTP_201_CREATED) + + +@extend_schema_view( + **_schema( + { + "preview": "주문 알림 발송 dry-run (recipient + context + missing_variables 조회)", + "render_preview": "주문 알림 렌더 미리보기 (첫 대상 기준 HTML)", + "send": "주문 알림 발송 (filterset 으로 대상 주문 지정, 결제까지 간 주문만)", + } + ) +) +class OrderNotificationAdminViewSet(_NotificationSendMixin): + """주문 단위 발송 — 주문 1건당 알림 1건. 수신처는 주문자. + + 구매자에게 보내는 알림은 전부 이쪽 담당 — 상품 단위 발송은 항상 참가자에게 간다. + """ + filterset_class = OrderAdminFilterSet serializer_class = OrderSendNotificationSerializer - # 발송 가능 상태(REFUNDABLE_STATUSES) 만 baked-in — admin 이 `?status=refunded` 등을 넘겨도 교집합 0건으로 보호. + # 환불 주문도 포함 — 환불 안내처럼 환불 이후에 보내야 하는 알림이 있다. queryset = ( Order.objects.filter_active() .annotate(current_status=PaymentHistory.objects.latest_per_order_field("status")) - .filter(current_status__in=REFUNDABLE_STATUSES) + .filter(current_status__in=PURCHASED_STATUSES) .select_related("customer_info") .prefetch_related( Order.prefetchs["_active_payment_histories"], models.Prefetch( "products", - queryset=OrderProductRelation.objects.filter_active().prefetch_related( - models.Prefetch( - "options", - queryset=OrderProductOptionRelation.objects.filter_active().select_related( - "product_option_group", - "product_option", - ), - ), - ), + queryset=OrderProductRelation.objects.filter_active().prefetch_active_options(), ), ) ) - @extend_schema( - summary="주문 알림 발송 dry-run (recipient + context + missing_variables 조회)", - responses={status.HTTP_200_OK: OrderSendNotificationPreviewResponseSerializer}, + +@extend_schema_view( + **_schema( + { + "preview": "주문 상품 알림 발송 dry-run (recipient + context + missing_variables 조회)", + "render_preview": "주문 상품 알림 렌더 미리보기 (첫 대상 기준 HTML)", + "send": "주문 상품 알림 발송 (filterset 으로 대상 상품 지정, 상품별 QR)", + } ) - @action(detail=False, methods=["post"], url_path="preview") - def preview(self, request: request.Request) -> response.Response: - req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data) - req.is_valid(raise_exception=True) - return response.Response(data=req.build_preview_response().data, status=status.HTTP_200_OK) +) +class OrderProductNotificationAdminViewSet(_NotificationSendMixin): + """상품(OPR) 단위 발송 — 티켓 N 장이면 알림 N 건. 수신처는 참가자, `scancode_url` 은 상품 QR.""" - @extend_schema( - summary="주문 알림 발송 (filterset 으로 대상 주문 지정, 환불 가능 상태만)", - responses={ - status.HTTP_201_CREATED: PolymorphicProxySerializer( - component_name="OrderSendNotificationHistory", - serializers=[ - EmailNotificationHistoryAdminSerializer, - NHNCloudSMSNotificationHistoryAdminSerializer, - NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer, - ], - resource_type_field_name=None, + filterset_class = OrderProductRelationAdminFilterSet + serializer_class = OrderProductSendNotificationSerializer + queryset = ( + OrderProductRelation.objects.filter_active() + .filter(order__isnull=False, status__in=OrderProductRelation.PURCHASED_OR_REFUNDED_STATUS) + .annotate( + order_current_status=PaymentHistory.objects.latest_per_order_field("status", outer_field="order_id"), + order_latest_imp_id=PaymentHistory.objects.latest_per_order_field("imp_id", outer_field="order_id"), + order_first_paid_at=( + PaymentHistory.objects.filter_active() + .filter(order_id=models.OuterRef("order_id")) + .order_by("created_at") + .values("created_at")[:1] ), - }, + ) + # 상품 가드와 짝을 맞추지 않으면 환불 대상이 부분 환불 주문에만 걸린다. + .filter(order_current_status__in=PURCHASED_STATUSES) + .select_related("product__category__event", "order", "order__customer_info", "ticket_info") + .prefetch_active_options() + .prefetch_related( + # order.build_notification_context() 의 first_paid_at/first_paid_price 용. + models.Prefetch( + "order__payment_histories", + queryset=PaymentHistory.objects.filter_active(), + to_attr="_active_payment_histories", + ), + models.Prefetch( + "order__products", + queryset=OrderProductRelation.objects.filter_active(), + to_attr="_active_products", + ), + ) + .order_by("order__created_at", "created_at", "pk") ) - @action(detail=False, methods=["post"], url_path="send") - def send(self, request: request.Request) -> response.Response: - req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data) - req.is_valid(raise_exception=True) - return response.Response(data=req.build_send_response().data, status=status.HTTP_201_CREATED) diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py index 2f35ecc..caf304d 100644 --- a/app/admin_api/views/shop/orders.py +++ b/app/admin_api/views/shop/orders.py @@ -21,7 +21,7 @@ from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets from rest_framework.decorators import action from shop.order import exports, imports -from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation +from shop.order.models import Order, OrderProductRelation from shop.payment_history.models import PURCHASED_STATUSES, REFUNDABLE_STATUSES, PaymentHistory from shop.product.models import Product from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer @@ -33,16 +33,7 @@ # OrderProductRelation + nested Options prefetch — `Order.products` 용. _OPR_PREFETCH_QS = ( - OrderProductRelation.objects.filter_active() - .select_related("product", "ticket_info") - .prefetch_related( - models.Prefetch( - "options", - queryset=OrderProductOptionRelation.objects.filter_active().select_related( - "product_option_group", "product_option" - ), - ), - ) + OrderProductRelation.objects.filter_active().select_related("product", "ticket_info").prefetch_active_options() ) # `Order.payment_histories` 용 prefetch — 최신순. @@ -237,14 +228,7 @@ def export(self, request: request.Request) -> StreamingHttpResponse: OrderProductRelation.objects.filter_active() .filter(order__in=order_qs) .select_related("product") - .prefetch_related( - models.Prefetch( - "options", - queryset=OrderProductOptionRelation.objects.filter_active().select_related( - "product_option_group", "product_option" - ), - ), - ) + .prefetch_active_options() .distinct() ) diff --git a/app/internal_api/views.py b/app/internal_api/views.py index 17a7de4..f97ce74 100644 --- a/app/internal_api/views.py +++ b/app/internal_api/views.py @@ -14,7 +14,7 @@ from internal_api.filters import DeskSupportFilterSet from internal_api.serializers import DeskSupportSerializer from rest_framework import mixins, request, response, status, viewsets -from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation +from shop.order.models import Order, OrderProductRelation from shop.payment_history.models import PaymentHistory from shop.serializers.refund import OrderTotalRefundSerializer @@ -57,16 +57,7 @@ class DeskSupportViewSet( models.Prefetch( lookup="products", queryset=( - OrderProductRelation.objects.filter_active() - .select_related("product") - .prefetch_related( - models.Prefetch( - lookup="options", - queryset=OrderProductOptionRelation.objects.filter_active().select_related( - "product_option_group", "product_option" - ), - ) - ) + OrderProductRelation.objects.filter_active().select_related("product").prefetch_active_options() ), ), models.Prefetch( diff --git a/app/notification/migrations/0005_sent_to_dedupe_key.py b/app/notification/migrations/0005_sent_to_dedupe_key.py new file mode 100644 index 0000000..0892cff --- /dev/null +++ b/app/notification/migrations/0005_sent_to_dedupe_key.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0.6 on 2026-08-09 05:49 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("notification", "0004_seed_payment_completed_email_template"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="emailnotificationhistorysentto", + name="uq_notification_emailnotificationhistorysentto_history_recipient", + ), + migrations.RemoveConstraint( + model_name="nhncloudkakaoalimtalknotificationhistorysentto", + name="uq_notification_nhncloudkakaoalimtalknotificationhistorysentto_history_recipient", + ), + migrations.RemoveConstraint( + model_name="nhncloudsmsnotificationhistorysentto", + name="uq_notification_nhncloudsmsnotificationhistorysentto_history_recipient", + ), + migrations.AddField( + model_name="emailnotificationhistorysentto", + name="dedupe_key", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="nhncloudkakaoalimtalknotificationhistorysentto", + name="dedupe_key", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="nhncloudsmsnotificationhistorysentto", + name="dedupe_key", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddConstraint( + model_name="emailnotificationhistorysentto", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("history", "recipient", "dedupe_key"), + name="uq_notification_emailnotificationhistorysentto_history_recipient", + ), + ), + migrations.AddConstraint( + model_name="nhncloudkakaoalimtalknotificationhistorysentto", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("history", "recipient", "dedupe_key"), + name="uq_notification_nhncloudkakaoalimtalknotificationhistorysentto_history_recipient", + ), + ), + migrations.AddConstraint( + model_name="nhncloudsmsnotificationhistorysentto", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("history", "recipient", "dedupe_key"), + name="uq_notification_nhncloudsmsnotificationhistorysentto_history_recipient", + ), + ), + ] diff --git a/app/notification/models/base.py b/app/notification/models/base.py index 00ac9ad..29fdbe4 100644 --- a/app/notification/models/base.py +++ b/app/notification/models/base.py @@ -35,6 +35,7 @@ class NotificationStatus(models.TextChoices): class Recipient(TypedDict): recipient: str context: NotRequired[dict[str, Any]] + dedupe_key: NotRequired[str] def _walk_strings(value: Any, fn: Any) -> Any: @@ -216,6 +217,8 @@ class NotificationHistorySentToBase(BaseAbstractModel): history: models.ForeignKey[NotificationHistoryBase] recipient = models.CharField(max_length=256) + # null 이 아닌 "" 기본값 — 안 쓰는 발송은 (history, recipient) 중복 방지가 그대로 유지된다. + dedupe_key = models.CharField(max_length=64, blank=True, default="") context = models.JSONField(default=dict) status = models.CharField( max_length=16, @@ -229,7 +232,7 @@ class Meta: abstract = True constraints = [ models.UniqueConstraint( - fields=["history", "recipient"], + fields=["history", "recipient", "dedupe_key"], condition=models.Q(deleted_at__isnull=True), name="uq_%(app_label)s_%(class)s_history_recipient", ), @@ -259,11 +262,16 @@ def assert_context_complete(self) -> None: f"without required context variables: {sorted(missing)}", ) - def render(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RAISE) -> dict[str, Any]: + def render( + self, + undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RAISE, + *, + autoescape: bool = False, + ) -> dict[str, Any]: # template_data를 JSON으로 먼저 파싱한 뒤 string value에만 Django Template을 적용 → # context가 JSON-special char(`"`, `\`, 줄바꿈 등)를 포함해도 결과 JSON 구조가 깨지지 않음. - # autoescape=False — 외부 채널(SMS, Kakao templateParameter)은 raw text 기대. HTML escape이 필요한 경우는 - # template 작성자가 명시적으로 |escape 필터를 사용해야 함. + # autoescape 기본 False — 외부 채널(SMS, Kakao templateParameter)은 raw text 기대. + # HTML 로 출력하는 경로는 반드시 autoescape=True (render_as_html 참고). template_class = self.history.template_class payload = self._parsed_template_data() @@ -285,11 +293,15 @@ def render(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandlin case UnhandledVariableHandling.REMOVE: rendered_context[key] = "" - ctx = Context(rendered_context, autoescape=False) + ctx = Context(rendered_context, autoescape=autoescape) return _walk_strings(payload, lambda s: Template(template_class._to_dtl(s)).render(ctx)) def render_as_html(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RANDOM) -> str: - return get_template(self.history.template_class.html_template_name).render(self.render(undef_var=undef_var)) + # autoescape=True 필수 — Template.render() 결과는 SafeString 이라 바깥 HTML 템플릿이 재이스케이프하지 않는다. + # 여기서 escape 하지 않으면 구매자가 입력한 이름/소속이 admin 브라우저에서 그대로 실행된다. + return get_template(self.history.template_class.html_template_name).render( + self.render(undef_var=undef_var, autoescape=True), + ) @property def payload(self) -> dict[str, Any]: diff --git a/app/shop/order/models.py b/app/shop/order/models.py index 9bfe017..4fab5a8 100644 --- a/app/shop/order/models.py +++ b/app/shop/order/models.py @@ -32,6 +32,16 @@ PAYMENT_HASH_LENGTH = 16 +def korean_alias_context(*, name: str, organization: str, year: int | None = None) -> dict: + """승인된 카카오 알림톡 템플릿의 한글 변수(`#{성함}` 등)용 별칭 — 변수명 변경이 재검수 대상이라 백엔드가 맞춘다. + + 수신자마다 다른 값이라 `context_override`(전 수신자 공통)로는 못 채운다. + `연도` 는 없으면 키를 뺀다 — 빈 문자열로 채우면 preview 의 누락 검사를 통과해 빈 채로 발송된다. + """ + aliases = {"성함": name, "성명": name, "소속": organization} + return aliases if year is None else aliases | {"연도": year} + + class PaymentPreparationMixin: id: UUID prepared_cart_snapshot: dict[str, typing.Any] | None @@ -155,14 +165,8 @@ def with_dto_prefetches(self) -> models.QuerySet[Order]: queryset=( OrderProductRelation.objects.filter_active() .select_related("product__category", "ticket_info") + .prefetch_active_options() .prefetch_related( - models.Prefetch( - "options", - queryset=OrderProductOptionRelation.objects.filter_active().select_related( - "product_option_group", - "product_option", - ), - ), models.Prefetch("issued_documents", queryset=IssuedDocument.objects.filter_active()), ) ), @@ -269,8 +273,8 @@ def latest_imp_id(self) -> str | None: def build_notification_context(self) -> dict: """결제 완료 알림 (auto + admin manual) 에서 공통으로 사용하는 Order-derived context. - `first_paid_at` 은 isoformat 문자열로 변환 — JSONField 저장 시 psycopg `json.dumps` 가 - datetime 을 직접 직렬화 못함. 호출자는 customer_info 존재를 사전 검증해야 함. + `first_paid_at` 은 isoformat 문자열로 변환 — JSONField 저장 시 psycopg `json.dumps` 가 datetime 을 직접 직렬화 못함. + 호출자는 customer_info 존재를 사전 검증해야 함. """ customer_info = self.customer_info return { @@ -280,7 +284,9 @@ def build_notification_context(self) -> dict: "customer_name": customer_info.name, "customer_phone": customer_info.phone, "customer_email": customer_info.email, - "scancode_url": urljoin(settings.BACKEND_DOMAIN, self.scancode_path), + "customer_organization": customer_info.organization or "", + # 주문은 event 를 몰라 연도는 빠진다. + **korean_alias_context(name=customer_info.name, organization=customer_info.organization or ""), } @functools.cached_property @@ -343,6 +349,19 @@ def not_fully_refundable_reason(self) -> str | None: return None +class OrderProductRelationQuerySet(BaseAbstractModelQuerySet): + def prefetch_active_options(self) -> models.QuerySet[OrderProductRelation]: + return self.prefetch_related( + models.Prefetch( + "options", + queryset=OrderProductOptionRelation.objects.filter_active().select_related( + "product_option_group", + "product_option", + ), + ), + ) + + class OrderProductRelation(ScanCodeMixin, IssuableMixin, BaseAbstractModel): ISSUED_DOCUMENT_TYPE = DocumentType.confirmation_of_participation scancode_prefix = "opr" @@ -366,6 +385,8 @@ class OrderProductStatus(models.TextChoices): single_product_cart: SingleProductCart | None options: BaseManager[OrderProductOptionRelation] + objects: OrderProductRelationQuerySet = OrderProductRelationQuerySet.as_manager() # type: ignore[assignment, misc] + issued_documents = GenericRelation( "document.IssuedDocument", content_type_field="issuable_content_type", @@ -440,6 +461,47 @@ def not_refundable_reason(self) -> str | None: return None + @property + def ticket_info_or_none(self) -> TicketInfo | None: + try: + ticket_info = self.ticket_info + except TicketInfo.DoesNotExist: + return None + return ticket_info if ticket_info.deleted_at is None else None + + @property + def customer_info_or_none(self) -> CustomerInfo | None: + customer_info = getattr(self.order or self.single_product_cart, "customer_info", None) + return customer_info if customer_info and customer_info.deleted_at is None else None + + @property + def participant_info(self) -> TicketInfo | CustomerInfo | None: + return self.ticket_info_or_none or self.customer_info_or_none + + def build_notification_context(self, participant: TicketInfo | CustomerInfo | None = None) -> dict: + if participant is None: + participant = self.participant_info + name = (getattr(participant, "name", "")) or "" + organization = (getattr(participant, "organization", "")) or "" + event = self.product.category.event + return { + "scancode_url": urljoin(settings.BACKEND_DOMAIN, self.scancode_path), + "product_name": self.product.name, + "product_price": self.price, + "product_donation_price": self.donation_price, + "product_status": self.get_status_display(), + "participant_name": name, + "participant_phone": (getattr(participant, "phone", "")) or "", + "participant_email": (getattr(participant, "email", "")) or "", + "participant_organization": organization, + "contribution_message": (getattr(participant, "contribution_message", "")) or "", + **korean_alias_context( + name=name, + organization=organization, + year=event.event_start_at.year if event and event.event_start_at else None, + ), + } + def build_document_context(self) -> dict: try: participant = self.ticket_info diff --git a/app/shop/payment_history/test/tasks_test.py b/app/shop/payment_history/test/tasks_test.py index 8280040..7f3726c 100644 --- a/app/shop/payment_history/test/tasks_test.py +++ b/app/shop/payment_history/test/tasks_test.py @@ -1,11 +1,9 @@ import logging import types from unittest.mock import patch -from urllib.parse import urljoin import pytest from core.models import BaseAbstractModelQuerySet -from django.conf import settings from notification.models import ( EmailNotificationHistory, EmailNotificationTemplate, @@ -111,7 +109,10 @@ def test_creates_email_history_when_template_exists(order_with_customer, email_t "customer_name": "홍길동", "customer_phone": "01012345678", "customer_email": "customer@example.com", - "scancode_url": urljoin(settings.BACKEND_DOMAIN, order_with_customer.scancode_path), + "customer_organization": "", + "성함": "홍길동", + "성명": "홍길동", + "소속": "", } assert sent_to.status == NotificationStatus.CREATED From 4e5fd3df66464bab41bf685b0d93a0649ec2cf27 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 10 Aug 2026 19:18:57 +0900 Subject: [PATCH 54/70] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EA=B4=80=EB=A0=A8=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/notification.py | 24 +++++++++ app/admin_api/test/notification_test.py | 34 +++++++++++++ app/core/external_apis/smtp_email.py | 12 ++++- app/core/test/smtp_email_test.py | 59 ++++++++++++++++++++++ app/notification/models/base.py | 16 ++++-- app/notification/models/email.py | 4 +- app/notification/models/nhn_cloud_sms.py | 1 + app/notification/test/history_send_test.py | 30 ++++++++--- app/notification/test/template_test.py | 18 +++++++ 9 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 app/core/test/smtp_email_test.py diff --git a/app/admin_api/serializers/notification.py b/app/admin_api/serializers/notification.py index 97a955d..befd149 100644 --- a/app/admin_api/serializers/notification.py +++ b/app/admin_api/serializers/notification.py @@ -1,3 +1,4 @@ +from json import loads as json_loads from typing import Any from core.const.serializer import COMMON_ADMIN_FIELDS @@ -23,6 +24,23 @@ ) from rest_framework import serializers + +def _validate_template_data(value: str, template_class: type[NotificationTemplateBase]) -> str: + # 에디터가 컴파일된 HTML을 통째로 넣는 실수를 발송 시점이 아니라 저장 시점에 잡는다. + try: + parsed = json_loads(value) + except ValueError as e: + raise serializers.ValidationError("올바른 JSON이 아닙니다.") from e + + if not isinstance(parsed, dict): + raise serializers.ValidationError('JSON object여야 합니다. (예: {"title": "제목", "body": "..."})') + + if missing := sorted(k for k in template_class.required_data_keys if not str(parsed.get(k) or "").strip()): + raise serializers.ValidationError(f"다음 key가 비어 있습니다: {missing}") + + return value + + # ---- SentTo nested ---------------------------------------------------------- @@ -70,6 +88,9 @@ class Meta: "sent_to_status_summary", ) + def validate_template_data(self, value: str) -> str: + return _validate_template_data(value, self.Meta.model.template_class) if value else value + def create(self, validated_data: dict[str, Any]) -> NotificationHistoryBase: # template이 명시되지 않은 templateless 경로면 transient (unsaved) template_class 인스턴스로 폴백. # Kakao는 template이 required + template_data/sent_from이 read-only라 or 우측이 실행되지 않음. @@ -155,6 +176,9 @@ class Meta: def get_template_variables(self, obj: NotificationTemplateBase) -> list[str]: return sorted(obj.template_variables) + def validate_data(self, value: str) -> str: + return _validate_template_data(value, self.Meta.model) + def render(self, context: dict[str, Any]) -> str: return self.instance.build_preview_sent_to(context).render_as_html(undef_var=UnhandledVariableHandling.RANDOM) diff --git a/app/admin_api/test/notification_test.py b/app/admin_api/test/notification_test.py index fb59b4d..15461e8 100644 --- a/app/admin_api/test/notification_test.py +++ b/app/admin_api/test/notification_test.py @@ -64,6 +64,40 @@ def test_template_create(api_client): assert EmailNotificationTemplate.objects.filter(code="new-tpl").exists() +@pytest.mark.django_db +@pytest.mark.parametrize( + "data", + [ + "Hello", # 에디터 컴파일 결과를 그대로 넣은 경우 + '["not", "an", "object"]', + '{"body":"b"}', # title 누락 + '{"title":" ","body":"b"}', + ], +) +def test_template_create_rejects_invalid_data(api_client, data): + response = api_client.post( + reverse("v1:admin-notification-email-template-list"), + data={"code": "bad-tpl", "title": "잘못됨", "sent_from": "from@example.com", "data": data}, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + assert not EmailNotificationTemplate.objects.filter(code="bad-tpl").exists() + + +@pytest.mark.django_db +def test_create_history_templateless_rejects_invalid_template_data(api_client): + response = api_client.post( + reverse("v1:admin-notification-email-history-list"), + data={ + "template_data": "plain", + "sent_from": "from@example.com", + "sent_to_list": [{"recipient": "to@example.com"}], + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST + + @pytest.mark.django_db def test_template_partial_update(api_client, email_template): response = api_client.patch( diff --git a/app/core/external_apis/smtp_email.py b/app/core/external_apis/smtp_email.py index 088ea75..4e2e204 100644 --- a/app/core/external_apis/smtp_email.py +++ b/app/core/external_apis/smtp_email.py @@ -1,3 +1,4 @@ +from email.policy import default as default_email_policy from logging import getLogger from typing import TypedDict, cast @@ -6,6 +7,15 @@ logger = getLogger(__name__) +# 기본값 78이면 긴 한글 제목이 RFC 2047 상한(75자)을 넘는 encoded-word로 접혀 일부 클라이언트에서 깨진다. +_EMAIL_POLICY = default_email_policy.clone(max_line_length=76) + + +class _SafeHeaderEmailMessage(EmailMessage): + # backend가 message()를 인자 없이 호출하므로 기본 policy 자체를 갈아끼운다. + def message(self, *, policy=_EMAIL_POLICY): # type: ignore[no-untyped-def] + return super().message(policy=policy) + class EmailPayload(TypedDict): title: str @@ -21,7 +31,7 @@ def send_message(self, *, data: SendParameters) -> None: if not payload.get("title"): raise ValueError("title is required in payload.") - message = EmailMessage( + message = _SafeHeaderEmailMessage( subject=payload["title"], body=payload.get("body", ""), from_email=data["sent_from"], diff --git a/app/core/test/smtp_email_test.py b/app/core/test/smtp_email_test.py new file mode 100644 index 0000000..21852e1 --- /dev/null +++ b/app/core/test/smtp_email_test.py @@ -0,0 +1,59 @@ +import re +from email.header import decode_header, make_header + +import pytest +from core.external_apis.__interface__ import SendParameters +from core.external_apis.smtp_email import email_client +from django.core import mail + +_MAX_ENCODED_WORD_LENGTH = 75 # RFC 2047, 구분자 포함 + +_LONG_KOREAN_SUBJECT = ( + "[파이콘 한국] 행사 일주일 전 꼭 확인해 주세요. / [PyCon Korea] One week to go — please check before you come" +) + + +def _params(**overrides) -> SendParameters: + return SendParameters( + payload=overrides.pop("payload", {"title": "제목", "body": "

본문

"}), + send_to=overrides.pop("send_to", "to@example.com"), + sent_from=overrides.pop("sent_from", "from@example.com"), + template_code=overrides.pop("template_code", ""), + ) + + +def _sent_subject_header() -> str: + head = mail.outbox[0].message().as_bytes().split(b"\r\n\r\n")[0].decode() + return re.search(r"^Subject:(.*?)(?=^\S+:)", head + "\nX:", re.S | re.M).group(1) + + +@pytest.mark.parametrize( + "subject", [_LONG_KOREAN_SUBJECT, "파이콘 한국 티켓 결제가 완료되었습니다!", "ASCII only subject"] +) +def test_send_message_subject_encoded_words_are_rfc2047_compliant(subject): + email_client.send_message(data=_params(payload={"title": subject, "body": "

본문

"})) + + header = _sent_subject_header() + encoded_words = [word for word in header.split() if word.startswith("=?")] + assert all(len(word) <= _MAX_ENCODED_WORD_LENGTH for word in encoded_words) + assert len({word.split("?")[2] for word in encoded_words}) <= 1 # base64/quoted-printable 혼용 금지 + assert str(make_header(decode_header(header.strip()))) == subject + + +def test_send_message_sends_html_body(): + email_client.send_message(data=_params(payload={"title": "제목", "body": "

본문

"})) + + message = mail.outbox[0] + assert message.content_subtype == "html" + assert message.body == "

본문

" + assert message.to == ["to@example.com"] + + +def test_send_message_requires_title(): + with pytest.raises(ValueError, match="title"): + email_client.send_message(data=_params(payload={"body": "

본문

"})) + + +def test_send_message_requires_sent_from(): + with pytest.raises(ValueError, match="sent_from"): + email_client.send_message(data=_params(sent_from="")) diff --git a/app/notification/models/base.py b/app/notification/models/base.py index 29fdbe4..9df4cbf 100644 --- a/app/notification/models/base.py +++ b/app/notification/models/base.py @@ -53,6 +53,7 @@ class NotificationTemplateBase(BaseAbstractModel): variable_start: ClassVar[str] = "{{" variable_end: ClassVar[str] = "}}" html_template_name: ClassVar[str] + required_data_keys: ClassVar[tuple[str, ...]] = () choices_meta_schema: ClassVar[dict] = { "code": {"label": "코드", "type": "string", "filter": "search"}, @@ -241,11 +242,20 @@ class Meta: def __str__(self) -> str: return f"{self.recipient} ({self.get_status_display()})" - def _parsed_template_data(self) -> Any: + def _parsed_template_data(self) -> dict[str, Any]: + # render 결과가 그대로 채널 payload가 되므로 JSON object가 아니면 여기서 fail-fast. + # (평문/HTML이 저장된 경우 Django template context나 채널 client에서 TypeError로 뒤늦게 터진다.) try: - return json_loads(self.history.template_data) + parsed = json_loads(self.history.template_data) except ValueError: - return self.history.template_data + parsed = None + + if not isinstance(parsed, dict): + raise ValueError( + f"Notification (template_code={self.history.template_code or '-'}) has invalid template_data: " + f"expected a JSON object, got {type(parsed).__name__ if parsed is not None else 'non-JSON text'}.", + ) + return parsed def _required_template_variables(self, payload: Any) -> set[str]: template_class = self.history.template_class diff --git a/app/notification/models/email.py b/app/notification/models/email.py index 27bc524..90f86db 100644 --- a/app/notification/models/email.py +++ b/app/notification/models/email.py @@ -12,6 +12,7 @@ class EmailNotificationTemplate(NotificationTemplateBase): html_template_name: ClassVar[str] = "email_preview.html" + required_data_keys: ClassVar[tuple[str, ...]] = ("title", "body") class EmailNotificationHistorySentTo(NotificationHistorySentToBase): @@ -19,8 +20,9 @@ class EmailNotificationHistorySentTo(NotificationHistorySentToBase): @property def payload(self) -> dict[str, Any]: + # body는 HTML이라 context를 escape, title은 메일 제목(plain text)이라 그대로 둔다. rendered = self.render() - rendered["body"] = self.render_as_html() + rendered["body"] = self.render(autoescape=True).get("body", "") return rendered diff --git a/app/notification/models/nhn_cloud_sms.py b/app/notification/models/nhn_cloud_sms.py index 44c5bb4..15a7032 100644 --- a/app/notification/models/nhn_cloud_sms.py +++ b/app/notification/models/nhn_cloud_sms.py @@ -12,6 +12,7 @@ class NHNCloudSMSNotificationTemplate(NotificationTemplateBase): html_template_name: ClassVar[str] = "nhn_cloud_sms_preview.html" + required_data_keys: ClassVar[tuple[str, ...]] = ("body",) # title은 MMS 전용이라 선택 class NHNCloudSMSNotificationHistorySentTo(NotificationHistorySentToBase): diff --git a/app/notification/test/history_send_test.py b/app/notification/test/history_send_test.py index 006d831..79a3e92 100644 --- a/app/notification/test/history_send_test.py +++ b/app/notification/test/history_send_test.py @@ -127,13 +127,13 @@ def test_history_send_parameters_uses_rendered_payload(system_user): @pytest.mark.django_db -def test_email_payload_body_is_html_rendered(system_user): - # 이메일 발송 시 payload["body"]는 HTML 템플릿으로 렌더링된 결과여야 함. +def test_email_payload_body_is_template_body_without_preview_chrome(system_user): + # 어드민 미리보기 껍데기(email_preview.html)의 아바타/날짜/"…에게" UI가 실제 메일에 섞이면 안 된다. tpl = EmailNotificationTemplate.objects.create( code="html-body", title="t", sent_from="a@b.c", - data='{"title":"안녕 {{ name }}","body":"본문 {{ name }}"}', + data='{"title":"안녕 {{ name }}","body":"

본문 {{ name }}

"}', created_by=system_user, updated_by=system_user, ) @@ -141,13 +141,27 @@ def test_email_payload_body_is_html_rendered(system_user): sent_to = history.sent_to_list.get() payload = sent_to.payload - # title은 plain text assert payload["title"] == "안녕 길동" - assert not payload["title"].strip().startswith("<") + assert payload["body"] == "

본문 길동

" + assert "email-main" not in payload["body"] + assert "에게" not in payload["body"] - # body는 HTML 렌더링 결과 - assert payload["body"].strip().startswith("<") - assert "길동" in payload["body"] + +@pytest.mark.django_db +def test_email_payload_escapes_context_in_body(system_user): + tpl = EmailNotificationTemplate.objects.create( + code="escape-body", + title="t", + sent_from="a@b.c", + data='{"title":"안녕 {{ name }}","body":"

{{ name }}

"}', + created_by=system_user, + updated_by=system_user, + ) + history = _create_history(tpl, context={"name": ""}) + payload = history.sent_to_list.get().payload + + assert payload["body"] == "

<script>alert(1)</script>

" + assert payload["title"] == "안녕 " # 제목은 plain text @pytest.mark.django_db diff --git a/app/notification/test/template_test.py b/app/notification/test/template_test.py index 285711b..553c1b9 100644 --- a/app/notification/test/template_test.py +++ b/app/notification/test/template_test.py @@ -130,6 +130,24 @@ def test_render_as_html_kakao_preview_renders_buttons(): assert "가기" in html +# ---- template_data 형식 검증 -------------------------------------------------- + + +def test_render_raises_on_non_json_template_data(): + # 에디터가 컴파일된 HTML을 data에 통째로 저장한 경우 — Django template context TypeError 대신 + # 발송 실패 사유로 읽히는 ValueError로 fail-fast. + tpl = EmailNotificationTemplate(data="Hello {{ name }}") + sent_to = tpl.build_preview_sent_to({"name": "길동"}) + with pytest.raises(ValueError, match="JSON object"): + sent_to.render_as_html() + + +def test_render_raises_on_json_non_object_template_data(): + tpl = EmailNotificationTemplate(data='["body"]') + with pytest.raises(ValueError, match="JSON object"): + tpl.build_preview_sent_to({}).render() + + # ---- JSON-unsafe context (per-string substitution 검증) ----------------------- From 082c4760e73e86eb338c5ef1e2827e62100abf82 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 10 Aug 2026 20:08:53 +0900 Subject: [PATCH 55/70] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EA=B3=BC=EB=8B=A4=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=B6=88=EA=B0=80=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/email_backends.py | 27 +++++++++++---- app/core/external_apis/smtp_email.py | 39 ++++++++++++++++++---- app/core/test/email_backends_test.py | 25 ++++++++++++++ app/core/test/smtp_email_test.py | 49 ++++++++++++++++++++++++++-- app/notification/tasks.py | 12 ++++++- 5 files changed, 136 insertions(+), 16 deletions(-) diff --git a/app/core/email_backends.py b/app/core/email_backends.py index b1b90d0..5ae6ff3 100644 --- a/app/core/email_backends.py +++ b/app/core/email_backends.py @@ -1,4 +1,4 @@ -from base64 import b64encode +from base64 import b64decode, b64encode from datetime import UTC, datetime, timedelta from logging import getLogger from smtplib import SMTPAuthenticationError @@ -41,15 +41,20 @@ def open(self) -> bool: self.password = saved_password @property - def _access_token(self) -> str: + def _refresh_token(self) -> str: if not ( record := cast(GoogleOAuth2 | None, GoogleOAuth2.objects.filter_active().order_by("-created_at").first()) ): raise RuntimeError( "No GoogleOAuth2 refresh token configured. Run /v1/external-api/google-oauth2/authorize first.", ) - refresh_token = cast(str, record.refresh_token) + return cast(str, record.refresh_token) + + @property + def _access_token(self) -> str: + return self._access_token_for(self._refresh_token) + def _access_token_for(self, refresh_token: str) -> str: access_token, expires_at = _access_token_cache.get(refresh_token, (None, None)) if access_token and expires_at and expires_at > datetime.now(UTC): return access_token @@ -81,8 +86,18 @@ def _authenticate_xoauth2(self) -> None: # STARTTLS 이후 smtplib가 ehlo_resp를 비워두기 때문에, docmd("AUTH") 전에 EHLO를 재전송해야 503을 피할 수 있다. self.connection.ehlo_or_helo_if_needed() - auth_payload = f"user={self.username}\x01auth=Bearer {self._access_token}\x01\x01" + refresh_token = self._refresh_token + auth_payload = f"user={self.username}\x01auth=Bearer {self._access_token_for(refresh_token)}\x01\x01" auth_payload = f"XOAUTH2 {b64encode(auth_payload.encode()).decode()}" code, response = self.connection.docmd("AUTH", auth_payload) - if code != 235: - raise SMTPAuthenticationError(code, response) + if code == 235: + return + + # XOAUTH2 실패는 334 challenge로 오고, 빈 줄을 보내야 사유가 담긴 최종 응답을 받는다. + if code == 334: + logger.warning("Gmail rejected XOAUTH2: %s", b64decode(response).decode(errors="replace")) + code, response = self.connection.docmd("") + + # 거부된 토큰을 캐시에 남겨두면 만료될 때까지 같은 토큰으로 계속 실패한다. + _access_token_cache.pop(refresh_token, None) + raise SMTPAuthenticationError(code, response) diff --git a/app/core/external_apis/smtp_email.py b/app/core/external_apis/smtp_email.py index 4e2e204..155fb2b 100644 --- a/app/core/external_apis/smtp_email.py +++ b/app/core/external_apis/smtp_email.py @@ -1,20 +1,23 @@ -from email.policy import default as default_email_policy +from email.policy import SMTP as SMTP_EMAIL_POLICY from logging import getLogger +from smtplib import SMTPServerDisconnected +from threading import Lock from typing import TypedDict, cast from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters -from django.core.mail import EmailMessage +from django.core.mail import EmailMessage, get_connection +from django.core.mail.backends.base import BaseEmailBackend logger = getLogger(__name__) # 기본값 78이면 긴 한글 제목이 RFC 2047 상한(75자)을 넘는 encoded-word로 접혀 일부 클라이언트에서 깨진다. -_EMAIL_POLICY = default_email_policy.clone(max_line_length=76) +_EMAIL_POLICY = SMTP_EMAIL_POLICY.clone(max_line_length=76) class _SafeHeaderEmailMessage(EmailMessage): - # backend가 message()를 인자 없이 호출하므로 기본 policy 자체를 갈아끼운다. - def message(self, *, policy=_EMAIL_POLICY): # type: ignore[no-untyped-def] - return super().message(policy=policy) + # SMTP 백엔드가 policy를 명시로 넘기므로, 기본 인자가 아니라 인자 자체를 무시해야 적용된다. + def message(self, *, policy=None): # type: ignore[no-untyped-def] + return super().message(policy=_EMAIL_POLICY) class EmailPayload(TypedDict): @@ -23,6 +26,11 @@ class EmailPayload(TypedDict): class EmailClient(NotificationServiceInterface): + def __init__(self) -> None: + # Gmail은 메일마다 로그인하면 "454 Too many login attempts"로 차단하므로 커넥션을 프로세스 단위로 재사용한다. + self._connection: BaseEmailBackend | None = None + self._lock = Lock() + def send_message(self, *, data: SendParameters) -> None: if not data["sent_from"]: raise ValueError("sent_from is required to send Email.") @@ -38,8 +46,25 @@ def send_message(self, *, data: SendParameters) -> None: to=[data["send_to"]], ) message.content_subtype = "html" - sent_count = message.send(fail_silently=False) + + with self._lock: + sent_count = self._send(message) logger.info("Email send results: sent_count=%s to=%s", sent_count, data["send_to"]) + def _send(self, message: EmailMessage) -> int: + # send_messages()는 이미 열린 커넥션이면 발송 후 닫지 않는다 — open()을 먼저 해야 재사용이 성립한다. + for is_last_attempt in (False, True): + if self._connection is None: + self._connection = get_connection() + self._connection.open() + try: + return self._connection.send_messages([message]) + except SMTPServerDisconnected: + # 유휴 커넥션을 서버가 끊은 경우. 메시지가 수락되기 전이므로 재연결 후 한 번만 재시도한다. + self._connection = None + if is_last_attempt: + raise + return 0 + email_client = EmailClient() diff --git a/app/core/test/email_backends_test.py b/app/core/test/email_backends_test.py index 18d2257..12b88db 100644 --- a/app/core/test/email_backends_test.py +++ b/app/core/test/email_backends_test.py @@ -140,3 +140,28 @@ def test_missing_username_raises(self, backend): backend.username = "" with pytest.raises(SMTPAuthenticationError, match="EMAIL_HOST_USER"): backend._authenticate_xoauth2() + + def test_334_challenge_sends_empty_response_to_get_final_error( + self, backend, google_oauth_record, mock_token_endpoint + ): + backend.username = "user@example.com" + backend.connection = MagicMock() + challenge = base64.b64encode(b'{"status":"400","schemes":"Bearer"}') + backend.connection.docmd.side_effect = [(334, challenge), (535, b"5.7.8 Bad credentials")] + + with pytest.raises(SMTPAuthenticationError) as e: + backend._authenticate_xoauth2() + + assert e.value.smtp_code == 535 + assert backend.connection.docmd.call_args.args == ("",) + + def test_auth_failure_evicts_cached_token(self, backend, google_oauth_record, mock_token_endpoint): + backend.username = "user@example.com" + backend.connection = MagicMock() + backend.connection.docmd.return_value = (535, b"Auth failed") + + with pytest.raises(SMTPAuthenticationError): + backend._authenticate_xoauth2() + + # 캐시가 남아 있으면 만료될 때까지 같은 토큰으로 계속 실패한다. + assert google_oauth_record.refresh_token not in _access_token_cache diff --git a/app/core/test/smtp_email_test.py b/app/core/test/smtp_email_test.py index 21852e1..edba0c8 100644 --- a/app/core/test/smtp_email_test.py +++ b/app/core/test/smtp_email_test.py @@ -1,9 +1,12 @@ import re from email.header import decode_header, make_header +from email.policy import SMTP as SMTP_EMAIL_POLICY +from smtplib import SMTPServerDisconnected +from unittest.mock import MagicMock, patch import pytest from core.external_apis.__interface__ import SendParameters -from core.external_apis.smtp_email import email_client +from core.external_apis.smtp_email import EmailClient, email_client from django.core import mail _MAX_ENCODED_WORD_LENGTH = 75 # RFC 2047, 구분자 포함 @@ -23,7 +26,8 @@ def _params(**overrides) -> SendParameters: def _sent_subject_header() -> str: - head = mail.outbox[0].message().as_bytes().split(b"\r\n\r\n")[0].decode() + # SMTP 백엔드(_send)와 동일하게 policy를 명시로 넘겨 실제 발송되는 헤더를 재현한다. + head = mail.outbox[0].message(policy=SMTP_EMAIL_POLICY).as_bytes().split(b"\r\n\r\n")[0].decode() return re.search(r"^Subject:(.*?)(?=^\S+:)", head + "\nX:", re.S | re.M).group(1) @@ -57,3 +61,44 @@ def test_send_message_requires_title(): def test_send_message_requires_sent_from(): with pytest.raises(ValueError, match="sent_from"): email_client.send_message(data=_params(sent_from="")) + + +class TestConnectionReuse: + # 메일마다 로그인하면 Gmail이 "454 Too many login attempts"로 차단한다. + def test_connection_is_opened_once_and_reused(self): + client = EmailClient() + connection = MagicMock() + connection.send_messages.return_value = 1 + + with patch("core.external_apis.smtp_email.get_connection", return_value=connection) as factory: + for _ in range(3): + client.send_message(data=_params()) + + assert factory.call_count == 1 + assert connection.open.call_count == 1 + assert connection.send_messages.call_count == 3 + assert connection.close.call_count == 0 + + def test_disconnected_connection_is_reopened_once(self): + client = EmailClient() + dropped, fresh = MagicMock(), MagicMock() + dropped.send_messages.side_effect = SMTPServerDisconnected("idle timeout") + fresh.send_messages.return_value = 1 + + with patch("core.external_apis.smtp_email.get_connection", side_effect=[dropped, fresh]): + client.send_message(data=_params()) + + assert fresh.send_messages.call_count == 1 + + def test_repeated_disconnect_propagates(self): + client = EmailClient() + connection = MagicMock() + connection.send_messages.side_effect = SMTPServerDisconnected("gone") + + with ( + patch("core.external_apis.smtp_email.get_connection", return_value=connection), + pytest.raises(SMTPServerDisconnected), + ): + client.send_message(data=_params()) + + assert connection.send_messages.call_count == 2 diff --git a/app/notification/tasks.py b/app/notification/tasks.py index f5f7746..d49245f 100644 --- a/app/notification/tasks.py +++ b/app/notification/tasks.py @@ -1,3 +1,4 @@ +from smtplib import SMTPAuthenticationError, SMTPConnectError from traceback import format_exc from celery import shared_task @@ -5,7 +6,16 @@ from notification.models.base import NotificationStatus, slack_logger -@shared_task(ignore_result=True) +# 연결/인증 단계 실패만 재시도한다 — 메시지 전송이 시작된 뒤의 예외는 중복 발송 위험이 있다. +# Gmail은 짧은 시간에 로그인이 몰리면 XOAUTH2를 일시 거부하므로 백오프가 필요하다. +@shared_task( + ignore_result=True, + autoretry_for=(SMTPAuthenticationError, SMTPConnectError), + retry_backoff=30, + retry_backoff_max=600, + retry_jitter=True, + max_retries=5, +) def send_notification_to_recipient(model_label: str, sent_to_id: str, force: bool = False) -> None: sent_to_class = apps.get_model(model_label) sent_to = sent_to_class.objects.select_related("history").get(pk=sent_to_id) From a733aadea3e3d9dc65eafb6b80014081559893aa Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Tue, 11 Aug 2026 08:53:54 +0900 Subject: [PATCH 56/70] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=8B=9C=20=EC=BB=A4=EB=84=A5=EC=85=98?= =?UTF-8?q?=EC=9D=B4=20=EB=84=88=EB=AC=B4=20=EC=98=A4=EB=9E=98=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EB=90=98=EC=96=B4=20=EB=B0=9C=EC=86=A1=EC=9D=B4=20?= =?UTF-8?q?=EB=B6=88=EA=B0=80=EB=8A=A5=ED=95=B4=EC=A7=88=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/external_apis/smtp_email.py | 24 +++++++++++++++++++----- app/core/test/smtp_email_test.py | 28 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/app/core/external_apis/smtp_email.py b/app/core/external_apis/smtp_email.py index 155fb2b..6558bd8 100644 --- a/app/core/external_apis/smtp_email.py +++ b/app/core/external_apis/smtp_email.py @@ -1,6 +1,7 @@ +from contextlib import suppress from email.policy import SMTP as SMTP_EMAIL_POLICY from logging import getLogger -from smtplib import SMTPServerDisconnected +from smtplib import SMTPException, SMTPSenderRefused, SMTPServerDisconnected from threading import Lock from typing import TypedDict, cast @@ -14,6 +15,14 @@ _EMAIL_POLICY = SMTP_EMAIL_POLICY.clone(max_line_length=76) +def _is_stale_connection(error: SMTPException) -> bool: + # Gmail은 유휴 커넥션을 끊을 때 TCP를 그냥 닫기도 하고 MAIL FROM에 `451 4.4.2 Timeout - closing connection`을 + # 응답하기도 한다. 둘 다 메시지가 수락되기 전이라 재연결 후 재시도해도 중복 발송되지 않는다. + if isinstance(error, SMTPServerDisconnected): + return True + return isinstance(error, SMTPSenderRefused) and 400 <= error.smtp_code < 500 + + class _SafeHeaderEmailMessage(EmailMessage): # SMTP 백엔드가 policy를 명시로 넘기므로, 기본 인자가 아니라 인자 자체를 무시해야 적용된다. def message(self, *, policy=None): # type: ignore[no-untyped-def] @@ -59,12 +68,17 @@ def _send(self, message: EmailMessage) -> int: self._connection.open() try: return self._connection.send_messages([message]) - except SMTPServerDisconnected: - # 유휴 커넥션을 서버가 끊은 경우. 메시지가 수락되기 전이므로 재연결 후 한 번만 재시도한다. - self._connection = None - if is_last_attempt: + except (SMTPServerDisconnected, SMTPSenderRefused) as error: + if is_last_attempt or not _is_stale_connection(error): raise + self._discard_connection() return 0 + def _discard_connection(self) -> None: + connection, self._connection = self._connection, None + if connection is not None: + with suppress(Exception): + connection.close() + email_client = EmailClient() diff --git a/app/core/test/smtp_email_test.py b/app/core/test/smtp_email_test.py index edba0c8..92c2710 100644 --- a/app/core/test/smtp_email_test.py +++ b/app/core/test/smtp_email_test.py @@ -1,7 +1,7 @@ import re from email.header import decode_header, make_header from email.policy import SMTP as SMTP_EMAIL_POLICY -from smtplib import SMTPServerDisconnected +from smtplib import SMTPSenderRefused, SMTPServerDisconnected from unittest.mock import MagicMock, patch import pytest @@ -90,6 +90,32 @@ def test_disconnected_connection_is_reopened_once(self): assert fresh.send_messages.call_count == 1 + def test_idle_timeout_on_mail_from_is_retried(self): + # Gmail이 유휴 커넥션에 `451 4.4.2 Timeout - closing connection`을 응답하는 경우. + client = EmailClient() + stale, fresh = MagicMock(), MagicMock() + stale.send_messages.side_effect = SMTPSenderRefused(451, b"4.4.2 Timeout - closing connection.", "a@b.c") + fresh.send_messages.return_value = 1 + + with patch("core.external_apis.smtp_email.get_connection", side_effect=[stale, fresh]): + client.send_message(data=_params()) + + assert stale.close.call_count == 1 + assert fresh.send_messages.call_count == 1 + + def test_permanent_sender_rejection_is_not_retried(self): + client = EmailClient() + connection = MagicMock() + connection.send_messages.side_effect = SMTPSenderRefused(550, b"5.7.1 Sender denied", "a@b.c") + + with ( + patch("core.external_apis.smtp_email.get_connection", return_value=connection), + pytest.raises(SMTPSenderRefused), + ): + client.send_message(data=_params()) + + assert connection.send_messages.call_count == 1 + def test_repeated_disconnect_propagates(self): client = EmailClient() connection = MagicMock() From 323d8a4c160baa4c50f5492206535ad52de45873 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Tue, 11 Aug 2026 09:00:08 +0900 Subject: [PATCH 57/70] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=EC=97=90=20rate-limit=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/notification/tasks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/notification/tasks.py b/app/notification/tasks.py index d49245f..27cb264 100644 --- a/app/notification/tasks.py +++ b/app/notification/tasks.py @@ -8,8 +8,11 @@ # 연결/인증 단계 실패만 재시도한다 — 메시지 전송이 시작된 뒤의 예외는 중복 발송 위험이 있다. # Gmail은 짧은 시간에 로그인이 몰리면 XOAUTH2를 일시 거부하므로 백오프가 필요하다. +# rate_limit은 worker 단위이고 채널 공통 — 분당 90건대에서 Gmail이 차단했던 이력을 기준으로 여유를 뒀다. +# 운영 중 조정은 배포 없이 `app.control.rate_limit(task_name, "30/m")`로 가능하다. @shared_task( ignore_result=True, + rate_limit="60/m", autoretry_for=(SMTPAuthenticationError, SMTPConnectError), retry_backoff=30, retry_backoff_max=600, From e416f18ce2c2964f70bbc9e66c747759a415adcf Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Thu, 13 Aug 2026 11:01:14 +0900 Subject: [PATCH 58/70] =?UTF-8?q?feat:=20DB=20Constraint=20Error=EC=97=90?= =?UTF-8?q?=20=EB=8C=80=ED=95=9C=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/exception_handler.py | 36 +++++++++++ app/core/settings.py | 2 + app/core/test/exception_handler_test.py | 81 +++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 app/core/exception_handler.py create mode 100644 app/core/test/exception_handler_test.py diff --git a/app/core/exception_handler.py b/app/core/exception_handler.py new file mode 100644 index 0000000..8a735f0 --- /dev/null +++ b/app/core/exception_handler.py @@ -0,0 +1,36 @@ +from logging import getLogger + +from django.db import IntegrityError +from drf_standardized_errors.handler import ExceptionHandler +from rest_framework import exceptions, status + +logger = getLogger(__name__) + +UNIQUE_VIOLATION = "23505" +EXCLUSION_VIOLATION = "23P01" +CHECK_VIOLATION = "23514" + +CHECK_VIOLATION_MESSAGE = "입력값이 데이터 제약 조건을 위반했습니다." + + +class ConflictError(exceptions.APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = "이미 있는 데이터와 충돌합니다." + default_code = "conflict" + + +class DBConstraintExceptionHandler(ExceptionHandler): + def convert_known_exceptions(self, exc: Exception) -> Exception: + if isinstance(exc, IntegrityError) and (converted := self._convert_integrity_error(exc)) is not None: + logger.warning("DB constraint violation: %s", exc) + return converted + return super().convert_known_exceptions(exc) + + @staticmethod + def _convert_integrity_error(exc: IntegrityError) -> exceptions.APIException | None: + sqlstate = getattr(exc.__cause__, "sqlstate", None) + if sqlstate in (UNIQUE_VIOLATION, EXCLUSION_VIOLATION): + return ConflictError() + if sqlstate == CHECK_VIOLATION: + return exceptions.ValidationError(CHECK_VIOLATION_MESSAGE) + return None diff --git a/app/core/settings.py b/app/core/settings.py index 623b7bc..ae1708c 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -400,6 +400,8 @@ "URL_FORMAT_OVERRIDE": None, } +DRF_STANDARDIZED_ERRORS = {"EXCEPTION_HANDLER_CLASS": "core.exception_handler.DBConstraintExceptionHandler"} + # DRF Spectacular Settings SPECTACULAR_SETTINGS = { "TITLE": "PyCon KR Backend API", diff --git a/app/core/test/exception_handler_test.py b/app/core/test/exception_handler_test.py new file mode 100644 index 0000000..d7d938b --- /dev/null +++ b/app/core/test/exception_handler_test.py @@ -0,0 +1,81 @@ +import pytest +from core.exception_handler import ( + CHECK_VIOLATION, + EXCLUSION_VIOLATION, + UNIQUE_VIOLATION, + ConflictError, + DBConstraintExceptionHandler, +) +from django.db import IntegrityError +from model_bakery import baker +from rest_framework import exceptions +from rest_framework.status import HTTP_409_CONFLICT +from rest_framework.test import APIClient +from rest_framework.validators import UniqueValidator +from user.models import UserExt + +NOT_NULL_VIOLATION = "23502" + +# 어드민 Event 라우트는 basename 이 공개 Event 라우트("event")와 겹쳐 reverse 로 지목할 수 없다. +EVENT_ADMIN_LIST_URL = "/v1/admin-api/event/event/" + + +class _PgError(Exception): + def __init__(self, sqlstate: str) -> None: + super().__init__(sqlstate) + self.sqlstate = sqlstate + + +def _integrity_error(sqlstate: str) -> IntegrityError: + exc = IntegrityError("constraint violation") + exc.__cause__ = _PgError(sqlstate) + return exc + + +def _convert(exc: Exception) -> Exception: + return DBConstraintExceptionHandler(exc, {}).convert_known_exceptions(exc) + + +@pytest.fixture +def api_client(db) -> APIClient: + client = APIClient() + client.force_authenticate(user=UserExt.objects.create_superuser(username="admin", email="a@example.com")) + return client + + +@pytest.mark.parametrize("sqlstate", [UNIQUE_VIOLATION, EXCLUSION_VIOLATION]) +def test_conflicting_constraints_become_409(sqlstate): + assert isinstance(_convert(_integrity_error(sqlstate)), ConflictError) + + +def test_check_violation_becomes_400(): + assert isinstance(_convert(_integrity_error(CHECK_VIOLATION)), exceptions.ValidationError) + + +def test_other_violations_stay_unhandled(): + # NOT NULL·FK 위반은 코드 결함이라 500 으로 노출돼야 한다. + exc = _integrity_error(NOT_NULL_VIOLATION) + + assert _convert(exc) is exc + + +def test_integrity_error_without_db_cause_stays_unhandled(): + exc = IntegrityError("raised by hand") + + assert _convert(exc) is exc + + +@pytest.mark.django_db +def test_violation_reaching_the_db_returns_409_instead_of_500(api_client, monkeypatch): + """검증 통과 후 INSERT 가 제약에 걸리는 경합을 재현 — 실제 psycopg 예외로 409 까지 확인한다.""" + monkeypatch.setattr(UniqueValidator, "__call__", lambda *args, **kwargs: None) + event = baker.make("event.Event", name="파이콘 한국 2026") + + response = api_client.post( + EVENT_ADMIN_LIST_URL, + {"organization": str(event.organization_id), "name_ko": event.name}, + format="json", + ) + + assert response.status_code == HTTP_409_CONFLICT + assert response.json()["type"] == "client_error" From 60b17145511ff4df394288aa8b897683ed99b5b9 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Thu, 13 Aug 2026 12:19:45 +0900 Subject: [PATCH 59/70] =?UTF-8?q?feat:=20=EB=93=B1=EB=A1=9D=20=EB=8D=B0?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/internal_api.py | 56 ++ app/admin_api/serializers/shop/orders.py | 39 +- app/admin_api/test/internal_api_test.py | 251 +++++++++ .../order_product_relation_tags_api_test.py | 122 +++++ app/admin_api/urls.py | 12 +- app/admin_api/views/internal_api.py | 25 + app/admin_api/views/shop/orders.py | 87 ++- app/core/const/tag.py | 2 + .../serializer/nested_model_serializer.py | 4 +- app/core/settings.py | 1 + app/core/urls.py | 2 + app/internal_api/filters.py | 86 --- app/internal_api/migrations/0001_initial.py | 91 ++++ app/internal_api/migrations/__init__.py | 0 app/internal_api/models.py | 91 ++++ .../registration_desk/__init__.py | 0 app/internal_api/registration_desk/filters.py | 90 ++++ .../registration_desk/serializers.py | 315 +++++++++++ app/internal_api/registration_desk/urls.py | 12 + app/internal_api/registration_desk/views.py | 278 ++++++++++ app/internal_api/serializers.py | 147 ----- app/internal_api/test/__init__.py | 0 app/internal_api/test/config_model_test.py | 132 +++++ app/internal_api/test/configuration_test.py | 80 +++ app/internal_api/test/conftest.py | 42 ++ app/internal_api/test/order_products_test.py | 217 ++++++++ app/internal_api/test/orders_test.py | 510 ++++++++++++++++++ app/internal_api/test/session_test.py | 48 ++ app/internal_api/test/statistics_test.py | 122 +++++ app/internal_api/urls.py | 7 +- app/internal_api/views.py | 112 ---- .../0006_orderproductrelationtag.py | 70 +++ app/shop/order/models.py | 23 + app/well_known/__init__.py | 0 app/well_known/apps.py | 6 + app/well_known/test/__init__.py | 0 app/well_known/test/rosa_test.py | 48 ++ app/well_known/urls.py | 6 + app/well_known/views.py | 70 +++ 39 files changed, 2845 insertions(+), 359 deletions(-) create mode 100644 app/admin_api/serializers/internal_api.py create mode 100644 app/admin_api/test/internal_api_test.py create mode 100644 app/admin_api/test/shop/order_product_relation_tags_api_test.py create mode 100644 app/admin_api/views/internal_api.py delete mode 100644 app/internal_api/filters.py create mode 100644 app/internal_api/migrations/0001_initial.py create mode 100644 app/internal_api/migrations/__init__.py create mode 100644 app/internal_api/models.py create mode 100644 app/internal_api/registration_desk/__init__.py create mode 100644 app/internal_api/registration_desk/filters.py create mode 100644 app/internal_api/registration_desk/serializers.py create mode 100644 app/internal_api/registration_desk/urls.py create mode 100644 app/internal_api/registration_desk/views.py delete mode 100644 app/internal_api/serializers.py create mode 100644 app/internal_api/test/__init__.py create mode 100644 app/internal_api/test/config_model_test.py create mode 100644 app/internal_api/test/configuration_test.py create mode 100644 app/internal_api/test/conftest.py create mode 100644 app/internal_api/test/order_products_test.py create mode 100644 app/internal_api/test/orders_test.py create mode 100644 app/internal_api/test/session_test.py create mode 100644 app/internal_api/test/statistics_test.py delete mode 100644 app/internal_api/views.py create mode 100644 app/shop/order/migrations/0006_orderproductrelationtag.py create mode 100644 app/well_known/__init__.py create mode 100644 app/well_known/apps.py create mode 100644 app/well_known/test/__init__.py create mode 100644 app/well_known/test/rosa_test.py create mode 100644 app/well_known/urls.py create mode 100644 app/well_known/views.py diff --git a/app/admin_api/serializers/internal_api.py b/app/admin_api/serializers/internal_api.py new file mode 100644 index 0000000..5c548ec --- /dev/null +++ b/app/admin_api/serializers/internal_api.py @@ -0,0 +1,56 @@ +from core.const.serializer import COMMON_ADMIN_FIELDS +from core.serializer.base_abstract_serializer import BaseAbstractSerializer +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from event.models import Event +from internal_api.models import RegistrationDeskConfig +from rest_framework import serializers +from shop.product.models import Category + + +class RegistrationDeskConfigAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + event = serializers.PrimaryKeyRelatedField(queryset=Event.objects.filter_active()) + logo_url = serializers.CharField(source="event.logo.file.url", read_only=True, allow_null=True) + + categories = serializers.PrimaryKeyRelatedField( + many=True, allow_empty=False, queryset=Category.objects.filter_active() + ) + + class Meta: + model = RegistrationDeskConfig + fields = COMMON_ADMIN_FIELDS + ( + "name", + "event", + "start_date", + "end_date", + "logo_url", + "categories", + ) + + def validate(self, attrs: dict) -> dict: + merged = {**attrs} + for field, fallback in ( + ("start_date", RegistrationDeskConfig.DEFAULT_START_DATE), + ("end_date", RegistrationDeskConfig.DEFAULT_END_DATE), + ): + if merged.get(field) is None: + merged[field] = getattr(self.instance, field, None) or fallback + + start, end = merged["start_date"], merged["end_date"] + if start > end: + raise serializers.ValidationError({"end_date": "종료일은 시작일보다 빠를 수 없습니다."}) + + # 행사 없는 공용 카테고리(굿즈 등)는 어느 설정에서나 허용. + event = attrs.get("event") or getattr(self.instance, "event", None) + categories = attrs.get("categories", [] if self.instance is None else list(self.instance.categories.all())) + if event and (mismatched := [c.name for c in categories if c.event_id not in (None, event.id)]): + joined = ", ".join(mismatched) + raise serializers.ValidationError({"categories": f"설정의 행사와 다른 행사의 카테고리입니다: {joined}"}) + + if conflict := ( + RegistrationDeskConfig.objects.filter_active() + .filter_by_overlap(start_date=start, end_date=end, exclude_pk=self.instance.pk if self.instance else None) + .first() + ): + raise serializers.ValidationError({"start_date": f"적용 기간이 «{conflict.name}» 과(와) 겹칩니다."}) + + return attrs diff --git a/app/admin_api/serializers/shop/orders.py b/app/admin_api/serializers/shop/orders.py index 8b57fb5..7d85d4b 100644 --- a/app/admin_api/serializers/shop/orders.py +++ b/app/admin_api/serializers/shop/orders.py @@ -8,7 +8,14 @@ from notification.channels import NotificationChannel from notification.models.base import Recipient from rest_framework import serializers -from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation, TicketInfo +from shop.order.models import ( + CustomerInfo, + Order, + OrderProductOptionRelation, + OrderProductRelation, + OrderProductRelationTag, + TicketInfo, +) from shop.payment_history.models import PaymentHistory from shop.product.models import Product from user.models import UserExt @@ -68,14 +75,20 @@ class Meta: model = TicketInfo fields = ("name", "phone", "email", "organization", "contribution_message") + class SimpleTagSerializer(serializers.ModelSerializer): + class Meta: + model = OrderProductRelationTag + fields = ("id", "code", "name") + product = SimpleProductSerializer(read_only=True) options = SimpleOrderProductOptionRelationSerializer(many=True, read_only=True) ticket_info = SimpleTicketInfoSerializer(read_only=True, allow_null=True) + tags = SimpleTagSerializer(many=True, read_only=True) class Meta: model = OrderProductRelation - fields = ("id", "product", "status", "price", "donation_price", "options", "ticket_info") - read_only_fields = ("id", "product", "price", "donation_price", "options", "ticket_info") + fields = ("id", "product", "status", "price", "donation_price", "options", "ticket_info", "tags") + read_only_fields = ("id", "product", "price", "donation_price", "options", "ticket_info", "tags") user = SimpleUserSerializer(read_only=True) customer_info = SimpleCustomerInfoSerializer(required=False, allow_null=True) @@ -273,3 +286,23 @@ def build_send_response(self) -> serializers.Serializer: class OrderProductSendNotificationSerializer(OrderSendNotificationSerializer): recipient_item_serializer_class = _OrderProductRecipientItemSerializer empty_recipients_message = "발송 대상이 없습니다 (filterset 결과 0건 또는 참가자/주문자 정보 부재)." + + +class OrderProductRelationTagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = OrderProductRelationTag + fields = COMMON_ADMIN_FIELDS + ("code", "name", "priority") + + def validate_code(self, value: str) -> str: + duplicated = ( + OrderProductRelationTag.objects.filter_active() + .filter(code=value) + .exclude(pk=self.instance.pk if self.instance else None) + ) + if duplicated.exists(): + raise serializers.ValidationError("이미 있는 코드입니다.") + return value + + +class OrderProductRelationTagAssignResultSerializer(serializers.Serializer): + affected = serializers.IntegerField() diff --git a/app/admin_api/test/internal_api_test.py b/app/admin_api/test/internal_api_test.py new file mode 100644 index 0000000..5575728 --- /dev/null +++ b/app/admin_api/test/internal_api_test.py @@ -0,0 +1,251 @@ +from datetime import date + +import pytest +from django.db.utils import IntegrityError +from django.urls import reverse +from event.models import Event +from file.models import PublicFile +from internal_api.models import RegistrationDeskConfig +from model_bakery import baker +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, +) +from rest_framework.test import APIClient +from shop.conftest import ticket_product # noqa: F401 + +LIST_URL = reverse("v1:admin-registration-desk-config-list") + + +def _detail_url(pk) -> str: + return reverse("v1:admin-registration-desk-config-detail", args=[pk]) + + +def _payload(event, category_id, **overrides) -> dict: + return {"name": "설정", "event": str(event.id), "categories": [str(category_id)], **overrides} + + +@pytest.fixture +def anon_client() -> APIClient: + return APIClient() + + +@pytest.fixture +def customer_client(customer_user) -> APIClient: + client = APIClient() + client.force_authenticate(user=customer_user) + return client + + +@pytest.fixture +def event(db) -> Event: + return baker.make("event.Event", name="파이콘 한국 2026") + + +@pytest.fixture +def day1_config(event) -> RegistrationDeskConfig: + return RegistrationDeskConfig.objects.create( + name="Day 1", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 15) + ) + + +@pytest.mark.parametrize("client_fixture", ["anon_client", "customer_client"]) +@pytest.mark.django_db +def test_config_list_rejects_non_superuser(request, client_fixture): + assert request.getfixturevalue(client_fixture).get(LIST_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_config_list_is_paginated(api_client, day1_config): + response = api_client.get(LIST_URL) + + assert response.status_code == HTTP_200_OK + body = response.json() + assert body["count"] == 1 + assert body["results"][0]["name"] == "Day 1" + + +@pytest.mark.django_db +def test_config_create_persists_target_categories(api_client, ticket_product, event): # noqa: F811 + response = api_client.post( + LIST_URL, + { + "name": "Day 1", + "event": str(event.id), + "start_date": "2026-08-15", + "end_date": "2026-08-15", + "categories": [str(ticket_product.category_id)], + }, + format="json", + ) + + assert response.status_code == HTTP_201_CREATED + body = response.json() + assert body["categories"] == [str(ticket_product.category_id)] + config = RegistrationDeskConfig.objects.get(id=body["id"]) + assert config.start_date == date(2026, 8, 15) + assert list(config.categories.all()) == [ticket_product.category] + + +@pytest.mark.django_db +def test_config_exposes_event_logo_url(api_client, event, ticket_product): # noqa: F811 + logo = PublicFile.objects.create(file="public/desk-logo.png", mimetype="image/png", hash="desk-logo", size=0) + event.logo = logo + event.save() + + response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json") + + assert response.status_code == HTTP_201_CREATED + assert response.json()["logo_url"] == logo.file.url + + +@pytest.mark.django_db +def test_config_create_requires_event(api_client): + response = api_client.post(LIST_URL, {"name": "이벤트 없음"}, format="json") + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "event" in str(response.json()) + + +@pytest.mark.django_db +def test_config_create_rejects_end_date_before_start_date(api_client, event, ticket_product): # noqa: F811 + response = api_client.post( + LIST_URL, + _payload(event, ticket_product.category_id, start_date="2026-08-16", end_date="2026-08-15"), + format="json", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "end_date" in str(response.json()) + + +@pytest.mark.django_db +def test_config_create_rejects_overlapping_period(api_client, day1_config, event, ticket_product): # noqa: F811 + response = api_client.post( + LIST_URL, + _payload(event, ticket_product.category_id, start_date="2026-08-15", end_date="2026-08-16"), + format="json", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "Day 1" in str(response.json()) + + +@pytest.mark.django_db +def test_config_create_allows_adjacent_period(api_client, day1_config, event, ticket_product): # noqa: F811 + response = api_client.post( + LIST_URL, + _payload(event, ticket_product.category_id, start_date="2026-08-16", end_date="2026-08-16"), + format="json", + ) + + assert response.status_code == HTTP_201_CREATED + + +@pytest.mark.django_db +def test_config_create_rejects_open_ended_period_when_another_exists( + api_client, + day1_config, + event, + ticket_product, # noqa: F811 +): + response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json") + + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_config_update_does_not_conflict_with_itself(api_client, day1_config): + response = api_client.patch(_detail_url(day1_config.id), {"name": "첫째 날"}, format="json") + + assert response.status_code == HTTP_200_OK + assert response.json()["name"] == "첫째 날" + + +@pytest.mark.django_db +def test_config_partial_update_merges_stored_dates_for_overlap_check(api_client, day1_config, event): + RegistrationDeskConfig.objects.create( + name="Day 2", event=event, start_date=date(2026, 8, 16), end_date=date(2026, 8, 16) + ) + + # end_date 만 보내도 저장된 start_date 와 합쳐 Day 2 와의 겹침을 잡아야 한다. + response = api_client.patch(_detail_url(day1_config.id), {"end_date": "2026-08-16"}, format="json") + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "Day 2" in str(response.json()) + + +@pytest.mark.django_db +def test_config_destroy_soft_deletes(api_client, day1_config): + assert api_client.delete(_detail_url(day1_config.id)).status_code == HTTP_204_NO_CONTENT + + day1_config.refresh_from_db() + assert day1_config.deleted_at is not None + assert api_client.get(LIST_URL).json()["count"] == 0 + + +@pytest.mark.django_db +def test_config_json_schema_and_selectables_are_available(api_client, day1_config): + schema_response = api_client.get(f"{LIST_URL}json-schema/") + selectables_response = api_client.get(f"{LIST_URL}selectables/") + + assert schema_response.status_code == HTTP_200_OK + assert "start_date" in schema_response.json()["schema"]["properties"] + assert selectables_response.status_code == HTTP_200_OK + assert [row["const"] for row in selectables_response.json()["results"]] == [str(day1_config.id)] + + +@pytest.mark.django_db +def test_config_create_rejects_empty_categories(api_client, event): + response = api_client.post(LIST_URL, {"name": "빈 설정", "event": str(event.id), "categories": []}, format="json") + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "categories" in str(response.json()) + + +@pytest.mark.django_db +def test_config_create_rejects_category_of_other_event(api_client, event, ticket_product): # noqa: F811 + other_event = baker.make("event.Event", name="다른 행사") + ticket_product.category.event = other_event + ticket_product.category.save(update_fields=["event"]) + + response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json") + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "categories" in str(response.json()) + + +@pytest.mark.django_db +def test_config_create_allows_category_without_event(api_client, event, ticket_product): # noqa: F811 + assert ticket_product.category.event_id is None + + response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json") + + assert response.status_code == HTTP_201_CREATED + + +@pytest.mark.django_db +def test_config_overlapping_period_is_blocked_by_db_constraint(event): + # 시리얼라이저를 우회한 동시 요청까지 DB EXCLUDE 제약이 막는다. + RegistrationDeskConfig.objects.create( + name="Day 1", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 16) + ) + + with pytest.raises(IntegrityError): + RegistrationDeskConfig.objects.create( + name="겹침", event=event, start_date=date(2026, 8, 16), end_date=date(2026, 8, 17) + ) + + +@pytest.mark.django_db +def test_config_overlapping_period_ignores_soft_deleted(event): + RegistrationDeskConfig.objects.create( + name="Day 1", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 16) + ).delete() + + RegistrationDeskConfig.objects.create( + name="Day 1 재등록", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 16) + ) diff --git a/app/admin_api/test/shop/order_product_relation_tags_api_test.py b/app/admin_api/test/shop/order_product_relation_tags_api_test.py new file mode 100644 index 0000000..be174bc --- /dev/null +++ b/app/admin_api/test/shop/order_product_relation_tags_api_test.py @@ -0,0 +1,122 @@ +import pytest +from django.urls import reverse +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, +) +from shop.order.models import OrderProductRelationTag + +LIST_URL = reverse("v1:admin-shop-order-product-relation-tag-list") + + +def _assign_url(tag_id, action: str = "assign") -> str: + return reverse(f"v1:admin-shop-order-product-relation-tag-{action}", args=[tag_id]) + + +@pytest.fixture +def speaker_tag(db) -> OrderProductRelationTag: + return OrderProductRelationTag.objects.create(code="speaker", name="발표자") + + +@pytest.mark.parametrize("client_fixture", ["anon_client", "customer_client"]) +@pytest.mark.django_db +def test_tag_list_rejects_non_superuser(request, client_fixture): + assert request.getfixturevalue(client_fixture).get(LIST_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_tag_create_and_reject_duplicated_code(api_client): + create = api_client.post(LIST_URL, {"code": "speaker", "name": "발표자", "priority": 1}, format="json") + assert create.status_code == HTTP_201_CREATED + + duplicated = api_client.post(LIST_URL, {"code": "speaker", "name": "다른 이름"}, format="json") + assert duplicated.status_code == HTTP_400_BAD_REQUEST + assert "code" in str(duplicated.json()) + + +@pytest.mark.django_db +def test_tag_create_allows_code_of_soft_deleted_tag(api_client): + OrderProductRelationTag.objects.create(code="speaker", name="발표자").delete() + + assert api_client.post(LIST_URL, {"code": "speaker", "name": "발표자"}, format="json").status_code == ( + HTTP_201_CREATED + ) + + +@pytest.mark.django_db +def test_assign_attaches_tag_to_filtered_order_products(api_client, speaker_tag, order_factory): + order = order_factory(status="completed") + order_product = order.products.get() + + response = api_client.post(f"{_assign_url(speaker_tag.id)}?order_id={order.id}") + + assert response.status_code == HTTP_200_OK + assert response.json() == {"affected": 1} + assert list(order_product.tags.all()) == [speaker_tag] + + +@pytest.mark.django_db +def test_assign_is_idempotent(api_client, speaker_tag, order_factory): + order = order_factory(status="completed") + url = f"{_assign_url(speaker_tag.id)}?order_id={order.id}" + + api_client.post(url) + api_client.post(url) + + assert order.products.get().tags.count() == 1 + + +@pytest.mark.django_db +def test_assign_skips_order_products_outside_the_filter(api_client, speaker_tag, order_factory): + order_factory(status="completed") + other_order = order_factory(status="completed") + + api_client.post(f"{_assign_url(speaker_tag.id)}?order_id={other_order.id}") + + assert speaker_tag.order_product_relations.count() == 1 + + +@pytest.mark.django_db +def test_assign_rejects_request_without_any_filter(api_client, speaker_tag, order_factory): + order_factory(status="completed") + + response = api_client.post(_assign_url(speaker_tag.id)) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "조회 조건" in str(response.json()) + assert speaker_tag.order_product_relations.count() == 0 + + +@pytest.mark.django_db +def test_unassign_detaches_tag(api_client, speaker_tag, order_factory): + order = order_factory(status="completed") + speaker_tag.order_product_relations.add(order.products.get()) + + response = api_client.post(f"{_assign_url(speaker_tag.id, 'unassign')}?order_id={order.id}") + + assert response.status_code == HTTP_200_OK + assert response.json() == {"affected": 1} + assert speaker_tag.order_product_relations.count() == 0 + + +@pytest.mark.django_db +def test_order_detail_exposes_tags(api_client, speaker_tag, order_factory): + order = order_factory(status="completed") + speaker_tag.order_product_relations.add(order.products.get()) + + body = api_client.get(reverse("v1:admin-shop-order-detail", args=[order.id])).json() + + assert body["products"][0]["tags"] == [{"id": str(speaker_tag.id), "code": "speaker", "name": "발표자"}] + + +@pytest.mark.django_db +def test_assign_rejects_blank_filter_value(api_client, speaker_tag, order_factory): + # django-filter 는 빈 값을 무시하므로 `?id=` 가 전체 태깅이 되면 안 된다. + order_factory(status="completed") + + response = api_client.post(f"{_assign_url(speaker_tag.id)}?id=") + + assert response.status_code == HTTP_400_BAD_REQUEST + assert speaker_tag.order_product_relations.count() == 0 diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index 7137080..f0b0e7d 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -14,6 +14,7 @@ from admin_api.views.event.timetable import EventPresentationTimetableAdminViewSet from admin_api.views.external_api.google_oauth2 import GoogleOAuth2AdminViewSet from admin_api.views.file import PublicFileAdminViewSet +from admin_api.views.internal_api import RegistrationDeskConfigAdminViewSet from admin_api.views.mcp_token import McpTokenAdminViewSet from admin_api.views.merge import UserMergeAdminViewSet from admin_api.views.modification_audit import ModificationAuditAdminViewSet @@ -30,7 +31,7 @@ OrderNotificationAdminViewSet, OrderProductNotificationAdminViewSet, ) -from admin_api.views.shop.orders import OrderAdminViewSet +from admin_api.views.shop.orders import OrderAdminViewSet, OrderProductRelationTagAdminViewSet from admin_api.views.shop.products import ( CategoryAdminViewSet, CategoryGroupAdminViewSet, @@ -134,11 +135,19 @@ admin_shop_router.register("categorygroup", CategoryGroupAdminViewSet, basename="admin-shop-category-group") admin_shop_router.register("optiongroup", OptionGroupAdminViewSet, basename="admin-shop-option-group") admin_shop_router.register("refund-authorizer", RefundAuthorizerAdminViewSet, basename="admin-shop-refund-authorizer") +admin_shop_router.register( + "orderproductrelationtag", OrderProductRelationTagAdminViewSet, basename="admin-shop-order-product-relation-tag" +) admin_document_router = routers.SimpleRouter() admin_document_router.register("documenttemplate", DocumentTemplateAdminViewSet, basename="admin-document-template") admin_document_router.register("issueddocument", IssuedDocumentAdminViewSet, basename="admin-document-issued") +admin_internal_api_router = routers.SimpleRouter() +admin_internal_api_router.register( + "registrationdeskconfig", RegistrationDeskConfigAdminViewSet, basename="admin-registration-desk-config" +) + admin_dashboard_router = routers.SimpleRouter() admin_dashboard_router.register("charts", DashboardChartAdminViewSet, basename="admin-dashboard-chart") @@ -156,6 +165,7 @@ path("notification/", include(admin_notification_router.urls)), path("external_api/", include(admin_external_api_router.urls)), path("shop/", include(admin_shop_router.urls)), + path("internal_api/", include(admin_internal_api_router.urls)), path("document/", include(admin_document_router.urls)), path("dashboard/", include(admin_dashboard_router.urls)), path("allauth/", include(admin_allauth_router.urls)), diff --git a/app/admin_api/views/internal_api.py b/app/admin_api/views/internal_api.py new file mode 100644 index 0000000..c256d06 --- /dev/null +++ b/app/admin_api/views/internal_api.py @@ -0,0 +1,25 @@ +from admin_api.serializers.internal_api import RegistrationDeskConfigAdminSerializer +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.pagination import AdminPagination +from core.viewset.json_schema_viewset import JsonSchemaMixin +from core.viewset.selectables_viewset import SelectablesMixin +from drf_spectacular.utils import extend_schema, extend_schema_view +from internal_api.models import RegistrationDeskConfig +from rest_framework import viewsets + +CRUD_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_REGISTRATION_DESK]) for m in CRUD_METHODS}) +class RegistrationDeskConfigAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.ModelViewSet): + pagination_class = AdminPagination + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = RegistrationDeskConfigAdminSerializer + permission_classes = [IsSuperUser] + filterset_fields = ["event"] + queryset = ( + RegistrationDeskConfig.objects.filter_active() + .select_related_with_user("event", "event__logo") + .prefetch_active_targets() + ) diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py index caf304d..247a517 100644 --- a/app/admin_api/views/shop/orders.py +++ b/app/admin_api/views/shop/orders.py @@ -5,8 +5,14 @@ from logging import getLogger import pandas +from admin_api.filtersets.shop.order_products import OrderProductRelationAdminFilterSet from admin_api.filtersets.shop.orders import OrderAdminFilterSet -from admin_api.serializers.shop.orders import OrderAdminSerializer, OrderExportRequestSerializer +from admin_api.serializers.shop.orders import ( + OrderAdminSerializer, + OrderExportRequestSerializer, + OrderProductRelationTagAdminSerializer, + OrderProductRelationTagAssignResultSerializer, +) from core.authz import IsSuperUser from core.const.tag import OpenAPITag from core.pagination import AdminPagination @@ -21,7 +27,7 @@ from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets from rest_framework.decorators import action from shop.order import exports, imports -from shop.order.models import Order, OrderProductRelation +from shop.order.models import Order, OrderProductRelation, OrderProductRelationTag from shop.payment_history.models import PURCHASED_STATUSES, REFUNDABLE_STATUSES, PaymentHistory from shop.product.models import Product from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer @@ -33,9 +39,32 @@ # OrderProductRelation + nested Options prefetch — `Order.products` 용. _OPR_PREFETCH_QS = ( - OrderProductRelation.objects.filter_active().select_related("product", "ticket_info").prefetch_active_options() + OrderProductRelation.objects.filter_active() + .select_related("product", "ticket_info") + .prefetch_active_options() + .prefetch_related(models.Prefetch("tags", queryset=OrderProductRelationTag.objects.filter_active())) ) +_TAGGABLE_ORDER_PRODUCT_QS = ( + OrderProductRelation.objects.filter_active() + .filter(order__isnull=False) + .annotate( + order_current_status=PaymentHistory.objects.latest_per_order_field("status", outer_field="order_id"), + order_latest_imp_id=PaymentHistory.objects.latest_per_order_field("imp_id", outer_field="order_id"), + order_first_paid_at=( + PaymentHistory.objects.filter_active() + .filter(order_id=models.OuterRef("order_id")) + .order_by("created_at") + .values("created_at")[:1] + ), + ) +) + +_OPR_FILTER_PARAMETERS = [ + OpenApiParameter(name=name, type=OpenApiTypes.STR, location=OpenApiParameter.QUERY) + for name in OrderProductRelationAdminFilterSet.Meta.fields +] + # `Order.payment_histories` 용 prefetch — 최신순. _PAYMENT_HISTORY_PREFETCH_QS = PaymentHistory.objects.filter_active().order_by("-created_at") @@ -249,3 +278,55 @@ def export(self, request: request.Request) -> StreamingHttpResponse: content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) + + +@extend_schema_view( + **{ + m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER_PRODUCT_TAG]) + for m in ["list", "retrieve", "create", "update", "partial_update", "destroy"] + } +) +class OrderProductRelationTagAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.ModelViewSet): + pagination_class = AdminPagination + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = OrderProductRelationTagAdminSerializer + permission_classes = [IsSuperUser] + queryset = OrderProductRelationTag.objects.filter_active().select_related_with_user() + + def _target_order_product_ids(self, req: request.Request) -> list: + # 빈 값은 django-filter 가 무시하므로 `?id=` 같은 요청이 전체 태깅으로 이어지지 않도록 값까지 확인한다. + if not any(req.query_params.get(name, "").strip() for name in OrderProductRelationAdminFilterSet.base_filters): + joined = ", ".join(OrderProductRelationAdminFilterSet.base_filters) + raise exceptions.ValidationError(f"대상을 좁힐 조회 조건이 하나 이상 필요합니다: {joined}") + filterset = OrderProductRelationAdminFilterSet( + req.query_params, queryset=_TAGGABLE_ORDER_PRODUCT_QS, request=req + ) + if not filterset.is_valid(): + raise exceptions.ValidationError(filterset.errors) + return list(filterset.qs.values_list("id", flat=True)) + + @extend_schema( + summary="주문 상품에 태그 부착 (filterset 으로 대상 지정)", + tags=[OpenAPITag.ADMIN_SHOP_ORDER_PRODUCT_TAG], + parameters=_OPR_FILTER_PARAMETERS, + request=None, + responses={status.HTTP_200_OK: OrderProductRelationTagAssignResultSerializer}, + ) + @action(detail=True, methods=["post"]) + def assign(self, request: request.Request, pk: str | None = None) -> response.Response: + target_ids = self._target_order_product_ids(request) + self.get_object().order_product_relations.add(*target_ids) + return response.Response({"affected": len(target_ids)}) + + @extend_schema( + summary="주문 상품에서 태그 해제 (filterset 으로 대상 지정)", + tags=[OpenAPITag.ADMIN_SHOP_ORDER_PRODUCT_TAG], + parameters=_OPR_FILTER_PARAMETERS, + request=None, + responses={status.HTTP_200_OK: OrderProductRelationTagAssignResultSerializer}, + ) + @action(detail=True, methods=["post"]) + def unassign(self, request: request.Request, pk: str | None = None) -> response.Response: + target_ids = self._target_order_product_ids(request) + self.get_object().order_product_relations.remove(*target_ids) + return response.Response({"affected": len(target_ids)}) diff --git a/app/core/const/tag.py b/app/core/const/tag.py index 912cb6a..4ae5006 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -30,12 +30,14 @@ class OpenAPITag: ADMIN_NOTI_SMS = "Admin > Notification > SMS" ADMIN_EXT_API_GOOGLE_OAUTH2 = "Admin > External API > Google OAuth2" ADMIN_SHOP_ORDER = "Admin > Shop > 주문" + ADMIN_SHOP_ORDER_PRODUCT_TAG = "Admin > Shop > 주문상품 태그" ADMIN_SHOP_ORDER_REFUND = "Admin > Shop > 환불" ADMIN_SHOP_PRODUCT = "Admin > Shop > 상품" ADMIN_SHOP_CATEGORY = "Admin > Shop > 카테고리" ADMIN_SHOP_TAG = "Admin > Shop > 태그" ADMIN_SHOP_REFUND_AUTHORIZER = "Admin > Shop > 환불 승인자" ADMIN_DOCUMENT = "Admin > Document" + ADMIN_REGISTRATION_DESK = "Admin > 등록 데스크" ADMIN_DASHBOARD = "Admin > Dashboard" PARTICIPANT_PORTAL_USER = "Participant Portal > Sign-In & Sign-Out & My Profile" diff --git a/app/core/serializer/nested_model_serializer.py b/app/core/serializer/nested_model_serializer.py index 1e66836..0c1eefd 100644 --- a/app/core/serializer/nested_model_serializer.py +++ b/app/core/serializer/nested_model_serializer.py @@ -120,9 +120,11 @@ def _update_list_instances(self, field: serializers.ListSerializer, data: list[d def update(self, instance: Model, validated_data: dict) -> Model: info: model_meta.FieldInfo = model_meta.get_field_info(instance.__class__) m2m_fields: list[tuple[str, typing.Any]] = [] + # validated_data 의 키는 field name 이 아니라 source — `source=` 를 지정한 필드도 찾을 수 있어야 한다. + fields_by_source = {field.source: field for field in self.fields.values()} for field_name, value in validated_data.items(): - if (field := self.fields[field_name]).read_only: + if (field := fields_by_source[field_name]).read_only: continue if isinstance(field, serializers.BaseSerializer): diff --git a/app/core/settings.py b/app/core/settings.py index ae1708c..18456f2 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -157,6 +157,7 @@ "participant_portal_api", "external_api", "external_api.google_oauth2", + "well_known", # django-constance "constance", ] diff --git a/app/core/urls.py b/app/core/urls.py index e3dcc3f..f9e345f 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -46,6 +46,8 @@ SCHEMA_CACHE_TIMEOUT = 60 * 60 urlpatterns = [ + # Well-Known URI + path(".well-known/", include("well_known.urls")), # Health Check path("readyz/", readyz), path("livez/", livez), diff --git a/app/internal_api/filters.py b/app/internal_api/filters.py deleted file mode 100644 index 476566f..0000000 --- a/app/internal_api/filters.py +++ /dev/null @@ -1,86 +0,0 @@ -from django.db import models -from django_filters import rest_framework as filters -from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation, OrderQuerySet -from user.models import UserExt - - -class DeskSupportFilterSet(filters.FilterSet): - category_groups = filters.BaseCSVFilter(method="filter_by_category_groups") - categories = filters.BaseCSVFilter(method="filter_by_categories") - keywords = filters.BaseCSVFilter(method="filter_by_keywords") - - user_unique_id = filters.UUIDFilter(field_name="user__unique_id", lookup_expr="exact") - order_product_relation_id = filters.UUIDFilter(method="filter_by_order_product_relation_id") - order_id = filters.UUIDFilter(field_name="id", lookup_expr="exact") - - class Meta: - model = Order - fields = [ - "category_groups", - "categories", - "keywords", - "user_unique_id", - "order_product_relation_id", - "order_id", - ] - - def filter_by_category_groups(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet: - if not (filtered_values := [v.strip() for v in values if v.strip()]): - return qs - - opor_order_qs = ( - OrderProductRelation.objects.filter_active() - .filter( - product__category__group__name__in=filtered_values, - ) - .values_list("order_id", flat=True) - ) - - return qs.filter(id__in=opor_order_qs) - - def filter_by_categories(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet: - if not (filtered_values := [v.strip() for v in values if v.strip()]): - return qs - - opor_order_qs = ( - OrderProductRelation.objects.filter_active() - .filter( - product__category__name__in=filtered_values, - ) - .values_list("order_id", flat=True) - ) - - return qs.filter(id__in=opor_order_qs) - - def filter_by_order_product_relation_id(self, qs: OrderQuerySet, name: str, value: str) -> OrderQuerySet: - if not value: - return qs - - return qs.filter( - id__in=OrderProductRelation.objects.filter_active().filter(id=value).values_list("order_id", flat=True) - ) - - def filter_by_keywords(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet: - if not (filtered_values := [v.strip() for v in values if v.strip()]): - return qs - - opor_order_qs = ( - OrderProductOptionRelation.objects.filter_active() - .filter( - custom_response__in=filtered_values, - ) - .values_list("order_product_relation__order_id", flat=True) - ) - ci_order_qs = CustomerInfo.objects.filter( - models.Q(name__in=filtered_values) - | models.Q(email__in=filtered_values) - | models.Q(phone__in=filtered_values) - | models.Q(organization__in=filtered_values) - ).values_list("order_id", flat=True) - - user_subquery = models.Q() - for value in filtered_values: - user_subquery |= models.Q(username__icontains=value) | models.Q(email__icontains=value) - user_order_qs = qs.filter(user__in=UserExt.objects.filter(user_subquery)).values_list("id", flat=True) - - return qs.filter(models.Q(id__in=opor_order_qs) | models.Q(id__in=ci_order_qs) | models.Q(id__in=user_order_qs)) diff --git a/app/internal_api/migrations/0001_initial.py b/app/internal_api/migrations/0001_initial.py new file mode 100644 index 0000000..ca90083 --- /dev/null +++ b/app/internal_api/migrations/0001_initial.py @@ -0,0 +1,91 @@ +# Generated by Django 6.0.6 on 2026-08-13 00:46 + +import datetime +import uuid + +import django.contrib.postgres.constraints +import django.contrib.postgres.fields.ranges +import django.db.models.deletion +import internal_api.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("event", "0006_event_logo_historicalevent_logo"), + ("product", "0009_historicaloptiongroup_placeholder_mode_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RegistrationDeskConfig", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.CharField(max_length=100)), + ("start_date", models.DateField(default=datetime.date(1, 1, 1))), + ("end_date", models.DateField(default=datetime.date(9999, 12, 31))), + ("categories", models.ManyToManyField(related_name="+", to="product.category")), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "event", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="event.event"), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("start_date", "end_date"), + "constraints": [ + models.CheckConstraint( + condition=models.Q(("start_date__lte", models.F("end_date"))), + name="registration_desk_config_period_order", + ), + django.contrib.postgres.constraints.ExclusionConstraint( + condition=models.Q(("deleted_at__isnull", True)), + expressions=[ + ( + internal_api.models.DateRange( + "start_date", + "end_date", + django.contrib.postgres.fields.ranges.RangeBoundary(inclusive_upper=True), + ), + "&&", + ) + ], + name="registration_desk_config_period_overlap", + ), + ], + }, + ), + ] diff --git a/app/internal_api/migrations/__init__.py b/app/internal_api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/internal_api/models.py b/app/internal_api/models.py new file mode 100644 index 0000000..51879c4 --- /dev/null +++ b/app/internal_api/models.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import uuid +from datetime import date + +from core.models import BaseAbstractModel, BaseAbstractModelQuerySet +from core.util.dateutil import now_aware +from django.contrib.postgres.constraints import ExclusionConstraint +from django.contrib.postgres.fields import DateRangeField, RangeBoundary, RangeOperators +from django.db import models +from shop.product.models import Category + + +class DateRange(models.Func): + function = "daterange" + output_field = DateRangeField() + + +class RegistrationDeskConfigQuerySet(BaseAbstractModelQuerySet): + def prefetch_active_targets(self) -> models.QuerySet[RegistrationDeskConfig]: + return self.prefetch_related( + models.Prefetch( + "categories", + queryset=Category.objects.filter_active().select_related("group"), + ), + ) + + def filter_by_date(self, on_date: date | None = None) -> models.QuerySet[RegistrationDeskConfig]: + on_date = on_date or now_aware().date() + return self.filter(start_date__lte=on_date, end_date__gte=on_date) + + def filter_by_overlap( + self, + *, + start_date: date, + end_date: date, + exclude_pk: uuid.UUID | None = None, + ) -> models.QuerySet[RegistrationDeskConfig]: + queryset = self.filter(start_date__lte=end_date, end_date__gte=start_date) + return queryset.exclude(pk=exclude_pk) if exclude_pk else queryset + + +class RegistrationDeskConfig(BaseAbstractModel): + """등록 데스크 운영 설정. 날짜만으로 "오늘의 설정" 이 하나로 정해져야 해서 기간 중복을 금지한다.""" + + DEFAULT_START_DATE = date(1, 1, 1) + DEFAULT_END_DATE = date(9999, 12, 31) + + name = models.CharField(max_length=100) + event = models.ForeignKey("event.Event", on_delete=models.PROTECT, related_name="+") + + start_date = models.DateField(default=DEFAULT_START_DATE) + end_date = models.DateField(default=DEFAULT_END_DATE) + + categories = models.ManyToManyField("product.Category", related_name="+") + + objects: RegistrationDeskConfigQuerySet = RegistrationDeskConfigQuerySet.as_manager() # type: ignore + + class Meta: + ordering = ("start_date", "end_date") + constraints = [ + models.CheckConstraint( + condition=models.Q(start_date__lte=models.F("end_date")), + name="registration_desk_config_period_order", + ), + ExclusionConstraint( + name="registration_desk_config_period_overlap", + expressions=[ + (DateRange("start_date", "end_date", RangeBoundary(inclusive_upper=True)), RangeOperators.OVERLAPS), + ], + condition=models.Q(deleted_at__isnull=True), + ), + ] + + def __str__(self) -> str: # pragma: no cover + return self.name + + def build_query(self) -> models.Q: + category_ids = list(self.categories.filter_active().values_list("id", flat=True)) + return models.Q(product__category_id__in=category_ids) + + def covers(self, order_product_relation_ids: list[uuid.UUID]) -> bool: + from shop.order.models import OrderProductRelation + + covered = ( + OrderProductRelation.objects.filter_active() + .filter(self.build_query()) + .filter(id__in=order_product_relation_ids) + .count() + ) + return covered == len(set(order_product_relation_ids)) diff --git a/app/internal_api/registration_desk/__init__.py b/app/internal_api/registration_desk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/internal_api/registration_desk/filters.py b/app/internal_api/registration_desk/filters.py new file mode 100644 index 0000000..f9434fc --- /dev/null +++ b/app/internal_api/registration_desk/filters.py @@ -0,0 +1,90 @@ +from django.db import models +from django_filters import rest_framework as filters +from rest_framework import serializers +from shop.order.models import ( + CustomerInfo, + Order, + OrderProductOptionRelation, + OrderProductRelation, + OrderQuerySet, + TicketInfo, +) +from user.models import UserExt + +INVALID_SCANCODE_MESSAGE = "스캔코드 형식이 올바르지 않습니다." + + +class RegistrationDeskOrderProductFilterSet(filters.FilterSet): + order_product_relation_id = filters.UUIDFilter(field_name="id", lookup_expr="exact") + scancode = filters.CharFilter(method="filter_by_scancode") + + class Meta: + model = OrderProductRelation + fields = ["order_product_relation_id", "scancode"] + + def filter_by_scancode(self, qs: models.QuerySet, name: str, value: str) -> models.QuerySet: + parts = value.split(":") + if len(parts) != 3 or parts[0] != OrderProductRelation.scancode_prefix or not all(parts[1:]): + # 문법 오류는 조회 실패가 아니라 잘못된 요청. + raise serializers.ValidationError({"scancode": INVALID_SCANCODE_MESSAGE}) + scanned = OrderProductRelation.from_scancode_token(value) + return qs.filter(pk=scanned.pk) if scanned else qs.none() + + +class RegistrationDeskOrderFilterSet(filters.FilterSet): + keywords = filters.BaseCSVFilter(method="filter_by_keywords") + + user_unique_id = filters.UUIDFilter(field_name="user__unique_id", lookup_expr="exact") + order_product_relation_id = filters.UUIDFilter(method="filter_by_order_product_relation_id") + order_id = filters.UUIDFilter(field_name="id", lookup_expr="exact") + + class Meta: + model = Order + fields = [ + "keywords", + "user_unique_id", + "order_product_relation_id", + "order_id", + ] + + def filter_by_order_product_relation_id(self, qs: OrderQuerySet, name: str, value: str) -> OrderQuerySet: + if not value: + return qs + + return qs.filter( + id__in=OrderProductRelation.objects.filter_active().filter(id=value).values_list("order_id", flat=True) + ) + + def filter_by_keywords(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet: + if not (filtered_values := [v.strip() for v in values if v.strip()]): + return qs + + participant_query = ( + models.Q(name__in=filtered_values) + | models.Q(email__in=filtered_values) + | models.Q(phone__in=filtered_values) + | models.Q(organization__in=filtered_values) + ) + + opor_order_qs = ( + OrderProductOptionRelation.objects.filter_active() + .filter(custom_response__in=filtered_values) + .values_list("order_product_relation__order_id", flat=True) + ) + ci_order_qs = CustomerInfo.objects.filter(participant_query).values_list("order_id", flat=True) + ti_order_qs = ( + TicketInfo.objects.filter_active() + .filter(participant_query, order_product_relation__deleted_at__isnull=True) + .values_list("order_product_relation__order_id", flat=True) + ) + + user_query = models.Q() + for value in filtered_values: + user_query |= models.Q(username__icontains=value) | models.Q(email__icontains=value) + + return qs.filter( + models.Q(id__in=opor_order_qs) + | models.Q(id__in=ci_order_qs) + | models.Q(id__in=ti_order_qs) + | models.Q(user__in=UserExt.objects.filter(user_query)) + ) diff --git a/app/internal_api/registration_desk/serializers.py b/app/internal_api/registration_desk/serializers.py new file mode 100644 index 0000000..2f37dc3 --- /dev/null +++ b/app/internal_api/registration_desk/serializers.py @@ -0,0 +1,315 @@ +import re +import typing + +from core.serializer.nested_model_serializer import InstanceListSerializer, NestedModelSerializer +from drf_spectacular.utils import extend_schema_field +from event.models import Event +from internal_api.models import RegistrationDeskConfig +from rest_framework import exceptions, serializers +from shop.order.models import ( + CustomerInfo, + Order, + OrderProductOptionRelation, + OrderProductRelation, + OrderProductRelationTag, + TicketInfo, +) +from shop.order.serializers.validator import TicketInfoSerializer, validate_ticket_info_against_product +from shop.payment_history.models import PaymentHistory +from shop.product.models import Category, Option, OptionGroup, Product +from shop.serializers.refund import _REFUND_DATE_OVERRIDABLE_REASONS as REFUND_DATE_OVERRIDABLE_REASONS +from user.models import UserExt + +OrderProductStatus = OrderProductRelation.OrderProductStatus + +PossibleStatusFSM: dict[OrderProductStatus, set[OrderProductStatus]] = { + OrderProductStatus.pending: set(), + OrderProductStatus.paid: {OrderProductStatus.used}, + OrderProductStatus.used: {OrderProductStatus.paid}, + OrderProductStatus.refunded: set(), +} + +TICKET_INFO_EDITABLE_STATUSES = frozenset({OrderProductStatus.paid, OrderProductStatus.used}) +OPTION_EDITABLE_STATUSES = frozenset({OrderProductStatus.paid}) +OUT_OF_SCOPE_MESSAGE = "오늘 등록 데스크 설정의 대상 카테고리가 아닙니다." + + +def desk_refund_reason(reason: str | None) -> str | None: + """등록 데스크 환불은 `check_refundable_date=False` 라 일자 관련 사유는 실제로 막지 않는다.""" + return None if reason in REFUND_DATE_OVERRIDABLE_REASONS else reason + + +class RegistrationDeskTagDto(serializers.ModelSerializer): + class Meta: + fields = ("id", "code", "name") + model = OrderProductRelationTag + + +class RegistrationDeskProductDto(serializers.ModelSerializer): + class RegistrationDeskProductCategoryDto(serializers.ModelSerializer): + class Meta: + fields = ("id", "name") + model = Category + + category = RegistrationDeskProductCategoryDto(read_only=True) + + class Meta: + fields = ("id", "name", "price", "category") + model = Product + + +class RegistrationDeskTicketInfoDto(TicketInfoSerializer): + class Meta(TicketInfoSerializer.Meta): + fields = ("name", "phone", "email", "organization") + + def get_attribute(self, instance: OrderProductRelation) -> TicketInfo | None: + # 소프트 삭제된 참가자 정보를 None 으로 흡수. 쓰기는 실제 관계(`ticket_info`)로 들어간다. + return instance.ticket_info_or_none + + +class RegistrationDeskOrderProductOptionDto(NestedModelSerializer): + class RegistrationDeskOptionGroupDto(serializers.ModelSerializer): + class Meta: + fields = ("id", "name", "is_custom_response", "custom_response_pattern", "placeholder_mode") + model = OptionGroup + + class RegistrationDeskOptionDto(serializers.ModelSerializer): + class Meta: + fields = ("id", "name", "additional_price") + model = Option + + id = serializers.UUIDField(required=True) + product_option_group = RegistrationDeskOptionGroupDto(read_only=True) + product_option = RegistrationDeskOptionDto(allow_null=True, read_only=True) + custom_response = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + class Meta: + fields = ("id", "product_option_group", "product_option", "custom_response") + model = OrderProductOptionRelation + list_serializer_class = InstanceListSerializer + + def validate_id(self, value: str) -> str: + if value != typing.cast(OrderProductOptionRelation, self.instance).id: + raise serializers.ValidationError("id must not be modified") + return value + + def validate_custom_response(self, value: str | None) -> str | None: + option_group: OptionGroup = typing.cast(OrderProductOptionRelation, self.instance).product_option_group + if not option_group.is_custom_response: + raise serializers.ValidationError("cannot set custom response to non-custom-response option group") + if option_group.placeholder_mode == OptionGroup.PlaceholderMode.REQUIRED and not value: + raise serializers.ValidationError("응답이 필수인 옵션입니다.") + if value is None: + return value + if not option_group.custom_response_pattern: + raise serializers.ValidationError("custom response pattern is not set, please contact the administrator") + if not re.match(option_group.custom_response_pattern, value): + raise serializers.ValidationError("custom response does not match the pattern") + + return value + + +class RegistrationDeskOrderProductFieldsMixin(serializers.Serializer): + """주문 검색과 주문 상품 조회가 공통으로 내려주는 읽기 전용 필드.""" + + is_ticket = serializers.BooleanField(source="product.category.is_ticket", read_only=True) + not_refundable_reason = serializers.SerializerMethodField() + product = RegistrationDeskProductDto(read_only=True) + tags = RegistrationDeskTagDto(many=True, read_only=True) + + @staticmethod + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_not_refundable_reason(obj: OrderProductRelation) -> str | None: + return desk_refund_reason(obj.not_refundable_reason) + + +class RegistrationDeskOrderProductDto(RegistrationDeskOrderProductFieldsMixin, NestedModelSerializer): + id = serializers.UUIDField(required=True) + price = serializers.IntegerField(read_only=True) + donation_price = serializers.IntegerField(read_only=True) + status = serializers.ChoiceField(choices=OrderProductStatus.choices, required=False) + + options = RegistrationDeskOrderProductOptionDto(many=True, required=False) + ticket_info = RegistrationDeskTicketInfoDto(required=False) + + class Meta: + fields = ( + "id", + "is_ticket", + "price", + "donation_price", + "status", + "not_refundable_reason", + "product", + "options", + "tags", + "ticket_info", + ) + model = OrderProductRelation + list_serializer_class = InstanceListSerializer + + def validate_status(self, value: OrderProductStatus) -> OrderProductStatus: + current_status = typing.cast(OrderProductRelation, self.instance).status + if value == current_status: + return value + if value not in PossibleStatusFSM[typing.cast(OrderProductStatus, current_status)]: + raise serializers.ValidationError("해당 상태로 변경할 수 없습니다.") + return value + + def validate(self, attrs: dict) -> dict: + instance = typing.cast(OrderProductRelation, self.instance) + if "ticket_info" in attrs: + if instance.status not in TICKET_INFO_EDITABLE_STATUSES: + msg = "결제 완료 또는 사용 상태에서만 참가자 정보를 수정할 수 있습니다." + raise serializers.ValidationError({"ticket_info": msg}) + validate_ticket_info_against_product(instance.product, attrs["ticket_info"]) + if "options" in attrs and instance.status not in OPTION_EDITABLE_STATUSES: + raise serializers.ValidationError({"options": "결제 완료 상태에서만 옵션을 수정할 수 있습니다."}) + return attrs + + def update(self, instance: OrderProductRelation, validated_data: dict) -> OrderProductRelation: + # 부모의 nested 처리는 미존재 시 생성을 못 한다. + ticket_info_data = validated_data.pop("ticket_info", None) + instance = typing.cast(OrderProductRelation, super().update(instance, validated_data)) + if ticket_info_data is not None: + self._sync_ticket_info(instance, ticket_info_data) + return instance + + @staticmethod + def _sync_ticket_info(instance: OrderProductRelation, data: dict) -> None: + # OneToOne 이라 소프트 삭제된 row 가 있으면 새로 만들 수 없다. + ticket_info = TicketInfo.objects.filter(order_product_relation=instance).first() or TicketInfo( + order_product_relation=instance + ) + for field, value in data.items(): + setattr(ticket_info, field, value) + ticket_info.deleted_at = ticket_info.deleted_by = None + ticket_info.save() + # 캐시된 이전 값이 응답에 새어 나가지 않도록 갱신. + instance.ticket_info = ticket_info + + +class RegistrationDeskOrderSerializer(NestedModelSerializer): + class RegistrationDeskPaymentHistoryDto(serializers.ModelSerializer): + class Meta: + fields = ("status", "price", "created_at") + model = PaymentHistory + + class RegistrationDeskUserDto(serializers.ModelSerializer): + class Meta: + fields = ("id", "username", "email", "unique_id") + model = UserExt + + class RegistrationDeskCustomerInfoDto(serializers.ModelSerializer): + class Meta: + fields = ("name", "email", "phone", "organization") + model = CustomerInfo + + id = serializers.UUIDField(read_only=True) + # 데스크가 바꿀 수 있는 건 상품(체크인·참가자 정보·옵션)뿐 — 나머지가 열려 있으면 범위 검증을 우회한다. + name = serializers.CharField(read_only=True) + first_paid_price = serializers.IntegerField(read_only=True) + first_paid_at = serializers.DateTimeField(read_only=True) + current_paid_price = serializers.IntegerField(read_only=True) + current_status = serializers.CharField(read_only=True) + + created_at = serializers.DateTimeField(read_only=True) + not_fully_refundable_reason = serializers.SerializerMethodField() + + payment_histories = RegistrationDeskPaymentHistoryDto(many=True, read_only=True) + products = RegistrationDeskOrderProductDto(many=True, required=False, source="active_products") + user = RegistrationDeskUserDto(read_only=True) + customer_info = RegistrationDeskCustomerInfoDto(read_only=True) + + class Meta: + fields = ( + "id", + "name", + "first_paid_price", + "first_paid_at", + "current_paid_price", + "current_status", + "created_at", + "not_fully_refundable_reason", + "payment_histories", + "products", + "user", + "customer_info", + ) + model = Order + + @staticmethod + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_not_fully_refundable_reason(obj: Order) -> str | None: + return desk_refund_reason(obj.not_fully_refundable_reason) + + def validate(self, attrs: dict) -> dict: + config = self.context.get("desk_config") + target_ids = [item["id"] for item in attrs.get("active_products", []) if item.get("id")] + if config and target_ids and not config.covers(target_ids): + raise exceptions.PermissionDenied(OUT_OF_SCOPE_MESSAGE) + return attrs + + +class RegistrationDeskOrderProductSerializer(RegistrationDeskOrderProductFieldsMixin, serializers.ModelSerializer): + class _OrderDto(serializers.ModelSerializer): + current_status = serializers.CharField(read_only=True) + first_paid_at = serializers.DateTimeField(read_only=True) + + class Meta: + fields = ("id", "name", "current_status", "first_paid_at") + model = Order + + options = RegistrationDeskOrderProductOptionDto(many=True, read_only=True) + order = _OrderDto(read_only=True) + ticket_info = RegistrationDeskTicketInfoDto(read_only=True) + + class Meta: + fields = ( + "id", + "short_id", + "is_ticket", + "status", + "price", + "donation_price", + "not_refundable_reason", + "product", + "options", + "tags", + "order", + "ticket_info", + ) + model = OrderProductRelation + + +class RegistrationDeskStatisticsSerializer(serializers.Serializer): + registration_target_count = serializers.IntegerField() + registered_count = serializers.IntegerField() + waiting_count = serializers.IntegerField() + + +class RegistrationDeskSessionSerializer(serializers.ModelSerializer): + class Meta: + fields = ("id", "unique_id", "username", "nickname", "email") + model = UserExt + + +class RegistrationDeskConfigurationSerializer(serializers.ModelSerializer): + class RegistrationDeskEventDto(serializers.ModelSerializer): + logo_url = serializers.CharField(source="logo.file.url", read_only=True, allow_null=True) + + class Meta: + fields = ("id", "name", "event_start_at", "event_end_at", "logo_url") + model = Event + + event = RegistrationDeskEventDto(read_only=True) + available_tags = serializers.SerializerMethodField() + + class Meta: + fields = ("id", "name", "start_date", "end_date", "event", "available_tags") + model = RegistrationDeskConfig + + @staticmethod + @extend_schema_field(RegistrationDeskTagDto(many=True)) + def get_available_tags(obj: RegistrationDeskConfig) -> list[dict]: + return RegistrationDeskTagDto(OrderProductRelationTag.objects.filter_active(), many=True).data diff --git a/app/internal_api/registration_desk/urls.py b/app/internal_api/registration_desk/urls.py new file mode 100644 index 0000000..6dba116 --- /dev/null +++ b/app/internal_api/registration_desk/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from internal_api.registration_desk import views +from rest_framework import routers + +router = routers.SimpleRouter() +router.register("orders", views.RegistrationDeskOrderViewSet, basename="orders") +router.register("order-products", views.RegistrationDeskOrderProductViewSet, basename="order-products") +router.register("", views.RegistrationDeskViewSet, basename="desk") + +app_name = "registration_desk" + +urlpatterns = [path("", include(router.urls))] diff --git a/app/internal_api/registration_desk/views.py b/app/internal_api/registration_desk/views.py new file mode 100644 index 0000000..d900573 --- /dev/null +++ b/app/internal_api/registration_desk/views.py @@ -0,0 +1,278 @@ +import functools + +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.pagination import AdminPagination +from django.db import models, transaction +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view +from drf_standardized_errors.openapi_serializers import ( + Error403Serializer, + Error404Serializer, + ValidationErrorResponseSerializer, +) +from internal_api.models import RegistrationDeskConfig +from internal_api.registration_desk.filters import ( + RegistrationDeskOrderFilterSet, + RegistrationDeskOrderProductFilterSet, +) +from internal_api.registration_desk.serializers import ( + OUT_OF_SCOPE_MESSAGE, + RegistrationDeskConfigurationSerializer, + RegistrationDeskOrderProductSerializer, + RegistrationDeskOrderSerializer, + RegistrationDeskSessionSerializer, + RegistrationDeskStatisticsSerializer, +) +from rest_framework import decorators, exceptions, mixins, permissions, request, response, status, viewsets +from shop.order.models import Order, OrderProductRelation, OrderProductRelationTag +from shop.payment_history.models import PaymentHistory +from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer + +NO_DESK_CONFIG_MESSAGE = "오늘 적용되는 등록 데스크 설정이 없습니다." + + +class CurrentDeskConfigMixin: + """조회는 열어 두되 변경은 오늘 설정의 범위로 제한한다.""" + + @functools.cached_property + def current_config(self) -> RegistrationDeskConfig: + queryset = RegistrationDeskConfig.objects.filter_active().select_related("event__logo") + if not (config := queryset.filter_by_date().first()): + raise exceptions.PermissionDenied(NO_DESK_CONFIG_MESSAGE) + return config + + def assert_in_scope(self, order_product_relation_ids: list) -> None: + if not self.current_config.covers(order_product_relation_ids): + raise exceptions.PermissionDenied(OUT_OF_SCOPE_MESSAGE) + + +class RequiredFilterMixin: + required_filter_params: tuple[str, ...] = () + exclusive_filter_params = False + + def list(self, request: request.Request, *args: object, **kwargs: object) -> response.Response: + if not (provided := [p for p in self.required_filter_params if request.query_params.get(p, "").strip()]): + joined = ", ".join(self.required_filter_params) + raise exceptions.ValidationError(f"다음 조회 조건 중 하나 이상이 필요합니다: {joined}") + if self.exclusive_filter_params and len(provided) > 1: + joined = ", ".join(provided) + raise exceptions.ValidationError(f"다음 조회 조건은 동시에 사용할 수 없습니다: {joined}") + return super().list(request, *args, **kwargs) + + +@method_decorator( + ensure_csrf_cookie, name="dispatch" +) # 4xx 응답에도 CSRF 쿠키를 실어야 로그인 후 곧바로 PATCH/DELETE 를 보낼 수 있음 +class RegistrationDeskViewSet(CurrentDeskConfigMixin, viewsets.ViewSet): + permission_classes = [IsSuperUser] + + @extend_schema( + summary="세션 조회", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + responses={ + status.HTTP_200_OK: RegistrationDeskSessionSerializer, + status.HTTP_403_FORBIDDEN: Error403Serializer, + }, + ) + @decorators.action(detail=False, methods=["get"]) + def session(self, req: request.Request) -> response.Response: + return response.Response(RegistrationDeskSessionSerializer(req.user).data) + + @extend_schema( + summary="데스크 설정 조회", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + responses={ + status.HTTP_200_OK: RegistrationDeskConfigurationSerializer, + status.HTTP_403_FORBIDDEN: Error403Serializer, + }, + ) + @decorators.action(detail=False, methods=["get"]) + def configuration(self, req: request.Request) -> response.Response: + return response.Response(RegistrationDeskConfigurationSerializer(self.current_config).data) + + @extend_schema( + summary="등록 통계 조회", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + responses={ + status.HTTP_200_OK: RegistrationDeskStatisticsSerializer, + status.HTTP_403_FORBIDDEN: Error403Serializer, + }, + ) + @decorators.action(detail=False, methods=["get"]) + def statistics(self, req: request.Request) -> response.Response: + counts = ( + OrderProductRelation.objects.filter_active() + .filter(status__in=OrderProductRelation.PURCHASED_STOCK_STATUS) + .filter( + order__in=Order.objects.filter_has_payment_histories(), + product__deleted_at__isnull=True, + product__category__deleted_at__isnull=True, + product__category__is_ticket=True, + ) + .filter(self.current_config.build_query()) + .aggregate( + registration_target_count=models.Count("id"), + registered_count=models.Count( + "id", filter=models.Q(status=OrderProductRelation.OrderProductStatus.used) + ), + waiting_count=models.Count("id", filter=models.Q(status=OrderProductRelation.OrderProductStatus.paid)), + ) + ) + return response.Response(RegistrationDeskStatisticsSerializer(counts).data) + + +@extend_schema_view( + list=extend_schema( + summary="주문 검색", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + responses={ + status.HTTP_200_OK: RegistrationDeskOrderSerializer(many=True), + status.HTTP_403_FORBIDDEN: Error403Serializer, + }, + ), + partial_update=extend_schema( + summary="주문 수정", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + responses={ + status.HTTP_200_OK: RegistrationDeskOrderSerializer, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: Error403Serializer, + status.HTTP_404_NOT_FOUND: Error404Serializer, + }, + ), +) +@method_decorator(name="partial_update", decorator=transaction.atomic) +class RegistrationDeskOrderViewSet( + CurrentDeskConfigMixin, + RequiredFilterMixin, + mixins.ListModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + Order.objects.filter_has_payment_histories() + .select_related("customer_info") + .with_dto_prefetches() + .order_by("-created_at") + ) + filterset_class = RegistrationDeskOrderFilterSet + serializer_class = RegistrationDeskOrderSerializer + pagination_class = AdminPagination + permission_classes = [IsSuperUser] + http_method_names = ["get", "patch", "delete"] + required_filter_params = ("keywords", "order_id", "order_product_relation_id", "user_unique_id") + + def get_serializer_context(self) -> dict: + context = super().get_serializer_context() + if self.request.method not in permissions.SAFE_METHODS: + context["desk_config"] = self.current_config + return context + + @extend_schema( + summary="주문 전체 환불", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + parameters=[ + OpenApiParameter( + name="otp", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + allow_blank=False, + required=True, + description="환불 승인자의 6자리 TOTP 코드", + ), + ], + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: Error403Serializer, + status.HTTP_404_NOT_FOUND: Error404Serializer, + }, + ) + @decorators.action(detail=True, methods=["delete"], url_path="refund") + @transaction.atomic + def refund(self, req: request.Request, pk: str | None = None) -> response.Response: + order = self.get_object() + # 전체 환불은 활성 상품 전부를 건드린다. + self.assert_in_scope([product.id for product in order.active_products]) + serializer = OrderTotalRefundSerializer( + instance=order, + data={"totp": req.query_params.get("otp")}, + context={"check_refundable_date": False}, + ) + serializer.is_valid(raise_exception=True) + serializer.refund() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + +@extend_schema_view( + list=extend_schema( + summary="주문 상품 조회", + description="`order_product_relation_id` 또는 `scancode` 중 정확히 하나를 전달해야 한다.", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + responses={ + status.HTTP_200_OK: RegistrationDeskOrderProductSerializer(many=True), + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: Error403Serializer, + }, + ), +) +class RegistrationDeskOrderProductViewSet( + CurrentDeskConfigMixin, RequiredFilterMixin, mixins.ListModelMixin, viewsets.GenericViewSet +): + queryset = ( + OrderProductRelation.objects.filter_active() + .filter(order__in=Order.objects.filter_has_payment_histories()) + .select_related("product", "product__category", "order", "ticket_info") + .prefetch_active_options() + .prefetch_related( + models.Prefetch( + "order__payment_histories", + queryset=PaymentHistory.objects.filter_active(), + to_attr="_active_payment_histories", + ), + models.Prefetch("tags", queryset=OrderProductRelationTag.objects.filter_active()), + ) + .order_by("-created_at") + ) + serializer_class = RegistrationDeskOrderProductSerializer + filterset_class = RegistrationDeskOrderProductFilterSet + pagination_class = AdminPagination + permission_classes = [IsSuperUser] + required_filter_params = ("order_product_relation_id", "scancode") + exclusive_filter_params = True + + @extend_schema( + summary="주문 상품 부분 환불", + tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], + parameters=[ + OpenApiParameter( + name="otp", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + allow_blank=False, + required=True, + description="환불 승인자의 6자리 TOTP 코드", + ), + ], + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: Error403Serializer, + status.HTTP_404_NOT_FOUND: Error404Serializer, + }, + ) + @decorators.action(detail=True, methods=["delete"], url_path="refund") + @transaction.atomic + def refund(self, req: request.Request, pk: str | None = None) -> response.Response: + order_product = self.get_object() + self.assert_in_scope([order_product.id]) + serializer = OrderProductRefundSerializer( + instance=order_product, + data={"totp": req.query_params.get("otp")}, + context={"check_refundable_date": False}, + ) + serializer.is_valid(raise_exception=True) + serializer.refund() + return response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/internal_api/serializers.py b/app/internal_api/serializers.py deleted file mode 100644 index 022a837..0000000 --- a/app/internal_api/serializers.py +++ /dev/null @@ -1,147 +0,0 @@ -import re -import typing - -from core.serializer.nested_model_serializer import InstanceListSerializer, NestedModelSerializer -from rest_framework import serializers -from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation -from shop.payment_history.models import PaymentHistory -from shop.product.models import Option, OptionGroup, Product -from user.models import UserExt - -PossibleStatusFSM: dict[OrderProductRelation.OrderProductStatus, set[OrderProductRelation.OrderProductStatus]] = { - # 접수 데스크에서는 결제 완료나 그 이후의 상태로 변경할 수 없음 - OrderProductRelation.OrderProductStatus.pending: set(), - OrderProductRelation.OrderProductStatus.paid: {OrderProductRelation.OrderProductStatus.used}, - OrderProductRelation.OrderProductStatus.used: {OrderProductRelation.OrderProductStatus.paid}, - # 이미 환불된 상품은 사용 또는 결제 완료로 변경할 수 없음 - OrderProductRelation.OrderProductStatus.refunded: set(), -} - - -class SimplePaymentHistoryDeskSupportDto(serializers.ModelSerializer): - class Meta: - fields = ("status", "price", "created_at") - model = PaymentHistory - - -class SimpleProductDeskSupportDto(serializers.ModelSerializer): - class Meta: - fields = ("id", "name", "price") - model = Product - - -class SimpleOptionGroupDeskSupportDto(serializers.ModelSerializer): - class Meta: - fields = ("id", "name", "is_custom_response", "custom_response_pattern", "placeholder_mode") - model = OptionGroup - - -class SimpleOptionDeskSupportDto(serializers.ModelSerializer): - class Meta: - fields = ("id", "name", "additional_price") - model = Option - - -class SimpleOrderProductOptionRelationDeskSupportDto(NestedModelSerializer): - id = serializers.UUIDField(required=True) - product_option_group = SimpleOptionGroupDeskSupportDto(read_only=True) - product_option = SimpleOptionDeskSupportDto(allow_null=True, read_only=True) - custom_response = serializers.CharField(allow_null=False, allow_blank=True) # Modifiable - - class Meta: - fields = ("id", "product_option_group", "product_option", "custom_response") - model = OrderProductOptionRelation - list_serializer_class = InstanceListSerializer - - def validate_id(self, value: str) -> str: - if value != typing.cast(OrderProductOptionRelation, self.instance).id: - raise serializers.ValidationError("id must not be modified") - return value - - def validate_custom_response(self, value: str) -> str: - option_group: OptionGroup = typing.cast(OrderProductOptionRelation, self.instance).product_option_group - if not option_group.is_custom_response: - raise serializers.ValidationError("cannot set custom response to non-custom-response option group") - if not option_group.custom_response_pattern: - raise serializers.ValidationError("custom response pattern is not set, please contact the administrator") - if not re.match(option_group.custom_response_pattern, value): - raise serializers.ValidationError("custom response does not match the pattern") - - return value - - -class SimpleOrderProductRelationDeskSupportDto(NestedModelSerializer): - id = serializers.UUIDField(required=True) - price = serializers.IntegerField(read_only=True) - donation_price = serializers.IntegerField(read_only=True) - status = serializers.ChoiceField( - choices=OrderProductRelation.OrderProductStatus.choices, - required=False, - ) # Modifiable - - product = SimpleProductDeskSupportDto(read_only=True) - options = SimpleOrderProductOptionRelationDeskSupportDto(many=True, required=False) # Modifiable - - class Meta: - fields = ( - "id", - "price", - "donation_price", - "status", - # Related fields - "product", - "options", - ) - model = OrderProductRelation - list_serializer_class = InstanceListSerializer - - def validate_status( - self, value: OrderProductRelation.OrderProductStatus - ) -> OrderProductRelation.OrderProductStatus: - instance = typing.cast(OrderProductRelation, self.instance) - if value == instance.status: - return value - if value not in PossibleStatusFSM[typing.cast(OrderProductRelation.OrderProductStatus, instance.status)]: - raise serializers.ValidationError("해당 상태로 변경할 수 없습니다.") - return value - - -class SimpleUserDeskSupportDto(serializers.ModelSerializer): - class Meta: - fields = ("id", "username", "email", "unique_id") - model = UserExt - - -class SimpleCustomerInfoDeskSupportDto(serializers.ModelSerializer): - class Meta: - fields = ("name", "email", "phone", "organization") - model = CustomerInfo - - -class DeskSupportSerializer(NestedModelSerializer): - id = serializers.UUIDField(read_only=True) - first_paid_price = serializers.IntegerField(read_only=True) - first_paid_at = serializers.DateTimeField(read_only=True) - current_paid_price = serializers.IntegerField(read_only=True) - current_status = serializers.CharField(read_only=True) - - payment_histories = SimplePaymentHistoryDeskSupportDto(many=True, read_only=True) - products = SimpleOrderProductRelationDeskSupportDto(many=True, required=False) # Modifiable - user = SimpleUserDeskSupportDto(read_only=True) - customer_info = SimpleCustomerInfoDeskSupportDto(read_only=True) - - class Meta: - fields = ( - "id", - "name", - "first_paid_price", - "first_paid_at", - "current_paid_price", - "current_status", - # Related fields - "payment_histories", - "products", - "user", - "customer_info", - ) - model = Order diff --git a/app/internal_api/test/__init__.py b/app/internal_api/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/internal_api/test/config_model_test.py b/app/internal_api/test/config_model_test.py new file mode 100644 index 0000000..06dc6af --- /dev/null +++ b/app/internal_api/test/config_model_test.py @@ -0,0 +1,132 @@ +from datetime import date + +import pytest +from django.db.utils import IntegrityError +from internal_api.models import RegistrationDeskConfig + +FOREVER_START = RegistrationDeskConfig.DEFAULT_START_DATE +FOREVER_END = RegistrationDeskConfig.DEFAULT_END_DATE + + +def _config(event, name: str, start: date = FOREVER_START, end: date = FOREVER_END) -> RegistrationDeskConfig: + return RegistrationDeskConfig.objects.create(name=name, event=event, start_date=start, end_date=end) + + +def _overlapping(start: date, end: date, exclude_pk=None): + return RegistrationDeskConfig.objects.filter_active().filter_by_overlap( + start_date=start, end_date=end, exclude_pk=exclude_pk + ) + + +def _for_date(on_date: date | None = None) -> RegistrationDeskConfig | None: + return RegistrationDeskConfig.objects.filter_active().filter_by_date(on_date).first() + + +@pytest.mark.django_db +def test_period_defaults_to_unbounded_range(desk_event): + config = RegistrationDeskConfig.objects.create(name="상시", event=desk_event) + + assert (config.start_date, config.end_date) == (date(1, 1, 1), date(9999, 12, 31)) + + +@pytest.mark.django_db +def test_db_constraint_rejects_end_date_before_start_date(desk_event): + with pytest.raises(IntegrityError): + _config(desk_event, "역전", date(2026, 8, 16), date(2026, 8, 15)) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("start", "end"), + [ + (date(2026, 8, 15), date(2026, 8, 15)), # 완전히 동일 + (date(2026, 8, 14), date(2026, 8, 15)), # 앞쪽 걸침 + (date(2026, 8, 16), date(2026, 8, 20)), # 뒤쪽 걸침 + (date(2026, 8, 10), date(2026, 8, 20)), # 포함 + (FOREVER_START, FOREVER_END), # 무기한 + (FOREVER_START, date(2026, 8, 15)), # 시작 무제한 + (date(2026, 8, 16), FOREVER_END), # 종료 무제한 + ], +) +def test_filter_by_overlap_detects_conflicting_period(desk_event, start, end): + existing = _config(desk_event, "기존", date(2026, 8, 15), date(2026, 8, 16)) + + assert _overlapping(start, end).first() == existing + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("start", "end"), + [ + (date(2026, 8, 17), date(2026, 8, 18)), # 직후 + (date(2026, 8, 13), date(2026, 8, 14)), # 직전 + (FOREVER_START, date(2026, 8, 14)), # 시작 무제한, 겹치지 않음 + (date(2026, 8, 17), FOREVER_END), # 종료 무제한, 겹치지 않음 + ], +) +def test_filter_by_overlap_allows_disjoint_period(desk_event, start, end): + _config(desk_event, "기존", date(2026, 8, 15), date(2026, 8, 16)) + + assert not _overlapping(start, end).exists() + + +@pytest.mark.django_db +def test_filter_by_overlap_excludes_given_pk(desk_event): + config = _config(desk_event, "기존", date(2026, 8, 15), date(2026, 8, 16)) + + assert not _overlapping(config.start_date, config.end_date, exclude_pk=config.pk).exists() + + +@pytest.mark.django_db +def test_filter_by_overlap_ignores_soft_deleted_config(desk_event): + _config(desk_event, "삭제됨", date(2026, 8, 15), date(2026, 8, 16)).delete() + + assert not _overlapping(date(2026, 8, 15), date(2026, 8, 16)).exists() + + +@pytest.mark.django_db +def test_filter_by_date_picks_config_covering_the_date(desk_event): + _config(desk_event, "Day 1", date(2026, 8, 15), date(2026, 8, 15)) + day2 = _config(desk_event, "Day 2", date(2026, 8, 16), date(2026, 8, 16)) + + assert _for_date(date(2026, 8, 16)) == day2 + + +@pytest.mark.django_db +def test_filter_by_date_returns_nothing_when_no_config_covers_the_date(desk_event): + _config(desk_event, "Day 1", date(2026, 8, 15), date(2026, 8, 15)) + + assert _for_date(date(2026, 8, 20)) is None + + +@pytest.mark.django_db +def test_filter_by_date_matches_unbounded_config(desk_event): + config = _config(desk_event, "상시") + + assert _for_date(date(2026, 8, 20)) == config + + +@pytest.mark.django_db +def test_filter_by_date_defaults_to_today(desk_event): + config = _config(desk_event, "상시") + + assert _for_date() == config + + +@pytest.mark.django_db +def test_prefetch_active_targets_excludes_soft_deleted_categories(desk_event, ticket_product, non_ticket_product): + config = _config(desk_event, "상시") + config.categories.add(ticket_product.category, non_ticket_product.category) + non_ticket_product.category.delete() + + fetched = RegistrationDeskConfig.objects.filter_active().prefetch_active_targets().get(pk=config.pk) + + assert list(fetched.categories.all()) == [ticket_product.category] + + +@pytest.mark.django_db +def test_ordering_is_by_period(desk_event): + day2 = _config(desk_event, "Day 2", date(2026, 8, 16), date(2026, 8, 16)) + day1 = _config(desk_event, "Day 1", date(2026, 8, 15), date(2026, 8, 15)) + + assert list(RegistrationDeskConfig.objects.filter_active()) == [day1, day2] diff --git a/app/internal_api/test/configuration_test.py b/app/internal_api/test/configuration_test.py new file mode 100644 index 0000000..b443733 --- /dev/null +++ b/app/internal_api/test/configuration_test.py @@ -0,0 +1,80 @@ +from datetime import timedelta + +import pytest +from core.util.dateutil import now_aware +from django.urls import reverse +from file.models import PublicFile +from model_bakery import baker +from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN +from shop.order.models import OrderProductRelationTag + +CONFIGURATION_URL = reverse("v1:registration_desk:desk-configuration") + + +@pytest.mark.django_db +def test_configuration_rejects_anonymous(anon_client): + assert anon_client.get(CONFIGURATION_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_configuration_rejects_non_superuser(customer_client): + assert customer_client.get(CONFIGURATION_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_configuration_returns_config_of_today(staff_client, ticket_config, desk_event): + response = staff_client.get(CONFIGURATION_URL) + + assert response.status_code == HTTP_200_OK + body = response.json() + assert set(body) == {"id", "name", "start_date", "end_date", "event", "available_tags"} + assert body["id"] == str(ticket_config.id) + assert body["name"] == "티켓" + assert (body["start_date"], body["end_date"]) == ("0001-01-01", "9999-12-31") + assert set(body["event"]) == {"id", "name", "event_start_at", "event_end_at", "logo_url"} + assert body["event"]["id"] == str(desk_event.id) + assert body["event"]["logo_url"] is None + assert body["available_tags"] == [] + + +@pytest.mark.django_db +def test_configuration_rejects_when_no_config_applies_today(staff_client, ticket_config): + ticket_config.start_date = ticket_config.end_date = now_aware().date() + timedelta(days=1) + ticket_config.save() + + response = staff_client.get(CONFIGURATION_URL) + + assert response.status_code == HTTP_403_FORBIDDEN + assert "등록 데스크 설정" in str(response.json()) + + +@pytest.mark.django_db +def test_configuration_exposes_event_logo(staff_client, ticket_config, desk_event): + desk_event.logo = PublicFile.objects.create( + file="public/desk-logo.png", mimetype="image/png", hash="desk-logo", size=0 + ) + desk_event.save() + + assert staff_client.get(CONFIGURATION_URL).json()["event"]["logo_url"] == desk_event.logo.file.url + + +@pytest.mark.django_db +def test_configuration_uses_event_of_current_desk_config(staff_client, ticket_config): + config_event = baker.make("event.Event", name="2025", event_start_at="2025-08-01T00:00:00Z") + baker.make("event.Event", name="2027", event_start_at="2027-08-01T00:00:00Z") + ticket_config.event = config_event + ticket_config.save() + + assert staff_client.get(CONFIGURATION_URL).json()["event"]["id"] == str(config_event.id) + + +@pytest.mark.django_db +def test_configuration_lists_available_tags(staff_client, ticket_config): + OrderProductRelationTag.objects.create(code="volunteer", name="자원봉사자", priority=2) + OrderProductRelationTag.objects.create(code="speaker", name="발표자", priority=1) + OrderProductRelationTag.objects.create(code="deleted", name="삭제됨").delete() + + tags = staff_client.get(CONFIGURATION_URL).json()["available_tags"] + + # 소프트 삭제 태그는 빠지고, priority 순서로 고정된다. + assert [tag["code"] for tag in tags] == ["speaker", "volunteer"] diff --git a/app/internal_api/test/conftest.py b/app/internal_api/test/conftest.py new file mode 100644 index 0000000..1c92bfc --- /dev/null +++ b/app/internal_api/test/conftest.py @@ -0,0 +1,42 @@ +import pytest +from event.models import Event +from internal_api.models import RegistrationDeskConfig +from model_bakery import baker +from shop.conftest import ( # noqa: F401 + anon_client, + customer_client, + customer_user, + donation_product, + mock_portone_req_cancel_payment, + modifiable_option_relation, + non_ticket_product, + option, + option_group, + order_factory, + staff_client, + staff_user, + tag, + ticket_product, +) +from shop.order.models import OrderProductRelation + + +@pytest.fixture +def used_opr(order_factory): # noqa: F811 + """체크인(사용) 완료된 티켓 OPR.""" + order = order_factory(status="completed") + order.products.update(status=OrderProductRelation.OrderProductStatus.used) + return order.products.get() + + +@pytest.fixture +def desk_event(db) -> Event: + return baker.make("event.Event", name="파이콘 한국 2026", event_start_at="2026-08-01T00:00:00Z") + + +@pytest.fixture +def ticket_config(ticket_product, desk_event) -> RegistrationDeskConfig: # noqa: F811 + """`ticket_product` 의 카테고리만 집계하는, 기간 제한 없는 데스크 설정.""" + config = RegistrationDeskConfig.objects.create(name="티켓", event=desk_event) + config.categories.add(ticket_product.category) + return config diff --git a/app/internal_api/test/order_products_test.py b/app/internal_api/test/order_products_test.py new file mode 100644 index 0000000..8ce0cc6 --- /dev/null +++ b/app/internal_api/test/order_products_test.py @@ -0,0 +1,217 @@ +import uuid + +import pytest +from django.urls import reverse +from rest_framework.status import ( + HTTP_200_OK, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, +) +from shop.order.models import OrderProductRelation, TicketInfo +from shop.test.helpers import valid_refund_totp + +LIST_URL = reverse("v1:registration_desk:order-products-list") + + +def _refund_url(opr_id) -> str: + return reverse("v1:registration_desk:order-products-refund", args=[opr_id]) + + +def _results(response) -> list[dict]: + return response.json()["results"] + + +@pytest.mark.django_db +def test_order_product_list_rejects_anonymous(anon_client, order_factory): + opr = order_factory(status="completed").products.get() + assert anon_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_order_product_list_rejects_non_superuser(customer_client, order_factory): + opr = order_factory(status="completed").products.get() + assert customer_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_order_product_list_requires_a_filter(staff_client): + response = staff_client.get(LIST_URL) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "order_product_relation_id" in str(response.json()) + + +@pytest.mark.django_db +def test_order_product_list_rejects_both_filters(staff_client, order_factory): + opr = order_factory(status="completed").products.get() + + response = staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id), "scancode": opr.scancode_token}) + + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_order_product_list_returns_detail_by_id(staff_client, order_factory, ticket_product): + order = order_factory(status="completed") + opr = order.products.get() + + response = staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}) + + assert response.status_code == HTTP_200_OK + assert response.json()["count"] == 1 + [body] = _results(response) + assert body["id"] == str(opr.id) + assert body["short_id"] == opr.short_id + assert body["is_ticket"] is True + assert body["status"] == OrderProductRelation.OrderProductStatus.paid + assert body["product"]["name"] == ticket_product.name + assert body["product"]["category"] == {"id": str(ticket_product.category_id), "name": "티켓"} + assert body["order"]["id"] == str(order.id) + assert body["ticket_info"] is None + + +@pytest.mark.django_db +def test_order_product_list_marks_non_ticket_product(staff_client, order_factory): + opr = order_factory(status="completed", is_ticket=False).products.get() + + [body] = _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})) + + assert body["is_ticket"] is False + + +@pytest.mark.django_db +def test_order_product_list_returns_ticket_info(staff_client, order_factory): + opr = order_factory(status="completed").products.get() + TicketInfo.objects.create( + order_product_relation=opr, + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + + [body] = _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})) + + assert body["ticket_info"] == { + "name": "김참가", + "email": "attendee@example.com", + "phone": "010-9999-8888", + "organization": "PSK", + } + + +@pytest.mark.django_db +def test_order_product_list_returns_null_ticket_info_when_soft_deleted(staff_client, order_factory): + opr = order_factory(status="completed").products.get() + TicketInfo.objects.create( + order_product_relation=opr, name="김참가", phone="010-9999-8888", email="attendee@example.com" + ).delete() + + [body] = _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})) + + assert body["ticket_info"] is None + + +@pytest.mark.django_db +def test_order_product_list_returns_empty_for_unknown_id(staff_client): + response = staff_client.get(LIST_URL, {"order_product_relation_id": str(uuid.uuid4())}) + + assert response.status_code == HTTP_200_OK + assert response.json() == {"count": 0, "next": None, "previous": None, "results": []} + + +@pytest.mark.django_db +def test_order_product_list_returns_empty_for_soft_deleted_relation(staff_client, order_factory): + opr = order_factory(status="completed").products.get() + opr.delete() + + assert _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})) == [] + + +@pytest.mark.django_db +def test_order_product_list_excludes_cart_without_payment(staff_client, order_factory): + opr = order_factory(status="cart").products.get() + + assert _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})) == [] + + +@pytest.mark.django_db +def test_order_product_list_finds_by_scancode(staff_client, order_factory): + opr = order_factory(status="completed").products.get() + + [body] = _results(staff_client.get(LIST_URL, {"scancode": opr.scancode_token})) + + assert body["id"] == str(opr.id) + + +@pytest.mark.django_db +@pytest.mark.parametrize("token", ["그냥문자열", "opr:short", "order:AbCdEf:salt"]) +def test_order_product_list_rejects_malformed_scancode(staff_client, token): + response = staff_client.get(LIST_URL, {"scancode": token}) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "스캔코드" in str(response.json()) + + +@pytest.mark.django_db +def test_order_product_list_returns_empty_for_tampered_salt(staff_client, order_factory): + opr = order_factory(status="completed").products.get() + prefix, short_id, _salt = opr.scancode_token.split(":") + + response = staff_client.get(LIST_URL, {"scancode": f"{prefix}:{short_id}:tampered"}) + + assert response.status_code == HTTP_200_OK + assert _results(response) == [] + + +@pytest.mark.django_db +def test_order_product_refund_rejects_non_superuser(customer_client, order_factory, mock_portone_req_cancel_payment): + opr = order_factory(status="completed").products.get() + + response = customer_client.delete(f"{_refund_url(opr.id)}?otp={valid_refund_totp()}") + + assert response.status_code == HTTP_403_FORBIDDEN + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_order_product_refund_requires_valid_otp( + ticket_config, staff_client, order_factory, mock_portone_req_cancel_payment +): + opr = order_factory(status="completed").products.get() + + response = staff_client.delete(f"{_refund_url(opr.id)}?otp=000000") + + assert response.status_code == HTTP_400_BAD_REQUEST + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_order_product_refund_marks_only_that_product_refunded( + ticket_config, staff_client, order_factory, ticket_product, mock_portone_req_cancel_payment +): + order = order_factory(status="completed") + target = order.products.get() + other = OrderProductRelation.objects.create( + order=order, product=ticket_product, price=ticket_product.price, status=target.status + ) + + response = staff_client.delete(f"{_refund_url(target.id)}?otp={valid_refund_totp()}") + + assert response.status_code == HTTP_204_NO_CONTENT + mock_portone_req_cancel_payment.assert_called_once() + target.refresh_from_db() + other.refresh_from_db() + assert target.status == OrderProductRelation.OrderProductStatus.refunded + assert other.status == OrderProductRelation.OrderProductStatus.paid + + +@pytest.mark.django_db +def test_order_product_refund_rejects_used_product( + ticket_config, staff_client, used_opr, mock_portone_req_cancel_payment +): + response = staff_client.delete(f"{_refund_url(used_opr.id)}?otp={valid_refund_totp()}") + + assert response.status_code == HTTP_400_BAD_REQUEST + mock_portone_req_cancel_payment.assert_not_called() diff --git a/app/internal_api/test/orders_test.py b/app/internal_api/test/orders_test.py new file mode 100644 index 0000000..0a10b4f --- /dev/null +++ b/app/internal_api/test/orders_test.py @@ -0,0 +1,510 @@ +from datetime import timedelta + +import pytest +from core.util.dateutil import now_aware +from django.urls import reverse +from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN +from shop.order.models import OrderProductRelation, OrderProductRelationTag, TicketInfo +from shop.product.models import OptionGroup +from shop.test.helpers import valid_refund_totp + +ORDERS_URL = reverse("v1:registration_desk:orders-list") +SEARCH = {"keywords": "홍길동"} + + +def _detail_url(order_id) -> str: + return reverse("v1:registration_desk:orders-detail", args=[order_id]) + + +def _refund_url(order_id) -> str: + return reverse("v1:registration_desk:orders-refund", args=[order_id]) + + +@pytest.mark.django_db +def test_orders_rejects_anonymous(anon_client): + assert anon_client.get(ORDERS_URL, SEARCH).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_orders_rejects_non_superuser(customer_client, order_factory): + order_factory(status="completed") + assert customer_client.get(ORDERS_URL, SEARCH).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_orders_list_returns_paginated_paid_orders(staff_client, order_factory): + order = order_factory(status="completed") + order_factory(status="cart") # 결제 이력 없음 → 제외 + + response = staff_client.get(ORDERS_URL, SEARCH) + + assert response.status_code == HTTP_200_OK + body = response.json() + assert body["count"] == 1 + assert [result["id"] for result in body["results"]] == [str(order.id)] + assert body["results"][0]["customer_info"]["name"] == "홍길동" + + +@pytest.mark.django_db +def test_orders_list_exposes_ticket_info_and_product_category(staff_client, order_factory, ticket_product): + order = order_factory(status="completed") + TicketInfo.objects.create( + order_product_relation=order.products.get(), + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + + product = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]["products"][0] + + assert product["ticket_info"]["name"] == "김참가" + assert product["is_ticket"] is True + assert product["product"]["category"] == {"id": str(ticket_product.category_id), "name": "티켓"} + + +@pytest.mark.django_db +def test_orders_list_returns_null_ticket_info_when_absent(staff_client, order_factory): + order_factory(status="completed") + + product = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]["products"][0] + + assert product["ticket_info"] is None + + +@pytest.mark.django_db +def test_orders_list_excludes_soft_deleted_products(staff_client, order_factory): + order = order_factory(status="completed") + order.products.get().delete() + + assert staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]["products"] == [] + + +@pytest.mark.django_db +def test_orders_list_filters_by_keyword(staff_client, order_factory): + order = order_factory(status="completed") + + assert staff_client.get(ORDERS_URL, {"keywords": "홍길동"}).json()["count"] == 1 + assert staff_client.get(ORDERS_URL, {"keywords": "없는사람"}).json()["count"] == 0 + assert staff_client.get(ORDERS_URL, {"order_id": str(order.id)}).json()["count"] == 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize("keyword", ["김참가", "attendee@example.com", "010-9999-8888", "PSK"]) +def test_orders_list_filters_by_ticket_info(staff_client, order_factory, keyword): + order = order_factory(status="completed") + TicketInfo.objects.create( + order_product_relation=order.products.get(), + name="김참가", + phone="010-9999-8888", + email="attendee@example.com", + organization="PSK", + ) + order_factory(status="completed") # 참가자 정보 없는 다른 주문 + + response = staff_client.get(ORDERS_URL, {"keywords": keyword}) + + assert [result["id"] for result in response.json()["results"]] == [str(order.id)] + + +@pytest.mark.django_db +def test_orders_list_ignores_ticket_info_of_soft_deleted_product(staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + TicketInfo.objects.create( + order_product_relation=opr, name="김참가", phone="010-9999-8888", email="attendee@example.com" + ) + opr.delete() + + assert staff_client.get(ORDERS_URL, {"keywords": "김참가"}).json()["count"] == 0 + + +@pytest.mark.django_db +def test_orders_patch_checks_in_paid_product(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + + response = staff_client.patch( + _detail_url(order.id), + {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.used}]}, + format="json", + ) + + assert response.status_code == HTTP_200_OK + opr.refresh_from_db() + assert opr.status == OrderProductRelation.OrderProductStatus.used + + +@pytest.mark.django_db +def test_orders_patch_can_revert_check_in(ticket_config, staff_client, used_opr): + response = staff_client.patch( + _detail_url(used_opr.order_id), + {"products": [{"id": str(used_opr.id), "status": OrderProductRelation.OrderProductStatus.paid}]}, + format="json", + ) + + assert response.status_code == HTTP_200_OK + used_opr.refresh_from_db() + assert used_opr.status == OrderProductRelation.OrderProductStatus.paid + + +@pytest.mark.django_db +def test_orders_patch_rejects_forbidden_status_transition(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + + response = staff_client.patch( + _detail_url(order.id), + {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.refunded}]}, + format="json", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + opr.refresh_from_db() + assert opr.status == OrderProductRelation.OrderProductStatus.paid + + +@pytest.mark.django_db +def test_orders_refund_requires_valid_otp(ticket_config, staff_client, order_factory, mock_portone_req_cancel_payment): + order = order_factory(status="completed") + + response = staff_client.delete(f"{_refund_url(order.id)}?otp=000000") + + assert response.status_code == HTTP_400_BAD_REQUEST + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_orders_refund_cancels_payment_and_marks_products_refunded( + ticket_config, staff_client, order_factory, mock_portone_req_cancel_payment +): + order = order_factory(status="completed") + + response = staff_client.delete(f"{_refund_url(order.id)}?otp={valid_refund_totp()}") + + assert response.status_code == HTTP_204_NO_CONTENT + mock_portone_req_cancel_payment.assert_called_once() + assert order.products.get().status == OrderProductRelation.OrderProductStatus.refunded + + +@pytest.mark.django_db +def test_orders_refund_rejects_non_superuser(customer_client, order_factory, mock_portone_req_cancel_payment): + order = order_factory(status="completed") + + response = customer_client.delete(f"{_refund_url(order.id)}?otp={valid_refund_totp()}") + + assert response.status_code == HTTP_403_FORBIDDEN + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_orders_does_not_expose_destroy(staff_client, order_factory): + order = order_factory(status="completed") + + assert staff_client.delete(_detail_url(order.id)).status_code == 405 + + +def _patch_ticket_info(client, order, opr, ticket_info): + return client.patch( + _detail_url(order.id), + {"products": [{"id": str(opr.id), "ticket_info": ticket_info}]}, + format="json", + ) + + +VALID_TICKET_INFO = { + "name": "김참가", + "phone": "010-9999-8888", + "email": "attendee@example.com", + "organization": "PSK", +} + + +@pytest.mark.django_db +def test_orders_patch_creates_ticket_info(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + + response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO) + + assert response.status_code == HTTP_200_OK + assert response.json()["products"][0]["ticket_info"] == VALID_TICKET_INFO + assert TicketInfo.objects.get(order_product_relation=opr).name == "김참가" + + +@pytest.mark.django_db +def test_orders_patch_updates_existing_ticket_info(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + ticket_info = TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO) + + response = _patch_ticket_info(staff_client, order, opr, {**VALID_TICKET_INFO, "name": "이참가"}) + + assert response.status_code == HTTP_200_OK + ticket_info.refresh_from_db() + assert ticket_info.name == "이참가" + assert TicketInfo.objects.filter(order_product_relation=opr).count() == 1 + + +@pytest.mark.django_db +def test_orders_patch_restores_soft_deleted_ticket_info(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + ticket_info = TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO) + ticket_info.delete() + + response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO) + + # OneToOne 이라 새 row 를 만들 수 없어, 삭제된 row 를 되살려 덮어쓴다. + assert response.status_code == HTTP_200_OK + ticket_info.refresh_from_db() + assert ticket_info.deleted_at is None + assert TicketInfo.objects.filter(order_product_relation=opr).count() == 1 + + +@pytest.mark.django_db +def test_orders_patch_rejects_null_ticket_info(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + ticket_info = TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO) + + response = _patch_ticket_info(staff_client, order, opr, None) + + assert response.status_code == HTTP_400_BAD_REQUEST + ticket_info.refresh_from_db() + assert ticket_info.deleted_at is None + + +@pytest.mark.django_db +def test_orders_patch_keeps_ticket_info_when_key_is_omitted(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO) + + response = staff_client.patch( + _detail_url(order.id), + {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.used}]}, + format="json", + ) + + assert response.status_code == HTTP_200_OK + assert response.json()["products"][0]["ticket_info"] == VALID_TICKET_INFO + + +@pytest.mark.django_db +def test_orders_patch_rejects_ticket_info_for_non_ticket_product( + ticket_config, staff_client, order_factory, non_ticket_product +): + order = order_factory(status="completed", is_ticket=False) + opr = order.products.get() + # 범위 검증(403)이 아니라 티켓 여부 검증(400)에 걸리도록 굿즈 카테고리도 설정에 포함시킨다. + ticket_config.categories.add(non_ticket_product.category) + + response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert not TicketInfo.objects.filter(order_product_relation=opr).exists() + + +@pytest.mark.django_db +def test_orders_patch_rejects_malformed_phone(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + opr = order.products.get() + + response = _patch_ticket_info(staff_client, order, opr, {**VALID_TICKET_INFO, "phone": "전화번호아님"}) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert not TicketInfo.objects.filter(order_product_relation=opr).exists() + + +@pytest.mark.django_db +def test_orders_list_requires_a_filter(staff_client, order_factory): + order_factory(status="completed") + + response = staff_client.get(ORDERS_URL) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert "keywords" in str(response.json()) + + +@pytest.mark.django_db +def test_orders_list_rejects_pagination_only_request(staff_client, order_factory): + order_factory(status="completed") + + assert staff_client.get(ORDERS_URL, {"page": 1, "page_size": 10}).status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_orders_list_rejects_blank_filter(staff_client, order_factory): + order_factory(status="completed") + + assert staff_client.get(ORDERS_URL, {"keywords": " "}).status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_orders_patch_rejects_ticket_info_for_refunded_product(ticket_config, staff_client, order_factory): + order = order_factory(status="refunded") + opr = order.products.get() + + response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO) + + assert response.status_code == HTTP_400_BAD_REQUEST + assert not TicketInfo.objects.filter(order_product_relation=opr).exists() + + +@pytest.mark.django_db +def test_orders_patch_rejects_out_of_scope_product(ticket_config, staff_client, order_factory): + order = order_factory(status="completed", is_ticket=False) + opr = order.products.get() + + response = staff_client.patch( + _detail_url(order.id), + {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.used}]}, + format="json", + ) + + assert response.status_code == HTTP_403_FORBIDDEN + opr.refresh_from_db() + assert opr.status == OrderProductRelation.OrderProductStatus.paid + + +@pytest.mark.django_db +def test_orders_patch_allows_reading_out_of_scope_order(ticket_config, staff_client, order_factory): + order_factory(status="completed", is_ticket=False) + + assert staff_client.get(ORDERS_URL, SEARCH).json()["count"] == 1 + + +@pytest.mark.django_db +def test_orders_refund_rejects_out_of_scope_order( + ticket_config, staff_client, order_factory, mock_portone_req_cancel_payment +): + order = order_factory(status="completed", is_ticket=False) + + response = staff_client.delete(f"{_refund_url(order.id)}?otp={valid_refund_totp()}") + + assert response.status_code == HTTP_403_FORBIDDEN + mock_portone_req_cancel_payment.assert_not_called() + + +@pytest.mark.django_db +def test_orders_exposes_desk_refund_reasons(ticket_config, staff_client, order_factory): + order_factory(status="completed") + + body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0] + + assert body["not_fully_refundable_reason"] is None + assert body["products"][0]["not_refundable_reason"] is None + assert body["created_at"] is not None + + +@pytest.mark.django_db +def test_orders_refund_reason_ignores_date_limit(ticket_config, staff_client, order_factory, ticket_product): + # 데스크 환불은 환불 기한을 우회하므로, 기한 만료는 "환불 불가 사유" 로 응답하지 않는다. + ticket_product.refundable_ends_at = now_aware() - timedelta(days=1) + ticket_product.save() + order_factory(status="completed") + + body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0] + + assert body["not_fully_refundable_reason"] is None + assert body["products"][0]["not_refundable_reason"] is None + + +@pytest.mark.django_db +def test_orders_refund_reason_reports_used_product(ticket_config, staff_client, used_opr): + body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0] + + assert body["products"][0]["not_refundable_reason"] is not None + assert body["not_fully_refundable_reason"] is not None + + +@pytest.mark.django_db +def test_orders_patch_rejects_option_change_for_used_product(ticket_config, staff_client, modifiable_option_relation): + opr = modifiable_option_relation.order_product_relation + opr.status = OrderProductRelation.OrderProductStatus.used + opr.save() + + response = staff_client.patch( + _detail_url(opr.order_id), + { + "products": [ + { + "id": str(opr.id), + "options": [{"id": str(modifiable_option_relation.id), "custom_response": "변경"}], + } + ] + }, + format="json", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_orders_patch_allows_null_custom_response(ticket_config, staff_client, modifiable_option_relation): + opr = modifiable_option_relation.order_product_relation + + response = staff_client.patch( + _detail_url(opr.order_id), + { + "products": [ + { + "id": str(opr.id), + "options": [{"id": str(modifiable_option_relation.id), "custom_response": None}], + } + ] + }, + format="json", + ) + + assert response.status_code == HTTP_200_OK + modifiable_option_relation.refresh_from_db() + assert modifiable_option_relation.custom_response is None + + +@pytest.mark.django_db +def test_orders_patch_rejects_null_custom_response_when_required( + ticket_config, staff_client, modifiable_option_relation +): + group = modifiable_option_relation.product_option_group + group.placeholder_mode = OptionGroup.PlaceholderMode.REQUIRED + group.save() + + response = staff_client.patch( + _detail_url(modifiable_option_relation.order_product_relation.order_id), + { + "products": [ + { + "id": str(modifiable_option_relation.order_product_relation_id), + "options": [{"id": str(modifiable_option_relation.id), "custom_response": None}], + } + ] + }, + format="json", + ) + + assert response.status_code == HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_orders_list_exposes_tags(ticket_config, staff_client, order_factory): + order = order_factory(status="completed") + tag = OrderProductRelationTag.objects.create(code="speaker", name="발표자") + tag.order_product_relations.add(order.products.get()) + + body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0] + + assert body["products"][0]["tags"] == [{"id": str(tag.id), "code": "speaker", "name": "발표자"}] + + +@pytest.mark.django_db +def test_orders_patch_ignores_order_name(ticket_config, staff_client, order_factory): + # 상품 외 필드가 열려 있으면 범위 검증 없이 주문을 바꿀 수 있다. + order = order_factory(status="completed") + + response = staff_client.patch(_detail_url(order.id), {"name": "바뀐 이름"}, format="json") + + assert response.status_code == HTTP_200_OK + order.refresh_from_db() + assert order.name != "바뀐 이름" diff --git a/app/internal_api/test/session_test.py b/app/internal_api/test/session_test.py new file mode 100644 index 0000000..029362c --- /dev/null +++ b/app/internal_api/test/session_test.py @@ -0,0 +1,48 @@ +import pytest +from django.urls import reverse +from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_405_METHOD_NOT_ALLOWED + +SESSION_URL = reverse("v1:registration_desk:desk-session") + + +@pytest.mark.django_db +def test_session_rejects_anonymous(anon_client): + assert anon_client.get(SESSION_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_session_rejects_non_superuser(customer_client): + assert customer_client.get(SESSION_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_session_returns_staff_profile(staff_client, staff_user): + staff_user.nickname = "등록데스크" + staff_user.save() + + response = staff_client.get(SESSION_URL) + + assert response.status_code == HTTP_200_OK + assert response.json() == { + "id": staff_user.id, + "unique_id": str(staff_user.unique_id), + "username": staff_user.username, + "nickname": "등록데스크", + "email": staff_user.email, + } + + +@pytest.mark.django_db +def test_session_sets_csrf_cookie(staff_client, settings): + assert settings.CSRF_COOKIE_NAME in staff_client.get(SESSION_URL).cookies + + +@pytest.mark.django_db +def test_session_sets_csrf_cookie_even_when_forbidden(anon_client, settings): + # 로그인 직후 곧바로 PATCH 를 보낼 수 있어야 하므로 403 응답에도 쿠키를 실는다. + assert settings.CSRF_COOKIE_NAME in anon_client.get(SESSION_URL).cookies + + +@pytest.mark.django_db +def test_session_does_not_handle_logout(staff_client): + assert staff_client.delete(SESSION_URL).status_code == HTTP_405_METHOD_NOT_ALLOWED diff --git a/app/internal_api/test/statistics_test.py b/app/internal_api/test/statistics_test.py new file mode 100644 index 0000000..0d29c31 --- /dev/null +++ b/app/internal_api/test/statistics_test.py @@ -0,0 +1,122 @@ +from datetime import timedelta + +import pytest +from core.util.dateutil import now_aware +from django.urls import reverse +from internal_api.models import RegistrationDeskConfig +from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN +from shop.order.models import OrderProductRelation + +STATISTICS_URL = reverse("v1:registration_desk:desk-statistics") + + +@pytest.mark.django_db +def test_statistics_rejects_anonymous(anon_client): + assert anon_client.get(STATISTICS_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_statistics_rejects_non_superuser(customer_client): + assert customer_client.get(STATISTICS_URL).status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_statistics_rejects_when_no_config_applies_today(staff_client, order_factory, ticket_config): + order_factory(status="completed") + ticket_config.start_date = ticket_config.end_date = now_aware().date() + timedelta(days=1) + ticket_config.save() + + response = staff_client.get(STATISTICS_URL) + + assert response.status_code == HTTP_403_FORBIDDEN + assert "등록 데스크 설정" in str(response.json()) + + +@pytest.mark.django_db +def test_statistics_counts_used_as_registered_and_paid_as_waiting(staff_client, order_factory, ticket_config, used_opr): + order_factory(status="completed") # paid → 대기 + order_factory(status="refunded") # 환불 → 집계 제외 + + response = staff_client.get(STATISTICS_URL) + + assert response.status_code == HTTP_200_OK + assert response.json() == { + "registration_target_count": 2, + "registered_count": 1, + "waiting_count": 1, + } + + +@pytest.mark.django_db +def test_statistics_excludes_pending_cart_products(staff_client, order_factory, ticket_config): + order_factory(status="cart") + + response = staff_client.get(STATISTICS_URL) + + assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0} + + +@pytest.mark.django_db +def test_statistics_counts_only_config_categories(staff_client, order_factory, ticket_config): + order_factory(status="completed") + order_factory(status="completed", is_ticket=False) # 설정(티켓 카테고리) 밖 + + response = staff_client.get(STATISTICS_URL) + + assert response.json() == {"registration_target_count": 1, "registered_count": 0, "waiting_count": 1} + + +@pytest.mark.django_db +def test_statistics_counts_nothing_when_config_has_no_categories(staff_client, order_factory, desk_event): + # 빈 카테고리를 "전체" 로 해석하지 않는다 — 설정 실수의 영향을 줄이기 위한 의도적 동작. + RegistrationDeskConfig.objects.create(name="빈 설정", event=desk_event) + order_factory(status="completed") + + response = staff_client.get(STATISTICS_URL) + + assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0} + + +@pytest.mark.django_db +def test_statistics_excludes_non_ticket_category(staff_client, order_factory, non_ticket_product, desk_event): + config = RegistrationDeskConfig.objects.create(name="굿즈", event=desk_event) + config.categories.add(non_ticket_product.category) + order_factory(status="completed", is_ticket=False) + + assert staff_client.get(STATISTICS_URL).json()["registration_target_count"] == 0 + + +@pytest.mark.django_db +def test_statistics_uses_config_matching_today(staff_client, order_factory, ticket_config, ticket_product, desk_event): + order_factory(status="completed") + yesterday = now_aware().date() - timedelta(days=1) + ticket_config.start_date = ticket_config.end_date = yesterday + ticket_config.save() + # 어제 설정은 티켓을 담고 있지만, 오늘 설정은 비어 있어 아무것도 집계되지 않아야 한다. + RegistrationDeskConfig.objects.create( + name="오늘", event=desk_event, start_date=now_aware().date(), end_date=now_aware().date() + ) + + response = staff_client.get(STATISTICS_URL) + + assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0} + + +@pytest.mark.django_db +def test_statistics_ignores_soft_deleted_order_products(staff_client, order_factory, ticket_config): + order = order_factory(status="completed") + OrderProductRelation.objects.filter(order=order).delete() + + response = staff_client.get(STATISTICS_URL) + + assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0} + + +@pytest.mark.django_db +def test_statistics_ignores_soft_deleted_config_categories(staff_client, order_factory, ticket_config, ticket_product): + order_factory(status="completed") + ticket_product.category.delete() + + response = staff_client.get(STATISTICS_URL) + + assert response.json()["registration_target_count"] == 0 diff --git a/app/internal_api/urls.py b/app/internal_api/urls.py index 8140361..17029e0 100644 --- a/app/internal_api/urls.py +++ b/app/internal_api/urls.py @@ -1,8 +1,3 @@ from django.urls import include, path -from internal_api import views -from rest_framework import routers -router = routers.SimpleRouter() -router.register("desk-support", views.DeskSupportViewSet, basename="desk-support") - -urlpatterns = [path("", include(router.urls))] +urlpatterns = [path("registration-desk/", include("internal_api.registration_desk.urls"))] diff --git a/app/internal_api/views.py b/app/internal_api/views.py deleted file mode 100644 index f97ce74..0000000 --- a/app/internal_api/views.py +++ /dev/null @@ -1,112 +0,0 @@ -import typing - -from core.authn.api_key import APIKeyAuthentication -from core.authz.api_key import RegistrationDeskAPIKeyPermission -from core.const.tag import OpenAPITag -from django.db import models, transaction -from django.utils.decorators import method_decorator -from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema -from drf_standardized_errors.openapi_serializers import ( - Error403Serializer, - Error404Serializer, - ValidationErrorResponseSerializer, -) -from internal_api.filters import DeskSupportFilterSet -from internal_api.serializers import DeskSupportSerializer -from rest_framework import mixins, request, response, status, viewsets -from shop.order.models import Order, OrderProductRelation -from shop.payment_history.models import PaymentHistory -from shop.serializers.refund import OrderTotalRefundSerializer - - -@method_decorator( - name="list", - decorator=extend_schema( - summary="주문 검색", - tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], - responses={ - status.HTTP_200_OK: DeskSupportSerializer(many=True), - status.HTTP_403_FORBIDDEN: Error403Serializer, - }, - ), -) -@method_decorator( - name="partial_update", - decorator=extend_schema( - summary="주문 수정", - tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], - responses={ - status.HTTP_200_OK: DeskSupportSerializer, - status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, - status.HTTP_403_FORBIDDEN: Error403Serializer, - status.HTTP_404_NOT_FOUND: Error404Serializer, - }, - ), -) -@method_decorator(name="partial_update", decorator=transaction.atomic) -class DeskSupportViewSet( - mixins.ListModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): - queryset = ( - Order.objects.filter_has_payment_histories() - .select_related("customer_info") - .prefetch_related( - models.Prefetch( - lookup="products", - queryset=( - OrderProductRelation.objects.filter_active().select_related("product").prefetch_active_options() - ), - ), - models.Prefetch( - "payment_histories", - queryset=PaymentHistory.objects.filter_active(), - to_attr="_active_payment_histories", - ), - ) - .order_by("-created_at") - ) - filterset_class = DeskSupportFilterSet - serializer_class = DeskSupportSerializer - authentication_classes = [APIKeyAuthentication] - permission_classes = [RegistrationDeskAPIKeyPermission] - http_method_names = ["get", "patch", "delete"] - - @extend_schema( - summary="주문 전체 환불", - tags=[OpenAPITag.EXT_REGISTRATION_DESK_API], - parameters=[ - OpenApiParameter( - name="otp", - type=OpenApiTypes.STR, - location=OpenApiParameter.QUERY, - allow_blank=False, - required=True, - description="환불 승인자의 6자리 TOTP 코드", - ), - ], - responses={ - status.HTTP_204_NO_CONTENT: None, - status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, - status.HTTP_403_FORBIDDEN: Error403Serializer, - status.HTTP_404_NOT_FOUND: Error404Serializer, - }, - ) - @transaction.atomic - def destroy( - self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] - ) -> response.Response: - """ - Order의 사용 및 환불하지 않은 상품을 refunded 상태로 변경하고, 결제 취소를 요청합니다. - 일반 전체 환불 API와의 차이점은, 환불 시간에 대한 제약이 없고, 환불 승인자의 OTP 코드가 필요하다는 점입니다. - """ - serializer = OrderTotalRefundSerializer( - instance=self.get_object(), - data={"totp": request.query_params.get("otp")}, - context={"check_refundable_date": False}, - ) - serializer.is_valid(raise_exception=True) - serializer.refund() - return response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/shop/order/migrations/0006_orderproductrelationtag.py b/app/shop/order/migrations/0006_orderproductrelationtag.py new file mode 100644 index 0000000..87772d1 --- /dev/null +++ b/app/shop/order/migrations/0006_orderproductrelationtag.py @@ -0,0 +1,70 @@ +# Generated by Django 6.0.6 on 2026-08-13 00:46 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("order", "0005_backfill_ticketinfo"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="OrderProductRelationTag", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("code", models.CharField(max_length=50)), + ("name", models.CharField(max_length=100)), + ("priority", models.IntegerField(default=0)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "order_product_relations", + models.ManyToManyField(blank=True, related_name="tags", to="order.orderproductrelation"), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("priority", "code"), + "constraints": [ + models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code",), + name="uq__order_product_relation_tag__code", + ) + ], + }, + ), + ] diff --git a/app/shop/order/models.py b/app/shop/order/models.py index 4fab5a8..4662bdb 100644 --- a/app/shop/order/models.py +++ b/app/shop/order/models.py @@ -684,6 +684,29 @@ class Meta: ] +class OrderProductRelationTag(BaseAbstractModel): + """주문상품 운영 태그. `code` 는 ROSA 의 템플릿 매핑 키라 변경하지 않는다.""" + + code = models.CharField(max_length=50) + name = models.CharField(max_length=100) + priority = models.IntegerField(default=0) + + order_product_relations = models.ManyToManyField(OrderProductRelation, blank=True, related_name="tags") + + class Meta: + ordering = ("priority", "code") + constraints = [ + models.UniqueConstraint( + fields=("code",), + condition=models.Q(deleted_at__isnull=True), + name="uq__order_product_relation_tag__code", + ), + ] + + def __str__(self) -> str: # pragma: no cover + return f"{self.name} ({self.code})" + + class TicketInfo(BaseAbstractModel): order_product_relation = models.OneToOneField( OrderProductRelation, on_delete=models.PROTECT, related_name="ticket_info" diff --git a/app/well_known/__init__.py b/app/well_known/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/well_known/apps.py b/app/well_known/apps.py new file mode 100644 index 0000000..25e00ca --- /dev/null +++ b/app/well_known/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WellKnownConfig(AppConfig): + name = "well_known" + verbose_name = "Well-Known URI" diff --git a/app/well_known/test/__init__.py b/app/well_known/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/well_known/test/rosa_test.py b/app/well_known/test/rosa_test.py new file mode 100644 index 0000000..f07547d --- /dev/null +++ b/app/well_known/test/rosa_test.py @@ -0,0 +1,48 @@ +import http + +from django.conf import settings +from django.urls import reverse +from rest_framework.test import APIClient + + +def test_rosa_discovery_is_available_without_authentication(): + response = APIClient().get(reverse("rosa")) + + assert response.status_code == http.HTTPStatus.OK + assert response.json() == { + "schema_version": "1.0", + "service": "rosa", + "api": { + "base_url": "/v1/internal-api/registration-desk/", + "openapi_url": "/api/schema/v1/", + }, + "authentication": { + "type": "session", + "session_url": "/v1/internal-api/registration-desk/session/", + "login": { + "type": "browser_redirect", + "method": "POST", + "provider_redirect_url": "/authn/social/browser/v1/auth/provider/redirect", + "providers": ["google", "naver", "kakao"], + "provider_parameter": "provider", + "callback_url_parameter": "callback_url", + "fixed_parameters": {"process": "login"}, + "csrf_form_field": "csrfmiddlewaretoken", + }, + "logout_url": "/authn/social/browser/v1/auth/session", + "logout_method": "DELETE", + "csrf": { + "cookie_name": settings.CSRF_COOKIE_NAME, + "header_name": "X-CSRFToken", + }, + }, + "routes": { + "retrieve_configuration": {"method": "GET", "path": "configuration/"}, + "retrieve_statistics": {"method": "GET", "path": "statistics/"}, + "list_orders": {"method": "GET", "path": "orders/"}, + "modify_order": {"method": "PATCH", "path": "orders/{id}/"}, + "refund_order": {"method": "DELETE", "path": "orders/{id}/refund/"}, + "list_order_products": {"method": "GET", "path": "order-products/"}, + "refund_order_product": {"method": "DELETE", "path": "order-products/{id}/refund/"}, + }, + } diff --git a/app/well_known/urls.py b/app/well_known/urls.py new file mode 100644 index 0000000..4c21248 --- /dev/null +++ b/app/well_known/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from well_known.views import retrieve_rosa_discovery + +urlpatterns = [ + path("rosa", retrieve_rosa_discovery, name="rosa"), +] diff --git a/app/well_known/views.py b/app/well_known/views.py new file mode 100644 index 0000000..141d42b --- /dev/null +++ b/app/well_known/views.py @@ -0,0 +1,70 @@ +from django.conf import settings +from django.http import HttpRequest +from django.urls import reverse +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# reverse 는 실제 인자를 요구하므로 sentinel 로 만든 뒤 placeholder 로 치환한다. +_ARG_SENTINEL = "00000000-0000-0000-0000-000000000000" + +_ROSA_ROUTES = ( + ("retrieve_configuration", "GET", "desk-configuration", None), + ("retrieve_statistics", "GET", "desk-statistics", None), + ("list_orders", "GET", "orders-list", None), + ("modify_order", "PATCH", "orders-detail", "{id}"), + ("refund_order", "DELETE", "orders-refund", "{id}"), + ("list_order_products", "GET", "order-products-list", None), + ("refund_order_product", "DELETE", "order-products-refund", "{id}"), +) + + +def _registration_desk_url(url_name: str, placeholder: str | None = None) -> str: + url = reverse(f"v1:registration_desk:{url_name}", args=[_ARG_SENTINEL] if placeholder else []) + return url.replace(_ARG_SENTINEL, placeholder) if placeholder else url + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +@authentication_classes([]) +def retrieve_rosa_discovery(request: HttpRequest) -> Response: + base_url = _registration_desk_url("desk-configuration").removesuffix("configuration/") + session_url = _registration_desk_url("desk-session") + + return Response( + data={ + "schema_version": "1.0", + "service": "rosa", + "api": { + "base_url": base_url, + "openapi_url": reverse("v1-schema"), + }, + "authentication": { + "type": "session", + "session_url": session_url, + "login": { + "type": "browser_redirect", + "method": "POST", + "provider_redirect_url": reverse("headless:browser:socialaccount:redirect_to_provider"), + "providers": ["google", "naver", "kakao"], + "provider_parameter": "provider", + "callback_url_parameter": "callback_url", + "fixed_parameters": {"process": "login"}, + "csrf_form_field": "csrfmiddlewaretoken", + }, + "logout_url": reverse("headless:browser:account:current_session"), + "logout_method": "DELETE", + "csrf": { + "cookie_name": settings.CSRF_COOKIE_NAME, + "header_name": "X-CSRFToken", + }, + }, + "routes": { + key: { + "method": method, + "path": _registration_desk_url(url_name, placeholder).removeprefix(base_url), + } + for key, method, url_name, placeholder in _ROSA_ROUTES + }, + } + ) From 129ee4baacdbc76782a84bef86df10cb82d54e02 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Thu, 13 Aug 2026 13:54:41 +0900 Subject: [PATCH 60/70] =?UTF-8?q?feat:=20=EB=93=B1=EB=A1=9D=20=EB=8D=B0?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=AA=85=EC=84=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/internal_api/test/session_test.py | 22 ++++++++++++++++++++++ app/well_known/test/rosa_test.py | 11 ++++------- app/well_known/views.py | 11 ++++------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/internal_api/test/session_test.py b/app/internal_api/test/session_test.py index 029362c..0a1bb68 100644 --- a/app/internal_api/test/session_test.py +++ b/app/internal_api/test/session_test.py @@ -1,8 +1,11 @@ import pytest +from django.conf import settings from django.urls import reverse from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_405_METHOD_NOT_ALLOWED +from rest_framework.test import APIClient SESSION_URL = reverse("v1:registration_desk:desk-session") +LOGIN_URL = reverse("headless:browser:account:login") @pytest.mark.django_db @@ -46,3 +49,22 @@ def test_session_sets_csrf_cookie_even_when_forbidden(anon_client, settings): @pytest.mark.django_db def test_session_does_not_handle_logout(staff_client): assert staff_client.delete(SESSION_URL).status_code == HTTP_405_METHOD_NOT_ALLOWED + + +@pytest.mark.django_db +def test_email_password_login_creates_registration_desk_session(staff_user): + staff_user.set_password("registration-desk-password") + staff_user.save() + client = APIClient(enforce_csrf_checks=True) + csrf_response = client.get(SESSION_URL) + csrf_token = csrf_response.cookies[settings.CSRF_COOKIE_NAME].value + + login_response = client.post( + LOGIN_URL, + {"email": staff_user.email, "password": "registration-desk-password"}, + format="json", + HTTP_X_CSRFTOKEN=csrf_token, + ) + + assert login_response.status_code == HTTP_200_OK + assert client.get(SESSION_URL).status_code == HTTP_200_OK diff --git a/app/well_known/test/rosa_test.py b/app/well_known/test/rosa_test.py index f07547d..30a7252 100644 --- a/app/well_known/test/rosa_test.py +++ b/app/well_known/test/rosa_test.py @@ -20,14 +20,11 @@ def test_rosa_discovery_is_available_without_authentication(): "type": "session", "session_url": "/v1/internal-api/registration-desk/session/", "login": { - "type": "browser_redirect", + "type": "password", "method": "POST", - "provider_redirect_url": "/authn/social/browser/v1/auth/provider/redirect", - "providers": ["google", "naver", "kakao"], - "provider_parameter": "provider", - "callback_url_parameter": "callback_url", - "fixed_parameters": {"process": "login"}, - "csrf_form_field": "csrfmiddlewaretoken", + "url": "/authn/social/browser/v1/auth/login", + "identifier_field": "email", + "password_field": "password", }, "logout_url": "/authn/social/browser/v1/auth/session", "logout_method": "DELETE", diff --git a/app/well_known/views.py b/app/well_known/views.py index 141d42b..df48f1a 100644 --- a/app/well_known/views.py +++ b/app/well_known/views.py @@ -43,14 +43,11 @@ def retrieve_rosa_discovery(request: HttpRequest) -> Response: "type": "session", "session_url": session_url, "login": { - "type": "browser_redirect", + "type": "password", "method": "POST", - "provider_redirect_url": reverse("headless:browser:socialaccount:redirect_to_provider"), - "providers": ["google", "naver", "kakao"], - "provider_parameter": "provider", - "callback_url_parameter": "callback_url", - "fixed_parameters": {"process": "login"}, - "csrf_form_field": "csrfmiddlewaretoken", + "url": reverse("headless:browser:account:login"), + "identifier_field": "email", + "password_field": "password", }, "logout_url": reverse("headless:browser:account:current_session"), "logout_method": "DELETE", From ce98d825c36d72da56bfb4330f19430c2e92315b Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Thu, 13 Aug 2026 14:24:40 +0900 Subject: [PATCH 61/70] =?UTF-8?q?fix:=20QR=EC=BD=94=EB=93=9C=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=8B=9C=20salt=EA=B0=80=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/shop/order/serializers/scancode.py | 8 ++++---- app/shop/order/templates/scancode_view_opr.html | 2 +- app/shop/order/templates/scancode_view_order.html | 2 +- app/shop/order/templates/scancode_view_user.html | 2 +- app/shop/order/test/scancode_api_test.py | 10 ++++++++++ 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/shop/order/serializers/scancode.py b/app/shop/order/serializers/scancode.py index e482942..c617cb8 100644 --- a/app/shop/order/serializers/scancode.py +++ b/app/shop/order/serializers/scancode.py @@ -40,7 +40,7 @@ class Meta: class OrderProductScanCodeSerializer(_OrderProductRelationSerializer): - """단일 OrderProductRelation (티켓) 의 QR 페이지용 응답 — base + id/short_id/order context.""" + """단일 OrderProductRelation (티켓) 의 QR 페이지용 응답 — base + id/scancode_token/order context.""" class _OrderSerializer(serializers.ModelSerializer): class Meta: @@ -50,7 +50,7 @@ class Meta: order = _OrderSerializer() class Meta(_OrderProductRelationSerializer.Meta): - fields = ("id", "short_id", "order", *_OrderProductRelationSerializer.Meta.fields) + fields = ("id", "scancode_token", "order", *_OrderProductRelationSerializer.Meta.fields) class OrderScanCodeSerializer(serializers.ModelSerializer): @@ -68,7 +68,7 @@ class Meta: model = Order fields = ( "id", - "short_id", + "scancode_token", "name", "created_at", "first_paid_at", @@ -84,4 +84,4 @@ class UserScanCodeSerializer(serializers.ModelSerializer): class Meta: model = UserExt - fields = ("unique_id", "short_id") + fields = ("unique_id", "scancode_token") diff --git a/app/shop/order/templates/scancode_view_opr.html b/app/shop/order/templates/scancode_view_opr.html index 3aa1542..bfb6fbc 100644 --- a/app/shop/order/templates/scancode_view_opr.html +++ b/app/shop/order/templates/scancode_view_opr.html @@ -36,7 +36,7 @@
상품 정보