diff --git a/.github/workflows/deploy_on_dev.yml b/.github/workflows/deploy_on_dev.yml index a844f2e..41ca0fb 100644 --- a/.github/workflows/deploy_on_dev.yml +++ b/.github/workflows/deploy_on_dev.yml @@ -3,6 +3,7 @@ name: Deploy Django with zappa on DEV stage on: push: branches: [devdev] + workflow_dispatch: jobs: build: @@ -16,7 +17,7 @@ jobs: - uses: actions/checkout@v2 - uses: psf/black@stable with: - options: "--check --verbose" + options: "--check --verbose --exclude migrations" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 diff --git a/.github/workflows/deploy_on_prod.yml b/.github/workflows/deploy_on_prod.yml index 729a0e4..e2e66ab 100644 --- a/.github/workflows/deploy_on_prod.yml +++ b/.github/workflows/deploy_on_prod.yml @@ -3,6 +3,8 @@ name: Deploy Django with zappa on PRODUCTION stage on: push: branches: [main] + workflow_dispatch: + jobs: build: runs-on: ubuntu-latest @@ -14,7 +16,7 @@ jobs: - uses: actions/checkout@v2 - uses: psf/black@stable with: - options: "--check --verbose" + options: "--check --verbose --exclude migrations" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 diff --git a/.github/workflows/pull-request-merge-precondition.yml b/.github/workflows/pull-request-merge-precondition.yml index ddf7478..ddad109 100644 --- a/.github/workflows/pull-request-merge-precondition.yml +++ b/.github/workflows/pull-request-merge-precondition.yml @@ -3,6 +3,10 @@ name: Pull Request Merge Precondition on: pull_request: +permissions: + contents: read + pull-requests: write + jobs: build: runs-on: ubuntu-latest @@ -11,7 +15,32 @@ jobs: python-version: [3.8] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + with: + persist-credentials: true # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token + fetch-depth: 0 # otherwise, you will failed to push refs to dest repo + - uses: psf/black@stable with: - options: "--check --verbose" + options: "--check --verbose --exclude migrations" + + - uses: isort/isort-action@master + with: + configuration: "--check-only --diff --profile black" + requirementsFiles: "requirements.txt" + + - name: install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + pip install pytest-cov + + - name: run pytest + run: | + pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=pyconkr ./ | tee pytest-coverage.txt + + #- name: Pytest coverage comment + # uses: MishaKav/pytest-coverage-comment@main + # with: + # pytest-coverage-path: ./pytest-coverage.txt + # junitxml-path: ./pytest.xml diff --git a/.gitignore b/.gitignore index 8faa4d2..2dc53ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,160 @@ -/.idea -/db.sqlite3 +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..25ab02b --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# pyconkr-api-v2 + +파이콘 한국 행사를 위한 API 서비스입니다. (2023 ~ ) + +## PR 이전에 +* 컨벤션 유지를 위해 `black`과 `isort`를 적용하고 있습니다. + +## 개발 환경 +* mysql-client 설치 + * mac + * brew install mysql-client +* pip install -r requirements.txt + +## how to run localtesting ( sqlite3 based ) +``` +# to setup pytest and requirements +pip install -r requirements-dev.txt +# run test +pytest +``` diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..98a6f21 --- /dev/null +++ b/conftest.py @@ -0,0 +1,10 @@ +import pytest + + +# TODO +# https://djangostars.com/blog/django-pytest-testing/#header17 +@pytest.fixture +def api_client(): + from rest_framework.test import APIClient + + return APIClient() diff --git a/manage.py b/manage.py old mode 100644 new mode 100755 diff --git a/pyconkr/settings-dev.py b/pyconkr/settings-dev.py index 343d2ed..3b712de 100644 --- a/pyconkr/settings-dev.py +++ b/pyconkr/settings-dev.py @@ -1,8 +1,13 @@ import os + from pyconkr.settings import * DEBUG = True +ALLOWED_HOSTS += [ + "api-dev.pycon.kr", +] + # RDS DATABASES = { "default": { @@ -16,6 +21,7 @@ } # django-storages: S3 +del MEDIA_ROOT 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-localtest.py b/pyconkr/settings-localtest.py new file mode 100644 index 0000000..1b7ccd6 --- /dev/null +++ b/pyconkr/settings-localtest.py @@ -0,0 +1,21 @@ +import os + +from pyconkr.settings import * + +DEBUG = True + +ALLOWED_HOSTS += [ + "*", +] + + +# RDS +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} + +# django-storages: TODO fix to in memory? +del MEDIA_ROOT +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") +AWS_S3_SECRET_ACCESS_KEY = os.getenv("AWS_S3_SECRET_ACCESS_KEY") +AWS_STORAGE_BUCKET_NAME = "pyconkr-api-v2-static-dev" diff --git a/pyconkr/settings-prod.py b/pyconkr/settings-prod.py index 1e107f6..9655b74 100644 --- a/pyconkr/settings-prod.py +++ b/pyconkr/settings-prod.py @@ -1,8 +1,13 @@ import os + from pyconkr.settings import * DEBUG = False +ALLOWED_HOSTS += [ + "api.pycon.kr", +] + # RDS DATABASES = { "default": { @@ -16,6 +21,7 @@ } # django-storages: S3 +del MEDIA_ROOT 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 6b0b6b1..4a756fe 100644 --- a/pyconkr/settings.py +++ b/pyconkr/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ """ - +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -37,8 +37,16 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # djangorestframework + # add-on "rest_framework", + "django_summernote", + "constance", + "constance.backends.database", + # apps + "sponsor", + "status", + # swagger + "drf_spectacular", ] MIDDLEWARE = [ @@ -123,3 +131,42 @@ # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# django-summernote +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(BASE_DIR, "media/") + +# django-constance +CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" +CONSTANCE_CONFIG = { + "SLACK_SECRET": ( + "", + "Slack 알림 전송에 사용할 Secret", + ), + "SPONSOR_NOTI_CHANNEL": ( + "", + "후원사 변동사항에 대한 알림을 보낼 채널", + ), +} + +# drf-spectacular +REST_FRAMEWORK = { + # YOUR SETTINGS + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "pyconkr-api-v2", + "DESCRIPTION": "파이콘 한국 웹서비스용 API (2023 ~ )", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": True, + # available SwaggerUI configuration parameters + # https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ + "SWAGGER_UI_SETTINGS": { + "deepLinking": True, + "persistAuthorization": True, + "displayOperationId": True, + }, + # available SwaggerUI versions: https://github.com/swagger-api/swagger-ui/releases + "SWAGGER_UI_DIST": "//unpkg.com/swagger-ui-dist@3.35.1", +} diff --git a/pyconkr/urls.py b/pyconkr/urls.py index ecbd711..564933c 100644 --- a/pyconkr/urls.py +++ b/pyconkr/urls.py @@ -13,10 +13,38 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin -from django.urls import path, include +from django.urls import include, path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +import sponsor.routers +import status.urls urlpatterns = [ path("api-auth/", include("rest_framework.urls")), + path("summernote/", include("django_summernote.urls")), path("admin/", admin.site.urls), + path("sponsors/", include(sponsor.routers.get_router().urls)), + path("status/", include(status.urls)), ] + +if settings.DEBUG is True: + urlpatterns += [ + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + # Optional UI: + path( + "api/schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/schema/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), + ] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3a662ff --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +DJANGO_SETTINGS_MODULE = pyconkr.settings-localtest +# -- recommended but optional: +python_files = tests.py test_*.py *_tests.py +addopts = --no-migrations diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..112c1ef --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +black +isort +pytest +pytest-django diff --git a/requirements.txt b/requirements.txt index e5c04b1..523fdff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,10 @@ Markdown==3.4.1 mysql-connector-python==8.0.32 mysqlclient==2.1.1 sqlparse==0.4.3 -tzdata==2022.7 \ No newline at end of file +tzdata==2022.7 +sorl-thumbnail==12.9.0 +django-summernote==0.8.20.0 +Pillow==9.4.0 +django-constance==2.9.1 +django-picklefield==3.1 +drf-spectacular==0.25.1 diff --git a/sponsor/__init__.py b/sponsor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sponsor/admin.py b/sponsor/admin.py new file mode 100644 index 0000000..b0dceb5 --- /dev/null +++ b/sponsor/admin.py @@ -0,0 +1,44 @@ +from django.contrib import admin +from django_summernote.admin import SummernoteModelAdmin + +from sponsor.models import Sponsor, SponsorLevel + + +class SponsorAdmin(SummernoteModelAdmin): + summernote_fields = "__all__" + autocomplete_fields = ( + "creator", + "manager_id", + ) + list_display = ( + "creator", + "name", + "level", + "manager_name", + "manager_email", + "manager_id", + "submitted", + "accepted", + "paid_at", + ) + list_filter = ("accepted",) + ordering = ("-created_at",) + + +admin.site.register(Sponsor, SponsorAdmin) + + +class SponsorLevelAdmin(SummernoteModelAdmin): + list_display = ( + "id", + "order", + "name", + "price", + "limit", + ) + list_editable = ("order",) + ordering = ("order",) + search_fields = ("name",) + + +admin.site.register(SponsorLevel, SponsorLevelAdmin) diff --git a/sponsor/apps.py b/sponsor/apps.py new file mode 100644 index 0000000..674f284 --- /dev/null +++ b/sponsor/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SponsorConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "sponsor" diff --git a/sponsor/migrations/0001_initial.py b/sponsor/migrations/0001_initial.py new file mode 100644 index 0000000..ac2af39 --- /dev/null +++ b/sponsor/migrations/0001_initial.py @@ -0,0 +1,199 @@ +# Generated by Django 4.1.5 on 2023-02-09 13:28 + +import django.db.models.deletion +import sorl.thumbnail.fields +from django.conf import settings +from django.db import migrations, models + +import sponsor.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SponsorLevel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + blank=True, default="", help_text="후원 등급명", max_length=255 + ), + ), + ( + "desc", + models.TextField( + blank=True, + help_text="후원 혜택을 입력하면 될 거 같아요 :) 후원사가 등급을 정할 때 볼 문구입니다.", + null=True, + ), + ), + ("visible", models.BooleanField(default=True)), + ("price", models.IntegerField(default=0)), + ("limit", models.IntegerField(default=0, help_text="후원사 등급 별 구좌수")), + ("order", models.IntegerField(default=1)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="Sponsor", + 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, + ), + ), + ( + "eng_desc", + models.TextField( + blank=True, + help_text="후원사 영문 설명입니다. 이 설명은 영문 홈페이지에 게시됩니다.", + null=True, + ), + ), + ( + "manager_name", + models.CharField( + help_text="준비위원회와 후원과 관련된 논의를 진행할 담당자의 이름을 입력해주십시오.", + max_length=100, + ), + ), + ( + "manager_email", + models.CharField( + help_text="입력하신 메일로 후원과 관련된 안내 메일이나 문의를 보낼 예정입니다. 후원 담당자의 이메일 주소를 입력해주십시오.", + max_length=100, + ), + ), + ( + "business_registration_number", + models.CharField( + blank=True, + help_text="후원사 사업자 등록번호입니다. 세금 계산서 발급에 사용됩니다.", + max_length=100, + null=True, + ), + ), + ( + "business_registration_file", + models.FileField( + blank=True, + help_text="후원사 사업자 등록증 스캔본입니다. 세금 계산서 발급에 사용됩니다.", + null=True, + upload_to=sponsor.models.registration_file_upload_to, + ), + ), + ( + "url", + models.CharField( + blank=True, + help_text="파이콘 홈페이지에 공개되는 후원사 홈페이지 주소입니다.", + max_length=255, + null=True, + ), + ), + ( + "logo_image", + sorl.thumbnail.fields.ImageField( + blank=True, + help_text="홈페이지에 공개되는 후원사 로고 이미지입니다.", + null=True, + upload_to=sponsor.models.logo_image_upload_to, + ), + ), + ( + "submitted", + models.BooleanField( + default=False, + help_text="사용자가 제출했는지 여부를 저장합니다. 요청이 제출되면 준비위원회에서 검토하고 받아들일지를 결정합니다.", + ), + ), + ( + "accepted", + models.BooleanField( + default=False, + help_text="후원사 신청이 접수되었고, 입금 대기 상태인 경우 True로 설정됩니다.", + ), + ), + ( + "paid_at", + models.DateTimeField( + blank=True, + help_text="후원금이 입금된 일시입니다. 아직 입금되지 않았을 경우 None이 들어갑니다.", + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "creator", + models.ForeignKey( + blank=True, + help_text="후원사를 등록한 유저", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sponsor_creator", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "level", + models.ForeignKey( + blank=True, + help_text="후원을 원하시는 등급을 선택해주십시오. 모두 판매된 등급은 선택할 수 없습니다.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="sponsor.sponsorlevel", + ), + ), + ( + "manager_id", + models.ForeignKey( + blank=True, + help_text="후원사를 위한 추가 아이디", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="sponsor_temp_id", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["paid_at", "id"], + }, + ), + ] diff --git a/sponsor/migrations/0002_sponsor_bank_book_file_sponsor_manager_tel_and_more.py b/sponsor/migrations/0002_sponsor_bank_book_file_sponsor_manager_tel_and_more.py new file mode 100644 index 0000000..475aeb2 --- /dev/null +++ b/sponsor/migrations/0002_sponsor_bank_book_file_sponsor_manager_tel_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.1.5 on 2023-02-11 15:16 + +from django.db import migrations, models + +import sponsor.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sponsor", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="sponsor", + name="bank_book_file", + field=models.FileField( + blank=True, + help_text="후원사 사업자 등록증 스캔본입니다. 세금 계산서 발급에 사용됩니다.", + null=True, + upload_to=sponsor.models.bank_book_file_upload_to, + ), + ), + migrations.AddField( + model_name="sponsor", + name="manager_tel", + field=models.CharField( + default="", + help_text="메일에 회신이 없거나, 긴급한 건의 경우, 문자나 유선으로 안내드릴 수 있습니다. 후원 담당자의 유선 연락처를 입력해주십시오.", + max_length=20, + ), + ), + migrations.AlterField( + model_name="sponsor", + name="business_registration_file", + field=models.FileField( + blank=True, + default=None, + help_text="후원사 사업자 등록증 스캔본입니다. 세금 계산서 발급에 사용됩니다.", + null=True, + upload_to=sponsor.models.registration_file_upload_to, + ), + ), + ] diff --git a/sponsor/migrations/__init__.py b/sponsor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sponsor/models.py b/sponsor/models.py new file mode 100644 index 0000000..f1ecc80 --- /dev/null +++ b/sponsor/models.py @@ -0,0 +1,154 @@ +from django.contrib.auth import get_user_model +from django.db import models +from sorl.thumbnail import ImageField as SorlImageField + +User = get_user_model() + + +class SponsorLevelManager(models.Manager): + def get_queryset(self): + return super(SponsorLevelManager, self).get_queryset().all().order_by("order") + + +class SponsorLevel(models.Model): + name = models.CharField(max_length=255, blank=True, default="", help_text="후원 등급명") + desc = models.TextField( + null=True, blank=True, help_text="후원 혜택을 입력하면 될 거 같아요 :) 후원사가 등급을 정할 때 볼 문구입니다." + ) + visible = models.BooleanField(default=True) + price = models.IntegerField(default=0) + limit = models.IntegerField(default=0, help_text="후원사 등급 별 구좌수") + order = models.IntegerField(default=1) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = SponsorLevelManager() + + @property + def current_remaining_number(self): + return ( + 0 + if self.limit - self.accepted_count < 0 + else self.limit - self.accepted_count + ) + + @property + def paid_count(self): + return Sponsor.objects.filter( + level=self, submitted=True, accepted=True, paid_at__isnull=False + ).count() + + @property + def accepted_count(self): + return Sponsor.objects.filter(level=self, submitted=True, accepted=True).count() + + def __str__(self): + return self.name + + +def registration_file_upload_to(instance, filename): + return f"sponsor/business_registration/{instance.id}/{filename}" + + +def bank_book_file_upload_to(instance, filename): + return f"sponsor/bank_book/{instance.id}/{filename}" + + +def logo_image_upload_to(instance, filename): + return f"sponsor/logo/{instance.id}/{filename}" + + +class Sponsor(models.Model): + class Meta: + ordering = ["paid_at", "id"] + + creator = models.ForeignKey( + User, + null=True, # TODO: 추루 로그인 적용 후 입력 + blank=True, # TODO: 추루 로그인 적용 후 입력 + on_delete=models.CASCADE, + help_text="후원사를 등록한 유저", + related_name="sponsor_creator", + ) + name = models.CharField( + max_length=255, help_text="후원사의 이름입니다. 서비스나 회사 이름이 될 수 있습니다." + ) + level = models.ForeignKey( + SponsorLevel, + null=True, + on_delete=models.SET_NULL, + blank=True, + help_text="후원을 원하시는 등급을 선택해주십시오. 모두 판매된 등급은 선택할 수 없습니다.", + ) + desc = models.TextField( + null=True, blank=True, help_text="후원사 설명입니다. 이 설명은 국문 홈페이지에 게시됩니다." + ) + eng_desc = models.TextField( + null=True, blank=True, help_text="후원사 영문 설명입니다. 이 설명은 영문 홈페이지에 게시됩니다." + ) + manager_name = models.CharField( + max_length=100, help_text="준비위원회와 후원과 관련된 논의를 진행할 담당자의 이름을 입력해주십시오." + ) + manager_email = models.CharField( + max_length=100, + help_text="입력하신 메일로 후원과 관련된 안내 메일이나 문의를 보낼 예정입니다. 후원 담당자의 이메일 주소를 입력해주십시오.", + ) + manager_tel = models.CharField( + max_length=20, + default="", + help_text="메일에 회신이 없거나, 긴급한 건의 경우, 문자나 유선으로 안내드릴 수 있습니다. 후원 담당자의 유선 연락처를 입력해주십시오.", + ) + manager_id = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.CASCADE, + help_text="후원사를 위한 추가 아이디", + related_name="sponsor_temp_id", + ) + business_registration_number = models.CharField( + max_length=100, + null=True, + blank=True, + help_text="후원사 사업자 등록번호입니다. 세금 계산서 발급에 사용됩니다.", + ) + business_registration_file = models.FileField( + null=True, + blank=True, + default=None, + upload_to=registration_file_upload_to, + help_text="후원사 사업자 등록증 스캔본입니다. 세금 계산서 발급에 사용됩니다.", + ) + bank_book_file = models.FileField( + null=True, + blank=True, + upload_to=bank_book_file_upload_to, + help_text="후원사 사업자 등록증 스캔본입니다. 세금 계산서 발급에 사용됩니다.", + ) + url = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="파이콘 홈페이지에 공개되는 후원사 홈페이지 주소입니다.", + ) + logo_image = SorlImageField( + upload_to=logo_image_upload_to, + null=True, + blank=True, + help_text="홈페이지에 공개되는 후원사 로고 이미지입니다.", + ) + submitted = models.BooleanField( + default=False, + help_text="사용자가 제출했는지 여부를 저장합니다. 요청이 제출되면 준비위원회에서 검토하고 받아들일지를 결정합니다.", + ) + accepted = models.BooleanField( + default=False, help_text="후원사 신청이 접수되었고, 입금 대기 상태인 경우 True로 설정됩니다." + ) + paid_at = models.DateTimeField( + null=True, blank=True, help_text="후원금이 입금된 일시입니다. 아직 입금되지 않았을 경우 None이 들어갑니다." + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str_(self): + return f"{self.name}/{self.level}" diff --git a/sponsor/permissions.py b/sponsor/permissions.py new file mode 100644 index 0000000..84a6aef --- /dev/null +++ b/sponsor/permissions.py @@ -0,0 +1,17 @@ +from rest_framework import permissions + +from sponsor.models import Sponsor + + +class IsOwnerOrReadOnly(permissions.BasePermission): + # https://stackoverflow.com/questions/72691826/djnago-rest-framework-how-to-allow-only-update-user-own-content-only + def has_object_permission(self, request, view, obj: Sponsor): + if request.method in permissions.SAFE_METHODS: + return True + + return obj.manager_id == request.user or obj.creator == request.user + + +class OwnerOnly(permissions.BasePermission): + def has_object_permission(self, request, view, obj: Sponsor): + return obj.manager_id == request.user or obj.creator == request.user diff --git a/sponsor/routers.py b/sponsor/routers.py new file mode 100644 index 0000000..00d6291 --- /dev/null +++ b/sponsor/routers.py @@ -0,0 +1,12 @@ +from rest_framework.routers import DefaultRouter + +from sponsor.viewsets import * + + +def get_router(): + router = DefaultRouter() + router.register("remaining", SponsorRemainingAccountViewSet, basename="remaining") + router.register("prospectus", SponsorLevelViewSet, basename="prospectus") + router.register("", SponsorViewSet, basename="sponsor") + + return router diff --git a/sponsor/serializers.py b/sponsor/serializers.py new file mode 100644 index 0000000..0fe4371 --- /dev/null +++ b/sponsor/serializers.py @@ -0,0 +1,65 @@ +from rest_framework.fields import SerializerMethodField +from rest_framework.serializers import ModelSerializer + +from sponsor.models import Sponsor, SponsorLevel + + +class SponsorSerializer(ModelSerializer): + class Meta: + model = Sponsor + fields = [ + "name", + # "desc", # 국문/영문 모두 한 필드에 담아 제공하는 것으로 결정 # TODO: 상세 페이지 오픈 후 활성화 + "manager_name", # 상세에만 포함되는 필드 + "manager_email", # 상세에만 포함되는 필드 + "manager_tel", # 상세에만 포함되는 필드 + "business_registration_number", # 상세에만 포함되는 필드 + "business_registration_file", # 상세에만 포함되는 필드 + "bank_book_file", # 상세에만 포함되는 필드 + "url", + "logo_image", + "level", + "id", + ] + + +class SponsorListSerializer(ModelSerializer): + class Meta: + model = Sponsor + fields = [ + "name", + "level", + "url", + "logo_image", + "id", + ] + + +class SponsorLevelSerializer(ModelSerializer): + class Meta: + model = SponsorLevel + fields = [ + "name", + "price", + "desc", + "limit", + "id", + ] # TODO: Add fields to show + + +class SponsorRemainingAccountSerializer(ModelSerializer): + remaining = SerializerMethodField() + + class Meta: + model = SponsorLevel + fields = [ + "name", + "price", + "limit", + "remaining", + "id", + ] + + @staticmethod + def get_remaining(obj): + return obj.current_remaining_number diff --git a/sponsor/tests.py b/sponsor/tests.py new file mode 100644 index 0000000..d2f24a3 --- /dev/null +++ b/sponsor/tests.py @@ -0,0 +1,26 @@ +import pytest +from django.contrib.auth import get_user_model + +from sponsor.models import SponsorLevel + +pytestmark = pytest.mark.django_db + +UserModel = get_user_model() + + +@pytest.mark.django_db +class TestSponsorLevelModel: + pytestmark = pytest.mark.django_db + + def test_sponsor_level_creation_success(self): + assert SponsorLevel.objects.count() == 0 + SponsorLevel.objects.create( + name="test", + desc="test desc", + visible=True, + limit=1, + ) + assert SponsorLevel.objects.count() != 0 + + +# Create your tests here. diff --git a/sponsor/urls.py b/sponsor/urls.py new file mode 100644 index 0000000..1160f1c --- /dev/null +++ b/sponsor/urls.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + # path("", ), # TODO +] diff --git a/sponsor/validators.py b/sponsor/validators.py new file mode 100644 index 0000000..c7ba366 --- /dev/null +++ b/sponsor/validators.py @@ -0,0 +1,16 @@ +from collections import OrderedDict + +from sponsor.models import Sponsor + + +class SponsorValidater: + def assert_create(self, sponsor: OrderedDict): + target = [self.check_remain_slot(sponsor)] + + def check_remain_slot(self, sponsor: OrderedDict): + target_level = sponsor.get("level") + + if target_level.limit <= len( + Sponsor.objects.filter(level=target_level, accepted=True) + ): + raise RuntimeError("ERROR: 남은 슬롯 없음") diff --git a/sponsor/views.py b/sponsor/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/sponsor/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/sponsor/viewsets.py b/sponsor/viewsets.py new file mode 100644 index 0000000..5cbceaf --- /dev/null +++ b/sponsor/viewsets.py @@ -0,0 +1,88 @@ +from typing import Type + +from django.db.transaction import atomic +from django.shortcuts import get_object_or_404 +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from sponsor.models import Sponsor, SponsorLevel +from sponsor.permissions import IsOwnerOrReadOnly, OwnerOnly +from sponsor.serializers import ( + SponsorLevelSerializer, + SponsorListSerializer, + SponsorRemainingAccountSerializer, + SponsorSerializer, +) +from sponsor.validators import SponsorValidater + + +class SponsorViewSet(ModelViewSet): + serializer_class = SponsorSerializer + permission_classes = [IsOwnerOrReadOnly] # 본인 소유만 수정가능 + http_method_names = ["get", "post"] # 지금은 조회/등록만 가능 TODO: 추후 수정기능 추가 + validator = SponsorValidater() + + def get_queryset(self): + return Sponsor.objects.all() + + def list(self, request, *args, **kwargs): + queryset = Sponsor.objects.filter(accepted=True).order_by("name") + serializer = SponsorListSerializer(queryset, many=True) + return Response(serializer.data) + + @atomic + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.validator.assert_create(serializer.validated_data) + + new_sponsor = serializer.save() + + return Response(serializer.data) + + def retrieve(self, request, *args, **kwargs): + pk = kwargs["pk"] + sponsor_data = get_object_or_404(Sponsor, pk=pk) + + # 본인 소유인 경우는 모든 필드 + # 그렇지 않은 경우는 공개 가능한 필드만 응답 + serializer = ( + SponsorSerializer(sponsor_data) + if self.check_owner_permission(request, sponsor_data) + else SponsorListSerializer(sponsor_data) + ) + + return Response(serializer.data) + + def check_owner_permission(self, request, sponsor_data: Sponsor): + return OwnerOnly.has_object_permission( + self=Type[OwnerOnly], request=request, view=self, obj=sponsor_data + ) + + +class SponsorLevelViewSet(ModelViewSet): + serializer_class = SponsorLevelSerializer + http_method_names = ["get"] + + def get_queryset(self): + return SponsorLevel.objects.all() + + def list(self, request, *args, **kwargs): + queryset = SponsorLevel.objects.all().order_by("-price") + serializer = SponsorLevelSerializer(queryset, many=True) + + return Response(serializer.data) + + +class SponsorRemainingAccountViewSet(ModelViewSet): + serializer_class = SponsorLevelSerializer + http_method_names = ["get"] + + def get_queryset(self): + return SponsorLevel.objects.all() + + def list(self, request, *args, **kwargs): + queryset = SponsorLevel.objects.all().order_by("-price") + serializer = SponsorRemainingAccountSerializer(queryset, many=True) + + return Response(serializer.data) diff --git a/status/__init__.py b/status/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/status/admin.py b/status/admin.py new file mode 100644 index 0000000..608954c --- /dev/null +++ b/status/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin + +from status.models import Status + + +class StatusAdmin(admin.ModelAdmin): + list_display = ("name", "open_at", "close_at") + list_editable = ( + "open_at", + "close_at", + ) + ordering = ("open_at",) + search_fields = ("name",) + + +admin.site.register(Status, StatusAdmin) diff --git a/status/apps.py b/status/apps.py new file mode 100644 index 0000000..cb23d09 --- /dev/null +++ b/status/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class StatusConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "status" diff --git a/status/migrations/0001_initial.py b/status/migrations/0001_initial.py new file mode 100644 index 0000000..d259696 --- /dev/null +++ b/status/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 4.1.5 on 2023-02-24 17:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Status", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("open_at", models.DateTimeField()), + ("close_at", models.DateTimeField()), + ], + ), + ] diff --git a/status/migrations/__init__.py b/status/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/status/models.py b/status/models.py new file mode 100644 index 0000000..4e8c97d --- /dev/null +++ b/status/models.py @@ -0,0 +1,7 @@ +from django.db import models + + +class Status(models.Model): + name = models.CharField(max_length=100) + open_at = models.DateTimeField() + close_at = models.DateTimeField() diff --git a/status/tests.py b/status/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/status/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/status/urls.py b/status/urls.py new file mode 100644 index 0000000..9ba260f --- /dev/null +++ b/status/urls.py @@ -0,0 +1,23 @@ +"""pyconkr URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/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.contrib import admin +from django.urls import include, path + +from status.views import StatusView + +urlpatterns = [ + path("", StatusView.as_view()), +] diff --git a/status/views.py b/status/views.py new file mode 100644 index 0000000..7e8daad --- /dev/null +++ b/status/views.py @@ -0,0 +1,22 @@ +import datetime + +from pytz import timezone +from rest_framework.response import Response +from rest_framework.views import APIView + +from status.models import Status + + +class StatusView(APIView): + def get(self, request, name: str): + status = Status.objects.get(name=name) + now = datetime.datetime.now(tz=timezone("Asia/Seoul")) + + flag = None + + if status.open_at < now < status.close_at: + flag = True + else: + flag = False + + return Response({"name": name, "open": flag})