diff --git a/pyconkr/settings-localtest.py b/pyconkr/settings-localtest.py index 1b7ccd6..b9a0e74 100644 --- a/pyconkr/settings-localtest.py +++ b/pyconkr/settings-localtest.py @@ -1,6 +1,6 @@ import os -from pyconkr.settings import * +from pyconkr.settings import * # noqa DEBUG = True @@ -10,10 +10,15 @@ # RDS -DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "local.sqlite3", + } +} # django-storages: TODO fix to in memory? -del MEDIA_ROOT +del MEDIA_ROOT # noqa DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" STATICFILES_STORAGE = "storages.backends.s3boto3.S3StaticStorage" AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID") diff --git a/pyconkr/settings.py b/pyconkr/settings.py index 42f7222..5baf2ca 100644 --- a/pyconkr/settings.py +++ b/pyconkr/settings.py @@ -9,6 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ """ + import os import pathlib @@ -81,9 +82,10 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [BASE_DIR / "templates" , - BASE_DIR / "accounts/templates", - ], + "DIRS": [ + BASE_DIR / "templates", + BASE_DIR / "accounts/templates", + ], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -139,7 +141,9 @@ # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, @@ -219,7 +223,7 @@ "https://2023.pycon.kr", "https://pycon-dev2023.pycon.kr", "https://pycon-prod2023.pycon.kr", - "https://ticket-2023.pycon.kr", # PG 심사 대비 임시 도메인 + "https://ticket-2023.pycon.kr", # PG 심사 대비 임시 도메인 "https://127.0.0.1:3000", "https://localhost:3000", "http://2023.pycon.kr", diff --git a/sponsor/migrations/0006_sponsorbenefit.py b/sponsor/migrations/0006_sponsorbenefit.py new file mode 100644 index 0000000..e117e49 --- /dev/null +++ b/sponsor/migrations/0006_sponsorbenefit.py @@ -0,0 +1,50 @@ +# Generated by Django 4.1.5 on 2024-07-28 12:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsor", "0005_patron"), + ] + + operations = [ + migrations.CreateModel( + name="SponsorBenefit", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(help_text="혜택 이름", max_length=255)), + ("desc", models.TextField(blank=True, help_text="기타", null=True)), + ("offer", models.PositiveIntegerField(help_text="제공 하는 혜택 개수")), + ("unit", models.CharField(help_text="혜택 단위", max_length=10)), + ( + "is_countable", + models.BooleanField( + default=True, help_text="제공 하는 혜택이 셀 수 있는지 여부" + ), + ), + ( + "sponsor_level", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="benefits", + to="sponsor.sponsorlevel", + ), + ), + ], + options={ + "verbose_name": "후원사 등급 별 혜택", + "verbose_name_plural": "후원사 등급 별 혜택 목록", + }, + ), + ] diff --git a/sponsor/migrations/0007_remove_sponsorbenefit_offer_and_more.py b/sponsor/migrations/0007_remove_sponsorbenefit_offer_and_more.py new file mode 100644 index 0000000..90dc57d --- /dev/null +++ b/sponsor/migrations/0007_remove_sponsorbenefit_offer_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 4.1.5 on 2024-08-06 12:41 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsor", "0006_sponsorbenefit"), + ] + + operations = [ + migrations.RemoveField( + model_name="sponsorbenefit", + name="offer", + ), + migrations.RemoveField( + model_name="sponsorbenefit", + name="sponsor_level", + ), + migrations.CreateModel( + name="BenefitByLevel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("offer", models.PositiveIntegerField(help_text="제공 하는 혜택 개수")), + ( + "benefit", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="benefit_by_level", + to="sponsor.sponsorbenefit", + ), + ), + ( + "level", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="benefit_by_level", + to="sponsor.sponsorlevel", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="benefitbylevel", + constraint=models.UniqueConstraint( + fields=("benefit_id", "level_id"), name="IX_BENEFIT_BY_LEVEL_1" + ), + ), + ] diff --git a/sponsor/migrations/0008_merge_20240806_2206.py b/sponsor/migrations/0008_merge_20240806_2206.py new file mode 100644 index 0000000..e9c699a --- /dev/null +++ b/sponsor/migrations/0008_merge_20240806_2206.py @@ -0,0 +1,13 @@ +# Generated by Django 4.1.5 on 2024-08-06 13:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsor", "0006_sponsorlevel_year"), + ("sponsor", "0007_remove_sponsorbenefit_offer_and_more"), + ] + + operations = [] diff --git a/sponsor/migrations/0009_sponsorbenefit_year_sponsorlevel_benefits.py b/sponsor/migrations/0009_sponsorbenefit_year_sponsorlevel_benefits.py new file mode 100644 index 0000000..9104a84 --- /dev/null +++ b/sponsor/migrations/0009_sponsorbenefit_year_sponsorlevel_benefits.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.5 on 2024-08-06 14:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsor", "0008_merge_20240806_2206"), + ] + + operations = [ + migrations.AddField( + model_name="sponsorbenefit", + name="year", + field=models.IntegerField(default=2023), + ), + migrations.AddField( + model_name="sponsorlevel", + name="benefits", + field=models.ManyToManyField( + related_name="level", + through="sponsor.BenefitByLevel", + to="sponsor.sponsorbenefit", + ), + ), + ] diff --git a/sponsor/models.py b/sponsor/models.py index ac84386..4328acf 100644 --- a/sponsor/models.py +++ b/sponsor/models.py @@ -10,14 +10,32 @@ def get_queryset(self): return super(SponsorLevelManager, self).get_queryset().all().order_by("order") +class SponsorBenefit(models.Model): + class Meta: + verbose_name = "후원사 등급 별 혜택" + verbose_name_plural = "후원사 등급 별 혜택 목록" + + name = models.CharField(max_length=255, help_text="혜택 이름") + desc = models.TextField(null=True, blank=True, help_text="기타") + unit = models.CharField(max_length=10, help_text="혜택 단위") + year = models.IntegerField(default=2023) + is_countable = models.BooleanField( + default=True, help_text="제공 하는 혜택이 셀 수 있는지 여부" + ) + + class SponsorLevel(models.Model): class Meta: verbose_name = "후원사 등급" verbose_name_plural = "후원사 등급" - name = models.CharField(max_length=255, blank=True, default="", help_text="후원 등급명") + name = models.CharField( + max_length=255, blank=True, default="", help_text="후원 등급명" + ) desc = models.TextField( - null=True, blank=True, help_text="후원 혜택을 입력하면 될 거 같아요 :) 후원사가 등급을 정할 때 볼 문구입니다." + null=True, + blank=True, + help_text="후원 혜택을 입력하면 될 거 같아요 :) 후원사가 등급을 정할 때 볼 문구입니다.", ) visible = models.BooleanField(default=True) price = models.IntegerField(default=0) @@ -27,6 +45,10 @@ class Meta: updated_at = models.DateTimeField(auto_now=True) year = models.IntegerField(default=2023) + benefits = models.ManyToManyField( + SponsorBenefit, through="BenefitByLevel", related_name="level" + ) + objects = SponsorLevelManager() @property @@ -51,6 +73,23 @@ def __str__(self): return self.name +class BenefitByLevel(models.Model): + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["benefit_id", "level_id"], name="IX_BENEFIT_BY_LEVEL_1" + ) + ] + + benefit = models.ForeignKey( + SponsorBenefit, on_delete=models.CASCADE, related_name="benefit_by_level" + ) + level = models.ForeignKey( + SponsorLevel, on_delete=models.CASCADE, related_name="benefit_by_level" + ) + offer = models.PositiveIntegerField(help_text="제공 하는 혜택 개수") + + def registration_file_upload_to(instance, filename): return f"sponsor/business_registration/{instance.id}/{filename}" @@ -78,7 +117,8 @@ class Meta: related_name="sponsor_creator", ) name = models.CharField( - max_length=255, help_text="후원사의 이름입니다. 서비스나 회사 이름이 될 수 있습니다." + max_length=255, + help_text="후원사의 이름입니다. 서비스나 회사 이름이 될 수 있습니다.", ) level = models.ForeignKey( SponsorLevel, @@ -88,13 +128,18 @@ class Meta: help_text="후원을 원하시는 등급을 선택해주십시오. 모두 판매된 등급은 선택할 수 없습니다.", ) desc = models.TextField( - null=True, blank=True, help_text="후원사 설명입니다. 이 설명은 국문 홈페이지에 게시됩니다." + null=True, + blank=True, + help_text="후원사 설명입니다. 이 설명은 국문 홈페이지에 게시됩니다.", ) eng_desc = models.TextField( - null=True, blank=True, help_text="후원사 영문 설명입니다. 이 설명은 영문 홈페이지에 게시됩니다." + null=True, + blank=True, + help_text="후원사 영문 설명입니다. 이 설명은 영문 홈페이지에 게시됩니다.", ) manager_name = models.CharField( - max_length=100, help_text="준비위원회와 후원과 관련된 논의를 진행할 담당자의 이름을 입력해주십시오." + max_length=100, + help_text="준비위원회와 후원과 관련된 논의를 진행할 담당자의 이름을 입력해주십시오.", ) manager_email = models.CharField( max_length=100, @@ -149,10 +194,13 @@ class Meta: help_text="사용자가 제출했는지 여부를 저장합니다. 요청이 제출되면 준비위원회에서 검토하고 받아들일지를 결정합니다.", ) accepted = models.BooleanField( - default=False, help_text="후원사 신청이 접수되었고, 입금 대기 상태인 경우 True로 설정됩니다." + default=False, + help_text="후원사 신청이 접수되었고, 입금 대기 상태인 경우 True로 설정됩니다.", ) paid_at = models.DateTimeField( - null=True, blank=True, help_text="후원금이 입금된 일시입니다. 아직 입금되지 않았을 경우 None이 들어갑니다." + null=True, + blank=True, + help_text="후원금이 입금된 일시입니다. 아직 입금되지 않았을 경우 None이 들어갑니다.", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -161,7 +209,6 @@ def __str__(self): return f"{self.name}/{self.level}" - class Patron(models.Model): class Meta: ordering = ["-total_contribution", "contribution_datetime"] @@ -177,7 +224,9 @@ class Meta: help_text="개인후원을 등록한 유저", related_name="patron_user", ) - total_contribution = models.IntegerField(default=0, help_text="개인후원한 금액입니다.") + total_contribution = models.IntegerField( + default=0, help_text="개인후원한 금액입니다." + ) contribution_datetime = models.DateTimeField( help_text="개인후원 결제한 일시입니다." ) diff --git a/sponsor/serializers.py b/sponsor/serializers.py index f133dfe..9e291ad 100644 --- a/sponsor/serializers.py +++ b/sponsor/serializers.py @@ -1,7 +1,56 @@ import rest_framework.serializers as serializers from rest_framework.fields import SerializerMethodField -from sponsor.models import Patron, Sponsor, SponsorLevel +from sponsor.models import Patron, Sponsor, SponsorLevel, SponsorBenefit, BenefitByLevel + + +class BenefitByLevelSerializer(serializers.ModelSerializer): + benefit_id = serializers.PrimaryKeyRelatedField( + queryset=SponsorBenefit.objects.all(), source="benefit" + ) + level_id = serializers.PrimaryKeyRelatedField( + queryset=SponsorLevel.objects.get_queryset(), source="level", write_only=True + ) + + class Meta: + model = BenefitByLevel + fields = ["benefit_id", "offer", "level_id"] + + +class SponsorBenefitSerializer(serializers.ModelSerializer): + class Meta: + model = SponsorBenefit + fields = ["id", "year", "name", "desc", "unit", "is_countable"] + read_only_fields = ["id"] + extra_kwargs = {"year": {"write_only": True}} + + +class SponsorBenefitWithOfferSerializer(SponsorBenefitSerializer): + id = serializers.SlugRelatedField(slug_field="id", source="benefit", read_only=True) + name = serializers.SlugRelatedField( + slug_field="name", source="benefit", read_only=True + ) + desc = serializers.SlugRelatedField( + slug_field="desc", source="benefit", read_only=True + ) + unit = serializers.SlugRelatedField( + slug_field="unit", source="benefit", read_only=True + ) + is_countable = serializers.SlugRelatedField( + slug_field="is_countable", source="benefit", read_only=True + ) + + class Meta: + model = BenefitByLevel + fields = ["id", "name", "desc", "unit", "is_countable", "offer"] + + def get_benefit_id(self, obj): + breakpoint() + return + + def get_offer(self, obj): + breakpoint() + return obj.benefit_by_level.filter(benefit_id=obj.id) class SponsorSerializer(serializers.ModelSerializer): @@ -28,6 +77,34 @@ class Meta: ] +class SponsorLevelSerializer(serializers.ModelSerializer): + benefits = SponsorBenefitWithOfferSerializer( + many=True, read_only=True, source="benefit_by_level" + ) + + class Meta: + model = SponsorLevel + fields = [ + "id", + "name", + "desc", + "visible", + "price", + "limit", + "order", + "benefits", + ] + read_only_fields = ["id"] + + +class SponsorWithLevelSerializer(serializers.ModelSerializer): + sponsor = SponsorSerializer(read_only=True, many=True, source="sponsor_set") + + class Meta: + model = SponsorLevel + fields = ["name", "order", "sponsor"] + + class SponsorDetailSerializer(serializers.ModelSerializer): creator_userid = serializers.SerializerMethodField() diff --git a/sponsor/urls.py b/sponsor/urls.py index 245dabb..fc24249 100644 --- a/sponsor/urls.py +++ b/sponsor/urls.py @@ -1,9 +1,14 @@ from django.urls import path -from sponsor.viewsets import PatronListViewSet, SponsorViewSet +from sponsor.viewsets import ( + PatronListViewSet, + SponsorViewSet, + SponsorLevelViewSet, + SponsorBenefitViewSet, +) urlpatterns = [ - path("list/", SponsorViewSet.as_view({"get": "list"})), + path("list/", SponsorViewSet.as_view({"get": "list", "post": "create"})), path( "list//", SponsorViewSet.as_view({"get": "retrieve", "put": "update"}), @@ -12,4 +17,27 @@ "patron/list/", PatronListViewSet.as_view({"get": "list"}), ), + path( + "levels", + SponsorLevelViewSet.as_view({"get": "list", "post": "create"}), + ), + path( + "levels//", + SponsorLevelViewSet.as_view( + {"get": "retrieve", "delete": "destroy", "put": "update"} + ), + ), + path( + "levels/benefits/", + SponsorLevelViewSet.as_view( + {"post": "assign_benefits", "put": "create_or_update_benefits"} + ), + ), + path("benefits/", SponsorBenefitViewSet.as_view({"get": "list", "post": "create"})), + path( + "benefits//", + SponsorBenefitViewSet.as_view( + {"get": "retrieve", "delete": "destroy", "put": "update"} + ), + ), ] diff --git a/sponsor/viewsets.py b/sponsor/viewsets.py index f4bde66..ffc7f31 100644 --- a/sponsor/viewsets.py +++ b/sponsor/viewsets.py @@ -1,24 +1,76 @@ from typing import Type from django.db.transaction import atomic + from django.shortcuts import get_object_or_404 +from django.db.utils import IntegrityError from rest_framework import mixins, status, viewsets from rest_framework.response import Response +from rest_framework.decorators import action from rest_framework.viewsets import ModelViewSet, ViewSet -from sponsor.models import Patron, Sponsor, SponsorLevel +from sponsor.models import Patron, Sponsor, SponsorLevel, SponsorBenefit, BenefitByLevel from sponsor.permissions import IsOwnerOrReadOnly, OwnerOnly from sponsor.serializers import ( PatronListSerializer, SponsorDetailSerializer, - SponsorListSerializer, + SponsorWithLevelSerializer, SponsorRemainingAccountSerializer, SponsorSerializer, + SponsorLevelSerializer, + SponsorBenefitSerializer, + BenefitByLevelSerializer, ) from sponsor.slack import send_new_sponsor_notification from sponsor.validators import SponsorValidater +class SponsorBenefitViewSet(ModelViewSet): + lookup_field = "id" + http_method_names = ["get", "post", "put", "delete"] + serializer_class = SponsorBenefitSerializer + + def get_queryset(self): + return SponsorBenefit.objects.filter(year=self.request.version).all() + + +class SponsorLevelViewSet(ModelViewSet): + lookup_field = "id" + http_method_names = ["get", "post", "put", "delete"] + + def get_queryset(self): + return SponsorLevel.objects.get_queryset() + + def get_serializer_class(self): + match self.action: + case "create_or_update_benefits" | "assign_benefits": + return BenefitByLevelSerializer + case _: + return SponsorLevelSerializer + + @action(detail=False, methods=["POST"]) + def assign_benefits(self, request, version): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + serializer.save() + except IntegrityError: + return Response("Already assigned", status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.data) + + @action(detail=True, methods=["PUT"]) + def create_or_update_benefits(self, request, version): + level_id = request.data.get("level_id", None) + benefit_id = request.data.get("benefit_id", None) + benefit_by_level = get_object_or_404( + BenefitByLevel, level_id=level_id, benefit_id=benefit_id + ) + serializer = self.get_serializer(benefit_by_level, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + class SponsorViewSet( mixins.CreateModelMixin, mixins.RetrieveModelMixin, @@ -27,16 +79,20 @@ class SponsorViewSet( viewsets.GenericViewSet, ): queryset = Sponsor.objects.all() - serializer_class = SponsorSerializer permission_classes = [IsOwnerOrReadOnly] # 본인 소유만 수정 가능 validator = SponsorValidater() def get_queryset(self): - return super().get_queryset().filter(paid_at__isnull=False, level__year=self.request.version).order_by("level__order", "paid_at") + return ( + super() + .get_queryset() + .filter(paid_at__isnull=False, level__year=self.request.version) + .order_by("level__order", "paid_at") + ) def get_serializer_class(self): if self.action == "list": - return SponsorListSerializer + return SponsorWithLevelSerializer return SponsorSerializer @atomic @@ -45,7 +101,7 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.validator.assert_create(serializer.validated_data) - new_sponsor = serializer.save() + serializer.save() # slack 알림을 실패하더라도 transaction 전체를 롤백하지는 않아야 함 # TODO 람다 외부 인터넷 접근 확인 후 활성화